mirror of
https://github.com/ctwj/urldb.git
synced 2025-11-25 19:37:33 +08:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b0d385c52 | ||
|
|
05930a3e70 | ||
|
|
0e34cee3d8 | ||
|
|
b35971f43c | ||
|
|
285b01922d | ||
|
|
aa3b8585f9 | ||
|
|
25c7c47c96 | ||
|
|
b567531a7d | ||
|
|
1b0fc06bf7 | ||
|
|
f5b5455989 | ||
|
|
14f22f9128 | ||
|
|
76eb9c689b | ||
|
|
7032235923 | ||
|
|
f870779146 | ||
|
|
81eb99691d | ||
|
|
32e7240287 | ||
|
|
a041a6f01d | ||
|
|
eeb9c295f5 | ||
|
|
df86034ae5 | ||
|
|
be66667890 | ||
|
|
667338368a | ||
|
|
5cfd0ad3ee | ||
|
|
1cc70e439e | ||
|
|
0e99233417 | ||
|
|
000f92ffd1 | ||
|
|
4c3c9bd553 | ||
|
|
22db03dcea | ||
|
|
26c25520fa | ||
|
|
c2a8cdef4f | ||
|
|
7e8f42212a | ||
|
|
5af4c235d5 | ||
|
|
1d9451f071 | ||
|
|
4825b45511 | ||
|
|
5bd21e156d | ||
|
|
689d1e61a0 | ||
|
|
c8fd405d74 | ||
|
|
5f8d998c65 | ||
|
|
b5b3c55573 | ||
|
|
1d3ed2f8aa | ||
|
|
215f3170cd | ||
|
|
0700de36f5 | ||
|
|
14130eac8b | ||
|
|
bad6da4488 | ||
|
|
1126f84a3a | ||
|
|
24d644dc8b | ||
|
|
d0ac53320e | ||
|
|
853bb50854 | ||
|
|
dfb6a1707c | ||
|
|
9098b28ba6 | ||
|
|
b5e5052146 | ||
|
|
e88b8411b5 | ||
|
|
d1b406b1ee | ||
|
|
10432c1db6 | ||
|
|
440049c974 | ||
|
|
afb5a38f15 | ||
|
|
1ea7e87e6f | ||
|
|
e6b4455428 | ||
|
|
6aacf9aed8 | ||
|
|
1f6fdfba1a | ||
|
|
4d466af99e |
11
CHANGELOG.md
11
CHANGELOG.md
@@ -2,7 +2,16 @@
|
||||
|
||||
本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/) 规范。
|
||||
|
||||
## [未发布]
|
||||
## [v1.1.0]
|
||||
|
||||
1. 新增违禁词功能
|
||||
2. 管理后台体验优化
|
||||
3. bug修复
|
||||
|
||||
## [v1.0.0]
|
||||
|
||||
1. 自动转存
|
||||
2. 自动资源处理
|
||||
|
||||
### 新增
|
||||
- 项目开源准备
|
||||
|
||||
173
README.md
173
README.md
@@ -10,7 +10,7 @@
|
||||
|
||||
**一个现代化的网盘资源数据库,支持多网盘自动化转存分享,支持百度网盘,阿里云盘,夸克网盘, 天翼云盘,迅雷云盘,123云盘,115网盘,UC网盘 **
|
||||
|
||||
🌐 [在线演示](https://pan.l9.lc) | 📖 [文档](http://doc.l9.lc/) | 🐛 [问题反馈](https://github.com/ctwj/urldb/issues) | ⭐ [给个星标](https://github.com/ctwj/urldb)
|
||||
🌐 [在线演示](https://pan.l9.lc) | 📖 [文档](https://ecn5khs4t956.feishu.cn/wiki/PsnDwtxghiP0mLkTiruczKtxnwd?from=from_copylink) | 🐛 [问题反馈](https://github.com/ctwj/urldb/issues) | ⭐ [给个星标](https://github.com/ctwj/urldb)
|
||||
|
||||
### 支持的网盘平台
|
||||
|
||||
@@ -29,15 +29,52 @@
|
||||
|
||||
---
|
||||
|
||||
## 🔔 温馨提示
|
||||
## 🔔 版本改动
|
||||
|
||||
📌 **本项目仅供技术交流与学习使用**,自身不存储或提供任何资源文件及下载链接。
|
||||
- [文档说明](https://ecn5khs4t956.feishu.cn/wiki/PsnDwtxghiP0mLkTiruczKtxnwd?from=from_copylink)
|
||||
- [服务器要求](https://ecn5khs4t956.feishu.cn/wiki/W8YBww1Mmiu4Cdkp5W4c8pFNnMf?from=from_copylink)
|
||||
- [QQ机器人](https://github.com/ctwj/astrbot_plugin_urldb)
|
||||
|
||||
📌 **请勿将本项目用于任何违法用途**,否则后果自负。
|
||||
### v1.2.1
|
||||
1. 修复转存移除广告失败的问题和添加广告失败的问题
|
||||
2. 管理后台UI优化
|
||||
|
||||
📌 如有任何问题或建议,欢迎交流探讨! 😊
|
||||
### v1.2.0
|
||||
1. 新增手动批量转存
|
||||
2. 新增QQ机器人
|
||||
3. 新增任务管理功能
|
||||
4. 自动转存改版(批量转存修改为,显示二维码时自动转存)
|
||||
5. 新增支持第三方统计代码配置
|
||||
|
||||
> **免责声明**:本项目由 Trae AI 辅助编写。由于时间有限,仅在空闲时维护。如遇使用问题,请优先自行排查,感谢理解!
|
||||
### v1.0.0
|
||||
1. 支持API,手动批量录入资源
|
||||
2. 支持,自动判断资源有效性
|
||||
3. 支持自动转存
|
||||
4. 支持平台多账号管理(Quark)
|
||||
5. 支持简单的数据统计
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 📸 项目截图
|
||||
|
||||
|
||||
|
||||
### 🏠 首页
|
||||

|
||||
|
||||
### 🔧 后台管理
|
||||

|
||||
|
||||
### ⚙️ 系统配置
|
||||

|
||||
|
||||
### 🔍 批量转存
|
||||

|
||||
|
||||
### 👤 多账号管理
|
||||

|
||||
|
||||
---
|
||||
|
||||
@@ -68,111 +105,6 @@
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- **Docker** 和 **Docker Compose**
|
||||
- 或者本地环境:
|
||||
- **Go** 1.23+
|
||||
- **Node.js** 18+
|
||||
- **PostgreSQL** 15+
|
||||
- **pnpm** (推荐) 或 npm
|
||||
|
||||
### 方式一:Docker 部署(推荐)
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/ctwj/urldb.git
|
||||
cd urldb
|
||||
|
||||
# 使用 Docker Compose 启动
|
||||
docker compose up --build -d
|
||||
|
||||
# 访问应用
|
||||
# 前端: http://localhost:3030
|
||||
# 后端API: http://localhost:8080
|
||||
```
|
||||
|
||||
### 方式二:本地开发
|
||||
|
||||
#### 1. 克隆项目
|
||||
```bash
|
||||
git clone https://github.com/ctwj/urldb.git
|
||||
cd urldb
|
||||
```
|
||||
|
||||
#### 2. 后端设置
|
||||
```bash
|
||||
# 复制环境变量文件
|
||||
cp env.example .env
|
||||
|
||||
# 编辑环境变量
|
||||
vim .env
|
||||
|
||||
# 安装Go依赖
|
||||
go mod tidy
|
||||
|
||||
# 启动后端服务
|
||||
go run main.go
|
||||
```
|
||||
|
||||
#### 3. 前端设置
|
||||
```bash
|
||||
# 进入前端目录
|
||||
cd web
|
||||
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 启动开发服务器
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
#### 4. 数据库设置
|
||||
```sql
|
||||
-- 创建数据库
|
||||
CREATE DATABASE url_db;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
l9pan/
|
||||
├── 📁 common/ # 通用功能模块
|
||||
│ ├── 📄 pan_factory.go # 网盘工厂模式
|
||||
│ ├── 📄 alipan.go # 阿里云盘实现
|
||||
│ ├── 📄 baidu_pan.go # 百度网盘实现
|
||||
│ ├── 📄 quark_pan.go # 夸克网盘实现
|
||||
│ └── 📄 uc_pan.go # UC网盘实现
|
||||
├── 📁 db/ # 数据库层
|
||||
│ ├── 📁 entity/ # 数据实体
|
||||
│ ├── 📁 repo/ # 数据仓库
|
||||
│ ├── 📁 dto/ # 数据传输对象
|
||||
│ └── 📁 converter/ # 数据转换器
|
||||
├── 📁 handlers/ # API处理器
|
||||
├── 📁 middleware/ # 中间件
|
||||
├── 📁 utils/ # 工具函数
|
||||
├── 📁 web/ # 前端项目
|
||||
│ ├── 📁 pages/ # 页面组件
|
||||
│ ├── 📁 components/ # 通用组件
|
||||
│ ├── 📁 composables/ # 组合式函数
|
||||
│ └── 📁 stores/ # 状态管理
|
||||
├── 📁 docs/ # 项目文档
|
||||
├── 📁 nginx/ # Nginx配置
|
||||
│ ├── 📄 nginx.conf # 主配置文件
|
||||
│ └── 📁 conf.d/ # 站点配置
|
||||
├── 📄 main.go # 主程序入口
|
||||
├── 📄 Dockerfile # Docker配置
|
||||
├── 📄 docker-compose.yml # Docker Compose配置
|
||||
├── 📄 docker-start-nginx.sh # Nginx启动脚本
|
||||
└── 📄 README.md # 项目说明
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 配置说明
|
||||
|
||||
### 环境变量配置
|
||||
@@ -192,13 +124,6 @@ PORT=8080
|
||||
TIMEZONE=Asia/Shanghai
|
||||
```
|
||||
|
||||
### Docker 服务说明
|
||||
|
||||
| 服务 | 端口 | 说明 |
|
||||
|------|------|------|
|
||||
| server | 3030 | 应用 |
|
||||
| postgres | 5431 | PostgreSQL 数据库 |
|
||||
|
||||
### 镜像构建
|
||||
|
||||
```
|
||||
@@ -210,18 +135,6 @@ docker push ctwj/urldb-backend:1.0.7
|
||||
|
||||
---
|
||||
|
||||
## 📚 API 文档
|
||||
|
||||
### 公开统计
|
||||
|
||||
提供,批量入库和搜索api,通过 apiToken 授权
|
||||
|
||||
> 📖 完整API文档请访问:`http://doc.l9.lc/`
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
我们欢迎所有形式的贡献!
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 [GPL License](LICENSE) 许可证。
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/utils"
|
||||
)
|
||||
|
||||
// AlipanService 阿里云盘服务
|
||||
@@ -428,7 +430,7 @@ func (a *AlipanService) manageAccessToken() (string, error) {
|
||||
}
|
||||
|
||||
// 检查token是否过期
|
||||
if time.Now().After(tokenInfo.ExpiresAt) {
|
||||
if utils.GetCurrentTime().After(tokenInfo.ExpiresAt) {
|
||||
return a.getNewAccessToken()
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ const (
|
||||
UC
|
||||
NotFound
|
||||
Xunlei
|
||||
Tianyi
|
||||
Pan123
|
||||
Pan115
|
||||
)
|
||||
|
||||
// String 返回服务类型的字符串表示
|
||||
@@ -32,6 +35,12 @@ func (s ServiceType) String() string {
|
||||
return "uc"
|
||||
case Xunlei:
|
||||
return "xunlei"
|
||||
case Tianyi:
|
||||
return "tianyi"
|
||||
case Pan123:
|
||||
return "123pan"
|
||||
case Pan115:
|
||||
return "115"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
@@ -136,6 +145,10 @@ func (f *PanFactory) CreatePanServiceByType(serviceType ServiceType, config *Pan
|
||||
return NewBaiduPanService(config), nil
|
||||
case UC:
|
||||
return NewUCService(config), nil
|
||||
// case Xunlei:
|
||||
// return NewXunleiService(config), nil
|
||||
// case Tianyi:
|
||||
// return NewTianyiService(config), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的服务类型: %d", serviceType)
|
||||
}
|
||||
@@ -169,6 +182,11 @@ func (f *PanFactory) GetUCService(config *PanConfig) PanService {
|
||||
func ExtractServiceType(url string) ServiceType {
|
||||
url = strings.ToLower(url)
|
||||
|
||||
// "https://www.123pan.com/s/i4uaTd-WHn0", // 公开分享
|
||||
// "https://www.123912.com/s/U8f2Td-ZeOX",
|
||||
// "https://www.123684.coms/u9izjv-k3uWv",
|
||||
// "https://www.123pan.com/s/A6cA-AKH11", // 外链不存在
|
||||
|
||||
patterns := map[string]ServiceType{
|
||||
"pan.quark.cn": Quark,
|
||||
"www.alipan.com": Alipan,
|
||||
@@ -177,6 +195,13 @@ func ExtractServiceType(url string) ServiceType {
|
||||
"drive.uc.cn": UC,
|
||||
"fast.uc.cn": UC,
|
||||
"pan.xunlei.com": Xunlei,
|
||||
"cloud.189.cn": Tianyi,
|
||||
"www.123pan.com": Pan123,
|
||||
"www.123912.com": Pan123,
|
||||
"www.123684.com": Pan123,
|
||||
"115cdn.com": Pan115,
|
||||
"anxia.com": Pan115,
|
||||
"115.com/": Pan115,
|
||||
}
|
||||
|
||||
for pattern, serviceType := range patterns {
|
||||
@@ -196,12 +221,24 @@ func ExtractShareId(url string) (string, ServiceType) {
|
||||
}
|
||||
|
||||
// 提取分享ID
|
||||
substring := strings.Index(url, "/s/")
|
||||
shareID := ""
|
||||
substring := -1
|
||||
|
||||
if index := strings.Index(url, "/s/"); index != -1 {
|
||||
substring = index + 3
|
||||
} else if index := strings.Index(url, "/t/"); index != -1 {
|
||||
substring = index + 3
|
||||
} else if index := strings.Index(url, "/web/share?code="); index != -1 {
|
||||
substring = index + 16
|
||||
} else if index := strings.Index(url, "/p/"); index != -1 {
|
||||
substring = index + 3
|
||||
}
|
||||
|
||||
if substring == -1 {
|
||||
return "", NotFound
|
||||
}
|
||||
|
||||
shareID := url[substring+3:] // 去除 '/s/' 部分
|
||||
shareID = url[substring:]
|
||||
|
||||
// 去除可能的锚点
|
||||
if hashIndex := strings.Index(shareID, "#"); hashIndex != -1 {
|
||||
|
||||
@@ -4,10 +4,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/utils"
|
||||
)
|
||||
|
||||
// QuarkPanService 夸克网盘服务
|
||||
@@ -187,6 +190,11 @@ func (q *QuarkPanService) Transfer(shareID string) (*TransferResult, error) {
|
||||
log.Printf("删除广告文件失败: %v", err)
|
||||
}
|
||||
|
||||
// 添加个人自定义广告
|
||||
if err := q.addAd(myData.SaveAs.SaveAsTopFids[0]); err != nil {
|
||||
log.Printf("添加广告文件失败: %v", err)
|
||||
}
|
||||
|
||||
// 分享资源
|
||||
shareBtnResult, err := q.getShareBtn(myData.SaveAs.SaveAsTopFids, title)
|
||||
if err != nil {
|
||||
@@ -406,7 +414,7 @@ func (q *QuarkPanService) getShareSave(shareID, stoken string, fidList, fidToken
|
||||
|
||||
// 生成指定长度的时间戳
|
||||
func (q *QuarkPanService) generateTimestamp(length int) int64 {
|
||||
timestamp := time.Now().UnixNano() / int64(time.Millisecond)
|
||||
timestamp := utils.GetCurrentTime().UnixNano() / int64(time.Millisecond)
|
||||
timestampStr := strconv.FormatInt(timestamp, 10)
|
||||
if len(timestampStr) > length {
|
||||
timestampStr = timestampStr[:length]
|
||||
@@ -546,8 +554,139 @@ func (q *QuarkPanService) waitForTask(taskID string) (*TaskResult, error) {
|
||||
|
||||
// deleteAdFiles 删除广告文件
|
||||
func (q *QuarkPanService) deleteAdFiles(pdirFid string) error {
|
||||
// 这里可以添加广告文件删除逻辑
|
||||
// 需要从配置中读取禁止的关键词列表
|
||||
log.Printf("开始删除广告文件,目录ID: %s", pdirFid)
|
||||
|
||||
// 获取目录文件列表
|
||||
fileList, err := q.getDirFile(pdirFid)
|
||||
if err != nil {
|
||||
log.Printf("获取目录文件失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if fileList == nil || len(fileList) == 0 {
|
||||
log.Printf("目录为空,无需删除广告文件")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 删除包含广告关键词的文件
|
||||
for _, file := range fileList {
|
||||
if fileName, ok := file["file_name"].(string); ok {
|
||||
log.Printf("检查文件: %s", fileName)
|
||||
if q.containsAdKeywords(fileName) {
|
||||
if fid, ok := file["fid"].(string); ok {
|
||||
log.Printf("删除广告文件: %s (FID: %s)", fileName, fid)
|
||||
_, err := q.DeleteFiles([]string{fid})
|
||||
if err != nil {
|
||||
log.Printf("删除广告文件失败: %v", err)
|
||||
} else {
|
||||
log.Printf("成功删除广告文件: %s", fileName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// containsAdKeywords 检查文件名是否包含广告关键词
|
||||
func (q *QuarkPanService) containsAdKeywords(filename string) bool {
|
||||
// 默认广告关键词列表
|
||||
defaultAdKeywords := []string{
|
||||
"微信", "独家", "V信", "v信", "威信", "胖狗资源",
|
||||
"加微", "会员群", "q群", "v群", "公众号",
|
||||
"广告", "特价", "最后机会", "不要错过", "立减",
|
||||
"立得", "赚", "省", "回扣", "抽奖",
|
||||
"失效", "年会员", "空间容量", "微信群", "群文件", "全网资源", "影视资源", "扫码", "最新资源",
|
||||
"IMG_", "资源汇总", "緑铯粢源", ".url", "网盘推广", "大额优惠券",
|
||||
"资源文档", "dy8.xyz", "妙妙屋", "资源合集", "kkdm", "赚收益",
|
||||
}
|
||||
|
||||
// 尝试从系统配置中获取广告关键词
|
||||
adKeywords := defaultAdKeywords
|
||||
|
||||
// 这里可以添加从系统配置读取广告关键词的逻辑
|
||||
// 例如:从数据库或配置文件中读取自定义的广告关键词
|
||||
|
||||
return q.checkKeywordsInFilename(filename, adKeywords)
|
||||
}
|
||||
|
||||
// checkKeywordsInFilename 检查文件名是否包含指定关键词
|
||||
func (q *QuarkPanService) checkKeywordsInFilename(filename string, keywords []string) bool {
|
||||
// 转为小写进行比较
|
||||
lowercaseFilename := strings.ToLower(filename)
|
||||
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(lowercaseFilename, strings.ToLower(keyword)) {
|
||||
log.Printf("文件 %s 包含广告关键词: %s", filename, keyword)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// addAd 添加个人自定义广告
|
||||
func (q *QuarkPanService) addAd(dirID string) error {
|
||||
log.Printf("开始添加个人自定义广告到目录: %s", dirID)
|
||||
|
||||
// 这里可以从配置中读取广告文件ID列表
|
||||
// 暂时使用硬编码的广告文件ID,后续可以从系统配置中读取
|
||||
adFileIDs := []string{
|
||||
// 可以配置多个广告文件ID
|
||||
// "4c0381f2d1ca", // 示例广告文件ID
|
||||
}
|
||||
|
||||
if len(adFileIDs) == 0 {
|
||||
log.Printf("没有配置广告文件,跳过广告插入")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 随机选择一个广告文件
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
selectedAdID := adFileIDs[rand.Intn(len(adFileIDs))]
|
||||
|
||||
log.Printf("选择广告文件ID: %s", selectedAdID)
|
||||
|
||||
// 获取广告文件的stoken
|
||||
stokenResult, err := q.getStoken(selectedAdID)
|
||||
if err != nil {
|
||||
log.Printf("获取广告文件stoken失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 获取广告文件详情
|
||||
adDetail, err := q.getShare(selectedAdID, stokenResult.Stoken)
|
||||
if err != nil {
|
||||
log.Printf("获取广告文件详情失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if len(adDetail.List) == 0 {
|
||||
log.Printf("广告文件详情为空")
|
||||
return fmt.Errorf("广告文件详情为空")
|
||||
}
|
||||
|
||||
// 获取第一个广告文件的信息
|
||||
adFile := adDetail.List[0]
|
||||
fid := adFile.Fid
|
||||
shareFidToken := adFile.ShareFidToken
|
||||
|
||||
// 保存广告文件到目标目录
|
||||
saveResult, err := q.getShareSave(selectedAdID, stokenResult.Stoken, []string{fid}, []string{shareFidToken})
|
||||
if err != nil {
|
||||
log.Printf("保存广告文件失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 等待保存完成
|
||||
_, err = q.waitForTask(saveResult.TaskID)
|
||||
if err != nil {
|
||||
log.Printf("等待广告文件保存完成失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("广告文件添加成功")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
130
db/connection.go
130
db/connection.go
@@ -63,26 +63,37 @@ func InitDB() error {
|
||||
sqlDB.SetMaxOpenConns(100) // 最大打开连接数
|
||||
sqlDB.SetConnMaxLifetime(time.Hour) // 连接最大生命周期
|
||||
|
||||
// 自动迁移数据库表结构
|
||||
err = DB.AutoMigrate(
|
||||
&entity.User{},
|
||||
&entity.Category{},
|
||||
&entity.Pan{},
|
||||
&entity.Cks{},
|
||||
&entity.Tag{},
|
||||
&entity.Resource{},
|
||||
&entity.ResourceTag{},
|
||||
&entity.ReadyResource{},
|
||||
&entity.SearchStat{},
|
||||
&entity.SystemConfig{},
|
||||
&entity.HotDrama{},
|
||||
)
|
||||
if err != nil {
|
||||
utils.Fatal("数据库迁移失败: %v", err)
|
||||
// 检查是否需要迁移(只在开发环境或首次启动时)
|
||||
if shouldRunMigration() {
|
||||
utils.Info("开始数据库迁移...")
|
||||
err = DB.AutoMigrate(
|
||||
&entity.User{},
|
||||
&entity.Category{},
|
||||
&entity.Pan{},
|
||||
&entity.Cks{},
|
||||
&entity.Tag{},
|
||||
&entity.Resource{},
|
||||
&entity.ResourceTag{},
|
||||
&entity.ReadyResource{},
|
||||
&entity.SearchStat{},
|
||||
&entity.SystemConfig{},
|
||||
&entity.HotDrama{},
|
||||
&entity.ResourceView{},
|
||||
&entity.Task{},
|
||||
&entity.TaskItem{},
|
||||
)
|
||||
if err != nil {
|
||||
utils.Fatal("数据库迁移失败: %v", err)
|
||||
}
|
||||
utils.Info("数据库迁移完成")
|
||||
} else {
|
||||
utils.Info("跳过数据库迁移(表结构已是最新)")
|
||||
}
|
||||
|
||||
// 创建索引以提高查询性能
|
||||
createIndexes(DB)
|
||||
// 创建索引以提高查询性能(只在需要迁移时)
|
||||
if shouldRunMigration() {
|
||||
createIndexes(DB)
|
||||
}
|
||||
|
||||
// 插入默认数据(只在数据库为空时)
|
||||
if err := insertDefaultDataIfEmpty(); err != nil {
|
||||
@@ -93,9 +104,36 @@ func InitDB() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldRunMigration 检查是否需要运行数据库迁移
|
||||
func shouldRunMigration() bool {
|
||||
// 通过环境变量控制是否运行迁移
|
||||
skipMigration := os.Getenv("SKIP_MIGRATION")
|
||||
if skipMigration == "true" {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查环境变量
|
||||
env := os.Getenv("ENV")
|
||||
if env == "production" {
|
||||
// 生产环境:检查是否有迁移标记
|
||||
var count int64
|
||||
DB.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'schema_migrations'").Count(&count)
|
||||
if count == 0 {
|
||||
// 没有迁移表,说明是首次部署
|
||||
return true
|
||||
}
|
||||
// 有迁移表,检查是否需要迁移(这里可以添加更复杂的逻辑)
|
||||
return false
|
||||
}
|
||||
|
||||
// 开发环境:总是运行迁移
|
||||
return true
|
||||
}
|
||||
|
||||
// autoMigrate 自动迁移表结构
|
||||
func autoMigrate() error {
|
||||
return DB.AutoMigrate(
|
||||
&entity.SystemConfig{}, // 系统配置表(独立表,先创建)
|
||||
&entity.Pan{},
|
||||
&entity.Cks{},
|
||||
&entity.Category{},
|
||||
@@ -105,16 +143,13 @@ func autoMigrate() error {
|
||||
&entity.ReadyResource{},
|
||||
&entity.User{},
|
||||
&entity.SearchStat{},
|
||||
&entity.SystemConfig{},
|
||||
&entity.HotDrama{},
|
||||
)
|
||||
}
|
||||
|
||||
// createIndexes 创建数据库索引以提高查询性能
|
||||
func createIndexes(db *gorm.DB) {
|
||||
// 资源表索引
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_resources_title ON resources USING gin(to_tsvector('chinese', title))")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_resources_description ON resources USING gin(to_tsvector('chinese', description))")
|
||||
// 资源表索引(移除全文搜索索引,使用Meilisearch替代)
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_resources_category_id ON resources(category_id)")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_resources_pan_id ON resources(pan_id)")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_resources_created_at ON resources(created_at DESC)")
|
||||
@@ -122,13 +157,17 @@ func createIndexes(db *gorm.DB) {
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_resources_is_valid ON resources(is_valid)")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_resources_is_public ON resources(is_public)")
|
||||
|
||||
// 为Meilisearch准备的基础文本索引(用于精确匹配)
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_resources_title ON resources(title)")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_resources_description ON resources(description)")
|
||||
|
||||
// 待处理资源表索引
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_ready_resource_key ON ready_resource(key)")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_ready_resource_url ON ready_resource(url)")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_ready_resource_create_time ON ready_resource(create_time DESC)")
|
||||
|
||||
// 搜索统计表索引
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_search_stats_query ON search_stats(query)")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_search_stats_keyword ON search_stats(keyword)")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_search_stats_created_at ON search_stats(created_at DESC)")
|
||||
|
||||
// 热播剧表索引
|
||||
@@ -140,7 +179,7 @@ func createIndexes(db *gorm.DB) {
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_resource_tags_resource_id ON resource_tags(resource_id)")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_resource_tags_tag_id ON resource_tags(tag_id)")
|
||||
|
||||
utils.Info("数据库索引创建完成")
|
||||
utils.Info("数据库索引创建完成(已移除全文搜索索引,准备使用Meilisearch)")
|
||||
}
|
||||
|
||||
// insertDefaultDataIfEmpty 只在数据库为空时插入默认数据
|
||||
@@ -161,11 +200,18 @@ func insertDefaultDataIfEmpty() error {
|
||||
|
||||
// 插入默认分类(使用FirstOrCreate避免重复)
|
||||
defaultCategories := []entity.Category{
|
||||
{Name: "文档", Description: "各种文档资料"},
|
||||
{Name: "软件", Description: "软件工具"},
|
||||
{Name: "视频", Description: "视频教程"},
|
||||
{Name: "图片", Description: "图片资源"},
|
||||
{Name: "音频", Description: "音频文件"},
|
||||
{Name: "电影", Description: "电影"},
|
||||
{Name: "电视剧", Description: "电视剧"},
|
||||
{Name: "短剧", Description: "短剧"},
|
||||
{Name: "综艺", Description: "综艺"},
|
||||
{Name: "动漫", Description: "动漫"},
|
||||
{Name: "纪录片", Description: "纪录片"},
|
||||
{Name: "视频教程", Description: "视频教程"},
|
||||
{Name: "学习资料", Description: "学习资料"},
|
||||
{Name: "游戏", Description: "其他游戏资源"},
|
||||
{Name: "软件", Description: "软件"},
|
||||
{Name: "APP", Description: "APP"},
|
||||
{Name: "AI", Description: "AI"},
|
||||
{Name: "其他", Description: "其他资源"},
|
||||
}
|
||||
|
||||
@@ -196,6 +242,32 @@ func insertDefaultDataIfEmpty() error {
|
||||
}
|
||||
}
|
||||
|
||||
// 插入默认系统配置
|
||||
defaultSystemConfigs := []entity.SystemConfig{
|
||||
{Key: entity.ConfigKeySiteTitle, Value: entity.ConfigDefaultSiteTitle, Type: entity.ConfigTypeString},
|
||||
{Key: entity.ConfigKeySiteDescription, Value: entity.ConfigDefaultSiteDescription, Type: entity.ConfigTypeString},
|
||||
{Key: entity.ConfigKeyKeywords, Value: entity.ConfigDefaultKeywords, Type: entity.ConfigTypeString},
|
||||
{Key: entity.ConfigKeyAuthor, Value: entity.ConfigDefaultAuthor, Type: entity.ConfigTypeString},
|
||||
{Key: entity.ConfigKeyCopyright, Value: entity.ConfigDefaultCopyright, Type: entity.ConfigTypeString},
|
||||
{Key: entity.ConfigKeyAutoProcessReadyResources, Value: entity.ConfigDefaultAutoProcessReadyResources, Type: entity.ConfigTypeBool},
|
||||
{Key: entity.ConfigKeyAutoProcessInterval, Value: entity.ConfigDefaultAutoProcessInterval, Type: entity.ConfigTypeInt},
|
||||
{Key: entity.ConfigKeyAutoTransferEnabled, Value: entity.ConfigDefaultAutoTransferEnabled, Type: entity.ConfigTypeBool},
|
||||
{Key: entity.ConfigKeyAutoTransferLimitDays, Value: entity.ConfigDefaultAutoTransferLimitDays, Type: entity.ConfigTypeInt},
|
||||
{Key: entity.ConfigKeyAutoTransferMinSpace, Value: entity.ConfigDefaultAutoTransferMinSpace, Type: entity.ConfigTypeInt},
|
||||
{Key: entity.ConfigKeyAutoFetchHotDramaEnabled, Value: entity.ConfigDefaultAutoFetchHotDramaEnabled, Type: entity.ConfigTypeBool},
|
||||
{Key: entity.ConfigKeyApiToken, Value: entity.ConfigDefaultApiToken, Type: entity.ConfigTypeString},
|
||||
{Key: entity.ConfigKeyForbiddenWords, Value: entity.ConfigDefaultForbiddenWords, Type: entity.ConfigTypeString},
|
||||
{Key: entity.ConfigKeyPageSize, Value: entity.ConfigDefaultPageSize, Type: entity.ConfigTypeInt},
|
||||
{Key: entity.ConfigKeyMaintenanceMode, Value: entity.ConfigDefaultMaintenanceMode, Type: entity.ConfigTypeBool},
|
||||
}
|
||||
|
||||
for _, config := range defaultSystemConfigs {
|
||||
if err := DB.Where("key = ?", config.Key).FirstOrCreate(&config).Error; err != nil {
|
||||
utils.Error("插入系统配置 %s 失败: %v", config.Key, err)
|
||||
// 继续执行,不因为单个配置失败而停止
|
||||
}
|
||||
}
|
||||
|
||||
// 插入默认管理员用户
|
||||
defaultAdmin := entity.User{
|
||||
Username: "admin",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
)
|
||||
@@ -169,6 +171,12 @@ func ToCksResponseList(cksList []entity.Cks) []dto.CksResponse {
|
||||
|
||||
// ToReadyResourceResponse 将ReadyResource实体转换为ReadyResourceResponse
|
||||
func ToReadyResourceResponse(resource *entity.ReadyResource) dto.ReadyResourceResponse {
|
||||
isDeleted := !resource.DeletedAt.Time.IsZero()
|
||||
var deletedAt *time.Time
|
||||
if isDeleted {
|
||||
deletedAt = &resource.DeletedAt.Time
|
||||
}
|
||||
|
||||
return dto.ReadyResourceResponse{
|
||||
ID: resource.ID,
|
||||
Title: resource.Title,
|
||||
@@ -183,6 +191,8 @@ func ToReadyResourceResponse(resource *entity.ReadyResource) dto.ReadyResourceRe
|
||||
ErrorMsg: resource.ErrorMsg,
|
||||
CreateTime: resource.CreateTime,
|
||||
IP: resource.IP,
|
||||
DeletedAt: deletedAt,
|
||||
IsDeleted: isDeleted,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
)
|
||||
|
||||
// SystemConfigToResponse 将系统配置实体列表转换为响应DTO
|
||||
@@ -55,6 +56,8 @@ func SystemConfigToResponse(configs []entity.SystemConfig) *dto.SystemConfigResp
|
||||
}
|
||||
case entity.ConfigKeyApiToken:
|
||||
response.ApiToken = config.Value
|
||||
case entity.ConfigKeyForbiddenWords:
|
||||
response.ForbiddenWords = config.Value
|
||||
case entity.ConfigKeyPageSize:
|
||||
if val, err := strconv.Atoi(config.Value); err == nil {
|
||||
response.PageSize = val
|
||||
@@ -63,6 +66,12 @@ func SystemConfigToResponse(configs []entity.SystemConfig) *dto.SystemConfigResp
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response.MaintenanceMode = val
|
||||
}
|
||||
case entity.ConfigKeyEnableRegister:
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response.EnableRegister = val
|
||||
}
|
||||
case entity.ConfigKeyThirdPartyStatsCode:
|
||||
response.ThirdPartyStatsCode = config.Value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,21 +90,49 @@ func RequestToSystemConfig(req *dto.SystemConfigRequest) []entity.SystemConfig {
|
||||
return nil
|
||||
}
|
||||
|
||||
configs := []entity.SystemConfig{
|
||||
{Key: entity.ConfigKeySiteTitle, Value: req.SiteTitle, Type: entity.ConfigTypeString},
|
||||
{Key: entity.ConfigKeySiteDescription, Value: req.SiteDescription, Type: entity.ConfigTypeString},
|
||||
{Key: entity.ConfigKeyKeywords, Value: req.Keywords, Type: entity.ConfigTypeString},
|
||||
{Key: entity.ConfigKeyAuthor, Value: req.Author, Type: entity.ConfigTypeString},
|
||||
{Key: entity.ConfigKeyCopyright, Value: req.Copyright, Type: entity.ConfigTypeString},
|
||||
{Key: entity.ConfigKeyAutoProcessReadyResources, Value: strconv.FormatBool(req.AutoProcessReadyResources), Type: entity.ConfigTypeBool},
|
||||
{Key: entity.ConfigKeyAutoProcessInterval, Value: strconv.Itoa(req.AutoProcessInterval), Type: entity.ConfigTypeInt},
|
||||
{Key: entity.ConfigKeyAutoTransferEnabled, Value: strconv.FormatBool(req.AutoTransferEnabled), Type: entity.ConfigTypeBool},
|
||||
{Key: entity.ConfigKeyAutoTransferLimitDays, Value: strconv.Itoa(req.AutoTransferLimitDays), Type: entity.ConfigTypeInt},
|
||||
{Key: entity.ConfigKeyAutoTransferMinSpace, Value: strconv.Itoa(req.AutoTransferMinSpace), Type: entity.ConfigTypeInt},
|
||||
{Key: entity.ConfigKeyAutoFetchHotDramaEnabled, Value: strconv.FormatBool(req.AutoFetchHotDramaEnabled), Type: entity.ConfigTypeBool},
|
||||
{Key: entity.ConfigKeyApiToken, Value: req.ApiToken, Type: entity.ConfigTypeString},
|
||||
{Key: entity.ConfigKeyPageSize, Value: strconv.Itoa(req.PageSize), Type: entity.ConfigTypeInt},
|
||||
{Key: entity.ConfigKeyMaintenanceMode, Value: strconv.FormatBool(req.MaintenanceMode), Type: entity.ConfigTypeBool},
|
||||
var configs []entity.SystemConfig
|
||||
|
||||
// 只添加有值的字段
|
||||
if req.SiteTitle != "" {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeySiteTitle, Value: req.SiteTitle, Type: entity.ConfigTypeString})
|
||||
}
|
||||
if req.SiteDescription != "" {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeySiteDescription, Value: req.SiteDescription, Type: entity.ConfigTypeString})
|
||||
}
|
||||
if req.Keywords != "" {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyKeywords, Value: req.Keywords, Type: entity.ConfigTypeString})
|
||||
}
|
||||
if req.Author != "" {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAuthor, Value: req.Author, Type: entity.ConfigTypeString})
|
||||
}
|
||||
if req.Copyright != "" {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyCopyright, Value: req.Copyright, Type: entity.ConfigTypeString})
|
||||
}
|
||||
if req.ApiToken != "" {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyApiToken, Value: req.ApiToken, Type: entity.ConfigTypeString})
|
||||
}
|
||||
if req.ForbiddenWords != "" {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyForbiddenWords, Value: req.ForbiddenWords, Type: entity.ConfigTypeString})
|
||||
}
|
||||
|
||||
// 布尔值字段 - 只处理实际提交的字段
|
||||
// 注意:由于 Go 的零值机制,我们需要通过其他方式判断字段是否被提交
|
||||
// 这里暂时保持原样,但建议前端只提交有变化的字段
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAutoProcessReadyResources, Value: strconv.FormatBool(req.AutoProcessReadyResources), Type: entity.ConfigTypeBool})
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAutoTransferEnabled, Value: strconv.FormatBool(req.AutoTransferEnabled), Type: entity.ConfigTypeBool})
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAutoFetchHotDramaEnabled, Value: strconv.FormatBool(req.AutoFetchHotDramaEnabled), Type: entity.ConfigTypeBool})
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyMaintenanceMode, Value: strconv.FormatBool(req.MaintenanceMode), Type: entity.ConfigTypeBool})
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyEnableRegister, Value: strconv.FormatBool(req.EnableRegister), Type: entity.ConfigTypeBool})
|
||||
|
||||
// 整数字段 - 添加所有提交的字段,包括0值
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAutoProcessInterval, Value: strconv.Itoa(req.AutoProcessInterval), Type: entity.ConfigTypeInt})
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAutoTransferLimitDays, Value: strconv.Itoa(req.AutoTransferLimitDays), Type: entity.ConfigTypeInt})
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAutoTransferMinSpace, Value: strconv.Itoa(req.AutoTransferMinSpace), Type: entity.ConfigTypeInt})
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyPageSize, Value: strconv.Itoa(req.PageSize), Type: entity.ConfigTypeInt})
|
||||
|
||||
// 三方统计配置
|
||||
if req.ThirdPartyStatsCode != "" {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyThirdPartyStatsCode, Value: req.ThirdPartyStatsCode, Type: entity.ConfigTypeString})
|
||||
}
|
||||
|
||||
return configs
|
||||
@@ -105,8 +142,8 @@ func RequestToSystemConfig(req *dto.SystemConfigRequest) []entity.SystemConfig {
|
||||
func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]interface{} {
|
||||
response := map[string]interface{}{
|
||||
entity.ConfigResponseFieldID: 0,
|
||||
entity.ConfigResponseFieldCreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
entity.ConfigResponseFieldUpdatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
entity.ConfigResponseFieldCreatedAt: utils.GetCurrentTimeString(),
|
||||
entity.ConfigResponseFieldUpdatedAt: utils.GetCurrentTimeString(),
|
||||
entity.ConfigResponseFieldSiteTitle: entity.ConfigDefaultSiteTitle,
|
||||
entity.ConfigResponseFieldSiteDescription: entity.ConfigDefaultSiteDescription,
|
||||
entity.ConfigResponseFieldKeywords: entity.ConfigDefaultKeywords,
|
||||
@@ -118,8 +155,10 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
|
||||
entity.ConfigResponseFieldAutoTransferLimitDays: 0,
|
||||
entity.ConfigResponseFieldAutoTransferMinSpace: 100,
|
||||
entity.ConfigResponseFieldAutoFetchHotDramaEnabled: false,
|
||||
entity.ConfigResponseFieldForbiddenWords: "",
|
||||
entity.ConfigResponseFieldPageSize: 100,
|
||||
entity.ConfigResponseFieldMaintenanceMode: false,
|
||||
entity.ConfigResponseFieldEnableRegister: true, // 默认开启注册功能
|
||||
}
|
||||
|
||||
// 将键值对转换为map
|
||||
@@ -159,6 +198,8 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response[entity.ConfigResponseFieldAutoFetchHotDramaEnabled] = val
|
||||
}
|
||||
case entity.ConfigKeyForbiddenWords:
|
||||
response[entity.ConfigResponseFieldForbiddenWords] = config.Value
|
||||
case entity.ConfigKeyPageSize:
|
||||
if val, err := strconv.Atoi(config.Value); err == nil {
|
||||
response[entity.ConfigResponseFieldPageSize] = val
|
||||
@@ -167,13 +208,19 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response[entity.ConfigResponseFieldMaintenanceMode] = val
|
||||
}
|
||||
case entity.ConfigKeyEnableRegister:
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response[entity.ConfigResponseFieldEnableRegister] = val
|
||||
}
|
||||
case entity.ConfigKeyThirdPartyStatsCode:
|
||||
response[entity.ConfigResponseFieldThirdPartyStatsCode] = config.Value
|
||||
}
|
||||
}
|
||||
|
||||
// 设置时间戳(使用第一个配置的时间)
|
||||
if len(configs) > 0 {
|
||||
response[entity.ConfigResponseFieldCreatedAt] = configs[0].CreatedAt.Format("2006-01-02 15:04:05")
|
||||
response[entity.ConfigResponseFieldUpdatedAt] = configs[0].UpdatedAt.Format("2006-01-02 15:04:05")
|
||||
response[entity.ConfigResponseFieldCreatedAt] = configs[0].CreatedAt.Format(utils.TimeFormatDateTime)
|
||||
response[entity.ConfigResponseFieldUpdatedAt] = configs[0].UpdatedAt.Format(utils.TimeFormatDateTime)
|
||||
}
|
||||
|
||||
return response
|
||||
@@ -194,7 +241,10 @@ func getDefaultConfigResponse() *dto.SystemConfigResponse {
|
||||
AutoTransferMinSpace: 100,
|
||||
AutoFetchHotDramaEnabled: false,
|
||||
ApiToken: entity.ConfigDefaultApiToken,
|
||||
ForbiddenWords: entity.ConfigDefaultForbiddenWords,
|
||||
PageSize: 100,
|
||||
MaintenanceMode: false,
|
||||
EnableRegister: true, // 默认开启注册功能
|
||||
ThirdPartyStatsCode: entity.ConfigDefaultThirdPartyStatsCode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,19 +79,21 @@ type CksResponse struct {
|
||||
|
||||
// ReadyResourceResponse 待处理资源响应
|
||||
type ReadyResourceResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Title *string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url"`
|
||||
Category string `json:"category"`
|
||||
Tags string `json:"tags"`
|
||||
Img string `json:"img"`
|
||||
Source string `json:"source"`
|
||||
Extra string `json:"extra"`
|
||||
Key string `json:"key"`
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
CreateTime time.Time `json:"create_time"`
|
||||
IP *string `json:"ip"`
|
||||
ID uint `json:"id"`
|
||||
Title *string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url"`
|
||||
Category string `json:"category"`
|
||||
Tags string `json:"tags"`
|
||||
Img string `json:"img"`
|
||||
Source string `json:"source"`
|
||||
Extra string `json:"extra"`
|
||||
Key string `json:"key"`
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
CreateTime time.Time `json:"create_time"`
|
||||
IP *string `json:"ip"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
IsDeleted bool `json:"is_deleted"`
|
||||
}
|
||||
|
||||
// Stats 统计信息
|
||||
|
||||
@@ -3,26 +3,33 @@ package dto
|
||||
// SystemConfigRequest 系统配置请求
|
||||
type SystemConfigRequest struct {
|
||||
// SEO 配置
|
||||
SiteTitle string `json:"site_title" validate:"required"`
|
||||
SiteTitle string `json:"site_title"`
|
||||
SiteDescription string `json:"site_description"`
|
||||
Keywords string `json:"keywords"`
|
||||
Author string `json:"author"`
|
||||
Copyright string `json:"copyright"`
|
||||
|
||||
// 自动处理配置组
|
||||
AutoProcessReadyResources bool `json:"auto_process_ready_resources"` // 自动处理待处理资源
|
||||
AutoProcessInterval int `json:"auto_process_interval" validate:"min=1,max=1440"` // 自动处理间隔(分钟)
|
||||
AutoTransferEnabled bool `json:"auto_transfer_enabled"` // 开启自动转存
|
||||
AutoTransferLimitDays int `json:"auto_transfer_limit_days" validate:"min=0,max=365"` // 自动转存限制天数(0表示不限制)
|
||||
AutoTransferMinSpace int `json:"auto_transfer_min_space" validate:"min=100,max=1024"` // 最小存储空间(GB)
|
||||
AutoFetchHotDramaEnabled bool `json:"auto_fetch_hot_drama_enabled"` // 自动拉取热播剧名字
|
||||
AutoProcessReadyResources bool `json:"auto_process_ready_resources"` // 自动处理待处理资源
|
||||
AutoProcessInterval int `json:"auto_process_interval"` // 自动处理间隔(分钟)
|
||||
AutoTransferEnabled bool `json:"auto_transfer_enabled"` // 开启自动转存
|
||||
AutoTransferLimitDays int `json:"auto_transfer_limit_days"` // 自动转存限制天数(0表示不限制)
|
||||
AutoTransferMinSpace int `json:"auto_transfer_min_space"` // 最小存储空间(GB)
|
||||
AutoFetchHotDramaEnabled bool `json:"auto_fetch_hot_drama_enabled"` // 自动拉取热播剧名字
|
||||
|
||||
// API配置
|
||||
ApiToken string `json:"api_token"` // 公开API访问令牌
|
||||
|
||||
// 违禁词配置
|
||||
ForbiddenWords string `json:"forbidden_words"` // 违禁词列表,用逗号分隔
|
||||
|
||||
// 其他配置
|
||||
PageSize int `json:"page_size" validate:"min=10,max=500"`
|
||||
PageSize int `json:"page_size"`
|
||||
MaintenanceMode bool `json:"maintenance_mode"`
|
||||
EnableRegister bool `json:"enable_register"` // 开启注册功能
|
||||
|
||||
// 三方统计配置
|
||||
ThirdPartyStatsCode string `json:"third_party_stats_code"` // 三方统计代码
|
||||
}
|
||||
|
||||
// SystemConfigResponse 系统配置响应
|
||||
@@ -49,9 +56,16 @@ type SystemConfigResponse struct {
|
||||
// API配置
|
||||
ApiToken string `json:"api_token"` // 公开API访问令牌
|
||||
|
||||
// 违禁词配置
|
||||
ForbiddenWords string `json:"forbidden_words"` // 违禁词列表,用逗号分隔
|
||||
|
||||
// 其他配置
|
||||
PageSize int `json:"page_size"`
|
||||
MaintenanceMode bool `json:"maintenance_mode"`
|
||||
EnableRegister bool `json:"enable_register"` // 开启注册功能
|
||||
|
||||
// 三方统计配置
|
||||
ThirdPartyStatsCode string `json:"third_party_stats_code"` // 三方统计代码
|
||||
}
|
||||
|
||||
// SystemConfigItem 单个配置项
|
||||
|
||||
55
db/dto/task_config.go
Normal file
55
db/dto/task_config.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package dto
|
||||
|
||||
import "fmt"
|
||||
|
||||
// BatchTransferTaskConfig 批量转存任务配置
|
||||
type BatchTransferTaskConfig struct {
|
||||
CategoryID *uint `json:"category_id"` // 默认分类ID
|
||||
TagIDs []uint `json:"tag_ids"` // 默认标签ID列表
|
||||
}
|
||||
|
||||
// TaskConfig 通用任务配置接口
|
||||
type TaskConfig interface {
|
||||
// Validate 验证配置有效性
|
||||
Validate() error
|
||||
}
|
||||
|
||||
// Validate 验证批量转存任务配置
|
||||
func (config BatchTransferTaskConfig) Validate() error {
|
||||
// 这里可以添加配置验证逻辑
|
||||
return nil
|
||||
}
|
||||
|
||||
// 示例:未来可能的其他任务类型配置
|
||||
|
||||
// DataSyncTaskConfig 数据同步任务配置(示例)
|
||||
type DataSyncTaskConfig struct {
|
||||
SourceType string `json:"source_type"` // 数据源类型
|
||||
TargetType string `json:"target_type"` // 目标类型
|
||||
SyncMode string `json:"sync_mode"` // 同步模式
|
||||
}
|
||||
|
||||
// Validate 验证数据同步任务配置
|
||||
func (config DataSyncTaskConfig) Validate() error {
|
||||
if config.SourceType == "" {
|
||||
return fmt.Errorf("数据源类型不能为空")
|
||||
}
|
||||
if config.TargetType == "" {
|
||||
return fmt.Errorf("目标类型不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupTaskConfig 清理任务配置(示例)
|
||||
type CleanupTaskConfig struct {
|
||||
RetentionDays int `json:"retention_days"` // 保留天数
|
||||
CleanupType string `json:"cleanup_type"` // 清理类型
|
||||
}
|
||||
|
||||
// Validate 验证清理任务配置
|
||||
func (config CleanupTaskConfig) Validate() error {
|
||||
if config.RetentionDays < 0 {
|
||||
return fmt.Errorf("保留天数不能为负数")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
56
db/dto/task_data.go
Normal file
56
db/dto/task_data.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package dto
|
||||
|
||||
import "fmt"
|
||||
|
||||
// BatchTransferInputData 批量转存任务的输入数据
|
||||
type BatchTransferInputData struct {
|
||||
Title string `json:"title"` // 资源标题
|
||||
URL string `json:"url"` // 资源链接
|
||||
CategoryID *uint `json:"category_id"` // 分类ID
|
||||
TagIDs []uint `json:"tag_ids"` // 标签ID列表
|
||||
}
|
||||
|
||||
// BatchTransferOutputData 批量转存任务的输出数据
|
||||
type BatchTransferOutputData struct {
|
||||
ResourceID uint `json:"resource_id"` // 创建的资源ID
|
||||
SaveURL string `json:"save_url"` // 转存后的链接
|
||||
PlatformID uint `json:"platform_id"` // 平台ID
|
||||
}
|
||||
|
||||
// TaskItemData 通用任务项数据接口
|
||||
type TaskItemData interface {
|
||||
// GetDisplayName 获取显示名称(用于前端显示)
|
||||
GetDisplayName() string
|
||||
// Validate 验证数据有效性
|
||||
Validate() error
|
||||
}
|
||||
|
||||
// GetDisplayName 实现TaskItemData接口
|
||||
func (data BatchTransferInputData) GetDisplayName() string {
|
||||
return data.Title
|
||||
}
|
||||
|
||||
// Validate 验证批量转存输入数据
|
||||
func (data BatchTransferInputData) Validate() error {
|
||||
if data.Title == "" {
|
||||
return fmt.Errorf("标题不能为空")
|
||||
}
|
||||
if data.URL == "" {
|
||||
return fmt.Errorf("链接不能为空")
|
||||
}
|
||||
// 这里可以添加URL格式验证
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDisplayName 实现TaskItemData接口
|
||||
func (data BatchTransferOutputData) GetDisplayName() string {
|
||||
return fmt.Sprintf("ResourceID: %d", data.ResourceID)
|
||||
}
|
||||
|
||||
// Validate 验证批量转存输出数据
|
||||
func (data BatchTransferOutputData) Validate() error {
|
||||
if data.ResourceID == 0 {
|
||||
return fmt.Errorf("资源ID不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
25
db/entity/resource_view.go
Normal file
25
db/entity/resource_view.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ResourceView 资源访问记录
|
||||
type ResourceView struct {
|
||||
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
ResourceID uint `json:"resource_id" gorm:"not null;index;comment:资源ID"`
|
||||
IPAddress string `json:"ip_address" gorm:"size:45;comment:访问者IP地址"`
|
||||
UserAgent string `json:"user_agent" gorm:"type:text;comment:用户代理"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;comment:访问时间"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
||||
|
||||
// 关联关系
|
||||
Resource Resource `json:"resource" gorm:"foreignKey:ResourceID"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (ResourceView) TableName() string {
|
||||
return "resource_views"
|
||||
}
|
||||
@@ -11,8 +11,8 @@ type SystemConfig struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// 键值对配置
|
||||
Key string `json:"key" gorm:"size:100;not null;uniqueIndex"`
|
||||
Value string `json:"value" gorm:"size:1000"`
|
||||
Key string `json:"key" gorm:"size:100;not null;unique;comment:配置键"`
|
||||
Value string `json:"value" gorm:"type:text"`
|
||||
Type string `json:"type" gorm:"size:20;default:'string'"` // string, int, bool, json
|
||||
}
|
||||
|
||||
|
||||
@@ -20,9 +20,16 @@ const (
|
||||
// API配置
|
||||
ConfigKeyApiToken = "api_token"
|
||||
|
||||
// 违禁词配置
|
||||
ConfigKeyForbiddenWords = "forbidden_words"
|
||||
|
||||
// 其他配置
|
||||
ConfigKeyPageSize = "page_size"
|
||||
ConfigKeyMaintenanceMode = "maintenance_mode"
|
||||
ConfigKeyEnableRegister = "enable_register"
|
||||
|
||||
// 三方统计配置
|
||||
ConfigKeyThirdPartyStatsCode = "third_party_stats_code"
|
||||
)
|
||||
|
||||
// ConfigType 配置类型常量
|
||||
@@ -58,9 +65,16 @@ const (
|
||||
// API配置字段
|
||||
ConfigResponseFieldApiToken = "api_token"
|
||||
|
||||
// 违禁词配置字段
|
||||
ConfigResponseFieldForbiddenWords = "forbidden_words"
|
||||
|
||||
// 其他配置字段
|
||||
ConfigResponseFieldPageSize = "page_size"
|
||||
ConfigResponseFieldMaintenanceMode = "maintenance_mode"
|
||||
ConfigResponseFieldEnableRegister = "enable_register"
|
||||
|
||||
// 三方统计配置字段
|
||||
ConfigResponseFieldThirdPartyStatsCode = "third_party_stats_code"
|
||||
)
|
||||
|
||||
// ConfigDefaultValue 配置默认值常量
|
||||
@@ -83,7 +97,14 @@ const (
|
||||
// API配置默认值
|
||||
ConfigDefaultApiToken = ""
|
||||
|
||||
// 违禁词配置默认值
|
||||
ConfigDefaultForbiddenWords = ""
|
||||
|
||||
// 其他配置默认值
|
||||
ConfigDefaultPageSize = "100"
|
||||
ConfigDefaultMaintenanceMode = "false"
|
||||
ConfigDefaultEnableRegister = "true"
|
||||
|
||||
// 三方统计配置默认值
|
||||
ConfigDefaultThirdPartyStatsCode = ""
|
||||
)
|
||||
|
||||
62
db/entity/task.go
Normal file
62
db/entity/task.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TaskStatus 任务状态
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
TaskStatusPending TaskStatus = "pending" // 等待中
|
||||
TaskStatusRunning TaskStatus = "running" // 运行中
|
||||
TaskStatusPaused TaskStatus = "paused" // 已暂停
|
||||
TaskStatusCompleted TaskStatus = "completed" // 已完成
|
||||
TaskStatusFailed TaskStatus = "failed" // 失败
|
||||
TaskStatusCancelled TaskStatus = "cancelled" // 已取消
|
||||
)
|
||||
|
||||
// TaskType 任务类型
|
||||
type TaskType string
|
||||
|
||||
const (
|
||||
TaskTypeBatchTransfer TaskType = "batch_transfer" // 批量转存
|
||||
)
|
||||
|
||||
// Task 任务表
|
||||
type Task struct {
|
||||
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Title string `json:"title" gorm:"size:255;not null;comment:任务标题"`
|
||||
Type TaskType `json:"type" gorm:"size:50;not null;comment:任务类型"`
|
||||
Status TaskStatus `json:"status" gorm:"size:20;not null;default:pending;comment:任务状态"`
|
||||
Description string `json:"description" gorm:"type:text;comment:任务描述"`
|
||||
|
||||
// 进度信息
|
||||
TotalItems int `json:"total_items" gorm:"not null;default:0;comment:总项目数"`
|
||||
ProcessedItems int `json:"processed_items" gorm:"not null;default:0;comment:已处理项目数"`
|
||||
SuccessItems int `json:"success_items" gorm:"not null;default:0;comment:成功项目数"`
|
||||
FailedItems int `json:"failed_items" gorm:"not null;default:0;comment:失败项目数"`
|
||||
|
||||
// 任务配置 (JSON格式存储)
|
||||
Config string `json:"config" gorm:"type:text;comment:任务配置"`
|
||||
|
||||
// 任务消息
|
||||
Message string `json:"message" gorm:"type:text;comment:任务消息"`
|
||||
|
||||
// 时间信息
|
||||
StartedAt *time.Time `json:"started_at" gorm:"comment:开始时间"`
|
||||
CompletedAt *time.Time `json:"completed_at" gorm:"comment:完成时间"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
||||
|
||||
// 关联关系
|
||||
TaskItems []TaskItem `json:"task_items" gorm:"foreignKey:TaskID"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (Task) TableName() string {
|
||||
return "tasks"
|
||||
}
|
||||
51
db/entity/task_item.go
Normal file
51
db/entity/task_item.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TaskItemStatus 任务项状态
|
||||
type TaskItemStatus string
|
||||
|
||||
const (
|
||||
TaskItemStatusPending TaskItemStatus = "pending" // 等待处理
|
||||
TaskItemStatusProcessing TaskItemStatus = "processing" // 处理中
|
||||
TaskItemStatusSuccess TaskItemStatus = "success" // 成功
|
||||
TaskItemStatusFailed TaskItemStatus = "failed" // 失败
|
||||
TaskItemStatusSkipped TaskItemStatus = "skipped" // 跳过
|
||||
)
|
||||
|
||||
// TaskItem 任务项表(任务的详细记录)
|
||||
type TaskItem struct {
|
||||
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
TaskID uint `json:"task_id" gorm:"not null;index;comment:任务ID"`
|
||||
|
||||
// 通用任务项信息
|
||||
Status TaskItemStatus `json:"status" gorm:"size:20;not null;default:pending;comment:处理状态"`
|
||||
ErrorMessage string `json:"error_message" gorm:"type:text;comment:错误信息"`
|
||||
|
||||
// 输入数据 (JSON格式存储,支持不同任务类型的不同数据结构)
|
||||
InputData string `json:"input_data" gorm:"type:text;not null;comment:输入数据(JSON格式)"`
|
||||
|
||||
// 输出数据 (JSON格式存储,支持不同任务类型的不同结果数据)
|
||||
OutputData string `json:"output_data" gorm:"type:text;comment:输出数据(JSON格式)"`
|
||||
|
||||
// 处理日志 (可选,用于记录详细的处理过程)
|
||||
ProcessLog string `json:"process_log" gorm:"type:text;comment:处理日志"`
|
||||
|
||||
// 时间信息
|
||||
ProcessedAt *time.Time `json:"processed_at" gorm:"comment:处理时间"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
||||
|
||||
// 关联关系
|
||||
Task Task `json:"task" gorm:"foreignKey:TaskID"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (TaskItem) TableName() string {
|
||||
return "task_items"
|
||||
}
|
||||
104
db/entity/task_item_helpers.go
Normal file
104
db/entity/task_item_helpers.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
)
|
||||
|
||||
// SetInputData 设置输入数据(将结构体转换为JSON字符串)
|
||||
func (item *TaskItem) SetInputData(data interface{}) error {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化输入数据失败: %v", err)
|
||||
}
|
||||
item.InputData = string(jsonData)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInputData 获取输入数据(根据任务类型解析JSON)
|
||||
func (item *TaskItem) GetInputData(taskType TaskType) (interface{}, error) {
|
||||
if item.InputData == "" {
|
||||
return nil, fmt.Errorf("输入数据为空")
|
||||
}
|
||||
|
||||
switch taskType {
|
||||
case TaskTypeBatchTransfer:
|
||||
var data dto.BatchTransferInputData
|
||||
err := json.Unmarshal([]byte(item.InputData), &data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析批量转存输入数据失败: %v", err)
|
||||
}
|
||||
return data, nil
|
||||
default:
|
||||
// 对于未知任务类型,返回原始JSON数据
|
||||
var data map[string]interface{}
|
||||
err := json.Unmarshal([]byte(item.InputData), &data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析输入数据失败: %v", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
// SetOutputData 设置输出数据(将结构体转换为JSON字符串)
|
||||
func (item *TaskItem) SetOutputData(data interface{}) error {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化输出数据失败: %v", err)
|
||||
}
|
||||
item.OutputData = string(jsonData)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOutputData 获取输出数据(根据任务类型解析JSON)
|
||||
func (item *TaskItem) GetOutputData(taskType TaskType) (interface{}, error) {
|
||||
if item.OutputData == "" {
|
||||
return nil, fmt.Errorf("输出数据为空")
|
||||
}
|
||||
|
||||
switch taskType {
|
||||
case TaskTypeBatchTransfer:
|
||||
var data dto.BatchTransferOutputData
|
||||
err := json.Unmarshal([]byte(item.OutputData), &data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析批量转存输出数据失败: %v", err)
|
||||
}
|
||||
return data, nil
|
||||
default:
|
||||
// 对于未知任务类型,返回原始JSON数据
|
||||
var data map[string]interface{}
|
||||
err := json.Unmarshal([]byte(item.OutputData), &data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析输出数据失败: %v", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
// GetDisplayName 获取显示名称(用于前端显示)
|
||||
func (item *TaskItem) GetDisplayName(taskType TaskType) string {
|
||||
inputData, err := item.GetInputData(taskType)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("TaskItem#%d", item.ID)
|
||||
}
|
||||
|
||||
switch taskType {
|
||||
case TaskTypeBatchTransfer:
|
||||
if data, ok := inputData.(dto.BatchTransferInputData); ok {
|
||||
return data.Title
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("TaskItem#%d", item.ID)
|
||||
}
|
||||
|
||||
// AddProcessLog 添加处理日志
|
||||
func (item *TaskItem) AddProcessLog(message string) {
|
||||
if item.ProcessLog == "" {
|
||||
item.ProcessLog = message
|
||||
} else {
|
||||
item.ProcessLog += "\n" + message
|
||||
}
|
||||
}
|
||||
1302
db/forbidden.txt
Normal file
1302
db/forbidden.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import (
|
||||
type CategoryRepository interface {
|
||||
BaseRepository[entity.Category]
|
||||
FindByName(name string) (*entity.Category, error)
|
||||
FindByNameIncludingDeleted(name string) (*entity.Category, error)
|
||||
FindWithResources() ([]entity.Category, error)
|
||||
FindWithTags() ([]entity.Category, error)
|
||||
GetResourceCount(categoryID uint) (int64, error)
|
||||
@@ -17,6 +18,7 @@ type CategoryRepository interface {
|
||||
GetTagNames(categoryID uint) ([]string, error)
|
||||
FindWithPagination(page, pageSize int) ([]entity.Category, int64, error)
|
||||
Search(query string, page, pageSize int) ([]entity.Category, int64, error)
|
||||
RestoreDeletedCategory(id uint) error
|
||||
}
|
||||
|
||||
// CategoryRepositoryImpl Category的Repository实现
|
||||
@@ -41,6 +43,21 @@ func (r *CategoryRepositoryImpl) FindByName(name string) (*entity.Category, erro
|
||||
return &category, nil
|
||||
}
|
||||
|
||||
// FindByNameIncludingDeleted 根据名称查找(包括已删除的记录)
|
||||
func (r *CategoryRepositoryImpl) FindByNameIncludingDeleted(name string) (*entity.Category, error) {
|
||||
var category entity.Category
|
||||
err := r.db.Unscoped().Where("name = ?", name).First(&category).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &category, nil
|
||||
}
|
||||
|
||||
// RestoreDeletedCategory 恢复已删除的分类
|
||||
func (r *CategoryRepositoryImpl) RestoreDeletedCategory(id uint) error {
|
||||
return r.db.Unscoped().Model(&entity.Category{}).Where("id = ?", id).Update("deleted_at", nil).Error
|
||||
}
|
||||
|
||||
// FindWithResources 查找包含资源的分类
|
||||
func (r *CategoryRepositoryImpl) FindWithResources() ([]entity.Category, error) {
|
||||
var categories []entity.Category
|
||||
|
||||
@@ -16,6 +16,9 @@ type RepositoryManager struct {
|
||||
SearchStatRepository SearchStatRepository
|
||||
SystemConfigRepository SystemConfigRepository
|
||||
HotDramaRepository HotDramaRepository
|
||||
ResourceViewRepository ResourceViewRepository
|
||||
TaskRepository TaskRepository
|
||||
TaskItemRepository TaskItemRepository
|
||||
}
|
||||
|
||||
// NewRepositoryManager 创建Repository管理器
|
||||
@@ -31,5 +34,8 @@ func NewRepositoryManager(db *gorm.DB) *RepositoryManager {
|
||||
SearchStatRepository: NewSearchStatRepository(db),
|
||||
SystemConfigRepository: NewSystemConfigRepository(db),
|
||||
HotDramaRepository: NewHotDramaRepository(db),
|
||||
ResourceViewRepository: NewResourceViewRepository(db),
|
||||
TaskRepository: NewTaskRepository(db),
|
||||
TaskItemRepository: NewTaskItemRepository(db),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,14 @@ type ReadyResourceRepository interface {
|
||||
BatchFindByURLs(urls []string) ([]entity.ReadyResource, error)
|
||||
GenerateUniqueKey() (string, error)
|
||||
FindWithErrors() ([]entity.ReadyResource, error)
|
||||
FindWithErrorsPaginated(page, limit int) ([]entity.ReadyResource, int64, error)
|
||||
FindWithErrorsIncludingDeleted() ([]entity.ReadyResource, error)
|
||||
FindWithErrorsPaginatedIncludingDeleted(page, limit int, errorFilter string) ([]entity.ReadyResource, int64, error)
|
||||
FindWithErrorsByQuery(errorFilter string) ([]entity.ReadyResource, error)
|
||||
FindWithoutErrors() ([]entity.ReadyResource, error)
|
||||
ClearErrorMsg(id uint) error
|
||||
ClearErrorMsgAndRestore(id uint) error
|
||||
ClearAllErrorsByQuery(errorFilter string) (int64, error) // 批量清除错误信息并真正删除资源
|
||||
}
|
||||
|
||||
// ReadyResourceRepositoryImpl ReadyResource的Repository实现
|
||||
@@ -117,13 +123,31 @@ func (r *ReadyResourceRepositoryImpl) GenerateUniqueKey() (string, error) {
|
||||
return "", gorm.ErrInvalidData
|
||||
}
|
||||
|
||||
// FindWithErrors 查找有错误信息的资源
|
||||
// FindWithErrors 查找有错误信息的资源(包括软删除的)
|
||||
func (r *ReadyResourceRepositoryImpl) FindWithErrors() ([]entity.ReadyResource, error) {
|
||||
var resources []entity.ReadyResource
|
||||
err := r.db.Where("error_msg != '' AND error_msg IS NOT NULL").Find(&resources).Error
|
||||
err := r.db.Unscoped().Where("error_msg != '' AND error_msg IS NOT NULL").Find(&resources).Error
|
||||
return resources, err
|
||||
}
|
||||
|
||||
// FindWithErrorsPaginated 分页查找有错误信息的资源(包括软删除的)
|
||||
func (r *ReadyResourceRepositoryImpl) FindWithErrorsPaginated(page, limit int) ([]entity.ReadyResource, int64, error) {
|
||||
var resources []entity.ReadyResource
|
||||
var total int64
|
||||
|
||||
offset := (page - 1) * limit
|
||||
db := r.db.Model(&entity.ReadyResource{}).Unscoped().Where("error_msg != '' AND error_msg IS NOT NULL")
|
||||
|
||||
// 获取总数
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 获取分页数据
|
||||
err := db.Offset(offset).Limit(limit).Order("created_at DESC").Find(&resources).Error
|
||||
return resources, total, err
|
||||
}
|
||||
|
||||
// FindWithoutErrors 查找没有错误信息的资源
|
||||
func (r *ReadyResourceRepositoryImpl) FindWithoutErrors() ([]entity.ReadyResource, error) {
|
||||
var resources []entity.ReadyResource
|
||||
@@ -131,7 +155,75 @@ func (r *ReadyResourceRepositoryImpl) FindWithoutErrors() ([]entity.ReadyResourc
|
||||
return resources, err
|
||||
}
|
||||
|
||||
// FindWithErrorsIncludingDeleted 查找有错误信息的资源(包括软删除的,用于管理页面)
|
||||
func (r *ReadyResourceRepositoryImpl) FindWithErrorsIncludingDeleted() ([]entity.ReadyResource, error) {
|
||||
var resources []entity.ReadyResource
|
||||
err := r.db.Unscoped().Where("error_msg != '' AND error_msg IS NOT NULL").Find(&resources).Error
|
||||
return resources, err
|
||||
}
|
||||
|
||||
// FindWithErrorsPaginatedIncludingDeleted 分页查找有错误信息的资源(包括软删除的,用于管理页面)
|
||||
func (r *ReadyResourceRepositoryImpl) FindWithErrorsPaginatedIncludingDeleted(page, limit int, errorFilter string) ([]entity.ReadyResource, int64, error) {
|
||||
var resources []entity.ReadyResource
|
||||
var total int64
|
||||
|
||||
offset := (page - 1) * limit
|
||||
db := r.db.Model(&entity.ReadyResource{}).Unscoped().Where("error_msg != '' AND error_msg IS NOT NULL")
|
||||
|
||||
// 如果有错误过滤条件,添加到查询中
|
||||
if errorFilter != "" {
|
||||
db = db.Where("error_msg ILIKE ?", "%"+errorFilter+"%")
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 获取分页数据
|
||||
err := db.Offset(offset).Limit(limit).Order("created_at DESC").Find(&resources).Error
|
||||
return resources, total, err
|
||||
}
|
||||
|
||||
// ClearErrorMsg 清除指定资源的错误信息
|
||||
func (r *ReadyResourceRepositoryImpl) ClearErrorMsg(id uint) error {
|
||||
return r.db.Model(&entity.ReadyResource{}).Where("id = ?", id).Update("error_msg", "").Error
|
||||
return r.db.Model(&entity.ReadyResource{}).Where("id = ?", id).Update("error_msg", "").Update("deleted_at", nil).Error
|
||||
}
|
||||
|
||||
// ClearErrorMsgAndRestore 清除错误信息并恢复软删除的资源
|
||||
func (r *ReadyResourceRepositoryImpl) ClearErrorMsgAndRestore(id uint) error {
|
||||
return r.db.Unscoped().Model(&entity.ReadyResource{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"error_msg": "",
|
||||
"deleted_at": nil,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// FindWithErrorsByQuery 根据查询条件查找有错误信息的资源(不分页,用于批量操作)
|
||||
func (r *ReadyResourceRepositoryImpl) FindWithErrorsByQuery(errorFilter string) ([]entity.ReadyResource, error) {
|
||||
var resources []entity.ReadyResource
|
||||
|
||||
db := r.db.Model(&entity.ReadyResource{}).Unscoped().Where("error_msg != '' AND error_msg IS NOT NULL")
|
||||
|
||||
// 如果有错误过滤条件,添加到查询中
|
||||
if errorFilter != "" {
|
||||
db = db.Where("error_msg ILIKE ?", "%"+errorFilter+"%")
|
||||
}
|
||||
|
||||
err := db.Order("created_at DESC").Find(&resources).Error
|
||||
return resources, err
|
||||
}
|
||||
|
||||
// ClearAllErrorsByQuery 根据查询条件批量清除错误信息并真正删除资源
|
||||
func (r *ReadyResourceRepositoryImpl) ClearAllErrorsByQuery(errorFilter string) (int64, error) {
|
||||
db := r.db.Unscoped().Model(&entity.ReadyResource{}).Where("error_msg != '' AND error_msg IS NOT NULL")
|
||||
|
||||
// 如果有错误过滤条件,添加到查询中
|
||||
if errorFilter != "" {
|
||||
db = db.Where("error_msg ILIKE ?", "%"+errorFilter+"%")
|
||||
}
|
||||
|
||||
// 真正删除资源(物理删除)
|
||||
result := db.Delete(&entity.ReadyResource{})
|
||||
|
||||
return result.RowsAffected, result.Error
|
||||
}
|
||||
|
||||
@@ -2,11 +2,9 @@ package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -32,8 +30,10 @@ type ResourceRepository interface {
|
||||
InvalidateCache() error
|
||||
FindExists(url string, excludeID ...uint) (bool, error)
|
||||
BatchFindByURLs(urls []string) ([]entity.Resource, error)
|
||||
GetResourcesForTransfer(panID uint, sinceTime time.Time) ([]*entity.Resource, error)
|
||||
CreateResourceTag(resourceID, tagID uint) error
|
||||
GetResourcesForTransfer(panID uint, sinceTime time.Time, limit int) ([]*entity.Resource, error)
|
||||
GetByURL(url string) (*entity.Resource, error)
|
||||
UpdateSaveURL(id uint, saveURL string) error
|
||||
CreateResourceTag(resourceTag *entity.ResourceTag) error
|
||||
}
|
||||
|
||||
// ResourceRepositoryImpl Resource的Repository实现
|
||||
@@ -212,18 +212,43 @@ func (r *ResourceRepositoryImpl) SearchWithFilters(params map[string]interface{}
|
||||
var resources []entity.Resource
|
||||
var total int64
|
||||
|
||||
db := r.db.Model(&entity.Resource{})
|
||||
db := r.db.Model(&entity.Resource{}).Preload("Category").Preload("Pan").Preload("Tags")
|
||||
|
||||
// 处理参数
|
||||
for key, value := range params {
|
||||
switch key {
|
||||
case "query":
|
||||
case "search": // 添加search参数支持
|
||||
if query, ok := value.(string); ok && query != "" {
|
||||
db = db.Where("title ILIKE ? OR description ILIKE ?", "%"+query+"%", "%"+query+"%")
|
||||
}
|
||||
case "category_id":
|
||||
case "category_id": // 添加category_id参数支持
|
||||
if categoryID, ok := value.(uint); ok {
|
||||
fmt.Printf("应用分类筛选: category_id = %d\n", categoryID)
|
||||
db = db.Where("category_id = ?", categoryID)
|
||||
} else {
|
||||
fmt.Printf("分类ID类型错误: %T, value: %v\n", value, value)
|
||||
}
|
||||
case "category": // 添加category参数支持(字符串形式)
|
||||
if category, ok := value.(string); ok && category != "" {
|
||||
// 根据分类名称查找分类ID
|
||||
var categoryEntity entity.Category
|
||||
if err := r.db.Where("name ILIKE ?", "%"+category+"%").First(&categoryEntity).Error; err == nil {
|
||||
db = db.Where("category_id = ?", categoryEntity.ID)
|
||||
}
|
||||
}
|
||||
case "tag": // 添加tag参数支持
|
||||
if tag, ok := value.(string); ok && tag != "" {
|
||||
// 根据标签名称查找相关资源
|
||||
var tagEntity entity.Tag
|
||||
if err := r.db.Where("name ILIKE ?", "%"+tag+"%").First(&tagEntity).Error; err == nil {
|
||||
// 通过中间表查找包含该标签的资源
|
||||
db = db.Joins("JOIN resource_tags ON resources.id = resource_tags.resource_id").
|
||||
Where("resource_tags.tag_id = ?", tagEntity.ID)
|
||||
}
|
||||
}
|
||||
case "pan_id": // 添加pan_id参数支持
|
||||
if panID, ok := value.(uint); ok {
|
||||
db = db.Where("pan_id = ?", panID)
|
||||
}
|
||||
case "is_valid":
|
||||
if isValid, ok := value.(bool); ok {
|
||||
@@ -233,20 +258,76 @@ func (r *ResourceRepositoryImpl) SearchWithFilters(params map[string]interface{}
|
||||
if isPublic, ok := value.(bool); ok {
|
||||
db = db.Where("is_public = ?", isPublic)
|
||||
}
|
||||
case "pan_id":
|
||||
if panID, ok := value.(uint); ok {
|
||||
db = db.Where("pan_id = ?", panID)
|
||||
case "has_save_url": // 添加has_save_url参数支持
|
||||
if hasSaveURL, ok := value.(bool); ok {
|
||||
fmt.Printf("处理 has_save_url 参数: %v\n", hasSaveURL)
|
||||
if hasSaveURL {
|
||||
// 有转存链接:save_url不为空且不为空格
|
||||
db = db.Where("save_url IS NOT NULL AND save_url != '' AND TRIM(save_url) != ''")
|
||||
fmt.Printf("应用 has_save_url=true 条件: save_url IS NOT NULL AND save_url != '' AND TRIM(save_url) != ''\n")
|
||||
} else {
|
||||
// 没有转存链接:save_url为空、NULL或只有空格
|
||||
db = db.Where("(save_url IS NULL OR save_url = '' OR TRIM(save_url) = '')")
|
||||
fmt.Printf("应用 has_save_url=false 条件: (save_url IS NULL OR save_url = '' OR TRIM(save_url) = '')\n")
|
||||
}
|
||||
}
|
||||
case "no_save_url": // 添加no_save_url参数支持(与has_save_url=false相同)
|
||||
if noSaveURL, ok := value.(bool); ok && noSaveURL {
|
||||
db = db.Where("(save_url IS NULL OR save_url = '' OR TRIM(save_url) = '')")
|
||||
}
|
||||
case "pan_name": // 添加pan_name参数支持
|
||||
if panName, ok := value.(string); ok && panName != "" {
|
||||
// 根据平台名称查找平台ID
|
||||
var panEntity entity.Pan
|
||||
if err := r.db.Where("name ILIKE ?", "%"+panName+"%").First(&panEntity).Error; err == nil {
|
||||
db = db.Where("pan_id = ?", panEntity.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 管理后台显示所有资源,公开API才限制为有效的公开资源
|
||||
// 这里通过检查请求来源来判断是否为管理后台
|
||||
// 如果没有明确指定is_valid和is_public,则显示所有资源
|
||||
// 注意:这个逻辑可能需要根据实际需求调整
|
||||
if _, hasIsValid := params["is_valid"]; !hasIsValid {
|
||||
// 管理后台不限制is_valid
|
||||
// db = db.Where("is_valid = ?", true)
|
||||
}
|
||||
if _, hasIsPublic := params["is_public"]; !hasIsPublic {
|
||||
// 管理后台不限制is_public
|
||||
// db = db.Where("is_public = ?", true)
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 处理分页参数
|
||||
page := 1
|
||||
pageSize := 20
|
||||
|
||||
if pageVal, ok := params["page"].(int); ok && pageVal > 0 {
|
||||
page = pageVal
|
||||
}
|
||||
if pageSizeVal, ok := params["page_size"].(int); ok && pageSizeVal > 0 {
|
||||
pageSize = pageSizeVal
|
||||
fmt.Printf("原始pageSize: %d\n", pageSize)
|
||||
// 限制最大page_size为10000(管理后台需要更大的数据量)
|
||||
if pageSize > 10000 {
|
||||
pageSize = 10000
|
||||
fmt.Printf("pageSize超过10000,限制为: %d\n", pageSize)
|
||||
}
|
||||
fmt.Printf("最终pageSize: %d\n", pageSize)
|
||||
}
|
||||
|
||||
// 计算偏移量
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
// 获取分页数据,按更新时间倒序
|
||||
err := db.Order("updated_at DESC").Find(&resources).Error
|
||||
err := db.Order("updated_at DESC").Offset(offset).Limit(pageSize).Find(&resources).Error
|
||||
fmt.Printf("查询结果: 总数=%d, 当前页数据量=%d, pageSize=%d\n", total, len(resources), pageSize)
|
||||
return resources, total, err
|
||||
}
|
||||
|
||||
@@ -358,12 +439,18 @@ func (r *ResourceRepositoryImpl) BatchFindByURLs(urls []string) ([]entity.Resour
|
||||
}
|
||||
|
||||
// GetResourcesForTransfer 获取需要转存的资源
|
||||
func (r *ResourceRepositoryImpl) GetResourcesForTransfer(panID uint, sinceTime time.Time) ([]*entity.Resource, error) {
|
||||
func (r *ResourceRepositoryImpl) GetResourcesForTransfer(panID uint, sinceTime time.Time, limit int) ([]*entity.Resource, error) {
|
||||
var resources []*entity.Resource
|
||||
query := r.db.Where("pan_id = ? AND (save_url = '' OR save_url IS NULL) AND (error_msg = '' OR error_msg IS NULL)", panID)
|
||||
if !sinceTime.IsZero() {
|
||||
query = query.Where("created_at >= ?", sinceTime)
|
||||
}
|
||||
|
||||
// 添加数量限制
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
|
||||
err := query.Order("created_at DESC").Find(&resources).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -371,11 +458,22 @@ func (r *ResourceRepositoryImpl) GetResourcesForTransfer(panID uint, sinceTime t
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
// CreateResourceTag 创建资源与标签的关联
|
||||
func (r *ResourceRepositoryImpl) CreateResourceTag(resourceID, tagID uint) error {
|
||||
resourceTag := &entity.ResourceTag{
|
||||
ResourceID: resourceID,
|
||||
TagID: tagID,
|
||||
// GetByURL 根据URL获取资源
|
||||
func (r *ResourceRepositoryImpl) GetByURL(url string) (*entity.Resource, error) {
|
||||
var resource entity.Resource
|
||||
err := r.GetDB().Where("url = ?", url).First(&resource).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resource, nil
|
||||
}
|
||||
|
||||
// UpdateSaveURL 更新资源的转存链接
|
||||
func (r *ResourceRepositoryImpl) UpdateSaveURL(id uint, saveURL string) error {
|
||||
return r.GetDB().Model(&entity.Resource{}).Where("id = ?", id).Update("save_url", saveURL).Error
|
||||
}
|
||||
|
||||
// CreateResourceTag 创建资源与标签的关联
|
||||
func (r *ResourceRepositoryImpl) CreateResourceTag(resourceTag *entity.ResourceTag) error {
|
||||
return r.GetDB().Create(resourceTag).Error
|
||||
}
|
||||
|
||||
90
db/repo/resource_view_repository.go
Normal file
90
db/repo/resource_view_repository.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ResourceViewRepository 资源访问记录仓库接口
|
||||
type ResourceViewRepository interface {
|
||||
BaseRepository[entity.ResourceView]
|
||||
RecordView(resourceID uint, ipAddress, userAgent string) error
|
||||
GetTodayViews() (int64, error)
|
||||
GetViewsByDate(date string) (int64, error)
|
||||
GetViewsTrend(days int) ([]map[string]interface{}, error)
|
||||
GetResourceViews(resourceID uint, limit int) ([]entity.ResourceView, error)
|
||||
}
|
||||
|
||||
// ResourceViewRepositoryImpl 资源访问记录仓库实现
|
||||
type ResourceViewRepositoryImpl struct {
|
||||
BaseRepositoryImpl[entity.ResourceView]
|
||||
}
|
||||
|
||||
// NewResourceViewRepository 创建资源访问记录仓库
|
||||
func NewResourceViewRepository(db *gorm.DB) ResourceViewRepository {
|
||||
return &ResourceViewRepositoryImpl{
|
||||
BaseRepositoryImpl: BaseRepositoryImpl[entity.ResourceView]{db: db},
|
||||
}
|
||||
}
|
||||
|
||||
// RecordView 记录资源访问
|
||||
func (r *ResourceViewRepositoryImpl) RecordView(resourceID uint, ipAddress, userAgent string) error {
|
||||
view := &entity.ResourceView{
|
||||
ResourceID: resourceID,
|
||||
IPAddress: ipAddress,
|
||||
UserAgent: userAgent,
|
||||
}
|
||||
return r.db.Create(view).Error
|
||||
}
|
||||
|
||||
// GetTodayViews 获取今日访问量
|
||||
func (r *ResourceViewRepositoryImpl) GetTodayViews() (int64, error) {
|
||||
today := utils.GetTodayString()
|
||||
var count int64
|
||||
err := r.db.Model(&entity.ResourceView{}).
|
||||
Where("DATE(created_at) = ?", today).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// GetViewsByDate 获取指定日期的访问量
|
||||
func (r *ResourceViewRepositoryImpl) GetViewsByDate(date string) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&entity.ResourceView{}).
|
||||
Where("DATE(created_at) = ?", date).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// GetViewsTrend 获取访问量趋势数据
|
||||
func (r *ResourceViewRepositoryImpl) GetViewsTrend(days int) ([]map[string]interface{}, error) {
|
||||
var results []map[string]interface{}
|
||||
|
||||
for i := days - 1; i >= 0; i-- {
|
||||
date := utils.GetCurrentTime().AddDate(0, 0, -i)
|
||||
dateStr := date.Format(utils.TimeFormatDate)
|
||||
|
||||
count, err := r.GetViewsByDate(dateStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results = append(results, map[string]interface{}{
|
||||
"date": dateStr,
|
||||
"views": count,
|
||||
})
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetResourceViews 获取指定资源的访问记录
|
||||
func (r *ResourceViewRepositoryImpl) GetResourceViews(resourceID uint, limit int) ([]entity.ResourceView, error) {
|
||||
var views []entity.ResourceView
|
||||
err := r.db.Where("resource_id = ?", resourceID).
|
||||
Order("created_at DESC").
|
||||
Limit(limit).
|
||||
Find(&views).Error
|
||||
return views, err
|
||||
}
|
||||
@@ -2,9 +2,9 @@ package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -18,6 +18,7 @@ type SearchStatRepository interface {
|
||||
GetSearchTrend(days int) ([]entity.DailySearchStat, error)
|
||||
GetKeywordTrend(keyword string, days int) ([]entity.DailySearchStat, error)
|
||||
GetSummary() (map[string]int64, error)
|
||||
FindWithPaginationOrdered(page, limit int) ([]entity.SearchStat, int64, error)
|
||||
}
|
||||
|
||||
// SearchStatRepositoryImpl 搜索统计Repository实现
|
||||
@@ -37,7 +38,7 @@ func (r *SearchStatRepositoryImpl) RecordSearch(keyword, ip, userAgent string) e
|
||||
stat := entity.SearchStat{
|
||||
Keyword: keyword,
|
||||
Count: 1,
|
||||
Date: time.Now(), // 可保留 date 字段,实际用 created_at 统计
|
||||
Date: utils.GetCurrentTime(), // 可保留 date 字段,实际用 created_at 统计
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
}
|
||||
@@ -124,9 +125,9 @@ func (r *SearchStatRepositoryImpl) GetKeywordTrend(keyword string, days int) ([]
|
||||
// GetSummary 获取搜索统计汇总
|
||||
func (r *SearchStatRepositoryImpl) GetSummary() (map[string]int64, error) {
|
||||
var total, today, week, month, keywords int64
|
||||
now := time.Now()
|
||||
todayStr := now.Format("2006-01-02")
|
||||
weekStart := now.AddDate(0, 0, -int(now.Weekday())+1).Format("2006-01-02") // 周一
|
||||
now := utils.GetCurrentTime()
|
||||
todayStr := now.Format(utils.TimeFormatDate)
|
||||
weekStart := now.AddDate(0, 0, -int(now.Weekday())+1).Format(utils.TimeFormatDate) // 周一
|
||||
monthStart := now.Format("2006-01") + "-01"
|
||||
|
||||
// 总搜索次数
|
||||
@@ -157,3 +158,20 @@ func (r *SearchStatRepositoryImpl) GetSummary() (map[string]int64, error) {
|
||||
"keywords": keywords,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FindWithPaginationOrdered 按时间倒序分页查找搜索记录
|
||||
func (r *SearchStatRepositoryImpl) FindWithPaginationOrdered(page, limit int) ([]entity.SearchStat, int64, error) {
|
||||
var stats []entity.SearchStat
|
||||
var total int64
|
||||
|
||||
offset := (page - 1) * limit
|
||||
|
||||
// 获取总数
|
||||
if err := r.db.Model(&entity.SearchStat{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 获取分页数据,按创建时间倒序排列(最新的在前面)
|
||||
err := r.db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&stats).Error
|
||||
return stats, total, err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
|
||||
@@ -18,17 +19,25 @@ type SystemConfigRepository interface {
|
||||
GetConfigValue(key string) (string, error)
|
||||
GetConfigBool(key string) (bool, error)
|
||||
GetConfigInt(key string) (int, error)
|
||||
GetCachedConfigs() map[string]string
|
||||
ClearConfigCache()
|
||||
}
|
||||
|
||||
// SystemConfigRepositoryImpl 系统配置Repository实现
|
||||
type SystemConfigRepositoryImpl struct {
|
||||
BaseRepositoryImpl[entity.SystemConfig]
|
||||
|
||||
// 配置缓存
|
||||
configCache map[string]string // key -> value
|
||||
configCacheOnce sync.Once
|
||||
configCacheMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewSystemConfigRepository 创建系统配置Repository
|
||||
func NewSystemConfigRepository(db *gorm.DB) SystemConfigRepository {
|
||||
return &SystemConfigRepositoryImpl{
|
||||
BaseRepositoryImpl: BaseRepositoryImpl[entity.SystemConfig]{db: db},
|
||||
configCache: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +77,9 @@ func (r *SystemConfigRepositoryImpl) UpsertConfigs(configs []entity.SystemConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新配置后刷新缓存
|
||||
r.refreshConfigCache()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -93,8 +105,10 @@ func (r *SystemConfigRepositoryImpl) GetOrCreateDefault() ([]entity.SystemConfig
|
||||
{Key: entity.ConfigKeyAutoTransferMinSpace, Value: entity.ConfigDefaultAutoTransferMinSpace, Type: entity.ConfigTypeInt},
|
||||
{Key: entity.ConfigKeyAutoFetchHotDramaEnabled, Value: entity.ConfigDefaultAutoFetchHotDramaEnabled, Type: entity.ConfigTypeBool},
|
||||
{Key: entity.ConfigKeyApiToken, Value: entity.ConfigDefaultApiToken, Type: entity.ConfigTypeString},
|
||||
{Key: entity.ConfigKeyPageSize, Value: entity.ConfigDefaultPageSize, Type: entity.ConfigTypeInt},
|
||||
{Key: entity.ConfigKeyMaintenanceMode, Value: entity.ConfigDefaultMaintenanceMode, Type: entity.ConfigTypeBool},
|
||||
{Key: entity.ConfigKeyPageSize, Value: entity.ConfigDefaultPageSize, Type: entity.ConfigTypeInt},
|
||||
{Key: entity.ConfigKeyMaintenanceMode, Value: entity.ConfigDefaultMaintenanceMode, Type: entity.ConfigTypeBool},
|
||||
{Key: entity.ConfigKeyEnableRegister, Value: entity.ConfigDefaultEnableRegister, Type: entity.ConfigTypeBool},
|
||||
{Key: entity.ConfigKeyThirdPartyStatsCode, Value: entity.ConfigDefaultThirdPartyStatsCode, Type: entity.ConfigTypeString},
|
||||
}
|
||||
|
||||
err = r.UpsertConfigs(defaultConfigs)
|
||||
@@ -105,15 +119,118 @@ func (r *SystemConfigRepositoryImpl) GetOrCreateDefault() ([]entity.SystemConfig
|
||||
return defaultConfigs, nil
|
||||
}
|
||||
|
||||
// 检查是否有缺失的配置项,如果有则添加
|
||||
requiredConfigs := map[string]entity.SystemConfig{
|
||||
entity.ConfigKeySiteTitle: {Key: entity.ConfigKeySiteTitle, Value: entity.ConfigDefaultSiteTitle, Type: entity.ConfigTypeString},
|
||||
entity.ConfigKeySiteDescription: {Key: entity.ConfigKeySiteDescription, Value: entity.ConfigDefaultSiteDescription, Type: entity.ConfigTypeString},
|
||||
entity.ConfigKeyKeywords: {Key: entity.ConfigKeyKeywords, Value: entity.ConfigDefaultKeywords, Type: entity.ConfigTypeString},
|
||||
entity.ConfigKeyAuthor: {Key: entity.ConfigKeyAuthor, Value: entity.ConfigDefaultAuthor, Type: entity.ConfigTypeString},
|
||||
entity.ConfigKeyCopyright: {Key: entity.ConfigKeyCopyright, Value: entity.ConfigDefaultCopyright, Type: entity.ConfigTypeString},
|
||||
entity.ConfigKeyAutoProcessReadyResources: {Key: entity.ConfigKeyAutoProcessReadyResources, Value: entity.ConfigDefaultAutoProcessReadyResources, Type: entity.ConfigTypeBool},
|
||||
entity.ConfigKeyAutoProcessInterval: {Key: entity.ConfigKeyAutoProcessInterval, Value: entity.ConfigDefaultAutoProcessInterval, Type: entity.ConfigTypeInt},
|
||||
entity.ConfigKeyAutoTransferEnabled: {Key: entity.ConfigKeyAutoTransferEnabled, Value: entity.ConfigDefaultAutoTransferEnabled, Type: entity.ConfigTypeBool},
|
||||
entity.ConfigKeyAutoTransferLimitDays: {Key: entity.ConfigKeyAutoTransferLimitDays, Value: entity.ConfigDefaultAutoTransferLimitDays, Type: entity.ConfigTypeInt},
|
||||
entity.ConfigKeyAutoTransferMinSpace: {Key: entity.ConfigKeyAutoTransferMinSpace, Value: entity.ConfigDefaultAutoTransferMinSpace, Type: entity.ConfigTypeInt},
|
||||
entity.ConfigKeyAutoFetchHotDramaEnabled: {Key: entity.ConfigKeyAutoFetchHotDramaEnabled, Value: entity.ConfigDefaultAutoFetchHotDramaEnabled, Type: entity.ConfigTypeBool},
|
||||
entity.ConfigKeyApiToken: {Key: entity.ConfigKeyApiToken, Value: entity.ConfigDefaultApiToken, Type: entity.ConfigTypeString},
|
||||
entity.ConfigKeyPageSize: {Key: entity.ConfigKeyPageSize, Value: entity.ConfigDefaultPageSize, Type: entity.ConfigTypeInt},
|
||||
entity.ConfigKeyMaintenanceMode: {Key: entity.ConfigKeyMaintenanceMode, Value: entity.ConfigDefaultMaintenanceMode, Type: entity.ConfigTypeBool},
|
||||
entity.ConfigKeyEnableRegister: {Key: entity.ConfigKeyEnableRegister, Value: entity.ConfigDefaultEnableRegister, Type: entity.ConfigTypeBool},
|
||||
entity.ConfigKeyThirdPartyStatsCode: {Key: entity.ConfigKeyThirdPartyStatsCode, Value: entity.ConfigDefaultThirdPartyStatsCode, Type: entity.ConfigTypeString},
|
||||
}
|
||||
|
||||
// 检查现有配置中是否有缺失的配置项
|
||||
existingKeys := make(map[string]bool)
|
||||
for _, config := range configs {
|
||||
existingKeys[config.Key] = true
|
||||
}
|
||||
|
||||
// 找出缺失的配置项
|
||||
var missingConfigs []entity.SystemConfig
|
||||
for key, requiredConfig := range requiredConfigs {
|
||||
if !existingKeys[key] {
|
||||
missingConfigs = append(missingConfigs, requiredConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有缺失的配置项,则添加它们
|
||||
if len(missingConfigs) > 0 {
|
||||
err = r.UpsertConfigs(missingConfigs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 重新获取所有配置
|
||||
configs, err = r.FindAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
// initConfigCache 初始化配置缓存
|
||||
func (r *SystemConfigRepositoryImpl) initConfigCache() {
|
||||
r.configCacheOnce.Do(func() {
|
||||
// 获取所有配置
|
||||
configs, err := r.FindAll()
|
||||
if err != nil {
|
||||
// 如果获取失败,尝试创建默认配置
|
||||
configs, err = r.GetOrCreateDefault()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化缓存
|
||||
r.configCacheMutex.Lock()
|
||||
defer r.configCacheMutex.Unlock()
|
||||
|
||||
for _, config := range configs {
|
||||
r.configCache[config.Key] = config.Value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// refreshConfigCache 刷新配置缓存
|
||||
func (r *SystemConfigRepositoryImpl) refreshConfigCache() {
|
||||
// 重置Once,允许重新初始化
|
||||
r.configCacheOnce = sync.Once{}
|
||||
|
||||
// 清空缓存
|
||||
r.configCacheMutex.Lock()
|
||||
r.configCache = make(map[string]string)
|
||||
r.configCacheMutex.Unlock()
|
||||
|
||||
// 重新初始化缓存
|
||||
r.initConfigCache()
|
||||
}
|
||||
|
||||
// GetConfigValue 获取配置值(字符串)
|
||||
func (r *SystemConfigRepositoryImpl) GetConfigValue(key string) (string, error) {
|
||||
// 初始化缓存
|
||||
r.initConfigCache()
|
||||
|
||||
// 从缓存中读取
|
||||
r.configCacheMutex.RLock()
|
||||
value, exists := r.configCache[key]
|
||||
r.configCacheMutex.RUnlock()
|
||||
|
||||
if exists {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// 如果缓存中没有,尝试从数据库获取(可能是新添加的配置)
|
||||
config, err := r.FindByKey(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 更新缓存
|
||||
r.configCacheMutex.Lock()
|
||||
r.configCache[key] = config.Value
|
||||
r.configCacheMutex.Unlock()
|
||||
|
||||
return config.Value, nil
|
||||
}
|
||||
|
||||
@@ -146,3 +263,29 @@ func (r *SystemConfigRepositoryImpl) GetConfigInt(key string) (int, error) {
|
||||
_, err = fmt.Sscanf(value, "%d", &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// GetCachedConfigs 获取所有缓存的配置(用于调试)
|
||||
func (r *SystemConfigRepositoryImpl) GetCachedConfigs() map[string]string {
|
||||
r.initConfigCache()
|
||||
|
||||
r.configCacheMutex.RLock()
|
||||
defer r.configCacheMutex.RUnlock()
|
||||
|
||||
// 返回缓存的副本
|
||||
result := make(map[string]string)
|
||||
for k, v := range r.configCache {
|
||||
result[k] = v
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ClearConfigCache 清空配置缓存(用于测试或手动刷新)
|
||||
func (r *SystemConfigRepositoryImpl) ClearConfigCache() {
|
||||
r.configCacheMutex.Lock()
|
||||
r.configCache = make(map[string]string)
|
||||
r.configCacheMutex.Unlock()
|
||||
|
||||
// 重置Once,允许重新初始化
|
||||
r.configCacheOnce = sync.Once{}
|
||||
}
|
||||
|
||||
@@ -10,15 +10,19 @@ import (
|
||||
type TagRepository interface {
|
||||
BaseRepository[entity.Tag]
|
||||
FindByName(name string) (*entity.Tag, error)
|
||||
FindByNameIncludingDeleted(name string) (*entity.Tag, error)
|
||||
FindWithResources() ([]entity.Tag, error)
|
||||
FindByCategoryID(categoryID uint) ([]entity.Tag, error)
|
||||
FindByCategoryIDPaginated(categoryID uint, page, pageSize int) ([]entity.Tag, int64, error)
|
||||
GetResourceCount(tagID uint) (int64, error)
|
||||
FindByResourceID(resourceID uint) ([]entity.Tag, error)
|
||||
FindWithPagination(page, pageSize int) ([]entity.Tag, int64, error)
|
||||
FindWithPaginationOrderByResourceCount(page, pageSize int) ([]entity.Tag, int64, error)
|
||||
Search(query string, page, pageSize int) ([]entity.Tag, int64, error)
|
||||
SearchOrderByResourceCount(query string, page, pageSize int) ([]entity.Tag, int64, error)
|
||||
UpdateWithNulls(tag *entity.Tag) error
|
||||
GetByID(id uint) (*entity.Tag, error)
|
||||
RestoreDeletedTag(id uint) error
|
||||
}
|
||||
|
||||
// TagRepositoryImpl Tag的Repository实现
|
||||
@@ -43,6 +47,16 @@ func (r *TagRepositoryImpl) FindByName(name string) (*entity.Tag, error) {
|
||||
return &tag, nil
|
||||
}
|
||||
|
||||
// FindByNameIncludingDeleted 根据名称查找(包括已删除的记录)
|
||||
func (r *TagRepositoryImpl) FindByNameIncludingDeleted(name string) (*entity.Tag, error) {
|
||||
var tag entity.Tag
|
||||
err := r.db.Unscoped().Where("name = ?", name).First(&tag).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tag, nil
|
||||
}
|
||||
|
||||
// FindWithResources 查找包含资源的标签
|
||||
func (r *TagRepositoryImpl) FindWithResources() ([]entity.Tag, error) {
|
||||
var tags []entity.Tag
|
||||
@@ -155,3 +169,76 @@ func (r *TagRepositoryImpl) GetByID(id uint) (*entity.Tag, error) {
|
||||
}
|
||||
return &tag, nil
|
||||
}
|
||||
|
||||
// RestoreDeletedTag 恢复已删除的标签
|
||||
func (r *TagRepositoryImpl) RestoreDeletedTag(id uint) error {
|
||||
return r.db.Unscoped().Model(&entity.Tag{}).Where("id = ?", id).Update("deleted_at", nil).Error
|
||||
}
|
||||
|
||||
// FindWithPaginationOrderByResourceCount 按资源数量排序的分页查询
|
||||
func (r *TagRepositoryImpl) FindWithPaginationOrderByResourceCount(page, pageSize int) ([]entity.Tag, int64, error) {
|
||||
var tags []entity.Tag
|
||||
var total int64
|
||||
|
||||
// 获取总数
|
||||
err := r.db.Model(&entity.Tag{}).Count(&total).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 使用子查询统计每个标签的资源数量并排序
|
||||
offset := (page - 1) * pageSize
|
||||
err = r.db.Preload("Category").
|
||||
Select("tags.*, COALESCE(resource_counts.count, 0) as resource_count").
|
||||
Joins(`LEFT JOIN (
|
||||
SELECT rt.tag_id, COUNT(rt.resource_id) as count
|
||||
FROM resource_tags rt
|
||||
INNER JOIN resources r ON rt.resource_id = r.id AND r.deleted_at IS NULL
|
||||
GROUP BY rt.tag_id
|
||||
) as resource_counts ON tags.id = resource_counts.tag_id`).
|
||||
Order("COALESCE(resource_counts.count, 0) DESC, tags.created_at DESC").
|
||||
Offset(offset).Limit(pageSize).
|
||||
Find(&tags).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return tags, total, nil
|
||||
}
|
||||
|
||||
// SearchOrderByResourceCount 按资源数量排序的搜索
|
||||
func (r *TagRepositoryImpl) SearchOrderByResourceCount(query string, page, pageSize int) ([]entity.Tag, int64, error) {
|
||||
var tags []entity.Tag
|
||||
var total int64
|
||||
|
||||
// 构建搜索条件
|
||||
searchQuery := "%" + query + "%"
|
||||
|
||||
// 获取总数
|
||||
err := r.db.Model(&entity.Tag{}).Where("name ILIKE ? OR description ILIKE ?", searchQuery, searchQuery).Count(&total).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 使用子查询统计每个标签的资源数量并排序
|
||||
offset := (page - 1) * pageSize
|
||||
err = r.db.Preload("Category").
|
||||
Select("tags.*, COALESCE(resource_counts.count, 0) as resource_count").
|
||||
Joins(`LEFT JOIN (
|
||||
SELECT rt.tag_id, COUNT(rt.resource_id) as count
|
||||
FROM resource_tags rt
|
||||
INNER JOIN resources r ON rt.resource_id = r.id AND r.deleted_at IS NULL
|
||||
GROUP BY rt.tag_id
|
||||
) as resource_counts ON tags.id = resource_counts.tag_id`).
|
||||
Where("tags.name ILIKE ? OR tags.description ILIKE ?", searchQuery, searchQuery).
|
||||
Order("COALESCE(resource_counts.count, 0) DESC, tags.created_at DESC").
|
||||
Offset(offset).Limit(pageSize).
|
||||
Find(&tags).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return tags, total, nil
|
||||
}
|
||||
|
||||
145
db/repo/task_item_repository.go
Normal file
145
db/repo/task_item_repository.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TaskItemRepository 任务项仓库接口
|
||||
type TaskItemRepository interface {
|
||||
GetByID(id uint) (*entity.TaskItem, error)
|
||||
Create(item *entity.TaskItem) error
|
||||
Delete(id uint) error
|
||||
DeleteByTaskID(taskID uint) error
|
||||
GetByTaskIDAndStatus(taskID uint, status string) ([]*entity.TaskItem, error)
|
||||
GetListByTaskID(taskID uint, page, pageSize int, status string) ([]*entity.TaskItem, int64, error)
|
||||
UpdateStatus(id uint, status string) error
|
||||
UpdateStatusAndOutput(id uint, status, outputData string) error
|
||||
GetStatsByTaskID(taskID uint) (map[string]int, error)
|
||||
ResetProcessingItems(taskID uint) error
|
||||
}
|
||||
|
||||
// TaskItemRepositoryImpl 任务项仓库实现
|
||||
type TaskItemRepositoryImpl struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewTaskItemRepository 创建任务项仓库
|
||||
func NewTaskItemRepository(db *gorm.DB) TaskItemRepository {
|
||||
return &TaskItemRepositoryImpl{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// GetByID 根据ID获取任务项
|
||||
func (r *TaskItemRepositoryImpl) GetByID(id uint) (*entity.TaskItem, error) {
|
||||
var item entity.TaskItem
|
||||
err := r.db.First(&item, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
// Create 创建任务项
|
||||
func (r *TaskItemRepositoryImpl) Create(item *entity.TaskItem) error {
|
||||
return r.db.Create(item).Error
|
||||
}
|
||||
|
||||
// Delete 删除任务项
|
||||
func (r *TaskItemRepositoryImpl) Delete(id uint) error {
|
||||
return r.db.Delete(&entity.TaskItem{}, id).Error
|
||||
}
|
||||
|
||||
// DeleteByTaskID 根据任务ID删除所有任务项
|
||||
func (r *TaskItemRepositoryImpl) DeleteByTaskID(taskID uint) error {
|
||||
return r.db.Where("task_id = ?", taskID).Delete(&entity.TaskItem{}).Error
|
||||
}
|
||||
|
||||
// GetByTaskIDAndStatus 根据任务ID和状态获取任务项
|
||||
func (r *TaskItemRepositoryImpl) GetByTaskIDAndStatus(taskID uint, status string) ([]*entity.TaskItem, error) {
|
||||
var items []*entity.TaskItem
|
||||
err := r.db.Where("task_id = ? AND status = ?", taskID, status).Order("id ASC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// GetListByTaskID 根据任务ID分页获取任务项
|
||||
func (r *TaskItemRepositoryImpl) GetListByTaskID(taskID uint, page, pageSize int, status string) ([]*entity.TaskItem, int64, error) {
|
||||
var items []*entity.TaskItem
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&entity.TaskItem{}).Where("task_id = ?", taskID)
|
||||
|
||||
// 添加状态过滤
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
err := query.Count(&total).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
offset := (page - 1) * pageSize
|
||||
err = query.Offset(offset).Limit(pageSize).Order("id ASC").Find(&items).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// UpdateStatus 更新任务项状态
|
||||
func (r *TaskItemRepositoryImpl) UpdateStatus(id uint, status string) error {
|
||||
return r.db.Model(&entity.TaskItem{}).Where("id = ?", id).Update("status", status).Error
|
||||
}
|
||||
|
||||
// UpdateStatusAndOutput 更新任务项状态和输出数据
|
||||
func (r *TaskItemRepositoryImpl) UpdateStatusAndOutput(id uint, status, outputData string) error {
|
||||
return r.db.Model(&entity.TaskItem{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"output_data": outputData,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// GetStatsByTaskID 获取任务项统计信息
|
||||
func (r *TaskItemRepositoryImpl) GetStatsByTaskID(taskID uint) (map[string]int, error) {
|
||||
var results []struct {
|
||||
Status string
|
||||
Count int
|
||||
}
|
||||
|
||||
err := r.db.Model(&entity.TaskItem{}).
|
||||
Select("status, count(*) as count").
|
||||
Where("task_id = ?", taskID).
|
||||
Group("status").
|
||||
Find(&results).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stats := map[string]int{
|
||||
"total": 0,
|
||||
"pending": 0,
|
||||
"processing": 0,
|
||||
"completed": 0,
|
||||
"failed": 0,
|
||||
}
|
||||
|
||||
for _, result := range results {
|
||||
stats[result.Status] = result.Count
|
||||
stats["total"] += result.Count
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// ResetProcessingItems 重置处理中的任务项为pending状态
|
||||
func (r *TaskItemRepositoryImpl) ResetProcessingItems(taskID uint) error {
|
||||
return r.db.Model(&entity.TaskItem{}).
|
||||
Where("task_id = ? AND status = ?", taskID, "processing").
|
||||
Update("status", "pending").Error
|
||||
}
|
||||
136
db/repo/task_repository.go
Normal file
136
db/repo/task_repository.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TaskRepository 任务仓库接口
|
||||
type TaskRepository interface {
|
||||
GetByID(id uint) (*entity.Task, error)
|
||||
Create(task *entity.Task) error
|
||||
Delete(id uint) error
|
||||
GetList(page, pageSize int, taskType, status string) ([]*entity.Task, int64, error)
|
||||
UpdateStatus(id uint, status string) error
|
||||
UpdateProgress(id uint, progress float64, progressData string) error
|
||||
UpdateStatusAndMessage(id uint, status, message string) error
|
||||
UpdateTaskStats(id uint, processed, success, failed int) error
|
||||
}
|
||||
|
||||
// TaskRepositoryImpl 任务仓库实现
|
||||
type TaskRepositoryImpl struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewTaskRepository 创建任务仓库
|
||||
func NewTaskRepository(db *gorm.DB) TaskRepository {
|
||||
return &TaskRepositoryImpl{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// GetByID 根据ID获取任务
|
||||
func (r *TaskRepositoryImpl) GetByID(id uint) (*entity.Task, error) {
|
||||
var task entity.Task
|
||||
err := r.db.First(&task, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
// Create 创建任务
|
||||
func (r *TaskRepositoryImpl) Create(task *entity.Task) error {
|
||||
return r.db.Create(task).Error
|
||||
}
|
||||
|
||||
// Delete 删除任务
|
||||
func (r *TaskRepositoryImpl) Delete(id uint) error {
|
||||
return r.db.Delete(&entity.Task{}, id).Error
|
||||
}
|
||||
|
||||
// GetList 获取任务列表
|
||||
func (r *TaskRepositoryImpl) GetList(page, pageSize int, taskType, status string) ([]*entity.Task, int64, error) {
|
||||
var tasks []*entity.Task
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&entity.Task{})
|
||||
|
||||
// 添加过滤条件
|
||||
if taskType != "" {
|
||||
query = query.Where("task_type = ?", taskType)
|
||||
}
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
err := query.Count(&total).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
offset := (page - 1) * pageSize
|
||||
err = query.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&tasks).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return tasks, total, nil
|
||||
}
|
||||
|
||||
// UpdateStatus 更新任务状态
|
||||
func (r *TaskRepositoryImpl) UpdateStatus(id uint, status string) error {
|
||||
return r.db.Model(&entity.Task{}).Where("id = ?", id).Update("status", status).Error
|
||||
}
|
||||
|
||||
// UpdateProgress 更新任务进度
|
||||
func (r *TaskRepositoryImpl) UpdateProgress(id uint, progress float64, progressData string) error {
|
||||
// 检查progress和progress_data字段是否存在
|
||||
var count int64
|
||||
err := r.db.Raw("SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'tasks' AND column_name = 'progress'").Count(&count).Error
|
||||
if err != nil || count == 0 {
|
||||
// 如果检查失败或字段不存在,只更新processed_items等现有字段
|
||||
return r.db.Model(&entity.Task{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"processed_items": progress, // 使用progress作为processed_items的近似值
|
||||
}).Error
|
||||
}
|
||||
|
||||
// 字段存在,正常更新
|
||||
return r.db.Model(&entity.Task{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"progress": progress,
|
||||
"progress_data": progressData,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// UpdateStatusAndMessage 更新任务状态和消息
|
||||
func (r *TaskRepositoryImpl) UpdateStatusAndMessage(id uint, status, message string) error {
|
||||
// 检查message字段是否存在
|
||||
var count int64
|
||||
err := r.db.Raw("SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'tasks' AND column_name = 'message'").Count(&count).Error
|
||||
if err != nil {
|
||||
// 如果检查失败,只更新状态
|
||||
return r.db.Model(&entity.Task{}).Where("id = ?", id).Update("status", status).Error
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
// message字段存在,更新状态和消息
|
||||
return r.db.Model(&entity.Task{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"message": message,
|
||||
}).Error
|
||||
} else {
|
||||
// message字段不存在,只更新状态
|
||||
return r.db.Model(&entity.Task{}).Where("id = ?", id).Update("status", status).Error
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateTaskStats 更新任务统计信息
|
||||
func (r *TaskRepositoryImpl) UpdateTaskStats(id uint, processed, success, failed int) error {
|
||||
return r.db.Model(&entity.Task{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"processed_items": processed,
|
||||
"success_items": success,
|
||||
"failed_items": failed,
|
||||
}).Error
|
||||
}
|
||||
@@ -20,7 +20,7 @@ services:
|
||||
- app-network
|
||||
|
||||
backend:
|
||||
image: ctwj/urldb-backend:1.0.9
|
||||
image: ctwj/urldb-backend:1.2.0
|
||||
environment:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
@@ -38,7 +38,7 @@ services:
|
||||
- app-network
|
||||
|
||||
frontend:
|
||||
image: ctwj/urldb-frontend:1.0.9
|
||||
image: ctwj/urldb-frontend:1.2.0
|
||||
environment:
|
||||
NUXT_PUBLIC_API_SERVER: http://backend:8080/api
|
||||
NUXT_PUBLIC_API_CLIENT: /api
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// docsify 配置文件
|
||||
window.$docsify = {
|
||||
name: 'URL数据库管理系统',
|
||||
name: '老九网盘链接数据库',
|
||||
repo: 'https://github.com/ctwj/urldb',
|
||||
loadSidebar: true,
|
||||
loadSidebar: '_sidebar.md',
|
||||
subMaxLevel: 3,
|
||||
auto2top: true,
|
||||
// 添加侧边栏配置
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="//cdn.jsdelivr.net/npm/docsify@4"></script>
|
||||
<script src="./docsify.config.js"></script>
|
||||
<script src="docsify.config.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/docsify@4/lib/plugins/search.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/docsify@4/lib/plugins/copy-code.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/docsify@4/lib/plugins/pagination.min.js"></script>
|
||||
|
||||
BIN
github/account.webp
Normal file
BIN
github/account.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
github/admin.webp
Normal file
BIN
github/admin.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
github/config.webp
Normal file
BIN
github/config.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
BIN
github/index.webp
Normal file
BIN
github/index.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
BIN
github/save.webp
Normal file
BIN
github/save.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/ctwj/urldb/db/converter"
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -18,6 +19,8 @@ func GetCategories(c *gin.Context) {
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
search := c.Query("search")
|
||||
|
||||
utils.Debug("获取分类列表 - 分页参数: page=%d, pageSize=%d, search=%s", page, pageSize, search)
|
||||
|
||||
var categories []entity.Category
|
||||
var total int64
|
||||
var err error
|
||||
@@ -35,6 +38,8 @@ func GetCategories(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.Debug("查询到分类数量: %d, 总数: %d", len(categories), total)
|
||||
|
||||
// 获取每个分类的资源数量和标签名称
|
||||
resourceCounts := make(map[uint]int64)
|
||||
tagNamesMap := make(map[uint][]string)
|
||||
@@ -73,12 +78,50 @@ func CreateCategory(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 首先检查是否存在已删除的同名分类
|
||||
deletedCategory, err := repoManager.CategoryRepository.FindByNameIncludingDeleted(req.Name)
|
||||
if err == nil && deletedCategory.DeletedAt.Valid {
|
||||
utils.Debug("找到已删除的分类: ID=%d, Name=%s", deletedCategory.ID, deletedCategory.Name)
|
||||
|
||||
// 如果存在已删除的同名分类,则恢复它
|
||||
err = repoManager.CategoryRepository.RestoreDeletedCategory(deletedCategory.ID)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "恢复已删除分类失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
utils.Debug("分类恢复成功: ID=%d", deletedCategory.ID)
|
||||
|
||||
// 重新获取恢复后的分类
|
||||
restoredCategory, err := repoManager.CategoryRepository.FindByID(deletedCategory.ID)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取恢复的分类失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
utils.Debug("重新获取到恢复的分类: ID=%d, Name=%s", restoredCategory.ID, restoredCategory.Name)
|
||||
|
||||
// 更新分类信息
|
||||
restoredCategory.Description = req.Description
|
||||
err = repoManager.CategoryRepository.Update(restoredCategory)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "更新恢复的分类失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
utils.Debug("分类信息更新成功: ID=%d, Description=%s", restoredCategory.ID, restoredCategory.Description)
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"message": "分类恢复成功",
|
||||
"category": converter.ToCategoryResponse(restoredCategory, 0, []string{}),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 如果不存在已删除的同名分类,则创建新分类
|
||||
category := &entity.Category{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
}
|
||||
|
||||
err := repoManager.CategoryRepository.Create(category)
|
||||
err = repoManager.CategoryRepository.Create(category)
|
||||
if err != nil {
|
||||
ErrorResponse(c, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
@@ -17,6 +18,56 @@ func NewPublicAPIHandler() *PublicAPIHandler {
|
||||
return &PublicAPIHandler{}
|
||||
}
|
||||
|
||||
// filterForbiddenWords 过滤包含违禁词的资源
|
||||
func (h *PublicAPIHandler) filterForbiddenWords(resources []entity.Resource) ([]entity.Resource, []string) {
|
||||
// 获取违禁词配置
|
||||
forbiddenWords, err := repoManager.SystemConfigRepository.GetConfigValue(entity.ConfigKeyForbiddenWords)
|
||||
if err != nil {
|
||||
// 如果获取失败,返回原资源列表
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
if forbiddenWords == "" {
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
// 分割违禁词
|
||||
words := strings.Split(forbiddenWords, ",")
|
||||
var filteredResources []entity.Resource
|
||||
var foundForbiddenWords []string
|
||||
|
||||
for _, resource := range resources {
|
||||
shouldSkip := false
|
||||
title := strings.ToLower(resource.Title)
|
||||
description := strings.ToLower(resource.Description)
|
||||
|
||||
for _, word := range words {
|
||||
word = strings.TrimSpace(word)
|
||||
if word != "" && (strings.Contains(title, strings.ToLower(word)) || strings.Contains(description, strings.ToLower(word))) {
|
||||
foundForbiddenWords = append(foundForbiddenWords, word)
|
||||
shouldSkip = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !shouldSkip {
|
||||
filteredResources = append(filteredResources, resource)
|
||||
}
|
||||
}
|
||||
|
||||
// 去重违禁词
|
||||
uniqueForbiddenWords := make([]string, 0)
|
||||
wordMap := make(map[string]bool)
|
||||
for _, word := range foundForbiddenWords {
|
||||
if !wordMap[word] {
|
||||
wordMap[word] = true
|
||||
uniqueForbiddenWords = append(uniqueForbiddenWords, word)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredResources, uniqueForbiddenWords
|
||||
}
|
||||
|
||||
// AddBatchResources godoc
|
||||
// @Summary 批量添加资源
|
||||
// @Description 通过公开API批量添加多个资源到待处理列表
|
||||
@@ -112,7 +163,7 @@ func (h *PublicAPIHandler) AddBatchResources(c *gin.Context) {
|
||||
|
||||
// SearchResources godoc
|
||||
// @Summary 资源搜索
|
||||
// @Description 搜索资源,支持关键词、标签、分类过滤
|
||||
// @Description 搜索资源,支持关键词、标签、分类过滤,自动过滤包含违禁词的资源
|
||||
// @Tags PublicAPI
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -122,7 +173,7 @@ func (h *PublicAPIHandler) AddBatchResources(c *gin.Context) {
|
||||
// @Param category query string false "分类过滤"
|
||||
// @Param page query int false "页码" default(1)
|
||||
// @Param page_size query int false "每页数量" default(20) maximum(100)
|
||||
// @Success 200 {object} map[string]interface{} "搜索成功"
|
||||
// @Success 200 {object} map[string]interface{} "搜索成功,如果存在违禁词过滤会返回forbidden_words_filtered字段"
|
||||
// @Failure 401 {object} map[string]interface{} "认证失败"
|
||||
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
|
||||
// @Router /api/public/resources/search [get]
|
||||
@@ -169,9 +220,15 @@ func (h *PublicAPIHandler) SearchResources(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 过滤违禁词
|
||||
filteredResources, foundForbiddenWords := h.filterForbiddenWords(resources)
|
||||
|
||||
// 计算过滤后的总数
|
||||
filteredTotal := len(filteredResources)
|
||||
|
||||
// 转换为响应格式
|
||||
var resourceResponses []gin.H
|
||||
for _, resource := range resources {
|
||||
for _, resource := range filteredResources {
|
||||
resourceResponses = append(resourceResponses, gin.H{
|
||||
"id": resource.ID,
|
||||
"title": resource.Title,
|
||||
@@ -183,12 +240,23 @@ func (h *PublicAPIHandler) SearchResources(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
// 构建响应数据
|
||||
responseData := gin.H{
|
||||
"list": resourceResponses,
|
||||
"total": total,
|
||||
"total": filteredTotal,
|
||||
"page": page,
|
||||
"limit": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// 如果存在违禁词过滤,添加提醒字段
|
||||
if len(foundForbiddenWords) > 0 {
|
||||
responseData["forbidden_words_filtered"] = true
|
||||
responseData["filtered_forbidden_words"] = foundForbiddenWords
|
||||
responseData["original_total"] = total
|
||||
responseData["filtered_count"] = total - int64(filteredTotal)
|
||||
}
|
||||
|
||||
SuccessResponse(c, responseData)
|
||||
}
|
||||
|
||||
// GetHotDramas godoc
|
||||
|
||||
@@ -316,6 +316,7 @@ func GetReadyResourcesWithErrors(c *gin.Context) {
|
||||
// 获取分页参数
|
||||
pageStr := c.DefaultQuery("page", "1")
|
||||
pageSizeStr := c.DefaultQuery("page_size", "100")
|
||||
errorFilter := c.Query("error_filter")
|
||||
|
||||
page, err := strconv.Atoi(pageStr)
|
||||
if err != nil || page < 1 {
|
||||
@@ -327,8 +328,8 @@ func GetReadyResourcesWithErrors(c *gin.Context) {
|
||||
pageSize = 100
|
||||
}
|
||||
|
||||
// 获取有错误的资源
|
||||
resources, err := repoManager.ReadyResourceRepository.FindWithErrors()
|
||||
// 获取有错误的资源(分页,包括软删除的)
|
||||
resources, total, err := repoManager.ReadyResourceRepository.FindWithErrorsPaginatedIncludingDeleted(page, pageSize, errorFilter)
|
||||
if err != nil {
|
||||
ErrorResponse(c, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -385,7 +386,7 @@ func GetReadyResourcesWithErrors(c *gin.Context) {
|
||||
"data": responses,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": len(resources),
|
||||
"total": total,
|
||||
"count": len(resources),
|
||||
"error_stats": errorTypeStats,
|
||||
"retryable_count": getRetryableErrorCount(resources),
|
||||
@@ -465,3 +466,109 @@ func RetryFailedResources(c *gin.Context) {
|
||||
"retryable_count": getRetryableErrorCount(resources),
|
||||
})
|
||||
}
|
||||
|
||||
// BatchRestoreToReadyPool 批量将失败资源重新放入待处理池
|
||||
func BatchRestoreToReadyPool(c *gin.Context) {
|
||||
var req struct {
|
||||
IDs []uint `json:"ids" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, "请求参数错误: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.IDs) == 0 {
|
||||
ErrorResponse(c, "资源ID列表不能为空", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
failedCount := 0
|
||||
|
||||
for _, id := range req.IDs {
|
||||
// 清除错误信息并恢复软删除的资源
|
||||
err := repoManager.ReadyResourceRepository.ClearErrorMsgAndRestore(id)
|
||||
if err != nil {
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
successCount++
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"message": "批量重新放入待处理池操作完成",
|
||||
"total_count": len(req.IDs),
|
||||
"success_count": successCount,
|
||||
"failed_count": failedCount,
|
||||
})
|
||||
}
|
||||
|
||||
// BatchRestoreToReadyPoolByQuery 根据查询条件批量将失败资源重新放入待处理池
|
||||
func BatchRestoreToReadyPoolByQuery(c *gin.Context) {
|
||||
var req struct {
|
||||
ErrorFilter string `json:"error_filter"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, "请求参数错误: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 根据查询条件获取所有符合条件的资源
|
||||
resources, err := repoManager.ReadyResourceRepository.FindWithErrorsByQuery(req.ErrorFilter)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "查询资源失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(resources) == 0 {
|
||||
SuccessResponse(c, gin.H{
|
||||
"message": "没有找到符合条件的资源",
|
||||
"total_count": 0,
|
||||
"success_count": 0,
|
||||
"failed_count": 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
failedCount := 0
|
||||
for _, resource := range resources {
|
||||
err := repoManager.ReadyResourceRepository.ClearErrorMsgAndRestore(resource.ID)
|
||||
if err != nil {
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
successCount++
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"message": "批量重新放入待处理池操作完成",
|
||||
"total_count": len(resources),
|
||||
"success_count": successCount,
|
||||
"failed_count": failedCount,
|
||||
})
|
||||
}
|
||||
|
||||
// ClearAllErrorsByQuery 根据查询条件批量清除错误信息并删除资源
|
||||
func ClearAllErrorsByQuery(c *gin.Context) {
|
||||
var req struct {
|
||||
ErrorFilter string `json:"error_filter"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, "请求参数错误: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 根据查询条件批量删除失败资源
|
||||
affectedRows, err := repoManager.ReadyResourceRepository.ClearAllErrorsByQuery(req.ErrorFilter)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "批量删除失败资源失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"message": "批量删除失败资源操作完成",
|
||||
"affected_rows": affectedRows,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
pan "github.com/ctwj/urldb/common"
|
||||
commonutils "github.com/ctwj/urldb/common/utils"
|
||||
"github.com/ctwj/urldb/db/converter"
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -16,6 +20,8 @@ func GetResources(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
utils.Info("资源列表请求 - page: %d, pageSize: %d", page, pageSize)
|
||||
|
||||
params := map[string]interface{}{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
@@ -30,10 +36,29 @@ func GetResources(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if categoryID := c.Query("category_id"); categoryID != "" {
|
||||
utils.Info("收到分类ID参数: %s", categoryID)
|
||||
if id, err := strconv.ParseUint(categoryID, 10, 32); err == nil {
|
||||
params["category_id"] = uint(id)
|
||||
utils.Info("解析分类ID成功: %d", uint(id))
|
||||
} else {
|
||||
utils.Error("解析分类ID失败: %v", err)
|
||||
}
|
||||
}
|
||||
if hasSaveURL := c.Query("has_save_url"); hasSaveURL != "" {
|
||||
if hasSaveURL == "true" {
|
||||
params["has_save_url"] = true
|
||||
} else if hasSaveURL == "false" {
|
||||
params["has_save_url"] = false
|
||||
}
|
||||
}
|
||||
if noSaveURL := c.Query("no_save_url"); noSaveURL != "" {
|
||||
if noSaveURL == "true" {
|
||||
params["no_save_url"] = true
|
||||
}
|
||||
}
|
||||
if panName := c.Query("pan_name"); panName != "" {
|
||||
params["pan_name"] = panName
|
||||
}
|
||||
|
||||
resources, total, err := repoManager.ResourceRepository.SearchWithFilters(params)
|
||||
|
||||
@@ -285,11 +310,23 @@ func IncrementResourceViewCount(c *gin.Context) {
|
||||
ErrorResponse(c, "无效的资源ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 增加资源访问量
|
||||
err = repoManager.ResourceRepository.IncrementViewCount(uint(id))
|
||||
if err != nil {
|
||||
ErrorResponse(c, "增加浏览次数失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 记录访问记录
|
||||
ipAddress := c.ClientIP()
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
err = repoManager.ResourceViewRepository.RecordView(uint(id), ipAddress, userAgent)
|
||||
if err != nil {
|
||||
// 记录访问失败不影响主要功能,只记录日志
|
||||
utils.Error("记录资源访问失败: %v", err)
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{"message": "浏览次数+1"})
|
||||
}
|
||||
|
||||
@@ -310,3 +347,305 @@ func BatchDeleteResources(c *gin.Context) {
|
||||
}
|
||||
SuccessResponse(c, gin.H{"deleted": count, "message": "批量删除成功"})
|
||||
}
|
||||
|
||||
// GetResourceLink 获取资源链接(智能转存)
|
||||
func GetResourceLink(c *gin.Context) {
|
||||
// 获取资源ID
|
||||
resourceIDStr := c.Param("id")
|
||||
resourceID, err := strconv.ParseUint(resourceIDStr, 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的资源ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("获取资源链接请求 - resourceID: %d", resourceID)
|
||||
|
||||
// 查询资源信息
|
||||
resource, err := repoManager.ResourceRepository.FindByID(uint(resourceID))
|
||||
if err != nil {
|
||||
utils.Error("查询资源失败: %v", err)
|
||||
ErrorResponse(c, "资源不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// 查询平台信息
|
||||
var panInfo entity.Pan
|
||||
if resource.PanID != nil {
|
||||
panPtr, err := repoManager.PanRepository.FindByID(*resource.PanID)
|
||||
if err != nil {
|
||||
utils.Error("查询平台信息失败: %v", err)
|
||||
} else if panPtr != nil {
|
||||
panInfo = *panPtr
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info("资源信息 - 平台: %s, 原始链接: %s, 转存链接: %s", panInfo.Name, resource.URL, resource.SaveURL)
|
||||
|
||||
// 统计访问次数
|
||||
err = repoManager.ResourceRepository.IncrementViewCount(uint(resourceID))
|
||||
if err != nil {
|
||||
utils.Error("增加资源访问量失败: %v", err)
|
||||
}
|
||||
|
||||
// 记录访问记录
|
||||
ipAddress := c.ClientIP()
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
err = repoManager.ResourceViewRepository.RecordView(uint(resourceID), ipAddress, userAgent)
|
||||
if err != nil {
|
||||
utils.Error("记录资源访问失败: %v", err)
|
||||
}
|
||||
|
||||
// 如果不是夸克网盘,直接返回原链接
|
||||
if panInfo.Name != "quark" {
|
||||
utils.Info("非夸克资源,直接返回原链接")
|
||||
SuccessResponse(c, gin.H{
|
||||
"url": resource.URL,
|
||||
"type": "original",
|
||||
"platform": panInfo.Remark,
|
||||
"resource_id": resource.ID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 夸克资源处理逻辑
|
||||
utils.Info("夸克资源处理开始")
|
||||
|
||||
// 如果已存在转存链接,直接返回
|
||||
if resource.SaveURL != "" {
|
||||
utils.Info("已存在转存链接,直接返回: %s", resource.SaveURL)
|
||||
SuccessResponse(c, gin.H{
|
||||
"url": resource.SaveURL,
|
||||
"type": "transferred",
|
||||
"platform": panInfo.Remark,
|
||||
"resource_id": resource.ID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否开启自动转存
|
||||
autoTransferEnabled, err := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoTransferEnabled)
|
||||
if err != nil {
|
||||
utils.Error("获取自动转存配置失败: %v", err)
|
||||
// 配置获取失败,返回原链接
|
||||
SuccessResponse(c, gin.H{
|
||||
"url": resource.URL,
|
||||
"type": "original",
|
||||
"platform": panInfo.Remark,
|
||||
"resource_id": resource.ID,
|
||||
"message": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !autoTransferEnabled {
|
||||
utils.Info("自动转存功能未开启,返回原链接")
|
||||
SuccessResponse(c, gin.H{
|
||||
"url": resource.URL,
|
||||
"type": "original",
|
||||
"platform": panInfo.Remark,
|
||||
"resource_id": resource.ID,
|
||||
"message": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 执行自动转存
|
||||
utils.Info("开始执行自动转存")
|
||||
transferResult := performAutoTransfer(resource)
|
||||
|
||||
if transferResult.Success {
|
||||
utils.Info("自动转存成功,返回转存链接: %s", transferResult.SaveURL)
|
||||
SuccessResponse(c, gin.H{
|
||||
"url": transferResult.SaveURL,
|
||||
"type": "transferred",
|
||||
"platform": panInfo.Remark,
|
||||
"resource_id": resource.ID,
|
||||
"message": "资源易和谐,请及时用手机夸克扫码转存",
|
||||
})
|
||||
} else {
|
||||
utils.Error("自动转存失败: %s", transferResult.ErrorMsg)
|
||||
SuccessResponse(c, gin.H{
|
||||
"url": resource.URL,
|
||||
"type": "original",
|
||||
"platform": panInfo.Remark,
|
||||
"resource_id": resource.ID,
|
||||
"message": "",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TransferResult 转存结果
|
||||
type TransferResult struct {
|
||||
Success bool `json:"success"`
|
||||
SaveURL string `json:"save_url"`
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
}
|
||||
|
||||
// performAutoTransfer 执行自动转存
|
||||
func performAutoTransfer(resource *entity.Resource) TransferResult {
|
||||
utils.Info("开始执行资源转存 - ID: %d, URL: %s", resource.ID, resource.URL)
|
||||
|
||||
// 获取夸克平台ID
|
||||
quarkPanID, err := getQuarkPanID()
|
||||
if err != nil {
|
||||
utils.Error("获取夸克平台ID失败: %v", err)
|
||||
return TransferResult{
|
||||
Success: false,
|
||||
ErrorMsg: fmt.Sprintf("获取夸克平台ID失败: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
// 获取可用的夸克账号
|
||||
accounts, err := repoManager.CksRepository.FindAll()
|
||||
if err != nil {
|
||||
utils.Error("获取网盘账号失败: %v", err)
|
||||
return TransferResult{
|
||||
Success: false,
|
||||
ErrorMsg: fmt.Sprintf("获取网盘账号失败: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
// 获取最小存储空间配置
|
||||
autoTransferMinSpace, err := repoManager.SystemConfigRepository.GetConfigInt(entity.ConfigKeyAutoTransferMinSpace)
|
||||
if err != nil {
|
||||
utils.Error("获取最小存储空间配置失败: %v", err)
|
||||
autoTransferMinSpace = 5 // 默认5GB
|
||||
}
|
||||
|
||||
// 过滤:只保留已激活、夸克平台、剩余空间足够的账号
|
||||
minSpaceBytes := int64(autoTransferMinSpace) * 1024 * 1024 * 1024
|
||||
var validAccounts []entity.Cks
|
||||
for _, acc := range accounts {
|
||||
if acc.IsValid && acc.PanID == quarkPanID && acc.LeftSpace >= minSpaceBytes {
|
||||
validAccounts = append(validAccounts, acc)
|
||||
}
|
||||
}
|
||||
|
||||
if len(validAccounts) == 0 {
|
||||
utils.Info("没有可用的夸克网盘账号")
|
||||
return TransferResult{
|
||||
Success: false,
|
||||
ErrorMsg: "没有可用的夸克网盘账号",
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info("找到 %d 个可用夸克网盘账号,开始转存处理...", len(validAccounts))
|
||||
|
||||
// 使用第一个可用账号进行转存
|
||||
account := validAccounts[0]
|
||||
|
||||
// 创建网盘服务工厂
|
||||
factory := pan.NewPanFactory()
|
||||
|
||||
// 执行转存
|
||||
result := transferSingleResource(resource, account, factory)
|
||||
|
||||
if result.Success {
|
||||
// 更新资源的转存信息
|
||||
resource.SaveURL = result.SaveURL
|
||||
resource.ErrorMsg = ""
|
||||
if err := repoManager.ResourceRepository.Update(resource); err != nil {
|
||||
utils.Error("更新资源转存信息失败: %v", err)
|
||||
}
|
||||
} else {
|
||||
// 更新错误信息
|
||||
resource.ErrorMsg = result.ErrorMsg
|
||||
if err := repoManager.ResourceRepository.Update(resource); err != nil {
|
||||
utils.Error("更新资源错误信息失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// transferSingleResource 转存单个资源
|
||||
func transferSingleResource(resource *entity.Resource, account entity.Cks, factory *pan.PanFactory) TransferResult {
|
||||
utils.Info("开始转存资源 - 资源ID: %d, 账号: %s", resource.ID, account.Username)
|
||||
|
||||
service, err := factory.CreatePanService(resource.URL, &pan.PanConfig{
|
||||
URL: resource.URL,
|
||||
ExpiredType: 0,
|
||||
IsType: 0,
|
||||
Cookie: account.Ck,
|
||||
})
|
||||
if err != nil {
|
||||
utils.Error("创建网盘服务失败: %v", err)
|
||||
return TransferResult{
|
||||
Success: false,
|
||||
ErrorMsg: fmt.Sprintf("创建网盘服务失败: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
// 提取分享ID
|
||||
shareID, _ := commonutils.ExtractShareIdString(resource.URL)
|
||||
if shareID == "" {
|
||||
return TransferResult{
|
||||
Success: false,
|
||||
ErrorMsg: "无效的分享链接",
|
||||
}
|
||||
}
|
||||
|
||||
// 执行转存
|
||||
transferResult, err := service.Transfer(shareID)
|
||||
if err != nil {
|
||||
utils.Error("转存失败: %v", err)
|
||||
return TransferResult{
|
||||
Success: false,
|
||||
ErrorMsg: fmt.Sprintf("转存失败: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
if transferResult == nil || !transferResult.Success {
|
||||
errMsg := "转存失败"
|
||||
if transferResult != nil && transferResult.Message != "" {
|
||||
errMsg = transferResult.Message
|
||||
}
|
||||
utils.Error("转存失败: %s", errMsg)
|
||||
return TransferResult{
|
||||
Success: false,
|
||||
ErrorMsg: errMsg,
|
||||
}
|
||||
}
|
||||
|
||||
// 提取转存链接
|
||||
var saveURL string
|
||||
if data, ok := transferResult.Data.(map[string]interface{}); ok {
|
||||
if v, ok := data["shareUrl"]; ok {
|
||||
saveURL, _ = v.(string)
|
||||
}
|
||||
}
|
||||
if saveURL == "" {
|
||||
saveURL = transferResult.ShareURL
|
||||
}
|
||||
|
||||
if saveURL == "" {
|
||||
return TransferResult{
|
||||
Success: false,
|
||||
ErrorMsg: "转存成功但未获取到分享链接",
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info("转存成功 - 资源ID: %d, 转存链接: %s", resource.ID, saveURL)
|
||||
|
||||
return TransferResult{
|
||||
Success: true,
|
||||
SaveURL: saveURL,
|
||||
}
|
||||
}
|
||||
|
||||
// getQuarkPanID 获取夸克网盘ID
|
||||
func getQuarkPanID() (uint, error) {
|
||||
// 通过FindAll方法查找所有平台,然后过滤出quark平台
|
||||
pans, err := repoManager.PanRepository.FindAll()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("查询平台信息失败: %v", err)
|
||||
}
|
||||
|
||||
for _, p := range pans {
|
||||
if p.Name == "quark" {
|
||||
return p.ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("未找到quark平台")
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/ctwj/urldb/utils"
|
||||
"github.com/ctwj/urldb/scheduler"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetSchedulerStatus 获取调度器状态
|
||||
func GetSchedulerStatus(c *gin.Context) {
|
||||
scheduler := utils.GetGlobalScheduler(
|
||||
scheduler := scheduler.GetGlobalScheduler(
|
||||
repoManager.HotDramaRepository,
|
||||
repoManager.ReadyResourceRepository,
|
||||
repoManager.ResourceRepository,
|
||||
@@ -31,7 +31,7 @@ func GetSchedulerStatus(c *gin.Context) {
|
||||
|
||||
// 启动热播剧定时任务
|
||||
func StartHotDramaScheduler(c *gin.Context) {
|
||||
scheduler := utils.GetGlobalScheduler(
|
||||
scheduler := scheduler.GetGlobalScheduler(
|
||||
repoManager.HotDramaRepository,
|
||||
repoManager.ReadyResourceRepository,
|
||||
repoManager.ResourceRepository,
|
||||
@@ -51,7 +51,7 @@ func StartHotDramaScheduler(c *gin.Context) {
|
||||
|
||||
// 停止热播剧定时任务
|
||||
func StopHotDramaScheduler(c *gin.Context) {
|
||||
scheduler := utils.GetGlobalScheduler(
|
||||
scheduler := scheduler.GetGlobalScheduler(
|
||||
repoManager.HotDramaRepository,
|
||||
repoManager.ReadyResourceRepository,
|
||||
repoManager.ResourceRepository,
|
||||
@@ -71,7 +71,7 @@ func StopHotDramaScheduler(c *gin.Context) {
|
||||
|
||||
// 手动触发热播剧定时任务
|
||||
func TriggerHotDramaScheduler(c *gin.Context) {
|
||||
scheduler := utils.GetGlobalScheduler(
|
||||
scheduler := scheduler.GetGlobalScheduler(
|
||||
repoManager.HotDramaRepository,
|
||||
repoManager.ReadyResourceRepository,
|
||||
repoManager.ResourceRepository,
|
||||
@@ -87,7 +87,7 @@ func TriggerHotDramaScheduler(c *gin.Context) {
|
||||
|
||||
// 手动获取热播剧名字
|
||||
func FetchHotDramaNames(c *gin.Context) {
|
||||
scheduler := utils.GetGlobalScheduler(
|
||||
scheduler := scheduler.GetGlobalScheduler(
|
||||
repoManager.HotDramaRepository,
|
||||
repoManager.ReadyResourceRepository,
|
||||
repoManager.ResourceRepository,
|
||||
@@ -107,7 +107,7 @@ func FetchHotDramaNames(c *gin.Context) {
|
||||
|
||||
// 启动待处理资源自动处理任务
|
||||
func StartReadyResourceScheduler(c *gin.Context) {
|
||||
scheduler := utils.GetGlobalScheduler(
|
||||
scheduler := scheduler.GetGlobalScheduler(
|
||||
repoManager.HotDramaRepository,
|
||||
repoManager.ReadyResourceRepository,
|
||||
repoManager.ResourceRepository,
|
||||
@@ -127,7 +127,7 @@ func StartReadyResourceScheduler(c *gin.Context) {
|
||||
|
||||
// 停止待处理资源自动处理任务
|
||||
func StopReadyResourceScheduler(c *gin.Context) {
|
||||
scheduler := utils.GetGlobalScheduler(
|
||||
scheduler := scheduler.GetGlobalScheduler(
|
||||
repoManager.HotDramaRepository,
|
||||
repoManager.ReadyResourceRepository,
|
||||
repoManager.ResourceRepository,
|
||||
@@ -147,7 +147,7 @@ func StopReadyResourceScheduler(c *gin.Context) {
|
||||
|
||||
// 手动触发待处理资源自动处理任务
|
||||
func TriggerReadyResourceScheduler(c *gin.Context) {
|
||||
scheduler := utils.GetGlobalScheduler(
|
||||
scheduler := scheduler.GetGlobalScheduler(
|
||||
repoManager.HotDramaRepository,
|
||||
repoManager.ReadyResourceRepository,
|
||||
repoManager.ResourceRepository,
|
||||
@@ -157,14 +157,13 @@ func TriggerReadyResourceScheduler(c *gin.Context) {
|
||||
repoManager.TagRepository,
|
||||
repoManager.CategoryRepository,
|
||||
)
|
||||
// 手动触发一次处理
|
||||
scheduler.ProcessReadyResources()
|
||||
scheduler.StartReadyResourceScheduler() // 直接启动一次
|
||||
SuccessResponse(c, gin.H{"message": "手动触发待处理资源自动处理任务成功"})
|
||||
}
|
||||
|
||||
// 启动自动转存定时任务
|
||||
func StartAutoTransferScheduler(c *gin.Context) {
|
||||
scheduler := utils.GetGlobalScheduler(
|
||||
scheduler := scheduler.GetGlobalScheduler(
|
||||
repoManager.HotDramaRepository,
|
||||
repoManager.ReadyResourceRepository,
|
||||
repoManager.ResourceRepository,
|
||||
@@ -184,7 +183,7 @@ func StartAutoTransferScheduler(c *gin.Context) {
|
||||
|
||||
// 停止自动转存定时任务
|
||||
func StopAutoTransferScheduler(c *gin.Context) {
|
||||
scheduler := utils.GetGlobalScheduler(
|
||||
scheduler := scheduler.GetGlobalScheduler(
|
||||
repoManager.HotDramaRepository,
|
||||
repoManager.ReadyResourceRepository,
|
||||
repoManager.ResourceRepository,
|
||||
@@ -204,7 +203,7 @@ func StopAutoTransferScheduler(c *gin.Context) {
|
||||
|
||||
// 手动触发自动转存定时任务
|
||||
func TriggerAutoTransferScheduler(c *gin.Context) {
|
||||
scheduler := utils.GetGlobalScheduler(
|
||||
scheduler := scheduler.GetGlobalScheduler(
|
||||
repoManager.HotDramaRepository,
|
||||
repoManager.ReadyResourceRepository,
|
||||
repoManager.ResourceRepository,
|
||||
@@ -214,7 +213,6 @@ func TriggerAutoTransferScheduler(c *gin.Context) {
|
||||
repoManager.TagRepository,
|
||||
repoManager.CategoryRepository,
|
||||
)
|
||||
// 手动触发一次处理
|
||||
scheduler.ProcessAutoTransfer()
|
||||
scheduler.StartAutoTransferScheduler() // 直接启动一次
|
||||
SuccessResponse(c, gin.H{"message": "手动触发自动转存定时任务成功"})
|
||||
}
|
||||
|
||||
@@ -37,7 +37,8 @@ func GetSearchStats(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
stats, total, err := repoManager.SearchStatRepository.FindWithPagination(page, pageSize)
|
||||
// 使用自定义方法获取按时间倒序排列的搜索记录
|
||||
stats, total, err := repoManager.SearchStatRepository.FindWithPaginationOrdered(page, pageSize)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取搜索统计失败", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -22,17 +22,44 @@ func GetStats(c *gin.Context) {
|
||||
db.DB.Model(&entity.Tag{}).Count(&totalTags)
|
||||
db.DB.Model(&entity.Resource{}).Select("COALESCE(SUM(view_count), 0)").Scan(&totalViews)
|
||||
|
||||
// 获取今日更新数量
|
||||
// 获取今日数据
|
||||
today := utils.GetTodayString()
|
||||
|
||||
// 今日新增资源数量
|
||||
var todayResources int64
|
||||
db.DB.Model(&entity.Resource{}).Where("DATE(created_at) = ?", today).Count(&todayResources)
|
||||
|
||||
// 今日更新资源数量(包括新增和修改)
|
||||
var todayUpdates int64
|
||||
today := utils.GetCurrentTime().Format("2006-01-02")
|
||||
db.DB.Model(&entity.Resource{}).Where("DATE(updated_at) = ?", today).Count(&todayUpdates)
|
||||
|
||||
// 今日浏览量 - 使用访问记录表统计今日访问量
|
||||
var todayViews int64
|
||||
todayViews, err := repoManager.ResourceViewRepository.GetTodayViews()
|
||||
if err != nil {
|
||||
utils.Error("获取今日访问量失败: %v", err)
|
||||
todayViews = 0
|
||||
}
|
||||
|
||||
// 今日搜索量
|
||||
var todaySearches int64
|
||||
db.DB.Model(&entity.SearchStat{}).Where("DATE(date) = ?", today).Count(&todaySearches)
|
||||
|
||||
// 添加调试日志
|
||||
utils.Info("统计数据 - 总资源: %d, 总分类: %d, 总标签: %d, 总浏览量: %d",
|
||||
totalResources, totalCategories, totalTags, totalViews)
|
||||
utils.Info("今日数据 - 新增资源: %d, 今日更新: %d, 今日浏览量: %d, 今日搜索: %d",
|
||||
todayResources, todayUpdates, todayViews, todaySearches)
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"total_resources": totalResources,
|
||||
"total_categories": totalCategories,
|
||||
"total_tags": totalTags,
|
||||
"total_views": totalViews,
|
||||
"today_resources": todayResources,
|
||||
"today_updates": todayUpdates,
|
||||
"today_views": todayViews,
|
||||
"today_searches": todaySearches,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -89,7 +116,7 @@ func GetPerformanceStats(c *gin.Context) {
|
||||
func GetSystemInfo(c *gin.Context) {
|
||||
SuccessResponse(c, gin.H{
|
||||
"uptime": time.Since(startTime).String(),
|
||||
"start_time": utils.FormatTime(startTime, "2006-01-02 15:04:05"),
|
||||
"start_time": utils.FormatTime(startTime, utils.TimeFormatDateTime),
|
||||
"version": utils.Version,
|
||||
"environment": gin.H{
|
||||
"gin_mode": gin.Mode(),
|
||||
@@ -97,5 +124,60 @@ func GetSystemInfo(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// GetViewsTrend 获取访问量趋势数据
|
||||
func GetViewsTrend(c *gin.Context) {
|
||||
// 使用访问记录表获取最近7天的访问量数据
|
||||
results, err := repoManager.ResourceViewRepository.GetViewsTrend(7)
|
||||
if err != nil {
|
||||
utils.Error("获取访问量趋势数据失败: %v", err)
|
||||
// 如果获取失败,返回空数据
|
||||
results = []map[string]interface{}{}
|
||||
}
|
||||
|
||||
// 添加调试日志
|
||||
utils.Info("访问量趋势数据: %+v", results)
|
||||
for i, result := range results {
|
||||
utils.Info("第%d天: 日期=%s, 访问量=%d", i+1, result["date"], result["views"])
|
||||
}
|
||||
|
||||
SuccessResponse(c, results)
|
||||
}
|
||||
|
||||
// GetSearchesTrend 获取搜索量趋势数据
|
||||
func GetSearchesTrend(c *gin.Context) {
|
||||
// 获取最近7天的搜索量数据
|
||||
var results []gin.H
|
||||
|
||||
// 生成最近7天的日期
|
||||
for i := 6; i >= 0; i-- {
|
||||
date := utils.GetCurrentTime().AddDate(0, 0, -i)
|
||||
dateStr := date.Format(utils.TimeFormatDate)
|
||||
|
||||
// 查询该日期的搜索量(从搜索统计表)
|
||||
var searches int64
|
||||
db.DB.Model(&entity.SearchStat{}).
|
||||
Where("DATE(date) = ?", dateStr).
|
||||
Count(&searches)
|
||||
|
||||
// 如果没有搜索记录,返回0
|
||||
// 移除模拟数据生成逻辑,只返回真实数据
|
||||
|
||||
results = append(results, gin.H{
|
||||
"date": dateStr,
|
||||
"searches": searches,
|
||||
})
|
||||
}
|
||||
|
||||
// 添加调试日志
|
||||
utils.Info("搜索量趋势数据: %+v", results)
|
||||
|
||||
// 添加更详细的调试信息
|
||||
for i, result := range results {
|
||||
utils.Info("第%d天: 日期=%s, 搜索量=%d", i+1, result["date"], result["searches"])
|
||||
}
|
||||
|
||||
SuccessResponse(c, results)
|
||||
}
|
||||
|
||||
// 记录启动时间
|
||||
var startTime = utils.GetCurrentTime()
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/ctwj/urldb/scheduler"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -44,29 +45,23 @@ func (h *SystemConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证参数
|
||||
if req.SiteTitle == "" {
|
||||
ErrorResponse(c, "网站标题不能为空", http.StatusBadRequest)
|
||||
// 验证参数 - 只验证提交的字段
|
||||
if req.SiteTitle != "" && (len(req.SiteTitle) < 1 || len(req.SiteTitle) > 100) {
|
||||
ErrorResponse(c, "网站标题长度必须在1-100字符之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.AutoProcessInterval < 1 || req.AutoProcessInterval > 1440 {
|
||||
if req.AutoProcessInterval > 0 && (req.AutoProcessInterval < 1 || req.AutoProcessInterval > 1440) {
|
||||
ErrorResponse(c, "自动处理间隔必须在1-1440分钟之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.PageSize < 10 || req.PageSize > 500 {
|
||||
if req.PageSize > 0 && (req.PageSize < 10 || req.PageSize > 500) {
|
||||
ErrorResponse(c, "每页显示数量必须在10-500之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证自动转存配置
|
||||
if req.AutoTransferLimitDays < 0 || req.AutoTransferLimitDays > 365 {
|
||||
ErrorResponse(c, "自动转存限制天数必须在0-365之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.AutoTransferMinSpace < 100 || req.AutoTransferMinSpace > 1024 {
|
||||
if req.AutoTransferMinSpace > 0 && (req.AutoTransferMinSpace < 100 || req.AutoTransferMinSpace > 1024) {
|
||||
ErrorResponse(c, "最小存储空间必须在100-1024GB之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -116,29 +111,32 @@ func UpdateSystemConfig(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证参数
|
||||
if req.SiteTitle == "" {
|
||||
ErrorResponse(c, "网站标题不能为空", http.StatusBadRequest)
|
||||
// 调试信息
|
||||
utils.Info("接收到的配置请求: %+v", req)
|
||||
|
||||
// 验证参数 - 只验证提交的字段
|
||||
if req.SiteTitle != "" && (len(req.SiteTitle) < 1 || len(req.SiteTitle) > 100) {
|
||||
ErrorResponse(c, "网站标题长度必须在1-100字符之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.AutoProcessInterval < 1 || req.AutoProcessInterval > 1440 {
|
||||
if req.AutoProcessInterval != 0 && (req.AutoProcessInterval < 1 || req.AutoProcessInterval > 1440) {
|
||||
ErrorResponse(c, "自动处理间隔必须在1-1440分钟之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.PageSize < 10 || req.PageSize > 500 {
|
||||
if req.PageSize != 0 && (req.PageSize < 10 || req.PageSize > 500) {
|
||||
ErrorResponse(c, "每页显示数量必须在10-500之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证自动转存配置
|
||||
if req.AutoTransferLimitDays < 0 || req.AutoTransferLimitDays > 365 {
|
||||
if req.AutoTransferLimitDays != 0 && (req.AutoTransferLimitDays < 0 || req.AutoTransferLimitDays > 365) {
|
||||
ErrorResponse(c, "自动转存限制天数必须在0-365之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.AutoTransferMinSpace < 100 || req.AutoTransferMinSpace > 1024 {
|
||||
if req.AutoTransferMinSpace != 0 && (req.AutoTransferMinSpace < 100 || req.AutoTransferMinSpace > 1024) {
|
||||
ErrorResponse(c, "最小存储空间必须在100-1024GB之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -158,7 +156,7 @@ func UpdateSystemConfig(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 根据配置更新定时任务状态(错误不影响配置保存)
|
||||
scheduler := utils.GetGlobalScheduler(
|
||||
scheduler := scheduler.GetGlobalScheduler(
|
||||
repoManager.HotDramaRepository,
|
||||
repoManager.ReadyResourceRepository,
|
||||
repoManager.ResourceRepository,
|
||||
@@ -230,7 +228,7 @@ func ToggleAutoProcess(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 更新定时任务状态
|
||||
scheduler := utils.GetGlobalScheduler(
|
||||
scheduler := scheduler.GetGlobalScheduler(
|
||||
repoManager.HotDramaRepository,
|
||||
repoManager.ReadyResourceRepository,
|
||||
repoManager.ResourceRepository,
|
||||
|
||||
@@ -24,11 +24,11 @@ func GetTags(c *gin.Context) {
|
||||
var err error
|
||||
|
||||
if search != "" {
|
||||
// 搜索标签
|
||||
tags, total, err = repoManager.TagRepository.Search(search, page, pageSize)
|
||||
// 搜索标签(按资源数量排序)
|
||||
tags, total, err = repoManager.TagRepository.SearchOrderByResourceCount(search, page, pageSize)
|
||||
} else {
|
||||
// 分页查询
|
||||
tags, total, err = repoManager.TagRepository.FindWithPagination(page, pageSize)
|
||||
// 分页查询(按资源数量排序)
|
||||
tags, total, err = repoManager.TagRepository.FindWithPaginationOrderByResourceCount(page, pageSize)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -65,13 +65,47 @@ func CreateTag(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 首先检查是否存在已删除的同名标签
|
||||
deletedTag, err := repoManager.TagRepository.FindByNameIncludingDeleted(req.Name)
|
||||
if err == nil && deletedTag.DeletedAt.Valid {
|
||||
// 如果存在已删除的同名标签,则恢复它
|
||||
err = repoManager.TagRepository.RestoreDeletedTag(deletedTag.ID)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "恢复已删除标签失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 重新获取恢复后的标签
|
||||
restoredTag, err := repoManager.TagRepository.FindByID(deletedTag.ID)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取恢复的标签失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 更新标签信息
|
||||
restoredTag.Description = req.Description
|
||||
restoredTag.CategoryID = req.CategoryID
|
||||
err = repoManager.TagRepository.UpdateWithNulls(restoredTag)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "更新恢复的标签失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"message": "标签恢复成功",
|
||||
"tag": converter.ToTagResponse(restoredTag, 0),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 如果不存在已删除的同名标签,则创建新标签
|
||||
tag := &entity.Tag{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
CategoryID: req.CategoryID,
|
||||
}
|
||||
|
||||
err := repoManager.TagRepository.Create(tag)
|
||||
err = repoManager.TagRepository.Create(tag)
|
||||
if err != nil {
|
||||
ErrorResponse(c, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
375
handlers/task_handler.go
Normal file
375
handlers/task_handler.go
Normal file
@@ -0,0 +1,375 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/ctwj/urldb/task"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TaskHandler 任务处理器
|
||||
type TaskHandler struct {
|
||||
repoMgr *repo.RepositoryManager
|
||||
taskManager *task.TaskManager
|
||||
}
|
||||
|
||||
// NewTaskHandler 创建任务处理器
|
||||
func NewTaskHandler(repoMgr *repo.RepositoryManager, taskManager *task.TaskManager) *TaskHandler {
|
||||
return &TaskHandler{
|
||||
repoMgr: repoMgr,
|
||||
taskManager: taskManager,
|
||||
}
|
||||
}
|
||||
|
||||
// 批量转存任务资源项
|
||||
type BatchTransferResource struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
CategoryID uint `json:"category_id,omitempty"`
|
||||
PanID uint `json:"pan_id,omitempty"`
|
||||
Tags []uint `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// CreateBatchTransferTask 创建批量转存任务
|
||||
func (h *TaskHandler) CreateBatchTransferTask(c *gin.Context) {
|
||||
var req struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Resources []BatchTransferResource `json:"resources" binding:"required,min=1"`
|
||||
SelectedAccounts []uint `json:"selected_accounts,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, "参数错误: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("创建批量转存任务: %s,资源数量: %d,选择账号数量: %d", req.Title, len(req.Resources), len(req.SelectedAccounts))
|
||||
|
||||
// 构建任务配置
|
||||
taskConfig := map[string]interface{}{
|
||||
"selected_accounts": req.SelectedAccounts,
|
||||
}
|
||||
configJSON, _ := json.Marshal(taskConfig)
|
||||
|
||||
// 创建任务
|
||||
newTask := &entity.Task{
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Type: "transfer",
|
||||
Status: "pending",
|
||||
TotalItems: len(req.Resources),
|
||||
Config: string(configJSON),
|
||||
CreatedAt: utils.GetCurrentTime(),
|
||||
UpdatedAt: utils.GetCurrentTime(),
|
||||
}
|
||||
|
||||
err := h.repoMgr.TaskRepository.Create(newTask)
|
||||
if err != nil {
|
||||
utils.Error("创建任务失败: %v", err)
|
||||
ErrorResponse(c, "创建任务失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 创建任务项
|
||||
for _, resource := range req.Resources {
|
||||
// 构建转存输入数据
|
||||
transferInput := task.TransferInput{
|
||||
Title: resource.Title,
|
||||
URL: resource.URL,
|
||||
CategoryID: resource.CategoryID,
|
||||
PanID: resource.PanID,
|
||||
Tags: resource.Tags,
|
||||
}
|
||||
|
||||
inputJSON, _ := json.Marshal(transferInput)
|
||||
|
||||
taskItem := &entity.TaskItem{
|
||||
TaskID: newTask.ID,
|
||||
Status: "pending",
|
||||
InputData: string(inputJSON),
|
||||
CreatedAt: utils.GetCurrentTime(),
|
||||
UpdatedAt: utils.GetCurrentTime(),
|
||||
}
|
||||
|
||||
err = h.repoMgr.TaskItemRepository.Create(taskItem)
|
||||
if err != nil {
|
||||
utils.Error("创建任务项失败: %v", err)
|
||||
// 继续创建其他任务项
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info("批量转存任务创建完成: %d, 共 %d 项", newTask.ID, len(req.Resources))
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"task_id": newTask.ID,
|
||||
"total_items": len(req.Resources),
|
||||
"message": "任务创建成功",
|
||||
})
|
||||
}
|
||||
|
||||
// StartTask 启动任务
|
||||
func (h *TaskHandler) StartTask(c *gin.Context) {
|
||||
taskIDStr := c.Param("id")
|
||||
taskID, err := strconv.ParseUint(taskIDStr, 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的任务ID: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("启动任务: %d", taskID)
|
||||
|
||||
err = h.taskManager.StartTask(uint(taskID))
|
||||
if err != nil {
|
||||
utils.Error("启动任务失败: %v", err)
|
||||
ErrorResponse(c, "启动任务失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"message": "任务启动成功",
|
||||
})
|
||||
}
|
||||
|
||||
// StopTask 停止任务
|
||||
func (h *TaskHandler) StopTask(c *gin.Context) {
|
||||
taskIDStr := c.Param("id")
|
||||
taskID, err := strconv.ParseUint(taskIDStr, 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的任务ID: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("停止任务: %d", taskID)
|
||||
|
||||
err = h.taskManager.StopTask(uint(taskID))
|
||||
if err != nil {
|
||||
utils.Error("停止任务失败: %v", err)
|
||||
ErrorResponse(c, "停止任务失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"message": "任务停止成功",
|
||||
})
|
||||
}
|
||||
|
||||
// PauseTask 暂停任务
|
||||
func (h *TaskHandler) PauseTask(c *gin.Context) {
|
||||
taskIDStr := c.Param("id")
|
||||
taskID, err := strconv.ParseUint(taskIDStr, 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的任务ID: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("暂停任务: %d", taskID)
|
||||
|
||||
err = h.taskManager.PauseTask(uint(taskID))
|
||||
if err != nil {
|
||||
utils.Error("暂停任务失败: %v", err)
|
||||
ErrorResponse(c, "暂停任务失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"message": "任务暂停成功",
|
||||
})
|
||||
}
|
||||
|
||||
// GetTaskStatus 获取任务状态
|
||||
func (h *TaskHandler) GetTaskStatus(c *gin.Context) {
|
||||
taskIDStr := c.Param("id")
|
||||
taskID, err := strconv.ParseUint(taskIDStr, 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的任务ID: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取任务详情
|
||||
task, err := h.repoMgr.TaskRepository.GetByID(uint(taskID))
|
||||
if err != nil {
|
||||
ErrorResponse(c, "任务不存在: "+err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取任务项统计
|
||||
stats, err := h.repoMgr.TaskItemRepository.GetStatsByTaskID(uint(taskID))
|
||||
if err != nil {
|
||||
utils.Error("获取任务项统计失败: %v", err)
|
||||
stats = map[string]int{
|
||||
"total": 0,
|
||||
"pending": 0,
|
||||
"processing": 0,
|
||||
"completed": 0,
|
||||
"failed": 0,
|
||||
}
|
||||
}
|
||||
|
||||
// 检查任务是否在运行
|
||||
isRunning := h.taskManager.IsTaskRunning(uint(taskID))
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"id": task.ID,
|
||||
"title": task.Title,
|
||||
"description": task.Description,
|
||||
"task_type": task.Type,
|
||||
"status": task.Status,
|
||||
"total_items": task.TotalItems,
|
||||
"processed_items": task.ProcessedItems,
|
||||
"success_items": task.SuccessItems,
|
||||
"failed_items": task.FailedItems,
|
||||
"is_running": isRunning,
|
||||
"stats": stats,
|
||||
"created_at": task.CreatedAt,
|
||||
"updated_at": task.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// GetTasks 获取任务列表
|
||||
func (h *TaskHandler) GetTasks(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
|
||||
taskType := c.Query("task_type")
|
||||
status := c.Query("status")
|
||||
|
||||
utils.Info("GetTasks: 获取任务列表 page=%d, pageSize=%d, taskType=%s, status=%s", page, pageSize, taskType, status)
|
||||
|
||||
tasks, total, err := h.repoMgr.TaskRepository.GetList(page, pageSize, taskType, status)
|
||||
if err != nil {
|
||||
utils.Error("获取任务列表失败: %v", err)
|
||||
ErrorResponse(c, "获取任务列表失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("GetTasks: 从数据库获取到 %d 个任务", len(tasks))
|
||||
|
||||
// 为每个任务添加运行状态
|
||||
var result []gin.H
|
||||
for _, task := range tasks {
|
||||
isRunning := h.taskManager.IsTaskRunning(task.ID)
|
||||
utils.Info("GetTasks: 任务 %d (%s) 数据库状态: %s, TaskManager运行状态: %v", task.ID, task.Title, task.Status, isRunning)
|
||||
|
||||
result = append(result, gin.H{
|
||||
"id": task.ID,
|
||||
"title": task.Title,
|
||||
"description": task.Description,
|
||||
"task_type": task.Type,
|
||||
"status": task.Status,
|
||||
"total_items": task.TotalItems,
|
||||
"processed_items": task.ProcessedItems,
|
||||
"success_items": task.SuccessItems,
|
||||
"failed_items": task.FailedItems,
|
||||
"is_running": isRunning,
|
||||
"created_at": task.CreatedAt,
|
||||
"updated_at": task.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"items": result,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// GetTaskItems 获取任务项列表
|
||||
func (h *TaskHandler) GetTaskItems(c *gin.Context) {
|
||||
taskIDStr := c.Param("id")
|
||||
taskID, err := strconv.ParseUint(taskIDStr, 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的任务ID: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10000"))
|
||||
status := c.Query("status")
|
||||
|
||||
items, total, err := h.repoMgr.TaskItemRepository.GetListByTaskID(uint(taskID), page, pageSize, status)
|
||||
if err != nil {
|
||||
utils.Error("获取任务项列表失败: %v", err)
|
||||
ErrorResponse(c, "获取任务项列表失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析输入和输出数据
|
||||
var result []gin.H
|
||||
for _, item := range items {
|
||||
itemData := gin.H{
|
||||
"id": item.ID,
|
||||
"status": item.Status,
|
||||
"created_at": item.CreatedAt,
|
||||
"updated_at": item.UpdatedAt,
|
||||
}
|
||||
|
||||
// 解析输入数据
|
||||
if item.InputData != "" {
|
||||
var inputData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(item.InputData), &inputData); err == nil {
|
||||
itemData["input"] = inputData
|
||||
}
|
||||
}
|
||||
|
||||
// 解析输出数据
|
||||
if item.OutputData != "" {
|
||||
var outputData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(item.OutputData), &outputData); err == nil {
|
||||
itemData["output"] = outputData
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, itemData)
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"items": result,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteTask 删除任务
|
||||
func (h *TaskHandler) DeleteTask(c *gin.Context) {
|
||||
taskIDStr := c.Param("id")
|
||||
taskID, err := strconv.ParseUint(taskIDStr, 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的任务ID: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查任务是否在运行
|
||||
if h.taskManager.IsTaskRunning(uint(taskID)) {
|
||||
ErrorResponse(c, "任务正在运行,请先停止任务", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 删除任务项
|
||||
err = h.repoMgr.TaskItemRepository.DeleteByTaskID(uint(taskID))
|
||||
if err != nil {
|
||||
utils.Error("删除任务项失败: %v", err)
|
||||
ErrorResponse(c, "删除任务项失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 删除任务
|
||||
err = h.repoMgr.TaskRepository.Delete(uint(taskID))
|
||||
if err != nil {
|
||||
utils.Error("删除任务失败: %v", err)
|
||||
ErrorResponse(c, "删除任务失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("任务删除成功: %d", taskID)
|
||||
SuccessResponse(c, gin.H{
|
||||
"message": "任务删除成功",
|
||||
})
|
||||
}
|
||||
88
main.go
88
main.go
@@ -4,13 +4,12 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/ctwj/urldb/utils"
|
||||
|
||||
"github.com/ctwj/urldb/db"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/ctwj/urldb/handlers"
|
||||
"github.com/ctwj/urldb/middleware"
|
||||
"github.com/ctwj/urldb/task"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -69,48 +68,21 @@ func main() {
|
||||
// 创建Repository管理器
|
||||
repoManager := repo.NewRepositoryManager(db.DB)
|
||||
|
||||
// 创建全局调度器
|
||||
scheduler := utils.GetGlobalScheduler(
|
||||
repoManager.HotDramaRepository,
|
||||
repoManager.ReadyResourceRepository,
|
||||
repoManager.ResourceRepository,
|
||||
repoManager.SystemConfigRepository,
|
||||
repoManager.PanRepository,
|
||||
repoManager.CksRepository,
|
||||
repoManager.TagRepository,
|
||||
repoManager.CategoryRepository,
|
||||
)
|
||||
// 创建任务管理器
|
||||
taskManager := task.NewTaskManager(repoManager)
|
||||
|
||||
// 检查系统配置,决定是否启动各种自动任务
|
||||
autoProcessReadyResources, err := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoProcessReadyResources)
|
||||
if err != nil {
|
||||
utils.Error("获取自动处理待处理资源配置失败: %v", err)
|
||||
} else if autoProcessReadyResources {
|
||||
scheduler.StartReadyResourceScheduler()
|
||||
utils.Info("已启动待处理资源自动处理任务")
|
||||
// 注册转存任务处理器
|
||||
transferProcessor := task.NewTransferProcessor(repoManager)
|
||||
taskManager.RegisterProcessor(transferProcessor)
|
||||
|
||||
// 恢复运行中的任务(服务器重启后)
|
||||
if err := taskManager.RecoverRunningTasks(); err != nil {
|
||||
utils.Error("恢复运行中任务失败: %v", err)
|
||||
} else {
|
||||
utils.Info("系统配置中自动处理待处理资源功能已禁用,跳过启动定时任务")
|
||||
utils.Info("运行中任务恢复完成")
|
||||
}
|
||||
|
||||
autoFetchHotDramaEnabled, err := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoFetchHotDramaEnabled)
|
||||
if err != nil {
|
||||
utils.Error("获取自动拉取热播剧配置失败: %v", err)
|
||||
} else if autoFetchHotDramaEnabled {
|
||||
scheduler.StartHotDramaScheduler()
|
||||
utils.Info("已启动热播剧自动拉取任务")
|
||||
} else {
|
||||
utils.Info("系统配置中自动拉取热播剧功能已禁用,跳过启动定时任务")
|
||||
}
|
||||
|
||||
autoTransferEnabled, err := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoTransferEnabled)
|
||||
if err != nil {
|
||||
utils.Error("获取自动转存配置失败: %v", err)
|
||||
} else if autoTransferEnabled {
|
||||
scheduler.StartAutoTransferScheduler()
|
||||
utils.Info("已启动自动转存任务")
|
||||
} else {
|
||||
utils.Info("系统配置中自动转存功能已禁用,跳过启动定时任务")
|
||||
}
|
||||
utils.Info("任务管理器初始化完成")
|
||||
|
||||
// 创建Gin实例
|
||||
r := gin.Default()
|
||||
@@ -131,6 +103,9 @@ func main() {
|
||||
// 创建公开API处理器
|
||||
publicAPIHandler := handlers.NewPublicAPIHandler()
|
||||
|
||||
// 创建任务处理器
|
||||
taskHandler := handlers.NewTaskHandler(repoManager, taskManager)
|
||||
|
||||
// API路由
|
||||
api := r.Group("/api")
|
||||
{
|
||||
@@ -159,6 +134,7 @@ func main() {
|
||||
api.GET("/resources/:id", handlers.GetResourceByID)
|
||||
api.GET("/resources/check-exists", handlers.CheckResourceExists)
|
||||
api.POST("/resources/:id/view", handlers.IncrementResourceViewCount)
|
||||
api.GET("/resources/:id/link", handlers.GetResourceLink)
|
||||
api.DELETE("/resources/batch", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.BatchDeleteResources)
|
||||
|
||||
// 分类管理
|
||||
@@ -173,6 +149,8 @@ func main() {
|
||||
// 统计
|
||||
api.GET("/stats", handlers.GetStats)
|
||||
api.GET("/performance", handlers.GetPerformanceStats)
|
||||
api.GET("/stats/views-trend", handlers.GetViewsTrend)
|
||||
api.GET("/stats/searches-trend", handlers.GetSearchesTrend)
|
||||
api.GET("/system/info", handlers.GetSystemInfo)
|
||||
|
||||
// 平台管理
|
||||
@@ -209,6 +187,9 @@ func main() {
|
||||
api.GET("/ready-resources/errors", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetReadyResourcesWithErrors)
|
||||
api.POST("/ready-resources/:id/clear-error", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.ClearErrorMsg)
|
||||
api.POST("/ready-resources/retry-failed", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.RetryFailedResources)
|
||||
api.POST("/ready-resources/batch-restore", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.BatchRestoreToReadyPool)
|
||||
api.POST("/ready-resources/batch-restore-by-query", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.BatchRestoreToReadyPoolByQuery)
|
||||
api.POST("/ready-resources/clear-all-errors-by-query", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.ClearAllErrorsByQuery)
|
||||
|
||||
// 用户管理(仅管理员)
|
||||
api.GET("/users", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetUsers)
|
||||
@@ -240,22 +221,15 @@ func main() {
|
||||
api.PUT("/hot-dramas/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.UpdateHotDrama)
|
||||
api.DELETE("/hot-dramas/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.DeleteHotDrama)
|
||||
|
||||
// 调度器管理路由(查询接口无需认证)
|
||||
api.GET("/scheduler/status", handlers.GetSchedulerStatus)
|
||||
api.GET("/scheduler/hot-drama/names", handlers.FetchHotDramaNames)
|
||||
api.POST("/scheduler/hot-drama/start", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.StartHotDramaScheduler)
|
||||
api.POST("/scheduler/hot-drama/stop", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.StopHotDramaScheduler)
|
||||
api.POST("/scheduler/hot-drama/trigger", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.TriggerHotDramaScheduler)
|
||||
|
||||
// 待处理资源自动处理管理路由
|
||||
api.POST("/scheduler/ready-resource/start", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.StartReadyResourceScheduler)
|
||||
api.POST("/scheduler/ready-resource/stop", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.StopReadyResourceScheduler)
|
||||
api.POST("/scheduler/ready-resource/trigger", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.TriggerReadyResourceScheduler)
|
||||
|
||||
// 自动转存管理路由
|
||||
api.POST("/scheduler/auto-transfer/start", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.StartAutoTransferScheduler)
|
||||
api.POST("/scheduler/auto-transfer/stop", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.StopAutoTransferScheduler)
|
||||
api.POST("/scheduler/auto-transfer/trigger", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.TriggerAutoTransferScheduler)
|
||||
// 任务管理路由
|
||||
api.POST("/tasks/transfer", middleware.AuthMiddleware(), middleware.AdminMiddleware(), taskHandler.CreateBatchTransferTask)
|
||||
api.GET("/tasks", middleware.AuthMiddleware(), middleware.AdminMiddleware(), taskHandler.GetTasks)
|
||||
api.GET("/tasks/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), taskHandler.GetTaskStatus)
|
||||
api.POST("/tasks/:id/start", middleware.AuthMiddleware(), middleware.AdminMiddleware(), taskHandler.StartTask)
|
||||
api.POST("/tasks/:id/stop", middleware.AuthMiddleware(), middleware.AdminMiddleware(), taskHandler.StopTask)
|
||||
api.POST("/tasks/:id/pause", middleware.AuthMiddleware(), middleware.AdminMiddleware(), taskHandler.PauseTask)
|
||||
api.DELETE("/tasks/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), taskHandler.DeleteTask)
|
||||
api.GET("/tasks/:id/items", middleware.AuthMiddleware(), middleware.AdminMiddleware(), taskHandler.GetTaskItems)
|
||||
|
||||
// 版本管理路由
|
||||
api.GET("/version", handlers.GetVersion)
|
||||
|
||||
444
scheduler/auto_transfer.go
Normal file
444
scheduler/auto_transfer.go
Normal file
@@ -0,0 +1,444 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
panutils "github.com/ctwj/urldb/common"
|
||||
commonutils "github.com/ctwj/urldb/common/utils"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AutoTransferScheduler 自动转存调度器
|
||||
type AutoTransferScheduler struct {
|
||||
*BaseScheduler
|
||||
autoTransferRunning bool
|
||||
autoTransferMutex sync.Mutex // 防止自动转存任务重叠执行
|
||||
}
|
||||
|
||||
// NewAutoTransferScheduler 创建自动转存调度器
|
||||
func NewAutoTransferScheduler(base *BaseScheduler) *AutoTransferScheduler {
|
||||
return &AutoTransferScheduler{
|
||||
BaseScheduler: base,
|
||||
autoTransferRunning: false,
|
||||
autoTransferMutex: sync.Mutex{},
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动自动转存定时任务
|
||||
func (a *AutoTransferScheduler) Start() {
|
||||
|
||||
// 自动转存已经放弃,不再自动缓存
|
||||
return
|
||||
|
||||
if a.autoTransferRunning {
|
||||
utils.Info("自动转存定时任务已在运行中")
|
||||
return
|
||||
}
|
||||
|
||||
a.autoTransferRunning = true
|
||||
utils.Info("启动自动转存定时任务")
|
||||
|
||||
go func() {
|
||||
// 获取系统配置中的间隔时间
|
||||
interval := 5 * time.Minute // 默认5分钟
|
||||
if autoProcessInterval, err := a.systemConfigRepo.GetConfigInt(entity.ConfigKeyAutoProcessInterval); err == nil && autoProcessInterval > 0 {
|
||||
interval = time.Duration(autoProcessInterval) * time.Minute
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
utils.Info(fmt.Sprintf("自动转存定时任务已启动,间隔时间: %v", interval))
|
||||
|
||||
// 立即执行一次
|
||||
a.processAutoTransfer()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
// 使用TryLock防止任务重叠执行
|
||||
if a.autoTransferMutex.TryLock() {
|
||||
go func() {
|
||||
defer a.autoTransferMutex.Unlock()
|
||||
a.processAutoTransfer()
|
||||
}()
|
||||
} else {
|
||||
utils.Info("上一次自动转存任务还在执行中,跳过本次执行")
|
||||
}
|
||||
case <-a.GetStopChan():
|
||||
utils.Info("停止自动转存定时任务")
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop 停止自动转存定时任务
|
||||
func (a *AutoTransferScheduler) Stop() {
|
||||
if !a.autoTransferRunning {
|
||||
utils.Info("自动转存定时任务未在运行")
|
||||
return
|
||||
}
|
||||
|
||||
a.GetStopChan() <- true
|
||||
a.autoTransferRunning = false
|
||||
utils.Info("已发送停止信号给自动转存定时任务")
|
||||
}
|
||||
|
||||
// IsAutoTransferRunning 检查自动转存任务是否正在运行
|
||||
func (a *AutoTransferScheduler) IsAutoTransferRunning() bool {
|
||||
return a.autoTransferRunning
|
||||
}
|
||||
|
||||
// processAutoTransfer 处理自动转存
|
||||
func (a *AutoTransferScheduler) processAutoTransfer() {
|
||||
utils.Info("开始处理自动转存...")
|
||||
|
||||
// 检查系统配置,确认是否启用自动转存
|
||||
autoTransferEnabled, err := a.systemConfigRepo.GetConfigBool(entity.ConfigKeyAutoTransferEnabled)
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取系统配置失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if !autoTransferEnabled {
|
||||
utils.Info("自动转存功能已禁用")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取quark平台ID
|
||||
quarkPanID, err := a.getQuarkPanID()
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取夸克网盘ID失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 获取所有账号
|
||||
accounts, err := a.cksRepo.FindAll()
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取网盘账号失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 获取最小存储空间配置
|
||||
autoTransferMinSpace, err := a.systemConfigRepo.GetConfigInt(entity.ConfigKeyAutoTransferMinSpace)
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取最小存储空间配置失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 过滤:只保留已激活、quark平台、剩余空间足够的账号
|
||||
minSpaceBytes := int64(autoTransferMinSpace) * 1024 * 1024 * 1024
|
||||
var validAccounts []entity.Cks
|
||||
for _, acc := range accounts {
|
||||
if acc.IsValid && acc.PanID == quarkPanID && acc.LeftSpace >= minSpaceBytes {
|
||||
validAccounts = append(validAccounts, acc)
|
||||
}
|
||||
}
|
||||
|
||||
if len(validAccounts) == 0 {
|
||||
utils.Info("没有可用的quark网盘账号")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info(fmt.Sprintf("找到 %d 个可用quark网盘账号,开始自动转存处理...", len(validAccounts)))
|
||||
|
||||
// 计算处理数量限制
|
||||
// 假设每5秒转存一个资源,每分钟20个,5分钟100个
|
||||
// 根据时间间隔和账号数量计算大致的处理数量
|
||||
interval := 5 * time.Minute // 默认5分钟
|
||||
if autoProcessInterval, err := a.systemConfigRepo.GetConfigInt(entity.ConfigKeyAutoProcessInterval); err == nil && autoProcessInterval > 0 {
|
||||
interval = time.Duration(autoProcessInterval) * time.Minute
|
||||
}
|
||||
|
||||
// 计算每分钟能处理的资源数量:账号数 * 12(每分钟12个,即每5秒一个)
|
||||
resourcesPerMinute := len(validAccounts) * 12
|
||||
// 根据时间间隔计算总处理数量
|
||||
maxProcessCount := int(float64(resourcesPerMinute) * interval.Minutes())
|
||||
|
||||
utils.Info(fmt.Sprintf("时间间隔: %v, 账号数: %d, 每分钟处理能力: %d, 最大处理数量: %d",
|
||||
interval, len(validAccounts), resourcesPerMinute, maxProcessCount))
|
||||
|
||||
// 获取需要转存的资源(限制数量)
|
||||
resources, err := a.getResourcesForTransfer(quarkPanID, maxProcessCount)
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取需要转存的资源失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if len(resources) == 0 {
|
||||
utils.Info("没有需要转存的资源")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info(fmt.Sprintf("找到 %d 个需要转存的资源", len(resources)))
|
||||
|
||||
// 获取违禁词配置
|
||||
forbiddenWords, err := a.systemConfigRepo.GetConfigValue(entity.ConfigKeyForbiddenWords)
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取违禁词配置失败: %v", err))
|
||||
forbiddenWords = "" // 如果获取失败,使用空字符串
|
||||
}
|
||||
|
||||
// 过滤包含违禁词的资源,并标记违禁词错误
|
||||
var filteredResources []*entity.Resource
|
||||
var forbiddenResources []*entity.Resource
|
||||
|
||||
if forbiddenWords != "" {
|
||||
words := strings.Split(forbiddenWords, ",")
|
||||
// 清理违禁词数组,去除空格
|
||||
var cleanWords []string
|
||||
for _, word := range words {
|
||||
word = strings.TrimSpace(word)
|
||||
if word != "" {
|
||||
cleanWords = append(cleanWords, word)
|
||||
}
|
||||
}
|
||||
|
||||
for _, resource := range resources {
|
||||
shouldSkip := false
|
||||
var matchedWords []string
|
||||
title := strings.ToLower(resource.Title)
|
||||
description := strings.ToLower(resource.Description)
|
||||
|
||||
for _, word := range cleanWords {
|
||||
wordLower := strings.ToLower(word)
|
||||
if strings.Contains(title, wordLower) || strings.Contains(description, wordLower) {
|
||||
matchedWords = append(matchedWords, word)
|
||||
shouldSkip = true
|
||||
}
|
||||
}
|
||||
|
||||
if shouldSkip {
|
||||
// 标记为违禁词错误
|
||||
resource.ErrorMsg = fmt.Sprintf("存在违禁词: %s", strings.Join(matchedWords, ", "))
|
||||
forbiddenResources = append(forbiddenResources, resource)
|
||||
utils.Info(fmt.Sprintf("标记违禁词资源: %s, 违禁词: %s", resource.Title, strings.Join(matchedWords, ", ")))
|
||||
} else {
|
||||
filteredResources = append(filteredResources, resource)
|
||||
}
|
||||
}
|
||||
utils.Info(fmt.Sprintf("违禁词过滤后,剩余 %d 个资源需要转存,违禁词资源 %d 个", len(filteredResources), len(forbiddenResources)))
|
||||
} else {
|
||||
filteredResources = resources
|
||||
}
|
||||
|
||||
// 注意:资源数量已在数据库查询时限制,无需再次限制
|
||||
|
||||
// 保存违禁词资源的错误信息
|
||||
for _, resource := range forbiddenResources {
|
||||
if err := a.resourceRepo.Update(resource); err != nil {
|
||||
utils.Error(fmt.Sprintf("保存违禁词错误信息失败 (ID: %d): %v", resource.ID, err))
|
||||
}
|
||||
}
|
||||
|
||||
// 并发自动转存
|
||||
resourceCh := make(chan *entity.Resource, len(filteredResources))
|
||||
for _, res := range filteredResources {
|
||||
resourceCh <- res
|
||||
}
|
||||
close(resourceCh)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, account := range validAccounts {
|
||||
wg.Add(1)
|
||||
go func(acc entity.Cks) {
|
||||
defer wg.Done()
|
||||
factory := panutils.GetInstance() // 使用单例模式
|
||||
for res := range resourceCh {
|
||||
if err := a.transferResource(res, []entity.Cks{acc}, factory); err != nil {
|
||||
utils.Error(fmt.Sprintf("转存资源失败 (ID: %d): %v", res.ID, err))
|
||||
} else {
|
||||
utils.Info(fmt.Sprintf("成功转存资源: %s", res.Title))
|
||||
rand.Seed(utils.GetCurrentTime().UnixNano())
|
||||
sleepSec := rand.Intn(3) + 1 // 1,2,3
|
||||
time.Sleep(time.Duration(sleepSec) * time.Second)
|
||||
}
|
||||
}
|
||||
}(account)
|
||||
}
|
||||
wg.Wait()
|
||||
utils.Info(fmt.Sprintf("自动转存处理完成,账号数: %d,处理资源数: %d,违禁词资源数: %d",
|
||||
len(validAccounts), len(filteredResources), len(forbiddenResources)))
|
||||
}
|
||||
|
||||
// getQuarkPanID 获取夸克网盘ID
|
||||
func (a *AutoTransferScheduler) getQuarkPanID() (uint, error) {
|
||||
// 获取panRepo的实现,以便访问数据库
|
||||
panRepoImpl, ok := a.panRepo.(interface{ GetDB() *gorm.DB })
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("panRepo不支持GetDB方法")
|
||||
}
|
||||
|
||||
var quarkPan entity.Pan
|
||||
err := panRepoImpl.GetDB().Where("name = ?", "quark").First(&quarkPan).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("未找到quark平台: %v", err)
|
||||
}
|
||||
|
||||
return quarkPan.ID, nil
|
||||
}
|
||||
|
||||
// getResourcesForTransfer 获取需要转存的资源
|
||||
func (a *AutoTransferScheduler) getResourcesForTransfer(quarkPanID uint, limit int) ([]*entity.Resource, error) {
|
||||
// 获取最近24小时内的资源
|
||||
sinceTime := utils.GetCurrentTime().Add(-24 * time.Hour)
|
||||
|
||||
// 使用资源仓库的方法获取需要转存的资源
|
||||
repoImpl, ok := a.resourceRepo.(*repo.ResourceRepositoryImpl)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("资源仓库类型错误")
|
||||
}
|
||||
|
||||
return repoImpl.GetResourcesForTransfer(quarkPanID, sinceTime, limit)
|
||||
}
|
||||
|
||||
// transferResource 转存单个资源
|
||||
func (a *AutoTransferScheduler) transferResource(resource *entity.Resource, accounts []entity.Cks, factory *panutils.PanFactory) error {
|
||||
if len(accounts) == 0 {
|
||||
return fmt.Errorf("没有可用的网盘账号")
|
||||
}
|
||||
account := accounts[0]
|
||||
|
||||
service, err := factory.CreatePanService(resource.URL, &panutils.PanConfig{
|
||||
URL: resource.URL,
|
||||
ExpiredType: 0,
|
||||
IsType: 0,
|
||||
Cookie: account.Ck,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建网盘服务失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取最小存储空间配置
|
||||
autoTransferMinSpace, err := a.systemConfigRepo.GetConfigInt(entity.ConfigKeyAutoTransferMinSpace)
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取最小存储空间配置失败: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查账号剩余空间
|
||||
minSpaceBytes := int64(autoTransferMinSpace) * 1024 * 1024 * 1024
|
||||
if account.LeftSpace < minSpaceBytes {
|
||||
return fmt.Errorf("账号剩余空间不足,需要 %d GB,当前剩余 %d GB", autoTransferMinSpace, account.LeftSpace/1024/1024/1024)
|
||||
}
|
||||
|
||||
// 提取分享ID
|
||||
shareID, _ := commonutils.ExtractShareIdString(resource.URL)
|
||||
|
||||
// 转存资源
|
||||
result, err := service.Transfer(shareID)
|
||||
if err != nil {
|
||||
// 更新错误信息
|
||||
resource.ErrorMsg = err.Error()
|
||||
a.resourceRepo.Update(resource)
|
||||
return fmt.Errorf("转存失败: %v", err)
|
||||
}
|
||||
|
||||
if result == nil || !result.Success {
|
||||
errMsg := "转存失败"
|
||||
if result != nil && result.Message != "" {
|
||||
errMsg = result.Message
|
||||
}
|
||||
// 更新错误信息
|
||||
resource.ErrorMsg = errMsg
|
||||
a.resourceRepo.Update(resource)
|
||||
return fmt.Errorf("转存失败: %s", errMsg)
|
||||
}
|
||||
|
||||
// 提取转存链接、fid等
|
||||
var saveURL, fid string
|
||||
if data, ok := result.Data.(map[string]interface{}); ok {
|
||||
if v, ok := data["shareUrl"]; ok {
|
||||
saveURL, _ = v.(string)
|
||||
}
|
||||
if v, ok := data["fid"]; ok {
|
||||
fid, _ = v.(string)
|
||||
}
|
||||
}
|
||||
if saveURL == "" {
|
||||
saveURL = result.ShareURL
|
||||
}
|
||||
|
||||
// 更新资源信息
|
||||
resource.SaveURL = saveURL
|
||||
resource.CkID = &account.ID
|
||||
resource.Fid = fid
|
||||
resource.ErrorMsg = ""
|
||||
|
||||
// 保存更新
|
||||
err = a.resourceRepo.Update(resource)
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存转存结果失败: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectBestAccount 选择最佳账号
|
||||
func (a *AutoTransferScheduler) selectBestAccount(accounts []entity.Cks) *entity.Cks {
|
||||
if len(accounts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 获取最小存储空间配置
|
||||
autoTransferMinSpace, err := a.systemConfigRepo.GetConfigInt(entity.ConfigKeyAutoTransferMinSpace)
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取最小存储空间配置失败: %v", err))
|
||||
return &accounts[0] // 返回第一个账号
|
||||
}
|
||||
|
||||
minSpaceBytes := int64(autoTransferMinSpace) * 1024 * 1024 * 1024
|
||||
|
||||
var bestAccount *entity.Cks
|
||||
var bestScore int64 = -1
|
||||
|
||||
for i := range accounts {
|
||||
account := &accounts[i]
|
||||
if account.LeftSpace < minSpaceBytes {
|
||||
continue // 跳过空间不足的账号
|
||||
}
|
||||
|
||||
score := a.calculateAccountScore(account)
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestAccount = account
|
||||
}
|
||||
}
|
||||
|
||||
return bestAccount
|
||||
}
|
||||
|
||||
// calculateAccountScore 计算账号评分
|
||||
func (a *AutoTransferScheduler) calculateAccountScore(account *entity.Cks) int64 {
|
||||
// TODO: 实现账号评分算法
|
||||
// 1. VIP账号加分
|
||||
// 2. 剩余空间大的账号加分
|
||||
// 3. 使用率低的账号加分
|
||||
// 4. 可以根据历史使用情况调整评分
|
||||
|
||||
score := int64(0)
|
||||
|
||||
// VIP账号加分
|
||||
if account.VipStatus {
|
||||
score += 1000
|
||||
}
|
||||
|
||||
// 剩余空间加分(每GB加1分)
|
||||
score += account.LeftSpace / (1024 * 1024 * 1024)
|
||||
|
||||
// 使用率加分(使用率越低分数越高)
|
||||
if account.Space > 0 {
|
||||
usageRate := float64(account.UsedSpace) / float64(account.Space)
|
||||
score += int64((1 - usageRate) * 500) // 使用率越低,加分越多
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
88
scheduler/base.go
Normal file
88
scheduler/base.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
)
|
||||
|
||||
// BaseScheduler 基础调度器结构
|
||||
type BaseScheduler struct {
|
||||
// 共享的仓库
|
||||
hotDramaRepo repo.HotDramaRepository
|
||||
readyResourceRepo repo.ReadyResourceRepository
|
||||
resourceRepo repo.ResourceRepository
|
||||
systemConfigRepo repo.SystemConfigRepository
|
||||
panRepo repo.PanRepository
|
||||
cksRepo repo.CksRepository
|
||||
tagRepo repo.TagRepository
|
||||
categoryRepo repo.CategoryRepository
|
||||
|
||||
// 控制字段
|
||||
stopChan chan bool
|
||||
isRunning bool
|
||||
|
||||
// 平台映射缓存
|
||||
panCache map[string]*uint // serviceType -> panID
|
||||
panCacheOnce sync.Once
|
||||
}
|
||||
|
||||
// NewBaseScheduler 创建基础调度器
|
||||
func NewBaseScheduler(
|
||||
hotDramaRepo repo.HotDramaRepository,
|
||||
readyResourceRepo repo.ReadyResourceRepository,
|
||||
resourceRepo repo.ResourceRepository,
|
||||
systemConfigRepo repo.SystemConfigRepository,
|
||||
panRepo repo.PanRepository,
|
||||
cksRepo repo.CksRepository,
|
||||
tagRepo repo.TagRepository,
|
||||
categoryRepo repo.CategoryRepository,
|
||||
) *BaseScheduler {
|
||||
return &BaseScheduler{
|
||||
hotDramaRepo: hotDramaRepo,
|
||||
readyResourceRepo: readyResourceRepo,
|
||||
resourceRepo: resourceRepo,
|
||||
systemConfigRepo: systemConfigRepo,
|
||||
panRepo: panRepo,
|
||||
cksRepo: cksRepo,
|
||||
tagRepo: tagRepo,
|
||||
categoryRepo: categoryRepo,
|
||||
stopChan: make(chan bool),
|
||||
isRunning: false,
|
||||
panCache: make(map[string]*uint),
|
||||
}
|
||||
}
|
||||
|
||||
// Stop 停止调度器
|
||||
func (b *BaseScheduler) Stop() {
|
||||
if b.isRunning {
|
||||
b.stopChan <- true
|
||||
b.isRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
// IsRunning 检查是否正在运行
|
||||
func (b *BaseScheduler) IsRunning() bool {
|
||||
return b.isRunning
|
||||
}
|
||||
|
||||
// SetRunning 设置运行状态
|
||||
func (b *BaseScheduler) SetRunning(running bool) {
|
||||
b.isRunning = running
|
||||
}
|
||||
|
||||
// GetStopChan 获取停止通道
|
||||
func (b *BaseScheduler) GetStopChan() chan bool {
|
||||
return b.stopChan
|
||||
}
|
||||
|
||||
// SleepWithStopCheck 带停止检查的睡眠
|
||||
func (b *BaseScheduler) SleepWithStopCheck(duration time.Duration) bool {
|
||||
select {
|
||||
case <-time.After(duration):
|
||||
return false
|
||||
case <-b.stopChan:
|
||||
return true
|
||||
}
|
||||
}
|
||||
184
scheduler/global.go
Normal file
184
scheduler/global.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
)
|
||||
|
||||
// GlobalScheduler 全局调度器管理器
|
||||
type GlobalScheduler struct {
|
||||
manager *Manager
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
var (
|
||||
globalScheduler *GlobalScheduler
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// GetGlobalScheduler 获取全局调度器实例(单例模式)
|
||||
func GetGlobalScheduler(hotDramaRepo repo.HotDramaRepository, readyResourceRepo repo.ReadyResourceRepository, resourceRepo repo.ResourceRepository, systemConfigRepo repo.SystemConfigRepository, panRepo repo.PanRepository, cksRepo repo.CksRepository, tagRepo repo.TagRepository, categoryRepo repo.CategoryRepository) *GlobalScheduler {
|
||||
once.Do(func() {
|
||||
globalScheduler = &GlobalScheduler{
|
||||
manager: NewManager(hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo, panRepo, cksRepo, tagRepo, categoryRepo),
|
||||
}
|
||||
})
|
||||
return globalScheduler
|
||||
}
|
||||
|
||||
// StartHotDramaScheduler 启动热播剧定时任务
|
||||
func (gs *GlobalScheduler) StartHotDramaScheduler() {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
if gs.manager.IsHotDramaRunning() {
|
||||
utils.Info("热播剧定时任务已在运行中")
|
||||
return
|
||||
}
|
||||
|
||||
gs.manager.StartHotDramaScheduler()
|
||||
utils.Info("全局调度器已启动热播剧定时任务")
|
||||
}
|
||||
|
||||
// StopHotDramaScheduler 停止热播剧定时任务
|
||||
func (gs *GlobalScheduler) StopHotDramaScheduler() {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
if !gs.manager.IsHotDramaRunning() {
|
||||
utils.Info("热播剧定时任务未在运行")
|
||||
return
|
||||
}
|
||||
|
||||
gs.manager.StopHotDramaScheduler()
|
||||
utils.Info("全局调度器已停止热播剧定时任务")
|
||||
}
|
||||
|
||||
// IsHotDramaSchedulerRunning 检查热播剧定时任务是否在运行
|
||||
func (gs *GlobalScheduler) IsHotDramaSchedulerRunning() bool {
|
||||
gs.mutex.RLock()
|
||||
defer gs.mutex.RUnlock()
|
||||
return gs.manager.IsHotDramaRunning()
|
||||
}
|
||||
|
||||
// GetHotDramaNames 手动获取热播剧名字
|
||||
func (gs *GlobalScheduler) GetHotDramaNames() ([]string, error) {
|
||||
return gs.manager.GetHotDramaNames()
|
||||
}
|
||||
|
||||
// StartReadyResourceScheduler 启动待处理资源自动处理任务
|
||||
func (gs *GlobalScheduler) StartReadyResourceScheduler() {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
if gs.manager.IsReadyResourceRunning() {
|
||||
utils.Info("待处理资源自动处理任务已在运行中")
|
||||
return
|
||||
}
|
||||
|
||||
gs.manager.StartReadyResourceScheduler()
|
||||
utils.Info("全局调度器已启动待处理资源自动处理任务")
|
||||
}
|
||||
|
||||
// StopReadyResourceScheduler 停止待处理资源自动处理任务
|
||||
func (gs *GlobalScheduler) StopReadyResourceScheduler() {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
if !gs.manager.IsReadyResourceRunning() {
|
||||
utils.Info("待处理资源自动处理任务未在运行")
|
||||
return
|
||||
}
|
||||
|
||||
gs.manager.StopReadyResourceScheduler()
|
||||
utils.Info("全局调度器已停止待处理资源自动处理任务")
|
||||
}
|
||||
|
||||
// IsReadyResourceRunning 检查待处理资源自动处理任务是否在运行
|
||||
func (gs *GlobalScheduler) IsReadyResourceRunning() bool {
|
||||
gs.mutex.RLock()
|
||||
defer gs.mutex.RUnlock()
|
||||
return gs.manager.IsReadyResourceRunning()
|
||||
}
|
||||
|
||||
// StartAutoTransferScheduler 启动自动转存定时任务
|
||||
func (gs *GlobalScheduler) StartAutoTransferScheduler() {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
if gs.manager.IsAutoTransferRunning() {
|
||||
utils.Info("自动转存定时任务已在运行中")
|
||||
return
|
||||
}
|
||||
|
||||
gs.manager.StartAutoTransferScheduler()
|
||||
utils.Info("全局调度器已启动自动转存定时任务")
|
||||
}
|
||||
|
||||
// StopAutoTransferScheduler 停止自动转存定时任务
|
||||
func (gs *GlobalScheduler) StopAutoTransferScheduler() {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
if !gs.manager.IsAutoTransferRunning() {
|
||||
utils.Info("自动转存定时任务未在运行")
|
||||
return
|
||||
}
|
||||
|
||||
gs.manager.StopAutoTransferScheduler()
|
||||
utils.Info("全局调度器已停止自动转存定时任务")
|
||||
}
|
||||
|
||||
// IsAutoTransferRunning 检查自动转存定时任务是否在运行
|
||||
func (gs *GlobalScheduler) IsAutoTransferRunning() bool {
|
||||
gs.mutex.RLock()
|
||||
defer gs.mutex.RUnlock()
|
||||
return gs.manager.IsAutoTransferRunning()
|
||||
}
|
||||
|
||||
// UpdateSchedulerStatusWithAutoTransfer 根据系统配置更新调度器状态(包含自动转存)
|
||||
func (gs *GlobalScheduler) UpdateSchedulerStatusWithAutoTransfer(autoFetchHotDramaEnabled bool, autoProcessReadyResources bool, autoTransferEnabled bool) {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
// 处理热播剧自动拉取功能
|
||||
if autoFetchHotDramaEnabled {
|
||||
if !gs.manager.IsHotDramaRunning() {
|
||||
utils.Info("系统配置启用自动拉取热播剧,启动定时任务")
|
||||
gs.manager.StartHotDramaScheduler()
|
||||
}
|
||||
} else {
|
||||
if gs.manager.IsHotDramaRunning() {
|
||||
utils.Info("系统配置禁用自动拉取热播剧,停止定时任务")
|
||||
gs.manager.StopHotDramaScheduler()
|
||||
}
|
||||
}
|
||||
|
||||
// 处理待处理资源自动处理功能
|
||||
if autoProcessReadyResources {
|
||||
if !gs.manager.IsReadyResourceRunning() {
|
||||
utils.Info("系统配置启用自动处理待处理资源,启动定时任务")
|
||||
gs.manager.StartReadyResourceScheduler()
|
||||
}
|
||||
} else {
|
||||
if gs.manager.IsReadyResourceRunning() {
|
||||
utils.Info("系统配置禁用自动处理待处理资源,停止定时任务")
|
||||
gs.manager.StopReadyResourceScheduler()
|
||||
}
|
||||
}
|
||||
|
||||
// 处理自动转存功能
|
||||
if autoTransferEnabled {
|
||||
if !gs.manager.IsAutoTransferRunning() {
|
||||
utils.Info("系统配置启用自动转存,启动定时任务")
|
||||
gs.manager.StartAutoTransferScheduler()
|
||||
}
|
||||
} else {
|
||||
if gs.manager.IsAutoTransferRunning() {
|
||||
utils.Info("系统配置禁用自动转存,停止定时任务")
|
||||
gs.manager.StopAutoTransferScheduler()
|
||||
}
|
||||
}
|
||||
}
|
||||
235
scheduler/hot_drama.go
Normal file
235
scheduler/hot_drama.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
)
|
||||
|
||||
// HotDramaScheduler 热播剧调度器
|
||||
type HotDramaScheduler struct {
|
||||
*BaseScheduler
|
||||
doubanService *utils.DoubanService
|
||||
hotDramaMutex sync.Mutex // 防止热播剧任务重叠执行
|
||||
}
|
||||
|
||||
// NewHotDramaScheduler 创建热播剧调度器
|
||||
func NewHotDramaScheduler(base *BaseScheduler) *HotDramaScheduler {
|
||||
return &HotDramaScheduler{
|
||||
BaseScheduler: base,
|
||||
doubanService: utils.NewDoubanService(),
|
||||
hotDramaMutex: sync.Mutex{},
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动热播剧定时任务
|
||||
func (h *HotDramaScheduler) Start() {
|
||||
if h.IsRunning() {
|
||||
utils.Info("热播剧定时任务已在运行中")
|
||||
return
|
||||
}
|
||||
|
||||
h.SetRunning(true)
|
||||
utils.Info("启动热播剧定时任务")
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(12 * time.Hour) // 每12小时执行一次
|
||||
defer ticker.Stop()
|
||||
|
||||
// 立即执行一次
|
||||
h.fetchHotDramaData()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
// 使用TryLock防止任务重叠执行
|
||||
if h.hotDramaMutex.TryLock() {
|
||||
go func() {
|
||||
defer h.hotDramaMutex.Unlock()
|
||||
h.fetchHotDramaData()
|
||||
}()
|
||||
} else {
|
||||
utils.Info("上一次热播剧任务还在执行中,跳过本次执行")
|
||||
}
|
||||
case <-h.GetStopChan():
|
||||
utils.Info("停止热播剧定时任务")
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop 停止热播剧定时任务
|
||||
func (h *HotDramaScheduler) Stop() {
|
||||
if !h.IsRunning() {
|
||||
utils.Info("热播剧定时任务未在运行")
|
||||
return
|
||||
}
|
||||
|
||||
h.GetStopChan() <- true
|
||||
h.SetRunning(false)
|
||||
utils.Info("已发送停止信号给热播剧定时任务")
|
||||
}
|
||||
|
||||
// fetchHotDramaData 获取热播剧数据
|
||||
func (h *HotDramaScheduler) fetchHotDramaData() {
|
||||
utils.Info("开始获取热播剧数据...")
|
||||
|
||||
// 直接处理电影和电视剧数据,不再需要FetchHotDramaNames
|
||||
h.processHotDramaNames([]string{})
|
||||
}
|
||||
|
||||
// processHotDramaNames 处理热播剧名称
|
||||
func (h *HotDramaScheduler) processHotDramaNames(dramaNames []string) {
|
||||
utils.Info("开始处理热播剧数据,共 %d 个", len(dramaNames))
|
||||
|
||||
// 收集所有数据
|
||||
var allDramas []*entity.HotDrama
|
||||
|
||||
// 获取电影数据
|
||||
movieDramas := h.processMovieData()
|
||||
allDramas = append(allDramas, movieDramas...)
|
||||
|
||||
// 获取电视剧数据
|
||||
tvDramas := h.processTvData()
|
||||
allDramas = append(allDramas, tvDramas...)
|
||||
|
||||
// 清空数据库
|
||||
utils.Info("准备清空数据库,当前共有 %d 条数据", len(allDramas))
|
||||
if err := h.hotDramaRepo.DeleteAll(); err != nil {
|
||||
utils.Error(fmt.Sprintf("清空数据库失败: %v", err))
|
||||
return
|
||||
}
|
||||
utils.Info("数据库清空完成")
|
||||
|
||||
// 批量插入所有数据
|
||||
if len(allDramas) > 0 {
|
||||
utils.Info("开始批量插入 %d 条数据", len(allDramas))
|
||||
if err := h.hotDramaRepo.BatchCreate(allDramas); err != nil {
|
||||
utils.Error(fmt.Sprintf("批量插入数据失败: %v", err))
|
||||
} else {
|
||||
utils.Info("成功批量插入 %d 条数据", len(allDramas))
|
||||
}
|
||||
} else {
|
||||
utils.Info("没有数据需要插入")
|
||||
}
|
||||
|
||||
utils.Info("热播剧数据处理完成")
|
||||
}
|
||||
|
||||
// processMovieData 处理电影数据
|
||||
func (h *HotDramaScheduler) processMovieData() []*entity.HotDrama {
|
||||
utils.Info("开始处理电影数据...")
|
||||
|
||||
var movieDramas []*entity.HotDrama
|
||||
|
||||
// 使用GetTypePage方法获取电影数据
|
||||
movieResult, err := h.doubanService.GetTypePage("热门", "全部")
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取电影榜单失败: %v", err))
|
||||
return movieDramas
|
||||
}
|
||||
|
||||
if movieResult.Success && movieResult.Data != nil {
|
||||
utils.Info("电影获取到 %d 个数据", len(movieResult.Data.Items))
|
||||
|
||||
for _, item := range movieResult.Data.Items {
|
||||
drama := &entity.HotDrama{
|
||||
Title: item.Title,
|
||||
CardSubtitle: item.CardSubtitle,
|
||||
EpisodesInfo: item.EpisodesInfo,
|
||||
IsNew: item.IsNew,
|
||||
Rating: item.Rating.Value,
|
||||
RatingCount: item.Rating.Count,
|
||||
Year: item.Year,
|
||||
Region: item.Region,
|
||||
Genres: strings.Join(item.Genres, ", "),
|
||||
Directors: strings.Join(item.Directors, ", "),
|
||||
Actors: strings.Join(item.Actors, ", "),
|
||||
PosterURL: item.Pic.Normal,
|
||||
Category: "电影",
|
||||
SubType: "热门",
|
||||
Source: "douban",
|
||||
DoubanID: item.ID,
|
||||
DoubanURI: item.URI,
|
||||
}
|
||||
|
||||
movieDramas = append(movieDramas, drama)
|
||||
utils.Info("收集电影: %s (评分: %.1f, 年份: %s, 地区: %s)",
|
||||
item.Title, item.Rating.Value, item.Year, item.Region)
|
||||
}
|
||||
} else {
|
||||
utils.Warn("电影获取数据失败或为空")
|
||||
}
|
||||
|
||||
utils.Info("电影数据处理完成,共收集 %d 条数据", len(movieDramas))
|
||||
return movieDramas
|
||||
}
|
||||
|
||||
// processTvData 处理电视剧数据
|
||||
func (h *HotDramaScheduler) processTvData() []*entity.HotDrama {
|
||||
utils.Info("开始处理电视剧数据...")
|
||||
|
||||
var tvDramas []*entity.HotDrama
|
||||
|
||||
// 获取所有tv类型
|
||||
tvTypes := h.doubanService.GetAllTvTypes()
|
||||
utils.Info("获取到 %d 个tv类型: %v", len(tvTypes), tvTypes)
|
||||
|
||||
// 遍历每个type,分别请求数据
|
||||
for _, tvType := range tvTypes {
|
||||
utils.Info("正在处理tv类型: %s", tvType)
|
||||
|
||||
// 使用GetTypePage方法请求数据
|
||||
tvResult, err := h.doubanService.GetTypePage("tv", tvType)
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取tv类型 %s 数据失败: %v", tvType, err))
|
||||
continue
|
||||
}
|
||||
|
||||
if tvResult.Success && tvResult.Data != nil {
|
||||
utils.Info("tv类型 %s 获取到 %d 个数据", tvType, len(tvResult.Data.Items))
|
||||
|
||||
for _, item := range tvResult.Data.Items {
|
||||
drama := &entity.HotDrama{
|
||||
Title: item.Title,
|
||||
CardSubtitle: item.CardSubtitle,
|
||||
EpisodesInfo: item.EpisodesInfo,
|
||||
IsNew: item.IsNew,
|
||||
Rating: item.Rating.Value,
|
||||
RatingCount: item.Rating.Count,
|
||||
Year: item.Year,
|
||||
Region: item.Region,
|
||||
Genres: strings.Join(item.Genres, ", "),
|
||||
Directors: strings.Join(item.Directors, ", "),
|
||||
Actors: strings.Join(item.Actors, ", "),
|
||||
PosterURL: item.Pic.Normal,
|
||||
Category: "电视剧",
|
||||
SubType: tvType, // 使用具体的tv类型
|
||||
Source: "douban",
|
||||
DoubanID: item.ID,
|
||||
DoubanURI: item.URI,
|
||||
}
|
||||
|
||||
tvDramas = append(tvDramas, drama)
|
||||
utils.Info("收集tv类型 %s: %s (评分: %.1f, 年份: %s, 地区: %s)",
|
||||
tvType, item.Title, item.Rating.Value, item.Year, item.Region)
|
||||
}
|
||||
} else {
|
||||
utils.Warn("tv类型 %s 获取数据失败或为空", tvType)
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info("电视剧数据处理完成,共收集 %d 条数据", len(tvDramas))
|
||||
return tvDramas
|
||||
}
|
||||
|
||||
// GetHotDramaNames 获取热播剧名称列表(公共方法)
|
||||
func (h *HotDramaScheduler) GetHotDramaNames() ([]string, error) {
|
||||
// 由于删除了FetchHotDramaNames方法,返回空数组
|
||||
return []string{}, nil
|
||||
}
|
||||
141
scheduler/manager.go
Normal file
141
scheduler/manager.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
)
|
||||
|
||||
// Manager 调度器管理器
|
||||
type Manager struct {
|
||||
baseScheduler *BaseScheduler
|
||||
hotDramaScheduler *HotDramaScheduler
|
||||
readyResourceScheduler *ReadyResourceScheduler
|
||||
autoTransferScheduler *AutoTransferScheduler
|
||||
}
|
||||
|
||||
// NewManager 创建调度器管理器
|
||||
func NewManager(
|
||||
hotDramaRepo repo.HotDramaRepository,
|
||||
readyResourceRepo repo.ReadyResourceRepository,
|
||||
resourceRepo repo.ResourceRepository,
|
||||
systemConfigRepo repo.SystemConfigRepository,
|
||||
panRepo repo.PanRepository,
|
||||
cksRepo repo.CksRepository,
|
||||
tagRepo repo.TagRepository,
|
||||
categoryRepo repo.CategoryRepository,
|
||||
) *Manager {
|
||||
// 创建基础调度器
|
||||
baseScheduler := NewBaseScheduler(
|
||||
hotDramaRepo,
|
||||
readyResourceRepo,
|
||||
resourceRepo,
|
||||
systemConfigRepo,
|
||||
panRepo,
|
||||
cksRepo,
|
||||
tagRepo,
|
||||
categoryRepo,
|
||||
)
|
||||
|
||||
// 创建各个具体的调度器
|
||||
hotDramaScheduler := NewHotDramaScheduler(baseScheduler)
|
||||
readyResourceScheduler := NewReadyResourceScheduler(baseScheduler)
|
||||
autoTransferScheduler := NewAutoTransferScheduler(baseScheduler)
|
||||
|
||||
return &Manager{
|
||||
baseScheduler: baseScheduler,
|
||||
hotDramaScheduler: hotDramaScheduler,
|
||||
readyResourceScheduler: readyResourceScheduler,
|
||||
autoTransferScheduler: autoTransferScheduler,
|
||||
}
|
||||
}
|
||||
|
||||
// StartAll 启动所有调度任务
|
||||
func (m *Manager) StartAll() {
|
||||
utils.Info("启动所有调度任务")
|
||||
|
||||
// 启动热播剧调度任务
|
||||
m.hotDramaScheduler.Start()
|
||||
|
||||
// 启动待处理资源调度任务
|
||||
m.readyResourceScheduler.Start()
|
||||
|
||||
// 启动自动转存调度任务
|
||||
m.autoTransferScheduler.Start()
|
||||
|
||||
utils.Info("所有调度任务已启动")
|
||||
}
|
||||
|
||||
// StopAll 停止所有调度任务
|
||||
func (m *Manager) StopAll() {
|
||||
utils.Info("停止所有调度任务")
|
||||
|
||||
// 停止热播剧调度任务
|
||||
m.hotDramaScheduler.Stop()
|
||||
|
||||
// 停止待处理资源调度任务
|
||||
m.readyResourceScheduler.Stop()
|
||||
|
||||
// 停止自动转存调度任务
|
||||
m.autoTransferScheduler.Stop()
|
||||
|
||||
utils.Info("所有调度任务已停止")
|
||||
}
|
||||
|
||||
// StartHotDramaScheduler 启动热播剧调度任务
|
||||
func (m *Manager) StartHotDramaScheduler() {
|
||||
m.hotDramaScheduler.Start()
|
||||
}
|
||||
|
||||
// StopHotDramaScheduler 停止热播剧调度任务
|
||||
func (m *Manager) StopHotDramaScheduler() {
|
||||
m.hotDramaScheduler.Stop()
|
||||
}
|
||||
|
||||
// IsHotDramaRunning 检查热播剧调度任务是否正在运行
|
||||
func (m *Manager) IsHotDramaRunning() bool {
|
||||
return m.hotDramaScheduler.IsRunning()
|
||||
}
|
||||
|
||||
// StartReadyResourceScheduler 启动待处理资源调度任务
|
||||
func (m *Manager) StartReadyResourceScheduler() {
|
||||
m.readyResourceScheduler.Start()
|
||||
}
|
||||
|
||||
// StopReadyResourceScheduler 停止待处理资源调度任务
|
||||
func (m *Manager) StopReadyResourceScheduler() {
|
||||
m.readyResourceScheduler.Stop()
|
||||
}
|
||||
|
||||
// IsReadyResourceRunning 检查待处理资源调度任务是否正在运行
|
||||
func (m *Manager) IsReadyResourceRunning() bool {
|
||||
return m.readyResourceScheduler.IsReadyResourceRunning()
|
||||
}
|
||||
|
||||
// StartAutoTransferScheduler 启动自动转存调度任务
|
||||
func (m *Manager) StartAutoTransferScheduler() {
|
||||
m.autoTransferScheduler.Start()
|
||||
}
|
||||
|
||||
// StopAutoTransferScheduler 停止自动转存调度任务
|
||||
func (m *Manager) StopAutoTransferScheduler() {
|
||||
m.autoTransferScheduler.Stop()
|
||||
}
|
||||
|
||||
// IsAutoTransferRunning 检查自动转存调度任务是否正在运行
|
||||
func (m *Manager) IsAutoTransferRunning() bool {
|
||||
return m.autoTransferScheduler.IsAutoTransferRunning()
|
||||
}
|
||||
|
||||
// GetHotDramaNames 获取热播剧名称列表
|
||||
func (m *Manager) GetHotDramaNames() ([]string, error) {
|
||||
return m.hotDramaScheduler.GetHotDramaNames()
|
||||
}
|
||||
|
||||
// GetStatus 获取所有调度任务的状态
|
||||
func (m *Manager) GetStatus() map[string]bool {
|
||||
return map[string]bool{
|
||||
"hot_drama": m.IsHotDramaRunning(),
|
||||
"ready_resource": m.IsReadyResourceRunning(),
|
||||
"auto_transfer": m.IsAutoTransferRunning(),
|
||||
}
|
||||
}
|
||||
490
scheduler/ready_resource.go
Normal file
490
scheduler/ready_resource.go
Normal file
@@ -0,0 +1,490 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
panutils "github.com/ctwj/urldb/common"
|
||||
commonutils "github.com/ctwj/urldb/common/utils"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
)
|
||||
|
||||
// ReadyResourceScheduler 待处理资源调度器
|
||||
type ReadyResourceScheduler struct {
|
||||
*BaseScheduler
|
||||
readyResourceRunning bool
|
||||
processingMutex sync.Mutex // 防止ready_resource任务重叠执行
|
||||
}
|
||||
|
||||
// NewReadyResourceScheduler 创建待处理资源调度器
|
||||
func NewReadyResourceScheduler(base *BaseScheduler) *ReadyResourceScheduler {
|
||||
return &ReadyResourceScheduler{
|
||||
BaseScheduler: base,
|
||||
readyResourceRunning: false,
|
||||
processingMutex: sync.Mutex{},
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动待处理资源定时任务
|
||||
func (r *ReadyResourceScheduler) Start() {
|
||||
if r.readyResourceRunning {
|
||||
utils.Info("待处理资源自动处理任务已在运行中")
|
||||
return
|
||||
}
|
||||
|
||||
r.readyResourceRunning = true
|
||||
utils.Info("启动待处理资源自动处理任务")
|
||||
|
||||
go func() {
|
||||
// 获取系统配置中的间隔时间
|
||||
interval := 3 * time.Minute // 默认3分钟
|
||||
if autoProcessInterval, err := r.systemConfigRepo.GetConfigInt(entity.ConfigKeyAutoProcessInterval); err == nil && autoProcessInterval > 0 {
|
||||
interval = time.Duration(autoProcessInterval) * time.Minute
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
utils.Info(fmt.Sprintf("待处理资源自动处理任务已启动,间隔时间: %v", interval))
|
||||
|
||||
// 立即执行一次
|
||||
r.processReadyResources()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
// 使用TryLock防止任务重叠执行
|
||||
if r.processingMutex.TryLock() {
|
||||
go func() {
|
||||
defer r.processingMutex.Unlock()
|
||||
r.processReadyResources()
|
||||
}()
|
||||
} else {
|
||||
utils.Info("上一次待处理资源任务还在执行中,跳过本次执行")
|
||||
}
|
||||
case <-r.GetStopChan():
|
||||
utils.Info("停止待处理资源自动处理任务")
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop 停止待处理资源定时任务
|
||||
func (r *ReadyResourceScheduler) Stop() {
|
||||
if !r.readyResourceRunning {
|
||||
utils.Info("待处理资源自动处理任务未在运行")
|
||||
return
|
||||
}
|
||||
|
||||
r.GetStopChan() <- true
|
||||
r.readyResourceRunning = false
|
||||
utils.Info("已发送停止信号给待处理资源自动处理任务")
|
||||
}
|
||||
|
||||
// IsReadyResourceRunning 检查待处理资源任务是否正在运行
|
||||
func (r *ReadyResourceScheduler) IsReadyResourceRunning() bool {
|
||||
return r.readyResourceRunning
|
||||
}
|
||||
|
||||
// processReadyResources 处理待处理资源
|
||||
func (r *ReadyResourceScheduler) processReadyResources() {
|
||||
utils.Info("开始处理待处理资源...")
|
||||
|
||||
// 检查系统配置,确认是否启用自动处理
|
||||
autoProcess, err := r.systemConfigRepo.GetConfigBool(entity.ConfigKeyAutoProcessReadyResources)
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取系统配置失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if !autoProcess {
|
||||
utils.Info("自动处理待处理资源功能已禁用")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取所有没有错误的待处理资源
|
||||
readyResources, err := r.readyResourceRepo.FindAll()
|
||||
// readyResources, err := r.readyResourceRepo.FindWithoutErrors()
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取待处理资源失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if len(readyResources) == 0 {
|
||||
utils.Info("没有待处理的资源")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info(fmt.Sprintf("找到 %d 个待处理资源,开始处理...", len(readyResources)))
|
||||
|
||||
processedCount := 0
|
||||
factory := panutils.GetInstance() // 使用单例模式
|
||||
for _, readyResource := range readyResources {
|
||||
|
||||
//readyResource.URL 是 查重
|
||||
exits, err := r.resourceRepo.FindExists(readyResource.URL)
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("查重失败: %v", err))
|
||||
continue
|
||||
}
|
||||
if exits {
|
||||
utils.Info(fmt.Sprintf("资源已存在: %s", readyResource.URL))
|
||||
r.readyResourceRepo.Delete(readyResource.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := r.convertReadyResourceToResource(readyResource, factory); err != nil {
|
||||
utils.Error(fmt.Sprintf("处理资源失败 (ID: %d): %v", readyResource.ID, err))
|
||||
|
||||
// 保存完整的错误信息
|
||||
readyResource.ErrorMsg = err.Error()
|
||||
|
||||
if updateErr := r.readyResourceRepo.Update(&readyResource); updateErr != nil {
|
||||
utils.Error(fmt.Sprintf("更新错误信息失败 (ID: %d): %v", readyResource.ID, updateErr))
|
||||
} else {
|
||||
utils.Info(fmt.Sprintf("已保存错误信息到资源 (ID: %d): %s", readyResource.ID, err.Error()))
|
||||
}
|
||||
|
||||
// 处理失败后删除资源,避免重复处理
|
||||
r.readyResourceRepo.Delete(readyResource.ID)
|
||||
} else {
|
||||
// 处理成功,删除readyResource
|
||||
r.readyResourceRepo.Delete(readyResource.ID)
|
||||
processedCount++
|
||||
utils.Info(fmt.Sprintf("成功处理资源: %s", readyResource.URL))
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info(fmt.Sprintf("待处理资源处理完成,共处理 %d 个资源", processedCount))
|
||||
}
|
||||
|
||||
// convertReadyResourceToResource 将待处理资源转换为正式资源
|
||||
func (r *ReadyResourceScheduler) convertReadyResourceToResource(readyResource entity.ReadyResource, factory *panutils.PanFactory) error {
|
||||
utils.Debug(fmt.Sprintf("开始处理资源: %s", readyResource.URL))
|
||||
|
||||
// 提取分享ID和服务类型
|
||||
shareID, serviceType := panutils.ExtractShareId(readyResource.URL)
|
||||
if serviceType == panutils.NotFound {
|
||||
utils.Warn(fmt.Sprintf("不支持的链接地址: %s", readyResource.URL))
|
||||
return fmt.Errorf("不支持的链接地址: %s", readyResource.URL)
|
||||
}
|
||||
|
||||
utils.Debug(fmt.Sprintf("检测到服务类型: %s, 分享ID: %s", serviceType.String(), shareID))
|
||||
|
||||
resource := &entity.Resource{
|
||||
Title: derefString(readyResource.Title),
|
||||
Description: readyResource.Description,
|
||||
URL: readyResource.URL,
|
||||
Cover: readyResource.Img,
|
||||
IsValid: true,
|
||||
IsPublic: true,
|
||||
Key: readyResource.Key,
|
||||
PanID: r.getPanIDByServiceType(serviceType),
|
||||
}
|
||||
|
||||
// 检查违禁词
|
||||
forbiddenWords, err := r.systemConfigRepo.GetConfigValue(entity.ConfigKeyForbiddenWords)
|
||||
if err == nil && forbiddenWords != "" {
|
||||
words := strings.Split(forbiddenWords, ",")
|
||||
var matchedWords []string
|
||||
title := strings.ToLower(resource.Title)
|
||||
description := strings.ToLower(resource.Description)
|
||||
|
||||
for _, word := range words {
|
||||
word = strings.TrimSpace(word)
|
||||
if word != "" {
|
||||
wordLower := strings.ToLower(word)
|
||||
if strings.Contains(title, wordLower) || strings.Contains(description, wordLower) {
|
||||
matchedWords = append(matchedWords, word)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(matchedWords) > 0 {
|
||||
utils.Warn(fmt.Sprintf("资源包含违禁词: %s, 违禁词: %s", resource.Title, strings.Join(matchedWords, ", ")))
|
||||
return fmt.Errorf("存在违禁词: %s", strings.Join(matchedWords, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// 不是夸克,直接保存
|
||||
if serviceType != panutils.Quark {
|
||||
// 检测是否有效
|
||||
checkResult, err := commonutils.CheckURL(readyResource.URL)
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("链接检查失败: %v", err))
|
||||
return fmt.Errorf("链接检查失败: %v", err)
|
||||
}
|
||||
if !checkResult.Status {
|
||||
utils.Warn(fmt.Sprintf("链接无效: %s", readyResource.URL))
|
||||
return fmt.Errorf("链接无效: %s", readyResource.URL)
|
||||
}
|
||||
} else {
|
||||
// 获取夸克网盘账号的 cookie
|
||||
panID := r.getPanIDByServiceType(serviceType)
|
||||
if panID == nil {
|
||||
utils.Error("未找到对应的平台ID")
|
||||
return fmt.Errorf("未找到对应的平台ID")
|
||||
}
|
||||
|
||||
accounts, err := r.cksRepo.FindByPanID(*panID)
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取夸克网盘账号失败: %v", err))
|
||||
return fmt.Errorf("获取网盘账号失败: %v", err)
|
||||
}
|
||||
|
||||
if len(accounts) == 0 {
|
||||
utils.Error("没有可用的夸克网盘账号")
|
||||
return fmt.Errorf("没有可用的夸克网盘账号")
|
||||
}
|
||||
|
||||
// 选择第一个有效的账号
|
||||
var selectedAccount *entity.Cks
|
||||
for _, account := range accounts {
|
||||
if account.IsValid {
|
||||
selectedAccount = &account
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if selectedAccount == nil {
|
||||
utils.Error("没有有效的夸克网盘账号")
|
||||
return fmt.Errorf("没有有效的夸克网盘账号")
|
||||
}
|
||||
|
||||
utils.Debug(fmt.Sprintf("使用夸克网盘账号: %d, Cookie: %s", selectedAccount.ID, selectedAccount.Ck[:20]+"..."))
|
||||
|
||||
// 准备配置
|
||||
config := &panutils.PanConfig{
|
||||
URL: readyResource.URL,
|
||||
Code: "", // 可以从readyResource中获取
|
||||
IsType: 1, // 转存并分享后的资源信息 0 转存后分享, 1 只获取基本信息
|
||||
ExpiredType: 1, // 永久分享
|
||||
AdFid: "",
|
||||
Stoken: "",
|
||||
Cookie: selectedAccount.Ck, // 添加 cookie
|
||||
}
|
||||
|
||||
// 通过工厂获取对应的网盘服务单例
|
||||
panService, err := factory.CreatePanService(readyResource.URL, config)
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取网盘服务失败: %v", err))
|
||||
return fmt.Errorf("获取网盘服务失败: %v", err)
|
||||
}
|
||||
|
||||
// 统一处理:尝试转存获取标题
|
||||
result, err := panService.Transfer(shareID)
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("网盘信息获取失败: %v", err))
|
||||
return fmt.Errorf("网盘信息获取失败: %v", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
utils.Error(fmt.Sprintf("网盘信息获取失败: %s", result.Message))
|
||||
return fmt.Errorf("网盘信息获取失败: %s", result.Message)
|
||||
}
|
||||
|
||||
// 从结果中提取标题等信息
|
||||
if result.Data != nil {
|
||||
if data, ok := result.Data.(map[string]interface{}); ok {
|
||||
if title, ok := data["title"].(string); ok && title != "" {
|
||||
resource.Title = title
|
||||
}
|
||||
if description, ok := data["description"].(string); ok && description != "" {
|
||||
resource.Description = description
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理分类
|
||||
if readyResource.Category != "" {
|
||||
categoryID, err := r.resolveCategory(readyResource.Category, nil)
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("解析分类失败: %v", err))
|
||||
} else {
|
||||
resource.CategoryID = categoryID
|
||||
}
|
||||
}
|
||||
|
||||
// 处理标签
|
||||
if readyResource.Tags != "" {
|
||||
tagIDs, err := r.handleTags(readyResource.Tags)
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("处理标签失败: %v", err))
|
||||
} else {
|
||||
// 保存资源
|
||||
err = r.resourceRepo.Create(resource)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建资源失败: %v", err)
|
||||
}
|
||||
|
||||
// 创建资源标签关联
|
||||
for _, tagID := range tagIDs {
|
||||
resourceTag := &entity.ResourceTag{
|
||||
ResourceID: resource.ID,
|
||||
TagID: tagID,
|
||||
}
|
||||
err = r.resourceRepo.CreateResourceTag(resourceTag)
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("创建资源标签关联失败: %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 保存资源
|
||||
err := r.resourceRepo.Create(resource)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建资源失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// initPanCache 初始化平台缓存
|
||||
func (r *ReadyResourceScheduler) initPanCache() {
|
||||
r.panCacheOnce.Do(func() {
|
||||
// 获取所有平台数据
|
||||
pans, err := r.panRepo.FindAll()
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("初始化平台缓存失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 建立 ServiceType 到 PanID 的映射
|
||||
serviceTypeToPanName := map[string]string{
|
||||
"quark": "quark",
|
||||
"alipan": "aliyun", // 阿里云盘在数据库中的名称是 aliyun
|
||||
"baidu": "baidu",
|
||||
"uc": "uc",
|
||||
"xunlei": "xunlei",
|
||||
"tianyi": "tianyi",
|
||||
"123pan": "123pan",
|
||||
"115": "115",
|
||||
"unknown": "other",
|
||||
}
|
||||
|
||||
// 创建平台名称到ID的映射
|
||||
panNameToID := make(map[string]*uint)
|
||||
for _, pan := range pans {
|
||||
panID := pan.ID
|
||||
panNameToID[pan.Name] = &panID
|
||||
}
|
||||
|
||||
// 建立 ServiceType 到 PanID 的映射
|
||||
for serviceType, panName := range serviceTypeToPanName {
|
||||
if panID, exists := panNameToID[panName]; exists {
|
||||
r.panCache[serviceType] = panID
|
||||
utils.Info(fmt.Sprintf("平台映射缓存: %s -> %s (ID: %d)", serviceType, panName, *panID))
|
||||
} else {
|
||||
utils.Error(fmt.Sprintf("警告: 未找到平台 %s 对应的数据库记录", panName))
|
||||
}
|
||||
}
|
||||
|
||||
// 确保有默认的 other 平台
|
||||
if otherID, exists := panNameToID["other"]; exists {
|
||||
r.panCache["unknown"] = otherID
|
||||
}
|
||||
|
||||
utils.Info(fmt.Sprintf("平台映射缓存初始化完成,共 %d 个映射", len(r.panCache)))
|
||||
})
|
||||
}
|
||||
|
||||
// getPanIDByServiceType 根据服务类型获取平台ID
|
||||
func (r *ReadyResourceScheduler) getPanIDByServiceType(serviceType panutils.ServiceType) *uint {
|
||||
r.initPanCache()
|
||||
|
||||
serviceTypeStr := serviceType.String()
|
||||
if panID, exists := r.panCache[serviceTypeStr]; exists {
|
||||
return panID
|
||||
}
|
||||
|
||||
// 如果找不到,返回 other 平台的ID
|
||||
if otherID, exists := r.panCache["other"]; exists {
|
||||
utils.Error(fmt.Sprintf("未找到服务类型 %s 的映射,使用默认平台 other", serviceTypeStr))
|
||||
return otherID
|
||||
}
|
||||
|
||||
utils.Error(fmt.Sprintf("未找到服务类型 %s 的映射,且没有默认平台,返回nil", serviceTypeStr))
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleTags 处理标签
|
||||
func (r *ReadyResourceScheduler) handleTags(tagStr string) ([]uint, error) {
|
||||
if tagStr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tagNames := splitTags(tagStr)
|
||||
var tagIDs []uint
|
||||
|
||||
for _, tagName := range tagNames {
|
||||
tagName = strings.TrimSpace(tagName)
|
||||
if tagName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 查找或创建标签
|
||||
tag, err := r.tagRepo.FindByName(tagName)
|
||||
if err != nil {
|
||||
// 标签不存在,创建新标签
|
||||
tag = &entity.Tag{
|
||||
Name: tagName,
|
||||
}
|
||||
err = r.tagRepo.Create(tag)
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("创建标签失败: %v", err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
tagIDs = append(tagIDs, tag.ID)
|
||||
}
|
||||
|
||||
return tagIDs, nil
|
||||
}
|
||||
|
||||
// resolveCategory 解析分类
|
||||
func (r *ReadyResourceScheduler) resolveCategory(categoryName string, tagIDs []uint) (*uint, error) {
|
||||
if categoryName == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 查找分类
|
||||
category, err := r.categoryRepo.FindByName(categoryName)
|
||||
if err != nil {
|
||||
// 分类不存在,创建新分类
|
||||
category = &entity.Category{
|
||||
Name: categoryName,
|
||||
}
|
||||
err = r.categoryRepo.Create(category)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建分类失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &category.ID, nil
|
||||
}
|
||||
|
||||
// splitTags 分割标签字符串
|
||||
func splitTags(tagStr string) []string {
|
||||
// 支持多种分隔符
|
||||
tagStr = strings.ReplaceAll(tagStr, ",", ",")
|
||||
tagStr = strings.ReplaceAll(tagStr, ";", ",")
|
||||
tagStr = strings.ReplaceAll(tagStr, ";", ",")
|
||||
tagStr = strings.ReplaceAll(tagStr, "、", ",")
|
||||
|
||||
return strings.Split(tagStr, ",")
|
||||
}
|
||||
|
||||
// derefString 解引用字符串指针
|
||||
func derefString(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
433
task/task_processor.go
Normal file
433
task/task_processor.go
Normal file
@@ -0,0 +1,433 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
)
|
||||
|
||||
// TaskProcessor 任务处理器接口
|
||||
type TaskProcessor interface {
|
||||
Process(ctx context.Context, taskID uint, item *entity.TaskItem) error
|
||||
GetTaskType() string
|
||||
}
|
||||
|
||||
// TaskManager 任务管理器
|
||||
type TaskManager struct {
|
||||
processors map[string]TaskProcessor
|
||||
repoMgr *repo.RepositoryManager
|
||||
mu sync.RWMutex
|
||||
running map[uint]context.CancelFunc // 正在运行的任务
|
||||
}
|
||||
|
||||
// NewTaskManager 创建任务管理器
|
||||
func NewTaskManager(repoMgr *repo.RepositoryManager) *TaskManager {
|
||||
return &TaskManager{
|
||||
processors: make(map[string]TaskProcessor),
|
||||
repoMgr: repoMgr,
|
||||
running: make(map[uint]context.CancelFunc),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterProcessor 注册任务处理器
|
||||
func (tm *TaskManager) RegisterProcessor(processor TaskProcessor) {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
tm.processors[processor.GetTaskType()] = processor
|
||||
utils.Info("注册任务处理器: %s", processor.GetTaskType())
|
||||
}
|
||||
|
||||
// getRegisteredProcessors 获取已注册的处理器列表(用于调试)
|
||||
func (tm *TaskManager) getRegisteredProcessors() []string {
|
||||
var types []string
|
||||
for taskType := range tm.processors {
|
||||
types = append(types, taskType)
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
// StartTask 启动任务
|
||||
func (tm *TaskManager) StartTask(taskID uint) error {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
utils.Info("StartTask: 尝试启动任务 %d", taskID)
|
||||
|
||||
// 检查任务是否已在运行
|
||||
if _, exists := tm.running[taskID]; exists {
|
||||
utils.Info("任务 %d 已在运行中", taskID)
|
||||
return fmt.Errorf("任务 %d 已在运行中", taskID)
|
||||
}
|
||||
|
||||
// 获取任务信息
|
||||
task, err := tm.repoMgr.TaskRepository.GetByID(taskID)
|
||||
if err != nil {
|
||||
utils.Error("获取任务失败: %v", err)
|
||||
return fmt.Errorf("获取任务失败: %v", err)
|
||||
}
|
||||
|
||||
utils.Info("StartTask: 获取到任务 %d, 类型: %s, 状态: %s", task.ID, task.Type, task.Status)
|
||||
|
||||
// 获取处理器
|
||||
processor, exists := tm.processors[string(task.Type)]
|
||||
if !exists {
|
||||
utils.Error("未找到任务类型 %s 的处理器, 已注册的处理器: %v", task.Type, tm.getRegisteredProcessors())
|
||||
return fmt.Errorf("未找到任务类型 %s 的处理器", task.Type)
|
||||
}
|
||||
|
||||
utils.Info("StartTask: 找到处理器 %s", task.Type)
|
||||
|
||||
// 创建上下文
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
tm.running[taskID] = cancel
|
||||
|
||||
utils.Info("StartTask: 启动后台任务协程")
|
||||
// 启动后台任务
|
||||
go tm.processTask(ctx, task, processor)
|
||||
|
||||
utils.Info("StartTask: 任务 %d 启动成功", taskID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// PauseTask 暂停任务
|
||||
func (tm *TaskManager) PauseTask(taskID uint) error {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
utils.Info("PauseTask: 尝试暂停任务 %d", taskID)
|
||||
|
||||
// 检查任务是否在运行
|
||||
cancel, exists := tm.running[taskID]
|
||||
if !exists {
|
||||
// 检查数据库中任务状态
|
||||
task, err := tm.repoMgr.TaskRepository.GetByID(taskID)
|
||||
if err != nil {
|
||||
utils.Error("获取任务信息失败: %v", err)
|
||||
return fmt.Errorf("获取任务信息失败: %v", err)
|
||||
}
|
||||
|
||||
// 如果数据库中的状态是running,说明服务器重启了,直接更新状态
|
||||
if task.Status == "running" {
|
||||
utils.Info("任务 %d 在数据库中状态为running,但内存中不存在,可能是服务器重启,直接更新状态为paused", taskID)
|
||||
err := tm.repoMgr.TaskRepository.UpdateStatus(taskID, "paused")
|
||||
if err != nil {
|
||||
utils.Error("更新任务状态为暂停失败: %v", err)
|
||||
return fmt.Errorf("更新任务状态失败: %v", err)
|
||||
}
|
||||
utils.Info("任务 %d 暂停成功(服务器重启恢复)", taskID)
|
||||
return nil
|
||||
}
|
||||
|
||||
utils.Info("任务 %d 未在运行,无法暂停", taskID)
|
||||
return fmt.Errorf("任务 %d 未在运行", taskID)
|
||||
}
|
||||
|
||||
// 停止任务(类似stop,但状态标记为paused)
|
||||
cancel()
|
||||
delete(tm.running, taskID)
|
||||
|
||||
// 更新任务状态为暂停
|
||||
err := tm.repoMgr.TaskRepository.UpdateStatus(taskID, "paused")
|
||||
if err != nil {
|
||||
utils.Error("更新任务状态为暂停失败: %v", err)
|
||||
return fmt.Errorf("更新任务状态失败: %v", err)
|
||||
}
|
||||
|
||||
utils.Info("任务 %d 暂停成功", taskID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopTask 停止任务
|
||||
func (tm *TaskManager) StopTask(taskID uint) error {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
cancel, exists := tm.running[taskID]
|
||||
if !exists {
|
||||
// 检查数据库中任务状态
|
||||
task, err := tm.repoMgr.TaskRepository.GetByID(taskID)
|
||||
if err != nil {
|
||||
utils.Error("获取任务信息失败: %v", err)
|
||||
return fmt.Errorf("获取任务信息失败: %v", err)
|
||||
}
|
||||
|
||||
// 如果数据库中的状态是running,说明服务器重启了,直接更新状态
|
||||
if task.Status == "running" {
|
||||
utils.Info("任务 %d 在数据库中状态为running,但内存中不存在,可能是服务器重启,直接更新状态为paused", taskID)
|
||||
err := tm.repoMgr.TaskRepository.UpdateStatus(taskID, "paused")
|
||||
if err != nil {
|
||||
utils.Error("更新任务状态失败: %v", err)
|
||||
return fmt.Errorf("更新任务状态失败: %v", err)
|
||||
}
|
||||
utils.Info("任务 %d 停止成功(服务器重启恢复)", taskID)
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("任务 %d 未在运行", taskID)
|
||||
}
|
||||
|
||||
cancel()
|
||||
delete(tm.running, taskID)
|
||||
|
||||
// 更新任务状态为暂停
|
||||
err := tm.repoMgr.TaskRepository.UpdateStatus(taskID, "paused")
|
||||
if err != nil {
|
||||
utils.Error("更新任务状态失败: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processTask 处理任务
|
||||
func (tm *TaskManager) processTask(ctx context.Context, task *entity.Task, processor TaskProcessor) {
|
||||
defer func() {
|
||||
tm.mu.Lock()
|
||||
delete(tm.running, task.ID)
|
||||
tm.mu.Unlock()
|
||||
utils.Info("processTask: 任务 %d 处理完成,清理资源", task.ID)
|
||||
}()
|
||||
|
||||
utils.Info("processTask: 开始处理任务: %d, 类型: %s", task.ID, task.Type)
|
||||
|
||||
// 更新任务状态为运行中
|
||||
err := tm.repoMgr.TaskRepository.UpdateStatus(task.ID, "running")
|
||||
if err != nil {
|
||||
utils.Error("更新任务状态失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取任务项统计信息,用于计算正确的进度
|
||||
stats, err := tm.repoMgr.TaskItemRepository.GetStatsByTaskID(task.ID)
|
||||
if err != nil {
|
||||
utils.Error("获取任务项统计失败: %v", err)
|
||||
stats = map[string]int{
|
||||
"total": 0,
|
||||
"pending": 0,
|
||||
"processing": 0,
|
||||
"completed": 0,
|
||||
"failed": 0,
|
||||
}
|
||||
}
|
||||
|
||||
// 获取待处理的任务项
|
||||
items, err := tm.repoMgr.TaskItemRepository.GetByTaskIDAndStatus(task.ID, "pending")
|
||||
if err != nil {
|
||||
utils.Error("获取任务项失败: %v", err)
|
||||
tm.markTaskFailed(task.ID, fmt.Sprintf("获取任务项失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 计算总任务项数和已完成的项数
|
||||
totalItems := stats["total"]
|
||||
completedItems := stats["completed"]
|
||||
initialFailedItems := stats["failed"]
|
||||
processingItems := stats["processing"]
|
||||
|
||||
// 如果当前批次有处理中的任务项,重置它们为pending状态(服务器重启恢复)
|
||||
if processingItems > 0 {
|
||||
utils.Info("任务 %d 发现 %d 个处理中的任务项,重置为pending状态", task.ID, processingItems)
|
||||
err = tm.repoMgr.TaskItemRepository.ResetProcessingItems(task.ID)
|
||||
if err != nil {
|
||||
utils.Error("重置处理中任务项失败: %v", err)
|
||||
}
|
||||
// 重新获取待处理的任务项
|
||||
items, err = tm.repoMgr.TaskItemRepository.GetByTaskIDAndStatus(task.ID, "pending")
|
||||
if err != nil {
|
||||
utils.Error("重新获取任务项失败: %v", err)
|
||||
tm.markTaskFailed(task.ID, fmt.Sprintf("重新获取任务项失败: %v", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
currentBatchItems := len(items)
|
||||
processedItems := completedItems + initialFailedItems // 已经处理的项目数
|
||||
successItems := completedItems
|
||||
failedItems := initialFailedItems
|
||||
|
||||
utils.Info("任务 %d 统计信息: 总计=%d, 已完成=%d, 已失败=%d, 待处理=%d",
|
||||
task.ID, totalItems, completedItems, failedItems, currentBatchItems)
|
||||
|
||||
for _, item := range items {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
utils.Info("任务 %d 被取消", task.ID)
|
||||
return
|
||||
default:
|
||||
// 处理单个任务项
|
||||
err := tm.processTaskItem(ctx, task.ID, item, processor)
|
||||
processedItems++
|
||||
|
||||
if err != nil {
|
||||
failedItems++
|
||||
utils.Error("处理任务项 %d 失败: %v", item.ID, err)
|
||||
} else {
|
||||
successItems++
|
||||
}
|
||||
|
||||
// 更新任务进度(基于总任务项数)
|
||||
if totalItems > 0 {
|
||||
progress := float64(processedItems) / float64(totalItems) * 100
|
||||
tm.updateTaskProgress(task.ID, progress, processedItems, successItems, failedItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 任务完成
|
||||
status := "completed"
|
||||
message := fmt.Sprintf("任务完成,共处理 %d 项,成功 %d 项,失败 %d 项", processedItems, successItems, failedItems)
|
||||
|
||||
if failedItems > 0 && successItems == 0 {
|
||||
status = "failed"
|
||||
message = fmt.Sprintf("任务失败,共处理 %d 项,全部失败", processedItems)
|
||||
} else if failedItems > 0 {
|
||||
status = "partial_success"
|
||||
message = fmt.Sprintf("任务部分成功,共处理 %d 项,成功 %d 项,失败 %d 项", processedItems, successItems, failedItems)
|
||||
}
|
||||
|
||||
err = tm.repoMgr.TaskRepository.UpdateStatusAndMessage(task.ID, status, message)
|
||||
if err != nil {
|
||||
utils.Error("更新任务状态失败: %v", err)
|
||||
}
|
||||
|
||||
utils.Info("任务 %d 处理完成: %s", task.ID, message)
|
||||
}
|
||||
|
||||
// processTaskItem 处理单个任务项
|
||||
func (tm *TaskManager) processTaskItem(ctx context.Context, taskID uint, item *entity.TaskItem, processor TaskProcessor) error {
|
||||
// 更新任务项状态为处理中
|
||||
err := tm.repoMgr.TaskItemRepository.UpdateStatus(item.ID, "processing")
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新任务项状态失败: %v", err)
|
||||
}
|
||||
|
||||
// 处理任务项
|
||||
err = processor.Process(ctx, taskID, item)
|
||||
|
||||
if err != nil {
|
||||
// 处理失败
|
||||
outputData := map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"time": utils.GetCurrentTime(),
|
||||
}
|
||||
outputJSON, _ := json.Marshal(outputData)
|
||||
|
||||
updateErr := tm.repoMgr.TaskItemRepository.UpdateStatusAndOutput(item.ID, "failed", string(outputJSON))
|
||||
if updateErr != nil {
|
||||
utils.Error("更新失败任务项状态失败: %v", updateErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 处理成功
|
||||
outputData := map[string]interface{}{
|
||||
"success": true,
|
||||
"time": utils.GetCurrentTime(),
|
||||
}
|
||||
outputJSON, _ := json.Marshal(outputData)
|
||||
|
||||
err = tm.repoMgr.TaskItemRepository.UpdateStatusAndOutput(item.ID, "completed", string(outputJSON))
|
||||
if err != nil {
|
||||
utils.Error("更新成功任务项状态失败: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateTaskProgress 更新任务进度
|
||||
func (tm *TaskManager) updateTaskProgress(taskID uint, progress float64, processed, success, failed int) {
|
||||
// 更新任务统计信息
|
||||
err := tm.repoMgr.TaskRepository.UpdateTaskStats(taskID, processed, success, failed)
|
||||
if err != nil {
|
||||
utils.Error("更新任务统计信息失败: %v", err)
|
||||
}
|
||||
|
||||
// 更新进度数据(用于兼容性)
|
||||
progressData := map[string]interface{}{
|
||||
"progress": progress,
|
||||
"processed": processed,
|
||||
"success": success,
|
||||
"failed": failed,
|
||||
"time": utils.GetCurrentTime(),
|
||||
}
|
||||
|
||||
progressJSON, _ := json.Marshal(progressData)
|
||||
|
||||
err = tm.repoMgr.TaskRepository.UpdateProgress(taskID, progress, string(progressJSON))
|
||||
if err != nil {
|
||||
utils.Error("更新任务进度数据失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// markTaskFailed 标记任务失败
|
||||
func (tm *TaskManager) markTaskFailed(taskID uint, message string) {
|
||||
err := tm.repoMgr.TaskRepository.UpdateStatusAndMessage(taskID, "failed", message)
|
||||
if err != nil {
|
||||
utils.Error("标记任务失败状态失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// GetTaskStatus 获取任务状态
|
||||
func (tm *TaskManager) GetTaskStatus(taskID uint) (string, error) {
|
||||
task, err := tm.repoMgr.TaskRepository.GetByID(taskID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(task.Status), nil
|
||||
}
|
||||
|
||||
// IsTaskRunning 检查任务是否在运行
|
||||
func (tm *TaskManager) IsTaskRunning(taskID uint) bool {
|
||||
tm.mu.RLock()
|
||||
defer tm.mu.RUnlock()
|
||||
_, exists := tm.running[taskID]
|
||||
return exists
|
||||
}
|
||||
|
||||
// RecoverRunningTasks 恢复运行中的任务(服务器重启后调用)
|
||||
func (tm *TaskManager) RecoverRunningTasks() error {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
utils.Info("开始恢复运行中的任务...")
|
||||
|
||||
// 获取数据库中状态为running的任务
|
||||
tasks, _, err := tm.repoMgr.TaskRepository.GetList(1, 1000, "", "running")
|
||||
if err != nil {
|
||||
utils.Error("获取运行中任务失败: %v", err)
|
||||
return fmt.Errorf("获取运行中任务失败: %v", err)
|
||||
}
|
||||
|
||||
recoveredCount := 0
|
||||
for _, task := range tasks {
|
||||
// 检查任务是否已在内存中运行
|
||||
if _, exists := tm.running[task.ID]; exists {
|
||||
utils.Info("任务 %d 已在内存中运行,跳过恢复", task.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取处理器
|
||||
processor, exists := tm.processors[string(task.Type)]
|
||||
if !exists {
|
||||
utils.Error("未找到任务类型 %s 的处理器,跳过恢复任务 %d", task.Type, task.ID)
|
||||
// 将任务状态重置为pending,避免卡在running状态
|
||||
tm.repoMgr.TaskRepository.UpdateStatus(task.ID, "pending")
|
||||
continue
|
||||
}
|
||||
|
||||
// 创建上下文并恢复任务
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
tm.running[task.ID] = cancel
|
||||
|
||||
utils.Info("恢复任务 %d (类型: %s)", task.ID, task.Type)
|
||||
go tm.processTask(ctx, task, processor)
|
||||
recoveredCount++
|
||||
}
|
||||
|
||||
utils.Info("任务恢复完成,共恢复 %d 个任务", recoveredCount)
|
||||
return nil
|
||||
}
|
||||
513
task/transfer_processor.go
Normal file
513
task/transfer_processor.go
Normal file
@@ -0,0 +1,513 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
pan "github.com/ctwj/urldb/common"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
)
|
||||
|
||||
// TransferProcessor 转存任务处理器
|
||||
type TransferProcessor struct {
|
||||
repoMgr *repo.RepositoryManager
|
||||
}
|
||||
|
||||
// NewTransferProcessor 创建转存任务处理器
|
||||
func NewTransferProcessor(repoMgr *repo.RepositoryManager) *TransferProcessor {
|
||||
return &TransferProcessor{
|
||||
repoMgr: repoMgr,
|
||||
}
|
||||
}
|
||||
|
||||
// GetTaskType 获取任务类型
|
||||
func (tp *TransferProcessor) GetTaskType() string {
|
||||
return "transfer"
|
||||
}
|
||||
|
||||
// TransferInput 转存任务输入数据结构
|
||||
type TransferInput struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
CategoryID uint `json:"category_id"`
|
||||
PanID uint `json:"pan_id"`
|
||||
Tags []uint `json:"tags"`
|
||||
}
|
||||
|
||||
// TransferOutput 转存任务输出数据结构
|
||||
type TransferOutput struct {
|
||||
ResourceID uint `json:"resource_id,omitempty"`
|
||||
SaveURL string `json:"save_url,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
// Process 处理转存任务项
|
||||
func (tp *TransferProcessor) Process(ctx context.Context, taskID uint, item *entity.TaskItem) error {
|
||||
utils.Info("开始处理转存任务项: %d", item.ID)
|
||||
|
||||
// 解析输入数据
|
||||
var input TransferInput
|
||||
if err := json.Unmarshal([]byte(item.InputData), &input); err != nil {
|
||||
return fmt.Errorf("解析输入数据失败: %v", err)
|
||||
}
|
||||
|
||||
// 验证输入数据
|
||||
if err := tp.validateInput(&input); err != nil {
|
||||
return fmt.Errorf("输入数据验证失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取任务配置中的账号信息
|
||||
var selectedAccounts []uint
|
||||
task, err := tp.repoMgr.TaskRepository.GetByID(taskID)
|
||||
if err == nil && task.Config != "" {
|
||||
var taskConfig map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(task.Config), &taskConfig); err == nil {
|
||||
if accounts, ok := taskConfig["selected_accounts"].([]interface{}); ok {
|
||||
for _, acc := range accounts {
|
||||
if accID, ok := acc.(float64); ok {
|
||||
selectedAccounts = append(selectedAccounts, uint(accID))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查资源是否已存在
|
||||
exists, existingResource, err := tp.checkResourceExists(input.URL)
|
||||
if err != nil {
|
||||
utils.Error("检查资源是否存在失败: %v", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
// 检查已存在的资源是否有有效的转存链接
|
||||
if existingResource.SaveURL == "" {
|
||||
// 资源存在但没有转存链接,需要重新转存
|
||||
utils.Info("资源已存在但无转存链接,重新转存: %s", input.Title)
|
||||
} else {
|
||||
// 资源已存在且有转存链接,跳过转存
|
||||
output := TransferOutput{
|
||||
ResourceID: existingResource.ID,
|
||||
SaveURL: existingResource.SaveURL,
|
||||
Success: true,
|
||||
Time: utils.GetCurrentTimeString(),
|
||||
}
|
||||
|
||||
outputJSON, _ := json.Marshal(output)
|
||||
item.OutputData = string(outputJSON)
|
||||
|
||||
utils.Info("资源已存在且有转存链接,跳过转存: %s", input.Title)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 执行转存操作
|
||||
resourceID, saveURL, err := tp.performTransfer(ctx, &input, selectedAccounts)
|
||||
if err != nil {
|
||||
// 转存失败,更新输出数据
|
||||
output := TransferOutput{
|
||||
Error: err.Error(),
|
||||
Success: false,
|
||||
Time: utils.GetCurrentTimeString(),
|
||||
}
|
||||
|
||||
outputJSON, _ := json.Marshal(output)
|
||||
item.OutputData = string(outputJSON)
|
||||
|
||||
utils.Error("转存任务项处理失败: %d, 错误: %v", item.ID, err)
|
||||
return fmt.Errorf("转存失败: %v", err)
|
||||
}
|
||||
|
||||
// 验证转存结果
|
||||
if saveURL == "" {
|
||||
output := TransferOutput{
|
||||
Error: "转存成功但未获取到分享链接",
|
||||
Success: false,
|
||||
Time: utils.GetCurrentTimeString(),
|
||||
}
|
||||
|
||||
outputJSON, _ := json.Marshal(output)
|
||||
item.OutputData = string(outputJSON)
|
||||
|
||||
utils.Error("转存任务项处理失败: %d, 未获取到分享链接", item.ID)
|
||||
return fmt.Errorf("转存成功但未获取到分享链接")
|
||||
}
|
||||
|
||||
// 转存成功,更新输出数据
|
||||
output := TransferOutput{
|
||||
ResourceID: resourceID,
|
||||
SaveURL: saveURL,
|
||||
Success: true,
|
||||
Time: utils.GetCurrentTimeString(),
|
||||
}
|
||||
|
||||
outputJSON, _ := json.Marshal(output)
|
||||
item.OutputData = string(outputJSON)
|
||||
|
||||
utils.Info("转存任务项处理完成: %d, 资源ID: %d, 转存链接: %s", item.ID, resourceID, saveURL)
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateInput 验证输入数据
|
||||
func (tp *TransferProcessor) validateInput(input *TransferInput) error {
|
||||
if strings.TrimSpace(input.Title) == "" {
|
||||
return fmt.Errorf("标题不能为空")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(input.URL) == "" {
|
||||
return fmt.Errorf("链接不能为空")
|
||||
}
|
||||
|
||||
// 验证URL格式
|
||||
if !tp.isValidURL(input.URL) {
|
||||
return fmt.Errorf("链接格式不正确")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidURL 验证URL格式
|
||||
func (tp *TransferProcessor) isValidURL(url string) bool {
|
||||
// 简单的URL验证,可以根据需要扩展
|
||||
quarkPattern := `https://pan\.quark\.cn/s/[a-zA-Z0-9]+`
|
||||
matched, _ := regexp.MatchString(quarkPattern, url)
|
||||
return matched
|
||||
}
|
||||
|
||||
// checkResourceExists 检查资源是否已存在
|
||||
func (tp *TransferProcessor) checkResourceExists(url string) (bool, *entity.Resource, error) {
|
||||
// 根据URL查找资源
|
||||
resource, err := tp.repoMgr.ResourceRepository.GetByURL(url)
|
||||
if err != nil {
|
||||
// 如果是未找到记录的错误,则表示资源不存在
|
||||
if strings.Contains(err.Error(), "record not found") {
|
||||
return false, nil, nil
|
||||
}
|
||||
return false, nil, err
|
||||
}
|
||||
|
||||
return true, resource, nil
|
||||
}
|
||||
|
||||
// performTransfer 执行转存操作
|
||||
func (tp *TransferProcessor) performTransfer(ctx context.Context, input *TransferInput, selectedAccounts []uint) (uint, string, error) {
|
||||
// 解析URL获取分享信息
|
||||
shareInfo, err := tp.parseShareURL(input.URL)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("解析分享链接失败: %v", err)
|
||||
}
|
||||
|
||||
// 先执行转存操作
|
||||
saveURL, err := tp.transferToCloud(ctx, shareInfo, selectedAccounts)
|
||||
if err != nil {
|
||||
utils.Error("云端转存失败: %v", err)
|
||||
return 0, "", fmt.Errorf("转存失败: %v", err)
|
||||
}
|
||||
|
||||
// 验证转存链接是否有效
|
||||
if saveURL == "" {
|
||||
utils.Error("转存成功但未获取到分享链接")
|
||||
return 0, "", fmt.Errorf("转存成功但未获取到分享链接")
|
||||
}
|
||||
|
||||
// 转存成功,创建资源记录
|
||||
var categoryID *uint
|
||||
if input.CategoryID != 0 {
|
||||
categoryID = &input.CategoryID
|
||||
}
|
||||
|
||||
// 确定平台ID
|
||||
var panID uint
|
||||
if input.PanID != 0 {
|
||||
// 使用指定的平台ID
|
||||
panID = input.PanID
|
||||
utils.Info("使用指定的平台ID: %d", panID)
|
||||
} else {
|
||||
// 如果没有指定,默认使用夸克平台ID
|
||||
quarkPanID, err := tp.getQuarkPanID()
|
||||
if err != nil {
|
||||
utils.Error("获取夸克平台ID失败: %v", err)
|
||||
return 0, "", fmt.Errorf("获取夸克平台ID失败: %v", err)
|
||||
}
|
||||
panID = quarkPanID
|
||||
utils.Info("使用默认夸克平台ID: %d", panID)
|
||||
}
|
||||
|
||||
resource := &entity.Resource{
|
||||
Title: input.Title,
|
||||
URL: input.URL,
|
||||
CategoryID: categoryID,
|
||||
PanID: &panID, // 设置平台ID
|
||||
SaveURL: saveURL, // 直接设置转存链接
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// 保存资源到数据库
|
||||
err = tp.repoMgr.ResourceRepository.Create(resource)
|
||||
if err != nil {
|
||||
utils.Error("保存转存成功的资源失败: %v", err)
|
||||
return 0, "", fmt.Errorf("保存资源失败: %v", err)
|
||||
}
|
||||
|
||||
// 添加标签关联
|
||||
if len(input.Tags) > 0 {
|
||||
err = tp.addResourceTags(resource.ID, input.Tags)
|
||||
if err != nil {
|
||||
utils.Error("添加资源标签失败: %v", err)
|
||||
// 标签添加失败不影响资源创建,只记录错误
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info("转存成功,资源已创建 - 资源ID: %d, 转存链接: %s", resource.ID, saveURL)
|
||||
return resource.ID, saveURL, nil
|
||||
}
|
||||
|
||||
// ShareInfo 分享信息结构
|
||||
type ShareInfo struct {
|
||||
PanType string
|
||||
ShareID string
|
||||
URL string
|
||||
}
|
||||
|
||||
// parseShareURL 解析分享链接
|
||||
func (tp *TransferProcessor) parseShareURL(url string) (*ShareInfo, error) {
|
||||
// 解析夸克网盘链接
|
||||
quarkPattern := `https://pan\.quark\.cn/s/([a-zA-Z0-9]+)`
|
||||
re := regexp.MustCompile(quarkPattern)
|
||||
matches := re.FindStringSubmatch(url)
|
||||
|
||||
if len(matches) >= 2 {
|
||||
return &ShareInfo{
|
||||
PanType: "quark",
|
||||
ShareID: matches[1],
|
||||
URL: url,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("不支持的分享链接格式: %s", url)
|
||||
}
|
||||
|
||||
// addResourceTags 添加资源标签
|
||||
func (tp *TransferProcessor) addResourceTags(resourceID uint, tagIDs []uint) error {
|
||||
for _, tagID := range tagIDs {
|
||||
// 创建资源标签关联
|
||||
resourceTag := &entity.ResourceTag{
|
||||
ResourceID: resourceID,
|
||||
TagID: tagID,
|
||||
}
|
||||
|
||||
err := tp.repoMgr.ResourceRepository.CreateResourceTag(resourceTag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建资源标签关联失败: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// transferToCloud 执行云端转存
|
||||
func (tp *TransferProcessor) transferToCloud(ctx context.Context, shareInfo *ShareInfo, selectedAccounts []uint) (string, error) {
|
||||
// 转存任务独立于自动转存开关,直接执行转存逻辑
|
||||
// 获取转存相关的配置(如最小存储空间等),但不检查自动转存开关
|
||||
|
||||
// 如果指定了账号,使用指定的账号
|
||||
if len(selectedAccounts) > 0 {
|
||||
utils.Info("使用指定的账号进行转存,账号数量: %d", len(selectedAccounts))
|
||||
|
||||
// 获取指定的账号
|
||||
var validAccounts []entity.Cks
|
||||
for _, accountID := range selectedAccounts {
|
||||
account, err := tp.repoMgr.CksRepository.FindByID(accountID)
|
||||
if err != nil {
|
||||
utils.Error("获取账号 %d 失败: %v", accountID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !account.IsValid {
|
||||
utils.Error("账号 %d 无效", accountID)
|
||||
continue
|
||||
}
|
||||
|
||||
validAccounts = append(validAccounts, *account)
|
||||
}
|
||||
|
||||
if len(validAccounts) == 0 {
|
||||
return "", fmt.Errorf("指定的账号都无效或不存在")
|
||||
}
|
||||
|
||||
utils.Info("找到 %d 个有效账号,开始转存处理...", len(validAccounts))
|
||||
|
||||
// 使用第一个有效账号进行转存
|
||||
account := validAccounts[0]
|
||||
|
||||
// 创建网盘服务工厂
|
||||
factory := pan.NewPanFactory()
|
||||
|
||||
// 执行转存
|
||||
result := tp.transferSingleResource(shareInfo, account, factory)
|
||||
if !result.Success {
|
||||
return "", fmt.Errorf("转存失败: %s", result.ErrorMsg)
|
||||
}
|
||||
|
||||
return result.SaveURL, nil
|
||||
}
|
||||
|
||||
// 如果没有指定账号,使用原来的逻辑(自动选择)
|
||||
utils.Info("未指定账号,使用自动选择逻辑")
|
||||
|
||||
// 获取夸克平台ID
|
||||
quarkPanID, err := tp.getQuarkPanID()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取夸克平台ID失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取可用的夸克账号
|
||||
accounts, err := tp.repoMgr.CksRepository.FindAll()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取网盘账号失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取最小存储空间配置(转存任务需要关注此配置)
|
||||
autoTransferMinSpace, err := tp.repoMgr.SystemConfigRepository.GetConfigInt("auto_transfer_min_space")
|
||||
if err != nil {
|
||||
utils.Error("获取最小存储空间配置失败: %v", err)
|
||||
autoTransferMinSpace = 5 // 默认5GB
|
||||
}
|
||||
|
||||
// 过滤:只保留已激活、夸克平台、剩余空间足够的账号
|
||||
minSpaceBytes := int64(autoTransferMinSpace) * 1024 * 1024 * 1024
|
||||
var validAccounts []entity.Cks
|
||||
for _, acc := range accounts {
|
||||
if acc.IsValid && acc.PanID == quarkPanID && acc.LeftSpace >= minSpaceBytes {
|
||||
validAccounts = append(validAccounts, acc)
|
||||
}
|
||||
}
|
||||
|
||||
if len(validAccounts) == 0 {
|
||||
return "", fmt.Errorf("没有可用的夸克网盘账号(需要剩余空间 >= %d GB)", autoTransferMinSpace)
|
||||
}
|
||||
|
||||
utils.Info("找到 %d 个可用夸克网盘账号,开始转存处理...", len(validAccounts))
|
||||
|
||||
// 使用第一个可用账号进行转存
|
||||
account := validAccounts[0]
|
||||
|
||||
// 创建网盘服务工厂
|
||||
factory := pan.NewPanFactory()
|
||||
|
||||
// 执行转存
|
||||
result := tp.transferSingleResource(shareInfo, account, factory)
|
||||
if !result.Success {
|
||||
return "", fmt.Errorf("转存失败: %s", result.ErrorMsg)
|
||||
}
|
||||
|
||||
return result.SaveURL, nil
|
||||
}
|
||||
|
||||
// getQuarkPanID 获取夸克网盘ID
|
||||
func (tp *TransferProcessor) getQuarkPanID() (uint, error) {
|
||||
// 通过FindAll方法查找所有平台,然后过滤出quark平台
|
||||
pans, err := tp.repoMgr.PanRepository.FindAll()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("查询平台信息失败: %v", err)
|
||||
}
|
||||
|
||||
for _, p := range pans {
|
||||
if p.Name == "quark" {
|
||||
return p.ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("未找到quark平台")
|
||||
}
|
||||
|
||||
// TransferResult 转存结果
|
||||
type TransferResult struct {
|
||||
Success bool `json:"success"`
|
||||
SaveURL string `json:"save_url"`
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
}
|
||||
|
||||
// transferSingleResource 转存单个资源
|
||||
func (tp *TransferProcessor) transferSingleResource(shareInfo *ShareInfo, account entity.Cks, factory *pan.PanFactory) TransferResult {
|
||||
utils.Info("开始转存资源 - 分享ID: %s, 账号: %s", shareInfo.ShareID, account.Username)
|
||||
|
||||
service, err := factory.CreatePanService(shareInfo.URL, &pan.PanConfig{
|
||||
URL: shareInfo.URL,
|
||||
ExpiredType: 0,
|
||||
IsType: 0,
|
||||
Cookie: account.Ck,
|
||||
})
|
||||
if err != nil {
|
||||
utils.Error("创建网盘服务失败: %v", err)
|
||||
return TransferResult{
|
||||
Success: false,
|
||||
ErrorMsg: fmt.Sprintf("创建网盘服务失败: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
// 执行转存
|
||||
transferResult, err := service.Transfer(shareInfo.ShareID)
|
||||
if err != nil {
|
||||
utils.Error("转存失败: %v", err)
|
||||
return TransferResult{
|
||||
Success: false,
|
||||
ErrorMsg: fmt.Sprintf("转存失败: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
if transferResult == nil || !transferResult.Success {
|
||||
errMsg := "转存失败"
|
||||
if transferResult != nil && transferResult.Message != "" {
|
||||
errMsg = transferResult.Message
|
||||
}
|
||||
utils.Error("转存失败: %s", errMsg)
|
||||
return TransferResult{
|
||||
Success: false,
|
||||
ErrorMsg: errMsg,
|
||||
}
|
||||
}
|
||||
|
||||
// 提取转存链接
|
||||
var saveURL string
|
||||
if data, ok := transferResult.Data.(map[string]interface{}); ok {
|
||||
if v, ok := data["shareUrl"]; ok {
|
||||
saveURL, _ = v.(string)
|
||||
}
|
||||
}
|
||||
if saveURL == "" {
|
||||
saveURL = transferResult.ShareURL
|
||||
}
|
||||
|
||||
// 验证转存链接是否有效
|
||||
if saveURL == "" {
|
||||
utils.Error("转存成功但未获取到分享链接 - 分享ID: %s", shareInfo.ShareID)
|
||||
return TransferResult{
|
||||
Success: false,
|
||||
ErrorMsg: "转存成功但未获取到分享链接",
|
||||
}
|
||||
}
|
||||
|
||||
// 验证链接格式
|
||||
if !strings.HasPrefix(saveURL, "http") {
|
||||
utils.Error("转存链接格式无效 - 分享ID: %s, 链接: %s", shareInfo.ShareID, saveURL)
|
||||
return TransferResult{
|
||||
Success: false,
|
||||
ErrorMsg: "转存链接格式无效",
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info("转存成功 - 分享ID: %s, 转存链接: %s", shareInfo.ShareID, saveURL)
|
||||
|
||||
return TransferResult{
|
||||
Success: true,
|
||||
SaveURL: saveURL,
|
||||
}
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
)
|
||||
|
||||
// GlobalScheduler 全局调度器管理器
|
||||
type GlobalScheduler struct {
|
||||
scheduler *Scheduler
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
var (
|
||||
globalScheduler *GlobalScheduler
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// GetGlobalScheduler 获取全局调度器实例(单例模式)
|
||||
func GetGlobalScheduler(hotDramaRepo repo.HotDramaRepository, readyResourceRepo repo.ReadyResourceRepository, resourceRepo repo.ResourceRepository, systemConfigRepo repo.SystemConfigRepository, panRepo repo.PanRepository, cksRepo repo.CksRepository, tagRepo repo.TagRepository, categoryRepo repo.CategoryRepository) *GlobalScheduler {
|
||||
once.Do(func() {
|
||||
globalScheduler = &GlobalScheduler{
|
||||
scheduler: NewScheduler(hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo, panRepo, cksRepo, tagRepo, categoryRepo),
|
||||
}
|
||||
})
|
||||
return globalScheduler
|
||||
}
|
||||
|
||||
// StartHotDramaScheduler 启动热播剧定时任务
|
||||
func (gs *GlobalScheduler) StartHotDramaScheduler() {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
if gs.scheduler.IsRunning() {
|
||||
Info("热播剧定时任务已在运行中")
|
||||
return
|
||||
}
|
||||
|
||||
gs.scheduler.StartHotDramaScheduler()
|
||||
Info("全局调度器已启动热播剧定时任务")
|
||||
}
|
||||
|
||||
// StopHotDramaScheduler 停止热播剧定时任务
|
||||
func (gs *GlobalScheduler) StopHotDramaScheduler() {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
if !gs.scheduler.IsRunning() {
|
||||
Info("热播剧定时任务未在运行")
|
||||
return
|
||||
}
|
||||
|
||||
gs.scheduler.StopHotDramaScheduler()
|
||||
Info("全局调度器已停止热播剧定时任务")
|
||||
}
|
||||
|
||||
// IsHotDramaSchedulerRunning 检查热播剧定时任务是否在运行
|
||||
func (gs *GlobalScheduler) IsHotDramaSchedulerRunning() bool {
|
||||
gs.mutex.RLock()
|
||||
defer gs.mutex.RUnlock()
|
||||
return gs.scheduler.IsRunning()
|
||||
}
|
||||
|
||||
// GetHotDramaNames 手动获取热播剧名字
|
||||
func (gs *GlobalScheduler) GetHotDramaNames() ([]string, error) {
|
||||
return gs.scheduler.GetHotDramaNames()
|
||||
}
|
||||
|
||||
// StartReadyResourceScheduler 启动待处理资源自动处理任务
|
||||
func (gs *GlobalScheduler) StartReadyResourceScheduler() {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
if gs.scheduler.IsReadyResourceRunning() {
|
||||
Info("待处理资源自动处理任务已在运行中")
|
||||
return
|
||||
}
|
||||
|
||||
gs.scheduler.StartReadyResourceScheduler()
|
||||
Info("全局调度器已启动待处理资源自动处理任务")
|
||||
}
|
||||
|
||||
// StopReadyResourceScheduler 停止待处理资源自动处理任务
|
||||
func (gs *GlobalScheduler) StopReadyResourceScheduler() {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
if !gs.scheduler.IsReadyResourceRunning() {
|
||||
Info("待处理资源自动处理任务未在运行")
|
||||
return
|
||||
}
|
||||
|
||||
gs.scheduler.StopReadyResourceScheduler()
|
||||
Info("全局调度器已停止待处理资源自动处理任务")
|
||||
}
|
||||
|
||||
// IsReadyResourceRunning 检查待处理资源自动处理任务是否在运行
|
||||
func (gs *GlobalScheduler) IsReadyResourceRunning() bool {
|
||||
gs.mutex.RLock()
|
||||
defer gs.mutex.RUnlock()
|
||||
return gs.scheduler.IsReadyResourceRunning()
|
||||
}
|
||||
|
||||
// ProcessReadyResources 手动触发待处理资源处理
|
||||
func (gs *GlobalScheduler) ProcessReadyResources() {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
gs.scheduler.processReadyResources()
|
||||
}
|
||||
|
||||
// UpdateSchedulerStatus 根据系统配置更新调度器状态
|
||||
func (gs *GlobalScheduler) UpdateSchedulerStatus(autoFetchHotDramaEnabled bool, autoProcessReadyResources bool) {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
// 处理热播剧自动拉取功能
|
||||
if autoFetchHotDramaEnabled {
|
||||
if !gs.scheduler.IsRunning() {
|
||||
Info("系统配置启用自动拉取热播剧,启动定时任务")
|
||||
gs.scheduler.StartHotDramaScheduler()
|
||||
}
|
||||
} else {
|
||||
if gs.scheduler.IsRunning() {
|
||||
Info("系统配置禁用自动拉取热播剧,停止定时任务")
|
||||
gs.scheduler.StopHotDramaScheduler()
|
||||
}
|
||||
}
|
||||
|
||||
// 处理待处理资源自动处理功能
|
||||
if autoProcessReadyResources {
|
||||
if !gs.scheduler.IsReadyResourceRunning() {
|
||||
Info("系统配置启用自动处理待处理资源,启动定时任务")
|
||||
gs.scheduler.StartReadyResourceScheduler()
|
||||
}
|
||||
} else {
|
||||
if gs.scheduler.IsReadyResourceRunning() {
|
||||
Info("系统配置禁用自动处理待处理资源,停止定时任务")
|
||||
gs.scheduler.StopReadyResourceScheduler()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StartAutoTransferScheduler 启动自动转存定时任务
|
||||
func (gs *GlobalScheduler) StartAutoTransferScheduler() {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
if gs.scheduler.IsAutoTransferRunning() {
|
||||
Info("自动转存定时任务已在运行中")
|
||||
return
|
||||
}
|
||||
|
||||
gs.scheduler.StartAutoTransferScheduler()
|
||||
Info("全局调度器已启动自动转存定时任务")
|
||||
}
|
||||
|
||||
// StopAutoTransferScheduler 停止自动转存定时任务
|
||||
func (gs *GlobalScheduler) StopAutoTransferScheduler() {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
if !gs.scheduler.IsAutoTransferRunning() {
|
||||
Info("自动转存定时任务未在运行")
|
||||
return
|
||||
}
|
||||
|
||||
gs.scheduler.StopAutoTransferScheduler()
|
||||
Info("全局调度器已停止自动转存定时任务")
|
||||
}
|
||||
|
||||
// IsAutoTransferRunning 检查自动转存定时任务是否在运行
|
||||
func (gs *GlobalScheduler) IsAutoTransferRunning() bool {
|
||||
gs.mutex.RLock()
|
||||
defer gs.mutex.RUnlock()
|
||||
return gs.scheduler.IsAutoTransferRunning()
|
||||
}
|
||||
|
||||
// ProcessAutoTransfer 手动触发自动转存处理
|
||||
func (gs *GlobalScheduler) ProcessAutoTransfer() {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
gs.scheduler.processAutoTransfer()
|
||||
}
|
||||
|
||||
// UpdateSchedulerStatusWithAutoTransfer 根据系统配置更新调度器状态(包含自动转存)
|
||||
func (gs *GlobalScheduler) UpdateSchedulerStatusWithAutoTransfer(autoFetchHotDramaEnabled bool, autoProcessReadyResources bool, autoTransferEnabled bool) {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
// 处理热播剧自动拉取功能
|
||||
if autoFetchHotDramaEnabled {
|
||||
if !gs.scheduler.IsRunning() {
|
||||
Info("系统配置启用自动拉取热播剧,启动定时任务")
|
||||
gs.scheduler.StartHotDramaScheduler()
|
||||
}
|
||||
} else {
|
||||
if gs.scheduler.IsRunning() {
|
||||
Info("系统配置禁用自动拉取热播剧,停止定时任务")
|
||||
gs.scheduler.StopHotDramaScheduler()
|
||||
}
|
||||
}
|
||||
|
||||
// 处理待处理资源自动处理功能
|
||||
if autoProcessReadyResources {
|
||||
if !gs.scheduler.IsReadyResourceRunning() {
|
||||
Info("系统配置启用自动处理待处理资源,启动定时任务")
|
||||
gs.scheduler.StartReadyResourceScheduler()
|
||||
}
|
||||
} else {
|
||||
if gs.scheduler.IsReadyResourceRunning() {
|
||||
Info("系统配置禁用自动处理待处理资源,停止定时任务")
|
||||
gs.scheduler.StopReadyResourceScheduler()
|
||||
}
|
||||
}
|
||||
|
||||
// 处理自动转存功能
|
||||
if autoTransferEnabled {
|
||||
if !gs.scheduler.IsAutoTransferRunning() {
|
||||
Info("系统配置启用自动转存,启动定时任务")
|
||||
gs.scheduler.StartAutoTransferScheduler()
|
||||
}
|
||||
} else {
|
||||
if gs.scheduler.IsAutoTransferRunning() {
|
||||
Info("系统配置禁用自动转存,停止定时任务")
|
||||
gs.scheduler.StopAutoTransferScheduler()
|
||||
}
|
||||
}
|
||||
}
|
||||
1064
utils/scheduler.go
1064
utils/scheduler.go
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,13 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// 时间格式常量
|
||||
const (
|
||||
TimeFormatDate = "2006-01-02"
|
||||
TimeFormatDateTime = "2006-01-02 15:04:05"
|
||||
TimeFormatRFC3339 = time.RFC3339
|
||||
)
|
||||
|
||||
// InitTimezone 初始化时区设置
|
||||
func InitTimezone() {
|
||||
// 从环境变量获取时区配置
|
||||
@@ -36,20 +43,35 @@ func GetCurrentTime() time.Time {
|
||||
|
||||
// GetCurrentTimeString 获取当前时间字符串(使用配置的时区)
|
||||
func GetCurrentTimeString() string {
|
||||
return time.Now().Format("2006-01-02 15:04:05")
|
||||
return time.Now().Format(TimeFormatDateTime)
|
||||
}
|
||||
|
||||
// GetCurrentTimeRFC3339 获取当前时间RFC3339格式(使用配置的时区)
|
||||
func GetCurrentTimeRFC3339() string {
|
||||
return time.Now().Format(time.RFC3339)
|
||||
return time.Now().Format(TimeFormatRFC3339)
|
||||
}
|
||||
|
||||
// ParseTime 解析时间字符串(使用配置的时区)
|
||||
func ParseTime(timeStr string) (time.Time, error) {
|
||||
return time.Parse("2006-01-02 15:04:05", timeStr)
|
||||
return time.Parse(TimeFormatDateTime, timeStr)
|
||||
}
|
||||
|
||||
// FormatTime 格式化时间(使用配置的时区)
|
||||
func FormatTime(t time.Time, layout string) string {
|
||||
return t.Format(layout)
|
||||
}
|
||||
|
||||
// GetTodayString 获取今日日期字符串
|
||||
func GetTodayString() string {
|
||||
return time.Now().Format(TimeFormatDate)
|
||||
}
|
||||
|
||||
// GetCurrentTimestamp 获取当前时间戳
|
||||
func GetCurrentTimestamp() int64 {
|
||||
return time.Now().Unix()
|
||||
}
|
||||
|
||||
// GetCurrentTimestampNano 获取当前纳秒时间戳
|
||||
func GetCurrentTimestampNano() int64 {
|
||||
return time.Now().UnixNano()
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func GetFullVersionInfo() string {
|
||||
Node版本: %s
|
||||
平台: %s/%s`,
|
||||
info.Version,
|
||||
FormatTime(info.BuildTime, "2006-01-02 15:04:05"),
|
||||
FormatTime(info.BuildTime, TimeFormatDateTime),
|
||||
info.GitCommit,
|
||||
info.GitBranch,
|
||||
info.GoVersion,
|
||||
|
||||
26
web/components.d.ts
vendored
26
web/components.d.ts
vendored
@@ -10,11 +10,37 @@ declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
NA: typeof import('naive-ui')['NA']
|
||||
NAlert: typeof import('naive-ui')['NAlert']
|
||||
NAvatar: typeof import('naive-ui')['NAvatar']
|
||||
NButton: typeof import('naive-ui')['NButton']
|
||||
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
|
||||
NCard: typeof import('naive-ui')['NCard']
|
||||
NCheckbox: typeof import('naive-ui')['NCheckbox']
|
||||
NDataTable: typeof import('naive-ui')['NDataTable']
|
||||
NDatePicker: typeof import('naive-ui')['NDatePicker']
|
||||
NDescriptions: typeof import('naive-ui')['NDescriptions']
|
||||
NDescriptionsItem: typeof import('naive-ui')['NDescriptionsItem']
|
||||
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
|
||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
||||
NForm: typeof import('naive-ui')['NForm']
|
||||
NFormItem: typeof import('naive-ui')['NFormItem']
|
||||
NInput: typeof import('naive-ui')['NInput']
|
||||
NList: typeof import('naive-ui')['NList']
|
||||
NListItem: typeof import('naive-ui')['NListItem']
|
||||
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
||||
NModal: typeof import('naive-ui')['NModal']
|
||||
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
||||
NPagination: typeof import('naive-ui')['NPagination']
|
||||
NProgress: typeof import('naive-ui')['NProgress']
|
||||
NSelect: typeof import('naive-ui')['NSelect']
|
||||
NSpace: typeof import('naive-ui')['NSpace']
|
||||
NSpin: typeof import('naive-ui')['NSpin']
|
||||
NSwitch: typeof import('naive-ui')['NSwitch']
|
||||
NTabPane: typeof import('naive-ui')['NTabPane']
|
||||
NTabs: typeof import('naive-ui')['NTabs']
|
||||
NTag: typeof import('naive-ui')['NTag']
|
||||
NText: typeof import('naive-ui')['NText']
|
||||
NThing: typeof import('naive-ui')['NThing']
|
||||
NVirtualList: typeof import('naive-ui')['NVirtualList']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<p class="mb-2"><strong>格式要求:</strong>标题和URL为一组,标题必填, 同一标题URL支持多行</p>
|
||||
<pre class="bg-white dark:bg-gray-800 p-2 rounded border text-xs">
|
||||
电影1
|
||||
https://pan.baidu.com/s/123456 # 百度网盘 电影1
|
||||
https://pan.quark.com/s/123456 # 夸克网盘 电影1
|
||||
https://pan.baidu.com/s/123456
|
||||
https://pan.quark.com/s/123456
|
||||
电影标题2
|
||||
https://pan.baidu.com/s/789012
|
||||
电视剧标题3
|
||||
@@ -21,9 +21,9 @@ https://pan.quark.cn/s/345678</pre>
|
||||
</div>
|
||||
<div class="mb-4 flex-1 w-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">资源内容:</label>
|
||||
<textarea v-model="batchInput" rows="15"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-900 dark:text-gray-100"
|
||||
placeholder="请输入资源内容,格式:标题和URL两行为一组..."></textarea>
|
||||
<n-input v-model:value="batchInput" type="textarea"
|
||||
:autosize="{ minRows: 10, maxRows: 15 }"
|
||||
placeholder="请输入资源内容,格式:标题和URL为一组..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,6 +45,7 @@ const emit = defineEmits(['success', 'error', 'cancel'])
|
||||
|
||||
const loading = ref(false)
|
||||
const batchInput = ref('')
|
||||
const notification = useNotification()
|
||||
|
||||
const readyResourceApi = useReadyResourceApi()
|
||||
|
||||
@@ -63,7 +64,11 @@ const validateInput = () => {
|
||||
// 首行必须为标题
|
||||
if (/^https?:\/\//i.test(lines[0])) {
|
||||
// 你可以用 alert、ElMessage 或其它方式提示
|
||||
alert('首行必须为标题,不能为链接!')
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: '首行必须为标题,不能为链接!',
|
||||
duration: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,6 @@
|
||||
{{ currentPageTitle }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 页面描述 -->
|
||||
<!-- <div v-if="currentPageDescription && currentPageTitle !== '管理后台'" class="text-xs text-white/60 mt-1">
|
||||
{{ currentPageDescription }}
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<div class="absolute left-4 top-4 flex items-center gap-2">
|
||||
@@ -37,7 +33,7 @@
|
||||
<!-- 用户信息 -->
|
||||
<div v-if="userStore.isAuthenticated" class="hidden sm:flex items-center gap-2">
|
||||
<span class="text-sm text-white/80">欢迎,{{ userStore.user?.username || '管理员' }}</span>
|
||||
<span class="px-2 py-1 bg-blue-600/80 rounded text-xs text-white">{{ userStore.user?.role || 'admin' }}</span>
|
||||
<n-tag type="success" size="small" round>{{ userStore.user?.role || '-' }}</n-tag>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
@@ -116,6 +112,7 @@ const pageConfig = computed(() => {
|
||||
'/admin/users': { title: '用户管理', icon: 'fas fa-users', description: '管理系统用户' },
|
||||
'/admin/categories': { title: '分类管理', icon: 'fas fa-folder', description: '管理资源分类' },
|
||||
'/admin/tags': { title: '标签管理', icon: 'fas fa-tags', description: '管理资源标签' },
|
||||
'/admin/tasks': { title: '任务管理', icon: 'fas fa-tasks', description: '管理系统任务' },
|
||||
'/admin/system-config': { title: '系统配置', icon: 'fas fa-cog', description: '系统参数设置' },
|
||||
'/admin/resources': { title: '资源管理', icon: 'fas fa-database', description: '管理网盘资源' },
|
||||
'/admin/cks': { title: '平台账号管理', icon: 'fas fa-key', description: '管理第三方平台账号' },
|
||||
@@ -125,7 +122,8 @@ const pageConfig = computed(() => {
|
||||
'/monitor': { title: '系统监控', icon: 'fas fa-desktop', description: '系统性能监控' },
|
||||
'/admin/add-resource': { title: '添加资源', icon: 'fas fa-plus', description: '添加新资源' },
|
||||
'/api-docs': { title: 'API文档', icon: 'fas fa-book', description: '接口文档说明' },
|
||||
'/admin/version': { title: '版本信息', icon: 'fas fa-code-branch', description: '系统版本详情' }
|
||||
'/admin/version': { title: '版本信息', icon: 'fas fa-code-branch', description: '系统版本详情' },
|
||||
'/admin/failed-resources': { title: '错误资源', icon: 'fas fa-code-branch', description: '错误资源' }
|
||||
}
|
||||
return configs[route.path] || { title: props.title, icon: 'fas fa-cog', description: '管理页面' }
|
||||
})
|
||||
@@ -138,7 +136,7 @@ const systemConfigStore = useSystemConfigStore()
|
||||
const systemConfig = computed(() => systemConfigStore.config)
|
||||
|
||||
onMounted(() => {
|
||||
systemConfigStore.initConfig()
|
||||
systemConfigStore.initConfig(false, true)
|
||||
})
|
||||
|
||||
// 退出登录
|
||||
572
web/components/Admin/ManualBatchTransfer.vue
Normal file
572
web/components/Admin/ManualBatchTransfer.vue
Normal file
@@ -0,0 +1,572 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<n-card title="批量转存资源列表">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- 左侧:资源输入 -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
资源内容 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<n-input
|
||||
v-model:value="resourceText"
|
||||
type="textarea"
|
||||
placeholder="请输入资源内容,格式:标题和URL为一组..."
|
||||
:autosize="{ minRows: 10, maxRows: 15 }"
|
||||
show-count
|
||||
:maxlength="100000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:配置选项 -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
默认分类
|
||||
</label>
|
||||
<CategorySelector
|
||||
v-model="selectedCategory"
|
||||
placeholder="选择分类"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
标签
|
||||
</label>
|
||||
<TagSelector
|
||||
v-model="selectedTags"
|
||||
placeholder="选择标签"
|
||||
multiple
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
网盘账号 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<n-select
|
||||
v-model:value="selectedAccounts"
|
||||
:options="accountOptions"
|
||||
placeholder="选择网盘账号"
|
||||
multiple
|
||||
filterable
|
||||
:loading="accountsLoading"
|
||||
@update:value="handleAccountChange"
|
||||
>
|
||||
<template #option="{ option: accountOption }">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm">{{ accountOption.label }}</span>
|
||||
<n-tag v-if="accountOption.is_valid" type="success" size="small">有效</n-tag>
|
||||
<n-tag v-else type="error" size="small">无效</n-tag>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ formatSpace(accountOption.left_space) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</n-select>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
请选择要使用的网盘账号,系统将使用选中的账号进行转存操作
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="space-y-3 pt-4">
|
||||
<n-button
|
||||
type="primary"
|
||||
block
|
||||
size="large"
|
||||
:loading="processing"
|
||||
:disabled="!resourceText.trim() || !selectedAccounts.length || processing"
|
||||
@click="handleBatchTransfer"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-upload"></i>
|
||||
</template>
|
||||
开始批量转存
|
||||
</n-button>
|
||||
|
||||
<n-button
|
||||
block
|
||||
@click="clearInput"
|
||||
:disabled="processing"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-trash"></i>
|
||||
</template>
|
||||
清空内容
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 处理结果 -->
|
||||
<n-card v-if="results.length > 0" title="转存结果">
|
||||
<div class="space-y-4">
|
||||
<!-- 结果统计 -->
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div class="text-center p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div class="text-xl font-bold text-blue-600">{{ results.length }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">总处理数</div>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div class="text-xl font-bold text-green-600">{{ successCount }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">成功</div>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<div class="text-xl font-bold text-red-600">{{ failedCount }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">失败</div>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
|
||||
<div class="text-xl font-bold text-yellow-600">{{ processingCount }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">处理中</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结果列表 -->
|
||||
<n-data-table
|
||||
:columns="resultColumns"
|
||||
:data="results"
|
||||
:pagination="false"
|
||||
max-height="300"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, h } from 'vue'
|
||||
import { usePanApi, useTaskApi, useCksApi } from '~/composables/useApi'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
// 数据状态
|
||||
const resourceText = ref('')
|
||||
const processing = ref(false)
|
||||
const results = ref<any[]>([])
|
||||
|
||||
// 任务状态
|
||||
const currentTaskId = ref<number | null>(null)
|
||||
const taskStatus = ref<any>(null)
|
||||
const taskStats = ref({
|
||||
total: 0,
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
completed: 0,
|
||||
failed: 0
|
||||
})
|
||||
const statusCheckInterval = ref<NodeJS.Timeout | null>(null)
|
||||
|
||||
// 配置选项
|
||||
const selectedCategory = ref(null)
|
||||
const selectedTags = ref([])
|
||||
const selectedPlatform = ref(null)
|
||||
const autoValidate = ref(true)
|
||||
const skipExisting = ref(true)
|
||||
const autoTransfer = ref(false)
|
||||
const selectedAccounts = ref<number[]>([])
|
||||
|
||||
// 选项数据
|
||||
const platformOptions = ref<any[]>([])
|
||||
const accountOptions = ref<any[]>([])
|
||||
const accountsLoading = ref(false)
|
||||
|
||||
// API实例
|
||||
const panApi = usePanApi()
|
||||
const taskApi = useTaskApi()
|
||||
const cksApi = useCksApi()
|
||||
const message = useMessage()
|
||||
|
||||
// 计算属性
|
||||
const totalLines = computed(() => {
|
||||
return resourceText.value ? resourceText.value.split('\n').filter(line => line.trim()).length : 0
|
||||
})
|
||||
|
||||
const validUrls = computed(() => {
|
||||
if (!resourceText.value) return 0
|
||||
const lines = resourceText.value.split('\n').filter(line => line.trim())
|
||||
return lines.filter(line => isValidUrl(line.trim())).length
|
||||
})
|
||||
|
||||
const invalidUrls = computed(() => {
|
||||
return totalLines.value - validUrls.value
|
||||
})
|
||||
|
||||
const successCount = computed(() => {
|
||||
return results.value.filter((r: any) => r.status === 'success').length
|
||||
})
|
||||
|
||||
const failedCount = computed(() => {
|
||||
return results.value.filter((r: any) => r.status === 'failed').length
|
||||
})
|
||||
|
||||
const processingCount = computed(() => {
|
||||
return results.value.filter((r: any) => r.status === 'processing').length
|
||||
})
|
||||
|
||||
// 结果表格列
|
||||
const resultColumns = [
|
||||
{
|
||||
title: '标题',
|
||||
key: 'title',
|
||||
width: 200,
|
||||
ellipsis: {
|
||||
tooltip: true
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '链接',
|
||||
key: 'url',
|
||||
width: 250,
|
||||
ellipsis: {
|
||||
tooltip: true
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (row: any) => {
|
||||
const statusMap = {
|
||||
success: { color: 'success', text: '成功', icon: 'fas fa-check' },
|
||||
failed: { color: 'error', text: '失败', icon: 'fas fa-times' },
|
||||
processing: { color: 'info', text: '处理中', icon: 'fas fa-spinner fa-spin' },
|
||||
pending: { color: 'warning', text: '等待中', icon: 'fas fa-clock' }
|
||||
}
|
||||
const status = statusMap[row.status as keyof typeof statusMap] || statusMap.failed
|
||||
return h('n-tag', { type: status.color }, {
|
||||
icon: () => h('i', { class: status.icon }),
|
||||
default: () => status.text
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '消息',
|
||||
key: 'message',
|
||||
ellipsis: {
|
||||
tooltip: true
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '转存链接',
|
||||
key: 'saveUrl',
|
||||
width: 200,
|
||||
ellipsis: {
|
||||
tooltip: true
|
||||
},
|
||||
render: (row: any) => {
|
||||
if (row.saveUrl) {
|
||||
return h('a', {
|
||||
href: row.saveUrl,
|
||||
target: '_blank',
|
||||
class: 'text-blue-500 hover:text-blue-700'
|
||||
}, '查看')
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// URL验证
|
||||
const isValidUrl = (url: string) => {
|
||||
try {
|
||||
new URL(url)
|
||||
// 简单检查是否包含常见网盘域名
|
||||
const diskDomains = ['quark.cn', 'pan.baidu.com', 'aliyundrive.com']
|
||||
return diskDomains.some(domain => url.includes(domain))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取平台选项
|
||||
const fetchPlatforms = async () => {
|
||||
try {
|
||||
const result = await panApi.getPans() as any
|
||||
if (result && Array.isArray(result)) {
|
||||
platformOptions.value = result.map((item: any) => ({
|
||||
label: item.remark || item.name,
|
||||
value: item.id
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取平台失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理批量转存
|
||||
const handleBatchTransfer = async () => {
|
||||
if (!resourceText.value.trim()) {
|
||||
message.warning('请输入资源内容')
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedAccounts.value || selectedAccounts.value.length === 0) {
|
||||
message.warning('请选择至少一个网盘账号')
|
||||
return
|
||||
}
|
||||
|
||||
processing.value = true
|
||||
results.value = []
|
||||
|
||||
try {
|
||||
// 第一步:拆解资源信息,按照一行标题,一行链接的形式
|
||||
const resourceList = parseResourceText(resourceText.value)
|
||||
|
||||
if (resourceList.length === 0) {
|
||||
message.warning('没有找到有效的资源信息,请按照格式要求输入:标题和URL为一组,标题必填')
|
||||
return
|
||||
}
|
||||
|
||||
// 第二步:生成任务标题和数据
|
||||
const taskTitle = `批量转存任务_${new Date().toLocaleString('zh-CN')}`
|
||||
const taskData = {
|
||||
title: taskTitle,
|
||||
description: `批量转存 ${resourceList.length} 个资源,使用 ${selectedAccounts.value.length} 个账号`,
|
||||
resources: resourceList.map(item => {
|
||||
const resource: any = {
|
||||
title: item.title,
|
||||
url: item.url
|
||||
}
|
||||
if (selectedCategory.value) {
|
||||
resource.category_id = selectedCategory.value
|
||||
}
|
||||
if (selectedTags.value && selectedTags.value.length > 0) {
|
||||
resource.tags = selectedTags.value
|
||||
}
|
||||
return resource
|
||||
}),
|
||||
// 添加选择的账号信息
|
||||
selected_accounts: selectedAccounts.value
|
||||
}
|
||||
|
||||
console.log('创建任务数据:', taskData)
|
||||
|
||||
// 第三步:创建任务
|
||||
const taskResponse = await taskApi.createBatchTransferTask(taskData) as any
|
||||
console.log('任务创建响应:', taskResponse)
|
||||
|
||||
currentTaskId.value = taskResponse.task_id
|
||||
|
||||
// 第四步:启动任务
|
||||
await taskApi.startTask(currentTaskId.value!)
|
||||
|
||||
// 第五步:开始实时监控任务状态
|
||||
startTaskMonitoring()
|
||||
|
||||
message.success('任务已创建并启动,开始处理...')
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('创建任务失败:', error)
|
||||
message.error('创建任务失败: ' + (error.message || '未知错误'))
|
||||
processing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 解析资源文本,按照 标题\n链接 的格式(支持同一标题多个URL)
|
||||
const parseResourceText = (text: string) => {
|
||||
const lines = text.split('\n').filter((line: string) => line.trim())
|
||||
const resourceList = []
|
||||
|
||||
let currentTitle = ''
|
||||
let currentUrls = []
|
||||
|
||||
for (const line of lines) {
|
||||
// 判断是否为 url(以 http/https 开头)
|
||||
if (/^https?:\/\//i.test(line)) {
|
||||
currentUrls.push(line.trim())
|
||||
} else {
|
||||
// 新标题,先保存上一个
|
||||
if (currentTitle && currentUrls.length > 0) {
|
||||
// 为每个URL创建一个资源项
|
||||
for (const url of currentUrls) {
|
||||
if (isValidUrl(url)) {
|
||||
resourceList.push({
|
||||
title: currentTitle,
|
||||
url: url,
|
||||
category_id: selectedCategory.value || 0,
|
||||
tags: selectedTags.value || []
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
currentTitle = line.trim()
|
||||
currentUrls = []
|
||||
}
|
||||
}
|
||||
|
||||
// 处理最后一组
|
||||
if (currentTitle && currentUrls.length > 0) {
|
||||
for (const url of currentUrls) {
|
||||
if (isValidUrl(url)) {
|
||||
resourceList.push({
|
||||
title: currentTitle,
|
||||
url: url,
|
||||
category_id: selectedCategory.value || 0,
|
||||
tags: selectedTags.value || []
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resourceList
|
||||
}
|
||||
|
||||
// 开始任务监控
|
||||
const startTaskMonitoring = () => {
|
||||
if (statusCheckInterval.value) {
|
||||
clearInterval(statusCheckInterval.value)
|
||||
}
|
||||
|
||||
statusCheckInterval.value = setInterval(async () => {
|
||||
try {
|
||||
const status = await taskApi.getTaskStatus(currentTaskId.value!) as any
|
||||
console.log('任务状态更新:', status)
|
||||
|
||||
taskStatus.value = status
|
||||
taskStats.value = status.stats || {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
completed: 0,
|
||||
failed: 0
|
||||
}
|
||||
|
||||
// 更新结果显示
|
||||
updateResultsDisplay()
|
||||
|
||||
// 如果任务完成,停止监控
|
||||
if (status.status === 'completed' || status.status === 'failed' || status.status === 'partial_success') {
|
||||
stopTaskMonitoring()
|
||||
processing.value = false
|
||||
|
||||
const { completed, failed } = taskStats.value
|
||||
message.success(`批量转存完成!成功: ${completed}, 失败: ${failed}`)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取任务状态失败:', error)
|
||||
// 如果连续失败,停止监控
|
||||
stopTaskMonitoring()
|
||||
processing.value = false
|
||||
}
|
||||
}, 2000) // 每2秒检查一次
|
||||
}
|
||||
|
||||
// 停止任务监控
|
||||
const stopTaskMonitoring = () => {
|
||||
if (statusCheckInterval.value) {
|
||||
clearInterval(statusCheckInterval.value)
|
||||
statusCheckInterval.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 更新结果显示
|
||||
const updateResultsDisplay = () => {
|
||||
if (!taskStatus.value) return
|
||||
|
||||
// 如果还没有结果,初始化
|
||||
if (results.value.length === 0) {
|
||||
const resourceList = parseResourceText(resourceText.value)
|
||||
results.value = resourceList.map(item => ({
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
status: 'pending',
|
||||
message: '等待处理...',
|
||||
saveUrl: null
|
||||
}))
|
||||
}
|
||||
|
||||
// 更新整体进度显示
|
||||
const { pending, processing, completed, failed } = taskStats.value
|
||||
const processed = completed + failed
|
||||
|
||||
// 简单的状态更新逻辑 - 这里可以根据需要获取详细的任务项状态
|
||||
for (let i = 0; i < results.value.length; i++) {
|
||||
const result = results.value[i]
|
||||
|
||||
if (i < completed) {
|
||||
// 已完成的项目
|
||||
result.status = 'success'
|
||||
result.message = '转存成功'
|
||||
} else if (i < completed + failed) {
|
||||
// 失败的项目
|
||||
result.status = 'failed'
|
||||
result.message = '转存失败'
|
||||
} else if (i < processed + processing) {
|
||||
// 正在处理的项目
|
||||
result.status = 'processing'
|
||||
result.message = '正在处理...'
|
||||
} else {
|
||||
// 等待处理的项目
|
||||
result.status = 'pending'
|
||||
result.message = '等待处理...'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取网盘账号选项
|
||||
const getAccountOptions = async () => {
|
||||
accountsLoading.value = true
|
||||
try {
|
||||
const response = await cksApi.getCks() as any
|
||||
const accounts = Array.isArray(response) ? response : []
|
||||
|
||||
accountOptions.value = accounts.map((account: any) => ({
|
||||
label: `${account.username || '未知用户'} (${account.pan?.name || '未知平台'})`,
|
||||
value: account.id,
|
||||
is_valid: account.is_valid,
|
||||
left_space: account.left_space,
|
||||
username: account.username,
|
||||
pan_name: account.pan?.name || '未知平台'
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('获取网盘账号选项失败:', error)
|
||||
message.error('获取网盘账号失败')
|
||||
} finally {
|
||||
accountsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理账号选择变化
|
||||
const handleAccountChange = (value: number[]) => {
|
||||
selectedAccounts.value = value
|
||||
console.log('选择的账号:', value)
|
||||
}
|
||||
|
||||
// 格式化空间大小
|
||||
const formatSpace = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// 清空输入
|
||||
const clearInput = () => {
|
||||
resourceText.value = ''
|
||||
results.value = []
|
||||
selectedAccounts.value = []
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchPlatforms()
|
||||
getAccountOptions()
|
||||
})
|
||||
|
||||
// 组件销毁时清理定时器
|
||||
onBeforeUnmount(() => {
|
||||
stopTaskMonitoring()
|
||||
})
|
||||
</script>
|
||||
147
web/components/Admin/NewHeader.vue
Normal file
147
web/components/Admin/NewHeader.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<header class="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between px-6 py-4">
|
||||
<!-- 左侧:Logo和标题 -->
|
||||
<div class="flex items-center">
|
||||
<NuxtLink to="/admin" class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-shield-alt text-white text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">管理后台</h1>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">老九网盘资源数据库</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- 中间:状态信息 -->
|
||||
<div class="flex items-center space-x-6">
|
||||
<!-- 系统状态 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">系统正常</span>
|
||||
</div>
|
||||
|
||||
<!-- 自动处理状态 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<div :class="autoProcessEnabled ? 'w-2 h-2 bg-green-500 rounded-full' : 'w-2 h-2 bg-gray-400 rounded-full'"></div>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">
|
||||
自动处理{{ autoProcessEnabled ? '已开启' : '已关闭' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 自动转存状态 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<div :class="autoTransferEnabled ? 'w-2 h-2 bg-blue-500 rounded-full' : 'w-2 h-2 bg-gray-400 rounded-full'"></div>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">
|
||||
自动转存{{ autoTransferEnabled ? '已开启' : '已关闭' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 任务状态 -->
|
||||
<div v-if="taskStore.hasActiveTasks" class="flex items-center space-x-2">
|
||||
<div class="w-2 h-2 bg-orange-500 rounded-full animate-pulse"></div>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">
|
||||
<template v-if="taskStore.runningTaskCount > 0">
|
||||
{{ taskStore.runningTaskCount }}个任务运行中
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ taskStore.activeTaskCount }}个任务待处理
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:用户菜单 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<NuxtLink to="/" class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
<i class="fas fa-home text-lg"></i>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin-old" class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
<i class="fas fa-arrow-left text-lg"></i>
|
||||
</NuxtLink>
|
||||
<div class="flex items-center space-x-2 cursor-pointer p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-user text-white text-sm"></i>
|
||||
</div>
|
||||
<div class="hidden md:block text-left">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">管理员</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">admin</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useTaskStore } from '~/stores/task'
|
||||
import { useSystemConfigStore } from '~/stores/systemConfig'
|
||||
|
||||
// 任务状态管理
|
||||
const taskStore = useTaskStore()
|
||||
|
||||
// 系统配置状态管理
|
||||
const systemConfigStore = useSystemConfigStore()
|
||||
|
||||
// 自动处理和自动转存状态
|
||||
const autoProcessEnabled = ref(false)
|
||||
const autoTransferEnabled = ref(false)
|
||||
|
||||
// 获取系统配置状态
|
||||
const fetchSystemStatus = async () => {
|
||||
try {
|
||||
await systemConfigStore.initConfig(false, true)
|
||||
|
||||
// 从系统配置中获取自动处理和自动转存状态
|
||||
const config = systemConfigStore.config
|
||||
|
||||
if (config) {
|
||||
// 检查自动处理状态
|
||||
autoProcessEnabled.value = config.auto_process_ready_resources === '1' || config.auto_process_ready_resources === true
|
||||
|
||||
// 检查自动转存状态
|
||||
autoTransferEnabled.value = config.auto_transfer_enabled === '1' || config.auto_transfer_enabled === true
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取系统状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时启动
|
||||
onMounted(() => {
|
||||
// 启动任务状态自动更新
|
||||
taskStore.startAutoUpdate()
|
||||
|
||||
// 获取系统配置状态
|
||||
fetchSystemStatus()
|
||||
|
||||
// 定期更新系统配置状态(每30秒)
|
||||
const configInterval = setInterval(fetchSystemStatus, 30000)
|
||||
|
||||
// 保存定时器引用用于清理
|
||||
;(globalThis as any).__configInterval = configInterval
|
||||
})
|
||||
|
||||
// 组件销毁时清理
|
||||
onBeforeUnmount(() => {
|
||||
// 停止任务状态自动更新
|
||||
taskStore.stopAutoUpdate()
|
||||
|
||||
// 清理配置更新定时器
|
||||
if ((globalThis as any).__configInterval) {
|
||||
clearInterval((globalThis as any).__configInterval)
|
||||
delete (globalThis as any).__configInterval
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 确保Font Awesome图标正确显示 */
|
||||
.fas {
|
||||
font-family: 'Font Awesome 6 Free';
|
||||
font-weight: 900;
|
||||
}
|
||||
</style>
|
||||
188
web/components/Admin/NewSidebar.vue
Normal file
188
web/components/Admin/NewSidebar.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<aside class="w-64 bg-white dark:bg-gray-800 shadow-sm border-r border-gray-200 dark:border-gray-700 min-h-screen">
|
||||
<nav class="mt-8">
|
||||
<div class="px-4 space-y-2">
|
||||
<!-- 仪表盘 -->
|
||||
<NuxtLink
|
||||
:to="dashboardItem.to"
|
||||
class="flex items-center px-4 py-3 text-gray-700 dark:text-gray-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 rounded-lg transition-colors"
|
||||
:class="{ 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400': dashboardItem.active($route) }"
|
||||
>
|
||||
<i :class="dashboardItem.icon + ' w-5 h-5 mr-3'"></i>
|
||||
<span>{{ dashboardItem.label }}</span>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- 运营管理分组 -->
|
||||
<div class="mt-6">
|
||||
<div class="px-4 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
运营管理
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<NuxtLink
|
||||
v-for="item in operationItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="flex items-center px-4 py-3 text-gray-700 dark:text-gray-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 rounded-lg transition-colors"
|
||||
:class="{ 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400': item.active($route) }"
|
||||
>
|
||||
<i :class="item.icon + ' w-5 h-5 mr-3'"></i>
|
||||
<span>{{ item.label }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计分析分组 -->
|
||||
<div class="mt-6">
|
||||
<div class="px-4 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
统计分析
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<NuxtLink
|
||||
v-for="item in statisticsItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="flex items-center px-4 py-3 text-gray-700 dark:text-gray-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 rounded-lg transition-colors"
|
||||
:class="{ 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400': item.active($route) }"
|
||||
>
|
||||
<i :class="item.icon + ' w-5 h-5 mr-3'"></i>
|
||||
<span>{{ item.label }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统管理分组 -->
|
||||
<div class="mt-6">
|
||||
<div class="px-4 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
系统管理
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<NuxtLink
|
||||
v-for="item in systemItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="flex items-center px-4 py-3 text-gray-700 dark:text-gray-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 rounded-lg transition-colors"
|
||||
:class="{ 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400': item.active($route) }"
|
||||
>
|
||||
<i :class="item.icon + ' w-5 h-5 mr-3'"></i>
|
||||
<span>{{ item.label }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 仪表盘
|
||||
const dashboardItem = ref({
|
||||
to: '/admin',
|
||||
label: '仪表盘',
|
||||
icon: 'fas fa-tachometer-alt',
|
||||
active: (route: any) => route.path === '/admin'
|
||||
})
|
||||
|
||||
// 运营管理分组
|
||||
const operationItems = ref([
|
||||
{
|
||||
to: '/admin/resources',
|
||||
label: '资源管理',
|
||||
icon: 'fas fa-database',
|
||||
active: (route: any) => route.path.startsWith('/admin/resources')
|
||||
},
|
||||
{
|
||||
to: '/admin/ready-resources',
|
||||
label: '待处理资源',
|
||||
icon: 'fas fa-clock',
|
||||
active: (route: any) => route.path.startsWith('/admin/ready-resources')
|
||||
},
|
||||
{
|
||||
to: '/admin/categories',
|
||||
label: '分类管理',
|
||||
icon: 'fas fa-folder',
|
||||
active: (route: any) => route.path.startsWith('/admin/categories')
|
||||
},
|
||||
{
|
||||
to: '/admin/tags',
|
||||
label: '标签管理',
|
||||
icon: 'fas fa-tags',
|
||||
active: (route: any) => route.path.startsWith('/admin/tags')
|
||||
},
|
||||
{
|
||||
to: '/admin/platforms',
|
||||
label: '平台管理',
|
||||
icon: 'fas fa-cloud',
|
||||
active: (route: any) => route.path.startsWith('/admin/platforms')
|
||||
},
|
||||
{
|
||||
to: '/admin/accounts',
|
||||
label: '账号管理',
|
||||
icon: 'fas fa-user-shield',
|
||||
active: (route: any) => route.path.startsWith('/admin/accounts')
|
||||
},
|
||||
{
|
||||
to: '/admin/hot-dramas',
|
||||
label: '热播剧管理',
|
||||
icon: 'fas fa-film',
|
||||
active: (route: any) => route.path.startsWith('/admin/hot-dramas')
|
||||
},
|
||||
{
|
||||
to: '/admin/seo',
|
||||
label: 'SEO',
|
||||
icon: 'fas fa-search',
|
||||
active: (route: any) => route.path.startsWith('/admin/seo')
|
||||
},
|
||||
{
|
||||
to: '/admin/data-push',
|
||||
label: '数据推送',
|
||||
icon: 'fas fa-upload',
|
||||
active: (route: any) => route.path.startsWith('/admin/data-push')
|
||||
},
|
||||
{
|
||||
to: '/admin/bot',
|
||||
label: '机器人',
|
||||
icon: 'fas fa-robot',
|
||||
active: (route: any) => route.path.startsWith('/admin/bot')
|
||||
}
|
||||
])
|
||||
|
||||
// 统计分析分组
|
||||
const statisticsItems = ref([
|
||||
{
|
||||
to: '/admin/search-stats',
|
||||
label: '搜索统计',
|
||||
icon: 'fas fa-chart-line',
|
||||
active: (route: any) => route.path.startsWith('/admin/search-stats')
|
||||
},
|
||||
{
|
||||
to: '/admin/third-party-stats',
|
||||
label: '三方统计',
|
||||
icon: 'fas fa-chart-bar',
|
||||
active: (route: any) => route.path.startsWith('/admin/third-party-stats')
|
||||
}
|
||||
])
|
||||
|
||||
// 系统管理分组
|
||||
const systemItems = ref([
|
||||
{
|
||||
to: '/admin/users',
|
||||
label: '用户管理',
|
||||
icon: 'fas fa-users',
|
||||
active: (route: any) => route.path.startsWith('/admin/users')
|
||||
},
|
||||
{
|
||||
to: '/admin/system-config',
|
||||
label: '系统配置',
|
||||
icon: 'fas fa-cog',
|
||||
active: (route: any) => route.path.startsWith('/admin/system-config')
|
||||
}
|
||||
])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 确保Font Awesome图标正确显示 */
|
||||
.fas {
|
||||
font-family: 'Font Awesome 6 Free';
|
||||
font-weight: 900;
|
||||
}
|
||||
</style>
|
||||
@@ -5,9 +5,8 @@
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
标题 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.title"
|
||||
class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700"
|
||||
<n-input
|
||||
v-model:value="form.title"
|
||||
placeholder="输入资源标题(必填)"
|
||||
required
|
||||
/>
|
||||
@@ -18,12 +17,11 @@
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
描述 <span class="text-gray-400 text-xs">(可选)</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
rows="3"
|
||||
class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700"
|
||||
<n-input
|
||||
v-model:value="form.description"
|
||||
type="textarea"
|
||||
placeholder="输入资源描述,如:剧情简介、文件大小、清晰度等"
|
||||
></textarea>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- URL -->
|
||||
@@ -31,13 +29,12 @@
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
URL <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="form.url"
|
||||
rows="3"
|
||||
class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700"
|
||||
<n-input
|
||||
v-model:value="form.url"
|
||||
type="textarea"
|
||||
placeholder="请输入资源链接,支持多行,每行一个链接"
|
||||
required
|
||||
></textarea>
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
支持百度网盘、阿里云盘、夸克网盘等链接,每行一个链接
|
||||
</p>
|
||||
@@ -48,9 +45,8 @@
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
分类 <span class="text-gray-400 text-xs">(可选)</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.category"
|
||||
class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700"
|
||||
<n-input
|
||||
v-model:value="form.category"
|
||||
placeholder="如:电影、电视剧、动漫、音乐等"
|
||||
/>
|
||||
</div>
|
||||
@@ -76,10 +72,9 @@
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="newTag"
|
||||
<n-input
|
||||
v-model:value="newTag"
|
||||
@keyup.enter.prevent="addTag"
|
||||
class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700"
|
||||
placeholder="输入标签后回车添加,多个标签用逗号分隔"
|
||||
/>
|
||||
</div>
|
||||
@@ -89,9 +84,8 @@
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
封面图片 <span class="text-gray-400 text-xs">(可选)</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.img"
|
||||
class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700"
|
||||
<n-input
|
||||
v-model:value="form.img"
|
||||
placeholder="封面图片链接"
|
||||
/>
|
||||
</div>
|
||||
@@ -101,9 +95,8 @@
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
数据来源 <span class="text-gray-400 text-xs">(可选)</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.source"
|
||||
class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700"
|
||||
<n-input
|
||||
v-model:value="form.source"
|
||||
placeholder="如:手动添加、API导入、爬虫等"
|
||||
/>
|
||||
</div>
|
||||
@@ -113,12 +106,11 @@
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
额外数据 <span class="text-gray-400 text-xs">(可选)</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="form.extra"
|
||||
rows="3"
|
||||
class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700"
|
||||
<n-input
|
||||
v-model:value="form.extra"
|
||||
type="textarea"
|
||||
placeholder="JSON格式的额外数据,如:{'size': '2GB', 'quality': '1080p'}"
|
||||
></textarea>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 按钮区域 -->
|
||||
281
web/components/Admin/TransferredList.vue
Normal file
281
web/components/Admin/TransferredList.vue
Normal file
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<n-input
|
||||
v-model:value="searchQuery"
|
||||
placeholder="搜索已转存资源..."
|
||||
@keyup.enter="handleSearch"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
</n-input>
|
||||
|
||||
<CategorySelector
|
||||
v-model="selectedCategory"
|
||||
placeholder="选择分类"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<TagSelector
|
||||
v-model="selectedTag"
|
||||
placeholder="选择标签"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<n-button type="primary" @click="handleSearch">
|
||||
<template #icon>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
搜索
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<!-- 调试信息 -->
|
||||
<div class="text-sm text-gray-500 mb-2">
|
||||
数据数量: {{ resources.length }}, 总数: {{ total }}, 加载状态: {{ loading }}
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="resources"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:remote="true"
|
||||
@update:page="handlePageChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
:row-key="(row: any) => row.id"
|
||||
virtual-scroll
|
||||
max-height="500"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, h } from 'vue'
|
||||
import { useResourceApi } from '~/composables/useApi'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
// 消息提示
|
||||
const $message = useMessage()
|
||||
|
||||
// 数据状态
|
||||
const loading = ref(false)
|
||||
const resources = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10000)
|
||||
|
||||
// 搜索条件
|
||||
const searchQuery = ref('')
|
||||
const selectedCategory = ref(null)
|
||||
const selectedTag = ref(null)
|
||||
|
||||
// API实例
|
||||
const resourceApi = useResourceApi()
|
||||
|
||||
// 分页配置
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 10000,
|
||||
itemCount: 0,
|
||||
pageSizes: [10000, 20000, 50000, 100000],
|
||||
showSizePicker: true,
|
||||
showQuickJumper: true,
|
||||
prefix: ({ itemCount }: any) => `共 ${itemCount} 条`
|
||||
})
|
||||
|
||||
// 表格列配置
|
||||
const columns: any[] = [
|
||||
{
|
||||
title: 'ID',
|
||||
key: 'id',
|
||||
width: 60,
|
||||
fixed: 'left' as const
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
key: 'title',
|
||||
width: 200,
|
||||
ellipsis: {
|
||||
tooltip: true
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
key: 'category_name',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '平台',
|
||||
key: 'pan_name',
|
||||
width: 80,
|
||||
render: (row: any) => {
|
||||
if (row.pan_id) {
|
||||
const platform = platformOptions.value.find((p: any) => p.value === row.pan_id)
|
||||
return platform?.label || '未知'
|
||||
}
|
||||
return '未知'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '转存链接',
|
||||
key: 'save_url',
|
||||
width: 200,
|
||||
ellipsis: {
|
||||
tooltip: true
|
||||
},
|
||||
render: (row: any) => {
|
||||
return h('a', {
|
||||
href: row.save_url,
|
||||
target: '_blank',
|
||||
class: 'text-green-500 hover:text-green-700'
|
||||
}, row.save_url.length > 30 ? row.save_url.substring(0, 30) + '...' : row.save_url)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '转存时间',
|
||||
key: 'updated_at',
|
||||
width: 130,
|
||||
render: (row: any) => {
|
||||
return new Date(row.updated_at).toLocaleDateString()
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
fixed: 'right',
|
||||
render: (row: any) => {
|
||||
return h('div', { class: 'flex space-x-2' }, [
|
||||
h('n-button', {
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
onClick: () => viewResource(row)
|
||||
}, { default: () => '查看' }),
|
||||
h('n-button', {
|
||||
size: 'small',
|
||||
type: 'info',
|
||||
onClick: () => copyLink(row.save_url)
|
||||
}, { default: () => '复制' })
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 平台选项
|
||||
const platformOptions = ref([
|
||||
{ label: '夸克网盘', value: 1 },
|
||||
{ label: '百度网盘', value: 2 },
|
||||
{ label: '阿里云盘', value: 3 },
|
||||
{ label: '天翼云盘', value: 4 },
|
||||
{ label: '迅雷云盘', value: 5 },
|
||||
{ label: '123云盘', value: 6 },
|
||||
{ label: '115网盘', value: 7 },
|
||||
{ label: 'UC网盘', value: 8 }
|
||||
])
|
||||
|
||||
// 获取已转存资源
|
||||
const fetchTransferredResources = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value,
|
||||
has_save_url: true // 筛选有转存链接的资源
|
||||
}
|
||||
|
||||
if (searchQuery.value) {
|
||||
params.search = searchQuery.value
|
||||
}
|
||||
if (selectedCategory.value) {
|
||||
params.category_id = selectedCategory.value
|
||||
}
|
||||
|
||||
console.log('请求参数:', params)
|
||||
const result = await resourceApi.getResources(params) as any
|
||||
console.log('已转存资源结果:', result)
|
||||
console.log('结果类型:', typeof result)
|
||||
console.log('结果结构:', Object.keys(result || {}))
|
||||
|
||||
if (result && result.data) {
|
||||
console.log('使用 resources 格式,数量:', result.data.length)
|
||||
resources.value = result.data
|
||||
total.value = result.total || 0
|
||||
pagination.itemCount = result.total || 0
|
||||
} else if (Array.isArray(result)) {
|
||||
console.log('使用数组格式,数量:', result.length)
|
||||
resources.value = result
|
||||
total.value = result.length
|
||||
pagination.itemCount = result.length
|
||||
} else {
|
||||
console.log('未知格式,设置空数组')
|
||||
resources.value = []
|
||||
total.value = 0
|
||||
pagination.itemCount = 0
|
||||
}
|
||||
|
||||
console.log('最终 resources.value:', resources.value)
|
||||
console.log('最终 total.value:', total.value)
|
||||
|
||||
// 检查是否有资源没有 save_url
|
||||
const resourcesWithoutSaveUrl = resources.value.filter((r: any) => !r.save_url || r.save_url.trim() === '')
|
||||
if (resourcesWithoutSaveUrl.length > 0) {
|
||||
console.warn('发现没有 save_url 的资源:', resourcesWithoutSaveUrl.map((r: any) => ({ id: r.id, title: r.title, save_url: r.save_url })))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取已转存资源失败:', error)
|
||||
resources.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
currentPage.value = 1
|
||||
pagination.page = 1
|
||||
fetchTransferredResources()
|
||||
}
|
||||
|
||||
// 分页处理
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
pagination.page = page
|
||||
fetchTransferredResources()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
pageSize.value = size
|
||||
pagination.pageSize = size
|
||||
currentPage.value = 1
|
||||
pagination.page = 1
|
||||
fetchTransferredResources()
|
||||
}
|
||||
|
||||
// 查看资源
|
||||
const viewResource = (resource: any) => {
|
||||
// 这里可以打开资源详情模态框
|
||||
console.log('查看资源:', resource)
|
||||
}
|
||||
|
||||
// 复制链接
|
||||
const copyLink = async (url: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
$message.success('链接已复制到剪贴板')
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error)
|
||||
$message.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchTransferredResources()
|
||||
})
|
||||
</script>
|
||||
614
web/components/Admin/UntransferredList.vue
Normal file
614
web/components/Admin/UntransferredList.vue
Normal file
@@ -0,0 +1,614 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<n-input
|
||||
v-model:value="searchQuery"
|
||||
placeholder="搜索未转存资源..."
|
||||
@keyup.enter="handleSearch"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
</n-input>
|
||||
|
||||
<CategorySelector
|
||||
v-model="selectedCategory"
|
||||
placeholder="选择分类"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<TagSelector
|
||||
v-model="selectedTag"
|
||||
placeholder="选择标签"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<n-select
|
||||
v-model:value="selectedStatus"
|
||||
placeholder="资源状态"
|
||||
:options="statusOptions"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<n-button type="primary" @click="handleSearch">
|
||||
<template #icon>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
搜索
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<!-- 批量操作 -->
|
||||
<n-card>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<n-checkbox
|
||||
:checked="isAllSelected"
|
||||
@update:checked="toggleSelectAll"
|
||||
:indeterminate="isIndeterminate"
|
||||
/>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">全选</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-500">
|
||||
共 {{ total }} 个资源,已选择 {{ selectedResources.length }} 个
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="selectedResources.length === 0"
|
||||
:loading="batchTransferring"
|
||||
@click="handleBatchTransfer"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
</template>
|
||||
批量转存 ({{ selectedResources.length }})
|
||||
</n-button>
|
||||
|
||||
<n-button @click="refreshData">
|
||||
<template #icon>
|
||||
<i class="fas fa-refresh"></i>
|
||||
</template>
|
||||
刷新
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 资源列表 -->
|
||||
<n-card>
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<n-spin size="large" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="resources.length === 0" class="text-center py-8">
|
||||
<i class="fas fa-inbox text-4xl text-gray-400 mb-4"></i>
|
||||
<p class="text-gray-500">暂无未转存的夸克资源</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- 虚拟列表 -->
|
||||
<n-virtual-list
|
||||
:items="resources"
|
||||
:item-size="120"
|
||||
style="max-height: 500px"
|
||||
container-style="height: 500px;"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<div class="flex items-start space-x-4">
|
||||
<!-- 选择框 -->
|
||||
<div class="pt-2">
|
||||
<n-checkbox
|
||||
:checked="selectedResources.includes(item.id)"
|
||||
@update:checked="(checked) => toggleResourceSelection(item.id, checked)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 资源信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- 标题和状态 -->
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white line-clamp-1">
|
||||
{{ item.title || '未命名资源' }}
|
||||
</h3>
|
||||
<n-tag :type="getStatusType(item)" size="small">
|
||||
{{ getStatusText(item) }}
|
||||
</n-tag>
|
||||
</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm line-clamp-2 mb-2">
|
||||
{{ item.description || '暂无描述' }}
|
||||
</p>
|
||||
|
||||
<!-- 元信息 -->
|
||||
<div class="flex items-center space-x-4 text-sm text-gray-500">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-folder mr-1"></i>
|
||||
{{ item.category_name || '未分类' }}
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-cloud mr-1"></i>
|
||||
夸克网盘
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-eye mr-1"></i>
|
||||
{{ item.view_count || 0 }} 次浏览
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-calendar mr-1"></i>
|
||||
{{ formatDate(item.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 原始链接 -->
|
||||
<div class="mt-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-xs text-gray-400">原始链接:</span>
|
||||
<a
|
||||
:href="item.url"
|
||||
target="_blank"
|
||||
class="text-xs text-blue-500 hover:text-blue-700 truncate max-w-xs"
|
||||
>
|
||||
{{ item.url }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</n-virtual-list>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="mt-4 flex justify-center">
|
||||
<n-pagination
|
||||
v-model:page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:item-count="total"
|
||||
:page-sizes="[10000, 20000, 50000, 100000]"
|
||||
show-size-picker
|
||||
show-quick-jumper
|
||||
@update:page="handlePageChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 网盘账号选择模态框 -->
|
||||
<n-modal v-model:show="showAccountSelectionModal" preset="card" title="选择网盘账号" style="width: 600px">
|
||||
<div class="space-y-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
请选择要使用的网盘账号进行批量转存操作
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
网盘账号 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<n-select
|
||||
v-model:value="selectedAccounts"
|
||||
:options="accountOptions"
|
||||
placeholder="选择网盘账号"
|
||||
multiple
|
||||
filterable
|
||||
:loading="accountsLoading"
|
||||
@update:value="handleAccountChange"
|
||||
>
|
||||
<template #option="{ option: accountOption }">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm">{{ accountOption.label }}</span>
|
||||
<n-tag v-if="accountOption.is_valid" type="success" size="small">有效</n-tag>
|
||||
<n-tag v-else type="error" size="small">无效</n-tag>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ formatSpace(accountOption.left_space) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</n-select>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
请选择要使用的网盘账号,系统将使用选中的账号进行转存操作
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 p-3 rounded border border-yellow-200 dark:border-yellow-800">
|
||||
<div class="flex items-start space-x-2">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-500 mt-0.5"></i>
|
||||
<div class="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<p>• 转存过程可能需要较长时间</p>
|
||||
<p>• 请确保选中的网盘账号有足够的存储空间</p>
|
||||
<p>• 转存完成后可在"已转存列表"中查看结果</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<n-button @click="showAccountSelectionModal = false">
|
||||
取消
|
||||
</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="selectedAccounts.length === 0"
|
||||
:loading="batchTransferring"
|
||||
@click="confirmBatchTransfer"
|
||||
>
|
||||
{{ batchTransferring ? '创建任务中...' : '继续' }}
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<!-- 转存结果模态框 -->
|
||||
<n-modal v-model:show="showTransferResult" preset="card" title="转存结果" style="width: 600px">
|
||||
<div v-if="transferResults.length > 0" class="space-y-4">
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="text-center p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div class="text-xl font-bold text-green-600">{{ transferSuccessCount }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">成功</div>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<div class="text-xl font-bold text-red-600">{{ transferFailedCount }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">失败</div>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div class="text-xl font-bold text-blue-600">{{ transferResults.length }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">总计</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-h-300 overflow-y-auto">
|
||||
<div v-for="result in transferResults" :key="result.id" class="p-3 border rounded mb-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium truncate">{{ result.title }}</div>
|
||||
<div class="text-xs text-gray-500 truncate">{{ result.url }}</div>
|
||||
</div>
|
||||
<n-tag :type="result.success ? 'success' : 'error'" size="small">
|
||||
{{ result.success ? '成功' : '失败' }}
|
||||
</n-tag>
|
||||
</div>
|
||||
<div v-if="result.message" class="text-xs text-gray-600 mt-1">
|
||||
{{ result.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useResourceApi, useCategoryApi, useTagApi, useCksApi, useTaskApi } from '~/composables/useApi'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
// 数据状态
|
||||
const loading = ref(false)
|
||||
const resources = ref([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(2000)
|
||||
|
||||
// 搜索条件
|
||||
const searchQuery = ref('')
|
||||
const selectedCategory = ref(null)
|
||||
const selectedTag = ref(null)
|
||||
const selectedStatus = ref(null)
|
||||
|
||||
// 选择状态
|
||||
const selectedResources = ref([])
|
||||
|
||||
// 批量操作状态
|
||||
const batchTransferring = ref(false)
|
||||
const showTransferResult = ref(false)
|
||||
const transferResults = ref([])
|
||||
const showAccountSelectionModal = ref(false)
|
||||
const selectedAccounts = ref<number[]>([])
|
||||
const accountOptions = ref<any[]>([])
|
||||
const accountsLoading = ref(false)
|
||||
|
||||
// 选项数据
|
||||
const categoryOptions = ref([])
|
||||
const tagOptions = ref([])
|
||||
const statusOptions = [
|
||||
{ label: '有效', value: 'valid' },
|
||||
{ label: '无效', value: 'invalid' },
|
||||
{ label: '待验证', value: 'pending' }
|
||||
]
|
||||
|
||||
// API实例
|
||||
const resourceApi = useResourceApi()
|
||||
const categoryApi = useCategoryApi()
|
||||
const tagApi = useTagApi()
|
||||
const cksApi = useCksApi()
|
||||
const taskApi = useTaskApi()
|
||||
const message = useMessage()
|
||||
|
||||
// 计算属性
|
||||
const isAllSelected = computed(() => {
|
||||
return resources.value.length > 0 && selectedResources.value.length === resources.value.length
|
||||
})
|
||||
|
||||
const isIndeterminate = computed(() => {
|
||||
return selectedResources.value.length > 0 && selectedResources.value.length < resources.value.length
|
||||
})
|
||||
|
||||
const transferSuccessCount = computed(() => {
|
||||
return transferResults.value.filter(r => r.success).length
|
||||
})
|
||||
|
||||
const transferFailedCount = computed(() => {
|
||||
return transferResults.value.filter(r => !r.success).length
|
||||
})
|
||||
|
||||
// 获取未转存资源(夸克网盘且无save_url)
|
||||
const fetchUntransferredResources = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value,
|
||||
no_save_url: true, // 筛选没有转存链接的资源
|
||||
pan_name: 'quark' // 仅夸克网盘资源
|
||||
}
|
||||
|
||||
if (searchQuery.value) {
|
||||
params.search = searchQuery.value
|
||||
}
|
||||
if (selectedCategory.value) {
|
||||
params.category_id = selectedCategory.value
|
||||
}
|
||||
|
||||
const result = await resourceApi.getResources(params) as any
|
||||
console.log('未转存资源结果:', result)
|
||||
|
||||
if (result && result.data) {
|
||||
resources.value = result.data
|
||||
total.value = result.total || 0
|
||||
} else if (Array.isArray(result)) {
|
||||
resources.value = result
|
||||
total.value = result.length
|
||||
}
|
||||
|
||||
// 清空选择
|
||||
selectedResources.value = []
|
||||
} catch (error) {
|
||||
console.error('获取未转存资源失败:', error)
|
||||
resources.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取分类选项
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const result = await categoryApi.getCategories() as any
|
||||
if (result && result.items) {
|
||||
categoryOptions.value = result.items.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.id
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取分类失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取标签选项
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
const result = await tagApi.getTags() as any
|
||||
if (result && result.items) {
|
||||
tagOptions.value = result.items.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.id
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取标签失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取网盘账号选项
|
||||
const getAccountOptions = async () => {
|
||||
accountsLoading.value = true
|
||||
try {
|
||||
const response = await cksApi.getCks() as any
|
||||
const accounts = Array.isArray(response) ? response : []
|
||||
|
||||
accountOptions.value = accounts.map((account: any) => ({
|
||||
label: `${account.username || '未知用户'} (${account.pan?.name || '未知平台'})`,
|
||||
value: account.id,
|
||||
is_valid: account.is_valid,
|
||||
left_space: account.left_space,
|
||||
username: account.username,
|
||||
pan_name: account.pan?.name || '未知平台'
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('获取网盘账号选项失败:', error)
|
||||
message.error('获取网盘账号失败')
|
||||
} finally {
|
||||
accountsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理账号选择变化
|
||||
const handleAccountChange = (value: number[]) => {
|
||||
selectedAccounts.value = value
|
||||
console.log('选择的账号:', value)
|
||||
}
|
||||
|
||||
// 格式化空间大小
|
||||
const formatSpace = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
currentPage.value = 1
|
||||
fetchUntransferredResources()
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
const refreshData = () => {
|
||||
fetchUntransferredResources()
|
||||
}
|
||||
|
||||
// 分页处理
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchUntransferredResources()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
fetchUntransferredResources()
|
||||
}
|
||||
|
||||
// 选择处理
|
||||
const toggleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
selectedResources.value = resources.value.map(r => r.id)
|
||||
} else {
|
||||
selectedResources.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const toggleResourceSelection = (id: number, checked: boolean) => {
|
||||
if (checked) {
|
||||
if (!selectedResources.value.includes(id)) {
|
||||
selectedResources.value.push(id)
|
||||
}
|
||||
} else {
|
||||
const index = selectedResources.value.indexOf(id)
|
||||
if (index > -1) {
|
||||
selectedResources.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 批量转存
|
||||
const handleBatchTransfer = async () => {
|
||||
if (selectedResources.value.length === 0) {
|
||||
message.warning('请选择要转存的资源')
|
||||
return
|
||||
}
|
||||
|
||||
// 先获取网盘账号列表
|
||||
await getAccountOptions()
|
||||
|
||||
// 显示账号选择模态框
|
||||
showAccountSelectionModal.value = true
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 获取状态类型
|
||||
const getStatusType = (resource: any) => {
|
||||
if (resource.is_valid === false) return 'error'
|
||||
if (resource.is_valid === true) return 'success'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (resource: any) => {
|
||||
if (resource.is_valid === false) return '无效'
|
||||
if (resource.is_valid === true) return '有效'
|
||||
return '待验证'
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
}
|
||||
|
||||
// 确认批量转存
|
||||
const confirmBatchTransfer = async () => {
|
||||
if (selectedAccounts.value.length === 0) {
|
||||
message.warning('请选择至少一个网盘账号')
|
||||
return
|
||||
}
|
||||
|
||||
batchTransferring.value = true
|
||||
try {
|
||||
const selectedItems = resources.value.filter(r => selectedResources.value.includes(r.id))
|
||||
|
||||
const taskData = {
|
||||
title: `批量转存 ${selectedItems.length} 个资源`,
|
||||
description: `批量转存 ${selectedItems.length} 个资源,使用 ${selectedAccounts.value.length} 个账号`,
|
||||
resources: selectedItems.map(r => ({
|
||||
title: r.title,
|
||||
url: r.url,
|
||||
category_id: r.category_id || 0,
|
||||
pan_id: r.pan_id || 0
|
||||
})),
|
||||
selected_accounts: selectedAccounts.value
|
||||
}
|
||||
|
||||
const response = await taskApi.createBatchTransferTask(taskData) as any
|
||||
message.success(`批量转存任务已创建,共 ${selectedItems.length} 个资源`)
|
||||
|
||||
// 关闭模态框
|
||||
showAccountSelectionModal.value = false
|
||||
selectedAccounts.value = []
|
||||
|
||||
// 刷新列表
|
||||
refreshData()
|
||||
|
||||
} catch (error) {
|
||||
console.error('创建批量转存任务失败:', error)
|
||||
message.error('创建批量转存任务失败')
|
||||
} finally {
|
||||
batchTransferring.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchCategories()
|
||||
fetchTags()
|
||||
fetchUntransferredResources()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-1 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
105
web/components/CategorySelector.vue
Normal file
105
web/components/CategorySelector.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<n-select
|
||||
v-model:value="selectedValue"
|
||||
:placeholder="placeholder"
|
||||
:options="categoryOptions"
|
||||
:loading="loading"
|
||||
:clearable="clearable"
|
||||
:filterable="true"
|
||||
:disabled="disabled"
|
||||
@update:value="handleUpdate"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useCategoryApi } from '~/composables/useApi'
|
||||
|
||||
// Props定义
|
||||
interface Props {
|
||||
modelValue?: number | null
|
||||
placeholder?: string
|
||||
clearable?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: '选择分类',
|
||||
clearable: true,
|
||||
disabled: false
|
||||
})
|
||||
|
||||
// Emits定义
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number | null]
|
||||
}>()
|
||||
|
||||
// 定义选项类型
|
||||
interface CategoryOption {
|
||||
label: string
|
||||
value: number
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
// 内部状态
|
||||
const selectedValue = ref(props.modelValue)
|
||||
const categoryOptions = ref<CategoryOption[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// API实例
|
||||
const categoryApi = useCategoryApi()
|
||||
|
||||
// 监听外部值变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
selectedValue.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
// 监听内部值变化并向外发射
|
||||
const handleUpdate = (value: number | null) => {
|
||||
selectedValue.value = value
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
// 加载分类数据
|
||||
const loadCategories = async () => {
|
||||
// 如果已经加载过,直接返回
|
||||
if (categoryOptions.value.length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await categoryApi.getCategories() as any
|
||||
|
||||
const options: CategoryOption[] = []
|
||||
if (result && result.items) {
|
||||
options.push(...result.items.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
disabled: false
|
||||
})))
|
||||
} else if (Array.isArray(result)) {
|
||||
options.push(...result.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
disabled: false
|
||||
})))
|
||||
}
|
||||
|
||||
categoryOptions.value = options
|
||||
} catch (error) {
|
||||
console.error('获取分类失败:', error)
|
||||
categoryOptions.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时立即加载分类
|
||||
onMounted(() => {
|
||||
loadCategories()
|
||||
})
|
||||
</script>
|
||||
@@ -1,58 +0,0 @@
|
||||
<template>
|
||||
<div v-if="show" class="fixed top-4 right-4 z-50">
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-4 shadow-lg max-w-sm">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-circle text-red-400"></i>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<h3 class="text-sm font-medium text-red-800">错误</h3>
|
||||
<div class="mt-1 text-sm text-red-700">
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4 flex-shrink-0">
|
||||
<button
|
||||
@click="close"
|
||||
class="inline-flex text-red-400 hover:text-red-600 focus:outline-none"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
interface Props {
|
||||
message: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
duration: 5000
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const show = ref(false)
|
||||
|
||||
const close = () => {
|
||||
show.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
show.value = true
|
||||
if (props.duration > 0) {
|
||||
setTimeout(() => {
|
||||
close()
|
||||
}, props.duration)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
121
web/components/ForbiddenPage.vue
Normal file
121
web/components/ForbiddenPage.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<div class="forbidden-page">
|
||||
<div class="top-bar-guidance">
|
||||
<p class="top-bar-guidance-text">请按提示在手机 浏览器 打开<img src="/assets/images/3dian.png" class="icon-safari"></p>
|
||||
<p class="top-bar-guidance-text">苹果设备<img src="/assets/images/iphone.png" class="icon-safari">↗↗↗</p>
|
||||
<p class="top-bar-guidance-text">安卓设备<img src="/assets/images/android.png" class="icon-safari">↗↗↗</p>
|
||||
</div>
|
||||
|
||||
<div id="contens">
|
||||
<p><br/><br/></p>
|
||||
<p>1.本站不支持 微信,QQ等APP 内访问</p>
|
||||
<p><br/></p>
|
||||
<p>2.请按提示在手机 浏览器 打开</p>
|
||||
<p id="device-tip"><br/>3.请在浏览器中打开</p>
|
||||
</div>
|
||||
|
||||
<p><br/><br/></p>
|
||||
<div class="app-download-tip">
|
||||
<span class="guidance-desc" id="current-url"></span>
|
||||
</div>
|
||||
<p><br/></p>
|
||||
<div class="app-download-tip">
|
||||
<span class="guidance-desc">点击右上角···图标 or 复制网址自行打开</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
// 组件属性
|
||||
interface Props {
|
||||
currentUrl?: string
|
||||
isIOS?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
currentUrl: '',
|
||||
isIOS: false
|
||||
})
|
||||
|
||||
// 在组件挂载时设置内容
|
||||
onMounted(() => {
|
||||
const currentUrlElement = document.getElementById('current-url')
|
||||
const deviceTipElement = document.getElementById('device-tip')
|
||||
|
||||
if (currentUrlElement) {
|
||||
currentUrlElement.textContent = props.currentUrl || window.location.href
|
||||
}
|
||||
|
||||
if (deviceTipElement) {
|
||||
const deviceText = props.isIOS ? '苹果设备请在Safari浏览器中打开' : '安卓设备请在Chrome或其他浏览器中打开'
|
||||
deviceTipElement.innerHTML = `<br/>3.${deviceText}`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.forbidden-page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.top-bar-guidance {
|
||||
font-size: 15px;
|
||||
color: #fff;
|
||||
height: 70%;
|
||||
line-height: 1.2;
|
||||
padding-left: 20px;
|
||||
padding-top: 20px;
|
||||
background: url('/assets/images/banner.png') center top/cover no-repeat;
|
||||
}
|
||||
|
||||
.top-bar-guidance p {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.top-bar-guidance .icon-safari {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
vertical-align: middle;
|
||||
margin: 0 .2em;
|
||||
}
|
||||
|
||||
.top-bar-guidance-text {
|
||||
display: flex;
|
||||
justify-items: center;
|
||||
word-wrap: nowrap;
|
||||
}
|
||||
|
||||
.top-bar-guidance-text img {
|
||||
display: inline-block;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
vertical-align: middle;
|
||||
margin: 0 .2em;
|
||||
}
|
||||
|
||||
#contens {
|
||||
font-weight: bold;
|
||||
color: #2466f4;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
margin-bottom: 125px;
|
||||
}
|
||||
|
||||
.app-download-tip {
|
||||
margin: 0 auto;
|
||||
width: 290px;
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
color: #2466f4;
|
||||
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAcAQMAAACak0ePAAAABlBMVEUAAAAdYfh+GakkAAAAAXRSTlMAQObYZgAAAA5JREFUCNdjwA8acEkAAAy4AIE4hQq/AAAAAElFTkSuQmCC) left center/auto 15px repeat-x;
|
||||
}
|
||||
|
||||
.app-download-tip .guidance-desc {
|
||||
background-color: #fff;
|
||||
padding: 0 5px;
|
||||
}
|
||||
</style>
|
||||
51
web/components/LoadingState.vue
Normal file
51
web/components/LoadingState.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="loading-container">
|
||||
<div class="loading-spinner"></div>
|
||||
<p class="loading-text">{{ message }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 组件属性
|
||||
interface Props {
|
||||
message?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
message: '正在检测访问环境...'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
background: url('/assets/images/banner.webp') center / cover no-repeat;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 4px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
@@ -14,8 +14,22 @@
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<!-- 移动端:所有链接都显示链接文本和操作按钮 -->
|
||||
<div v-if="isMobile" class="space-y-4">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="space-y-4">
|
||||
<div class="flex flex-col items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">正在获取链接...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="error" class="space-y-4">
|
||||
<div class="bg-red-50 dark:bg-red-900/20 p-4 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-triangle text-red-500 mr-2"></i>
|
||||
<p class="text-sm text-red-700 dark:text-red-300">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-lg">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 break-all">{{ url }}</p>
|
||||
</div>
|
||||
@@ -35,8 +49,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 正常显示 -->
|
||||
<div v-else>
|
||||
<!-- 移动端:所有链接都显示链接文本和操作按钮 -->
|
||||
<div v-if="isMobile" class="space-y-4">
|
||||
<!-- 显示链接状态信息 -->
|
||||
<div v-if="message" class="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle text-blue-500 mr-2"></i>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300">{{ message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-lg">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 break-all">{{ url }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="openLink"
|
||||
class="flex-1 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors text-sm flex items-center justify-center gap-2"
|
||||
>
|
||||
<i class="fas fa-external-link-alt"></i> 跳转
|
||||
</button>
|
||||
<button
|
||||
@click="copyUrl"
|
||||
class="flex-1 px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 transition-colors text-sm flex items-center justify-center gap-2"
|
||||
>
|
||||
<i class="fas fa-copy"></i> 复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PC端:根据链接类型显示不同内容 -->
|
||||
<div v-else class="space-y-4">
|
||||
<!-- 显示链接状态信息 -->
|
||||
<div v-if="message" class="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle text-blue-500 mr-2"></i>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300">{{ message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 夸克链接:只显示二维码 -->
|
||||
<div v-if="isQuarkLink" class="space-y-4">
|
||||
<div class="bg-gray-100 dark:bg-gray-700 p-4 rounded-lg">
|
||||
@@ -86,6 +139,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,7 +150,13 @@ import QRCode from 'qrcode'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
save_url?: string
|
||||
url?: string
|
||||
loading?: boolean
|
||||
linkType?: string
|
||||
platform?: string
|
||||
message?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -122,7 +182,7 @@ const detectDevice = () => {
|
||||
|
||||
// 判断是否为夸克链接
|
||||
const isQuarkLink = computed(() => {
|
||||
return props.url.includes('pan.quark.cn') || props.url.includes('quark.cn')
|
||||
return (props.url.includes('pan.quark.cn') || props.url.includes('quark.cn')) && !!props.save_url
|
||||
})
|
||||
|
||||
// 生成二维码
|
||||
@@ -130,7 +190,7 @@ const generateQrCode = async () => {
|
||||
if (!qrCanvas.value || !props.url) return
|
||||
|
||||
try {
|
||||
await QRCode.toCanvas(qrCanvas.value, props.url, {
|
||||
await QRCode.toCanvas(qrCanvas.value, props.save_url || props.url, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: {
|
||||
@@ -170,7 +230,9 @@ const copyUrl = async () => {
|
||||
|
||||
// 跳转到链接
|
||||
const openLink = () => {
|
||||
window.open(props.url, '_blank')
|
||||
if (process.client) {
|
||||
window.open(props.url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
// 下载二维码
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-2xl w-full mx-4" style="height:600px;">
|
||||
<div class="p-6 h-full flex flex-col text-gray-900 dark:text-gray-100">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
添加资源
|
||||
</h2>
|
||||
<button @click="$emit('close')" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 切换 -->
|
||||
<div class="flex mb-6 border-b flex-shrink-0">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:class="['px-4 py-2 -mb-px border-b-2', mode === tab.value ? 'border-blue-500 text-blue-600 font-bold' : 'border-transparent text-gray-500']"
|
||||
@click="mode = tab.value"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<!-- 批量添加 -->
|
||||
<div v-if="mode === 'batch'">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">输入格式说明:</label>
|
||||
<div class="bg-gray-50 dark:bg-gray-800 p-3 rounded text-sm text-gray-600 dark:text-gray-300 mb-4">
|
||||
<p class="mb-2"><strong>格式1:</strong>标题和URL两行一组</p>
|
||||
<pre class="bg-white dark:bg-gray-800 p-2 rounded border text-xs">
|
||||
电影标题1
|
||||
https://pan.baidu.com/s/123456
|
||||
电影标题2
|
||||
https://pan.baidu.com/s/789012</pre>
|
||||
<p class="mt-2 mb-2"><strong>格式2:</strong>只有URL,系统自动判断</p>
|
||||
<pre class="bg-white dark:bg-gray-800 p-2 rounded border text-xs">
|
||||
https://pan.baidu.com/s/123456
|
||||
https://pan.baidu.com/s/789012
|
||||
https://pan.baidu.com/s/345678</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">资源内容:</label>
|
||||
<textarea
|
||||
v-model="batchInput"
|
||||
rows="15"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-900 dark:text-gray-100"
|
||||
placeholder="请输入资源内容,支持两种格式..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 单个添加 -->
|
||||
<div v-else-if="mode === 'single'" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">标题</label>
|
||||
<input v-model="form.title" class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700" placeholder="输入标题" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">描述</label>
|
||||
<textarea v-model="form.description" rows="3" class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700" placeholder="输入资源描述"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">类型</label>
|
||||
<select v-model="form.file_type" class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700">
|
||||
<option value="">选择类型</option>
|
||||
<option value="pan">网盘</option>
|
||||
<option value="link">直链</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">标签</label>
|
||||
<div class="flex flex-wrap gap-2 mb-2">
|
||||
<span v-for="tag in form.tags" :key="tag" class="bg-blue-100 text-blue-700 px-2 py-1 rounded text-xs flex items-center">
|
||||
{{ tag }}
|
||||
<button type="button" class="ml-1 text-xs" @click="removeTag(tag)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<input v-model="newTag" @keyup.enter.prevent="addTag" class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700" placeholder="输入标签后回车添加" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">链接(可多行,每行一个链接)</label>
|
||||
<textarea v-model="form.url" rows="3" class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700" placeholder="https://a.com https://b.com"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API说明 -->
|
||||
<div v-else class="space-y-4">
|
||||
<div class="text-gray-700 dark:text-gray-300 text-sm">
|
||||
<p>你可以通过API批量添加资源:</p>
|
||||
<pre class="bg-gray-100 dark:bg-gray-800 p-3 rounded text-xs overflow-x-auto mt-2">
|
||||
POST /api/resources/batch
|
||||
Content-Type: application/json
|
||||
Body:
|
||||
[
|
||||
{ "title": "资源A", "url": "https://a.com", "file_type": "pan", ... },
|
||||
{ "title": "资源B", "url": "https://b.com", ... }
|
||||
]
|
||||
</pre>
|
||||
<p>参数说明:<br/>
|
||||
title: 标题<br/>
|
||||
url: 资源链接<br/>
|
||||
file_type: 类型(pan/link/other)<br/>
|
||||
tags: 标签数组(可选)<br/>
|
||||
description: 描述(可选)<br/>
|
||||
... 其他字段参考文档
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成功/失败提示 -->
|
||||
<SuccessToast v-if="showSuccess" :message="successMsg" @close="showSuccess = false" />
|
||||
<ErrorToast v-if="showError" :message="errorMsg" @close="showError = false" />
|
||||
</div>
|
||||
|
||||
<!-- 按钮区域 -->
|
||||
<div class="flex-shrink-0 pt-4 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900/90 sticky bottom-0 left-0 w-full flex justify-end space-x-3 z-10 backdrop-blur">
|
||||
<template v-if="mode === 'batch'">
|
||||
<button type="button" @click="$emit('close')" class="btn-secondary">取消</button>
|
||||
<button type="button" @click="handleBatchSubmit" class="btn-primary" :disabled="loading">
|
||||
{{ loading ? '保存中...' : '批量添加' }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else-if="mode === 'single'">
|
||||
<button type="button" @click="$emit('close')" class="btn-secondary">取消</button>
|
||||
<button type="button" @click="handleSingleSubmit" class="btn-primary" :disabled="loading">
|
||||
{{ loading ? '保存中...' : '添加' }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button type="button" @click="$emit('close')" class="btn-secondary">关闭</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useResourceStore } from '~/stores/resource'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import SuccessToast from './SuccessToast.vue'
|
||||
import ErrorToast from './ErrorToast.vue'
|
||||
import { useReadyResourceApi } from '~/composables/useApi'
|
||||
|
||||
const store = useResourceStore()
|
||||
const { categories } = storeToRefs(store)
|
||||
|
||||
const props = defineProps<{ resource?: any }>()
|
||||
const emit = defineEmits(['close', 'save'])
|
||||
|
||||
const loading = ref(false)
|
||||
const newTag = ref('')
|
||||
const showSuccess = ref(false)
|
||||
const showError = ref(false)
|
||||
const successMsg = ref('')
|
||||
const errorMsg = ref('')
|
||||
|
||||
const tabs = [
|
||||
{ label: '批量添加', value: 'batch' },
|
||||
{ label: '单个添加', value: 'single' },
|
||||
{ label: 'API说明', value: 'api' },
|
||||
]
|
||||
const mode = ref('batch')
|
||||
|
||||
// 批量添加
|
||||
const batchInput = ref('')
|
||||
|
||||
// 单个添加表单
|
||||
const form = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
url: '', // 多行
|
||||
category_id: '',
|
||||
tags: [] as string[],
|
||||
file_path: '',
|
||||
file_type: '',
|
||||
file_size: 0,
|
||||
is_public: true,
|
||||
})
|
||||
|
||||
const readyResourceApi = useReadyResourceApi()
|
||||
|
||||
onMounted(() => {
|
||||
if (props.resource) {
|
||||
form.value = {
|
||||
title: props.resource.title || '',
|
||||
description: props.resource.description || '',
|
||||
url: props.resource.url || '',
|
||||
category_id: props.resource.category_id || '',
|
||||
tags: [...(props.resource.tags || [])],
|
||||
file_path: props.resource.file_path || '',
|
||||
file_type: props.resource.file_type || '',
|
||||
file_size: props.resource.file_size || 0,
|
||||
is_public: props.resource.is_public !== false,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const addTag = () => {
|
||||
const tag = newTag.value.trim()
|
||||
if (tag && !form.value.tags.includes(tag)) {
|
||||
form.value.tags.push(tag)
|
||||
newTag.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
const index = form.value.tags.indexOf(tag)
|
||||
if (index > -1) {
|
||||
form.value.tags.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量添加提交
|
||||
const handleBatchSubmit = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
if (!batchInput.value.trim()) throw new Error('请输入资源内容')
|
||||
const res: any = await readyResourceApi.createReadyResourcesFromText(batchInput.value)
|
||||
showSuccess.value = true
|
||||
successMsg.value = `成功添加 ${res.count || 0} 个资源,资源已进入待处理列表,处理完成后会自动入库`
|
||||
batchInput.value = ''
|
||||
} catch (e: any) {
|
||||
showError.value = true
|
||||
errorMsg.value = e.message || '批量添加失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 单个添加提交
|
||||
const handleSingleSubmit = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// 多行链接
|
||||
const urls = form.value.url.split(/\r?\n/).map(l => l.trim()).filter(Boolean)
|
||||
if (!urls.length) throw new Error('请输入至少一个链接')
|
||||
for (const url of urls) {
|
||||
await store.createResource({
|
||||
...form.value,
|
||||
url,
|
||||
tags: [...form.value.tags],
|
||||
})
|
||||
}
|
||||
showSuccess.value = true
|
||||
successMsg.value = '资源已进入待处理列表,处理完成后会自动入库'
|
||||
// 清空表单
|
||||
form.value.title = ''
|
||||
form.value.description = ''
|
||||
form.value.url = ''
|
||||
form.value.tags = []
|
||||
form.value.file_type = ''
|
||||
} catch (e: any) {
|
||||
showError.value = true
|
||||
errorMsg.value = e.message || '添加失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 可以添加自定义样式 */
|
||||
</style>
|
||||
@@ -1,58 +0,0 @@
|
||||
<template>
|
||||
<div v-if="show" class="fixed top-4 right-4 z-50">
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4 shadow-lg max-w-sm">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-check-circle text-green-400"></i>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<h3 class="text-sm font-medium text-green-800">成功</h3>
|
||||
<div class="mt-1 text-sm text-green-700">
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4 flex-shrink-0">
|
||||
<button
|
||||
@click="close"
|
||||
class="inline-flex text-green-400 hover:text-green-600 focus:outline-none"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
interface Props {
|
||||
message: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
duration: 3000
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const show = ref(false)
|
||||
|
||||
const close = () => {
|
||||
show.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
show.value = true
|
||||
if (props.duration > 0) {
|
||||
setTimeout(() => {
|
||||
close()
|
||||
}, props.duration)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
129
web/components/TagSelector.vue
Normal file
129
web/components/TagSelector.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<n-select
|
||||
v-model:value="selectedValue"
|
||||
:placeholder="placeholder"
|
||||
:options="tagOptions"
|
||||
:loading="loading"
|
||||
:multiple="multiple"
|
||||
:clearable="clearable"
|
||||
:filterable="true"
|
||||
:remote="true"
|
||||
:clear-filter-after-select="false"
|
||||
@search="handleSearch"
|
||||
@focus="loadInitialTags"
|
||||
@update:value="handleUpdate"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useTagApi } from '~/composables/useApi'
|
||||
|
||||
// Props定义
|
||||
interface Props {
|
||||
modelValue?: number | number[] | null
|
||||
placeholder?: string
|
||||
multiple?: boolean
|
||||
clearable?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: '选择标签',
|
||||
multiple: false,
|
||||
clearable: true,
|
||||
disabled: false
|
||||
})
|
||||
|
||||
// Emits定义
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number | number[] | null]
|
||||
}>()
|
||||
|
||||
// 定义选项类型
|
||||
interface TagOption {
|
||||
label: string
|
||||
value: number
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
// 内部状态
|
||||
const selectedValue = ref(props.modelValue)
|
||||
const tagOptions = ref<TagOption[]>([])
|
||||
const loading = ref(false)
|
||||
const searchCache = ref(new Map<string, TagOption[]>()) // 搜索缓存
|
||||
|
||||
// API实例
|
||||
const tagApi = useTagApi()
|
||||
|
||||
// 监听外部值变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
selectedValue.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
// 监听内部值变化并向外发射
|
||||
const handleUpdate = (value: number | number[] | null) => {
|
||||
selectedValue.value = value
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
// 加载标签数据
|
||||
const loadTags = async (query: string = '') => {
|
||||
// 检查缓存
|
||||
if (searchCache.value.has(query)) {
|
||||
const cachedOptions = searchCache.value.get(query)
|
||||
if (cachedOptions) {
|
||||
tagOptions.value = cachedOptions
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await tagApi.getTags({
|
||||
search: query,
|
||||
page: 1,
|
||||
page_size: 50 // 限制返回数量,避免数据过多
|
||||
}) as any
|
||||
|
||||
const options: TagOption[] = []
|
||||
if (result && result.items) {
|
||||
options.push(...result.items.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
disabled: false
|
||||
})))
|
||||
}
|
||||
|
||||
// 缓存结果
|
||||
searchCache.value.set(query, options)
|
||||
tagOptions.value = options
|
||||
} catch (error) {
|
||||
console.error('获取标签失败:', error)
|
||||
tagOptions.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始加载标签
|
||||
const loadInitialTags = async () => {
|
||||
if (tagOptions.value.length === 0) {
|
||||
await loadTags('')
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = async (query: string) => {
|
||||
await loadTags(query)
|
||||
}
|
||||
|
||||
// 组件挂载时预加载一些标签(可选)
|
||||
onMounted(() => {
|
||||
// 可以选择在挂载时就加载标签,或者等用户聚焦时再加载
|
||||
// loadInitialTags()
|
||||
})
|
||||
</script>
|
||||
112
web/components/User/Header.vue
Normal file
112
web/components/User/Header.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<header class="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<!-- 左侧 Logo 和标题 -->
|
||||
<div class="flex items-center">
|
||||
<NuxtLink to="/user" class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-user text-white text-sm"></i>
|
||||
</div>
|
||||
<span class="text-xl font-bold text-gray-900 dark:text-white">用户中心</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- 右侧用户信息和操作 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 用户信息 -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-user text-blue-600 dark:text-blue-400 text-sm"></i>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ userStore.user?.username || '用户' }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">普通用户</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下拉菜单 -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@click="showUserMenu = !showUserMenu"
|
||||
class="flex items-center space-x-2 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<i class="fas fa-chevron-down text-xs"></i>
|
||||
</button>
|
||||
|
||||
<!-- 下拉菜单内容 -->
|
||||
<div
|
||||
v-if="showUserMenu"
|
||||
class="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg py-1 z-50 border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<template v-for="item in userMenuItems" :key="item.label || item.type">
|
||||
<!-- 链接菜单项 -->
|
||||
<NuxtLink
|
||||
v-if="item.type === 'link' && item.to"
|
||||
:to="item.to"
|
||||
class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<i :class="item.icon + ' mr-2'"></i>
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
|
||||
<!-- 按钮菜单项 -->
|
||||
<button
|
||||
v-else-if="item.type === 'button'"
|
||||
@click="item.action"
|
||||
class="block w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
:class="item.className || 'text-gray-700 dark:text-gray-300'"
|
||||
>
|
||||
<i :class="item.icon + ' mr-2'"></i>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
|
||||
<!-- 分割线 -->
|
||||
<div
|
||||
v-else-if="item.type === 'divider'"
|
||||
class="border-t border-gray-200 dark:border-gray-700 my-1"
|
||||
></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserLayout } from '~/composables/useUserLayout'
|
||||
|
||||
// 用户状态管理
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 使用用户布局组合式函数
|
||||
const { getUserMenuItems } = useUserLayout()
|
||||
|
||||
// 用户菜单状态
|
||||
const showUserMenu = ref(false)
|
||||
|
||||
// 获取用户菜单项
|
||||
const userMenuItems = computed(() => getUserMenuItems())
|
||||
|
||||
// 点击外部关闭菜单
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('.relative')) {
|
||||
showUserMenu.value = false
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 确保Font Awesome图标正确显示 */
|
||||
.fas {
|
||||
font-family: 'Font Awesome 6 Free';
|
||||
font-weight: 900;
|
||||
}
|
||||
</style>
|
||||
47
web/components/User/Sidebar.vue
Normal file
47
web/components/User/Sidebar.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<aside class="w-64 bg-white dark:bg-gray-800 shadow-sm border-r border-gray-200 dark:border-gray-700 min-h-screen">
|
||||
<nav class="mt-8">
|
||||
<div class="px-4 space-y-2">
|
||||
<!-- 导航菜单 -->
|
||||
<NuxtLink
|
||||
v-for="item in navigationItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="flex items-center px-4 py-3 text-gray-700 dark:text-gray-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 rounded-lg transition-colors"
|
||||
:class="{ 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400': item.active($route) }"
|
||||
>
|
||||
<i :class="item.icon + ' w-5 h-5 mr-3'"></i>
|
||||
<span>{{ item.label }}</span>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 my-4"></div>
|
||||
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="flex items-center px-4 py-3 text-gray-700 dark:text-gray-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 rounded-lg transition-colors"
|
||||
>
|
||||
<i class="fas fa-arrow-left w-5 h-5 mr-3"></i>
|
||||
<span>返回首页</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserLayout } from '~/composables/useUserLayout'
|
||||
|
||||
// 使用用户布局组合式函数
|
||||
const { getNavigationItems } = useUserLayout()
|
||||
|
||||
// 获取导航菜单项
|
||||
const navigationItems = computed(() => getNavigationItems())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 确保Font Awesome图标正确显示 */
|
||||
.fas {
|
||||
font-family: 'Font Awesome 6 Free';
|
||||
font-weight: 900;
|
||||
}
|
||||
</style>
|
||||
47
web/composables/useAdminNewLayout.ts
Normal file
47
web/composables/useAdminNewLayout.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useUserLayout } from '~/composables/useUserLayout'
|
||||
import { adminNewNavigationItems, adminNewMenuItems } from '~/config/adminNewNavigation'
|
||||
|
||||
export const useAdminNewLayout = () => {
|
||||
// 直接复用 useUserLayout
|
||||
const userLayout = useUserLayout()
|
||||
|
||||
// 管理后台专用的认证检查 - 要求管理员权限
|
||||
const checkAdminAuth = () => {
|
||||
return userLayout.checkPermission('admin')
|
||||
}
|
||||
|
||||
// 管理后台专用的用户信息
|
||||
const getAdminInfo = () => {
|
||||
const userInfo = userLayout.getUserInfo()
|
||||
return {
|
||||
...userInfo,
|
||||
username: userInfo.username || '管理员',
|
||||
role: userInfo.role || 'admin',
|
||||
isAdmin: true
|
||||
}
|
||||
}
|
||||
|
||||
// 管理后台专用的导航菜单
|
||||
const getAdminNavigationItems = () => {
|
||||
return adminNewNavigationItems
|
||||
}
|
||||
|
||||
// 管理后台专用的菜单项
|
||||
const getAdminMenuItems = () => {
|
||||
return adminNewMenuItems
|
||||
}
|
||||
|
||||
return {
|
||||
// 管理后台专用方法
|
||||
checkAuth: checkAdminAuth,
|
||||
getAdminInfo,
|
||||
getNavigationItems: getAdminNavigationItems,
|
||||
getAdminMenuItems,
|
||||
|
||||
// 复用 useUserLayout 的所有方法
|
||||
checkPermission: userLayout.checkPermission,
|
||||
getUserInfo: userLayout.getUserInfo,
|
||||
handleLogout: userLayout.handleLogout,
|
||||
getUserMenuItems: userLayout.getUserMenuItems
|
||||
}
|
||||
}
|
||||
@@ -26,14 +26,14 @@ export const parseApiResponse = <T>(response: any): T => {
|
||||
// 检查是否是包含success字段的响应格式(如登录接口)
|
||||
if (response && typeof response === 'object' && 'success' in response && 'data' in response) {
|
||||
if (response.success) {
|
||||
// 特殊处理资源接口返回的data.list格式,转换为resources格式
|
||||
if (response.data && response.data.list && Array.isArray(response.data.list)) {
|
||||
return {
|
||||
resources: response.data.list,
|
||||
total: response.data.total,
|
||||
page: response.data.page,
|
||||
page_size: response.data.limit
|
||||
} as T
|
||||
// 特殊处理登录接口,直接返回data部分(包含token和user)
|
||||
if (response.data && response.data.token && response.data.user) {
|
||||
console.log('parseApiResponse - 登录接口处理,返回data:', response.data)
|
||||
return response.data
|
||||
}
|
||||
// 特殊处理删除操作响应,直接返回data部分
|
||||
if (response.data && response.data.affected_rows !== undefined) {
|
||||
return response.data
|
||||
}
|
||||
return response.data
|
||||
} else {
|
||||
@@ -53,11 +53,13 @@ export const useResourceApi = () => {
|
||||
const deleteResource = (id: number) => useApiFetch(`/resources/${id}`, { method: 'DELETE' }).then(parseApiResponse)
|
||||
const searchResources = (params: any) => useApiFetch('/search', { params }).then(parseApiResponse)
|
||||
const getResourcesByPan = (panId: number, params?: any) => useApiFetch('/resources', { params: { ...params, pan_id: panId } }).then(parseApiResponse)
|
||||
// 新增:统一的资源访问次数上报
|
||||
// 新增:统一的资源访问次数上报(注意:getResourceLink 已包含访问统计,通常不需要单独调用此方法)
|
||||
const incrementViewCount = (id: number) => useApiFetch(`/resources/${id}/view`, { method: 'POST' })
|
||||
// 新增:批量删除资源
|
||||
const batchDeleteResources = (ids: number[]) => useApiFetch('/resources/batch', { method: 'DELETE', body: { ids } }).then(parseApiResponse)
|
||||
return { getResources, getResource, createResource, updateResource, deleteResource, searchResources, getResourcesByPan, incrementViewCount, batchDeleteResources }
|
||||
// 新增:获取资源链接(智能转存)
|
||||
const getResourceLink = (id: number) => useApiFetch(`/resources/${id}/link`).then(parseApiResponse)
|
||||
return { getResources, getResource, createResource, updateResource, deleteResource, searchResources, getResourcesByPan, incrementViewCount, batchDeleteResources, getResourceLink }
|
||||
}
|
||||
|
||||
export const useAuthApi = () => {
|
||||
@@ -110,6 +112,7 @@ export const useTagApi = () => {
|
||||
|
||||
export const useReadyResourceApi = () => {
|
||||
const getReadyResources = (params?: any) => useApiFetch('/ready-resources', { params }).then(parseApiResponse)
|
||||
const getFailedResources = (params?: any) => useApiFetch('/ready-resources/errors', { params }).then(parseApiResponse)
|
||||
const createReadyResource = (data: any) => useApiFetch('/ready-resources', { method: 'POST', body: data }).then(parseApiResponse)
|
||||
const batchCreateReadyResources = (data: any) => useApiFetch('/ready-resources/batch', { method: 'POST', body: data }).then(parseApiResponse)
|
||||
const createReadyResourcesFromText = (text: string) => {
|
||||
@@ -119,7 +122,25 @@ export const useReadyResourceApi = () => {
|
||||
}
|
||||
const deleteReadyResource = (id: number) => useApiFetch(`/ready-resources/${id}`, { method: 'DELETE' }).then(parseApiResponse)
|
||||
const clearReadyResources = () => useApiFetch('/ready-resources', { method: 'DELETE' }).then(parseApiResponse)
|
||||
return { getReadyResources, createReadyResource, batchCreateReadyResources, createReadyResourcesFromText, deleteReadyResource, clearReadyResources }
|
||||
const clearErrorMsg = (id: number) => useApiFetch(`/ready-resources/${id}/clear-error`, { method: 'POST' }).then(parseApiResponse)
|
||||
const retryFailedResources = () => useApiFetch('/ready-resources/retry-failed', { method: 'POST' }).then(parseApiResponse)
|
||||
const batchRestoreToReadyPool = (ids: number[]) => useApiFetch('/ready-resources/batch-restore', { method: 'POST', body: { ids } }).then(parseApiResponse)
|
||||
const batchRestoreToReadyPoolByQuery = (queryParams: any) => useApiFetch('/ready-resources/batch-restore-by-query', { method: 'POST', body: queryParams }).then(parseApiResponse)
|
||||
const clearAllErrorsByQuery = (queryParams: any) => useApiFetch('/ready-resources/clear-all-errors-by-query', { method: 'POST', body: queryParams }).then(parseApiResponse)
|
||||
return {
|
||||
getReadyResources,
|
||||
getFailedResources,
|
||||
createReadyResource,
|
||||
batchCreateReadyResources,
|
||||
createReadyResourcesFromText,
|
||||
deleteReadyResource,
|
||||
clearReadyResources,
|
||||
clearErrorMsg,
|
||||
retryFailedResources,
|
||||
batchRestoreToReadyPool,
|
||||
batchRestoreToReadyPoolByQuery,
|
||||
clearAllErrorsByQuery
|
||||
}
|
||||
}
|
||||
|
||||
export const useStatsApi = () => {
|
||||
@@ -166,6 +187,19 @@ export const usePublicSystemConfigApi = () => {
|
||||
return { getPublicSystemConfig }
|
||||
}
|
||||
|
||||
// 任务管理API
|
||||
export const useTaskApi = () => {
|
||||
const createBatchTransferTask = (data: any) => useApiFetch('/tasks/transfer', { method: 'POST', body: data }).then(parseApiResponse)
|
||||
const getTasks = (params?: any) => useApiFetch('/tasks', { params }).then(parseApiResponse)
|
||||
const getTaskStatus = (id: number) => useApiFetch(`/tasks/${id}`).then(parseApiResponse)
|
||||
const startTask = (id: number) => useApiFetch(`/tasks/${id}/start`, { method: 'POST' }).then(parseApiResponse)
|
||||
const stopTask = (id: number) => useApiFetch(`/tasks/${id}/stop`, { method: 'POST' }).then(parseApiResponse)
|
||||
const pauseTask = (id: number) => useApiFetch(`/tasks/${id}/pause`, { method: 'POST' }).then(parseApiResponse)
|
||||
const deleteTask = (id: number) => useApiFetch(`/tasks/${id}`, { method: 'DELETE' }).then(parseApiResponse)
|
||||
const getTaskItems = (id: number, params?: any) => useApiFetch(`/tasks/${id}/items`, { params }).then(parseApiResponse)
|
||||
return { createBatchTransferTask, getTasks, getTaskStatus, startTask, stopTask, pauseTask, deleteTask, getTaskItems }
|
||||
}
|
||||
|
||||
// 日志函数:只在开发环境打印
|
||||
function log(...args: any[]) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
|
||||
@@ -22,6 +22,13 @@ export function useApiFetch<T = any>(
|
||||
...options,
|
||||
headers,
|
||||
onResponse({ response }) {
|
||||
console.log('API响应:', {
|
||||
status: response.status,
|
||||
data: response._data,
|
||||
url: url
|
||||
})
|
||||
|
||||
// 处理401认证错误
|
||||
if (response.status === 401 ||
|
||||
(response._data && (response._data.code === 401 || response._data.error === '无效的令牌'))
|
||||
) {
|
||||
@@ -36,13 +43,25 @@ export function useApiFetch<T = any>(
|
||||
})
|
||||
}
|
||||
|
||||
// 处理403权限错误
|
||||
if (response.status === 403 ||
|
||||
(response._data && (response._data.code === 403 || response._data.error === '需要管理员权限'))
|
||||
) {
|
||||
throw Object.assign(new Error('需要管理员权限,请使用管理员账号登录'), {
|
||||
data: response._data,
|
||||
status: response.status,
|
||||
})
|
||||
}
|
||||
|
||||
// 统一处理 code/message
|
||||
if (response._data && response._data.code && response._data.code !== 200) {
|
||||
console.error('API错误响应:', response._data)
|
||||
throw new Error(response._data.message || '请求失败')
|
||||
}
|
||||
},
|
||||
onResponseError({ error }: { error: any }) {
|
||||
console.log('error', error)
|
||||
|
||||
// 检查是否为"无效的令牌"错误
|
||||
if (error?.data?.error === '无效的令牌') {
|
||||
// 清除用户状态
|
||||
@@ -54,6 +73,11 @@ export function useApiFetch<T = any>(
|
||||
throw new Error('登录已过期,请重新登录')
|
||||
}
|
||||
|
||||
// 检查是否为权限错误
|
||||
if (error?.data?.error === '需要管理员权限' || error?.status === 403) {
|
||||
throw new Error('需要管理员权限,请使用管理员账号登录')
|
||||
}
|
||||
|
||||
// 统一错误提示
|
||||
// 你可以用 naive-ui 的 useMessage() 这里弹窗
|
||||
// useMessage().error(error.message)
|
||||
|
||||
82
web/composables/useTimeFormat.ts
Normal file
82
web/composables/useTimeFormat.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// 统一的时间格式化工具函数
|
||||
export const useTimeFormat = () => {
|
||||
// 格式化日期时间(标准格式)
|
||||
const formatDateTime = (dateString: string | Date) => {
|
||||
if (!dateString) return '-'
|
||||
const date = dateString instanceof Date ? dateString : new Date(dateString)
|
||||
return date.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 格式化日期(仅日期)
|
||||
const formatDate = (dateString: string | Date) => {
|
||||
if (!dateString) return '-'
|
||||
const date = dateString instanceof Date ? dateString : new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
// 格式化时间(仅时间)
|
||||
const formatTime = (dateString: string | Date) => {
|
||||
if (!dateString) return '-'
|
||||
const date = dateString instanceof Date ? dateString : new Date(dateString)
|
||||
return date.toLocaleTimeString('zh-CN')
|
||||
}
|
||||
|
||||
// 格式化相对时间
|
||||
const formatRelativeTime = (dateString: string | Date) => {
|
||||
if (!dateString) return '-'
|
||||
const date = dateString instanceof Date ? dateString : new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffSec = Math.floor(diffMs / 1000)
|
||||
const diffMin = Math.floor(diffSec / 60)
|
||||
const diffHour = Math.floor(diffMin / 60)
|
||||
const diffDay = Math.floor(diffHour / 24)
|
||||
const diffWeek = Math.floor(diffDay / 7)
|
||||
const diffMonth = Math.floor(diffDay / 30)
|
||||
const diffYear = Math.floor(diffDay / 365)
|
||||
|
||||
const isToday = date.toDateString() === now.toDateString()
|
||||
|
||||
if (isToday) {
|
||||
if (diffMin < 1) {
|
||||
return '刚刚'
|
||||
} else if (diffHour < 1) {
|
||||
return `${diffMin}分钟前`
|
||||
} else {
|
||||
return `${diffHour}小时前`
|
||||
}
|
||||
} else if (diffDay < 1) {
|
||||
return `${diffHour}小时前`
|
||||
} else if (diffDay < 7) {
|
||||
return `${diffDay}天前`
|
||||
} else if (diffWeek < 4) {
|
||||
return `${diffWeek}周前`
|
||||
} else if (diffMonth < 12) {
|
||||
return `${diffMonth}个月前`
|
||||
} else {
|
||||
return `${diffYear}年前`
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前时间字符串
|
||||
const getCurrentTimeString = () => {
|
||||
return new Date().toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 检查是否为今天
|
||||
const isToday = (dateString: string | Date) => {
|
||||
if (!dateString) return false
|
||||
const date = dateString instanceof Date ? dateString : new Date(dateString)
|
||||
const now = new Date()
|
||||
return date.toDateString() === now.toDateString()
|
||||
}
|
||||
|
||||
return {
|
||||
formatDateTime,
|
||||
formatDate,
|
||||
formatTime,
|
||||
formatRelativeTime,
|
||||
getCurrentTimeString,
|
||||
isToday
|
||||
}
|
||||
}
|
||||
66
web/composables/useUserLayout.ts
Normal file
66
web/composables/useUserLayout.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useUserStore } from '~/stores/user'
|
||||
import { getFullNavigationConfig, getUserMenuItems as getMenuItems } from '~/config/userNavigation'
|
||||
|
||||
export const useUserLayout = () => {
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
// 检查用户认证状态
|
||||
const checkAuth = () => {
|
||||
if (!userStore.isAuthenticated) {
|
||||
router.push('/login')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查用户权限
|
||||
const checkPermission = (requiredRole: string = 'user') => {
|
||||
if (!userStore.isAuthenticated) {
|
||||
router.push('/login')
|
||||
return false
|
||||
}
|
||||
|
||||
if (requiredRole === 'admin' && userStore.user?.role !== 'admin') {
|
||||
router.push('/user')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
const getUserInfo = () => {
|
||||
return {
|
||||
username: userStore.user?.username || '用户',
|
||||
email: userStore.user?.email || '',
|
||||
role: userStore.user?.role || 'user',
|
||||
isAdmin: userStore.user?.role === 'admin'
|
||||
}
|
||||
}
|
||||
|
||||
// 处理退出登录
|
||||
const handleLogout = () => {
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
// 获取导航菜单项
|
||||
const getNavigationItems = () => {
|
||||
return getFullNavigationConfig(userStore.user?.role)
|
||||
}
|
||||
|
||||
// 获取用户菜单项
|
||||
const getUserMenuItems = () => {
|
||||
return getMenuItems(handleLogout)
|
||||
}
|
||||
|
||||
return {
|
||||
checkAuth,
|
||||
checkPermission,
|
||||
getUserInfo,
|
||||
handleLogout,
|
||||
getNavigationItems,
|
||||
getUserMenuItems
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ interface VersionResponse {
|
||||
|
||||
export const useVersion = () => {
|
||||
const versionInfo = ref<VersionInfo>({
|
||||
version: '1.0.9',
|
||||
version: '1.2.0',
|
||||
build_time: '',
|
||||
git_commit: 'unknown',
|
||||
git_branch: 'unknown',
|
||||
|
||||
189
web/config/adminNewNavigation.ts
Normal file
189
web/config/adminNewNavigation.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
// 管理后台导航配置
|
||||
export interface AdminNavigationItem {
|
||||
to: string
|
||||
icon: string
|
||||
label: string
|
||||
active: (route: any) => boolean
|
||||
permission?: string // 权限要求
|
||||
description?: string // 页面描述
|
||||
group?: string // 分组
|
||||
}
|
||||
|
||||
// 管理后台导航菜单配置
|
||||
export const adminNewNavigationItems = [
|
||||
{
|
||||
key: 'dashboard',
|
||||
label: '仪表盘',
|
||||
icon: 'fas fa-tachometer-alt',
|
||||
to: '/admin',
|
||||
active: (route: any) => route.path === '/admin',
|
||||
group: 'dashboard'
|
||||
},
|
||||
// 运营管理分组
|
||||
{
|
||||
key: 'resources',
|
||||
label: '资源管理',
|
||||
icon: 'fas fa-database',
|
||||
to: '/admin/resources',
|
||||
active: (route: any) => route.path.startsWith('/admin/resources'),
|
||||
group: 'operation'
|
||||
},
|
||||
{
|
||||
key: 'ready-resources',
|
||||
label: '待处理资源',
|
||||
icon: 'fas fa-clock',
|
||||
to: '/admin/ready-resources',
|
||||
active: (route: any) => route.path.startsWith('/admin/ready-resources'),
|
||||
group: 'operation'
|
||||
},
|
||||
{
|
||||
key: 'categories',
|
||||
label: '分类管理',
|
||||
icon: 'fas fa-folder',
|
||||
to: '/admin/categories',
|
||||
active: (route: any) => route.path.startsWith('/admin/categories'),
|
||||
group: 'operation'
|
||||
},
|
||||
{
|
||||
key: 'tags',
|
||||
label: '标签管理',
|
||||
icon: 'fas fa-tags',
|
||||
to: '/admin/tags',
|
||||
active: (route: any) => route.path.startsWith('/admin/tags'),
|
||||
group: 'operation'
|
||||
},
|
||||
{
|
||||
key: 'platforms',
|
||||
label: '平台管理',
|
||||
icon: 'fas fa-cloud',
|
||||
to: '/admin/platforms',
|
||||
active: (route: any) => route.path.startsWith('/admin/platforms'),
|
||||
group: 'operation'
|
||||
},
|
||||
{
|
||||
key: 'accounts',
|
||||
label: '账号管理',
|
||||
icon: 'fas fa-user-shield',
|
||||
to: '/admin/accounts',
|
||||
active: (route: any) => route.path.startsWith('/admin/accounts'),
|
||||
group: 'operation'
|
||||
},
|
||||
{
|
||||
key: 'data-transfer',
|
||||
label: '数据转存管理',
|
||||
icon: 'fas fa-exchange-alt',
|
||||
to: '/admin/data-transfer',
|
||||
active: (route: any) => route.path.startsWith('/admin/data-transfer'),
|
||||
group: 'operation'
|
||||
},
|
||||
{
|
||||
key: 'tasks',
|
||||
label: '任务管理',
|
||||
icon: 'fas fa-tasks',
|
||||
to: '/admin/tasks',
|
||||
active: (route: any) => route.path.startsWith('/admin/tasks'),
|
||||
group: 'operation'
|
||||
},
|
||||
{
|
||||
key: 'seo',
|
||||
label: 'SEO',
|
||||
icon: 'fas fa-search',
|
||||
to: '/admin/seo',
|
||||
active: (route: any) => route.path.startsWith('/admin/seo'),
|
||||
group: 'operation'
|
||||
},
|
||||
{
|
||||
key: 'data-push',
|
||||
label: '数据推送',
|
||||
icon: 'fas fa-upload',
|
||||
to: '/admin/data-push',
|
||||
active: (route: any) => route.path.startsWith('/admin/data-push'),
|
||||
group: 'operation'
|
||||
},
|
||||
{
|
||||
key: 'bot',
|
||||
label: '机器人',
|
||||
icon: 'fas fa-robot',
|
||||
to: '/admin/bot',
|
||||
active: (route: any) => route.path.startsWith('/admin/bot'),
|
||||
group: 'operation'
|
||||
},
|
||||
// 统计分析分组
|
||||
{
|
||||
key: 'search-stats',
|
||||
label: '搜索统计',
|
||||
icon: 'fas fa-chart-line',
|
||||
to: '/admin/search-stats',
|
||||
active: (route: any) => route.path.startsWith('/admin/search-stats'),
|
||||
group: 'statistics'
|
||||
},
|
||||
{
|
||||
key: 'third-party-stats',
|
||||
label: '三方统计',
|
||||
icon: 'fas fa-chart-bar',
|
||||
to: '/admin/third-party-stats',
|
||||
active: (route: any) => route.path.startsWith('/admin/third-party-stats'),
|
||||
group: 'statistics'
|
||||
},
|
||||
// 系统管理分组
|
||||
{
|
||||
key: 'users',
|
||||
label: '用户管理',
|
||||
icon: 'fas fa-users',
|
||||
to: '/admin/users',
|
||||
active: (route: any) => route.path.startsWith('/admin/users'),
|
||||
group: 'system'
|
||||
},
|
||||
{
|
||||
key: 'system-config',
|
||||
label: '系统配置',
|
||||
icon: 'fas fa-cog',
|
||||
to: '/admin/system-config',
|
||||
active: (route: any) => route.path.startsWith('/admin/system-config'),
|
||||
group: 'system'
|
||||
},
|
||||
{
|
||||
key: 'version',
|
||||
label: '版本信息',
|
||||
icon: 'fas fa-code-branch',
|
||||
to: '/admin/version',
|
||||
active: (route: any) => route.path.startsWith('/admin/version'),
|
||||
group: 'system'
|
||||
}
|
||||
]
|
||||
|
||||
// 获取完整导航配置
|
||||
export const getAdminNewNavigationConfig = (): AdminNavigationItem[] => {
|
||||
return [...adminNewNavigationItems]
|
||||
}
|
||||
|
||||
// 管理员菜单项配置
|
||||
export interface AdminMenuItem {
|
||||
to?: string
|
||||
icon?: string
|
||||
label?: string
|
||||
type: 'link' | 'button' | 'divider'
|
||||
action?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const adminNewMenuItems = [
|
||||
{
|
||||
key: 'profile',
|
||||
label: '个人资料',
|
||||
icon: 'fas fa-user',
|
||||
to: '/admin/profile'
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: '设置',
|
||||
icon: 'fas fa-cog',
|
||||
to: '/admin/settings'
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
label: '退出登录',
|
||||
icon: 'fas fa-sign-out-alt',
|
||||
action: 'logout'
|
||||
}
|
||||
]
|
||||
114
web/config/userNavigation.ts
Normal file
114
web/config/userNavigation.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
// 用户导航配置
|
||||
export interface NavigationItem {
|
||||
to: string
|
||||
icon: string
|
||||
label: string
|
||||
active: (route: any) => boolean
|
||||
permission?: string // 权限要求
|
||||
description?: string // 页面描述
|
||||
}
|
||||
|
||||
// 用户导航菜单配置
|
||||
export const userNavigationConfig: NavigationItem[] = [
|
||||
{
|
||||
to: '/user',
|
||||
icon: 'fas fa-home',
|
||||
label: '首页',
|
||||
active: (route: any) => route.path === '/user',
|
||||
description: '用户中心首页,查看个人概览'
|
||||
},
|
||||
{
|
||||
to: '/user/resources',
|
||||
icon: 'fas fa-cloud',
|
||||
label: '我的资源',
|
||||
active: (route: any) => route.path.startsWith('/user/resources'),
|
||||
description: '管理您的个人资源'
|
||||
},
|
||||
{
|
||||
to: '/user/favorites',
|
||||
icon: 'fas fa-heart',
|
||||
label: '收藏夹',
|
||||
active: (route: any) => route.path.startsWith('/user/favorites'),
|
||||
description: '查看和管理收藏的资源'
|
||||
},
|
||||
{
|
||||
to: '/user/history',
|
||||
icon: 'fas fa-history',
|
||||
label: '浏览历史',
|
||||
active: (route: any) => route.path.startsWith('/user/history'),
|
||||
description: '查看浏览历史记录'
|
||||
},
|
||||
{
|
||||
to: '/user/profile',
|
||||
icon: 'fas fa-user-edit',
|
||||
label: '个人资料',
|
||||
active: (route: any) => route.path.startsWith('/user/profile'),
|
||||
description: '编辑个人信息'
|
||||
},
|
||||
{
|
||||
to: '/user/settings',
|
||||
icon: 'fas fa-cog',
|
||||
label: '设置',
|
||||
active: (route: any) => route.path.startsWith('/user/settings'),
|
||||
description: '账户设置和偏好'
|
||||
}
|
||||
]
|
||||
|
||||
// 管理员额外导航项
|
||||
export const adminNavigationConfig: NavigationItem[] = [
|
||||
{
|
||||
to: '/admin',
|
||||
icon: 'fas fa-user-shield',
|
||||
label: '管理后台',
|
||||
active: (route: any) => route.path.startsWith('/admin'),
|
||||
permission: 'admin',
|
||||
description: '系统管理功能'
|
||||
}
|
||||
]
|
||||
|
||||
// 获取完整导航配置
|
||||
export const getFullNavigationConfig = (userRole?: string): NavigationItem[] => {
|
||||
const config = [...userNavigationConfig]
|
||||
|
||||
// 如果是管理员,添加管理功能
|
||||
if (userRole === 'admin') {
|
||||
config.push(...adminNavigationConfig)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// 用户菜单项配置
|
||||
export interface UserMenuItem {
|
||||
to?: string
|
||||
icon?: string
|
||||
label?: string
|
||||
type: 'link' | 'button' | 'divider'
|
||||
action?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const getUserMenuItems = (handleLogout: () => void): UserMenuItem[] => [
|
||||
{
|
||||
to: '/user/profile',
|
||||
icon: 'fas fa-user-edit',
|
||||
label: '个人资料',
|
||||
type: 'link'
|
||||
},
|
||||
{
|
||||
to: '/user/settings',
|
||||
icon: 'fas fa-cog',
|
||||
label: '设置',
|
||||
type: 'link'
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
icon: 'fas fa-sign-out-alt',
|
||||
label: '退出登录',
|
||||
action: handleLogout,
|
||||
className: 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300'
|
||||
}
|
||||
]
|
||||
103
web/layouts/admin-old.vue
Normal file
103
web/layouts/admin-old.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-100">
|
||||
<!-- 全局加载状态 -->
|
||||
<div v-if="pageLoading" class="fixed inset-0 bg-gray-900 bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-8 shadow-xl">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
<div class="text-center">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">正在加载...</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">请稍候,正在初始化管理后台</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 管理页面头部 -->
|
||||
<div class="p-3 sm:p-5">
|
||||
<AdminHeader :title="pageTitle" />
|
||||
</div>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<div class="p-3 sm:p-5">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<ClientOnly>
|
||||
<n-notification-provider>
|
||||
<n-dialog-provider>
|
||||
<!-- 页面内容插槽 -->
|
||||
<slot />
|
||||
</n-dialog-provider>
|
||||
</n-notification-provider>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 页脚 -->
|
||||
<AppFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSystemConfigStore } from '~/stores/systemConfig'
|
||||
import { useUserLayout } from '~/composables/useUserLayout'
|
||||
|
||||
// 使用用户布局组合式函数
|
||||
const { checkAuth, checkPermission } = useUserLayout()
|
||||
|
||||
// 页面加载状态
|
||||
const pageLoading = ref(false)
|
||||
|
||||
// 页面标题
|
||||
const route = useRoute()
|
||||
const pageTitle = computed(() => {
|
||||
const titles: Record<string, string> = {
|
||||
'/admin-old': '管理后台',
|
||||
'/admin-old/users': '用户管理',
|
||||
'/admin-old/categories': '分类管理',
|
||||
'/admin-old/tags': '标签管理',
|
||||
'/admin-old/tasks': '任务管理',
|
||||
'/admin-old/system-config': '系统配置',
|
||||
'/admin-old/resources': '资源管理',
|
||||
'/admin-old/cks': '平台账号管理',
|
||||
'/admin-old/ready-resources': '待处理资源',
|
||||
'/admin-old/search-stats': '搜索统计',
|
||||
'/admin-old/hot-dramas': '热播剧管理',
|
||||
'/admin-old/monitor': '系统监控',
|
||||
'/admin-old/add-resource': '添加资源',
|
||||
'/admin-old/api-docs': 'API文档',
|
||||
'/admin-old/version': '版本信息'
|
||||
}
|
||||
return titles[route.path] || '管理后台'
|
||||
})
|
||||
|
||||
// 监听路由变化,显示加载状态
|
||||
watch(() => route.path, () => {
|
||||
pageLoading.value = true
|
||||
setTimeout(() => {
|
||||
pageLoading.value = false
|
||||
}, 300)
|
||||
})
|
||||
|
||||
const systemConfigStore = useSystemConfigStore()
|
||||
onMounted(() => {
|
||||
// 检查用户认证和权限
|
||||
if (!checkAuth()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否为管理员
|
||||
if (!checkPermission('admin')) {
|
||||
return
|
||||
}
|
||||
|
||||
systemConfigStore.initConfig()
|
||||
pageLoading.value = true
|
||||
setTimeout(() => {
|
||||
pageLoading.value = false
|
||||
}, 300)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 管理后台专用样式 */
|
||||
</style>
|
||||
@@ -1,83 +1,602 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-100">
|
||||
<!-- 全局加载状态 -->
|
||||
<div v-if="pageLoading" class="fixed inset-0 bg-gray-900 bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-8 shadow-xl">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
<div class="text-center">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">正在加载...</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">请稍候,正在初始化管理后台</p>
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<!-- 设置通用title -->
|
||||
<Head>
|
||||
<title>管理后台 - 老九网盘资源数据库</title>
|
||||
</Head>
|
||||
<!-- 顶部导航栏 -->
|
||||
<header class="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between px-6 py-4">
|
||||
<!-- 左侧:Logo和标题 -->
|
||||
<div class="flex items-center">
|
||||
<NuxtLink to="/admin" class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-shield-alt text-white text-sm"></i>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">管理后台</h1>
|
||||
<!-- 版本信息 -->
|
||||
<NuxtLink
|
||||
to="/admin/version"
|
||||
class="text-xs text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
v{{ versionInfo.version }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- 右侧:状态信息和用户菜单 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 自动处理状态 -->
|
||||
<div class="flex items-center gap-2 bg-gray-100 dark:bg-gray-700 rounded-lg px-3 py-2">
|
||||
<div class="w-2 h-2 rounded-full animate-pulse" :class="{
|
||||
'bg-red-400': !isAutoProcessEnabled,
|
||||
'bg-green-400': isAutoProcessEnabled
|
||||
}"></div>
|
||||
<span class="text-xs text-gray-700 dark:text-gray-300 font-medium">
|
||||
自动处理已<span>{{ isAutoProcessEnabled ? '开启' : '关闭' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 自动转存状态 -->
|
||||
<div class="flex items-center gap-2 bg-gray-100 dark:bg-gray-700 rounded-lg px-3 py-2">
|
||||
<div class="w-2 h-2 rounded-full animate-pulse" :class="{
|
||||
'bg-red-400': !isAutoTransferEnabled,
|
||||
'bg-green-400': isAutoTransferEnabled
|
||||
}"></div>
|
||||
<span class="text-xs text-gray-700 dark:text-gray-300 font-medium">
|
||||
自动转存已<span>{{ isAutoTransferEnabled ? '开启' : '关闭' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 任务状态 -->
|
||||
<div
|
||||
v-if="taskStore.hasActiveTasks"
|
||||
@click="navigateToTasks"
|
||||
class="flex items-center gap-2 bg-orange-50 dark:bg-orange-900/20 rounded-lg px-3 py-2 cursor-pointer hover:bg-orange-100 dark:hover:bg-orange-900/30 transition-colors"
|
||||
>
|
||||
<div class="w-2 h-2 bg-orange-500 rounded-full animate-pulse"></div>
|
||||
<span class="text-xs text-orange-700 dark:text-orange-300 font-medium">
|
||||
<template v-if="taskStore.runningTaskCount > 0">
|
||||
{{ taskStore.runningTaskCount }}个任务运行中
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ taskStore.activeTaskCount }}个任务待处理
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<NuxtLink to="/" class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
<i class="fas fa-home text-lg"></i>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- 用户信息和下拉菜单 -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@click="showUserMenu = !showUserMenu"
|
||||
class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-user text-white text-sm"></i>
|
||||
</div>
|
||||
<div class="hidden md:block text-left">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ userStore.user?.username || '管理员' }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">管理员</p>
|
||||
</div>
|
||||
<i class="fas fa-chevron-down text-xs text-gray-400"></i>
|
||||
</button>
|
||||
|
||||
<!-- 下拉菜单内容 -->
|
||||
<div
|
||||
v-if="showUserMenu"
|
||||
class="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg py-1 z-50 border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<template v-for="item in userMenuItems" :key="item.label || item.type">
|
||||
<!-- 链接菜单项 -->
|
||||
<NuxtLink
|
||||
v-if="item.type === 'link' && item.to"
|
||||
:to="item.to"
|
||||
class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<i :class="item.icon + ' mr-2'"></i>
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
|
||||
<!-- 按钮菜单项 -->
|
||||
<button
|
||||
v-else-if="item.type === 'button'"
|
||||
@click="item.action"
|
||||
class="block w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
:class="item.className || 'text-gray-700 dark:text-gray-300'"
|
||||
>
|
||||
<i :class="item.icon + ' mr-2'"></i>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
|
||||
<!-- 分割线 -->
|
||||
<div
|
||||
v-else-if="item.type === 'divider'"
|
||||
class="border-t border-gray-200 dark:border-gray-700 my-1"
|
||||
></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 管理页面头部 -->
|
||||
<div class="p-3 sm:p-5">
|
||||
<AdminHeader :title="pageTitle" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 侧边栏和主内容区域 -->
|
||||
<div class="flex">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="w-64 bg-white dark:bg-gray-800 shadow-sm border-r border-gray-200 dark:border-gray-700 min-h-screen">
|
||||
<nav class="mt-8">
|
||||
<div class="px-4 space-y-6">
|
||||
<!-- 仪表盘 -->
|
||||
<div>
|
||||
<h3 class="px-4 mb-2 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
仪表盘
|
||||
</h3>
|
||||
<div class="space-y-1">
|
||||
<NuxtLink
|
||||
v-for="item in dashboardItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="flex items-center px-4 py-3 text-gray-700 dark:text-gray-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 rounded-lg transition-colors"
|
||||
:class="{ 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400': item.active(useRoute()) }"
|
||||
>
|
||||
<i :class="item.icon + ' w-5 h-5 mr-3'"></i>
|
||||
<span>{{ item.label }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<div class="p-3 sm:p-5">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<!-- 页面内容插槽 -->
|
||||
<slot />
|
||||
</div>
|
||||
<!-- 数据管理 -->
|
||||
<div>
|
||||
<button
|
||||
@click="toggleGroup('dataManagement')"
|
||||
class="w-full flex items-center justify-between px-4 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<span>数据管理</span>
|
||||
<i
|
||||
class="fas fa-chevron-down text-xs transition-transform duration-200"
|
||||
:class="{ 'rotate-180': expandedGroups.dataManagement }"
|
||||
></i>
|
||||
</button>
|
||||
<div
|
||||
v-show="expandedGroups.dataManagement"
|
||||
class="space-y-1 mt-2"
|
||||
>
|
||||
<NuxtLink
|
||||
v-for="item in dataManagementItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="flex items-center px-8 py-3 text-gray-700 dark:text-gray-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 rounded-lg transition-colors"
|
||||
:class="{ 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400': item.active(useRoute()) }"
|
||||
>
|
||||
<i :class="item.icon + ' w-5 h-5 mr-3'"></i>
|
||||
<span>{{ item.label }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统配置 -->
|
||||
<div>
|
||||
<button
|
||||
@click="toggleGroup('systemConfig')"
|
||||
class="w-full flex items-center justify-between px-4 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<span>系统配置</span>
|
||||
<i
|
||||
class="fas fa-chevron-down text-xs transition-transform duration-200"
|
||||
:class="{ 'rotate-180': expandedGroups.systemConfig }"
|
||||
></i>
|
||||
</button>
|
||||
<div
|
||||
v-show="expandedGroups.systemConfig"
|
||||
class="space-y-1 mt-2"
|
||||
>
|
||||
<NuxtLink
|
||||
v-for="item in systemConfigItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="flex items-center px-8 py-3 text-gray-700 dark:text-gray-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 rounded-lg transition-colors"
|
||||
:class="{ 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400': item.active(useRoute()) }"
|
||||
>
|
||||
<i :class="item.icon + ' w-5 h-5 mr-3'"></i>
|
||||
<span>{{ item.label }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 运营管理 -->
|
||||
<div>
|
||||
<button
|
||||
@click="toggleGroup('operation')"
|
||||
class="w-full flex items-center justify-between px-4 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<span>运营管理</span>
|
||||
<i
|
||||
class="fas fa-chevron-down text-xs transition-transform duration-200"
|
||||
:class="{ 'rotate-180': expandedGroups.operation }"
|
||||
></i>
|
||||
</button>
|
||||
<div
|
||||
v-show="expandedGroups.operation"
|
||||
class="space-y-1 mt-2"
|
||||
>
|
||||
<NuxtLink
|
||||
v-for="item in operationItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="flex items-center px-8 py-3 text-gray-700 dark:text-gray-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 rounded-lg transition-colors"
|
||||
:class="{ 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400': item.active(useRoute()) }"
|
||||
>
|
||||
<i :class="item.icon + ' w-5 h-5 mr-3'"></i>
|
||||
<span>{{ item.label }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计分析 -->
|
||||
<div>
|
||||
<button
|
||||
@click="toggleGroup('statistics')"
|
||||
class="w-full flex items-center justify-between px-4 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<span>统计分析</span>
|
||||
<i
|
||||
class="fas fa-chevron-down text-xs transition-transform duration-200"
|
||||
:class="{ 'rotate-180': expandedGroups.statistics }"
|
||||
></i>
|
||||
</button>
|
||||
<div
|
||||
v-show="expandedGroups.statistics"
|
||||
class="space-y-1 mt-2"
|
||||
>
|
||||
<NuxtLink
|
||||
v-for="item in statisticsItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="flex items-center px-8 py-3 text-gray-700 dark:text-gray-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 rounded-lg transition-colors"
|
||||
:class="{ 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400': item.active(useRoute()) }"
|
||||
>
|
||||
<i :class="item.icon + ' w-5 h-5 mr-3'"></i>
|
||||
<span>{{ item.label }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<main class="flex-1 p-8">
|
||||
<ClientOnly>
|
||||
<n-message-provider>
|
||||
<n-notification-provider>
|
||||
<n-dialog-provider>
|
||||
<!-- 页面内容插槽 -->
|
||||
<slot />
|
||||
</n-dialog-provider>
|
||||
</n-notification-provider>
|
||||
</n-message-provider>
|
||||
</ClientOnly>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 页脚 -->
|
||||
<AppFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '~/stores/user'
|
||||
import { useSystemConfigStore } from '~/stores/systemConfig'
|
||||
import { useTaskStore } from '~/stores/task'
|
||||
|
||||
// 页面加载状态
|
||||
const pageLoading = ref(false)
|
||||
|
||||
// 页面标题
|
||||
const route = useRoute()
|
||||
const pageTitle = computed(() => {
|
||||
const titles: Record<string, string> = {
|
||||
'/admin': '管理后台',
|
||||
'/users': '用户管理',
|
||||
'/categories': '分类管理',
|
||||
'/tags': '标签管理',
|
||||
'/system-config': '系统配置',
|
||||
'/resources': '资源管理',
|
||||
'/cks': '平台账号管理',
|
||||
'/ready-resources': '待处理资源',
|
||||
'/search-stats': '搜索统计',
|
||||
'/hot-dramas': '热播剧管理',
|
||||
'/monitor': '系统监控',
|
||||
'/add-resource': '添加资源',
|
||||
'/api-docs': 'API文档',
|
||||
'/version': '版本信息'
|
||||
}
|
||||
return titles[route.path] || '管理后台'
|
||||
})
|
||||
|
||||
// 监听路由变化,显示加载状态
|
||||
watch(() => route.path, () => {
|
||||
pageLoading.value = true
|
||||
setTimeout(() => {
|
||||
pageLoading.value = false
|
||||
}, 300)
|
||||
})
|
||||
// 用户状态管理
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
// 系统配置store
|
||||
const systemConfigStore = useSystemConfigStore()
|
||||
onMounted(() => {
|
||||
systemConfigStore.initConfig()
|
||||
pageLoading.value = true
|
||||
setTimeout(() => {
|
||||
pageLoading.value = false
|
||||
}, 300)
|
||||
|
||||
// 任务状态管理
|
||||
const taskStore = useTaskStore()
|
||||
|
||||
// 初始化系统配置(管理员页面使用管理员API)
|
||||
// 在setup阶段初始化,确保数据可用
|
||||
await systemConfigStore.initConfig(false, true)
|
||||
|
||||
// 版本信息
|
||||
const versionInfo = ref({
|
||||
version: '1.1.0'
|
||||
})
|
||||
|
||||
// 获取版本信息
|
||||
const fetchVersionInfo = async () => {
|
||||
try {
|
||||
const response = await $fetch('/api/version') as any
|
||||
if (response.success) {
|
||||
versionInfo.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取版本信息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化版本信息和任务状态管理
|
||||
onMounted(() => {
|
||||
fetchVersionInfo()
|
||||
|
||||
// 启动任务状态自动更新
|
||||
taskStore.startAutoUpdate()
|
||||
console.log('Admin layout: 任务状态自动更新已启动')
|
||||
})
|
||||
|
||||
// 组件销毁时清理任务状态管理
|
||||
onBeforeUnmount(() => {
|
||||
// 停止任务状态自动更新
|
||||
taskStore.stopAutoUpdate()
|
||||
console.log('Admin layout: 任务状态自动更新已停止')
|
||||
})
|
||||
|
||||
// 系统配置
|
||||
const systemConfig = computed(() => {
|
||||
const config = systemConfigStore.config || {}
|
||||
console.log('顶部导航系统配置:', config)
|
||||
console.log('自动处理状态:', config.auto_process_ready_resources)
|
||||
console.log('自动转存状态:', config.auto_transfer_enabled)
|
||||
return config
|
||||
})
|
||||
|
||||
// 自动处理状态(确保布尔值)
|
||||
const isAutoProcessEnabled = computed(() => {
|
||||
const value = systemConfig.value?.auto_process_ready_resources
|
||||
return value === true || value === 'true' || value === '1'
|
||||
})
|
||||
|
||||
// 自动转存状态(确保布尔值)
|
||||
const isAutoTransferEnabled = computed(() => {
|
||||
const value = systemConfig.value?.auto_transfer_enabled
|
||||
return value === true || value === 'true' || value === '1'
|
||||
})
|
||||
|
||||
// 用户菜单状态
|
||||
const showUserMenu = ref(false)
|
||||
|
||||
// 展开/折叠状态管理
|
||||
const expandedGroups = ref({
|
||||
dataManagement: false,
|
||||
systemConfig: false,
|
||||
operation: false,
|
||||
statistics: false
|
||||
})
|
||||
|
||||
// 切换分组展开/折叠状态
|
||||
const toggleGroup = (groupName: string) => {
|
||||
expandedGroups.value[groupName as keyof typeof expandedGroups.value] = !expandedGroups.value[groupName as keyof typeof expandedGroups.value]
|
||||
}
|
||||
|
||||
// 处理退出登录
|
||||
const handleLogout = () => {
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
// 管理员菜单项
|
||||
const userMenuItems = computed(() => [
|
||||
{
|
||||
to: '/admin/tasks',
|
||||
icon: 'fas fa-tasks',
|
||||
label: '任务列表',
|
||||
type: 'link'
|
||||
},
|
||||
{
|
||||
to: '/admin/accounts',
|
||||
icon: 'fas fa-user-shield',
|
||||
label: '账号管理',
|
||||
type: 'link'
|
||||
},
|
||||
{
|
||||
to: '/admin/system-config',
|
||||
icon: 'fas fa-cog',
|
||||
label: '系统配置',
|
||||
type: 'link'
|
||||
},
|
||||
{
|
||||
to: '/admin/version',
|
||||
icon: 'fas fa-code-branch',
|
||||
label: '版本信息',
|
||||
type: 'link'
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
icon: 'fas fa-sign-out-alt',
|
||||
label: '退出登录',
|
||||
action: handleLogout,
|
||||
className: 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300'
|
||||
}
|
||||
])
|
||||
|
||||
// 仪表盘菜单项
|
||||
const dashboardItems = ref([
|
||||
{
|
||||
to: '/admin',
|
||||
label: '仪表盘',
|
||||
icon: 'fas fa-tachometer-alt',
|
||||
active: (route: any) => route.path === '/admin'
|
||||
}
|
||||
])
|
||||
|
||||
// 数据管理菜单项
|
||||
const dataManagementItems = ref([
|
||||
{
|
||||
to: '/admin/resources',
|
||||
label: '资源管理',
|
||||
icon: 'fas fa-database',
|
||||
active: (route: any) => route.path.startsWith('/admin/resources')
|
||||
},
|
||||
{
|
||||
to: '/admin/ready-resources',
|
||||
label: '待处理资源',
|
||||
icon: 'fas fa-clock',
|
||||
active: (route: any) => route.path.startsWith('/admin/ready-resources')
|
||||
},
|
||||
|
||||
{
|
||||
to: '/admin/tags',
|
||||
label: '标签管理',
|
||||
icon: 'fas fa-tags',
|
||||
active: (route: any) => route.path.startsWith('/admin/tags')
|
||||
},
|
||||
{
|
||||
to: '/admin/categories',
|
||||
label: '分类管理',
|
||||
icon: 'fas fa-folder',
|
||||
active: (route: any) => route.path.startsWith('/admin/categories')
|
||||
},
|
||||
{
|
||||
to: '/admin/accounts',
|
||||
label: '平台账号',
|
||||
icon: 'fas fa-user-shield',
|
||||
active: (route: any) => route.path.startsWith('/admin/accounts')
|
||||
}
|
||||
])
|
||||
|
||||
// 系统配置菜单项
|
||||
const systemConfigItems = ref([
|
||||
{
|
||||
to: '/admin/site-config',
|
||||
label: '站点配置',
|
||||
icon: 'fas fa-globe',
|
||||
active: (route: any) => route.path.startsWith('/admin/site-config')
|
||||
},
|
||||
{
|
||||
to: '/admin/feature-config',
|
||||
label: '功能配置',
|
||||
icon: 'fas fa-sliders-h',
|
||||
active: (route: any) => route.path.startsWith('/admin/feature-config')
|
||||
},
|
||||
{
|
||||
to: '/admin/dev-config',
|
||||
label: '开发配置',
|
||||
icon: 'fas fa-code',
|
||||
active: (route: any) => route.path.startsWith('/admin/dev-config')
|
||||
},
|
||||
{
|
||||
to: '/admin/users',
|
||||
label: '用户管理',
|
||||
icon: 'fas fa-users',
|
||||
active: (route: any) => route.path.startsWith('/admin/users')
|
||||
}
|
||||
])
|
||||
|
||||
// 运营管理菜单项
|
||||
const operationItems = ref([
|
||||
{
|
||||
to: '/admin/data-transfer',
|
||||
label: '数据转存管理',
|
||||
icon: 'fas fa-exchange-alt',
|
||||
active: (route: any) => route.path.startsWith('/admin/data-transfer')
|
||||
},
|
||||
{
|
||||
to: '/admin/data-push',
|
||||
label: '数据推送',
|
||||
icon: 'fas fa-upload',
|
||||
active: (route: any) => route.path.startsWith('/admin/data-push')
|
||||
},
|
||||
{
|
||||
to: '/admin/bot',
|
||||
label: '机器人',
|
||||
icon: 'fas fa-robot',
|
||||
active: (route: any) => route.path.startsWith('/admin/bot')
|
||||
},
|
||||
{
|
||||
to: '/admin/seo',
|
||||
label: 'SEO',
|
||||
icon: 'fas fa-search',
|
||||
active: (route: any) => route.path.startsWith('/admin/seo')
|
||||
}
|
||||
])
|
||||
|
||||
// 统计分析菜单项
|
||||
const statisticsItems = ref([
|
||||
{
|
||||
to: '/admin/search-stats',
|
||||
label: '搜索统计',
|
||||
icon: 'fas fa-chart-line',
|
||||
active: (route: any) => route.path.startsWith('/admin/search-stats')
|
||||
},
|
||||
{
|
||||
to: '/admin/third-party-stats',
|
||||
label: '三方统计',
|
||||
icon: 'fas fa-chart-bar',
|
||||
active: (route: any) => route.path.startsWith('/admin/third-party-stats')
|
||||
}
|
||||
])
|
||||
|
||||
// 自动展开当前页面所在的分组
|
||||
const autoExpandCurrentGroup = () => {
|
||||
const currentPath = useRoute().path
|
||||
|
||||
// 检查当前页面属于哪个分组并展开
|
||||
if (currentPath.startsWith('/admin/resources') || currentPath.startsWith('/admin/ready-resources') || currentPath.startsWith('/admin/tasks') || currentPath.startsWith('/admin/tags') || currentPath.startsWith('/admin/categories') || currentPath.startsWith('/admin/accounts')) {
|
||||
expandedGroups.value.dataManagement = true
|
||||
} else if (currentPath.startsWith('/admin/site-config') || currentPath.startsWith('/admin/feature-config') || currentPath.startsWith('/admin/dev-config') || currentPath.startsWith('/admin/users') || currentPath.startsWith('/admin/version')) {
|
||||
expandedGroups.value.systemConfig = true
|
||||
} else if (currentPath.startsWith('/admin/data-transfer') || currentPath.startsWith('/admin/seo') || currentPath.startsWith('/admin/data-push') || currentPath.startsWith('/admin/bot')) {
|
||||
expandedGroups.value.operation = true
|
||||
} else if (currentPath.startsWith('/admin/search-stats') || currentPath.startsWith('/admin/third-party-stats')) {
|
||||
expandedGroups.value.statistics = true
|
||||
}
|
||||
}
|
||||
|
||||
// 监听路由变化,自动展开对应分组
|
||||
watch(() => useRoute().path, (newPath) => {
|
||||
// 重置所有分组状态
|
||||
expandedGroups.value = {
|
||||
dataManagement: false,
|
||||
systemConfig: false,
|
||||
operation: false,
|
||||
statistics: false
|
||||
}
|
||||
|
||||
// 根据新路径展开对应分组
|
||||
if (newPath.startsWith('/admin/resources') || newPath.startsWith('/admin/ready-resources') || newPath.startsWith('/admin/tasks') || newPath.startsWith('/admin/tags') || newPath.startsWith('/admin/categories') || newPath.startsWith('/admin/accounts')) {
|
||||
expandedGroups.value.dataManagement = true
|
||||
} else if (newPath.startsWith('/admin/site-config') || newPath.startsWith('/admin/feature-config') || newPath.startsWith('/admin/dev-config') || newPath.startsWith('/admin/users') || newPath.startsWith('/admin/version')) {
|
||||
expandedGroups.value.systemConfig = true
|
||||
} else if (newPath.startsWith('/admin/data-transfer') || newPath.startsWith('/admin/seo') || newPath.startsWith('/admin/data-push') || newPath.startsWith('/admin/bot')) {
|
||||
expandedGroups.value.operation = true
|
||||
} else if (newPath.startsWith('/admin/search-stats') || newPath.startsWith('/admin/third-party-stats')) {
|
||||
expandedGroups.value.statistics = true
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 点击外部关闭菜单
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('.relative')) {
|
||||
showUserMenu.value = false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 导航到任务列表页面
|
||||
const navigateToTasks = () => {
|
||||
router.push('/admin/tasks')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 管理后台专用样式 */
|
||||
/* 确保Font Awesome图标正确显示 */
|
||||
.fas {
|
||||
font-family: 'Font Awesome 6 Free';
|
||||
font-weight: 900;
|
||||
}
|
||||
</style>
|
||||
@@ -16,8 +16,13 @@
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<NuxtPage />
|
||||
|
||||
<n-notification-provider>
|
||||
<n-dialog-provider>
|
||||
<NuxtPage />
|
||||
</n-dialog-provider>
|
||||
</n-notification-provider>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -39,11 +44,64 @@ const toggleDarkMode = () => {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
|
||||
const injectRawScript = (rawScriptString: string) => {
|
||||
if (process.client) {
|
||||
// 创建一个临时容器来解析原始字符串
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = rawScriptString.trim();
|
||||
|
||||
// 获取解析后的 script 元素
|
||||
const script = container.querySelector('script');
|
||||
|
||||
if (script) {
|
||||
// 创建新的 script 元素
|
||||
const newScript = document.createElement('script');
|
||||
|
||||
// 复制所有属性(包括 data-*、async、defer 等)
|
||||
Array.from(script.attributes).forEach((attr) => {
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
});
|
||||
|
||||
// 复制内容(如果是内联脚本)
|
||||
if (script.innerHTML) {
|
||||
newScript.innerHTML = script.innerHTML;
|
||||
}
|
||||
|
||||
// 插入到 DOM
|
||||
document.head.appendChild(newScript);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 获取三方统计代码并直接加载
|
||||
const fetchStatsCode = async () => {
|
||||
try {
|
||||
const { usePublicSystemConfigApi } = await import('~/composables/useApi')
|
||||
const publicSystemConfigApi = usePublicSystemConfigApi()
|
||||
const response = await publicSystemConfigApi.getPublicSystemConfig()
|
||||
|
||||
if (response?.data && response.data.third_party_stats_code) {
|
||||
injectRawScript(response.data.third_party_stats_code);
|
||||
console.log('三方统计代码已加载')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取三方统计代码失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
// 初始化主题
|
||||
if (localStorage.getItem('theme') === 'dark') {
|
||||
isDark.value = true
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
|
||||
// 获取三方统计代码并直接加载
|
||||
await fetchStatsCode()
|
||||
|
||||
})
|
||||
</script>
|
||||
@@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<div class="forbidden-layout">
|
||||
<slot />
|
||||
<div class="single-layout">
|
||||
<n-notification-provider>
|
||||
<slot />
|
||||
</n-notification-provider>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -17,7 +19,7 @@ body, html {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.forbidden-layout {
|
||||
.single-layout {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
45
web/layouts/user.vue
Normal file
45
web/layouts/user.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<!-- 顶部导航栏 -->
|
||||
<UserHeader />
|
||||
|
||||
<!-- 侧边栏和主内容区域 -->
|
||||
<div class="flex">
|
||||
<!-- 侧边栏 -->
|
||||
<UserSidebar />
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<main class="flex-1 p-8">
|
||||
<ClientOnly>
|
||||
<n-notification-provider>
|
||||
<n-dialog-provider>
|
||||
<!-- 页面内容插槽 -->
|
||||
<slot />
|
||||
</n-dialog-provider>
|
||||
</n-notification-provider>
|
||||
</ClientOnly>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useUserLayout } from '~/composables/useUserLayout'
|
||||
|
||||
// 使用用户布局组合式函数
|
||||
const { checkAuth } = useUserLayout()
|
||||
|
||||
// 页面加载时检查认证状态
|
||||
onMounted(() => {
|
||||
checkAuth()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 确保Font Awesome图标正确显示 */
|
||||
.fas {
|
||||
font-family: 'Font Awesome 6 Free';
|
||||
font-weight: 900;
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +0,0 @@
|
||||
export default defineNuxtRouteMiddleware((to, from) => {
|
||||
// 只在客户端执行
|
||||
if (process.client) {
|
||||
const ua = navigator.userAgent
|
||||
// 检测是否为QQ或微信内置浏览器
|
||||
if (['QQ/', 'MicroMessenger', 'WeiBo', 'DingTalk', 'Mail'].some(it => ua.includes(it))) {
|
||||
// 如果当前不在禁止访问页面,则跳转
|
||||
if (to.path !== '/forbidden') {
|
||||
return navigateTo('/forbidden')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
132
web/middleware/ua.global.ts
Normal file
132
web/middleware/ua.global.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { defineNuxtRouteMiddleware, useRequestEvent } from 'nuxt/app'
|
||||
import { getHeader, getRequestURL, setResponseStatus, setResponseHeader, send } from 'h3'
|
||||
|
||||
export default defineNuxtRouteMiddleware((to, from) => {
|
||||
// 只在服务端执行
|
||||
if (!process.server) return
|
||||
|
||||
const event = useRequestEvent()
|
||||
if (!event) return
|
||||
|
||||
const userAgent = getHeader(event, 'user-agent') || ''
|
||||
const isForbiddenApp = ['QQ/', 'MicroMessenger', 'WeiBo', 'DingTalk', 'Mail'].some(it => userAgent.includes(it))
|
||||
|
||||
if (isForbiddenApp) {
|
||||
// 获取当前 URL
|
||||
const currentUrl = getRequestURL(event).href
|
||||
|
||||
// 设置响应头
|
||||
setResponseStatus(event, 200)
|
||||
setResponseHeader(event, 'Content-Type', 'text/html; charset=utf-8')
|
||||
|
||||
// 直接返回 HTML 响应
|
||||
return send(event, generateForbiddenPage(currentUrl, userAgent))
|
||||
}
|
||||
})
|
||||
|
||||
// 生成禁止访问页面的函数
|
||||
function generateForbiddenPage(url: string, userAgent: string) {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>请在浏览器中打开</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.forbidden-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.top-bar-guidance {
|
||||
font-size: 15px;
|
||||
color: #fff;
|
||||
height: 70%;
|
||||
line-height: 1.2;
|
||||
padding-left: 20px;
|
||||
padding-top: 20px;
|
||||
background: url('/assets/images/banner.png') center right/cover no-repeat;
|
||||
}
|
||||
.top-bar-guidance p {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.top-bar-guidance .icon-safari {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
vertical-align: middle;
|
||||
margin: 0 .2em;
|
||||
}
|
||||
.top-bar-guidance-text {
|
||||
display: flex;
|
||||
justify-items: center;
|
||||
word-wrap: nowrap;
|
||||
}
|
||||
.top-bar-guidance-text img {
|
||||
display: inline-block;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
vertical-align: middle;
|
||||
margin: 0 .2em;
|
||||
}
|
||||
#contens {
|
||||
font-weight: bold;
|
||||
color: #2466f4;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
margin-bottom: 125px;
|
||||
}
|
||||
.app-download-tip {
|
||||
margin: 0 auto;
|
||||
width: 290px;
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
color: #2466f4;
|
||||
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAcAQMAAACak0ePAAAABlBMVEUAAAAdYfh+GakkAAAAAXRSTlMAQObYZgAAAA5JREFUCNdjwA8acEkAAAy4AIE4hQq/AAAAAElFTkSuQmCC) left center/auto 15px repeat-x;
|
||||
}
|
||||
.app-download-tip .guidance-desc {
|
||||
background-color: #fff;
|
||||
padding: 0 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="forbidden-page">
|
||||
<div class="top-bar-guidance">
|
||||
<p class="top-bar-guidance-text">请按提示在手机 浏览器 打开<img src="/assets/images/3dian.png" class="icon-safari"></p>
|
||||
<p class="top-bar-guidance-text">苹果设备<img src="/assets/images/iphone.png" class="icon-safari">↗↗↗</p>
|
||||
<p class="top-bar-guidance-text">安卓设备<img src="/assets/images/android.png" class="icon-safari">↗↗↗</p>
|
||||
</div>
|
||||
|
||||
<div id="contens">
|
||||
<p><br/><br/></p>
|
||||
<p>1.本站不支持 微信,QQ等APP 内访问</p>
|
||||
<p><br/></p>
|
||||
<p>2.请按提示在手机 浏览器 打开</p>
|
||||
<p id="device-tip"><br/>3.请在浏览器中打开</p>
|
||||
</div>
|
||||
|
||||
<p><br/><br/></p>
|
||||
<div class="app-download-tip">
|
||||
<span class="guidance-desc" id="current-url">${url}</span>
|
||||
</div>
|
||||
<p><br/></p>
|
||||
<div class="app-download-tip">
|
||||
<span class="guidance-desc">点击右上角···图标 or 复制网址自行打开</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "res-db-web",
|
||||
"version": "1.0.9",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
101
web/pages/admin-old-useless/add-resource.vue
Normal file
101
web/pages/admin-old-useless/add-resource.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-100">
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg">
|
||||
<!-- Tab 切换 -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:class="[
|
||||
'px-6 py-4 text-sm font-medium border-b-2 transition-colors',
|
||||
mode === tab.value
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
]"
|
||||
@click="mode = tab.value"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="p-6">
|
||||
<!-- 批量添加 -->
|
||||
<AdminBatchAddResource
|
||||
v-if="mode === 'batch'"
|
||||
@success="handleSuccess"
|
||||
@error="handleError"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
|
||||
<!-- 单个添加 -->
|
||||
<AdminSingleAddResource
|
||||
v-else-if="mode === 'single'"
|
||||
@success="handleSuccess"
|
||||
@error="handleError"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 设置页面布局
|
||||
definePageMeta({
|
||||
layout: 'admin-old',
|
||||
ssr: false
|
||||
})
|
||||
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
// 根据 Nuxt 3 组件规则,位于 components/Admin/ 的组件会自动以 Admin 前缀导入
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const tabs = [
|
||||
{ label: '批量添加', value: 'batch' },
|
||||
{ label: '单个添加', value: 'single' },
|
||||
]
|
||||
const mode = ref('batch')
|
||||
const notification = useNotification()
|
||||
|
||||
// 检查用户权限
|
||||
onMounted(() => {
|
||||
const userStore = useUserStore()
|
||||
if (!userStore.isAuthenticated) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
// 事件处理
|
||||
const handleSuccess = (message: string) => {
|
||||
notification.success({
|
||||
content: message,
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
|
||||
const handleError = (message: string) => {
|
||||
notification.error({
|
||||
content: message,
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 自定义样式 */
|
||||
</style>
|
||||
433
web/pages/admin-old-useless/categories.vue
Normal file
433
web/pages/admin-old-useless/categories.vue
Normal file
@@ -0,0 +1,433 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-100">
|
||||
<!-- 全局加载状态 -->
|
||||
<div v-if="pageLoading" class="fixed inset-0 bg-gray-900 bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-8 shadow-xl">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
<div class="text-center">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">正在加载...</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">请稍候,正在加载分类数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<n-alert class="mb-4" title="分类用于对资源进行分类管理,可以关联多个标签" type="info" />
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div class="flex gap-2">
|
||||
<n-button @click="showAddModal = true" type="success">
|
||||
<i class="fas fa-plus"></i> 添加分类
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative">
|
||||
<n-input v-model:value="searchQuery" @input="debounceSearch" type="text"
|
||||
placeholder="搜索分类名称..." />
|
||||
<div class="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||
<i class="fas fa-search text-gray-400 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<n-button @click="refreshData" type="tertiary">
|
||||
<i class="fas fa-refresh"></i> 刷新
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类列表 -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-full">
|
||||
<thead>
|
||||
<tr class="bg-slate-800 dark:bg-gray-700 text-white dark:text-gray-100">
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">ID</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">分类名称</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">描述</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">资源数量</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">关联标签</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr v-if="loading" class="text-center py-8">
|
||||
<td colspan="6" class="text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>加载中...
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else-if="categories.length === 0" class="text-center py-8">
|
||||
<td colspan="6" class="text-gray-500 dark:text-gray-400">
|
||||
<div class="flex flex-col items-center justify-center py-12">
|
||||
<svg class="w-16 h-16 text-gray-300 dark:text-gray-600 mb-4" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 48 48">
|
||||
<circle cx="24" cy="24" r="20" stroke-width="3" stroke-dasharray="6 6" />
|
||||
<path d="M16 24h16M24 16v16" stroke-width="3" stroke-linecap="round" />
|
||||
</svg>
|
||||
<div class="text-lg font-semibold text-gray-400 dark:text-gray-500 mb-2">暂无分类</div>
|
||||
<div class="text-sm text-gray-400 dark:text-gray-600 mb-4">你可以点击上方"添加分类"按钮创建新分类</div>
|
||||
<n-button @click="showAddModal = true" type="primary">
|
||||
<i class="fas fa-plus"></i> 添加分类
|
||||
</n-button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="category in categories" :key="category.id"
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 font-medium">{{ category.id }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||
<span :title="category.name">{{ category.name }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span v-if="category.description" :title="category.description">{{ category.description }}</span>
|
||||
<span v-else class="text-gray-400 dark:text-gray-500 italic">无描述</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span
|
||||
class="px-2 py-1 bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300 rounded-full text-xs">
|
||||
{{ category.resource_count || 0 }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span v-if="category.tag_names && category.tag_names.length > 0" class="text-gray-800 dark:text-gray-200">
|
||||
{{ category.tag_names.join(', ') }}
|
||||
</span>
|
||||
<span v-else class="text-gray-400 dark:text-gray-500 italic text-xs">无标签</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<n-button @click="editCategory(category)" type="info" size="small">
|
||||
<i class="fas fa-edit"></i>
|
||||
</n-button>
|
||||
<n-button @click="deleteCategory(category.id)" type="error" size="small">
|
||||
<i class="fas fa-trash"></i>
|
||||
</n-button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="totalPages > 1" class="flex flex-wrap justify-center gap-1 sm:gap-2 mt-6">
|
||||
<button v-if="currentPage > 1" @click="goToPage(currentPage - 1)"
|
||||
class="bg-white text-gray-700 hover:bg-gray-50 px-2 py-1 sm:px-4 sm:py-2 rounded border transition-colors text-sm flex items-center">
|
||||
<i class="fas fa-chevron-left mr-1"></i> 上一页
|
||||
</button>
|
||||
|
||||
<button @click="goToPage(1)"
|
||||
:class="currentPage === 1 ? 'bg-slate-800 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'"
|
||||
class="px-2 py-1 sm:px-4 sm:py-2 rounded border transition-colors text-sm">
|
||||
1
|
||||
</button>
|
||||
|
||||
<button v-if="totalPages > 1" @click="goToPage(2)"
|
||||
:class="currentPage === 2 ? 'bg-slate-800 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'"
|
||||
class="px-2 py-1 sm:px-4 sm:py-2 rounded border transition-colors text-sm">
|
||||
2
|
||||
</button>
|
||||
|
||||
<span v-if="currentPage > 2" class="px-2 py-1 sm:px-3 sm:py-2 text-gray-500 text-sm">...</span>
|
||||
|
||||
<button v-if="currentPage !== 1 && currentPage !== 2 && currentPage > 2"
|
||||
class="bg-slate-800 text-white px-2 py-1 sm:px-4 sm:py-2 rounded border transition-colors text-sm">
|
||||
{{ currentPage }}
|
||||
</button>
|
||||
|
||||
<button v-if="currentPage < totalPages" @click="goToPage(currentPage + 1)"
|
||||
class="bg-white text-gray-700 hover:bg-gray-50 px-2 py-1 sm:px-4 sm:py-2 rounded border transition-colors text-sm flex items-center">
|
||||
下一页 <i class="fas fa-chevron-right ml-1"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div v-if="totalPages <= 1" class="mt-4 text-center">
|
||||
<div class="inline-flex items-center bg-white dark:bg-gray-800 rounded-lg shadow px-6 py-3">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
共 <span class="font-semibold text-gray-900 dark:text-gray-100">{{ totalCount }}</span> 个分类
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑分类模态框 -->
|
||||
<div v-if="showAddModal" class="fixed inset-0 bg-gray-900 bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ editingCategory ? '编辑分类' : '添加分类' }}
|
||||
</h3>
|
||||
<n-button @click="closeModal" type="tertiary" size="small">
|
||||
<i class="fas fa-times"></i>
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">分类名称:</label>
|
||||
<n-input v-model:value="formData.name" type="text" required
|
||||
placeholder="请输入分类名称" />
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">描述:</label>
|
||||
<n-input v-model:value="formData.description" type="textarea"
|
||||
placeholder="请输入分类描述(可选)" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<n-button type="tertiary" @click="closeModal">
|
||||
取消
|
||||
</n-button>
|
||||
<n-button type="primary" :disabled="submitting" @click="handleSubmit">
|
||||
{{ submitting ? '提交中...' : (editingCategory ? '更新' : '添加') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 设置页面布局
|
||||
definePageMeta({
|
||||
layout: 'admin-old',
|
||||
ssr: false
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const config = useRuntimeConfig()
|
||||
import { useCategoryApi } from '~/composables/useApi'
|
||||
const categoryApi = useCategoryApi()
|
||||
|
||||
// 页面状态
|
||||
const pageLoading = ref(true)
|
||||
const loading = ref(false)
|
||||
const categories = ref<any[]>([])
|
||||
|
||||
// 分页状态
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const totalCount = ref(0)
|
||||
const totalPages = ref(0)
|
||||
|
||||
// 搜索状态
|
||||
const searchQuery = ref('')
|
||||
let searchTimeout: NodeJS.Timeout | null = null
|
||||
|
||||
// 模态框状态
|
||||
const showAddModal = ref(false)
|
||||
const submitting = ref(false)
|
||||
const editingCategory = ref<any>(null)
|
||||
const dialog = useDialog()
|
||||
|
||||
// 表单数据
|
||||
const formData = ref({
|
||||
name: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
// 获取认证头
|
||||
const getAuthHeaders = () => {
|
||||
return userStore.authHeaders
|
||||
}
|
||||
|
||||
// 检查认证状态
|
||||
const checkAuth = () => {
|
||||
userStore.initAuth()
|
||||
if (!userStore.isAuthenticated) {
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 获取分类列表
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const params = {
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value,
|
||||
search: searchQuery.value
|
||||
}
|
||||
console.log('获取分类列表参数:', params)
|
||||
const response = await categoryApi.getCategories(params)
|
||||
console.log('分类接口响应:', response)
|
||||
console.log('响应类型:', typeof response)
|
||||
console.log('响应是否为数组:', Array.isArray(response))
|
||||
|
||||
// 适配后端API响应格式
|
||||
if (response && (response as any).items && Array.isArray((response as any).items)) {
|
||||
console.log('使用 items 格式:', (response as any).items)
|
||||
categories.value = (response as any).items
|
||||
totalCount.value = (response as any).total || 0
|
||||
totalPages.value = Math.ceil(totalCount.value / pageSize.value)
|
||||
} else if (Array.isArray(response)) {
|
||||
console.log('使用数组格式:', response)
|
||||
// 兼容旧格式
|
||||
categories.value = response
|
||||
totalCount.value = response.length
|
||||
totalPages.value = 1
|
||||
} else {
|
||||
console.log('使用默认格式:', response)
|
||||
categories.value = []
|
||||
totalCount.value = 0
|
||||
totalPages.value = 1
|
||||
}
|
||||
console.log('最终分类数据:', categories.value)
|
||||
console.log('分类数据长度:', categories.value.length)
|
||||
} catch (error) {
|
||||
console.error('获取分类列表失败:', error)
|
||||
categories.value = []
|
||||
totalCount.value = 0
|
||||
totalPages.value = 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索防抖
|
||||
const debounceSearch = () => {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
fetchCategories()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
const refreshData = () => {
|
||||
fetchCategories()
|
||||
}
|
||||
|
||||
// 分页跳转
|
||||
const goToPage = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchCategories()
|
||||
}
|
||||
|
||||
// 编辑分类
|
||||
const editCategory = (category: any) => {
|
||||
console.log('编辑分类:', category)
|
||||
editingCategory.value = category
|
||||
formData.value = {
|
||||
name: category.name,
|
||||
description: category.description || ''
|
||||
}
|
||||
console.log('设置表单数据:', formData.value)
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
// 删除分类
|
||||
const deleteCategory = async (categoryId: number) => {
|
||||
dialog.warning({
|
||||
title: '警告',
|
||||
content: '确定要删除分类吗?',
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
draggable: true,
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await categoryApi.deleteCategory(categoryId)
|
||||
await fetchCategories()
|
||||
} catch (error) {
|
||||
console.error('删除分类失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
submitting.value = true
|
||||
let response: any
|
||||
if (editingCategory.value) {
|
||||
response = await categoryApi.updateCategory(editingCategory.value.id, formData.value)
|
||||
} else {
|
||||
response = await categoryApi.createCategory(formData.value)
|
||||
}
|
||||
console.log('分类操作响应:', response)
|
||||
|
||||
// 检查是否是恢复操作
|
||||
if (response && response.message && response.message.includes('恢复成功')) {
|
||||
console.log('检测到分类恢复操作,延迟刷新数据')
|
||||
console.log('恢复的分类信息:', response.category)
|
||||
closeModal()
|
||||
// 延迟一点时间再刷新,确保数据库状态已更新
|
||||
setTimeout(async () => {
|
||||
console.log('开始刷新分类数据...')
|
||||
await fetchCategories()
|
||||
console.log('分类数据刷新完成')
|
||||
}, 500)
|
||||
return
|
||||
}
|
||||
|
||||
closeModal()
|
||||
await fetchCategories()
|
||||
} catch (error) {
|
||||
console.error('提交分类失败:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
const closeModal = () => {
|
||||
showAddModal.value = false
|
||||
editingCategory.value = null
|
||||
formData.value = {
|
||||
name: '',
|
||||
description: ''
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timestamp: string) => {
|
||||
if (!timestamp) return '-'
|
||||
const date = new Date(timestamp)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
const handleLogout = () => {
|
||||
userStore.logout()
|
||||
navigateTo('/login')
|
||||
}
|
||||
|
||||
// 页面加载
|
||||
onMounted(async () => {
|
||||
try {
|
||||
checkAuth()
|
||||
await fetchCategories()
|
||||
|
||||
// 检查URL参数,如果action=add则自动打开新增弹窗
|
||||
const route = useRoute()
|
||||
if (route.query.action === 'add') {
|
||||
showAddModal.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('分类管理页面初始化失败:', error)
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 自定义样式 */
|
||||
</style>
|
||||
@@ -19,23 +19,23 @@
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
<n-button
|
||||
@click="showCreateModal = true"
|
||||
class="w-full sm:w-auto px-4 py-2 bg-green-600 hover:bg-green-700 rounded-md transition-colors text-center flex items-center justify-center gap-2"
|
||||
type="success"
|
||||
>
|
||||
<i class="fas fa-plus"></i> 添加账号
|
||||
</button>
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative w-40">
|
||||
<n-select v-model:value="platform" :options="platformOptions" />
|
||||
<n-select v-model:value="platform" :options="platformOptions" @update:value="onPlatformChange" />
|
||||
</div>
|
||||
<button
|
||||
<n-button
|
||||
@click="refreshData"
|
||||
class="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 flex items-center gap-2"
|
||||
type="tertiary"
|
||||
>
|
||||
<i class="fas fa-refresh"></i> 刷新
|
||||
</button>
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,12 +71,12 @@
|
||||
</svg>
|
||||
<div class="text-lg font-semibold text-gray-400 dark:text-gray-500 mb-2">暂无账号</div>
|
||||
<div class="text-sm text-gray-400 dark:text-gray-600 mb-4">你可以点击上方"添加账号"按钮创建新账号</div>
|
||||
<button
|
||||
<n-button
|
||||
@click="showCreateModal = true"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors text-sm flex items-center gap-2"
|
||||
type="primary"
|
||||
>
|
||||
<i class="fas fa-plus"></i> 添加账号
|
||||
</button>
|
||||
</n-button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -228,8 +228,8 @@
|
||||
class="mt-1 block w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
>
|
||||
<option value="">请选择平台</option>
|
||||
<option v-for="pan in platforms" :key="pan.id" :value="pan.id">
|
||||
{{ pan.name }}
|
||||
<option v-for="pan in platforms.filter(pan => pan.name === 'quark')" :key="pan.id" :value="pan.id">
|
||||
{{ pan.remark }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="showEditModal" class="mt-1 text-xs text-gray-500 dark:text-gray-400">编辑时不允许修改平台类型</p>
|
||||
@@ -242,49 +242,44 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Cookie <span class="text-red-500">*</span></label>
|
||||
<textarea
|
||||
v-model="form.ck"
|
||||
<n-input
|
||||
v-model:value="form.ck"
|
||||
required
|
||||
rows="4"
|
||||
class="mt-1 block w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
type="textarea"
|
||||
placeholder="请输入Cookie内容,系统将自动识别容量"
|
||||
></textarea>
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">备注</label>
|
||||
<input
|
||||
v-model="form.remark"
|
||||
<n-input
|
||||
v-model:value="form.remark"
|
||||
type="text"
|
||||
class="mt-1 block w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
placeholder="可选,备注信息"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showEditModal">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
v-model="form.is_valid"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600"
|
||||
<n-checkbox
|
||||
v-model:checked="form.is_valid"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">账号有效</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
<n-button
|
||||
type="tertiary"
|
||||
@click="closeModal"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="submitting"
|
||||
class="px-4 py-2 bg-indigo-600 border border-transparent rounded-md text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ submitting ? '处理中...' : (showEditModal ? '更新' : '创建') }}
|
||||
</button>
|
||||
</n-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -295,10 +290,11 @@
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
layout: 'admin-old',
|
||||
ssr: false
|
||||
})
|
||||
|
||||
const notification = useNotification()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
@@ -323,6 +319,7 @@ const loading = ref(true)
|
||||
const pageLoading = ref(true)
|
||||
const submitting = ref(false)
|
||||
const platform = ref(null)
|
||||
const dialog = useDialog()
|
||||
|
||||
import { useCksApi, usePanApi } from '~/composables/useApi'
|
||||
const cksApi = useCksApi()
|
||||
@@ -334,10 +331,18 @@ const pans = computed(() => {
|
||||
return Array.isArray(pansData.value) ? pansData.value : (pansData.value?.list || [])
|
||||
})
|
||||
const platformOptions = computed(() => {
|
||||
return pans.value.map(pan => ({
|
||||
label: pan.remark,
|
||||
value: pan.id
|
||||
}))
|
||||
const options = [
|
||||
{ label: '全部平台', value: null }
|
||||
]
|
||||
|
||||
pans.value.forEach(pan => {
|
||||
options.push({
|
||||
label: pan.remark || pan.name || `平台${pan.id}`,
|
||||
value: pan.id
|
||||
})
|
||||
})
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
// 检查认证
|
||||
@@ -383,8 +388,11 @@ const createCks = async () => {
|
||||
await fetchCks()
|
||||
closeModal()
|
||||
} catch (error) {
|
||||
console.error('创建账号失败:', error)
|
||||
alert('创建账号失败: ' + (error.message || '未知错误'))
|
||||
dialog.error({
|
||||
title: '错误',
|
||||
content: '创建账号失败: ' + (error.message || '未知错误'),
|
||||
positiveText: '确定'
|
||||
})
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
@@ -399,7 +407,11 @@ const updateCks = async () => {
|
||||
closeModal()
|
||||
} catch (error) {
|
||||
console.error('更新账号失败:', error)
|
||||
alert('更新账号失败: ' + (error.message || '未知错误'))
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: '更新账号失败: ' + (error.message || '未知错误'),
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
@@ -407,47 +419,88 @@ const updateCks = async () => {
|
||||
|
||||
// 删除账号
|
||||
const deleteCks = async (id) => {
|
||||
if (!confirm('确定要删除这个账号吗?')) return
|
||||
|
||||
try {
|
||||
await cksApi.deleteCks(id)
|
||||
await fetchCks()
|
||||
} catch (error) {
|
||||
console.error('删除账号失败:', error)
|
||||
alert('删除账号失败: ' + (error.message || '未知错误'))
|
||||
}
|
||||
dialog.warning({
|
||||
title: '警告',
|
||||
content: '确定要删除这个账号吗?',
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
draggable: true,
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await cksApi.deleteCks(id)
|
||||
await fetchCks()
|
||||
} catch (error) {
|
||||
console.error('删除账号失败:', error)
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: '删除账号失败: ' + (error.message || '未知错误'),
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 刷新容量
|
||||
const refreshCapacity = async (id) => {
|
||||
if (!confirm('确定要刷新此账号的容量信息吗?')) return
|
||||
|
||||
try {
|
||||
await cksApi.refreshCapacity(id)
|
||||
await fetchCks()
|
||||
alert('容量信息已刷新!')
|
||||
} catch (error) {
|
||||
console.error('刷新容量失败:', error)
|
||||
alert('刷新容量失败: ' + (error.message || '未知错误'))
|
||||
}
|
||||
dialog.warning({
|
||||
title: '警告',
|
||||
content: '确定要刷新此账号的容量信息吗?',
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
draggable: true,
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await cksApi.refreshCapacity(id)
|
||||
await fetchCks()
|
||||
notification.success({
|
||||
title: '成功',
|
||||
content: '容量信息已刷新!',
|
||||
duration: 3000
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('刷新容量失败:', error)
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: '刷新容量失败: ' + (error.message || '未知错误'),
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 切换账号状态
|
||||
const toggleStatus = async (cks) => {
|
||||
const newStatus = !cks.is_valid
|
||||
if (!confirm(`确定要${cks.is_valid ? '禁用' : '启用'}此账号吗?`)) return
|
||||
|
||||
try {
|
||||
console.log('切换状态 - 账号ID:', cks.id, '当前状态:', cks.is_valid, '新状态:', newStatus)
|
||||
await cksApi.updateCks(cks.id, { is_valid: newStatus })
|
||||
console.log('状态更新成功,正在刷新数据...')
|
||||
await fetchCks()
|
||||
console.log('数据刷新完成')
|
||||
alert(`账号已${newStatus ? '启用' : '禁用'}!`)
|
||||
} catch (error) {
|
||||
console.error('切换账号状态失败:', error)
|
||||
alert(`切换账号状态失败: ${error.message || '未知错误'}`)
|
||||
}
|
||||
dialog.warning({
|
||||
title: '警告',
|
||||
content: `确定要${cks.is_valid ? '禁用' : '启用'}此账号吗?`,
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
draggable: true,
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
console.log('切换状态 - 账号ID:', cks.id, '当前状态:', cks.is_valid, '新状态:', newStatus)
|
||||
await cksApi.updateCks(cks.id, { is_valid: newStatus })
|
||||
console.log('状态更新成功,正在刷新数据...')
|
||||
await fetchCks()
|
||||
console.log('数据刷新完成')
|
||||
notification.success({
|
||||
title: '成功',
|
||||
content: `账号已${newStatus ? '启用' : '禁用'}!`,
|
||||
duration: 3000
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('切换账号状态失败:', error)
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: `切换账号状态失败: ${error.message || '未知错误'}`,
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 编辑账号
|
||||
@@ -532,13 +585,24 @@ const formatFileSize = (bytes) => {
|
||||
// 过滤和分页计算
|
||||
const filteredCksList = computed(() => {
|
||||
let filtered = cksList.value
|
||||
console.log('原始账号数量:', filtered.length)
|
||||
|
||||
// 平台过滤
|
||||
if (platform.value !== null && platform.value !== undefined) {
|
||||
filtered = filtered.filter(cks => cks.pan_id === platform.value)
|
||||
console.log('平台过滤后数量:', filtered.length, '平台ID:', platform.value)
|
||||
}
|
||||
|
||||
// 搜索过滤
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
filtered = filtered.filter(cks =>
|
||||
cks.pan?.name?.toLowerCase().includes(query) ||
|
||||
cks.remark?.toLowerCase().includes(query)
|
||||
)
|
||||
console.log('搜索过滤后数量:', filtered.length, '搜索词:', searchQuery.value)
|
||||
}
|
||||
|
||||
totalPages.value = Math.ceil(filtered.length / itemsPerPage.value)
|
||||
const start = (currentPage.value - 1) * itemsPerPage.value
|
||||
const end = start + itemsPerPage.value
|
||||
@@ -556,9 +620,17 @@ const debounceSearch = () => {
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 平台变化处理
|
||||
const onPlatformChange = () => {
|
||||
currentPage.value = 1
|
||||
console.log('平台过滤条件变化:', platform.value)
|
||||
console.log('当前过滤后的账号数量:', filteredCksList.value.length)
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
const refreshData = () => {
|
||||
currentPage.value = 1
|
||||
// 保持当前的过滤条件,只刷新数据
|
||||
fetchCks()
|
||||
fetchPlatforms()
|
||||
}
|
||||
733
web/pages/admin-old-useless/failed-resources.vue
Normal file
733
web/pages/admin-old-useless/failed-resources.vue
Normal file
@@ -0,0 +1,733 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-100 p-3 sm:p-5">
|
||||
<!-- 全局加载状态 -->
|
||||
<div v-if="pageLoading" class="fixed inset-0 bg-gray-900 bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-8 shadow-xl">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-red-600"></div>
|
||||
<div class="text-center">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">正在加载...</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">请稍候,正在加载失败资源列表</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<!-- 页面标题 -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">失败资源列表</h1>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">显示处理失败的资源,包含错误信息</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="retryAllFailed"
|
||||
:disabled="!errorFilter.trim() || isProcessing"
|
||||
:class="[
|
||||
'w-full sm:w-auto px-4 py-2 rounded-md transition-colors text-center flex items-center justify-center gap-2',
|
||||
errorFilter.trim() && !isProcessing
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: 'bg-gray-400 text-gray-200 cursor-not-allowed'
|
||||
]"
|
||||
>
|
||||
<i v-if="isProcessing" class="fas fa-spinner fa-spin"></i>
|
||||
<i v-else class="fas fa-redo"></i>
|
||||
{{ isProcessing ? '处理中...' : '重新放入待处理池' }}
|
||||
</button>
|
||||
<button
|
||||
@click="clearAllErrors"
|
||||
:disabled="!errorFilter.trim() || isProcessing"
|
||||
:class="[
|
||||
'w-full sm:w-auto px-4 py-2 rounded-md transition-colors text-center flex items-center justify-center gap-2',
|
||||
errorFilter.trim() && !isProcessing
|
||||
? 'bg-yellow-600 hover:bg-yellow-700'
|
||||
: 'bg-gray-400 text-gray-200 cursor-not-allowed'
|
||||
]"
|
||||
>
|
||||
<i v-if="isProcessing" class="fas fa-spinner fa-spin"></i>
|
||||
<i v-else class="fas fa-trash"></i>
|
||||
{{ isProcessing ? '处理中...' : '删除失败资源' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<!-- 错误信息过滤 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<n-input
|
||||
v-model:value="errorFilter"
|
||||
type="text"
|
||||
placeholder="过滤错误信息..."
|
||||
class="w-48"
|
||||
clearable
|
||||
@input="onErrorFilterChange"
|
||||
/>
|
||||
<button
|
||||
v-if="errorFilter"
|
||||
@click="clearErrorFilter"
|
||||
class="px-2 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
title="清除过滤条件"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="refreshData"
|
||||
class="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 flex items-center gap-2"
|
||||
>
|
||||
<i class="fas fa-refresh"></i> 刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- 失败资源列表 -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-red-800 dark:bg-red-900 text-white dark:text-gray-100 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">ID</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">状态</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">标题</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">URL</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">错误信息</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">创建时间</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">IP地址</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700 max-h-96 overflow-y-auto">
|
||||
<tr v-if="loading" class="text-center py-8">
|
||||
<td colspan="8" class="text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>加载中...
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else-if="failedResources.length === 0">
|
||||
<td colspan="8">
|
||||
<div class="flex flex-col items-center justify-center py-12">
|
||||
<svg class="w-16 h-16 text-gray-300 dark:text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
||||
<circle cx="24" cy="24" r="20" stroke-width="3" stroke-dasharray="6 6" />
|
||||
<path d="M16 24h16M24 16v16" stroke-width="3" stroke-linecap="round" />
|
||||
</svg>
|
||||
<div class="text-lg font-semibold text-gray-400 dark:text-gray-500 mb-2">暂无失败资源</div>
|
||||
<div class="text-sm text-gray-400 dark:text-gray-600">所有资源处理成功</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="resource in failedResources"
|
||||
:key="resource.id"
|
||||
:class="[
|
||||
'hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors',
|
||||
resource.is_deleted ? 'bg-gray-100 dark:bg-gray-700' : ''
|
||||
]"
|
||||
>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 font-medium">{{ resource.id }}</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span
|
||||
v-if="resource.is_deleted"
|
||||
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
|
||||
title="已删除"
|
||||
>
|
||||
<i class="fas fa-trash mr-1"></i>已删除
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
title="正常"
|
||||
>
|
||||
<i class="fas fa-check mr-1"></i>正常
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||
<span v-if="resource.title && resource.title !== null" :title="resource.title">{{ escapeHtml(resource.title) }}</span>
|
||||
<span v-else class="text-gray-400 dark:text-gray-500 italic">未设置</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<a
|
||||
:href="checkUrlSafety(resource.url)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:underline break-all"
|
||||
:title="resource.url"
|
||||
>
|
||||
{{ escapeHtml(resource.url) }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="max-w-xs">
|
||||
<span
|
||||
class="text-red-600 dark:text-red-400 text-xs bg-red-50 dark:bg-red-900/20 px-2 py-1 rounded"
|
||||
:title="resource.error_msg"
|
||||
>
|
||||
{{ truncateError(resource.error_msg) }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ formatTime(resource.create_time) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ escapeHtml(resource.ip || '-') }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="retryResource(resource.id)"
|
||||
class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 transition-colors"
|
||||
title="重试此资源"
|
||||
>
|
||||
<i class="fas fa-redo"></i>
|
||||
</button>
|
||||
<button
|
||||
@click="clearError(resource.id)"
|
||||
class="text-yellow-600 hover:text-yellow-800 dark:text-yellow-400 dark:hover:text-yellow-300 transition-colors"
|
||||
title="清除错误信息"
|
||||
>
|
||||
<i class="fas fa-broom"></i>
|
||||
</button>
|
||||
<button
|
||||
@click="deleteResource(resource.id)"
|
||||
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
||||
title="删除此资源"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页组件 -->
|
||||
<div v-if="totalPages > 1" class="mt-6 flex justify-center">
|
||||
<div class="flex items-center space-x-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4">
|
||||
<!-- 总资源数 -->
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
共 <span class="font-semibold text-gray-900 dark:text-gray-100">{{ totalCount }}</span> 个失败资源
|
||||
<span v-if="errorFilter" class="ml-2 text-blue-600 dark:text-blue-400">
|
||||
(已过滤)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="w-px h-6 bg-gray-300 dark:bg-gray-600"></div>
|
||||
|
||||
<!-- 上一页 -->
|
||||
<button
|
||||
@click="goToPage(currentPage - 1)"
|
||||
:disabled="currentPage <= 1"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-500 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center gap-2"
|
||||
>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
<span>上一页</span>
|
||||
</button>
|
||||
|
||||
<!-- 页码 -->
|
||||
<template v-for="page in visiblePages" :key="page">
|
||||
<button
|
||||
v-if="typeof page === 'number'"
|
||||
@click="goToPage(page)"
|
||||
:class="[
|
||||
'px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 min-w-[40px]',
|
||||
page === currentPage
|
||||
? 'bg-red-600 text-white shadow-md'
|
||||
: 'text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
]"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
<span v-else class="px-3 py-2 text-sm text-gray-500">...</span>
|
||||
</template>
|
||||
|
||||
<!-- 下一页 -->
|
||||
<button
|
||||
@click="goToPage(currentPage + 1)"
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-500 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center gap-2"
|
||||
>
|
||||
<span>下一页</span>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div v-if="totalPages <= 1" class="mt-4 text-center">
|
||||
<div class="inline-flex items-center bg-white dark:bg-gray-800 rounded-lg shadow px-6 py-3">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
共 <span class="font-semibold text-gray-900 dark:text-gray-100">{{ totalCount }}</span> 个失败资源
|
||||
<span v-if="errorFilter" class="ml-2 text-blue-600 dark:text-blue-400">
|
||||
(已过滤)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 设置页面布局
|
||||
definePageMeta({
|
||||
layout: 'admin-old',
|
||||
ssr: false
|
||||
})
|
||||
|
||||
interface FailedResource {
|
||||
id: number
|
||||
title?: string | null
|
||||
url: string
|
||||
error_msg: string
|
||||
create_time: string
|
||||
ip?: string | null
|
||||
deleted_at?: string | null
|
||||
is_deleted: boolean
|
||||
}
|
||||
|
||||
const notification = useNotification()
|
||||
const failedResources = ref<FailedResource[]>([])
|
||||
const loading = ref(false)
|
||||
const pageLoading = ref(true)
|
||||
|
||||
// 分页相关状态
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(100)
|
||||
const totalCount = ref(0)
|
||||
const totalPages = ref(0)
|
||||
|
||||
|
||||
const dialog = useDialog()
|
||||
|
||||
// 过滤相关状态
|
||||
const errorFilter = ref('')
|
||||
|
||||
// 获取失败资源API
|
||||
import { useReadyResourceApi } from '~/composables/useApi'
|
||||
const readyResourceApi = useReadyResourceApi()
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value
|
||||
}
|
||||
|
||||
// 如果有过滤条件,添加到查询参数中
|
||||
if (errorFilter.value.trim()) {
|
||||
params.error_filter = errorFilter.value.trim()
|
||||
}
|
||||
|
||||
console.log('fetchData - 开始获取失败资源,参数:', params)
|
||||
|
||||
const response = await readyResourceApi.getFailedResources(params) as any
|
||||
|
||||
console.log('fetchData - 原始响应:', response)
|
||||
|
||||
if (response && response.data && Array.isArray(response.data)) {
|
||||
console.log('fetchData - 使用response.data格式(数组)')
|
||||
failedResources.value = response.data
|
||||
totalCount.value = response.total || 0
|
||||
totalPages.value = Math.ceil((response.total || 0) / pageSize.value)
|
||||
} else {
|
||||
console.log('fetchData - 使用空数据格式')
|
||||
failedResources.value = []
|
||||
totalCount.value = 0
|
||||
totalPages.value = 1
|
||||
}
|
||||
|
||||
console.log('fetchData - 处理后的数据:', {
|
||||
failedResourcesCount: failedResources.value.length,
|
||||
totalCount: totalCount.value,
|
||||
totalPages: totalPages.value
|
||||
})
|
||||
|
||||
// 打印第一个资源的数据结构(如果存在)
|
||||
if (failedResources.value.length > 0) {
|
||||
console.log('fetchData - 第一个资源的数据结构:', failedResources.value[0])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取失败资源失败:', error)
|
||||
failedResources.value = []
|
||||
totalCount.value = 0
|
||||
totalPages.value = 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到指定页面
|
||||
const goToPage = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
currentPage.value = page
|
||||
fetchData()
|
||||
}
|
||||
}
|
||||
|
||||
// 计算可见的页码
|
||||
const visiblePages = computed(() => {
|
||||
const pages: (number | string)[] = []
|
||||
const maxVisible = 5
|
||||
|
||||
if (totalPages.value <= maxVisible) {
|
||||
for (let i = 1; i <= totalPages.value; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
} else {
|
||||
if (currentPage.value <= 3) {
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
pages.push('...')
|
||||
pages.push(totalPages.value)
|
||||
} else if (currentPage.value >= totalPages.value - 2) {
|
||||
pages.push(1)
|
||||
pages.push('...')
|
||||
for (let i = totalPages.value - 3; i <= totalPages.value; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
} else {
|
||||
pages.push(1)
|
||||
pages.push('...')
|
||||
for (let i = currentPage.value - 1; i <= currentPage.value + 1; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
pages.push('...')
|
||||
pages.push(totalPages.value)
|
||||
}
|
||||
}
|
||||
|
||||
return pages
|
||||
})
|
||||
|
||||
// 防抖函数
|
||||
const debounce = (func: Function, delay: number) => {
|
||||
let timeoutId: NodeJS.Timeout
|
||||
return (...args: any[]) => {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = setTimeout(() => func.apply(null, args), delay)
|
||||
}
|
||||
}
|
||||
|
||||
// 错误过滤输入变化处理(防抖)
|
||||
const onErrorFilterChange = debounce(() => {
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
fetchData()
|
||||
}, 300)
|
||||
|
||||
// 清除错误过滤
|
||||
const clearErrorFilter = () => {
|
||||
errorFilter.value = ''
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
const refreshData = () => {
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 重试单个资源
|
||||
const retryResource = async (id: number) => {
|
||||
dialog.warning({
|
||||
title: '警告',
|
||||
content: '确定要重试这个资源吗?',
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
draggable: true,
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await readyResourceApi.clearErrorMsg(id)
|
||||
notification.success({
|
||||
title: '成功',
|
||||
content: '错误信息已清除,资源将在下次调度时重新处理',
|
||||
duration: 3000
|
||||
})
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('重试失败:', error)
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: '重试失败',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 清除单个资源错误
|
||||
const clearError = async (id: number) => {
|
||||
dialog.warning({
|
||||
title: '警告',
|
||||
content: '确定要清除这个资源的错误信息吗?',
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
draggable: true,
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await readyResourceApi.clearErrorMsg(id)
|
||||
notification.success({
|
||||
title: '成功',
|
||||
content: '错误信息已清除',
|
||||
duration: 3000
|
||||
})
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('清除错误失败:', error)
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: '清除错误失败',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 删除资源
|
||||
const deleteResource = async (id: number) => {
|
||||
dialog.warning({
|
||||
title: '警告',
|
||||
content: '确定要删除这个失败资源吗?',
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
draggable: true,
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await readyResourceApi.deleteReadyResource(id)
|
||||
if (failedResources.value.length === 1 && currentPage.value > 1) {
|
||||
currentPage.value--
|
||||
}
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: '删除失败',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 处理状态
|
||||
const isProcessing = ref(false)
|
||||
|
||||
// 重新放入待处理池
|
||||
const retryAllFailed = async () => {
|
||||
if (totalCount.value === 0) {
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: '没有可处理的资源',
|
||||
duration: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否有过滤条件
|
||||
if (!errorFilter.value.trim()) {
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: '请先设置过滤条件,以避免处理所有失败资源',
|
||||
duration: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 构建查询条件
|
||||
const queryParams: any = {}
|
||||
|
||||
// 如果有过滤条件,添加到查询参数中
|
||||
if (errorFilter.value.trim()) {
|
||||
queryParams.error_filter = errorFilter.value.trim()
|
||||
}
|
||||
|
||||
const count = totalCount.value
|
||||
|
||||
dialog.warning({
|
||||
title: '确认操作',
|
||||
content: `确定要将 ${count} 个资源重新放入待处理池吗?`,
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
draggable: true,
|
||||
onPositiveClick: async () => {
|
||||
if (isProcessing.value) return // 防止重复点击
|
||||
|
||||
isProcessing.value = true
|
||||
|
||||
try {
|
||||
const response = await readyResourceApi.batchRestoreToReadyPoolByQuery(queryParams) as any
|
||||
notification.success({
|
||||
title: '成功',
|
||||
content: `操作完成:\n总数量:${response.total_count}\n成功处理:${response.success_count}\n失败:${response.failed_count}`,
|
||||
duration: 3000
|
||||
})
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('重新放入待处理池失败:', error)
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: '操作失败',
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
isProcessing.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 清除所有错误
|
||||
const clearAllErrors = async () => {
|
||||
// 检查是否有过滤条件
|
||||
if (!errorFilter.value.trim()) {
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: '请先设置过滤条件,以避免删除所有失败资源',
|
||||
duration: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 构建查询条件
|
||||
const queryParams: any = {}
|
||||
|
||||
// 如果有过滤条件,添加到查询参数中
|
||||
if (errorFilter.value.trim()) {
|
||||
queryParams.error_filter = errorFilter.value.trim()
|
||||
}
|
||||
|
||||
const count = totalCount.value
|
||||
|
||||
dialog.warning({
|
||||
title: '警告',
|
||||
content: `确定要删除 ${count} 个失败资源吗?此操作将永久删除这些资源,不可恢复!`,
|
||||
positiveText: '确定删除',
|
||||
negativeText: '取消',
|
||||
draggable: true,
|
||||
onPositiveClick: async () => {
|
||||
if (isProcessing.value) return // 防止重复点击
|
||||
|
||||
isProcessing.value = true
|
||||
|
||||
try {
|
||||
console.log('开始调用删除API,参数:', queryParams)
|
||||
const response = await readyResourceApi.clearAllErrorsByQuery(queryParams) as any
|
||||
// console.log('删除API响应:', response)
|
||||
notification.success({
|
||||
title: '成功',
|
||||
content: `操作完成:\n删除失败资源:${response.affected_rows} 个资源`,
|
||||
duration: 3000
|
||||
})
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
console.error('删除失败资源失败:', error)
|
||||
console.error('错误详情:', {
|
||||
message: error?.message,
|
||||
stack: error?.stack,
|
||||
response: error?.response
|
||||
})
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: '删除失败',
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
isProcessing.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timeString: string) => {
|
||||
const date = new Date(timeString)
|
||||
return date.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 转义HTML防止XSS
|
||||
const escapeHtml = (text: string) => {
|
||||
if (!text) return text
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
return div.innerHTML
|
||||
}
|
||||
|
||||
// 验证URL安全性
|
||||
const checkUrlSafety = (url: string) => {
|
||||
if (!url) return '#'
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') {
|
||||
return '#'
|
||||
}
|
||||
return url
|
||||
} catch {
|
||||
return '#'
|
||||
}
|
||||
}
|
||||
|
||||
// 截断错误信息
|
||||
const truncateError = (errorMsg: string) => {
|
||||
if (!errorMsg) return ''
|
||||
return errorMsg.length > 50 ? errorMsg.substring(0, 50) + '...' : errorMsg
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 页面加载时获取数据
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await fetchData()
|
||||
} catch (error) {
|
||||
console.error('页面初始化失败:', error)
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 表格滚动样式 */
|
||||
.overflow-x-auto {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 表格头部固定 */
|
||||
thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 分页按钮悬停效果 */
|
||||
.pagination-button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* 当前页码按钮效果 */
|
||||
.current-page {
|
||||
box-shadow: 0 4px 6px -1px rgba(220, 38, 38, 0.3), 0 2px 4px -1px rgba(220, 38, 38, 0.2);
|
||||
}
|
||||
|
||||
/* 表格行悬停效果 */
|
||||
tbody tr:hover {
|
||||
background-color: rgba(220, 38, 38, 0.05);
|
||||
}
|
||||
|
||||
/* 暗黑模式下的表格行悬停 */
|
||||
.dark tbody tr:hover {
|
||||
background-color: rgba(220, 38, 38, 0.1);
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user