mirror of
https://github.com/ctwj/urldb.git
synced 2025-11-25 11:29:37 +08:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c053a17131 | ||
|
|
3d29f1bf23 | ||
|
|
a15a0fe2be | ||
|
|
05243bcfe7 | ||
|
|
98b94b3313 | ||
|
|
949a328ee3 | ||
|
|
acb462c6d5 | ||
|
|
e52043505f | ||
|
|
9d4eb38272 | ||
|
|
14ef85801a | ||
|
|
3f4430104d | ||
|
|
709029a123 | ||
|
|
559d69f52b | ||
|
|
dcd5e0bf73 | ||
|
|
4343a29bb3 | ||
|
|
3bf0d59a9c | ||
|
|
c3b2979977 | ||
|
|
6de20b7e13 | ||
|
|
2d96413a5d | ||
|
|
9b0d385c52 | ||
|
|
fae7de17d5 | ||
|
|
05930a3e70 |
153
README.md
153
README.md
@@ -29,12 +29,38 @@
|
||||
|
||||
---
|
||||
|
||||
## 🔔 温馨提示
|
||||
## 🔔 版本改动
|
||||
|
||||
- [文档说明](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.3
|
||||
1. 添加图片上传功能
|
||||
2. 添加Logo配置项,首页Logo显示
|
||||
3. 后台界面体验优化
|
||||
|
||||
### v1.2.1
|
||||
1. 修复转存移除广告失败的问题和添加广告失败的问题
|
||||
2. 管理后台UI优化
|
||||
3. 首页添加描述显示
|
||||
|
||||
### v1.2.0
|
||||
1. 新增手动批量转存
|
||||
2. 新增QQ机器人
|
||||
3. 新增任务管理功能
|
||||
4. 自动转存改版(批量转存修改为,显示二维码时自动转存)
|
||||
5. 新增支持第三方统计代码配置
|
||||
|
||||
### v1.0.0
|
||||
1. 支持API,手动批量录入资源
|
||||
2. 支持,自动判断资源有效性
|
||||
3. 支持自动转存
|
||||
4. 支持平台多账号管理(Quark)
|
||||
5. 支持简单的数据统计
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 📸 项目截图
|
||||
@@ -85,112 +111,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 # 项目说明
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 配置说明
|
||||
|
||||
### 环境变量配置
|
||||
@@ -210,13 +130,6 @@ PORT=8080
|
||||
TIMEZONE=Asia/Shanghai
|
||||
```
|
||||
|
||||
### Docker 服务说明
|
||||
|
||||
| 服务 | 端口 | 说明 |
|
||||
|------|------|------|
|
||||
| server | 3030 | 应用 |
|
||||
| postgres | 5431 | PostgreSQL 数据库 |
|
||||
|
||||
### 镜像构建
|
||||
|
||||
```
|
||||
@@ -228,18 +141,6 @@ docker push ctwj/urldb-backend:1.0.7
|
||||
|
||||
---
|
||||
|
||||
## 📚 API 文档
|
||||
|
||||
### 公开统计
|
||||
|
||||
提供,批量入库和搜索api,通过 apiToken 授权
|
||||
|
||||
> 📖 完整API文档请访问:`http://doc.l9.lc/`
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
我们欢迎所有形式的贡献!
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 [GPL License](LICENSE) 许可证。
|
||||
|
||||
@@ -129,6 +129,8 @@ func (f *PanFactory) CreatePanService(url string, config *PanConfig) (PanService
|
||||
return NewBaiduPanService(config), nil
|
||||
case UC:
|
||||
return NewUCService(config), nil
|
||||
case Xunlei:
|
||||
return NewXunleiPanService(config), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的服务类型: %s", url)
|
||||
}
|
||||
@@ -145,8 +147,8 @@ 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 Xunlei:
|
||||
return NewXunleiPanService(config), nil
|
||||
// case Tianyi:
|
||||
// return NewTianyiService(config), nil
|
||||
default:
|
||||
@@ -178,6 +180,12 @@ func (f *PanFactory) GetUCService(config *PanConfig) PanService {
|
||||
return service
|
||||
}
|
||||
|
||||
// GetXunleiService 获取迅雷网盘服务单例
|
||||
func (f *PanFactory) GetXunleiService(config *PanConfig) PanService {
|
||||
service := NewXunleiPanService(config)
|
||||
return service
|
||||
}
|
||||
|
||||
// ExtractServiceType 从URL中提取服务类型
|
||||
func ExtractServiceType(url string) ServiceType {
|
||||
url = strings.ToLower(url)
|
||||
|
||||
@@ -5,11 +5,16 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
commonutils "github.com/ctwj/urldb/common/utils"
|
||||
"github.com/ctwj/urldb/db"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
)
|
||||
|
||||
@@ -19,10 +24,15 @@ type QuarkPanService struct {
|
||||
configMutex sync.RWMutex // 保护配置的读写锁
|
||||
}
|
||||
|
||||
// 全局配置缓存刷新信号
|
||||
var configRefreshChan = make(chan bool, 1)
|
||||
|
||||
// 单例相关变量
|
||||
var (
|
||||
quarkInstance *QuarkPanService
|
||||
quarkOnce sync.Once
|
||||
quarkInstance *QuarkPanService
|
||||
quarkOnce sync.Once
|
||||
systemConfigRepo repo.SystemConfigRepository
|
||||
systemConfigOnce sync.Once
|
||||
)
|
||||
|
||||
// NewQuarkPanService 创建夸克网盘服务(单例模式)
|
||||
@@ -281,8 +291,26 @@ func (q *QuarkPanService) DeleteFiles(fileList []string) (*TransferResult, error
|
||||
return ErrorResult("文件列表为空"), nil
|
||||
}
|
||||
|
||||
// 逐个删除文件,确保每个删除操作都完成
|
||||
for _, fileID := range fileList {
|
||||
err := q.deleteSingleFile(fileID)
|
||||
if err != nil {
|
||||
log.Printf("删除文件 %s 失败: %v", fileID, err)
|
||||
return ErrorResult(fmt.Sprintf("删除文件 %s 失败: %v", fileID, err)), nil
|
||||
}
|
||||
}
|
||||
|
||||
return SuccessResult("删除成功", nil), nil
|
||||
}
|
||||
|
||||
// deleteSingleFile 删除单个文件
|
||||
func (q *QuarkPanService) deleteSingleFile(fileID string) error {
|
||||
log.Printf("正在删除文件: %s", fileID)
|
||||
|
||||
data := map[string]interface{}{
|
||||
"fid_list": fileList,
|
||||
"action_type": 2,
|
||||
"filelist": []string{fileID},
|
||||
"exclude_fids": []string{},
|
||||
}
|
||||
|
||||
queryParams := map[string]string{
|
||||
@@ -291,12 +319,41 @@ func (q *QuarkPanService) DeleteFiles(fileList []string) (*TransferResult, error
|
||||
"uc_param_str": "",
|
||||
}
|
||||
|
||||
_, err := q.HTTPPost("https://drive-pc.quark.cn/1/clouddrive/file/delete", data, queryParams)
|
||||
respData, err := q.HTTPPost("https://drive-pc.quark.cn/1/clouddrive/file/delete", data, queryParams)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("删除文件失败: %v", err)), nil
|
||||
return fmt.Errorf("删除文件请求失败: %v", err)
|
||||
}
|
||||
|
||||
return SuccessResult("删除成功", nil), nil
|
||||
// 解析响应
|
||||
var response struct {
|
||||
Status int `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
TaskID string `json:"task_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respData, &response); err != nil {
|
||||
return fmt.Errorf("解析删除响应失败: %v", err)
|
||||
}
|
||||
|
||||
if response.Status != 200 {
|
||||
return fmt.Errorf("删除文件失败: %s", response.Message)
|
||||
}
|
||||
|
||||
// 如果有任务ID,等待任务完成
|
||||
if response.Data.TaskID != "" {
|
||||
log.Printf("删除文件任务ID: %s", response.Data.TaskID)
|
||||
_, err := q.waitForTask(response.Data.TaskID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("等待删除任务完成失败: %v", err)
|
||||
}
|
||||
log.Printf("文件 %s 删除完成", fileID)
|
||||
} else {
|
||||
log.Printf("文件 %s 删除完成(无任务ID)", fileID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getStoken 获取stoken
|
||||
@@ -376,12 +433,17 @@ func (q *QuarkPanService) getShare(shareID, stoken string) (*ShareResult, error)
|
||||
|
||||
// getShareSave 转存分享
|
||||
func (q *QuarkPanService) getShareSave(shareID, stoken string, fidList, fidTokenList []string) (*SaveResult, error) {
|
||||
return q.getShareSaveToDir(shareID, stoken, fidList, fidTokenList, "0")
|
||||
}
|
||||
|
||||
// getShareSaveToDir 转存分享到指定目录
|
||||
func (q *QuarkPanService) getShareSaveToDir(shareID, stoken string, fidList, fidTokenList []string, toPdirFid string) (*SaveResult, error) {
|
||||
data := map[string]interface{}{
|
||||
"pwd_id": shareID,
|
||||
"stoken": stoken,
|
||||
"fid_list": fidList,
|
||||
"fid_token_list": fidTokenList,
|
||||
"to_pdir_fid": "0", // 默认存储到根目录
|
||||
"to_pdir_fid": toPdirFid, // 存储到指定目录
|
||||
}
|
||||
|
||||
queryParams := map[string]string{
|
||||
@@ -591,22 +653,20 @@ func (q *QuarkPanService) deleteAdFiles(pdirFid string) error {
|
||||
|
||||
// containsAdKeywords 检查文件名是否包含广告关键词
|
||||
func (q *QuarkPanService) containsAdKeywords(filename string) bool {
|
||||
// 默认广告关键词列表
|
||||
defaultAdKeywords := []string{
|
||||
"微信", "独家", "V信", "v信", "威信", "胖狗资源",
|
||||
"加微", "会员群", "q群", "v群", "公众号",
|
||||
"广告", "特价", "最后机会", "不要错过", "立减",
|
||||
"立得", "赚", "省", "回扣", "抽奖",
|
||||
"失效", "年会员", "空间容量", "微信群", "群文件", "全网资源", "影视资源", "扫码", "最新资源",
|
||||
"IMG_", "资源汇总", "緑铯粢源", ".url", "网盘推广", "大额优惠券",
|
||||
"资源文档", "dy8.xyz", "妙妙屋", "资源合集", "kkdm", "赚收益",
|
||||
// 从系统配置中获取广告关键词
|
||||
adKeywordsStr, err := q.getSystemConfigValue(entity.ConfigKeyAdKeywords)
|
||||
if err != nil {
|
||||
log.Printf("获取广告关键词配置失败: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// 尝试从系统配置中获取广告关键词
|
||||
adKeywords := defaultAdKeywords
|
||||
// 如果配置为空,返回false
|
||||
if adKeywordsStr == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// 这里可以添加从系统配置读取广告关键词的逻辑
|
||||
// 例如:从数据库或配置文件中读取自定义的广告关键词
|
||||
// 按逗号分割关键词(支持中文和英文逗号)
|
||||
adKeywords := q.splitKeywords(adKeywordsStr)
|
||||
|
||||
return q.checkKeywordsInFilename(filename, adKeywords)
|
||||
}
|
||||
@@ -626,24 +686,136 @@ func (q *QuarkPanService) checkKeywordsInFilename(filename string, keywords []st
|
||||
return false
|
||||
}
|
||||
|
||||
// getSystemConfigValue 获取系统配置值
|
||||
func (q *QuarkPanService) getSystemConfigValue(key string) (string, error) {
|
||||
// 检查是否需要刷新缓存
|
||||
select {
|
||||
case <-configRefreshChan:
|
||||
// 收到刷新信号,清空缓存
|
||||
systemConfigOnce.Do(func() {
|
||||
systemConfigRepo = repo.NewSystemConfigRepository(db.DB)
|
||||
})
|
||||
systemConfigRepo.ClearConfigCache()
|
||||
default:
|
||||
// 没有刷新信号,继续使用缓存
|
||||
}
|
||||
|
||||
// 使用单例模式获取系统配置仓库
|
||||
systemConfigOnce.Do(func() {
|
||||
systemConfigRepo = repo.NewSystemConfigRepository(db.DB)
|
||||
})
|
||||
return systemConfigRepo.GetConfigValue(key)
|
||||
}
|
||||
|
||||
// refreshSystemConfigCache 刷新系统配置缓存
|
||||
func (q *QuarkPanService) refreshSystemConfigCache() {
|
||||
systemConfigOnce.Do(func() {
|
||||
systemConfigRepo = repo.NewSystemConfigRepository(db.DB)
|
||||
})
|
||||
systemConfigRepo.ClearConfigCache()
|
||||
}
|
||||
|
||||
// RefreshSystemConfigCache 全局刷新系统配置缓存(供外部调用)
|
||||
func RefreshSystemConfigCache() {
|
||||
select {
|
||||
case configRefreshChan <- true:
|
||||
// 发送刷新信号
|
||||
default:
|
||||
// 通道已满,忽略
|
||||
}
|
||||
}
|
||||
|
||||
// splitKeywords 按逗号分割关键词(支持中文和英文逗号)
|
||||
func (q *QuarkPanService) splitKeywords(keywordsStr string) []string {
|
||||
if keywordsStr == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// 使用正则表达式同时匹配中英文逗号
|
||||
re := regexp.MustCompile(`[,,]`)
|
||||
parts := re.Split(keywordsStr, -1)
|
||||
|
||||
var result []string
|
||||
for _, part := range parts {
|
||||
// 去除首尾空格
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// splitAdURLs 按换行符分割广告URL列表
|
||||
func (q *QuarkPanService) splitAdURLs(autoInsertAdStr string) []string {
|
||||
if autoInsertAdStr == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// 按换行符分割
|
||||
lines := strings.Split(autoInsertAdStr, "\n")
|
||||
var result []string
|
||||
|
||||
for _, line := range lines {
|
||||
// 去除首尾空格
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// extractAdFileIDs 从广告URL列表中提取文件ID
|
||||
func (q *QuarkPanService) extractAdFileIDs(adURLs []string) []string {
|
||||
var result []string
|
||||
|
||||
for _, url := range adURLs {
|
||||
// 使用 ExtractShareIdString 提取分享ID
|
||||
shareID, _ := commonutils.ExtractShareIdString(url)
|
||||
if shareID != "" {
|
||||
result = append(result, shareID)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// addAd 添加个人自定义广告
|
||||
func (q *QuarkPanService) addAd(dirID string) error {
|
||||
log.Printf("开始添加个人自定义广告到目录: %s", dirID)
|
||||
|
||||
// 这里可以从配置中读取广告文件ID列表
|
||||
// 暂时使用硬编码的广告文件ID,后续可以从系统配置中读取
|
||||
adFileIDs := []string{
|
||||
// 可以配置多个广告文件ID
|
||||
// "4c0381f2d1ca", // 示例广告文件ID
|
||||
// 从系统配置中获取自动插入广告内容
|
||||
autoInsertAdStr, err := q.getSystemConfigValue(entity.ConfigKeyAutoInsertAd)
|
||||
if err != nil {
|
||||
log.Printf("获取自动插入广告配置失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果配置为空,跳过广告插入
|
||||
if autoInsertAdStr == "" {
|
||||
log.Printf("没有配置自动插入广告,跳过广告插入")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 按换行符分割广告URL列表
|
||||
adURLs := q.splitAdURLs(autoInsertAdStr)
|
||||
if len(adURLs) == 0 {
|
||||
log.Printf("没有有效的广告URL,跳过广告插入")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 提取广告文件ID列表
|
||||
adFileIDs := q.extractAdFileIDs(adURLs)
|
||||
if len(adFileIDs) == 0 {
|
||||
log.Printf("没有配置广告文件,跳过广告插入")
|
||||
log.Printf("没有有效的广告文件ID,跳过广告插入")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 随机选择一个广告文件
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
rand.Seed(utils.GetCurrentTimestampNano())
|
||||
selectedAdID := adFileIDs[rand.Intn(len(adFileIDs))]
|
||||
|
||||
log.Printf("选择广告文件ID: %s", selectedAdID)
|
||||
@@ -673,7 +845,7 @@ func (q *QuarkPanService) addAd(dirID string) error {
|
||||
shareFidToken := adFile.ShareFidToken
|
||||
|
||||
// 保存广告文件到目标目录
|
||||
saveResult, err := q.getShareSave(selectedAdID, stokenResult.Stoken, []string{fid}, []string{shareFidToken})
|
||||
saveResult, err := q.getShareSaveToDir(selectedAdID, stokenResult.Stoken, []string{fid}, []string{shareFidToken}, dirID)
|
||||
if err != nil {
|
||||
log.Printf("保存广告文件失败: %v", err)
|
||||
return err
|
||||
@@ -729,26 +901,8 @@ func (q *QuarkPanService) getDirFile(pdirFid string) ([]map[string]interface{},
|
||||
return nil, fmt.Errorf(response.Message)
|
||||
}
|
||||
|
||||
// 递归处理子目录
|
||||
var allFiles []map[string]interface{}
|
||||
for _, item := range response.Data.List {
|
||||
// 添加当前文件/目录
|
||||
allFiles = append(allFiles, item)
|
||||
|
||||
// 如果是目录,递归获取子目录内容
|
||||
if fileType, ok := item["file_type"].(float64); ok && fileType == 1 { // 1表示目录
|
||||
if fid, ok := item["fid"].(string); ok {
|
||||
subFiles, err := q.getDirFile(fid)
|
||||
if err != nil {
|
||||
log.Printf("获取子目录 %s 失败: %v", fid, err)
|
||||
continue
|
||||
}
|
||||
allFiles = append(allFiles, subFiles...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allFiles, nil
|
||||
// 直接返回文件列表,不递归处理子目录(与参考代码保持一致)
|
||||
return response.Data.List, nil
|
||||
}
|
||||
|
||||
// 定义各种结果结构体
|
||||
|
||||
544
common/xunlei_pan.go
Normal file
544
common/xunlei_pan.go
Normal file
@@ -0,0 +1,544 @@
|
||||
// 1. 修正接口 Host,增加配置项
|
||||
// 2. POST/GET 区分(xunleix 的 /drive/v1/share/list 是 GET,不是 POST)
|
||||
// 3. 参数传递方式严格区分 query/body
|
||||
// 4. header 应支持 Authorization(Bearer ...)、x-device-id、x-client-id、x-captcha-token 等
|
||||
// 5. 结构体返回字段需和 xunleix 100%一致(如 data 字段是 map 还是 list),注意 code 字段为 int 还是 string
|
||||
// 6. 错误处理,返回体未必有 code/msg,需先判断 HTTP 状态码再判断 body
|
||||
// 7. 建议增加日志和更清晰的错误提示
|
||||
|
||||
package pan
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type XunleiPanService struct {
|
||||
*BasePanService
|
||||
configMutex sync.RWMutex
|
||||
}
|
||||
|
||||
var (
|
||||
xunleiInstance *XunleiPanService
|
||||
xunleiOnce sync.Once
|
||||
)
|
||||
|
||||
// 配置化 API Host
|
||||
func (x *XunleiPanService) apiHost() string {
|
||||
return "https://api-pan.xunlei.com"
|
||||
}
|
||||
|
||||
// 工具:自动补全必要 header
|
||||
func (x *XunleiPanService) setCommonHeader(req *http.Request) {
|
||||
for k, v := range x.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
func NewXunleiPanService(config *PanConfig) *XunleiPanService {
|
||||
xunleiOnce.Do(func() {
|
||||
xunleiInstance = &XunleiPanService{
|
||||
BasePanService: NewBasePanService(config),
|
||||
}
|
||||
xunleiInstance.SetHeaders(map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Cookie": config.Cookie,
|
||||
})
|
||||
})
|
||||
xunleiInstance.UpdateConfig(config)
|
||||
return xunleiInstance
|
||||
}
|
||||
|
||||
// GetXunleiInstance 获取迅雷网盘服务单例实例
|
||||
func GetXunleiInstance() *XunleiPanService {
|
||||
return NewXunleiPanService(nil)
|
||||
}
|
||||
|
||||
func (x *XunleiPanService) UpdateConfig(config *PanConfig) {
|
||||
if config == nil {
|
||||
return
|
||||
}
|
||||
x.configMutex.Lock()
|
||||
defer x.configMutex.Unlock()
|
||||
x.config = config
|
||||
if config.Cookie != "" {
|
||||
x.SetHeader("Cookie", config.Cookie)
|
||||
}
|
||||
}
|
||||
|
||||
// GetServiceType 获取服务类型
|
||||
func (x *XunleiPanService) GetServiceType() ServiceType {
|
||||
return Xunlei
|
||||
}
|
||||
|
||||
// Transfer 转存分享链接 - 实现 PanService 接口
|
||||
func (x *XunleiPanService) Transfer(shareID string) (*TransferResult, error) {
|
||||
// 读取配置(线程安全)
|
||||
x.configMutex.RLock()
|
||||
config := x.config
|
||||
x.configMutex.RUnlock()
|
||||
|
||||
log.Printf("开始处理迅雷分享: %s", shareID)
|
||||
|
||||
// 检查是否为检验模式
|
||||
if config.IsType == 1 {
|
||||
// 检验模式:直接获取分享信息
|
||||
shareInfo, err := x.getShareInfo(shareID)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("获取分享信息失败: %v", err)), nil
|
||||
}
|
||||
|
||||
return SuccessResult("检验成功", map[string]interface{}{
|
||||
"title": shareInfo.Title,
|
||||
"shareUrl": config.URL,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// 转存模式:实现完整的转存流程
|
||||
// 1. 获取分享详情
|
||||
shareDetail, err := x.GetShareFolder(shareID, "", "")
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("获取分享详情失败: %v", err)), nil
|
||||
}
|
||||
|
||||
// 2. 提取文件ID列表
|
||||
fileIDs := make([]string, 0)
|
||||
for _, file := range shareDetail.Data.Files {
|
||||
fileIDs = append(fileIDs, file.FileID)
|
||||
}
|
||||
|
||||
if len(fileIDs) == 0 {
|
||||
return ErrorResult("分享中没有可转存的文件"), nil
|
||||
}
|
||||
|
||||
// 3. 转存文件
|
||||
restoreResult, err := x.Restore(shareID, "", fileIDs)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("转存失败: %v", err)), nil
|
||||
}
|
||||
|
||||
// 4. 等待转存完成
|
||||
taskID := restoreResult.Data.TaskID
|
||||
_, err = x.waitForTask(taskID)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("等待转存完成失败: %v", err)), nil
|
||||
}
|
||||
|
||||
// 5. 创建新的分享
|
||||
shareResult, err := x.FileBatchShare(fileIDs, false, 0) // 永久分享
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("创建分享失败: %v", err)), nil
|
||||
}
|
||||
|
||||
// 6. 返回结果
|
||||
return SuccessResult("转存成功", map[string]interface{}{
|
||||
"shareUrl": shareResult.Data.ShareURL,
|
||||
"title": fmt.Sprintf("迅雷分享_%s", shareID),
|
||||
"fid": strings.Join(fileIDs, ","),
|
||||
}), nil
|
||||
}
|
||||
|
||||
// waitForTask 等待任务完成
|
||||
func (x *XunleiPanService) waitForTask(taskID string) (*XLTaskResult, error) {
|
||||
maxRetries := 50
|
||||
retryDelay := 2 * time.Second
|
||||
|
||||
for retryIndex := 0; retryIndex < maxRetries; retryIndex++ {
|
||||
result, err := x.getTaskStatus(taskID, retryIndex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.Status == 2 { // 任务完成
|
||||
return result, nil
|
||||
}
|
||||
|
||||
time.Sleep(retryDelay)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("任务超时")
|
||||
}
|
||||
|
||||
// getTaskStatus 获取任务状态
|
||||
func (x *XunleiPanService) getTaskStatus(taskID string, retryIndex int) (*XLTaskResult, error) {
|
||||
apiURL := x.apiHost() + "/drive/v1/task"
|
||||
params := url.Values{}
|
||||
params.Set("task_id", taskID)
|
||||
params.Set("retry_index", fmt.Sprintf("%d", retryIndex))
|
||||
apiURL = apiURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x.setCommonHeader(req)
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
result, _ := ioutil.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(result))
|
||||
}
|
||||
var data XLTaskResult
|
||||
if err := json.Unmarshal(result, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// getShareInfo 获取分享信息(用于检验模式)
|
||||
func (x *XunleiPanService) getShareInfo(shareID string) (*XLShareInfo, error) {
|
||||
// 使用现有的 GetShareFolder 方法获取分享信息
|
||||
shareDetail, err := x.GetShareFolder(shareID, "", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 构造分享信息
|
||||
shareInfo := &XLShareInfo{
|
||||
ShareID: shareID,
|
||||
Title: fmt.Sprintf("迅雷分享_%s", shareID),
|
||||
Files: make([]XLFileInfo, 0),
|
||||
}
|
||||
|
||||
// 处理文件信息
|
||||
for _, file := range shareDetail.Data.Files {
|
||||
shareInfo.Files = append(shareInfo.Files, XLFileInfo{
|
||||
FileID: file.FileID,
|
||||
Name: file.Name,
|
||||
})
|
||||
}
|
||||
|
||||
return shareInfo, nil
|
||||
}
|
||||
|
||||
// GetFiles 获取文件列表 - 实现 PanService 接口
|
||||
func (x *XunleiPanService) GetFiles(pdirFid string) (*TransferResult, error) {
|
||||
log.Printf("开始获取迅雷网盘文件列表,目录ID: %s", pdirFid)
|
||||
|
||||
// 使用现有的 GetShareList 方法获取文件列表
|
||||
shareList, err := x.GetShareList("")
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("获取文件列表失败: %v", err)), nil
|
||||
}
|
||||
|
||||
// 转换为通用格式
|
||||
fileList := make([]interface{}, 0)
|
||||
for _, share := range shareList.Data.List {
|
||||
fileList = append(fileList, map[string]interface{}{
|
||||
"share_id": share.ShareID,
|
||||
"title": share.Title,
|
||||
})
|
||||
}
|
||||
|
||||
return SuccessResult("获取成功", fileList), nil
|
||||
}
|
||||
|
||||
// DeleteFiles 删除文件 - 实现 PanService 接口
|
||||
func (x *XunleiPanService) DeleteFiles(fileList []string) (*TransferResult, error) {
|
||||
log.Printf("开始删除迅雷网盘文件,文件数量: %d", len(fileList))
|
||||
|
||||
// 使用现有的 ShareBatchDelete 方法删除分享
|
||||
result, err := x.ShareBatchDelete(fileList)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("删除文件失败: %v", err)), nil
|
||||
}
|
||||
|
||||
if result.Code != 0 {
|
||||
return ErrorResult(fmt.Sprintf("删除文件失败: %s", result.Msg)), nil
|
||||
}
|
||||
|
||||
return SuccessResult("删除成功", nil), nil
|
||||
}
|
||||
|
||||
// GetUserInfo 获取用户信息 - 实现 PanService 接口
|
||||
func (x *XunleiPanService) GetUserInfo(cookie string) (*UserInfo, error) {
|
||||
log.Printf("开始获取迅雷网盘用户信息")
|
||||
|
||||
// 临时设置cookie
|
||||
originalCookie := x.GetHeader("Cookie")
|
||||
x.SetHeader("Cookie", cookie)
|
||||
defer x.SetHeader("Cookie", originalCookie) // 恢复原始cookie
|
||||
|
||||
// 获取用户信息
|
||||
apiURL := x.apiHost() + "/drive/v1/user/info"
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %v", err)
|
||||
}
|
||||
x.setCommonHeader(req)
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取用户信息失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
result, _ := ioutil.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(result))
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Username string `json:"username"`
|
||||
VIPStatus bool `json:"vip_status"`
|
||||
UsedSpace int64 `json:"used_space"`
|
||||
TotalSpace int64 `json:"total_space"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(result, &response); err != nil {
|
||||
return nil, fmt.Errorf("解析用户信息失败: %v", err)
|
||||
}
|
||||
|
||||
if response.Code != 0 {
|
||||
return nil, fmt.Errorf("获取用户信息失败: %s", response.Msg)
|
||||
}
|
||||
|
||||
return &UserInfo{
|
||||
Username: response.Data.Username,
|
||||
VIPStatus: response.Data.VIPStatus,
|
||||
UsedSpace: response.Data.UsedSpace,
|
||||
TotalSpace: response.Data.TotalSpace,
|
||||
ServiceType: "xunlei",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetShareList 严格对齐 GET + query(xunleix实现)
|
||||
func (x *XunleiPanService) GetShareList(pageToken string) (*XLShareListResp, error) {
|
||||
api := x.apiHost() + "/drive/v1/share/list"
|
||||
params := url.Values{}
|
||||
params.Set("limit", "100")
|
||||
params.Set("thumbnail_size", "SIZE_SMALL")
|
||||
if pageToken != "" {
|
||||
params.Set("page_token", pageToken)
|
||||
}
|
||||
apiURL := api + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x.setCommonHeader(req)
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
result, _ := ioutil.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(result))
|
||||
}
|
||||
var data XLShareListResp
|
||||
if err := json.Unmarshal(result, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// FileBatchShare 创建分享(POST, body)
|
||||
func (x *XunleiPanService) FileBatchShare(ids []string, needPassword bool, expirationDays int) (*XLBatchShareResp, error) {
|
||||
apiURL := x.apiHost() + "/drive/v1/share/batch"
|
||||
body := map[string]interface{}{
|
||||
"file_ids": ids,
|
||||
"need_password": needPassword,
|
||||
"expiration_days": expirationDays,
|
||||
}
|
||||
bs, _ := json.Marshal(body)
|
||||
req, err := http.NewRequest("POST", apiURL, bytes.NewReader(bs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x.setCommonHeader(req)
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
result, _ := ioutil.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(result))
|
||||
}
|
||||
var data XLBatchShareResp
|
||||
if err := json.Unmarshal(result, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// ShareBatchDelete 取消分享(POST, body)
|
||||
func (x *XunleiPanService) ShareBatchDelete(ids []string) (*XLCommonResp, error) {
|
||||
apiURL := x.apiHost() + "/drive/v1/share/batch/delete"
|
||||
body := map[string]interface{}{
|
||||
"share_ids": ids,
|
||||
}
|
||||
bs, _ := json.Marshal(body)
|
||||
req, err := http.NewRequest("POST", apiURL, bytes.NewReader(bs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x.setCommonHeader(req)
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
result, _ := ioutil.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(result))
|
||||
}
|
||||
var data XLCommonResp
|
||||
if err := json.Unmarshal(result, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// GetShareFolder 获取分享内容(POST, body)
|
||||
func (x *XunleiPanService) GetShareFolder(shareID, passCodeToken, parentID string) (*XLShareFolderResp, error) {
|
||||
apiURL := x.apiHost() + "/drive/v1/share/detail"
|
||||
body := map[string]interface{}{
|
||||
"share_id": shareID,
|
||||
"pass_code_token": passCodeToken,
|
||||
"parent_id": parentID,
|
||||
"limit": 100,
|
||||
"thumbnail_size": "SIZE_LARGE",
|
||||
"order": "6",
|
||||
}
|
||||
bs, _ := json.Marshal(body)
|
||||
req, err := http.NewRequest("POST", apiURL, bytes.NewReader(bs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x.setCommonHeader(req)
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
result, _ := ioutil.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(result))
|
||||
}
|
||||
var data XLShareFolderResp
|
||||
if err := json.Unmarshal(result, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// Restore 转存(POST, body)
|
||||
func (x *XunleiPanService) Restore(shareID, passCodeToken string, fileIDs []string) (*XLRestoreResp, error) {
|
||||
apiURL := x.apiHost() + "/drive/v1/share/restore"
|
||||
body := map[string]interface{}{
|
||||
"share_id": shareID,
|
||||
"pass_code_token": passCodeToken,
|
||||
"file_ids": fileIDs,
|
||||
"folder_type": "NORMAL",
|
||||
"specify_parent_id": true,
|
||||
"parent_id": "",
|
||||
}
|
||||
bs, _ := json.Marshal(body)
|
||||
req, err := http.NewRequest("POST", apiURL, bytes.NewReader(bs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x.setCommonHeader(req)
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
result, _ := ioutil.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(result))
|
||||
}
|
||||
var data XLRestoreResp
|
||||
if err := json.Unmarshal(result, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// 结构体完全对齐 xunleix
|
||||
type XLShareListResp struct {
|
||||
Data struct {
|
||||
List []struct {
|
||||
ShareID string `json:"share_id"`
|
||||
Title string `json:"title"`
|
||||
} `json:"list"`
|
||||
} `json:"data"`
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
type XLBatchShareResp struct {
|
||||
Data struct {
|
||||
ShareURL string `json:"share_url"`
|
||||
} `json:"data"`
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
type XLCommonResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
type XLShareFolderResp struct {
|
||||
Data struct {
|
||||
Files []struct {
|
||||
FileID string `json:"file_id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"files"`
|
||||
} `json:"data"`
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
type XLRestoreResp struct {
|
||||
Data struct {
|
||||
TaskID string `json:"task_id"`
|
||||
} `json:"data"`
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
// 新增辅助结构体
|
||||
type XLShareInfo struct {
|
||||
ShareID string `json:"share_id"`
|
||||
Title string `json:"title"`
|
||||
Files []XLFileInfo `json:"files"`
|
||||
}
|
||||
|
||||
type XLFileInfo struct {
|
||||
FileID string `json:"file_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type XLTaskResult struct {
|
||||
Status int `json:"status"`
|
||||
TaskID string `json:"task_id"`
|
||||
Data struct {
|
||||
ShareID string `json:"share_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
1
db/ad.txt
Normal file
1
db/ad.txt
Normal file
@@ -0,0 +1 @@
|
||||
微信,独家,V信,v信,威信,胖狗资源,加微,会员群,q群,v群,公众号,广告,特价,最后机会,不要错过,立减,立得,赚,省,回扣,抽奖,失效,年会员,空间容量,微信群,群文件,全网资源,影视资源,扫码,最新资源,IMG_,资源汇总,緑铯粢源,.url,网盘推广,大额优惠券,资源文档,dy8.xyz,妙妙屋,资源合集,kkdm,赚收益
|
||||
@@ -81,6 +81,7 @@ func InitDB() error {
|
||||
&entity.ResourceView{},
|
||||
&entity.Task{},
|
||||
&entity.TaskItem{},
|
||||
&entity.File{},
|
||||
)
|
||||
if err != nil {
|
||||
utils.Fatal("数据库迁移失败: %v", err)
|
||||
@@ -144,6 +145,7 @@ func autoMigrate() error {
|
||||
&entity.User{},
|
||||
&entity.SearchStat{},
|
||||
&entity.HotDrama{},
|
||||
&entity.File{},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -257,6 +259,8 @@ func insertDefaultDataIfEmpty() error {
|
||||
{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.ConfigKeyAdKeywords, Value: entity.ConfigDefaultAdKeywords, Type: entity.ConfigTypeString},
|
||||
{Key: entity.ConfigKeyAutoInsertAd, Value: entity.ConfigDefaultAutoInsertAd, Type: entity.ConfigTypeString},
|
||||
{Key: entity.ConfigKeyPageSize, Value: entity.ConfigDefaultPageSize, Type: entity.ConfigTypeInt},
|
||||
{Key: entity.ConfigKeyMaintenanceMode, Value: entity.ConfigDefaultMaintenanceMode, Type: entity.ConfigTypeBool},
|
||||
}
|
||||
|
||||
54
db/converter/file_converter.go
Normal file
54
db/converter/file_converter.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
)
|
||||
|
||||
// FileToResponse 将文件实体转换为响应DTO
|
||||
func FileToResponse(file *entity.File) dto.FileResponse {
|
||||
response := dto.FileResponse{
|
||||
ID: file.ID,
|
||||
CreatedAt: utils.FormatTime(file.CreatedAt, "2006-01-02 15:04:05"),
|
||||
UpdatedAt: utils.FormatTime(file.UpdatedAt, "2006-01-02 15:04:05"),
|
||||
OriginalName: file.OriginalName,
|
||||
FileName: file.FileName,
|
||||
FilePath: file.FilePath,
|
||||
FileSize: file.FileSize,
|
||||
FileType: file.FileType,
|
||||
MimeType: file.MimeType,
|
||||
FileHash: file.FileHash,
|
||||
AccessURL: file.AccessURL,
|
||||
UserID: file.UserID,
|
||||
Status: file.Status,
|
||||
IsPublic: file.IsPublic,
|
||||
IsDeleted: file.IsDeleted,
|
||||
}
|
||||
|
||||
// 添加用户名
|
||||
if file.User.ID > 0 {
|
||||
response.User = file.User.Username
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// FilesToResponse 将文件实体列表转换为响应DTO列表
|
||||
func FilesToResponse(files []entity.File) []dto.FileResponse {
|
||||
var responses []dto.FileResponse
|
||||
for _, file := range files {
|
||||
responses = append(responses, FileToResponse(&file))
|
||||
}
|
||||
return responses
|
||||
}
|
||||
|
||||
// FileListToResponse 将文件列表转换为列表响应
|
||||
func FileListToResponse(files []entity.File, total int64, page, pageSize int) dto.FileListResponse {
|
||||
return dto.FileListResponse{
|
||||
Files: FilesToResponse(files),
|
||||
Total: total,
|
||||
Page: page,
|
||||
Size: pageSize,
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ func SystemConfigToResponse(configs []entity.SystemConfig) *dto.SystemConfigResp
|
||||
response.Author = config.Value
|
||||
case entity.ConfigKeyCopyright:
|
||||
response.Copyright = config.Value
|
||||
case entity.ConfigKeySiteLogo:
|
||||
response.SiteLogo = config.Value
|
||||
case entity.ConfigKeyAutoProcessReadyResources:
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response.AutoProcessReadyResources = val
|
||||
@@ -58,6 +60,10 @@ func SystemConfigToResponse(configs []entity.SystemConfig) *dto.SystemConfigResp
|
||||
response.ApiToken = config.Value
|
||||
case entity.ConfigKeyForbiddenWords:
|
||||
response.ForbiddenWords = config.Value
|
||||
case entity.ConfigKeyAdKeywords:
|
||||
response.AdKeywords = config.Value
|
||||
case entity.ConfigKeyAutoInsertAd:
|
||||
response.AutoInsertAd = config.Value
|
||||
case entity.ConfigKeyPageSize:
|
||||
if val, err := strconv.Atoi(config.Value); err == nil {
|
||||
response.PageSize = val
|
||||
@@ -91,48 +97,99 @@ func RequestToSystemConfig(req *dto.SystemConfigRequest) []entity.SystemConfig {
|
||||
}
|
||||
|
||||
var configs []entity.SystemConfig
|
||||
var updatedKeys []string
|
||||
|
||||
// 只添加有值的字段
|
||||
if req.SiteTitle != "" {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeySiteTitle, Value: req.SiteTitle, Type: entity.ConfigTypeString})
|
||||
// 字符串字段 - 只处理被设置的字段
|
||||
if req.SiteTitle != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeySiteTitle, Value: *req.SiteTitle, Type: entity.ConfigTypeString})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeySiteTitle)
|
||||
}
|
||||
if req.SiteDescription != "" {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeySiteDescription, Value: req.SiteDescription, Type: entity.ConfigTypeString})
|
||||
if req.SiteDescription != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeySiteDescription, Value: *req.SiteDescription, Type: entity.ConfigTypeString})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeySiteDescription)
|
||||
}
|
||||
if req.Keywords != "" {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyKeywords, Value: req.Keywords, Type: entity.ConfigTypeString})
|
||||
if req.Keywords != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyKeywords, Value: *req.Keywords, Type: entity.ConfigTypeString})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyKeywords)
|
||||
}
|
||||
if req.Author != "" {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAuthor, Value: req.Author, Type: entity.ConfigTypeString})
|
||||
if req.Author != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAuthor, Value: *req.Author, Type: entity.ConfigTypeString})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyAuthor)
|
||||
}
|
||||
if req.Copyright != "" {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyCopyright, Value: req.Copyright, Type: entity.ConfigTypeString})
|
||||
if req.Copyright != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyCopyright, Value: *req.Copyright, Type: entity.ConfigTypeString})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyCopyright)
|
||||
}
|
||||
if req.ApiToken != "" {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyApiToken, Value: req.ApiToken, Type: entity.ConfigTypeString})
|
||||
if req.SiteLogo != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeySiteLogo, Value: *req.SiteLogo, Type: entity.ConfigTypeString})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeySiteLogo)
|
||||
}
|
||||
if req.ForbiddenWords != "" {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyForbiddenWords, Value: req.ForbiddenWords, Type: entity.ConfigTypeString})
|
||||
if req.ApiToken != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyApiToken, Value: *req.ApiToken, Type: entity.ConfigTypeString})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyApiToken)
|
||||
}
|
||||
if req.ForbiddenWords != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyForbiddenWords, Value: *req.ForbiddenWords, Type: entity.ConfigTypeString})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyForbiddenWords)
|
||||
}
|
||||
if req.AdKeywords != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAdKeywords, Value: *req.AdKeywords, Type: entity.ConfigTypeString})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyAdKeywords)
|
||||
}
|
||||
if req.AutoInsertAd != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAutoInsertAd, Value: *req.AutoInsertAd, Type: entity.ConfigTypeString})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyAutoInsertAd)
|
||||
}
|
||||
|
||||
// 布尔值字段 - 只处理实际提交的字段
|
||||
// 注意:由于 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})
|
||||
// 布尔值字段 - 只处理被设置的字段
|
||||
if req.AutoProcessReadyResources != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAutoProcessReadyResources, Value: strconv.FormatBool(*req.AutoProcessReadyResources), Type: entity.ConfigTypeBool})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyAutoProcessReadyResources)
|
||||
}
|
||||
if req.AutoTransferEnabled != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAutoTransferEnabled, Value: strconv.FormatBool(*req.AutoTransferEnabled), Type: entity.ConfigTypeBool})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyAutoTransferEnabled)
|
||||
}
|
||||
if req.AutoFetchHotDramaEnabled != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAutoFetchHotDramaEnabled, Value: strconv.FormatBool(*req.AutoFetchHotDramaEnabled), Type: entity.ConfigTypeBool})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyAutoFetchHotDramaEnabled)
|
||||
}
|
||||
if req.MaintenanceMode != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyMaintenanceMode, Value: strconv.FormatBool(*req.MaintenanceMode), Type: entity.ConfigTypeBool})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyMaintenanceMode)
|
||||
}
|
||||
if req.EnableRegister != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyEnableRegister, Value: strconv.FormatBool(*req.EnableRegister), Type: entity.ConfigTypeBool})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyEnableRegister)
|
||||
}
|
||||
|
||||
// 整数字段 - 添加所有提交的字段,包括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.AutoProcessInterval != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAutoProcessInterval, Value: strconv.Itoa(*req.AutoProcessInterval), Type: entity.ConfigTypeInt})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyAutoProcessInterval)
|
||||
}
|
||||
if req.AutoTransferLimitDays != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAutoTransferLimitDays, Value: strconv.Itoa(*req.AutoTransferLimitDays), Type: entity.ConfigTypeInt})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyAutoTransferLimitDays)
|
||||
}
|
||||
if req.AutoTransferMinSpace != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAutoTransferMinSpace, Value: strconv.Itoa(*req.AutoTransferMinSpace), Type: entity.ConfigTypeInt})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyAutoTransferMinSpace)
|
||||
}
|
||||
if req.PageSize != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyPageSize, Value: strconv.Itoa(*req.PageSize), Type: entity.ConfigTypeInt})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyPageSize)
|
||||
}
|
||||
|
||||
// 三方统计配置
|
||||
if req.ThirdPartyStatsCode != "" {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyThirdPartyStatsCode, Value: req.ThirdPartyStatsCode, Type: entity.ConfigTypeString})
|
||||
// 三方统计配置 - 只处理被设置的字段
|
||||
if req.ThirdPartyStatsCode != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyThirdPartyStatsCode, Value: *req.ThirdPartyStatsCode, Type: entity.ConfigTypeString})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyThirdPartyStatsCode)
|
||||
}
|
||||
|
||||
// 记录更新的配置项
|
||||
if len(updatedKeys) > 0 {
|
||||
utils.Info("配置更新 - 被修改的配置项: %v", updatedKeys)
|
||||
}
|
||||
|
||||
return configs
|
||||
@@ -149,6 +206,7 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
|
||||
entity.ConfigResponseFieldKeywords: entity.ConfigDefaultKeywords,
|
||||
entity.ConfigResponseFieldAuthor: entity.ConfigDefaultAuthor,
|
||||
entity.ConfigResponseFieldCopyright: entity.ConfigDefaultCopyright,
|
||||
"site_logo": "",
|
||||
entity.ConfigResponseFieldAutoProcessReadyResources: false,
|
||||
entity.ConfigResponseFieldAutoProcessInterval: 30,
|
||||
entity.ConfigResponseFieldAutoTransferEnabled: false,
|
||||
@@ -156,6 +214,8 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
|
||||
entity.ConfigResponseFieldAutoTransferMinSpace: 100,
|
||||
entity.ConfigResponseFieldAutoFetchHotDramaEnabled: false,
|
||||
entity.ConfigResponseFieldForbiddenWords: "",
|
||||
entity.ConfigResponseFieldAdKeywords: "",
|
||||
entity.ConfigResponseFieldAutoInsertAd: "",
|
||||
entity.ConfigResponseFieldPageSize: 100,
|
||||
entity.ConfigResponseFieldMaintenanceMode: false,
|
||||
entity.ConfigResponseFieldEnableRegister: true, // 默认开启注册功能
|
||||
@@ -174,6 +234,8 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
|
||||
response[entity.ConfigResponseFieldAuthor] = config.Value
|
||||
case entity.ConfigKeyCopyright:
|
||||
response[entity.ConfigResponseFieldCopyright] = config.Value
|
||||
case entity.ConfigKeySiteLogo:
|
||||
response["site_logo"] = config.Value
|
||||
case entity.ConfigKeyAutoProcessReadyResources:
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response[entity.ConfigResponseFieldAutoProcessReadyResources] = val
|
||||
@@ -200,6 +262,10 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
|
||||
}
|
||||
case entity.ConfigKeyForbiddenWords:
|
||||
response[entity.ConfigResponseFieldForbiddenWords] = config.Value
|
||||
case entity.ConfigKeyAdKeywords:
|
||||
response[entity.ConfigResponseFieldAdKeywords] = config.Value
|
||||
case entity.ConfigKeyAutoInsertAd:
|
||||
response[entity.ConfigResponseFieldAutoInsertAd] = config.Value
|
||||
case entity.ConfigKeyPageSize:
|
||||
if val, err := strconv.Atoi(config.Value); err == nil {
|
||||
response[entity.ConfigResponseFieldPageSize] = val
|
||||
@@ -234,6 +300,7 @@ func getDefaultConfigResponse() *dto.SystemConfigResponse {
|
||||
Keywords: entity.ConfigDefaultKeywords,
|
||||
Author: entity.ConfigDefaultAuthor,
|
||||
Copyright: entity.ConfigDefaultCopyright,
|
||||
SiteLogo: "",
|
||||
AutoProcessReadyResources: false,
|
||||
AutoProcessInterval: 30,
|
||||
AutoTransferEnabled: false,
|
||||
@@ -242,6 +309,8 @@ func getDefaultConfigResponse() *dto.SystemConfigResponse {
|
||||
AutoFetchHotDramaEnabled: false,
|
||||
ApiToken: entity.ConfigDefaultApiToken,
|
||||
ForbiddenWords: entity.ConfigDefaultForbiddenWords,
|
||||
AdKeywords: entity.ConfigDefaultAdKeywords,
|
||||
AutoInsertAd: entity.ConfigDefaultAutoInsertAd,
|
||||
PageSize: 100,
|
||||
MaintenanceMode: false,
|
||||
EnableRegister: true, // 默认开启注册功能
|
||||
|
||||
73
db/dto/file.go
Normal file
73
db/dto/file.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package dto
|
||||
|
||||
// FileUploadRequest 文件上传请求
|
||||
type FileUploadRequest struct {
|
||||
IsPublic bool `json:"is_public" form:"is_public"` // 是否公开
|
||||
FileHash string `json:"file_hash" form:"file_hash"` // 文件哈希值
|
||||
}
|
||||
|
||||
// FileResponse 文件响应
|
||||
type FileResponse struct {
|
||||
ID uint `json:"id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
|
||||
// 文件信息
|
||||
OriginalName string `json:"original_name"`
|
||||
FileName string `json:"file_name"`
|
||||
FilePath string `json:"file_path"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
FileType string `json:"file_type"`
|
||||
MimeType string `json:"mime_type"`
|
||||
FileHash string `json:"file_hash"`
|
||||
|
||||
// 访问信息
|
||||
AccessURL string `json:"access_url"`
|
||||
|
||||
// 用户信息
|
||||
UserID uint `json:"user_id"`
|
||||
User string `json:"user"` // 用户名
|
||||
|
||||
// 状态信息
|
||||
Status string `json:"status"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
IsDeleted bool `json:"is_deleted"`
|
||||
}
|
||||
|
||||
// FileListRequest 文件列表请求
|
||||
type FileListRequest struct {
|
||||
Page int `json:"page" form:"page"`
|
||||
PageSize int `json:"page_size" form:"page_size"`
|
||||
Search string `json:"search" form:"search"`
|
||||
FileType string `json:"file_type" form:"file_type"`
|
||||
Status string `json:"status" form:"status"`
|
||||
UserID uint `json:"user_id" form:"user_id"`
|
||||
}
|
||||
|
||||
// FileListResponse 文件列表响应
|
||||
type FileListResponse struct {
|
||||
Files []FileResponse `json:"files"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
// FileUploadResponse 文件上传响应
|
||||
type FileUploadResponse struct {
|
||||
File FileResponse `json:"file"`
|
||||
Message string `json:"message"`
|
||||
Success bool `json:"success"`
|
||||
IsDuplicate bool `json:"is_duplicate"` // 是否为重复文件
|
||||
}
|
||||
|
||||
// FileDeleteRequest 文件删除请求
|
||||
type FileDeleteRequest struct {
|
||||
IDs []uint `json:"ids" binding:"required"`
|
||||
}
|
||||
|
||||
// FileUpdateRequest 文件更新请求
|
||||
type FileUpdateRequest struct {
|
||||
ID uint `json:"id" binding:"required"`
|
||||
IsPublic *bool `json:"is_public"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
@@ -3,33 +3,38 @@ package dto
|
||||
// SystemConfigRequest 系统配置请求
|
||||
type SystemConfigRequest struct {
|
||||
// SEO 配置
|
||||
SiteTitle string `json:"site_title"`
|
||||
SiteDescription string `json:"site_description"`
|
||||
Keywords string `json:"keywords"`
|
||||
Author string `json:"author"`
|
||||
Copyright string `json:"copyright"`
|
||||
SiteTitle *string `json:"site_title,omitempty"`
|
||||
SiteDescription *string `json:"site_description,omitempty"`
|
||||
Keywords *string `json:"keywords,omitempty"`
|
||||
Author *string `json:"author,omitempty"`
|
||||
Copyright *string `json:"copyright,omitempty"`
|
||||
SiteLogo *string `json:"site_logo,omitempty"`
|
||||
|
||||
// 自动处理配置组
|
||||
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"` // 自动拉取热播剧名字
|
||||
AutoProcessReadyResources *bool `json:"auto_process_ready_resources,omitempty"` // 自动处理待处理资源
|
||||
AutoProcessInterval *int `json:"auto_process_interval,omitempty"` // 自动处理间隔(分钟)
|
||||
AutoTransferEnabled *bool `json:"auto_transfer_enabled,omitempty"` // 开启自动转存
|
||||
AutoTransferLimitDays *int `json:"auto_transfer_limit_days,omitempty"` // 自动转存限制天数(0表示不限制)
|
||||
AutoTransferMinSpace *int `json:"auto_transfer_min_space,omitempty"` // 最小存储空间(GB)
|
||||
AutoFetchHotDramaEnabled *bool `json:"auto_fetch_hot_drama_enabled,omitempty"` // 自动拉取热播剧名字
|
||||
|
||||
// API配置
|
||||
ApiToken string `json:"api_token"` // 公开API访问令牌
|
||||
ApiToken *string `json:"api_token,omitempty"` // 公开API访问令牌
|
||||
|
||||
// 违禁词配置
|
||||
ForbiddenWords string `json:"forbidden_words"` // 违禁词列表,用逗号分隔
|
||||
ForbiddenWords *string `json:"forbidden_words,omitempty"` // 违禁词列表,用逗号分隔
|
||||
|
||||
// 广告配置
|
||||
AdKeywords *string `json:"ad_keywords,omitempty"` // 广告关键词列表,用逗号分隔
|
||||
AutoInsertAd *string `json:"auto_insert_ad,omitempty"` // 自动插入广告内容
|
||||
|
||||
// 其他配置
|
||||
PageSize int `json:"page_size"`
|
||||
MaintenanceMode bool `json:"maintenance_mode"`
|
||||
EnableRegister bool `json:"enable_register"` // 开启注册功能
|
||||
PageSize *int `json:"page_size,omitempty"`
|
||||
MaintenanceMode *bool `json:"maintenance_mode,omitempty"`
|
||||
EnableRegister *bool `json:"enable_register,omitempty"` // 开启注册功能
|
||||
|
||||
// 三方统计配置
|
||||
ThirdPartyStatsCode string `json:"third_party_stats_code"` // 三方统计代码
|
||||
ThirdPartyStatsCode *string `json:"third_party_stats_code,omitempty"` // 三方统计代码
|
||||
}
|
||||
|
||||
// SystemConfigResponse 系统配置响应
|
||||
@@ -44,6 +49,7 @@ type SystemConfigResponse struct {
|
||||
Keywords string `json:"keywords"`
|
||||
Author string `json:"author"`
|
||||
Copyright string `json:"copyright"`
|
||||
SiteLogo string `json:"site_logo"`
|
||||
|
||||
// 自动处理配置组
|
||||
AutoProcessReadyResources bool `json:"auto_process_ready_resources"` // 自动处理待处理资源
|
||||
@@ -59,6 +65,10 @@ type SystemConfigResponse struct {
|
||||
// 违禁词配置
|
||||
ForbiddenWords string `json:"forbidden_words"` // 违禁词列表,用逗号分隔
|
||||
|
||||
// 广告配置
|
||||
AdKeywords string `json:"ad_keywords"` // 广告关键词列表,用逗号分隔
|
||||
AutoInsertAd string `json:"auto_insert_ad"` // 自动插入广告内容
|
||||
|
||||
// 其他配置
|
||||
PageSize int `json:"page_size"`
|
||||
MaintenanceMode bool `json:"maintenance_mode"`
|
||||
|
||||
45
db/entity/file.go
Normal file
45
db/entity/file.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// File 文件实体
|
||||
type File struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// 文件信息
|
||||
OriginalName string `json:"original_name" gorm:"size:255;not null;comment:原始文件名"`
|
||||
FileName string `json:"file_name" gorm:"size:255;not null;unique;comment:存储文件名"`
|
||||
FilePath string `json:"file_path" gorm:"size:500;not null;comment:文件路径"`
|
||||
FileSize int64 `json:"file_size" gorm:"not null;comment:文件大小(字节)"`
|
||||
FileType string `json:"file_type" gorm:"size:100;not null;comment:文件类型"`
|
||||
MimeType string `json:"mime_type" gorm:"size:100;comment:MIME类型"`
|
||||
FileHash string `json:"file_hash" gorm:"size:64;uniqueIndex;comment:文件哈希值"`
|
||||
|
||||
// 访问信息
|
||||
AccessURL string `json:"access_url" gorm:"size:500;comment:访问URL"`
|
||||
|
||||
// 用户信息
|
||||
UserID uint `json:"user_id" gorm:"comment:上传用户ID"`
|
||||
User User `json:"user" gorm:"foreignKey:UserID"`
|
||||
|
||||
// 状态信息
|
||||
Status string `json:"status" gorm:"size:20;default:'active';comment:文件状态"`
|
||||
IsPublic bool `json:"is_public" gorm:"default:true;comment:是否公开"`
|
||||
IsDeleted bool `json:"is_deleted" gorm:"default:false;comment:是否已删除"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (File) TableName() string {
|
||||
return "files"
|
||||
}
|
||||
|
||||
// FileStatus 文件状态常量
|
||||
const (
|
||||
FileStatusActive = "active" // 正常
|
||||
FileStatusInactive = "inactive" // 禁用
|
||||
FileStatusDeleted = "deleted" // 已删除
|
||||
)
|
||||
@@ -8,6 +8,7 @@ const (
|
||||
ConfigKeyKeywords = "keywords"
|
||||
ConfigKeyAuthor = "author"
|
||||
ConfigKeyCopyright = "copyright"
|
||||
ConfigKeySiteLogo = "site_logo"
|
||||
|
||||
// 自动处理配置组
|
||||
ConfigKeyAutoProcessReadyResources = "auto_process_ready_resources"
|
||||
@@ -23,6 +24,10 @@ const (
|
||||
// 违禁词配置
|
||||
ConfigKeyForbiddenWords = "forbidden_words"
|
||||
|
||||
// 广告配置
|
||||
ConfigKeyAdKeywords = "ad_keywords" // 广告关键词
|
||||
ConfigKeyAutoInsertAd = "auto_insert_ad" // 自动插入广告
|
||||
|
||||
// 其他配置
|
||||
ConfigKeyPageSize = "page_size"
|
||||
ConfigKeyMaintenanceMode = "maintenance_mode"
|
||||
@@ -68,6 +73,10 @@ const (
|
||||
// 违禁词配置字段
|
||||
ConfigResponseFieldForbiddenWords = "forbidden_words"
|
||||
|
||||
// 广告配置字段
|
||||
ConfigResponseFieldAdKeywords = "ad_keywords"
|
||||
ConfigResponseFieldAutoInsertAd = "auto_insert_ad"
|
||||
|
||||
// 其他配置字段
|
||||
ConfigResponseFieldPageSize = "page_size"
|
||||
ConfigResponseFieldMaintenanceMode = "maintenance_mode"
|
||||
@@ -100,6 +109,10 @@ const (
|
||||
// 违禁词配置默认值
|
||||
ConfigDefaultForbiddenWords = ""
|
||||
|
||||
// 广告配置默认值
|
||||
ConfigDefaultAdKeywords = ""
|
||||
ConfigDefaultAutoInsertAd = ""
|
||||
|
||||
// 其他配置默认值
|
||||
ConfigDefaultPageSize = "100"
|
||||
ConfigDefaultMaintenanceMode = "false"
|
||||
|
||||
167
db/repo/file_repository.go
Normal file
167
db/repo/file_repository.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// FileRepository 文件Repository接口
|
||||
type FileRepository interface {
|
||||
BaseRepository[entity.File]
|
||||
FindByFileName(fileName string) (*entity.File, error)
|
||||
FindByHash(fileHash string) (*entity.File, error)
|
||||
FindByUserID(userID uint, page, pageSize int) ([]entity.File, int64, error)
|
||||
FindPublicFiles(page, pageSize int) ([]entity.File, int64, error)
|
||||
SearchFiles(search string, fileType, status string, userID uint, page, pageSize int) ([]entity.File, int64, error)
|
||||
SoftDeleteByIDs(ids []uint) error
|
||||
UpdateFileStatus(id uint, status string) error
|
||||
UpdateFilePublic(id uint, isPublic bool) error
|
||||
}
|
||||
|
||||
// FileRepositoryImpl 文件Repository实现
|
||||
type FileRepositoryImpl struct {
|
||||
BaseRepositoryImpl[entity.File]
|
||||
}
|
||||
|
||||
// NewFileRepository 创建文件Repository
|
||||
func NewFileRepository(db *gorm.DB) FileRepository {
|
||||
return &FileRepositoryImpl{
|
||||
BaseRepositoryImpl: BaseRepositoryImpl[entity.File]{db: db},
|
||||
}
|
||||
}
|
||||
|
||||
// FindByFileName 根据文件名查找文件
|
||||
func (r *FileRepositoryImpl) FindByFileName(fileName string) (*entity.File, error) {
|
||||
var file entity.File
|
||||
err := r.db.Where("file_name = ? AND is_deleted = ?", fileName, false).First(&file).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &file, nil
|
||||
}
|
||||
|
||||
// FindByUserID 根据用户ID查找文件
|
||||
func (r *FileRepositoryImpl) FindByUserID(userID uint, page, pageSize int) ([]entity.File, int64, error) {
|
||||
var files []entity.File
|
||||
var total int64
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
// 获取总数
|
||||
err := r.db.Model(&entity.File{}).Where("user_id = ? AND is_deleted = ?", userID, false).Count(&total).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 获取文件列表
|
||||
err = r.db.Where("user_id = ? AND is_deleted = ?", userID, false).
|
||||
Preload("User").
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(pageSize).
|
||||
Find(&files).Error
|
||||
|
||||
return files, total, err
|
||||
}
|
||||
|
||||
// FindPublicFiles 查找公开文件
|
||||
func (r *FileRepositoryImpl) FindPublicFiles(page, pageSize int) ([]entity.File, int64, error) {
|
||||
var files []entity.File
|
||||
var total int64
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
// 获取总数
|
||||
err := r.db.Model(&entity.File{}).Where("is_public = ? AND is_deleted = ? AND status = ?", true, false, entity.FileStatusActive).Count(&total).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 获取文件列表
|
||||
err = r.db.Where("is_public = ? AND is_deleted = ? AND status = ?", true, false, entity.FileStatusActive).
|
||||
Preload("User").
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(pageSize).
|
||||
Find(&files).Error
|
||||
|
||||
return files, total, err
|
||||
}
|
||||
|
||||
// SearchFiles 搜索文件
|
||||
func (r *FileRepositoryImpl) SearchFiles(search string, fileType, status string, userID uint, page, pageSize int) ([]entity.File, int64, error) {
|
||||
var files []entity.File
|
||||
var total int64
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
query := r.db.Model(&entity.File{}).Where("is_deleted = ?", false)
|
||||
|
||||
// 添加调试日志
|
||||
utils.Info("搜索文件参数: search='%s', fileType='%s', status='%s', userID=%d, page=%d, pageSize=%d",
|
||||
search, fileType, status, userID, page, pageSize)
|
||||
|
||||
// 添加搜索条件
|
||||
if search != "" {
|
||||
query = query.Where("original_name LIKE ?", "%"+search+"%")
|
||||
utils.Info("添加搜索条件: original_name LIKE '%%%s%%'", search)
|
||||
}
|
||||
|
||||
if fileType != "" {
|
||||
query = query.Where("file_type = ?", fileType)
|
||||
}
|
||||
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
if userID > 0 {
|
||||
query = query.Where("user_id = ?", userID)
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
err := query.Count(&total).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 获取文件列表
|
||||
err = query.Preload("User").
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(pageSize).
|
||||
Find(&files).Error
|
||||
|
||||
// 添加调试日志
|
||||
utils.Info("搜索结果: 总数=%d, 当前页文件数=%d", total, len(files))
|
||||
if len(files) > 0 {
|
||||
utils.Info("第一个文件: ID=%d, 文件名='%s'", files[0].ID, files[0].OriginalName)
|
||||
}
|
||||
|
||||
return files, total, err
|
||||
}
|
||||
|
||||
// SoftDeleteByIDs 软删除文件
|
||||
func (r *FileRepositoryImpl) SoftDeleteByIDs(ids []uint) error {
|
||||
return r.db.Model(&entity.File{}).Where("id IN ?", ids).Update("is_deleted", true).Error
|
||||
}
|
||||
|
||||
// UpdateFileStatus 更新文件状态
|
||||
func (r *FileRepositoryImpl) UpdateFileStatus(id uint, status string) error {
|
||||
return r.db.Model(&entity.File{}).Where("id = ?", id).Update("status", status).Error
|
||||
}
|
||||
|
||||
// UpdateFilePublic 更新文件公开状态
|
||||
func (r *FileRepositoryImpl) UpdateFilePublic(id uint, isPublic bool) error {
|
||||
return r.db.Model(&entity.File{}).Where("id = ?", id).Update("is_public", isPublic).Error
|
||||
}
|
||||
|
||||
// FindByHash 根据文件哈希查找文件
|
||||
func (r *FileRepositoryImpl) FindByHash(fileHash string) (*entity.File, error) {
|
||||
var file entity.File
|
||||
err := r.db.Where("file_hash = ? AND is_deleted = ?", fileHash, false).First(&file).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &file, nil
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type RepositoryManager struct {
|
||||
ResourceViewRepository ResourceViewRepository
|
||||
TaskRepository TaskRepository
|
||||
TaskItemRepository TaskItemRepository
|
||||
FileRepository FileRepository
|
||||
}
|
||||
|
||||
// NewRepositoryManager 创建Repository管理器
|
||||
@@ -37,5 +38,6 @@ func NewRepositoryManager(db *gorm.DB) *RepositoryManager {
|
||||
ResourceViewRepository: NewResourceViewRepository(db),
|
||||
TaskRepository: NewTaskRepository(db),
|
||||
TaskItemRepository: NewTaskItemRepository(db),
|
||||
FileRepository: NewFileRepository(db),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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实现
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -21,6 +22,8 @@ type SystemConfigRepository interface {
|
||||
GetConfigInt(key string) (int, error)
|
||||
GetCachedConfigs() map[string]string
|
||||
ClearConfigCache()
|
||||
SafeRefreshConfigCache() error
|
||||
ValidateConfigIntegrity() error
|
||||
}
|
||||
|
||||
// SystemConfigRepositoryImpl 系统配置Repository实现
|
||||
@@ -60,27 +63,39 @@ func (r *SystemConfigRepositoryImpl) FindByKey(key string) (*entity.SystemConfig
|
||||
|
||||
// UpsertConfigs 批量创建或更新配置
|
||||
func (r *SystemConfigRepositoryImpl) UpsertConfigs(configs []entity.SystemConfig) error {
|
||||
for _, config := range configs {
|
||||
var existingConfig entity.SystemConfig
|
||||
err := r.db.Where("key = ?", config.Key).First(&existingConfig).Error
|
||||
// 使用事务确保数据一致性
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 在更新前备份当前配置
|
||||
var existingConfigs []entity.SystemConfig
|
||||
if err := tx.Find(&existingConfigs).Error; err != nil {
|
||||
utils.Error("备份配置失败: %v", err)
|
||||
// 不返回错误,继续执行更新
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// 如果不存在,则创建
|
||||
if err := r.db.Create(&config).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// 如果存在,则更新
|
||||
config.ID = existingConfig.ID
|
||||
if err := r.db.Save(&config).Error; err != nil {
|
||||
return err
|
||||
for _, config := range configs {
|
||||
var existingConfig entity.SystemConfig
|
||||
err := tx.Where("key = ?", config.Key).First(&existingConfig).Error
|
||||
|
||||
if err != nil {
|
||||
// 如果不存在,则创建
|
||||
if err := tx.Create(&config).Error; err != nil {
|
||||
utils.Error("创建配置失败 [%s]: %v", config.Key, err)
|
||||
return fmt.Errorf("创建配置失败 [%s]: %v", config.Key, err)
|
||||
}
|
||||
} else {
|
||||
// 如果存在,则更新
|
||||
config.ID = existingConfig.ID
|
||||
if err := tx.Save(&config).Error; err != nil {
|
||||
utils.Error("更新配置失败 [%s]: %v", config.Key, err)
|
||||
return fmt.Errorf("更新配置失败 [%s]: %v", config.Key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新配置后刷新缓存
|
||||
r.refreshConfigCache()
|
||||
return nil
|
||||
// 更新成功后刷新缓存
|
||||
r.refreshConfigCache()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetOrCreateDefault 获取配置或创建默认配置
|
||||
@@ -92,6 +107,7 @@ func (r *SystemConfigRepositoryImpl) GetOrCreateDefault() ([]entity.SystemConfig
|
||||
|
||||
// 如果没有配置,创建默认配置
|
||||
if len(configs) == 0 {
|
||||
utils.Info("未找到任何配置,创建默认配置")
|
||||
defaultConfigs := []entity.SystemConfig{
|
||||
{Key: entity.ConfigKeySiteTitle, Value: entity.ConfigDefaultSiteTitle, Type: entity.ConfigTypeString},
|
||||
{Key: entity.ConfigKeySiteDescription, Value: entity.ConfigDefaultSiteDescription, Type: entity.ConfigTypeString},
|
||||
@@ -105,10 +121,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.ConfigKeyEnableRegister, Value: entity.ConfigDefaultEnableRegister, Type: entity.ConfigTypeBool},
|
||||
{Key: entity.ConfigKeyThirdPartyStatsCode, Value: entity.ConfigDefaultThirdPartyStatsCode, Type: entity.ConfigTypeString},
|
||||
{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)
|
||||
@@ -133,6 +149,8 @@ func (r *SystemConfigRepositoryImpl) GetOrCreateDefault() ([]entity.SystemConfig
|
||||
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.ConfigKeyAdKeywords: {Key: entity.ConfigKeyAdKeywords, Value: entity.ConfigDefaultAdKeywords, Type: entity.ConfigTypeString},
|
||||
entity.ConfigKeyAutoInsertAd: {Key: entity.ConfigKeyAutoInsertAd, Value: entity.ConfigDefaultAutoInsertAd, 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},
|
||||
@@ -206,6 +224,66 @@ func (r *SystemConfigRepositoryImpl) refreshConfigCache() {
|
||||
r.initConfigCache()
|
||||
}
|
||||
|
||||
// SafeRefreshConfigCache 安全的刷新配置缓存(带错误处理)
|
||||
func (r *SystemConfigRepositoryImpl) SafeRefreshConfigCache() error {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
utils.Error("配置缓存刷新时发生panic: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
r.refreshConfigCache()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateConfigIntegrity 验证配置完整性
|
||||
func (r *SystemConfigRepositoryImpl) ValidateConfigIntegrity() error {
|
||||
configs, err := r.FindAll()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取配置失败: %v", err)
|
||||
}
|
||||
|
||||
// 检查关键配置是否存在
|
||||
requiredKeys := []string{
|
||||
entity.ConfigKeySiteTitle,
|
||||
entity.ConfigKeySiteDescription,
|
||||
entity.ConfigKeyKeywords,
|
||||
entity.ConfigKeyAuthor,
|
||||
entity.ConfigKeyCopyright,
|
||||
entity.ConfigKeyAutoProcessReadyResources,
|
||||
entity.ConfigKeyAutoProcessInterval,
|
||||
entity.ConfigKeyAutoTransferEnabled,
|
||||
entity.ConfigKeyAutoTransferLimitDays,
|
||||
entity.ConfigKeyAutoTransferMinSpace,
|
||||
entity.ConfigKeyAutoFetchHotDramaEnabled,
|
||||
entity.ConfigKeyApiToken,
|
||||
entity.ConfigKeyPageSize,
|
||||
entity.ConfigKeyMaintenanceMode,
|
||||
entity.ConfigKeyEnableRegister,
|
||||
entity.ConfigKeyThirdPartyStatsCode,
|
||||
}
|
||||
|
||||
existingKeys := make(map[string]bool)
|
||||
for _, config := range configs {
|
||||
existingKeys[config.Key] = true
|
||||
}
|
||||
|
||||
var missingKeys []string
|
||||
for _, key := range requiredKeys {
|
||||
if !existingKeys[key] {
|
||||
missingKeys = append(missingKeys, key)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missingKeys) > 0 {
|
||||
utils.Error("发现缺失的配置项: %v", missingKeys)
|
||||
return fmt.Errorf("配置不完整,缺失: %v", missingKeys)
|
||||
}
|
||||
|
||||
utils.Info("配置完整性检查通过")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetConfigValue 获取配置值(字符串)
|
||||
func (r *SystemConfigRepositoryImpl) GetConfigValue(key string) (string, error) {
|
||||
// 初始化缓存
|
||||
|
||||
@@ -20,7 +20,7 @@ services:
|
||||
- app-network
|
||||
|
||||
backend:
|
||||
image: ctwj/urldb-backend:1.1.0
|
||||
image: ctwj/urldb-backend:1.2.3
|
||||
environment:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
@@ -38,7 +38,7 @@ services:
|
||||
- app-network
|
||||
|
||||
frontend:
|
||||
image: ctwj/urldb-frontend:1.1.0
|
||||
image: ctwj/urldb-frontend:1.2.3
|
||||
environment:
|
||||
NUXT_PUBLIC_API_SERVER: http://backend:8080/api
|
||||
NUXT_PUBLIC_API_CLIENT: /api
|
||||
|
||||
@@ -14,4 +14,4 @@ TIMEZONE=Asia/Shanghai
|
||||
|
||||
# 文件上传配置
|
||||
UPLOAD_DIR=./uploads
|
||||
MAX_FILE_SIZE=100MB
|
||||
MAX_FILE_SIZE=5MB
|
||||
452
handlers/file_handler.go
Normal file
452
handlers/file_handler.go
Normal file
@@ -0,0 +1,452 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/converter"
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// FileHandler 文件处理器
|
||||
type FileHandler struct {
|
||||
fileRepo repo.FileRepository
|
||||
systemConfigRepo repo.SystemConfigRepository
|
||||
userRepo repo.UserRepository
|
||||
}
|
||||
|
||||
// NewFileHandler 创建文件处理器
|
||||
func NewFileHandler(fileRepo repo.FileRepository, systemConfigRepo repo.SystemConfigRepository, userRepo repo.UserRepository) *FileHandler {
|
||||
return &FileHandler{
|
||||
fileRepo: fileRepo,
|
||||
systemConfigRepo: systemConfigRepo,
|
||||
userRepo: userRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// UploadFile 上传文件
|
||||
func (h *FileHandler) UploadFile(c *gin.Context) {
|
||||
// 获取当前用户ID
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
ErrorResponse(c, "用户未登录", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// 从数据库获取用户信息
|
||||
currentUser, err := h.userRepo.FindByID(userID.(uint))
|
||||
if err != nil {
|
||||
ErrorResponse(c, "用户不存在", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取文件哈希值
|
||||
fileHash := c.PostForm("file_hash")
|
||||
|
||||
// 如果提供了文件哈希,先检查是否已存在
|
||||
if fileHash != "" {
|
||||
existingFile, err := h.fileRepo.FindByHash(fileHash)
|
||||
if err == nil && existingFile != nil {
|
||||
// 文件已存在,直接返回已存在的文件信息
|
||||
utils.Info("文件已存在,跳过上传 - Hash: %s, 文件名: %s", fileHash, existingFile.OriginalName)
|
||||
|
||||
response := dto.FileUploadResponse{
|
||||
File: converter.FileToResponse(existingFile),
|
||||
Message: "文件已存在,极速上传成功",
|
||||
Success: true,
|
||||
IsDuplicate: true,
|
||||
}
|
||||
|
||||
SuccessResponse(c, response)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 获取上传目录配置(从环境变量或使用默认值)
|
||||
uploadDir := os.Getenv("UPLOAD_DIR")
|
||||
if uploadDir == "" {
|
||||
uploadDir = "./uploads" // 默认值
|
||||
}
|
||||
|
||||
// 创建年月子文件夹
|
||||
now := time.Now()
|
||||
yearMonth := now.Format("200601") // 格式:202508
|
||||
monthlyDir := filepath.Join(uploadDir, yearMonth)
|
||||
|
||||
// 确保年月目录存在
|
||||
if err := os.MkdirAll(monthlyDir, 0755); err != nil {
|
||||
ErrorResponse(c, "创建年月目录失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取上传的文件
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取上传文件失败", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 检查文件大小(5MB)
|
||||
maxFileSize := int64(5 * 1024 * 1024) // 5MB
|
||||
if header.Size > maxFileSize {
|
||||
ErrorResponse(c, "文件大小不能超过5MB", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查文件类型,只允许图片
|
||||
allowedTypes := []string{
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"image/svg+xml",
|
||||
}
|
||||
|
||||
contentType := header.Header.Get("Content-Type")
|
||||
isAllowedType := false
|
||||
for _, allowedType := range allowedTypes {
|
||||
if contentType == allowedType {
|
||||
isAllowedType = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !isAllowedType {
|
||||
ErrorResponse(c, "只支持图片格式文件 (JPEG, PNG, GIF, WebP, BMP, SVG)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 生成随机文件名
|
||||
fileName := h.generateRandomFileName(header.Filename)
|
||||
filePath := filepath.Join(monthlyDir, fileName)
|
||||
|
||||
// 创建目标文件
|
||||
dst, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "创建文件失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
// 复制文件内容
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
ErrorResponse(c, "保存文件失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 计算文件哈希值(如果前端没有提供)
|
||||
if fileHash == "" {
|
||||
fileHash, err = h.calculateFileHash(filePath)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "计算文件哈希失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 再次检查文件是否已存在(使用计算出的哈希)
|
||||
existingFile, err := h.fileRepo.FindByHash(fileHash)
|
||||
if err == nil && existingFile != nil {
|
||||
// 文件已存在,删除刚上传的文件,返回已存在的文件信息
|
||||
os.Remove(filePath)
|
||||
utils.Info("文件已存在,跳过上传 - Hash: %s, 文件名: %s", fileHash, existingFile.OriginalName)
|
||||
|
||||
response := dto.FileUploadResponse{
|
||||
File: converter.FileToResponse(existingFile),
|
||||
Message: "文件已存在,极速上传成功",
|
||||
Success: true,
|
||||
IsDuplicate: true,
|
||||
}
|
||||
|
||||
SuccessResponse(c, response)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取文件类型
|
||||
fileType := h.getFileType(header.Filename)
|
||||
mimeType := header.Header.Get("Content-Type")
|
||||
|
||||
// 获取是否公开
|
||||
isPublic := true
|
||||
if isPublicStr := c.PostForm("is_public"); isPublicStr != "" {
|
||||
if isPublicBool, err := strconv.ParseBool(isPublicStr); err == nil {
|
||||
isPublic = isPublicBool
|
||||
}
|
||||
}
|
||||
|
||||
// 构建访问URL(使用绝对路径,不包含域名)
|
||||
accessURL := fmt.Sprintf("/uploads/%s/%s", yearMonth, fileName)
|
||||
|
||||
// 创建文件记录
|
||||
fileEntity := &entity.File{
|
||||
OriginalName: header.Filename,
|
||||
FileName: fileName,
|
||||
FilePath: filePath,
|
||||
FileSize: header.Size,
|
||||
FileType: fileType,
|
||||
MimeType: mimeType,
|
||||
FileHash: fileHash,
|
||||
AccessURL: accessURL,
|
||||
UserID: currentUser.ID,
|
||||
Status: entity.FileStatusActive,
|
||||
IsPublic: isPublic,
|
||||
IsDeleted: false,
|
||||
}
|
||||
|
||||
// 保存到数据库
|
||||
if err := h.fileRepo.Create(fileEntity); err != nil {
|
||||
// 删除已上传的文件
|
||||
os.Remove(filePath)
|
||||
ErrorResponse(c, "保存文件记录失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
response := dto.FileUploadResponse{
|
||||
File: converter.FileToResponse(fileEntity),
|
||||
Message: "文件上传成功",
|
||||
Success: true,
|
||||
}
|
||||
|
||||
SuccessResponse(c, response)
|
||||
}
|
||||
|
||||
// GetFileList 获取文件列表
|
||||
func (h *FileHandler) GetFileList(c *gin.Context) {
|
||||
var req dto.FileListRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
ErrorResponse(c, "请求参数错误", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 设置默认值
|
||||
if req.Page <= 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize <= 0 {
|
||||
req.PageSize = 20
|
||||
}
|
||||
|
||||
// 添加调试日志
|
||||
utils.Info("文件列表请求参数: page=%d, pageSize=%d, search='%s', fileType='%s', status='%s', userID=%d",
|
||||
req.Page, req.PageSize, req.Search, req.FileType, req.Status, req.UserID)
|
||||
|
||||
// 获取当前用户ID和角色
|
||||
userIDInterface, exists := c.Get("user_id")
|
||||
roleInterface, _ := c.Get("role")
|
||||
|
||||
var userID uint
|
||||
var role string
|
||||
if exists {
|
||||
userID = userIDInterface.(uint)
|
||||
}
|
||||
if roleInterface != nil {
|
||||
role = roleInterface.(string)
|
||||
}
|
||||
|
||||
// 根据用户角色决定查询范围
|
||||
var files []entity.File
|
||||
var total int64
|
||||
var err error
|
||||
|
||||
if exists && role == "admin" {
|
||||
// 管理员可以查看所有文件
|
||||
files, total, err = h.fileRepo.SearchFiles(req.Search, req.FileType, req.Status, req.UserID, req.Page, req.PageSize)
|
||||
} else if userID > 0 {
|
||||
// 普通用户只能查看自己的文件
|
||||
files, total, err = h.fileRepo.SearchFiles(req.Search, req.FileType, req.Status, userID, req.Page, req.PageSize)
|
||||
} else {
|
||||
// 未登录用户只能查看公开文件
|
||||
files, total, err = h.fileRepo.FindPublicFiles(req.Page, req.PageSize)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取文件列表失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := converter.FileListToResponse(files, total, req.Page, req.PageSize)
|
||||
SuccessResponse(c, response)
|
||||
}
|
||||
|
||||
// DeleteFiles 删除文件
|
||||
func (h *FileHandler) DeleteFiles(c *gin.Context) {
|
||||
var req dto.FileDeleteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, "请求参数错误", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前用户ID和角色
|
||||
userIDInterface, exists := c.Get("user_id")
|
||||
roleInterface, _ := c.Get("role")
|
||||
if !exists {
|
||||
ErrorResponse(c, "用户未登录", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID := userIDInterface.(uint)
|
||||
role := ""
|
||||
if roleInterface != nil {
|
||||
role = roleInterface.(string)
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if role != "admin" {
|
||||
// 普通用户只能删除自己的文件
|
||||
for _, id := range req.IDs {
|
||||
file, err := h.fileRepo.FindByID(id)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "文件不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if file.UserID != userID {
|
||||
ErrorResponse(c, "没有权限删除此文件", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取要删除的文件信息
|
||||
var filesToDelete []entity.File
|
||||
for _, id := range req.IDs {
|
||||
file, err := h.fileRepo.FindByID(id)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "文件不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
filesToDelete = append(filesToDelete, *file)
|
||||
}
|
||||
|
||||
// 删除本地文件
|
||||
for _, file := range filesToDelete {
|
||||
if err := os.Remove(file.FilePath); err != nil {
|
||||
utils.Error("删除本地文件失败: %s, 错误: %v", file.FilePath, err)
|
||||
// 继续删除其他文件,不因为单个文件删除失败而中断
|
||||
}
|
||||
}
|
||||
|
||||
// 删除数据库记录
|
||||
for _, id := range req.IDs {
|
||||
if err := h.fileRepo.Delete(id); err != nil {
|
||||
utils.Error("删除文件记录失败: ID=%d, 错误: %v", id, err)
|
||||
// 继续删除其他文件,不因为单个文件删除失败而中断
|
||||
}
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{"message": "文件删除成功"})
|
||||
}
|
||||
|
||||
// UpdateFile 更新文件信息
|
||||
func (h *FileHandler) UpdateFile(c *gin.Context) {
|
||||
var req dto.FileUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, "请求参数错误", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前用户ID和角色
|
||||
userIDInterface, exists := c.Get("user_id")
|
||||
roleInterface, _ := c.Get("role")
|
||||
if !exists {
|
||||
ErrorResponse(c, "用户未登录", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID := userIDInterface.(uint)
|
||||
role := ""
|
||||
if roleInterface != nil {
|
||||
role = roleInterface.(string)
|
||||
}
|
||||
|
||||
// 查找文件
|
||||
file, err := h.fileRepo.FindByID(req.ID)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "文件不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if role != "admin" && userID != file.UserID {
|
||||
ErrorResponse(c, "没有权限修改此文件", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// 更新文件信息
|
||||
if req.IsPublic != nil {
|
||||
if err := h.fileRepo.UpdateFilePublic(req.ID, *req.IsPublic); err != nil {
|
||||
ErrorResponse(c, "更新文件状态失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.Status != "" {
|
||||
if err := h.fileRepo.UpdateFileStatus(req.ID, req.Status); err != nil {
|
||||
ErrorResponse(c, "更新文件状态失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{"message": "文件更新成功"})
|
||||
}
|
||||
|
||||
// generateRandomFileName 生成随机文件名
|
||||
func (h *FileHandler) generateRandomFileName(originalName string) string {
|
||||
// 获取文件扩展名
|
||||
ext := filepath.Ext(originalName)
|
||||
|
||||
// 生成随机字符串
|
||||
bytes := make([]byte, 16)
|
||||
rand.Read(bytes)
|
||||
randomStr := fmt.Sprintf("%x", bytes)
|
||||
|
||||
// 添加时间戳
|
||||
timestamp := time.Now().Unix()
|
||||
|
||||
return fmt.Sprintf("%d_%s%s", timestamp, randomStr, ext)
|
||||
}
|
||||
|
||||
// getFileType 获取文件类型
|
||||
func (h *FileHandler) getFileType(filename string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
|
||||
// 图片类型
|
||||
imageExts := []string{".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"}
|
||||
for _, imgExt := range imageExts {
|
||||
if ext == imgExt {
|
||||
return "image"
|
||||
}
|
||||
}
|
||||
|
||||
return "image" // 默认返回image,因为只支持图片格式
|
||||
}
|
||||
|
||||
// calculateFileHash 计算文件哈希值
|
||||
func (h *FileHandler) calculateFileHash(filePath string) (string, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", hash.Sum(nil)), nil
|
||||
}
|
||||
@@ -20,7 +20,11 @@ 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)
|
||||
utils.Info("资源列表请求 - page: %d, pageSize: %d, User-Agent: %s", page, pageSize, c.GetHeader("User-Agent"))
|
||||
|
||||
// 添加缓存控制头,优化 SSR 性能
|
||||
c.Header("Cache-Control", "public, max-age=30") // 30秒缓存,平衡性能和实时性
|
||||
c.Header("ETag", fmt.Sprintf("resources-%d-%d-%s-%s", page, pageSize, c.Query("search"), c.Query("pan_id")))
|
||||
|
||||
params := map[string]interface{}{
|
||||
"page": page,
|
||||
@@ -62,16 +66,6 @@ func GetResources(c *gin.Context) {
|
||||
|
||||
resources, total, err := repoManager.ResourceRepository.SearchWithFilters(params)
|
||||
|
||||
// 搜索统计(仅非管理员)
|
||||
if search, ok := params["search"].(string); ok && search != "" {
|
||||
user, _ := c.Get("user")
|
||||
if user == nil || (user != nil && user.(entity.User).Role != "admin") {
|
||||
ip := c.ClientIP()
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
repoManager.SearchStatRepository.RecordSearch(search, ip, userAgent)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
ErrorResponse(c, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -283,10 +277,6 @@ func SearchResources(c *gin.Context) {
|
||||
} else {
|
||||
// 有搜索关键词时,执行搜索
|
||||
resources, total, err = repoManager.ResourceRepository.Search(query, nil, page, pageSize)
|
||||
// 新增:记录搜索关键词
|
||||
ip := c.ClientIP()
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
repoManager.SearchStatRepository.RecordSearch(query, ip, userAgent)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
pan "github.com/ctwj/urldb/common"
|
||||
"github.com/ctwj/urldb/db/converter"
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
@@ -27,6 +28,20 @@ func NewSystemConfigHandler(systemConfigRepo repo.SystemConfigRepository) *Syste
|
||||
|
||||
// GetConfig 获取系统配置
|
||||
func (h *SystemConfigHandler) GetConfig(c *gin.Context) {
|
||||
// 先验证配置完整性
|
||||
if err := h.systemConfigRepo.ValidateConfigIntegrity(); err != nil {
|
||||
utils.Error("配置完整性检查失败: %v", err)
|
||||
// 如果配置不完整,尝试重新创建默认配置
|
||||
configs, err := h.systemConfigRepo.GetOrCreateDefault()
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取系统配置失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
configResponse := converter.SystemConfigToResponse(configs)
|
||||
SuccessResponse(c, configResponse)
|
||||
return
|
||||
}
|
||||
|
||||
configs, err := h.systemConfigRepo.GetOrCreateDefault()
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取系统配置失败", http.StatusInternalServerError)
|
||||
@@ -46,22 +61,22 @@ func (h *SystemConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 验证参数 - 只验证提交的字段
|
||||
if req.SiteTitle != "" && (len(req.SiteTitle) < 1 || len(req.SiteTitle) > 100) {
|
||||
if req.SiteTitle != nil && (len(*req.SiteTitle) < 1 || len(*req.SiteTitle) > 100) {
|
||||
ErrorResponse(c, "网站标题长度必须在1-100字符之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.AutoProcessInterval > 0 && (req.AutoProcessInterval < 1 || req.AutoProcessInterval > 1440) {
|
||||
if req.AutoProcessInterval != nil && (*req.AutoProcessInterval < 1 || *req.AutoProcessInterval > 1440) {
|
||||
ErrorResponse(c, "自动处理间隔必须在1-1440分钟之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.PageSize > 0 && (req.PageSize < 10 || req.PageSize > 500) {
|
||||
if req.PageSize != nil && (*req.PageSize < 10 || *req.PageSize > 500) {
|
||||
ErrorResponse(c, "每页显示数量必须在10-500之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.AutoTransferMinSpace > 0 && (req.AutoTransferMinSpace < 100 || req.AutoTransferMinSpace > 1024) {
|
||||
if req.AutoTransferMinSpace != nil && (*req.AutoTransferMinSpace < 100 || *req.AutoTransferMinSpace > 1024) {
|
||||
ErrorResponse(c, "最小存储空间必须在100-1024GB之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -80,6 +95,9 @@ func (h *SystemConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 刷新系统配置缓存
|
||||
pan.RefreshSystemConfigCache()
|
||||
|
||||
// 返回更新后的配置
|
||||
updatedConfigs, err := h.systemConfigRepo.FindAll()
|
||||
if err != nil {
|
||||
@@ -114,29 +132,37 @@ func UpdateSystemConfig(c *gin.Context) {
|
||||
// 调试信息
|
||||
utils.Info("接收到的配置请求: %+v", req)
|
||||
|
||||
// 获取当前配置作为备份
|
||||
currentConfigs, err := repoManager.SystemConfigRepository.FindAll()
|
||||
if err != nil {
|
||||
utils.Error("获取当前配置失败: %v", err)
|
||||
} else {
|
||||
utils.Info("当前配置数量: %d", len(currentConfigs))
|
||||
}
|
||||
|
||||
// 验证参数 - 只验证提交的字段
|
||||
if req.SiteTitle != "" && (len(req.SiteTitle) < 1 || len(req.SiteTitle) > 100) {
|
||||
if req.SiteTitle != nil && (len(*req.SiteTitle) < 1 || len(*req.SiteTitle) > 100) {
|
||||
ErrorResponse(c, "网站标题长度必须在1-100字符之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.AutoProcessInterval != 0 && (req.AutoProcessInterval < 1 || req.AutoProcessInterval > 1440) {
|
||||
if req.AutoProcessInterval != nil && (*req.AutoProcessInterval < 1 || *req.AutoProcessInterval > 1440) {
|
||||
ErrorResponse(c, "自动处理间隔必须在1-1440分钟之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.PageSize != 0 && (req.PageSize < 10 || req.PageSize > 500) {
|
||||
if req.PageSize != nil && (*req.PageSize < 10 || *req.PageSize > 500) {
|
||||
ErrorResponse(c, "每页显示数量必须在10-500之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证自动转存配置
|
||||
if req.AutoTransferLimitDays != 0 && (req.AutoTransferLimitDays < 0 || req.AutoTransferLimitDays > 365) {
|
||||
if req.AutoTransferLimitDays != nil && (*req.AutoTransferLimitDays < 0 || *req.AutoTransferLimitDays > 365) {
|
||||
ErrorResponse(c, "自动转存限制天数必须在0-365之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.AutoTransferMinSpace != 0 && (req.AutoTransferMinSpace < 100 || req.AutoTransferMinSpace > 1024) {
|
||||
if req.AutoTransferMinSpace != nil && (*req.AutoTransferMinSpace < 100 || *req.AutoTransferMinSpace > 1024) {
|
||||
ErrorResponse(c, "最小存储空间必须在100-1024GB之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -148,13 +174,27 @@ func UpdateSystemConfig(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("准备更新配置,配置项数量: %d", len(configs))
|
||||
|
||||
// 保存配置
|
||||
err := repoManager.SystemConfigRepository.UpsertConfigs(configs)
|
||||
err = repoManager.SystemConfigRepository.UpsertConfigs(configs)
|
||||
if err != nil {
|
||||
utils.Error("保存系统配置失败: %v", err)
|
||||
ErrorResponse(c, "保存系统配置失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("配置保存成功")
|
||||
|
||||
// 安全刷新系统配置缓存
|
||||
if err := repoManager.SystemConfigRepository.SafeRefreshConfigCache(); err != nil {
|
||||
utils.Error("刷新配置缓存失败: %v", err)
|
||||
// 不返回错误,因为配置已经保存成功
|
||||
}
|
||||
|
||||
// 刷新系统配置缓存
|
||||
pan.RefreshSystemConfigCache()
|
||||
|
||||
// 根据配置更新定时任务状态(错误不影响配置保存)
|
||||
scheduler := scheduler.GetGlobalScheduler(
|
||||
repoManager.HotDramaRepository,
|
||||
@@ -167,16 +207,30 @@ func UpdateSystemConfig(c *gin.Context) {
|
||||
repoManager.CategoryRepository,
|
||||
)
|
||||
if scheduler != nil {
|
||||
scheduler.UpdateSchedulerStatusWithAutoTransfer(req.AutoFetchHotDramaEnabled, req.AutoProcessReadyResources, req.AutoTransferEnabled)
|
||||
// 只更新被设置的配置
|
||||
var autoFetchHotDrama, autoProcessReady, autoTransfer bool
|
||||
if req.AutoFetchHotDramaEnabled != nil {
|
||||
autoFetchHotDrama = *req.AutoFetchHotDramaEnabled
|
||||
}
|
||||
if req.AutoProcessReadyResources != nil {
|
||||
autoProcessReady = *req.AutoProcessReadyResources
|
||||
}
|
||||
if req.AutoTransferEnabled != nil {
|
||||
autoTransfer = *req.AutoTransferEnabled
|
||||
}
|
||||
scheduler.UpdateSchedulerStatusWithAutoTransfer(autoFetchHotDrama, autoProcessReady, autoTransfer)
|
||||
}
|
||||
|
||||
// 返回更新后的配置
|
||||
updatedConfigs, err := repoManager.SystemConfigRepository.FindAll()
|
||||
if err != nil {
|
||||
utils.Error("获取更新后的配置失败: %v", err)
|
||||
ErrorResponse(c, "获取更新后的配置失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("配置更新完成,当前配置数量: %d", len(updatedConfigs))
|
||||
|
||||
configResponse := converter.SystemConfigToResponse(updatedConfigs)
|
||||
SuccessResponse(c, configResponse)
|
||||
}
|
||||
@@ -192,6 +246,36 @@ func GetPublicSystemConfig(c *gin.Context) {
|
||||
SuccessResponse(c, configResponse)
|
||||
}
|
||||
|
||||
// 新增:配置监控端点
|
||||
func GetConfigStatus(c *gin.Context) {
|
||||
// 获取配置统计信息
|
||||
configs, err := repoManager.SystemConfigRepository.FindAll()
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取配置状态失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证配置完整性
|
||||
integrityErr := repoManager.SystemConfigRepository.ValidateConfigIntegrity()
|
||||
|
||||
// 获取缓存状态
|
||||
cachedConfigs := repoManager.SystemConfigRepository.GetCachedConfigs()
|
||||
|
||||
status := map[string]interface{}{
|
||||
"total_configs": len(configs),
|
||||
"cached_configs": len(cachedConfigs),
|
||||
"integrity_check": integrityErr == nil,
|
||||
"integrity_error": "",
|
||||
"last_check_time": utils.GetCurrentTimeString(),
|
||||
}
|
||||
|
||||
if integrityErr != nil {
|
||||
status["integrity_error"] = integrityErr.Error()
|
||||
}
|
||||
|
||||
SuccessResponse(c, status)
|
||||
}
|
||||
|
||||
// 新增:切换自动处理配置
|
||||
func ToggleAutoProcess(c *gin.Context) {
|
||||
var req struct {
|
||||
|
||||
21
main.go
21
main.go
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/ctwj/urldb/db"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
@@ -106,6 +107,9 @@ func main() {
|
||||
// 创建任务处理器
|
||||
taskHandler := handlers.NewTaskHandler(repoManager, taskManager)
|
||||
|
||||
// 创建文件处理器
|
||||
fileHandler := handlers.NewFileHandler(repoManager.FileRepository, repoManager.SystemConfigRepository, repoManager.UserRepository)
|
||||
|
||||
// API路由
|
||||
api := r.Group("/api")
|
||||
{
|
||||
@@ -211,6 +215,7 @@ func main() {
|
||||
// 系统配置路由
|
||||
api.GET("/system/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetSystemConfig)
|
||||
api.POST("/system/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.UpdateSystemConfig)
|
||||
api.GET("/system/config/status", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetConfigStatus)
|
||||
api.POST("/system/config/toggle-auto-process", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.ToggleAutoProcess)
|
||||
api.GET("/public/system-config", handlers.GetPublicSystemConfig)
|
||||
|
||||
@@ -236,11 +241,27 @@ func main() {
|
||||
api.GET("/version/string", handlers.GetVersionString)
|
||||
api.GET("/version/full", handlers.GetFullVersionInfo)
|
||||
api.GET("/version/check-update", handlers.CheckUpdate)
|
||||
|
||||
// 文件上传相关路由
|
||||
api.POST("/files/upload", middleware.AuthMiddleware(), fileHandler.UploadFile)
|
||||
api.GET("/files", fileHandler.GetFileList)
|
||||
api.DELETE("/files", middleware.AuthMiddleware(), fileHandler.DeleteFiles)
|
||||
api.PUT("/files", middleware.AuthMiddleware(), fileHandler.UpdateFile)
|
||||
}
|
||||
|
||||
// 静态文件服务
|
||||
r.Static("/uploads", "./uploads")
|
||||
|
||||
// 添加CORS头到静态文件
|
||||
r.Use(func(c *gin.Context) {
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/uploads/") {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept")
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
|
||||
@@ -60,6 +60,15 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 缓存设置
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
|
||||
# 允许跨域访问
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
add_header Access-Control-Allow-Methods "GET, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Origin, Content-Type, Accept";
|
||||
}
|
||||
|
||||
# 健康检查
|
||||
|
||||
10
web/components.d.ts
vendored
10
web/components.d.ts
vendored
@@ -23,24 +23,32 @@ declare module 'vue' {
|
||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
||||
NForm: typeof import('naive-ui')['NForm']
|
||||
NFormItem: typeof import('naive-ui')['NFormItem']
|
||||
NIcon: typeof import('naive-ui')['NIcon']
|
||||
NImage: typeof import('naive-ui')['NImage']
|
||||
NImageGroup: typeof import('naive-ui')['NImageGroup']
|
||||
NInput: typeof import('naive-ui')['NInput']
|
||||
NInputNumber: typeof import('naive-ui')['NInputNumber']
|
||||
NInputGroup: typeof import('naive-ui')['NInputGroup']
|
||||
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']
|
||||
NP: typeof import('naive-ui')['NP']
|
||||
NPagination: typeof import('naive-ui')['NPagination']
|
||||
NProgress: typeof import('naive-ui')['NProgress']
|
||||
NQrCode: typeof import('naive-ui')['NQrCode']
|
||||
NSelect: typeof import('naive-ui')['NSelect']
|
||||
NSpace: typeof import('naive-ui')['NSpace']
|
||||
NSpin: typeof import('naive-ui')['NSpin']
|
||||
NSwitch: typeof import('naive-ui')['NSwitch']
|
||||
NTable: typeof import('naive-ui')['NTable']
|
||||
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']
|
||||
NUpload: typeof import('naive-ui')['NUpload']
|
||||
NUploadDragger: typeof import('naive-ui')['NUploadDragger']
|
||||
NVirtualList: typeof import('naive-ui')['NVirtualList']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
|
||||
@@ -12,6 +12,25 @@
|
||||
<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 dataItems"
|
||||
: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">
|
||||
@@ -82,8 +101,8 @@ const dashboardItem = ref({
|
||||
active: (route: any) => route.path === '/admin'
|
||||
})
|
||||
|
||||
// 运营管理分组
|
||||
const operationItems = ref([
|
||||
// 数据管理分组
|
||||
const dataItems = ref([
|
||||
{
|
||||
to: '/admin/resources',
|
||||
label: '资源管理',
|
||||
@@ -108,6 +127,16 @@ const operationItems = ref([
|
||||
icon: 'fas fa-tags',
|
||||
active: (route: any) => route.path.startsWith('/admin/tags')
|
||||
},
|
||||
{
|
||||
to: '/admin/files',
|
||||
label: '文件管理',
|
||||
icon: 'fas fa-file-upload',
|
||||
active: (route: any) => route.path.startsWith('/admin/files')
|
||||
}
|
||||
])
|
||||
|
||||
// 运营管理分组
|
||||
const operationItems = ref([
|
||||
{
|
||||
to: '/admin/platforms',
|
||||
label: '平台管理',
|
||||
@@ -126,6 +155,12 @@ const operationItems = ref([
|
||||
icon: 'fas fa-film',
|
||||
active: (route: any) => route.path.startsWith('/admin/hot-dramas')
|
||||
},
|
||||
{
|
||||
to: '/admin/data-transfer',
|
||||
label: '数据转存管理',
|
||||
icon: 'fas fa-exchange-alt',
|
||||
active: (route: any) => route.path.startsWith('/admin/data-transfer')
|
||||
},
|
||||
{
|
||||
to: '/admin/seo',
|
||||
label: 'SEO',
|
||||
@@ -175,6 +210,12 @@ const systemItems = ref([
|
||||
label: '系统配置',
|
||||
icon: 'fas fa-cog',
|
||||
active: (route: any) => route.path.startsWith('/admin/system-config')
|
||||
},
|
||||
{
|
||||
to: '/admin/version',
|
||||
label: '版本信息',
|
||||
icon: 'fas fa-code-branch',
|
||||
active: (route: any) => route.path.startsWith('/admin/version')
|
||||
}
|
||||
])
|
||||
</script>
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, h } from 'vue'
|
||||
import { useResourceApi } from '~/composables/useApi'
|
||||
import { useResourceApi, usePanApi } from '~/composables/useApi'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
// 消息提示
|
||||
@@ -76,6 +76,26 @@ const selectedTag = ref(null)
|
||||
|
||||
// API实例
|
||||
const resourceApi = useResourceApi()
|
||||
const panApi = usePanApi()
|
||||
|
||||
// 获取平台数据
|
||||
const { data: platformsData } = await useAsyncData('transferredPlatforms', () => panApi.getPans())
|
||||
|
||||
// 平台选项
|
||||
const platformOptions = computed(() => {
|
||||
const data = platformsData.value as any
|
||||
const platforms = data?.data || data || []
|
||||
return platforms.map((platform: any) => ({
|
||||
label: platform.remark || platform.name,
|
||||
value: platform.id
|
||||
}))
|
||||
})
|
||||
|
||||
// 获取平台名称
|
||||
const getPlatformName = (platformId: number) => {
|
||||
const platform = (platformsData.value as any)?.data?.find((plat: any) => plat.id === platformId)
|
||||
return platform?.remark || platform?.name || '未知平台'
|
||||
}
|
||||
|
||||
// 分页配置
|
||||
const pagination = reactive({
|
||||
@@ -109,18 +129,6 @@ const columns: any[] = [
|
||||
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',
|
||||
@@ -143,41 +151,9 @@ const columns: any[] = [
|
||||
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
|
||||
@@ -257,23 +233,6 @@ const handlePageSizeChange = (size: number) => {
|
||||
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()
|
||||
|
||||
@@ -135,10 +135,6 @@
|
||||
<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 }} 次浏览
|
||||
@@ -296,9 +292,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useResourceApi, useCategoryApi, useTagApi, useCksApi, useTaskApi } from '~/composables/useApi'
|
||||
import { useResourceApi, useCategoryApi, useTagApi, useCksApi, useTaskApi, usePanApi } from '~/composables/useApi'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
// 消息提示
|
||||
const $message = useMessage()
|
||||
|
||||
// 数据状态
|
||||
const loading = ref(false)
|
||||
const resources = ref([])
|
||||
@@ -339,7 +338,10 @@ const categoryApi = useCategoryApi()
|
||||
const tagApi = useTagApi()
|
||||
const cksApi = useCksApi()
|
||||
const taskApi = useTaskApi()
|
||||
const message = useMessage()
|
||||
const panApi = usePanApi()
|
||||
|
||||
// 获取平台数据
|
||||
const { data: platformsData } = await useAsyncData('untransferredPlatforms', () => panApi.getPans())
|
||||
|
||||
// 计算属性
|
||||
const isAllSelected = computed(() => {
|
||||
@@ -445,7 +447,7 @@ const getAccountOptions = async () => {
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('获取网盘账号选项失败:', error)
|
||||
message.error('获取网盘账号失败')
|
||||
$message.error('获取网盘账号失败')
|
||||
} finally {
|
||||
accountsLoading.value = false
|
||||
}
|
||||
@@ -516,7 +518,7 @@ const toggleResourceSelection = (id: number, checked: boolean) => {
|
||||
// 批量转存
|
||||
const handleBatchTransfer = async () => {
|
||||
if (selectedResources.value.length === 0) {
|
||||
message.warning('请选择要转存的资源')
|
||||
$message.warning('请选择要转存的资源')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -543,6 +545,16 @@ const getStatusText = (resource: any) => {
|
||||
return '待验证'
|
||||
}
|
||||
|
||||
// 获取平台名称
|
||||
const getPlatformName = (panId: number) => {
|
||||
if (!panId) return '未知平台'
|
||||
|
||||
// 从后端获取的平台数据
|
||||
const platforms = platformsData.value as any
|
||||
const platform = platforms?.data?.find((p: any) => p.id === panId)
|
||||
return platform?.remark || platform?.name || '未知平台'
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
@@ -551,7 +563,7 @@ const formatDate = (dateString: string) => {
|
||||
// 确认批量转存
|
||||
const confirmBatchTransfer = async () => {
|
||||
if (selectedAccounts.value.length === 0) {
|
||||
message.warning('请选择至少一个网盘账号')
|
||||
$message.warning('请选择至少一个网盘账号')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -572,7 +584,7 @@ const confirmBatchTransfer = async () => {
|
||||
}
|
||||
|
||||
const response = await taskApi.createBatchTransferTask(taskData) as any
|
||||
message.success(`批量转存任务已创建,共 ${selectedItems.length} 个资源`)
|
||||
$message.success(`批量转存任务已创建,共 ${selectedItems.length} 个资源`)
|
||||
|
||||
// 关闭模态框
|
||||
showAccountSelectionModal.value = false
|
||||
@@ -583,7 +595,7 @@ const confirmBatchTransfer = async () => {
|
||||
|
||||
} catch (error) {
|
||||
console.error('创建批量转存任务失败:', error)
|
||||
message.error('创建批量转存任务失败')
|
||||
$message.error('创建批量转存任务失败')
|
||||
} finally {
|
||||
batchTransferring.value = false
|
||||
}
|
||||
|
||||
1
web/components/FileSelector.vue
Normal file
1
web/components/FileSelector.vue
Normal file
@@ -0,0 +1 @@
|
||||
<template>
|
||||
367
web/components/FileUpload.vue
Normal file
367
web/components/FileUpload.vue
Normal file
@@ -0,0 +1,367 @@
|
||||
<template>
|
||||
<div class="file-upload-container">
|
||||
<n-upload
|
||||
multiple
|
||||
directory-dnd
|
||||
:custom-request="customRequest"
|
||||
:on-before-upload="handleBeforeUpload"
|
||||
:on-finish="handleUploadFinish"
|
||||
:on-error="handleUploadError"
|
||||
:on-remove="handleFileRemove"
|
||||
:max="5"
|
||||
ref="uploadRef"
|
||||
>
|
||||
<n-upload-dragger>
|
||||
<div style="margin-bottom: 12px">
|
||||
<i class="fas fa-cloud-upload-alt text-4xl text-gray-400"></i>
|
||||
</div>
|
||||
<n-text style="font-size: 16px">
|
||||
点击或者拖动文件到该区域来上传
|
||||
</n-text>
|
||||
<n-p depth="3" style="margin: 8px 0 0 0">
|
||||
支持极速上传,相同文件将直接返回已上传的文件信息
|
||||
</n-p>
|
||||
</n-upload-dragger>
|
||||
</n-upload>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useFileApi } from '~/composables/useFileApi'
|
||||
|
||||
interface FileItem {
|
||||
id: number
|
||||
original_name: string
|
||||
file_name: string
|
||||
file_path: string
|
||||
file_size: number
|
||||
file_type: string
|
||||
mime_type: string
|
||||
file_hash: string
|
||||
access_url: string
|
||||
user_id: number
|
||||
user: string
|
||||
status: string
|
||||
is_public: boolean
|
||||
is_deleted: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface UploadFileInfo {
|
||||
id: string
|
||||
name: string
|
||||
status: 'pending' | 'uploading' | 'finished' | 'error' | 'removed'
|
||||
url?: string
|
||||
file?: File
|
||||
}
|
||||
|
||||
const message = useMessage()
|
||||
const fileApi = useFileApi()
|
||||
|
||||
// 响应式数据
|
||||
const uploadRef = ref()
|
||||
const fileList = ref<FileItem[]>([])
|
||||
const isPublic = ref(true) // 默认公开
|
||||
const maxFiles = ref(10)
|
||||
const maxFileSize = ref(5 * 1024 * 1024) // 5MB
|
||||
const acceptTypes = ref('image/*')
|
||||
|
||||
// 添加状态标记:用于跟踪已上传的文件
|
||||
const uploadedFiles = ref<Map<string, boolean>>(new Map()) // 文件哈希 -> 是否已上传
|
||||
const uploadingFiles = ref<Set<string>>(new Set()) // 正在上传的文件哈希
|
||||
|
||||
// 计算文件SHA256哈希值
|
||||
const calculateFileHash = async (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const arrayBuffer = e.target?.result as ArrayBuffer
|
||||
crypto.subtle.digest('SHA-256', arrayBuffer).then(hashBuffer => {
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
resolve(hashHex)
|
||||
}).catch(reject)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
reader.onerror = reject
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
}
|
||||
|
||||
// 生成文件哈希值(基于文件名、大小和修改时间,用于前端去重)
|
||||
const generateFileHash = (file: File): string => {
|
||||
return `${file.name}_${file.size}_${file.lastModified}`
|
||||
}
|
||||
|
||||
// 检查文件是否已经上传过
|
||||
const isFileAlreadyUploaded = (file: File): boolean => {
|
||||
const fileHash = generateFileHash(file)
|
||||
return uploadedFiles.value.has(fileHash)
|
||||
}
|
||||
|
||||
// 检查文件是否正在上传
|
||||
const isFileUploading = (file: File): boolean => {
|
||||
const fileHash = generateFileHash(file)
|
||||
return uploadingFiles.value.has(fileHash)
|
||||
}
|
||||
|
||||
// 标记文件为已上传
|
||||
const markFileAsUploaded = (file: File) => {
|
||||
const fileHash = generateFileHash(file)
|
||||
uploadedFiles.value.set(fileHash, true)
|
||||
uploadingFiles.value.delete(fileHash)
|
||||
}
|
||||
|
||||
// 标记文件为正在上传
|
||||
const markFileAsUploading = (file: File) => {
|
||||
const fileHash = generateFileHash(file)
|
||||
uploadingFiles.value.add(fileHash)
|
||||
}
|
||||
|
||||
// 标记文件上传失败
|
||||
const markFileAsFailed = (file: File) => {
|
||||
const fileHash = generateFileHash(file)
|
||||
uploadingFiles.value.delete(fileHash)
|
||||
}
|
||||
|
||||
// 自定义上传请求
|
||||
const customRequest = async (options: any) => {
|
||||
const { file, onProgress, onSuccess, onError } = options
|
||||
|
||||
// 检查文件是否已经上传过
|
||||
if (isFileAlreadyUploaded(file.file)) {
|
||||
message.warning(`${file.name} 已经上传过了,跳过重复上传`)
|
||||
if (onSuccess) {
|
||||
onSuccess({ message: '文件已存在,跳过上传' })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 检查文件是否正在上传
|
||||
if (isFileUploading(file.file)) {
|
||||
message.warning(`${file.name} 正在上传中,请稍候`)
|
||||
return
|
||||
}
|
||||
|
||||
// 标记文件为正在上传
|
||||
markFileAsUploading(file.file)
|
||||
|
||||
console.log('开始上传文件:', file.name, file.file)
|
||||
|
||||
try {
|
||||
// 计算文件哈希值
|
||||
const fileHash = await calculateFileHash(file.file)
|
||||
console.log('文件哈希值:', fileHash)
|
||||
|
||||
// 创建FormData
|
||||
const formData = new FormData()
|
||||
formData.append('file', file.file)
|
||||
formData.append('is_public', isPublic.value.toString())
|
||||
formData.append('file_hash', fileHash)
|
||||
|
||||
// 调用统一的API接口
|
||||
const response = await fileApi.uploadFile(formData)
|
||||
|
||||
console.log('文件上传成功:', file.name, response)
|
||||
|
||||
// 标记文件为已上传
|
||||
markFileAsUploaded(file.file)
|
||||
|
||||
// 检查是否为重复文件
|
||||
if (response.data && response.data.is_duplicate) {
|
||||
message.success(`${file.name} 极速上传成功(文件已存在)`)
|
||||
} else {
|
||||
message.success(`${file.name} 上传成功`)
|
||||
}
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(response)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('文件上传失败:', file.name, error)
|
||||
// 标记文件上传失败
|
||||
markFileAsFailed(file.file)
|
||||
if (onError) {
|
||||
onError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 默认文件列表(从props传入)
|
||||
const defaultFileList = ref<UploadFileInfo[]>([])
|
||||
|
||||
// 方法
|
||||
const handleBeforeUpload = (data: { file: Required<UploadFileInfo> }) => {
|
||||
const { file } = data
|
||||
|
||||
// 检查文件是否已经上传过
|
||||
if (file.file && isFileAlreadyUploaded(file.file)) {
|
||||
//message.warning(`${file.name} 已经上传过了,请勿重复上传`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查文件是否正在上传
|
||||
if (file.file && isFileUploading(file.file)) {
|
||||
message.warning(`${file.name} 正在上传中,请稍候`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
if (file.file && file.file.size > maxFileSize.value) {
|
||||
message.error(`文件大小不能超过 ${formatFileSize(maxFileSize.value)}`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查文件类型
|
||||
if (file.file) {
|
||||
const fileName = file.file.name.toLowerCase()
|
||||
const acceptedTypes = acceptTypes.value.split(',')
|
||||
const isAccepted = acceptedTypes.some(type => {
|
||||
if (type === 'image/*') {
|
||||
return file.file!.type.startsWith('image/')
|
||||
}
|
||||
if (type.startsWith('.')) {
|
||||
return fileName.endsWith(type)
|
||||
}
|
||||
return file.file!.type === type
|
||||
})
|
||||
|
||||
if (!isAccepted) {
|
||||
message.error('只支持图片格式文件')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const handleUploadFinish = (data: { file: Required<UploadFileInfo> }) => {
|
||||
const { file } = data
|
||||
|
||||
if (file.status === 'finished') {
|
||||
// 确保文件被标记为已上传
|
||||
if (file.file) {
|
||||
markFileAsUploaded(file.file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadError = (data: { file: Required<UploadFileInfo> }) => {
|
||||
const { file } = data
|
||||
message.error(`${file.name} 上传失败`)
|
||||
// 标记文件上传失败
|
||||
if (file.file) {
|
||||
markFileAsFailed(file.file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileRemove = (data: { file: Required<UploadFileInfo> }) => {
|
||||
const { file } = data
|
||||
message.info(`已移除 ${file.name}`)
|
||||
// 从上传状态中移除文件
|
||||
if (file.file) {
|
||||
const fileHash = generateFileHash(file.file)
|
||||
uploadingFiles.value.delete(fileHash)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFileList = async () => {
|
||||
try {
|
||||
const response = await fileApi.getFileList({
|
||||
page: 1,
|
||||
page_size: 50
|
||||
})
|
||||
fileList.value = response.data.files || []
|
||||
} catch (error) {
|
||||
console.error('加载文件列表失败:', error)
|
||||
message.error('加载文件列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const copyFileUrl = async (file: FileItem) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(file.access_url)
|
||||
message.success('文件链接已复制到剪贴板')
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error)
|
||||
message.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
const openFile = (file: FileItem) => {
|
||||
window.open(file.access_url, '_blank')
|
||||
}
|
||||
|
||||
const deleteFile = async (file: FileItem) => {
|
||||
try {
|
||||
await fileApi.deleteFiles([file.id])
|
||||
message.success('文件删除成功')
|
||||
loadFileList()
|
||||
} catch (error) {
|
||||
console.error('删除文件失败:', error)
|
||||
message.error('删除文件失败')
|
||||
}
|
||||
}
|
||||
|
||||
const getFileIconClass = (fileType: string) => {
|
||||
const iconMap: Record<string, string> = {
|
||||
'image': 'fas fa-image text-blue-500',
|
||||
'document': 'fas fa-file-alt text-green-500',
|
||||
'video': 'fas fa-video text-red-500',
|
||||
'audio': 'fas fa-music text-purple-500',
|
||||
'archive': 'fas fa-archive text-orange-500',
|
||||
'other': 'fas fa-file text-gray-500'
|
||||
}
|
||||
return iconMap[fileType] || iconMap.other
|
||||
}
|
||||
|
||||
const getFileDescription = (file: FileItem) => {
|
||||
return `${formatFileSize(file.file_size)} | ${file.file_type} | ${file.created_at}`
|
||||
}
|
||||
|
||||
const formatFileSize = (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 resetUpload = () => {
|
||||
if (uploadRef.value) {
|
||||
uploadRef.value.clear()
|
||||
}
|
||||
// 清空上传状态
|
||||
uploadedFiles.value.clear()
|
||||
uploadingFiles.value.clear()
|
||||
}
|
||||
|
||||
// 清空已上传文件状态(用于重新开始上传)
|
||||
const clearUploadedFiles = () => {
|
||||
uploadedFiles.value.clear()
|
||||
uploadingFiles.value.clear()
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
loadFileList,
|
||||
fileList,
|
||||
resetUpload,
|
||||
clearUploadedFiles,
|
||||
uploadedFiles,
|
||||
uploadingFiles
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
22
web/components/ProxyImage.vue
Normal file
22
web/components/ProxyImage.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<n-image
|
||||
:src="proxyUrl"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useImageUrl } from '~/composables/useImageUrl'
|
||||
|
||||
interface Props {
|
||||
src: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { getImageUrl } = useImageUrl()
|
||||
|
||||
const proxyUrl = computed(() => {
|
||||
return getImageUrl(props.src)
|
||||
})
|
||||
</script>
|
||||
@@ -1,153 +1,146 @@
|
||||
<template>
|
||||
<div v-if="visible" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" @click="closeModal">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-sm w-full mx-4" @click.stop>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ isQuarkLink ? '夸克网盘链接' : '链接二维码' }}
|
||||
</h3>
|
||||
<button
|
||||
@click="closeModal"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
<n-modal :show="visible" @update:show="closeModal" preset="card" title="链接二维码" class="max-w-sm">
|
||||
<div class="text-center">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="space-y-4">
|
||||
<div class="flex flex-col items-center justify-center py-8">
|
||||
<n-spin size="large" />
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-4">正在获取链接...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<!-- 加载状态 -->
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- 正常显示 -->
|
||||
<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">
|
||||
<canvas ref="qrCanvas" class="mx-auto"></canvas>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<button
|
||||
@click="closeModal"
|
||||
class="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 mx-auto"
|
||||
>
|
||||
<i class="fas fa-check"></i> 确认
|
||||
</button>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">请使用手机扫码操作</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 其他链接:同时显示链接和二维码 -->
|
||||
<div v-else class="space-y-4">
|
||||
<div class="mb-4">
|
||||
<div class="bg-gray-100 dark:bg-gray-700 p-4 rounded-lg">
|
||||
<canvas ref="qrCanvas" class="mx-auto"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">扫描二维码访问链接</p>
|
||||
<div class="bg-gray-50 dark:bg-gray-700 p-3 rounded border">
|
||||
<p class="text-xs text-gray-700 dark:text-gray-300 break-all">{{ url }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="copyUrl"
|
||||
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-copy"></i>
|
||||
复制链接
|
||||
</button>
|
||||
<button
|
||||
@click="downloadQrCode"
|
||||
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-download"></i>
|
||||
下载二维码
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="error" class="space-y-4">
|
||||
<n-alert type="error" :show-icon="false">
|
||||
<template #icon>
|
||||
<i class="fas fa-exclamation-triangle text-red-500 mr-2"></i>
|
||||
</template>
|
||||
{{ error }}
|
||||
</n-alert>
|
||||
<n-card size="small">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 break-all">{{ url }}</p>
|
||||
</n-card>
|
||||
<div class="flex gap-2">
|
||||
<n-button type="primary" @click="openLink" class="flex-1">
|
||||
<template #icon>
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</template>
|
||||
跳转
|
||||
</n-button>
|
||||
<n-button type="success" @click="copyUrl" class="flex-1">
|
||||
<template #icon>
|
||||
<i class="fas fa-copy"></i>
|
||||
</template>
|
||||
复制
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 正常显示 -->
|
||||
<div v-else>
|
||||
<!-- 移动端:所有链接都显示链接文本和操作按钮 -->
|
||||
<div v-if="isMobile" class="space-y-4">
|
||||
<!-- 显示链接状态信息 -->
|
||||
<n-alert v-if="message" type="info" :show-icon="false">
|
||||
<template #icon>
|
||||
<i class="fas fa-info-circle text-blue-500 mr-2"></i>
|
||||
</template>
|
||||
{{ message }}
|
||||
</n-alert>
|
||||
|
||||
<n-card size="small">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 break-all">{{ url }}</p>
|
||||
</n-card>
|
||||
<div class="flex gap-2">
|
||||
<n-button type="primary" @click="openLink" class="flex-1">
|
||||
<template #icon>
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</template>
|
||||
跳转
|
||||
</n-button>
|
||||
<n-button type="success" @click="copyUrl" class="flex-1">
|
||||
<template #icon>
|
||||
<i class="fas fa-copy"></i>
|
||||
</template>
|
||||
复制
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PC端:根据链接类型显示不同内容 -->
|
||||
<div v-else class="space-y-4">
|
||||
<!-- 显示链接状态信息 -->
|
||||
<n-alert v-if="message" type="info" :show-icon="false">
|
||||
<template #icon>
|
||||
<i class="fas fa-info-circle text-blue-500 mr-2"></i>
|
||||
</template>
|
||||
{{ message }}
|
||||
</n-alert>
|
||||
|
||||
<!-- 夸克链接:只显示二维码 -->
|
||||
<div v-if="isQuarkLink" class="space-y-4">
|
||||
<div class=" flex justify-center">
|
||||
<div class="flex qr-container items-center justify-center w-full">
|
||||
<n-qr-code
|
||||
:value="save_url || url"
|
||||
:size="size"
|
||||
:color="color"
|
||||
:background-color="backgroundColor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<n-button type="primary" @click="closeModal">
|
||||
<template #icon>
|
||||
<i class="fas fa-check"></i>
|
||||
</template>
|
||||
确认
|
||||
</n-button>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">请使用手机扫码操作</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 其他链接:同时显示链接和二维码 -->
|
||||
<div v-else class="space-y-4">
|
||||
<div class="mb-4 flex justify-center">
|
||||
<div class="flex qr-container items-center justify-center w-full">
|
||||
<n-qr-code :value="save_url || url"
|
||||
:size="size"
|
||||
:color="color"
|
||||
:background-color="backgroundColor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">扫描二维码访问链接</p>
|
||||
<n-card size="small">
|
||||
<p class="text-xs text-gray-700 dark:text-gray-300 break-all">{{ url }}</p>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<n-button type="primary" @click="copyUrl" class="flex-1">
|
||||
<template #icon>
|
||||
<i class="fas fa-copy"></i>
|
||||
</template>
|
||||
复制链接
|
||||
</n-button>
|
||||
<n-button type="success" @click="downloadQrCode" class="flex-1">
|
||||
<template #icon>
|
||||
<i class="fas fa-download"></i>
|
||||
</template>
|
||||
下载二维码
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
save_url?: string
|
||||
@@ -163,12 +156,14 @@ interface Emits {
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
url: ''
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const qrCanvas = ref<HTMLCanvasElement>()
|
||||
const size = ref(180)
|
||||
const color = ref('#409eff')
|
||||
const backgroundColor = ref('#F5F5F5')
|
||||
|
||||
// 检测是否为移动设备
|
||||
const isMobile = ref(false)
|
||||
@@ -185,24 +180,6 @@ const isQuarkLink = computed(() => {
|
||||
return (props.url.includes('pan.quark.cn') || props.url.includes('quark.cn')) && !!props.save_url
|
||||
})
|
||||
|
||||
// 生成二维码
|
||||
const generateQrCode = async () => {
|
||||
if (!qrCanvas.value || !props.url) return
|
||||
|
||||
try {
|
||||
await QRCode.toCanvas(qrCanvas.value, props.save_url || props.url, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('生成二维码失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
const closeModal = () => {
|
||||
emit('close')
|
||||
@@ -237,44 +214,31 @@ const openLink = () => {
|
||||
|
||||
// 下载二维码
|
||||
const downloadQrCode = () => {
|
||||
if (!qrCanvas.value) return
|
||||
// 使用 Naive UI 的二维码组件,需要获取 DOM 元素
|
||||
const qrElement = document.querySelector('.n-qr-code canvas') as HTMLCanvasElement
|
||||
if (!qrElement) return
|
||||
|
||||
try {
|
||||
const link = document.createElement('a')
|
||||
link.download = 'qrcode.png'
|
||||
link.href = qrCanvas.value.toDataURL()
|
||||
link.href = qrElement.toDataURL()
|
||||
link.click()
|
||||
} catch (error) {
|
||||
console.error('下载失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听visible变化,生成二维码
|
||||
// 监听visible变化
|
||||
watch(() => props.visible, (newVisible) => {
|
||||
if (newVisible) {
|
||||
detectDevice()
|
||||
nextTick(() => {
|
||||
// PC端生成二维码(包括夸克链接)
|
||||
if (!isMobile.value) {
|
||||
generateQrCode()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 监听url变化,重新生成二维码
|
||||
watch(() => props.url, () => {
|
||||
if (props.visible && !isMobile.value) {
|
||||
nextTick(() => {
|
||||
generateQrCode()
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 可以添加一些动画效果 */
|
||||
.fixed {
|
||||
.n-modal {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@@ -286,4 +250,13 @@ watch(() => props.url, () => {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
height: 200px;
|
||||
width: 200px;
|
||||
background-color: #F5F5F5;
|
||||
}
|
||||
.n-qr-code {
|
||||
padding: 0 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -28,7 +28,7 @@ export const parseApiResponse = <T>(response: any): T => {
|
||||
if (response.success) {
|
||||
// 特殊处理登录接口,直接返回data部分(包含token和user)
|
||||
if (response.data && response.data.token && response.data.user) {
|
||||
console.log('parseApiResponse - 登录接口处理,返回data:', response.data)
|
||||
// console.log('parseApiResponse - 登录接口处理,返回data:', response.data)
|
||||
return response.data
|
||||
}
|
||||
// 特殊处理删除操作响应,直接返回data部分
|
||||
@@ -148,11 +148,31 @@ export const useStatsApi = () => {
|
||||
return { getStats }
|
||||
}
|
||||
|
||||
export const useSearchStatsApi = () => {
|
||||
const getSearchStats = (params?: any) => useApiFetch('/search-stats', { params }).then(parseApiResponse)
|
||||
const getHotKeywords = (params?: any) => useApiFetch('/search-stats/hot-keywords', { params }).then(parseApiResponse)
|
||||
const getDailyStats = (params?: any) => useApiFetch('/search-stats/daily', { params }).then(parseApiResponse)
|
||||
const getSearchTrend = (params?: any) => useApiFetch('/search-stats/trend', { params }).then(parseApiResponse)
|
||||
const getKeywordTrend = (keyword: string, params?: any) => useApiFetch(`/search-stats/keyword/${keyword}/trend`, { params }).then(parseApiResponse)
|
||||
const getSearchStatsSummary = () => useApiFetch('/search-stats/summary').then(parseApiResponse)
|
||||
const recordSearch = (data: { keyword: string }) => useApiFetch('/search-stats/record', { method: 'POST', body: data }).then(parseApiResponse)
|
||||
return {
|
||||
getSearchStats,
|
||||
getHotKeywords,
|
||||
getDailyStats,
|
||||
getSearchTrend,
|
||||
getKeywordTrend,
|
||||
getSearchStatsSummary,
|
||||
recordSearch
|
||||
}
|
||||
}
|
||||
|
||||
export const useSystemConfigApi = () => {
|
||||
const getSystemConfig = () => useApiFetch('/system/config').then(parseApiResponse)
|
||||
const updateSystemConfig = (data: any) => useApiFetch('/system/config', { method: 'POST', body: data }).then(parseApiResponse)
|
||||
const getConfigStatus = () => useApiFetch('/system/config/status').then(parseApiResponse)
|
||||
const toggleAutoProcess = (enabled: boolean) => useApiFetch('/system/config/toggle-auto-process', { method: 'POST', body: { auto_process_ready_resources: enabled } }).then(parseApiResponse)
|
||||
return { getSystemConfig, updateSystemConfig, toggleAutoProcess }
|
||||
return { getSystemConfig, updateSystemConfig, getConfigStatus, toggleAutoProcess }
|
||||
}
|
||||
|
||||
export const useHotDramaApi = () => {
|
||||
|
||||
@@ -22,11 +22,11 @@ export function useApiFetch<T = any>(
|
||||
...options,
|
||||
headers,
|
||||
onResponse({ response }) {
|
||||
console.log('API响应:', {
|
||||
status: response.status,
|
||||
data: response._data,
|
||||
url: url
|
||||
})
|
||||
// console.log('API响应:', {
|
||||
// status: response.status,
|
||||
// data: response._data,
|
||||
// url: url
|
||||
// })
|
||||
|
||||
// 处理401认证错误
|
||||
if (response.status === 401 ||
|
||||
|
||||
307
web/composables/useConfigChangeDetection.ts
Normal file
307
web/composables/useConfigChangeDetection.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
export interface ConfigChangeDetectionOptions {
|
||||
// 是否启用自动检测
|
||||
autoDetect?: boolean
|
||||
// 是否在控制台输出调试信息
|
||||
debug?: boolean
|
||||
// 自定义比较函数
|
||||
customCompare?: (key: string, currentValue: any, originalValue: any) => boolean
|
||||
// 配置项映射(前端字段名 -> 后端字段名)
|
||||
fieldMapping?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface ConfigSubmitOptions {
|
||||
// 是否只提交改动的字段
|
||||
onlyChanged?: boolean
|
||||
// 是否包含所有配置项(用于后端识别)
|
||||
includeAllFields?: boolean
|
||||
// 自定义提交数据转换
|
||||
transformSubmitData?: (data: any) => any
|
||||
}
|
||||
|
||||
export const useConfigChangeDetection = <T extends Record<string, any>>(
|
||||
options: ConfigChangeDetectionOptions = {}
|
||||
) => {
|
||||
const { autoDetect = true, debug = false, customCompare, fieldMapping = {} } = options
|
||||
|
||||
// 原始配置数据
|
||||
const originalConfig = ref<T>({} as T)
|
||||
|
||||
// 当前配置数据
|
||||
const currentConfig = ref<T>({} as T)
|
||||
|
||||
// 是否已初始化
|
||||
const isInitialized = ref(false)
|
||||
|
||||
/**
|
||||
* 设置原始配置数据
|
||||
*/
|
||||
const setOriginalConfig = (config: T) => {
|
||||
originalConfig.value = { ...config }
|
||||
currentConfig.value = { ...config }
|
||||
isInitialized.value = true
|
||||
|
||||
if (debug) {
|
||||
console.log('useConfigChangeDetection - 设置原始配置:', config)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新当前配置数据
|
||||
*/
|
||||
const updateCurrentConfig = (config: Partial<T>) => {
|
||||
currentConfig.value = { ...currentConfig.value, ...config }
|
||||
|
||||
if (debug) {
|
||||
console.log('useConfigChangeDetection - 更新当前配置:', config)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测配置改动
|
||||
*/
|
||||
const getChangedConfig = (): Partial<T> => {
|
||||
if (!isInitialized.value) {
|
||||
if (debug) {
|
||||
console.warn('useConfigChangeDetection - 配置未初始化')
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
const changedConfig: Partial<T> = {}
|
||||
|
||||
// 遍历所有配置项
|
||||
for (const key in currentConfig.value) {
|
||||
const currentValue = currentConfig.value[key]
|
||||
const originalValue = originalConfig.value[key]
|
||||
|
||||
// 使用自定义比较函数或默认比较
|
||||
const hasChanged = customCompare
|
||||
? customCompare(key, currentValue, originalValue)
|
||||
: currentValue !== originalValue
|
||||
|
||||
if (hasChanged) {
|
||||
changedConfig[key as keyof T] = currentValue
|
||||
}
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log('useConfigChangeDetection - 检测到的改动:', changedConfig)
|
||||
}
|
||||
|
||||
return changedConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有改动
|
||||
*/
|
||||
const hasChanges = (): boolean => {
|
||||
const changedConfig = getChangedConfig()
|
||||
return Object.keys(changedConfig).length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取改动的字段列表
|
||||
*/
|
||||
const getChangedFields = (): string[] => {
|
||||
const changedConfig = getChangedConfig()
|
||||
return Object.keys(changedConfig)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取改动的详细信息
|
||||
*/
|
||||
const getChangedDetails = (): Array<{
|
||||
key: string
|
||||
originalValue: any
|
||||
currentValue: any
|
||||
}> => {
|
||||
if (!isInitialized.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
const details: Array<{
|
||||
key: string
|
||||
originalValue: any
|
||||
currentValue: any
|
||||
}> = []
|
||||
|
||||
for (const key in currentConfig.value) {
|
||||
const currentValue = currentConfig.value[key]
|
||||
const originalValue = originalConfig.value[key]
|
||||
|
||||
const hasChanged = customCompare
|
||||
? customCompare(key, currentValue, originalValue)
|
||||
: currentValue !== originalValue
|
||||
|
||||
if (hasChanged) {
|
||||
details.push({
|
||||
key,
|
||||
originalValue,
|
||||
currentValue
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置为原始配置
|
||||
*/
|
||||
const resetToOriginal = () => {
|
||||
currentConfig.value = { ...originalConfig.value }
|
||||
|
||||
if (debug) {
|
||||
console.log('useConfigChangeDetection - 重置为原始配置')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新原始配置(通常在保存成功后调用)
|
||||
*/
|
||||
const updateOriginalConfig = () => {
|
||||
originalConfig.value = { ...currentConfig.value }
|
||||
|
||||
if (debug) {
|
||||
console.log('useConfigChangeDetection - 更新原始配置')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置快照
|
||||
*/
|
||||
const getSnapshot = () => {
|
||||
return {
|
||||
original: { ...originalConfig.value },
|
||||
current: { ...currentConfig.value },
|
||||
changed: getChangedConfig(),
|
||||
hasChanges: hasChanges()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备提交数据
|
||||
*/
|
||||
const prepareSubmitData = (submitOptions: ConfigSubmitOptions = {}): any => {
|
||||
const { onlyChanged = true, includeAllFields = true, transformSubmitData } = submitOptions
|
||||
|
||||
let submitData: any = {}
|
||||
|
||||
if (onlyChanged) {
|
||||
// 只提交改动的字段
|
||||
submitData = getChangedConfig()
|
||||
} else {
|
||||
// 提交所有字段
|
||||
submitData = { ...currentConfig.value }
|
||||
}
|
||||
|
||||
// 应用字段映射
|
||||
if (Object.keys(fieldMapping).length > 0) {
|
||||
const mappedData: any = {}
|
||||
for (const [frontendKey, backendKey] of Object.entries(fieldMapping)) {
|
||||
if (submitData[frontendKey] !== undefined) {
|
||||
mappedData[backendKey] = submitData[frontendKey]
|
||||
}
|
||||
}
|
||||
submitData = mappedData
|
||||
}
|
||||
|
||||
// 如果包含所有字段,添加未改动的字段(值为undefined,让后端知道这些字段存在但未改动)
|
||||
if (includeAllFields && onlyChanged) {
|
||||
for (const key in originalConfig.value) {
|
||||
if (submitData[key] === undefined) {
|
||||
submitData[key] = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 应用自定义转换
|
||||
if (transformSubmitData) {
|
||||
submitData = transformSubmitData(submitData)
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log('useConfigChangeDetection - 准备提交数据:', submitData)
|
||||
}
|
||||
|
||||
return submitData
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用配置保存函数
|
||||
*/
|
||||
const saveConfig = async (
|
||||
apiFunction: (data: any) => Promise<any>,
|
||||
submitOptions: ConfigSubmitOptions = {},
|
||||
onSuccess?: () => void,
|
||||
onError?: (error: any) => void
|
||||
) => {
|
||||
try {
|
||||
// 检测是否有改动
|
||||
if (!hasChanges()) {
|
||||
if (debug) {
|
||||
console.log('useConfigChangeDetection - 没有检测到改动,跳过保存')
|
||||
}
|
||||
return { success: true, message: '没有检测到任何改动' }
|
||||
}
|
||||
|
||||
// 准备提交数据
|
||||
const submitData = prepareSubmitData(submitOptions)
|
||||
|
||||
if (debug) {
|
||||
console.log('useConfigChangeDetection - 提交数据:', submitData)
|
||||
}
|
||||
|
||||
// 调用API
|
||||
const response = await apiFunction(submitData)
|
||||
|
||||
// 更新原始配置
|
||||
updateOriginalConfig()
|
||||
|
||||
if (debug) {
|
||||
console.log('useConfigChangeDetection - 保存成功')
|
||||
}
|
||||
|
||||
// 调用成功回调
|
||||
if (onSuccess) {
|
||||
onSuccess()
|
||||
}
|
||||
|
||||
return { success: true, response }
|
||||
} catch (error) {
|
||||
if (debug) {
|
||||
console.error('useConfigChangeDetection - 保存失败:', error)
|
||||
}
|
||||
|
||||
// 调用错误回调
|
||||
if (onError) {
|
||||
onError(error)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 响应式数据
|
||||
originalConfig: originalConfig as Ref<T>,
|
||||
currentConfig: currentConfig as Ref<T>,
|
||||
isInitialized,
|
||||
|
||||
// 方法
|
||||
setOriginalConfig,
|
||||
updateCurrentConfig,
|
||||
getChangedConfig,
|
||||
hasChanges,
|
||||
getChangedFields,
|
||||
getChangedDetails,
|
||||
resetToOriginal,
|
||||
updateOriginalConfig,
|
||||
getSnapshot,
|
||||
prepareSubmitData,
|
||||
saveConfig
|
||||
}
|
||||
}
|
||||
36
web/composables/useFileApi.ts
Normal file
36
web/composables/useFileApi.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useApiFetch } from './useApiFetch'
|
||||
|
||||
export const useFileApi = () => {
|
||||
const getFileList = (params?: any) => useApiFetch('/files', { params }).then(parseApiResponse)
|
||||
const uploadFile = (data: FormData) => useApiFetch('/files/upload', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
headers: {
|
||||
// 不设置Content-Type,让浏览器自动设置multipart/form-data
|
||||
}
|
||||
}).then(parseApiResponse)
|
||||
const deleteFiles = (ids: number[]) => useApiFetch('/files', {
|
||||
method: 'DELETE',
|
||||
body: { ids }
|
||||
}).then(parseApiResponse)
|
||||
const updateFile = (data: any) => useApiFetch('/files', {
|
||||
method: 'PUT',
|
||||
body: data
|
||||
}).then(parseApiResponse)
|
||||
|
||||
return {
|
||||
getFileList,
|
||||
uploadFile,
|
||||
deleteFiles,
|
||||
updateFile
|
||||
}
|
||||
}
|
||||
|
||||
// 解析API响应
|
||||
function parseApiResponse(response: any) {
|
||||
if (response.success) {
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || '请求失败')
|
||||
}
|
||||
}
|
||||
25
web/composables/useImageUrl.ts
Normal file
25
web/composables/useImageUrl.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export const useImageUrl = () => {
|
||||
const getImageUrl = (url: string) => {
|
||||
if (!url) return ''
|
||||
|
||||
// 如果已经是完整URL,直接返回
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url
|
||||
}
|
||||
|
||||
// 如果是相对路径,在开发环境中添加后端地址
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const fullUrl = `http://localhost:8080${url}`
|
||||
// console.log('useImageUrl - 开发环境:', { original: url, processed: fullUrl })
|
||||
return fullUrl
|
||||
}
|
||||
|
||||
// 生产环境中直接返回相对路径(通过Nginx代理)
|
||||
// console.log('useImageUrl - 生产环境:', { original: url, processed: url })
|
||||
return url
|
||||
}
|
||||
|
||||
return {
|
||||
getImageUrl
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ interface VersionResponse {
|
||||
|
||||
export const useVersion = () => {
|
||||
const versionInfo = ref<VersionInfo>({
|
||||
version: '1.1.0',
|
||||
version: '1.2.3',
|
||||
build_time: '',
|
||||
git_commit: 'unknown',
|
||||
git_branch: 'unknown',
|
||||
|
||||
@@ -26,7 +26,7 @@ export const adminNewNavigationItems = [
|
||||
icon: 'fas fa-database',
|
||||
to: '/admin/resources',
|
||||
active: (route: any) => route.path.startsWith('/admin/resources'),
|
||||
group: 'operation'
|
||||
group: 'data'
|
||||
},
|
||||
{
|
||||
key: 'ready-resources',
|
||||
@@ -34,7 +34,7 @@ export const adminNewNavigationItems = [
|
||||
icon: 'fas fa-clock',
|
||||
to: '/admin/ready-resources',
|
||||
active: (route: any) => route.path.startsWith('/admin/ready-resources'),
|
||||
group: 'operation'
|
||||
group: 'data'
|
||||
},
|
||||
{
|
||||
key: 'categories',
|
||||
@@ -42,7 +42,7 @@ export const adminNewNavigationItems = [
|
||||
icon: 'fas fa-folder',
|
||||
to: '/admin/categories',
|
||||
active: (route: any) => route.path.startsWith('/admin/categories'),
|
||||
group: 'operation'
|
||||
group: 'data'
|
||||
},
|
||||
{
|
||||
key: 'tags',
|
||||
@@ -50,7 +50,7 @@ export const adminNewNavigationItems = [
|
||||
icon: 'fas fa-tags',
|
||||
to: '/admin/tags',
|
||||
active: (route: any) => route.path.startsWith('/admin/tags'),
|
||||
group: 'operation'
|
||||
group: 'data'
|
||||
},
|
||||
{
|
||||
key: 'platforms',
|
||||
@@ -100,6 +100,14 @@ export const adminNewNavigationItems = [
|
||||
active: (route: any) => route.path.startsWith('/admin/data-push'),
|
||||
group: 'operation'
|
||||
},
|
||||
{
|
||||
key: 'files',
|
||||
label: '文件管理',
|
||||
icon: 'fas fa-file-upload',
|
||||
to: '/admin/files',
|
||||
active: (route: any) => route.path.startsWith('/admin/files'),
|
||||
group: 'data'
|
||||
},
|
||||
{
|
||||
key: 'bot',
|
||||
label: '机器人',
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
<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>
|
||||
@@ -464,6 +464,12 @@ const dataManagementItems = ref([
|
||||
label: '平台账号',
|
||||
icon: 'fas fa-user-shield',
|
||||
active: (route: any) => route.path.startsWith('/admin/accounts')
|
||||
},
|
||||
{
|
||||
to: '/admin/files',
|
||||
label: '文件管理',
|
||||
icon: 'fas fa-file-upload',
|
||||
active: (route: any) => route.path.startsWith('/admin/files')
|
||||
}
|
||||
])
|
||||
|
||||
@@ -544,7 +550,7 @@ 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')) {
|
||||
if (currentPath.startsWith('/admin/resources') || currentPath.startsWith('/admin/ready-resources') || currentPath.startsWith('/admin/tags') || currentPath.startsWith('/admin/categories') || currentPath.startsWith('/admin/accounts') || currentPath.startsWith('/admin/files')) {
|
||||
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
|
||||
@@ -566,7 +572,7 @@ watch(() => useRoute().path, (newPath) => {
|
||||
}
|
||||
|
||||
// 根据新路径展开对应分组
|
||||
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')) {
|
||||
if (newPath.startsWith('/admin/resources') || newPath.startsWith('/admin/ready-resources') || newPath.startsWith('/admin/tags') || newPath.startsWith('/admin/categories') || newPath.startsWith('/admin/accounts') || newPath.startsWith('/admin/files')) {
|
||||
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
|
||||
|
||||
@@ -27,6 +27,16 @@ export default defineNuxtConfig({
|
||||
optimizeDeps: {
|
||||
include: ['vueuc', 'date-fns'],
|
||||
exclude: ["oxc-parser"] // 强制使用 WASM 版本
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/uploads': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (path) => path
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modules: ['@nuxtjs/tailwindcss', '@pinia/nuxt'],
|
||||
@@ -62,7 +72,13 @@ export default defineNuxtConfig({
|
||||
},
|
||||
ssr: true,
|
||||
nitro: {
|
||||
logLevel: 'verbose',
|
||||
preset: 'node-server'
|
||||
logLevel: 'info',
|
||||
preset: 'node-server',
|
||||
storage: {
|
||||
redis: {
|
||||
driver: 'memory',
|
||||
max: 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "res-db-web",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -28,12 +28,10 @@
|
||||
"@juggle/resize-observer": "^3.3.1",
|
||||
"@nuxtjs/tailwindcss": "^6.8.0",
|
||||
"@pinia/nuxt": "^0.5.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@vicons/ionicons5": "^0.12.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"naive-ui": "^2.37.0",
|
||||
"pinia": "^2.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"vfonts": "^0.0.3",
|
||||
"vue": "^3.3.0",
|
||||
"vue-router": "^4.2.0"
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
<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>
|
||||
@@ -1,433 +0,0 @@
|
||||
<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>
|
||||
@@ -1,655 +0,0 @@
|
||||
<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-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>
|
||||
<n-alert class="mb-4" title="平台账号管理当前只支持夸克" type="warning" />
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div class="flex gap-2">
|
||||
<n-button
|
||||
@click="showCreateModal = true"
|
||||
type="success"
|
||||
>
|
||||
<i class="fas fa-plus"></i> 添加账号
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative w-40">
|
||||
<n-select v-model:value="platform" :options="platformOptions" @update:value="onPlatformChange" />
|
||||
</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>
|
||||
<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="9" class="text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>加载中...
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else-if="filteredCksList.length === 0" class="text-center py-8">
|
||||
<td colspan="9" 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="showCreateModal = true"
|
||||
type="primary"
|
||||
>
|
||||
<i class="fas fa-plus"></i> 添加账号
|
||||
</n-button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="cks in filteredCksList"
|
||||
:key="cks.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">{{ cks.id }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||
<div class="flex items-center">
|
||||
<span v-html="getPlatformIcon(cks.pan?.name || '')" class="mr-2"></span>
|
||||
{{ cks.pan?.name || '未知平台' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||
<span v-if="cks.username" :title="cks.username">{{ cks.username }}</span>
|
||||
<span v-else class="text-gray-400 dark:text-gray-500 italic">未知用户</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span :class="cks.is_valid ? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-200' : 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-200'"
|
||||
class="px-2 py-1 text-xs font-medium rounded-full">
|
||||
{{ cks.is_valid ? '有效' : '无效' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ formatFileSize(cks.space) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ formatFileSize(Math.max(0, cks.used_space || (cks.space - cks.left_space))) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ formatFileSize(Math.max(0, cks.left_space)) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span v-if="cks.remark" :title="cks.remark">{{ cks.remark }}</span>
|
||||
<span v-else class="text-gray-400 dark:text-gray-500 italic">无备注</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="toggleStatus(cks)"
|
||||
:class="cks.is_valid ? 'text-orange-600 hover:text-orange-800 dark:text-orange-400 dark:hover:text-orange-300' : 'text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300'"
|
||||
class="transition-colors"
|
||||
:title="cks.is_valid ? '禁用账号' : '启用账号'"
|
||||
>
|
||||
<i :class="cks.is_valid ? 'fas fa-ban' : 'fas fa-check'"></i>
|
||||
</button>
|
||||
<button
|
||||
@click="refreshCapacity(cks.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-sync-alt"></i>
|
||||
</button>
|
||||
<button
|
||||
@click="editCks(cks)"
|
||||
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
|
||||
title="编辑账号"
|
||||
>
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button
|
||||
@click="deleteCks(cks.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="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">{{ filteredCksList.length }}</span> 个账号
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建/编辑账号模态框 -->
|
||||
<div v-if="showCreateModal || showEditModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white dark:bg-gray-800">
|
||||
<div class="mt-3">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
{{ showEditModal ? '编辑账号' : '添加账号' }}
|
||||
</h3>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">平台类型 <span class="text-red-500">*</span></label>
|
||||
<select
|
||||
v-model="form.pan_id"
|
||||
required
|
||||
:disabled="showEditModal"
|
||||
:class="showEditModal ? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed' : ''"
|
||||
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.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>
|
||||
</div>
|
||||
<div v-if="showEditModal && editingCks?.username">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">用户名</label>
|
||||
<div class="mt-1 px-3 py-2 bg-gray-100 dark:bg-gray-600 rounded-md text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ editingCks.username }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Cookie <span class="text-red-500">*</span></label>
|
||||
<n-input
|
||||
v-model:value="form.ck"
|
||||
required
|
||||
type="textarea"
|
||||
placeholder="请输入Cookie内容,系统将自动识别容量"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">备注</label>
|
||||
<n-input
|
||||
v-model:value="form.remark"
|
||||
type="text"
|
||||
placeholder="可选,备注信息"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showEditModal">
|
||||
<label class="flex items-center">
|
||||
<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">
|
||||
<n-button
|
||||
type="tertiary"
|
||||
@click="closeModal"
|
||||
>
|
||||
取消
|
||||
</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="submitting"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ submitting ? '处理中...' : (showEditModal ? '更新' : '创建') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin-old',
|
||||
ssr: false
|
||||
})
|
||||
|
||||
const notification = useNotification()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const cksList = ref([])
|
||||
const platforms = ref([])
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const editingCks = ref(null)
|
||||
const form = ref({
|
||||
pan_id: '',
|
||||
ck: '',
|
||||
is_valid: true,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
// 搜索和分页逻辑
|
||||
const searchQuery = ref('')
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
const totalPages = ref(1)
|
||||
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()
|
||||
const panApi = usePanApi()
|
||||
|
||||
const { data: pansData } = await useAsyncData('pans', () => panApi.getPans())
|
||||
const pans = computed(() => {
|
||||
// 统一接口格式后直接为数组
|
||||
return Array.isArray(pansData.value) ? pansData.value : (pansData.value?.list || [])
|
||||
})
|
||||
const platformOptions = computed(() => {
|
||||
const options = [
|
||||
{ label: '全部平台', value: null }
|
||||
]
|
||||
|
||||
pans.value.forEach(pan => {
|
||||
options.push({
|
||||
label: pan.remark || pan.name || `平台${pan.id}`,
|
||||
value: pan.id
|
||||
})
|
||||
})
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
// 检查认证
|
||||
const checkAuth = () => {
|
||||
userStore.initAuth()
|
||||
if (!userStore.isAuthenticated) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 获取账号列表
|
||||
const fetchCks = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
console.log('开始获取账号列表...')
|
||||
const response = await cksApi.getCks()
|
||||
cksList.value = Array.isArray(response) ? response : []
|
||||
console.log('获取账号列表成功,数据:', cksList.value)
|
||||
} catch (error) {
|
||||
console.error('获取账号列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取平台列表
|
||||
const fetchPlatforms = async () => {
|
||||
try {
|
||||
const response = await panApi.getPans()
|
||||
platforms.value = Array.isArray(response) ? response : []
|
||||
} catch (error) {
|
||||
console.error('获取平台列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建账号
|
||||
const createCks = async () => {
|
||||
submitting.value = true
|
||||
try {
|
||||
await cksApi.createCks(form.value)
|
||||
await fetchCks()
|
||||
closeModal()
|
||||
} catch (error) {
|
||||
dialog.error({
|
||||
title: '错误',
|
||||
content: '创建账号失败: ' + (error.message || '未知错误'),
|
||||
positiveText: '确定'
|
||||
})
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 更新账号
|
||||
const updateCks = async () => {
|
||||
submitting.value = true
|
||||
try {
|
||||
await cksApi.updateCks(editingCks.value.id, form.value)
|
||||
await fetchCks()
|
||||
closeModal()
|
||||
} catch (error) {
|
||||
console.error('更新账号失败:', error)
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: '更新账号失败: ' + (error.message || '未知错误'),
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除账号
|
||||
const deleteCks = async (id) => {
|
||||
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) => {
|
||||
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
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 编辑账号
|
||||
const editCks = (cks) => {
|
||||
editingCks.value = cks
|
||||
form.value = {
|
||||
pan_id: cks.pan_id,
|
||||
ck: cks.ck,
|
||||
is_valid: cks.is_valid,
|
||||
remark: cks.remark || ''
|
||||
}
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
const closeModal = () => {
|
||||
showCreateModal.value = false
|
||||
showEditModal.value = false
|
||||
editingCks.value = null
|
||||
form.value = {
|
||||
pan_id: '',
|
||||
ck: '',
|
||||
is_valid: true,
|
||||
remark: ''
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (showEditModal.value) {
|
||||
await updateCks()
|
||||
} else {
|
||||
await createCks()
|
||||
}
|
||||
}
|
||||
|
||||
// 获取平台图标
|
||||
const getPlatformIcon = (platformName) => {
|
||||
const defaultIcons = {
|
||||
'unknown': '<i class="fas fa-question-circle text-gray-400"></i>',
|
||||
'other': '<i class="fas fa-cloud text-gray-500"></i>',
|
||||
'magnet': '<i class="fas fa-magnet text-red-600"></i>',
|
||||
'uc': '<i class="fas fa-cloud-download-alt text-purple-600"></i>',
|
||||
'夸克网盘': '<i class="fas fa-cloud text-blue-600"></i>',
|
||||
'阿里云盘': '<i class="fas fa-cloud text-orange-600"></i>',
|
||||
'百度网盘': '<i class="fas fa-cloud text-blue-500"></i>',
|
||||
'天翼云盘': '<i class="fas fa-cloud text-red-500"></i>',
|
||||
'OneDrive': '<i class="fas fa-cloud text-blue-700"></i>',
|
||||
'Google Drive': '<i class="fas fa-cloud text-green-600"></i>'
|
||||
}
|
||||
|
||||
return defaultIcons[platformName] || defaultIcons['unknown']
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes || bytes <= 0) return '0 B'
|
||||
|
||||
const tb = bytes / (1024 * 1024 * 1024 * 1024)
|
||||
if (tb >= 1) {
|
||||
return tb.toFixed(2) + ' TB'
|
||||
}
|
||||
|
||||
const gb = bytes / (1024 * 1024 * 1024)
|
||||
if (gb >= 1) {
|
||||
return gb.toFixed(2) + ' GB'
|
||||
}
|
||||
|
||||
const mb = bytes / (1024 * 1024)
|
||||
if (mb >= 1) {
|
||||
return mb.toFixed(2) + ' MB'
|
||||
}
|
||||
|
||||
const kb = bytes / 1024
|
||||
if (kb >= 1) {
|
||||
return kb.toFixed(2) + ' KB'
|
||||
}
|
||||
|
||||
return bytes + ' B'
|
||||
}
|
||||
|
||||
// 过滤和分页计算
|
||||
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
|
||||
return filtered.slice(start, end)
|
||||
})
|
||||
|
||||
// 防抖搜索
|
||||
let searchTimeout = null
|
||||
const debounceSearch = () => {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 平台变化处理
|
||||
const onPlatformChange = () => {
|
||||
currentPage.value = 1
|
||||
console.log('平台过滤条件变化:', platform.value)
|
||||
console.log('当前过滤后的账号数量:', filteredCksList.value.length)
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
const refreshData = () => {
|
||||
currentPage.value = 1
|
||||
// 保持当前的过滤条件,只刷新数据
|
||||
fetchCks()
|
||||
fetchPlatforms()
|
||||
}
|
||||
|
||||
// 分页跳转
|
||||
const goToPage = (page) => {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
// 页面加载
|
||||
onMounted(async () => {
|
||||
try {
|
||||
checkAuth()
|
||||
await Promise.all([
|
||||
fetchCks(),
|
||||
fetchPlatforms()
|
||||
])
|
||||
} catch (error) {
|
||||
console.error('页面初始化失败:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,733 +0,0 @@
|
||||
<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>
|
||||
@@ -1,307 +0,0 @@
|
||||
<template>
|
||||
<!-- 管理功能区域 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
<!-- 资源管理 -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="p-3 bg-blue-100 rounded-lg">
|
||||
<i class="fas fa-cloud text-blue-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<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">管理所有资源</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<NuxtLink to="/admin-old/resources" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors block">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">查看所有资源</span>
|
||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin-old/add-resource" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors block">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">批量添加资源</span>
|
||||
<i class="fas fa-plus text-gray-400"></i>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 平台管理 -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="p-3 bg-green-100 rounded-lg">
|
||||
<i class="fas fa-server text-green-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<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">系统支持的网盘平台</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex flex-wrap gap-1 w-full text-left rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer">
|
||||
<div v-for="pan in pans" :key="pan.id" class="h-6 px-1 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span v-html="pan.icon"></span> {{ pan.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第三方平台账号管理 -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="p-3 bg-teal-100 rounded-lg">
|
||||
<i class="fas fa-key text-teal-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<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">管理第三方平台账号</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<NuxtLink to="/admin-old/cks" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors block">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">管理账号</span>
|
||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin-old/cks" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors block">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">添加账号</span>
|
||||
<i class="fas fa-plus text-gray-400"></i>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类管理 -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="p-3 bg-purple-100 rounded-lg">
|
||||
<i class="fas fa-folder text-purple-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<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">管理资源分类</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<button @click="goToCategoryManagement" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">管理分类</span>
|
||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
||||
</div>
|
||||
</button>
|
||||
<button @click="goToAddCategory" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">添加分类</span>
|
||||
<i class="fas fa-plus text-gray-400"></i>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签管理 -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="p-3 bg-orange-100 rounded-lg">
|
||||
<i class="fas fa-tags text-orange-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<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">管理资源标签</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<button @click="goToTagManagement" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">管理标签</span>
|
||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
||||
</div>
|
||||
</button>
|
||||
<button @click="goToAddTag" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">添加标签</span>
|
||||
<i class="fas fa-plus text-gray-400"></i>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="p-3 bg-red-100 rounded-lg">
|
||||
<i class="fas fa-chart-bar text-red-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<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">系统统计数据</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<NuxtLink to="/admin-old/search-stats" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors block">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">搜索统计</span>
|
||||
<i class="fas fa-chart-line text-gray-400"></i>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">总资源数</span>
|
||||
<span class="text-lg font-semibold text-gray-900 dark:text-gray-100">{{ stats?.total_resources || 0 }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">总浏览量</span>
|
||||
<span class="text-lg font-semibold text-gray-900 dark:text-gray-100">{{ stats?.total_views || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="p-3 bg-yellow-100 rounded-lg">
|
||||
<i class="fas fa-clock text-yellow-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<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">批量添加和管理</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<NuxtLink to="/admin-old/ready-resources" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors block">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">管理待处理资源</span>
|
||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin-old/failed-resources" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors block">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">失败列表</span>
|
||||
<i class="fas fa-exclamation-triangle text-red-400"></i>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统配置 -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="p-3 bg-indigo-100 rounded-lg">
|
||||
<i class="fas fa-cog text-indigo-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<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">系统参数设置</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<NuxtLink to="/admin-old/users" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors block">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">用户管理</span>
|
||||
<i class="fas fa-users text-gray-400"></i>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin-old/system-config" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors block">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">系统设置</span>
|
||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 版本信息 -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="p-3 bg-green-100 rounded-lg">
|
||||
<i class="fas fa-code-branch text-green-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<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">系统版本和文档</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<NuxtLink to="/admin-old/version" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors block">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">版本信息</span>
|
||||
<i class="fas fa-code-branch text-gray-400"></i>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 设置页面布局
|
||||
definePageMeta({
|
||||
layout: 'admin-old',
|
||||
ssr: false
|
||||
})
|
||||
|
||||
// 用户状态管理
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 统计数据
|
||||
import { useStatsApi, usePanApi } from '~/composables/useApi'
|
||||
|
||||
const statsApi = useStatsApi()
|
||||
const panApi = usePanApi()
|
||||
|
||||
const { data: statsData, error: statsError } = await useAsyncData('stats', () => statsApi.getStats())
|
||||
const stats = computed(() => (statsData.value as any) || {})
|
||||
|
||||
// 平台数据
|
||||
const { data: pansData, error: pansError } = await useAsyncData('pans', () => panApi.getPans())
|
||||
const pans = computed(() => (pansData.value as any) || [])
|
||||
|
||||
// 错误处理
|
||||
const notification = useNotification()
|
||||
|
||||
// 监听错误
|
||||
watch(statsError, (error) => {
|
||||
if (error) {
|
||||
console.error('获取统计数据失败:', error)
|
||||
notification.error({
|
||||
content: error.message || '获取统计数据失败',
|
||||
duration: 5000
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
watch(pansError, (error) => {
|
||||
if (error) {
|
||||
console.error('获取平台数据失败:', error)
|
||||
notification.error({
|
||||
content: error.message || '获取平台数据失败',
|
||||
duration: 5000
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 分类管理相关
|
||||
const goToCategoryManagement = () => {
|
||||
navigateTo('/admin-old/categories')
|
||||
}
|
||||
|
||||
const goToAddCategory = () => {
|
||||
navigateTo('/admin-old/categories')
|
||||
}
|
||||
|
||||
// 标签管理相关
|
||||
const goToTagManagement = () => {
|
||||
navigateTo('/admin-old/tags')
|
||||
}
|
||||
|
||||
const goToAddTag = () => {
|
||||
navigateTo('/admin-old/tags')
|
||||
}
|
||||
|
||||
// 权限检查已在 admin 布局中处理
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 可以添加自定义样式 */
|
||||
</style>
|
||||
@@ -1,587 +0,0 @@
|
||||
<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-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="max-w-7xl mx-auto">
|
||||
|
||||
<!-- 自动处理配置状态 -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<i class="fas fa-cog text-gray-600 dark:text-gray-400"></i>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">自动处理配置:</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div
|
||||
:class="[
|
||||
'w-3 h-3 rounded-full',
|
||||
systemConfig?.auto_process_ready_resources
|
||||
? 'bg-green-500 animate-pulse'
|
||||
: 'bg-red-500'
|
||||
]"
|
||||
></div>
|
||||
<span
|
||||
:class="[
|
||||
'text-sm font-medium',
|
||||
systemConfig?.auto_process_ready_resources
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
]"
|
||||
>
|
||||
{{ systemConfig?.auto_process_ready_resources ? '已开启' : '已关闭' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
{{ systemConfig?.auto_process_ready_resources
|
||||
? '系统会自动处理待处理资源并入库'
|
||||
: '需要手动处理待处理资源'
|
||||
}}
|
||||
</div>
|
||||
<button
|
||||
@click="refreshConfig"
|
||||
:disabled="updatingConfig"
|
||||
class="px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 rounded-md transition-colors"
|
||||
title="刷新配置"
|
||||
>
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
<button
|
||||
@click="toggleAutoProcess"
|
||||
:disabled="updatingConfig"
|
||||
:class="[
|
||||
'px-3 py-1 text-xs rounded-md transition-colors flex items-center gap-1',
|
||||
systemConfig?.auto_process_ready_resources
|
||||
? 'bg-red-100 hover:bg-red-200 text-red-700 dark:bg-red-900/20 dark:text-red-400'
|
||||
: 'bg-green-100 hover:bg-green-200 text-green-700 dark:bg-green-900/20 dark:text-green-400'
|
||||
]"
|
||||
>
|
||||
<i v-if="updatingConfig" class="fas fa-spinner fa-spin"></i>
|
||||
<i v-else :class="systemConfig?.auto_process_ready_resources ? 'fas fa-pause' : 'fas fa-play'"></i>
|
||||
{{ systemConfig?.auto_process_ready_resources ? '关闭' : '开启' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div class="flex gap-2">
|
||||
<NuxtLink
|
||||
to="/admin-old/failed-resources"
|
||||
class="w-full sm:w-auto px-4 py-2 bg-red-600 hover:bg-red-700 rounded-md transition-colors text-center flex items-center justify-center gap-2"
|
||||
>
|
||||
<i class="fas fa-plus"></i> 错误资源
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/admin-old/add-resource"
|
||||
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"
|
||||
>
|
||||
<i class="fas fa-plus"></i> 添加资源
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<n-button
|
||||
@click="refreshData"
|
||||
type="tertiary"
|
||||
>
|
||||
<i class="fas fa-refresh"></i> 刷新
|
||||
</n-button>
|
||||
<n-button
|
||||
@click="clearAll"
|
||||
type="error"
|
||||
>
|
||||
<i class="fas fa-trash"></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">
|
||||
<thead class="bg-slate-800 dark:bg-gray-700 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">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">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="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="readyResources.length === 0">
|
||||
<td colspan="6">
|
||||
<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>
|
||||
<div class="flex gap-2">
|
||||
<NuxtLink
|
||||
to="/admin-old/add-resource"
|
||||
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"
|
||||
>
|
||||
<i class="fas fa-plus"></i> 添加资源
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="resource in readyResources"
|
||||
:key="resource.id"
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<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 text-gray-900 dark:text-gray-100">
|
||||
<span v-if="resource.title" :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 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">
|
||||
<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>
|
||||
</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> 个待处理资源
|
||||
</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-blue-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> 个待处理资源
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 设置页面布局
|
||||
definePageMeta({
|
||||
layout: 'admin-old',
|
||||
ssr: false
|
||||
})
|
||||
|
||||
interface ReadyResource {
|
||||
id: number
|
||||
title?: string
|
||||
url: string
|
||||
create_time: string
|
||||
ip?: string
|
||||
}
|
||||
|
||||
const notification = useNotification()
|
||||
const readyResources = ref<ReadyResource[]>([])
|
||||
const loading = ref(false)
|
||||
const pageLoading = ref(true) // 添加页面加载状态
|
||||
|
||||
// 分页相关状态
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(100)
|
||||
const totalCount = ref(0)
|
||||
const totalPages = ref(0)
|
||||
|
||||
// 获取待处理资源API
|
||||
import { useReadyResourceApi, useSystemConfigApi } from '~/composables/useApi'
|
||||
import { useSystemConfigStore } from '~/stores/systemConfig'
|
||||
const readyResourceApi = useReadyResourceApi()
|
||||
const systemConfigApi = useSystemConfigApi()
|
||||
const systemConfigStore = useSystemConfigStore()
|
||||
|
||||
// 获取系统配置
|
||||
const systemConfig = ref<any>(null)
|
||||
const updatingConfig = ref(false) // 添加配置更新状态
|
||||
const dialog = useDialog()
|
||||
const fetchSystemConfig = async () => {
|
||||
try {
|
||||
const response = await systemConfigApi.getSystemConfig()
|
||||
systemConfig.value = response
|
||||
// 同时更新 Pinia store
|
||||
systemConfigStore.setConfig(response)
|
||||
} catch (error) {
|
||||
console.error('获取系统配置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await readyResourceApi.getReadyResources({
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value
|
||||
}) as any
|
||||
|
||||
// 适配后端API响应格式
|
||||
if (response && response.data) {
|
||||
readyResources.value = response.data
|
||||
// 后端返回格式: {data: [...], page: 1, page_size: 100, total: 123}
|
||||
totalCount.value = response.total || 0
|
||||
totalPages.value = Math.ceil((response.total || 0) / pageSize.value)
|
||||
} else if (Array.isArray(response)) {
|
||||
// 如果直接返回数组
|
||||
readyResources.value = response
|
||||
totalCount.value = response.length
|
||||
totalPages.value = 1
|
||||
} else {
|
||||
readyResources.value = []
|
||||
totalCount.value = 0
|
||||
totalPages.value = 1
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取待处理资源失败:', error)
|
||||
readyResources.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 refreshData = () => {
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 刷新配置
|
||||
const refreshConfig = () => {
|
||||
fetchSystemConfig()
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// 删除资源
|
||||
const deleteResource = async (id: number) => {
|
||||
dialog.warning({
|
||||
title: '警告',
|
||||
content: '确定要删除这个待处理资源吗?',
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
draggable: true,
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await readyResourceApi.deleteReadyResource(id)
|
||||
// 如果当前页没有数据了,回到上一页
|
||||
if (readyResources.value.length === 1 && currentPage.value > 1) {
|
||||
currentPage.value--
|
||||
}
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: '删除失败',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 清空全部
|
||||
const clearAll = async () => {
|
||||
dialog.warning({
|
||||
title: '警告',
|
||||
content: '确定要清空所有待处理资源吗?此操作不可恢复!',
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
draggable: true,
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
const response = await readyResourceApi.clearReadyResources() as any
|
||||
console.log('清空成功:', response)
|
||||
currentPage.value = 1 // 清空后回到第一页
|
||||
fetchData()
|
||||
notification.success({
|
||||
title: '成功',
|
||||
content: `成功清空 ${response.data.deleted_count} 个资源`,
|
||||
duration: 3000
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('清空失败:', error)
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: '清空失败',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
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)
|
||||
// 只允许http和https协议
|
||||
if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') {
|
||||
return '#'
|
||||
}
|
||||
return url
|
||||
} catch {
|
||||
return '#'
|
||||
}
|
||||
}
|
||||
|
||||
// 切换自动处理配置
|
||||
const toggleAutoProcess = async () => {
|
||||
if (updatingConfig.value) {
|
||||
return
|
||||
}
|
||||
updatingConfig.value = true
|
||||
try {
|
||||
const newValue = !systemConfig.value?.auto_process_ready_resources
|
||||
console.log('切换自动处理配置:', newValue)
|
||||
|
||||
// 使用专门的切换API
|
||||
const response = await systemConfigApi.toggleAutoProcess(newValue)
|
||||
console.log('切换响应:', response)
|
||||
|
||||
// 更新本地配置状态
|
||||
systemConfig.value = response
|
||||
|
||||
// 同时更新 Pinia store 中的系统配置
|
||||
systemConfigStore.setConfig(response)
|
||||
|
||||
notification.success({
|
||||
title: '成功',
|
||||
content: `自动处理配置已${newValue ? '开启' : '关闭'}`,
|
||||
duration: 3000
|
||||
})
|
||||
} catch (error: any) {
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: `切换自动处理配置失败`,
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
updatingConfig.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时获取数据
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await fetchData()
|
||||
await fetchSystemConfig()
|
||||
} 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(59, 130, 246, 0.3), 0 2px 4px -1px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
/* 表格行悬停效果 */
|
||||
tbody tr:hover {
|
||||
background-color: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
/* 暗黑模式下的表格行悬停 */
|
||||
.dark tbody tr:hover {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* 统计信息卡片效果 */
|
||||
.stats-card {
|
||||
backdrop-filter: blur(10px);
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.dark .stats-card {
|
||||
background-color: rgba(31, 41, 55, 0.9);
|
||||
}
|
||||
</style>
|
||||
@@ -1,828 +0,0 @@
|
||||
<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="max-w-7xl mx-auto">
|
||||
|
||||
<!-- 搜索和筛选区域 -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<!-- 搜索框 -->
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">搜索资源</label>
|
||||
<div class="relative">
|
||||
<n-input
|
||||
v-model:value="searchQuery"
|
||||
@keyup.enter="handleSearch"
|
||||
type="text"
|
||||
placeholder="输入文件名或链接进行搜索..."
|
||||
/>
|
||||
<div class="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||
<i class="fas fa-search text-gray-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 平台筛选 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">平台筛选</label>
|
||||
<select
|
||||
v-model="selectedPlatform"
|
||||
@change="handleSearch"
|
||||
class="w-full px-4 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"
|
||||
>
|
||||
<option value="">全部平台</option>
|
||||
<option v-for="platform in platforms" :key="platform.id" :value="platform.id">
|
||||
{{ platform.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 分类筛选 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">分类筛选</label>
|
||||
<select
|
||||
v-model="selectedCategory"
|
||||
@change="handleSearch"
|
||||
class="w-full px-4 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"
|
||||
>
|
||||
<option value="">全部分类</option>
|
||||
<option v-for="category in categories" :key="category.id" :value="category.id">
|
||||
{{ category.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索按钮 -->
|
||||
<div class="mt-4 flex justify-between items-center">
|
||||
<div class="flex gap-2">
|
||||
<n-button
|
||||
@click="handleSearch"
|
||||
type="primary"
|
||||
>
|
||||
<i class="fas fa-search"></i> 搜索
|
||||
</n-button>
|
||||
<n-button
|
||||
@click="clearFilters"
|
||||
type="tertiary"
|
||||
>
|
||||
<i class="fas fa-times"></i> 清除筛选
|
||||
</n-button>
|
||||
</div>
|
||||
<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 class="flex justify-between items-center mb-4">
|
||||
<div class="flex gap-2">
|
||||
<n-button
|
||||
@click="showBatchModal = true"
|
||||
type="primary"
|
||||
>
|
||||
<i class="fas fa-list"></i> 批量操作
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<n-button
|
||||
@click="refreshData"
|
||||
type="tertiary"
|
||||
>
|
||||
<i class="fas fa-refresh"></i> 刷新
|
||||
</n-button>
|
||||
<n-button
|
||||
@click="exportData"
|
||||
type="info"
|
||||
>
|
||||
<i class="fas fa-download"></i> 导出
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 批量操作模态框 -->
|
||||
<div v-if="showBatchModal" 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 p-6 max-w-2xl w-full mx-4 text-gray-900 dark:text-gray-100">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-bold">批量操作</h3>
|
||||
<n-button @click="closeBatchModal" type="tertiary" size="small">
|
||||
<i class="fas fa-times"></i>
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">选择操作:</label>
|
||||
<select
|
||||
v-model="batchAction"
|
||||
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"
|
||||
>
|
||||
<option value="">请选择操作</option>
|
||||
<option value="delete">批量删除</option>
|
||||
<option value="update_category">批量更新分类</option>
|
||||
<option value="update_tags">批量更新标签</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="batchAction === 'update_category'">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">选择分类:</label>
|
||||
<select
|
||||
v-model="batchCategory"
|
||||
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"
|
||||
>
|
||||
<option value="">请选择分类</option>
|
||||
<option v-for="category in categories" :key="category.id" :value="category.id">
|
||||
{{ category.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="batchAction === 'update_tags'">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">选择标签:</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="tag in tags" :key="tag.id" class="flex items-center">
|
||||
<n-checkbox
|
||||
:value="tag.id"
|
||||
:checked="batchTags.includes(tag.id)"
|
||||
@update:checked="(checked) => {
|
||||
if (checked) {
|
||||
batchTags.push(tag.id)
|
||||
} else {
|
||||
const index = batchTags.indexOf(tag.id)
|
||||
if (index > -1) {
|
||||
batchTags.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}"
|
||||
/>
|
||||
<span class="text-sm">{{ tag.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-6">
|
||||
<n-button @click="closeBatchModal" type="tertiary">
|
||||
取消
|
||||
</n-button>
|
||||
<n-button @click="handleBatchAction" type="primary">
|
||||
执行操作
|
||||
</n-button>
|
||||
</div>
|
||||
</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-slate-800 dark:bg-gray-700 text-white dark:text-gray-100 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium">
|
||||
<n-checkbox
|
||||
v-model:checked="selectAll"
|
||||
@update:checked="toggleSelectAll"
|
||||
/>
|
||||
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>
|
||||
<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 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="resources.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 mb-4">你可以点击上方"添加资源"按钮快速导入资源</div>
|
||||
<div class="flex gap-2">
|
||||
<NuxtLink
|
||||
to="/admin-old/add-resource"
|
||||
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors text-sm flex items-center gap-2"
|
||||
>
|
||||
<i class="fas fa-plus"></i> 添加资源
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="resource in resources"
|
||||
:key="resource.id"
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 font-medium">
|
||||
<n-checkbox
|
||||
:value="resource.id"
|
||||
:checked="selectedResources.includes(resource.id)"
|
||||
@update:checked="(checked) => {
|
||||
if (checked) {
|
||||
selectedResources.push(resource.id)
|
||||
} else {
|
||||
const index = selectedResources.indexOf(resource.id)
|
||||
if (index > -1) {
|
||||
selectedResources.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}"
|
||||
/>
|
||||
{{ resource.id }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||
<span :title="resource.title">{{ escapeHtml(resource.title) }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{{ getPlatformName(resource.pan_id) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ getCategoryName(resource.category_id) || '-' }}
|
||||
</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 text-gray-500 dark:text-gray-400">
|
||||
{{ resource.view_count || 0 }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ formatTime(resource.updated_at) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="editResource(resource)"
|
||||
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
|
||||
title="编辑资源"
|
||||
>
|
||||
<i class="fas fa-edit"></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> 个资源
|
||||
</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-blue-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> 个资源
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 设置页面布局
|
||||
definePageMeta({
|
||||
layout: 'admin-old',
|
||||
ssr: false
|
||||
})
|
||||
|
||||
interface Resource {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
pan_id?: number
|
||||
category_id?: number
|
||||
view_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface Platform {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface Tag {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const notification = useNotification()
|
||||
const resources = ref<Resource[]>([])
|
||||
const platforms = ref<Platform[]>([])
|
||||
const categories = ref<Category[]>([])
|
||||
const tags = ref<Tag[]>([])
|
||||
const loading = ref(false)
|
||||
const pageLoading = ref(true)
|
||||
|
||||
// 搜索和筛选状态
|
||||
const searchQuery = ref('')
|
||||
const selectedPlatform = ref('')
|
||||
const selectedCategory = ref('')
|
||||
|
||||
// 分页相关状态
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(50)
|
||||
const totalCount = ref(0)
|
||||
const totalPages = ref(0)
|
||||
|
||||
// 批量操作状态
|
||||
const showBatchModal = ref(false)
|
||||
const batchAction = ref('')
|
||||
const batchCategory = ref('')
|
||||
const batchTags = ref<number[]>([])
|
||||
const selectedResources = ref<number[]>([])
|
||||
const selectAll = ref(false)
|
||||
const dialog = useDialog()
|
||||
|
||||
// API
|
||||
import { useResourceApi, usePanApi, useCategoryApi, useTagApi } from '~/composables/useApi'
|
||||
|
||||
const resourceApi = useResourceApi()
|
||||
const panApi = usePanApi()
|
||||
const categoryApi = useCategoryApi()
|
||||
const tagApi = useTagApi()
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value
|
||||
}
|
||||
|
||||
if (searchQuery.value) {
|
||||
params.search = searchQuery.value
|
||||
}
|
||||
|
||||
if (selectedPlatform.value) {
|
||||
params.pan_id = selectedPlatform.value
|
||||
}
|
||||
|
||||
if (selectedCategory.value) {
|
||||
params.category_id = selectedCategory.value
|
||||
}
|
||||
|
||||
const response = await resourceApi.getResources(params) as any
|
||||
console.log('DEBUG - 资源API响应:', response)
|
||||
|
||||
// 适配后端API响应格式
|
||||
if (response && response.data && Array.isArray(response.data)) {
|
||||
console.log('使用 data 格式:', response.data)
|
||||
resources.value = response.data
|
||||
totalCount.value = response.total || 0
|
||||
totalPages.value = Math.ceil((response.total || 0) / pageSize.value)
|
||||
} else if (Array.isArray(response)) {
|
||||
console.log('使用数组格式:', response)
|
||||
resources.value = response
|
||||
totalCount.value = response.length
|
||||
totalPages.value = 1
|
||||
} else {
|
||||
console.log('使用默认格式:', response)
|
||||
resources.value = []
|
||||
totalCount.value = 0
|
||||
totalPages.value = 1
|
||||
}
|
||||
|
||||
console.log('最终资源数据:', resources.value)
|
||||
console.log('资源数据长度:', resources.value.length)
|
||||
} catch (error) {
|
||||
console.error('获取资源失败:', error)
|
||||
resources.value = []
|
||||
totalCount.value = 0
|
||||
totalPages.value = 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取平台列表
|
||||
const fetchPlatforms = async () => {
|
||||
try {
|
||||
const response = await panApi.getPans() as any
|
||||
console.log('平台API响应:', response)
|
||||
|
||||
// 适配后端API响应格式
|
||||
if (response && response.data && Array.isArray(response.data)) {
|
||||
console.log('使用 data 格式:', response.data)
|
||||
platforms.value = response.data
|
||||
} else if (Array.isArray(response)) {
|
||||
console.log('使用数组格式:', response)
|
||||
platforms.value = response
|
||||
} else {
|
||||
console.log('使用默认格式:', response)
|
||||
platforms.value = []
|
||||
}
|
||||
console.log('最终平台数据:', platforms.value)
|
||||
} catch (error) {
|
||||
console.error('获取平台列表失败:', error)
|
||||
platforms.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 获取分类列表
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const response = await categoryApi.getCategories() as any
|
||||
console.log('分类API响应:', response)
|
||||
|
||||
// 适配后端API响应格式
|
||||
if (response && response.data && Array.isArray(response.data)) {
|
||||
console.log('使用 data 格式:', response.data)
|
||||
categories.value = response.data
|
||||
} else if (Array.isArray(response)) {
|
||||
console.log('使用数组格式:', response)
|
||||
categories.value = response
|
||||
} else {
|
||||
console.log('使用默认格式:', response)
|
||||
categories.value = []
|
||||
}
|
||||
console.log('最终分类数据:', categories.value)
|
||||
} catch (error) {
|
||||
console.error('获取分类列表失败:', error)
|
||||
categories.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 获取标签列表
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
const response = await tagApi.getTags() as any
|
||||
console.log('标签API响应:', response)
|
||||
|
||||
// 适配后端API响应格式
|
||||
if (response && response.data && Array.isArray(response.data)) {
|
||||
console.log('使用 data 格式:', response.data)
|
||||
tags.value = response.data
|
||||
} else if (Array.isArray(response)) {
|
||||
console.log('使用数组格式:', response)
|
||||
tags.value = response
|
||||
} else {
|
||||
console.log('使用默认格式:', response)
|
||||
tags.value = []
|
||||
}
|
||||
console.log('最终标签数据:', tags.value)
|
||||
} catch (error) {
|
||||
console.error('获取标签列表失败:', error)
|
||||
tags.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
currentPage.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 清除筛选
|
||||
const clearFilters = () => {
|
||||
searchQuery.value = ''
|
||||
selectedPlatform.value = ''
|
||||
selectedCategory.value = ''
|
||||
currentPage.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
const refreshData = () => {
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 导出数据
|
||||
const exportData = () => {
|
||||
// 实现导出功能
|
||||
console.log('导出数据功能待实现')
|
||||
}
|
||||
|
||||
// 分页处理
|
||||
const goToPage = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 计算可见页码
|
||||
const visiblePages = computed(() => {
|
||||
const pages = []
|
||||
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 toggleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
selectedResources.value = resources.value.map(r => r.id)
|
||||
} else {
|
||||
selectedResources.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 批量操作
|
||||
const handleBatchAction = async () => {
|
||||
if (selectedResources.value.length === 0) {
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: '请选择要操作的资源',
|
||||
duration: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!batchAction.value) {
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: '请选择操作类型',
|
||||
duration: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
switch (batchAction.value) {
|
||||
case 'delete':
|
||||
dialog.warning({
|
||||
title: '警告',
|
||||
content: `确定要删除选中的 ${selectedResources.value.length} 个资源吗?`,
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
draggable: true,
|
||||
onPositiveClick: async () => {
|
||||
await resourceApi.batchDeleteResources(selectedResources.value)
|
||||
notification.success({
|
||||
title: '成功',
|
||||
content: '批量删除成功',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
})
|
||||
return
|
||||
break
|
||||
case 'update_category':
|
||||
if (!batchCategory.value) {
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: '请选择分类',
|
||||
duration: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
await Promise.all(selectedResources.value.map(id =>
|
||||
resourceApi.updateResource(id, { category_id: batchCategory.value })
|
||||
))
|
||||
notification.success({
|
||||
title: '成功',
|
||||
content: '批量更新分类成功',
|
||||
duration: 3000
|
||||
})
|
||||
break
|
||||
case 'update_tags':
|
||||
await Promise.all(selectedResources.value.map(id =>
|
||||
resourceApi.updateResource(id, { tag_ids: batchTags.value })
|
||||
))
|
||||
notification.success({
|
||||
title: '成功',
|
||||
content: '批量更新标签成功',
|
||||
duration: 3000
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
closeBatchModal()
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('批量操作失败:', error)
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: '批量操作失败',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭批量操作模态框
|
||||
const closeBatchModal = () => {
|
||||
showBatchModal.value = false
|
||||
batchAction.value = ''
|
||||
batchCategory.value = ''
|
||||
batchTags.value = []
|
||||
selectedResources.value = []
|
||||
selectAll.value = false
|
||||
}
|
||||
|
||||
// 编辑资源
|
||||
const editResource = (resource: Resource) => {
|
||||
// 跳转到编辑页面或打开编辑模态框
|
||||
console.log('编辑资源:', resource)
|
||||
}
|
||||
|
||||
// 删除资源
|
||||
const deleteResource = async (id: number) => {
|
||||
dialog.warning({
|
||||
title: '警告',
|
||||
content: '确定要删除这个资源吗?',
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
draggable: true,
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await resourceApi.deleteResource(id)
|
||||
notification.success({
|
||||
title: '成功',
|
||||
content: '删除成功',
|
||||
duration: 3000
|
||||
})
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: '删除失败',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
const escapeHtml = (text: string) => {
|
||||
if (!text) return ''
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
const checkUrlSafety = (url: string) => {
|
||||
if (!url) return '#'
|
||||
// 检查URL安全性,这里可以添加更多检查逻辑
|
||||
return url
|
||||
}
|
||||
|
||||
const formatTime = (timeString: string) => {
|
||||
if (!timeString) return '-'
|
||||
const date = new Date(timeString)
|
||||
return date.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const getPlatformName = (panId?: number) => {
|
||||
if (!panId) return '未知'
|
||||
const platform = platforms.value.find(p => p.id === panId)
|
||||
return platform?.name || '未知'
|
||||
}
|
||||
|
||||
const getCategoryName = (categoryId?: number) => {
|
||||
if (!categoryId) return null
|
||||
const category = categories.value.find(c => c.id === categoryId)
|
||||
return category?.name || null
|
||||
}
|
||||
|
||||
// 页面初始化
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchData(),
|
||||
fetchPlatforms(),
|
||||
fetchCategories(),
|
||||
fetchTags()
|
||||
])
|
||||
} catch (error) {
|
||||
console.error('页面初始化失败:', error)
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 可以添加自定义样式 */
|
||||
</style>
|
||||
@@ -1,219 +0,0 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- 页面标题 -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">搜索统计</h1>
|
||||
<p class="text-gray-600 mt-2">查看搜索量统计和热门关键词分析</p>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 rounded-full bg-blue-100 text-blue-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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">今日搜索</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ stats.todaySearches }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 rounded-full bg-green-100 text-green-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="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">本周搜索</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ stats.weekSearches }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 rounded-full bg-purple-100 text-purple-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="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">本月搜索</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ stats.monthSearches }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索趋势图表 -->
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">搜索趋势</h2>
|
||||
<div class="h-64">
|
||||
<canvas ref="trendChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 热门关键词 -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">热门关键词</h2>
|
||||
<div class="space-y-4">
|
||||
<div v-for="keyword in stats.hotKeywords" :key="keyword.keyword"
|
||||
class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<span class="inline-flex items-center justify-center w-8 h-8 bg-blue-100 text-blue-600 rounded-full text-sm font-medium mr-3">
|
||||
{{ keyword.rank }}
|
||||
</span>
|
||||
<span class="text-gray-900 font-medium">{{ keyword.keyword }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-gray-600 mr-2">{{ keyword.count }}次</span>
|
||||
<div class="w-24 bg-gray-200 rounded-full h-2">
|
||||
<div class="bg-blue-600 h-2 rounded-full"
|
||||
:style="{ width: getPercentage(keyword.count) + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索记录 -->
|
||||
<div class="bg-white rounded-lg shadow p-6 mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">搜索记录</h2>
|
||||
<table class="w-full table-auto text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-2 py-2 text-left">关键词</th>
|
||||
<th class="px-2 py-2 text-left">次数</th>
|
||||
<th class="px-2 py-2 text-left">日期</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in searchList" :key="item.id">
|
||||
<td class="px-2 py-2">{{ item.keyword }}</td>
|
||||
<td class="px-2 py-2">{{ item.count }}</td>
|
||||
<td class="px-2 py-2">{{ item.date ? (new Date(item.date)).toLocaleDateString() : '' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="searchList.length === 0" class="text-gray-400 text-center py-8">暂无搜索记录</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 设置页面布局
|
||||
definePageMeta({
|
||||
layout: 'admin-old',
|
||||
ssr: false
|
||||
})
|
||||
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import Chart from 'chart.js/auto'
|
||||
import { useApiFetch } from '~/composables/useApiFetch'
|
||||
import { parseApiResponse } from '~/composables/useApi'
|
||||
|
||||
const stats = ref({
|
||||
todaySearches: 0,
|
||||
weekSearches: 0,
|
||||
monthSearches: 0,
|
||||
hotKeywords: [],
|
||||
searchTrend: {
|
||||
days: [],
|
||||
values: []
|
||||
}
|
||||
})
|
||||
|
||||
const searchList = ref([])
|
||||
|
||||
const trendChart = ref(null)
|
||||
let chart = null
|
||||
|
||||
// 获取百分比
|
||||
const getPercentage = (count) => {
|
||||
if (stats.value.hotKeywords.length === 0) return 0
|
||||
const maxCount = Math.max(...stats.value.hotKeywords.map(k => k.count))
|
||||
return Math.round((count / maxCount) * 100)
|
||||
}
|
||||
|
||||
// 加载搜索统计
|
||||
const loadSearchStats = async () => {
|
||||
try {
|
||||
// 1. 汇总卡片
|
||||
const summary = await useApiFetch('/search-stats/summary').then(parseApiResponse)
|
||||
stats.value.todaySearches = summary.today || 0
|
||||
stats.value.weekSearches = summary.week || 0
|
||||
stats.value.monthSearches = summary.month || 0
|
||||
// 2. 热门关键词
|
||||
const hotKeywords = await useApiFetch('/search-stats/hot-keywords').then(parseApiResponse)
|
||||
stats.value.hotKeywords = hotKeywords || []
|
||||
// 3. 趋势
|
||||
const trend = await useApiFetch('/search-stats/trend').then(parseApiResponse)
|
||||
stats.value.searchTrend.days = (trend || []).map(item => item.date ? (new Date(item.date)).toLocaleDateString() : '')
|
||||
stats.value.searchTrend.values = (trend || []).map(item => item.total_searches)
|
||||
// 4. 搜索记录
|
||||
const data = await useApiFetch('/search-stats').then(parseApiResponse)
|
||||
searchList.value = data || []
|
||||
// 5. 更新图表
|
||||
setTimeout(updateChart, 100)
|
||||
} catch (error) {
|
||||
console.error('加载搜索统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新图表
|
||||
const updateChart = () => {
|
||||
if (chart) {
|
||||
chart.destroy()
|
||||
}
|
||||
|
||||
const ctx = trendChart.value.getContext('2d')
|
||||
chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: stats.value.searchTrend.days,
|
||||
datasets: [{
|
||||
label: '搜索量',
|
||||
data: stats.value.searchTrend.values,
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.1)'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.1)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSearchStats()
|
||||
})
|
||||
</script>
|
||||
@@ -1,587 +0,0 @@
|
||||
<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="">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<!-- 配置表单 -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<form @submit.prevent="saveConfig" class="space-y-6">
|
||||
|
||||
<n-tabs type="line" animated>
|
||||
<n-tab-pane name="站点配置" tab="站点配置">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- 网站标题 -->
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
网站标题 *
|
||||
</label>
|
||||
<n-input
|
||||
v-model:value="config.siteTitle"
|
||||
type="text"
|
||||
required
|
||||
placeholder="老九网盘资源数据库"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 网站描述 -->
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
网站描述
|
||||
</label>
|
||||
<n-input
|
||||
v-model:value="config.siteDescription"
|
||||
type="text"
|
||||
placeholder="专业的老九网盘资源数据库"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 关键词 -->
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
关键词 (用逗号分隔)
|
||||
</label>
|
||||
<n-input
|
||||
v-model:value="config.keywords"
|
||||
type="text"
|
||||
placeholder="网盘,资源管理,文件分享"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 版权信息 -->
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
版权信息
|
||||
</label>
|
||||
<n-input
|
||||
v-model:value="config.copyright"
|
||||
type="text"
|
||||
placeholder="© 2024 老九网盘资源数据库"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 禁止词 -->
|
||||
<div class="md:col-span-2">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
违禁词
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<n-button
|
||||
type="default"
|
||||
size="small"
|
||||
@click="openForbiddenWordsSource"
|
||||
>
|
||||
开源违禁词
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<n-input
|
||||
v-model:value="config.forbiddenWords"
|
||||
type="textarea"
|
||||
placeholder=""
|
||||
:autosize="{ minRows: 4, maxRows: 8 }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- <div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
每页显示数量
|
||||
</label>
|
||||
<select
|
||||
v-model.number="config.pageSize"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="20">20 条</option>
|
||||
<option value="50">50 条</option>
|
||||
<option value="100">100 条</option>
|
||||
<option value="200">200 条</option>
|
||||
</select>
|
||||
</div> -->
|
||||
|
||||
<!-- 系统维护模式 -->
|
||||
<div class="md:col-span-2 flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
维护模式
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
开启后,普通用户无法访问系统
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<n-switch v-model:value="config.maintenanceMode" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="功能配置" tab="功能配置">
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- 待处理资源自动处理 -->
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
待处理资源自动处理
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
开启后,系统将自动处理待处理的资源,无需手动操作
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<n-switch v-model:value="config.autoProcessReadyResources" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="config.autoProcessReadyResources" class="ml-6">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
自动处理间隔 (分钟)
|
||||
</label>
|
||||
<n-input
|
||||
v-model:value="config.autoProcessInterval"
|
||||
type="number"
|
||||
placeholder="30"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
建议设置 5-60 分钟,避免过于频繁的处理
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- 自动转存 -->
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
自动转存
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
开启后,系统将自动转存资源到其他网盘平台
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<n-switch v-model:value="config.autoTransferEnabled" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自动转存配置(仅在开启时显示) -->
|
||||
<div v-if="config.autoTransferEnabled" class="ml-6 space-y-4">
|
||||
<!-- 自动转存限制天数 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
自动转存限制(n天内资源)
|
||||
</label>
|
||||
<n-input
|
||||
v-model:value="config.autoTransferLimitDays"
|
||||
type="number"
|
||||
placeholder="30"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
只转存指定天数内的资源,0表示不限制时间
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 最小存储空间 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
最小存储空间(GB)
|
||||
</label>
|
||||
<n-input
|
||||
v-model:value="config.autoTransferMinSpace"
|
||||
type="number"
|
||||
placeholder="500"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
当网盘剩余空间小于此值时,停止自动转存(100-1024GB)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自动拉取热播剧 -->
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
自动拉取热播剧
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
开启后,系统将自动从豆瓣获取热播剧信息
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<n-switch v-model:value="config.autoFetchHotDramaEnabled" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自动处理间隔 -->
|
||||
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="API配置" tab="API配置">
|
||||
<div class="space-y-4">
|
||||
<!-- API Token -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
公开API访问令牌
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<n-input
|
||||
v-model:value="config.apiToken"
|
||||
type="password"
|
||||
placeholder="输入API Token,用于公开API访问认证"
|
||||
:show-password-on="'click'"
|
||||
/>
|
||||
<n-button
|
||||
v-if="!config.apiToken"
|
||||
type="primary"
|
||||
@click="generateApiToken"
|
||||
>
|
||||
生成
|
||||
</n-button>
|
||||
<template v-else>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="copyApiToken"
|
||||
>
|
||||
复制
|
||||
</n-button>
|
||||
<n-button
|
||||
type="default"
|
||||
@click="generateApiToken"
|
||||
>
|
||||
重新生成
|
||||
</n-button>
|
||||
</template>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
用于公开API的访问认证,建议使用随机字符串
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- API使用说明 -->
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
API使用说明
|
||||
</h3>
|
||||
<div class="text-xs text-blue-700 dark:text-blue-300 space-y-1">
|
||||
<p>• 批量添加资源: POST /api/public/resources/batch-add</p>
|
||||
<p>• 资源搜索: GET /api/public/resources/search</p>
|
||||
<p>• 热门剧: GET /api/public/hot-dramas</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<div class="flex justify-end space-x-4 pt-6">
|
||||
<n-button
|
||||
type="tertiary"
|
||||
@click="resetForm"
|
||||
>
|
||||
重置
|
||||
</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="saving"
|
||||
@click="saveConfig"
|
||||
>
|
||||
<i v-if="saving" class="fas fa-spinner fa-spin mr-2"></i>
|
||||
{{ saving ? '保存中...' : '保存配置' }}
|
||||
</n-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 设置页面布局
|
||||
definePageMeta({
|
||||
layout: 'admin-old',
|
||||
ssr: false
|
||||
})
|
||||
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useSystemConfigApi } from '~/composables/useApi'
|
||||
import { useSystemConfigStore } from '~/stores/systemConfig'
|
||||
|
||||
// 权限检查已在 admin 布局中处理
|
||||
|
||||
const systemConfigStore = useSystemConfigStore()
|
||||
|
||||
// API
|
||||
const systemConfigApi = useSystemConfigApi()
|
||||
const notification = useNotification()
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const loadingForbiddenWords = ref(false)
|
||||
const config = ref({
|
||||
// SEO 配置
|
||||
siteTitle: '老九网盘资源数据库',
|
||||
siteDescription: '专业的老九网盘资源数据库',
|
||||
keywords: '网盘,资源管理,文件分享',
|
||||
author: '系统管理员',
|
||||
copyright: '© 2024 老九网盘资源数据库',
|
||||
|
||||
// 自动处理配置
|
||||
autoProcessReadyResources: false,
|
||||
autoProcessInterval: 30,
|
||||
autoTransferEnabled: false, // 新增
|
||||
autoTransferLimitDays: 30, // 新增:自动转存限制天数
|
||||
autoTransferMinSpace: 500, // 新增:最小存储空间(GB)
|
||||
autoFetchHotDramaEnabled: false, // 新增
|
||||
|
||||
// 其他配置
|
||||
pageSize: 100,
|
||||
maintenanceMode: false,
|
||||
apiToken: '' // 新增
|
||||
})
|
||||
|
||||
// 系统配置状态(用于SEO)
|
||||
const systemConfig = ref({
|
||||
site_title: '老九网盘资源数据库',
|
||||
site_description: '系统配置管理页面',
|
||||
keywords: '系统配置,管理',
|
||||
author: '系统管理员'
|
||||
})
|
||||
const originalConfig = ref(null)
|
||||
|
||||
// 加载配置
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await systemConfigApi.getSystemConfig()
|
||||
console.log('系统配置响应:', response)
|
||||
|
||||
// 使用新的统一响应格式,直接使用response
|
||||
if (response) {
|
||||
const newConfig = {
|
||||
siteTitle: response.site_title || '老九网盘资源数据库',
|
||||
siteDescription: response.site_description || '专业的老九网盘资源数据库',
|
||||
keywords: response.keywords || '网盘,资源管理,文件分享',
|
||||
author: response.author || '系统管理员',
|
||||
copyright: response.copyright || '© 2024 老九网盘资源数据库',
|
||||
autoProcessReadyResources: response.auto_process_ready_resources || false,
|
||||
autoProcessInterval: String(response.auto_process_interval || 30),
|
||||
autoTransferEnabled: response.auto_transfer_enabled || false, // 新增
|
||||
autoTransferLimitDays: String(response.auto_transfer_limit_days || 30), // 新增:自动转存限制天数
|
||||
autoTransferMinSpace: String(response.auto_transfer_min_space || 500), // 新增:最小存储空间(GB)
|
||||
autoFetchHotDramaEnabled: response.auto_fetch_hot_drama_enabled || false, // 新增
|
||||
forbiddenWords: formatForbiddenWordsForDisplay(response.forbidden_words || ''),
|
||||
pageSize: String(response.page_size || 100),
|
||||
maintenanceMode: response.maintenance_mode || false,
|
||||
apiToken: response.api_token || '' // 加载API Token
|
||||
}
|
||||
config.value = newConfig
|
||||
originalConfig.value = JSON.parse(JSON.stringify(newConfig)) // 深拷贝保存原始数据
|
||||
systemConfig.value = response // 更新系统配置状态
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error)
|
||||
// 显示错误提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
const saveConfig = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
const changes = {}
|
||||
const currentConfig = config.value
|
||||
const original = originalConfig.value
|
||||
|
||||
// 检查每个字段是否有变化
|
||||
if (currentConfig.siteTitle !== original.siteTitle) {
|
||||
changes.site_title = currentConfig.siteTitle
|
||||
}
|
||||
if (currentConfig.siteDescription !== original.siteDescription) {
|
||||
changes.site_description = currentConfig.siteDescription
|
||||
}
|
||||
if (currentConfig.keywords !== original.keywords) {
|
||||
changes.keywords = currentConfig.keywords
|
||||
}
|
||||
if (currentConfig.author !== original.author) {
|
||||
changes.author = currentConfig.author
|
||||
}
|
||||
if (currentConfig.copyright !== original.copyright) {
|
||||
changes.copyright = currentConfig.copyright
|
||||
}
|
||||
if (currentConfig.autoProcessReadyResources !== original.autoProcessReadyResources) {
|
||||
changes.auto_process_ready_resources = currentConfig.autoProcessReadyResources
|
||||
}
|
||||
if (currentConfig.autoProcessInterval !== original.autoProcessInterval) {
|
||||
changes.auto_process_interval = parseInt(currentConfig.autoProcessInterval) || 0
|
||||
}
|
||||
if (currentConfig.autoTransferEnabled !== original.autoTransferEnabled) {
|
||||
changes.auto_transfer_enabled = currentConfig.autoTransferEnabled
|
||||
}
|
||||
if (currentConfig.autoTransferLimitDays !== original.autoTransferLimitDays) {
|
||||
changes.auto_transfer_limit_days = parseInt(currentConfig.autoTransferLimitDays) || 0
|
||||
}
|
||||
if (currentConfig.autoTransferMinSpace !== original.autoTransferMinSpace) {
|
||||
changes.auto_transfer_min_space = parseInt(currentConfig.autoTransferMinSpace) || 0
|
||||
}
|
||||
if (currentConfig.autoFetchHotDramaEnabled !== original.autoFetchHotDramaEnabled) {
|
||||
changes.auto_fetch_hot_drama_enabled = currentConfig.autoFetchHotDramaEnabled
|
||||
}
|
||||
if (currentConfig.forbiddenWords !== original.forbiddenWords) {
|
||||
changes.forbidden_words = formatForbiddenWordsForSave(currentConfig.forbiddenWords)
|
||||
}
|
||||
if (currentConfig.pageSize !== original.pageSize) {
|
||||
changes.page_size = parseInt(currentConfig.pageSize) || 0
|
||||
}
|
||||
if (currentConfig.maintenanceMode !== original.maintenanceMode) {
|
||||
changes.maintenance_mode = currentConfig.maintenanceMode
|
||||
}
|
||||
if (currentConfig.apiToken !== original.apiToken) {
|
||||
changes.api_token = currentConfig.apiToken
|
||||
}
|
||||
|
||||
console.log('检测到的变化:', changes)
|
||||
if (Object.keys(changes).length === 0) {
|
||||
notification.warning({
|
||||
content: '没有需要保存的配置',
|
||||
duration: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
const response = await systemConfigApi.updateSystemConfig(changes)
|
||||
// 使用新的统一响应格式,直接检查response是否存在
|
||||
if (response) {
|
||||
notification.success({
|
||||
content: '配置保存成功!',
|
||||
duration: 3000
|
||||
})
|
||||
await loadConfig()
|
||||
// 自动更新 systemConfig store(强制刷新)
|
||||
await systemConfigStore.initConfig(true)
|
||||
} else {
|
||||
notification.error({
|
||||
content: '保存配置失败:未知错误',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
notification.error({
|
||||
content: '保存配置失败:' + (error.message || '未知错误'),
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
notification.confirm({
|
||||
title: '确定要重置所有配置吗?',
|
||||
content: '重置后,所有配置将恢复为修改前配置',
|
||||
duration: 3000,
|
||||
onOk: () => {
|
||||
loadConfig()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 生成API Token
|
||||
const generateApiToken = () => {
|
||||
const newToken = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
||||
config.value.apiToken = newToken;
|
||||
notification.success({
|
||||
content: '新API Token已生成: ' + newToken,
|
||||
duration: 3000
|
||||
})
|
||||
};
|
||||
|
||||
// 复制API Token
|
||||
const copyApiToken = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(config.value.apiToken);
|
||||
notification.success({
|
||||
content: 'API Token已复制到剪贴板',
|
||||
duration: 3000
|
||||
});
|
||||
} catch (err) {
|
||||
// 降级方案:使用传统的复制方法
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = config.value.apiToken;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
notification.success({
|
||||
content: 'API Token已复制到剪贴板',
|
||||
duration: 3000
|
||||
});
|
||||
} catch (fallbackErr) {
|
||||
notification.error({
|
||||
content: '复制失败,请手动复制',
|
||||
duration: 3000
|
||||
});
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 打开违禁词源文件
|
||||
const openForbiddenWordsSource = () => {
|
||||
const url = 'https://raw.githubusercontent.com/ctwj/urldb/refs/heads/main/db/forbidden.txt'
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
// 格式化违禁词用于显示(逗号分隔转为多行)
|
||||
const formatForbiddenWordsForDisplay = (forbiddenWords) => {
|
||||
if (!forbiddenWords) return ''
|
||||
|
||||
// 按逗号分割,过滤空字符串,然后按行显示
|
||||
return forbiddenWords.split(',')
|
||||
.map(word => word.trim())
|
||||
.filter(word => word.length > 0)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
// 格式化违禁词用于保存(多行转为逗号分隔)
|
||||
const formatForbiddenWordsForSave = (forbiddenWords) => {
|
||||
if (!forbiddenWords) return ''
|
||||
|
||||
// 按行分割,过滤空行,然后用逗号连接
|
||||
return forbiddenWords.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.join(',')
|
||||
}
|
||||
|
||||
// 页面加载时获取配置
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
})
|
||||
</script>
|
||||
@@ -1,571 +0,0 @@
|
||||
<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="">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<n-alert class="mb-4" title="提交的数据中,如果包含标签,数据添加成功,会自动添加标签" type="info" />
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="showAddModal = true"
|
||||
class="px-4 py-2 bg-green-600 hover:bg-green-700 rounded-md transition-colors text-white text-sm flex items-center gap-2"
|
||||
>
|
||||
<i class="fas fa-plus"></i> 添加标签
|
||||
</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>
|
||||
<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 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>
|
||||
<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="7" class="text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>加载中...
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else-if="tags.length === 0" class="text-center py-8">
|
||||
<td colspan="7" 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>
|
||||
<button
|
||||
@click="showAddModal = 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"
|
||||
>
|
||||
<i class="fas fa-plus"></i> 添加标签
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="tag in tags"
|
||||
:key="tag.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">{{ tag.id }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||
<span :title="tag.name">{{ tag.name }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span v-if="tag.category_name" class="px-2 py-1 bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300 rounded-full text-xs">
|
||||
{{ tag.category_name }}
|
||||
</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 text-gray-600 dark:text-gray-400">
|
||||
<span v-if="tag.description" :title="tag.description">{{ tag.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-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300 rounded-full text-xs">
|
||||
{{ tag.resource_count || 0 }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ formatTime(tag.created_at) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="editTag(tag)"
|
||||
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
|
||||
title="编辑标签"
|
||||
>
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button
|
||||
@click="deleteTag(tag.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="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>
|
||||
</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">
|
||||
{{ editingTag ? '编辑标签' : '添加标签' }}
|
||||
</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>
|
||||
<select
|
||||
v-model="formData.category_id"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
>
|
||||
<option value="">选择分类(可选)</option>
|
||||
<option v-for="category in categories" :key="category.id" :value="category.id">
|
||||
{{ category.name }}
|
||||
</option>
|
||||
</select>
|
||||
</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 ? '提交中...' : (editingTag ? '更新' : '添加') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// API 导入
|
||||
import { useTagApi, useCategoryApi } from '~/composables/useApi'
|
||||
|
||||
// 设置页面布局
|
||||
definePageMeta({
|
||||
layout: 'admin-old',
|
||||
ssr: false
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const tagApi = useTagApi()
|
||||
const categoryApi = useCategoryApi()
|
||||
|
||||
// 调试信息
|
||||
console.log('tagApi:', tagApi)
|
||||
console.log('categoryApi:', categoryApi)
|
||||
|
||||
// 页面状态
|
||||
const pageLoading = ref(true)
|
||||
const loading = ref(false)
|
||||
const tags = ref<any[]>([])
|
||||
const categories = ref<any[]>([])
|
||||
|
||||
// 分页状态
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const totalCount = ref(0)
|
||||
const totalPages = ref(0)
|
||||
|
||||
// 搜索和筛选状态
|
||||
const searchQuery = ref('')
|
||||
const selectedCategory = ref('')
|
||||
let searchTimeout: NodeJS.Timeout | null = null
|
||||
|
||||
// 模态框状态
|
||||
const showAddModal = ref(false)
|
||||
const submitting = ref(false)
|
||||
const editingTag = ref<any>(null)
|
||||
const dialog = useDialog()
|
||||
|
||||
// 表单数据
|
||||
const formData = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
category_id: ''
|
||||
})
|
||||
|
||||
// 获取认证头
|
||||
const getAuthHeaders = () => {
|
||||
return userStore.authHeaders
|
||||
}
|
||||
|
||||
// 检查认证状态
|
||||
const checkAuth = () => {
|
||||
userStore.initAuth()
|
||||
if (!userStore.isAuthenticated) {
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 获取分类列表
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
console.log('获取分类列表...')
|
||||
const response = await categoryApi.getCategories()
|
||||
console.log('分类接口响应:', response)
|
||||
|
||||
// 适配后端API响应格式
|
||||
if (response && (response as any).items) {
|
||||
console.log('使用 items 格式:', (response as any).items)
|
||||
categories.value = (response as any).items
|
||||
} else if (Array.isArray(response)) {
|
||||
console.log('使用数组格式:', response)
|
||||
categories.value = response
|
||||
} else {
|
||||
console.log('使用默认格式:', response)
|
||||
categories.value = []
|
||||
}
|
||||
console.log('最终分类数据:', categories.value)
|
||||
} catch (error) {
|
||||
console.error('获取分类列表失败:', error)
|
||||
categories.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 获取标签列表
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const params = {
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value,
|
||||
search: searchQuery.value
|
||||
}
|
||||
console.log('获取标签列表参数:', params)
|
||||
console.log('搜索查询值:', searchQuery.value)
|
||||
console.log('搜索查询类型:', typeof searchQuery.value)
|
||||
|
||||
let response: any
|
||||
if (selectedCategory.value) {
|
||||
response = await tagApi.getTagsByCategory(parseInt(selectedCategory.value), params)
|
||||
} else {
|
||||
response = await tagApi.getTags(params)
|
||||
}
|
||||
console.log('标签接口响应:', response)
|
||||
|
||||
// 适配后端API响应格式
|
||||
if (response && (response as any).items && Array.isArray((response as any).items)) {
|
||||
console.log('使用 items 格式:', (response as any).items)
|
||||
tags.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)
|
||||
tags.value = response
|
||||
totalCount.value = response.length
|
||||
totalPages.value = 1
|
||||
} else {
|
||||
console.log('使用默认格式:', response)
|
||||
tags.value = []
|
||||
totalCount.value = 0
|
||||
totalPages.value = 1
|
||||
}
|
||||
console.log('最终标签数据:', tags.value)
|
||||
} catch (error) {
|
||||
console.error('获取标签列表失败:', error)
|
||||
tags.value = []
|
||||
totalCount.value = 0
|
||||
totalPages.value = 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 分类变化处理
|
||||
const onCategoryChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchTags()
|
||||
}
|
||||
|
||||
// 搜索防抖
|
||||
const debounceSearch = () => {
|
||||
console.log('搜索防抖触发,当前搜索值:', searchQuery.value)
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
searchTimeout = setTimeout(() => {
|
||||
console.log('执行搜索,搜索值:', searchQuery.value)
|
||||
currentPage.value = 1
|
||||
fetchTags()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
const refreshData = () => {
|
||||
fetchTags()
|
||||
}
|
||||
|
||||
// 分页跳转
|
||||
const goToPage = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchTags()
|
||||
}
|
||||
|
||||
// 编辑标签
|
||||
const editTag = (tag: any) => {
|
||||
editingTag.value = tag
|
||||
formData.value = {
|
||||
name: tag.name,
|
||||
description: tag.description || '',
|
||||
category_id: tag.category_id || ''
|
||||
}
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
// 删除标签
|
||||
const deleteTag = async (tagId: number) => {
|
||||
dialog.warning({
|
||||
title: '警告',
|
||||
content: '确定要删除标签吗?',
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
draggable: true,
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await tagApi.deleteTag(tagId)
|
||||
await fetchTags()
|
||||
} catch (error) {
|
||||
console.error('删除标签失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
submitting.value = true
|
||||
|
||||
// 正确处理category_id,空字符串应该转换为null
|
||||
let categoryId = null
|
||||
if (formData.value.category_id && formData.value.category_id !== '') {
|
||||
categoryId = parseInt(formData.value.category_id)
|
||||
}
|
||||
|
||||
const submitData = {
|
||||
name: formData.value.name,
|
||||
description: formData.value.description,
|
||||
category_id: categoryId
|
||||
}
|
||||
|
||||
let response: any
|
||||
if (editingTag.value) {
|
||||
response = await tagApi.updateTag(editingTag.value.id, submitData)
|
||||
} else {
|
||||
response = await tagApi.createTag(submitData)
|
||||
}
|
||||
|
||||
console.log('标签操作响应:', response)
|
||||
|
||||
// 检查是否是恢复操作
|
||||
if (response && response.message && response.message.includes('恢复成功')) {
|
||||
console.log('检测到标签恢复操作,延迟刷新数据')
|
||||
closeModal()
|
||||
// 延迟一点时间再刷新,确保数据库状态已更新
|
||||
setTimeout(async () => {
|
||||
await fetchTags()
|
||||
}, 500)
|
||||
return
|
||||
}
|
||||
|
||||
closeModal()
|
||||
await fetchTags()
|
||||
} catch (error) {
|
||||
console.error('提交标签失败:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
const closeModal = () => {
|
||||
showAddModal.value = false
|
||||
editingTag.value = null
|
||||
formData.value = {
|
||||
name: '',
|
||||
description: '',
|
||||
category_id: ''
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
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 {
|
||||
console.log('页面开始加载...')
|
||||
checkAuth()
|
||||
console.log('认证检查完成')
|
||||
|
||||
console.log('开始获取分类列表...')
|
||||
await fetchCategories()
|
||||
console.log('分类列表获取完成')
|
||||
|
||||
console.log('开始获取标签列表...')
|
||||
await fetchTags()
|
||||
console.log('标签列表获取完成')
|
||||
|
||||
// 检查URL参数,如果action=add则自动打开新增弹窗
|
||||
const route = useRoute()
|
||||
if (route.query.action === 'add') {
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
console.log('页面加载完成')
|
||||
} catch (error) {
|
||||
console.error('标签管理页面初始化失败:', error)
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 自定义样式 */
|
||||
</style>
|
||||
@@ -1,447 +0,0 @@
|
||||
<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="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">用户管理</h2>
|
||||
<div class="flex gap-2">
|
||||
<n-button
|
||||
@click="showCreateModal = true"
|
||||
type="primary"
|
||||
>
|
||||
添加用户
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户列表 -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-900">用户列表</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">用户名</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">邮箱</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">角色</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">最后登录</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="user in users" :key="user.id">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ user.id }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ user.username }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ user.email }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span :class="getRoleClass(user.role)" class="px-2 py-1 text-xs font-medium rounded-full">
|
||||
{{ user.role }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span :class="user.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'"
|
||||
class="px-2 py-1 text-xs font-medium rounded-full">
|
||||
{{ user.is_active ? '激活' : '禁用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ user.last_login ? formatDate(user.last_login) : '从未登录' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<n-button @click="editUser(user)" type="info" size="small" class="mr-3" :title="user.username === 'admin' ? '管理员用户信息不可修改' : '编辑用户'">
|
||||
编辑{{ user.username === 'admin' ? ' (只读)' : '' }}
|
||||
</n-button>
|
||||
<n-button @click="showChangePasswordModal(user)" type="warning" size="small" class="mr-3">修改密码</n-button>
|
||||
<n-button @click="deleteUser(user.id)" type="error" size="small">删除</n-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建/编辑用户模态框 -->
|
||||
<div v-if="showCreateModal || showEditModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">
|
||||
{{ showEditModal ? '编辑用户' : '创建用户' }}
|
||||
</h3>
|
||||
<div v-if="showEditModal && editingUser?.username === 'admin'" class="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||
<p class="text-sm text-yellow-800">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
管理员用户信息不可修改,只能通过修改密码功能来更新密码。
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="showEditModal && editingUser?.username !== 'admin'" class="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<p class="text-sm text-blue-800">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
编辑模式:用户名和邮箱不可修改,只能修改角色和激活状态。
|
||||
</p>
|
||||
</div>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">用户名</label>
|
||||
<n-input
|
||||
v-model:value="form.username"
|
||||
type="text"
|
||||
required
|
||||
:disabled="showEditModal"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">邮箱</label>
|
||||
<n-input
|
||||
v-model:value="form.email"
|
||||
type="text"
|
||||
required
|
||||
:disabled="showEditModal"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showCreateModal">
|
||||
<label class="block text-sm font-medium text-gray-700">密码</label>
|
||||
<n-input
|
||||
v-model:value="form.password"
|
||||
type="password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">角色</label>
|
||||
<select
|
||||
v-model="form.role"
|
||||
class="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
:disabled="showEditModal && editingUser?.username === 'admin'"
|
||||
>
|
||||
<option value="user">用户</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">激活状态</label>
|
||||
<n-switch
|
||||
v-model:value="form.is_active"
|
||||
size="medium"
|
||||
:disabled="showEditModal && editingUser?.username === 'admin'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
:disabled="showEditModal && editingUser?.username === 'admin'"
|
||||
>
|
||||
{{ showEditModal ? '更新' : '创建' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 修改密码模态框 -->
|
||||
<div v-if="showPasswordModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">
|
||||
修改用户密码
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
正在为用户 <strong>{{ changingPasswordUser?.username }}</strong> 修改密码
|
||||
</p>
|
||||
<form @submit.prevent="handlePasswordChange">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">新密码</label>
|
||||
<n-input
|
||||
v-model:value="passwordForm.newPassword"
|
||||
type="password"
|
||||
required
|
||||
minlength="6"
|
||||
placeholder="请输入新密码(至少6位)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">确认新密码</label>
|
||||
<n-input
|
||||
v-model:value="passwordForm.confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
placeholder="请再次输入新密码"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="closePasswordModal"
|
||||
class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 bg-yellow-600 border border-transparent rounded-md text-sm font-medium text-white hover:bg-yellow-700"
|
||||
>
|
||||
修改密码
|
||||
</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 notification = useNotification()
|
||||
|
||||
const pageLoading = ref(true)
|
||||
const users = ref<any[]>([])
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showPasswordModal = ref(false)
|
||||
const editingUser = ref<any>(null)
|
||||
const changingPasswordUser = ref<any>(null)
|
||||
const dialog = useDialog()
|
||||
const form = ref({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'user',
|
||||
is_active: true
|
||||
})
|
||||
const passwordForm = ref({
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
// 检查认证
|
||||
const checkAuth = () => {
|
||||
userStore.initAuth()
|
||||
if (!userStore.isAuthenticated) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户列表
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const { useUserApi } = await import('~/composables/useApi')
|
||||
const userApi = useUserApi()
|
||||
const response = await userApi.getUsers()
|
||||
if (response && (response as any).items && Array.isArray((response as any).items)) {
|
||||
users.value = (response as any).items
|
||||
} else if (Array.isArray(response)) {
|
||||
users.value = response
|
||||
} else {
|
||||
users.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
const createUser = async () => {
|
||||
try {
|
||||
const { useUserApi } = await import('~/composables/useApi')
|
||||
const userApi = useUserApi()
|
||||
await userApi.createUser(form.value)
|
||||
await fetchUsers()
|
||||
closeModal()
|
||||
} catch (error) {
|
||||
console.error('创建用户失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新用户
|
||||
const updateUser = async () => {
|
||||
try {
|
||||
const { useUserApi } = await import('~/composables/useApi')
|
||||
const userApi = useUserApi()
|
||||
await userApi.updateUser(editingUser.value.id, form.value)
|
||||
await fetchUsers()
|
||||
closeModal()
|
||||
} catch (error) {
|
||||
console.error('更新用户失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
const deleteUser = async (id: any) => {
|
||||
dialog.warning({
|
||||
title: '警告',
|
||||
content: '确定要删除这个用户吗?',
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
draggable: true,
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
const { useUserApi } = await import('~/composables/useApi')
|
||||
const userApi = useUserApi()
|
||||
await userApi.deleteUser(id)
|
||||
await fetchUsers()
|
||||
} catch (error) {
|
||||
console.error('删除用户失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 显示修改密码模态框
|
||||
const showChangePasswordModal = (user: any) => {
|
||||
changingPasswordUser.value = user
|
||||
passwordForm.value = {
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
}
|
||||
showPasswordModal.value = true
|
||||
}
|
||||
|
||||
// 关闭修改密码模态框
|
||||
const closePasswordModal = () => {
|
||||
showPasswordModal.value = false
|
||||
changingPasswordUser.value = null
|
||||
passwordForm.value = {
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
}
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
const changePassword = async () => {
|
||||
if (passwordForm.value.newPassword !== passwordForm.value.confirmPassword) {
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: '两次输入的密码不一致',
|
||||
duration: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (passwordForm.value.newPassword.length < 6) {
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: '密码长度至少6位',
|
||||
duration: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { useUserApi } = await import('~/composables/useApi')
|
||||
const userApi = useUserApi()
|
||||
await userApi.changePassword(changingPasswordUser.value.id, passwordForm.value.newPassword)
|
||||
notification.success({
|
||||
title: '成功',
|
||||
content: '密码修改成功',
|
||||
duration: 3000
|
||||
})
|
||||
closePasswordModal()
|
||||
} catch (error: any) {
|
||||
console.error('修改密码失败:', error)
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: '修改密码失败: ' + (error.message || '未知错误'),
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 处理密码修改表单提交
|
||||
const handlePasswordChange = () => {
|
||||
changePassword()
|
||||
}
|
||||
|
||||
// 编辑用户
|
||||
const editUser = (user: any) => {
|
||||
editingUser.value = user
|
||||
form.value = {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
password: '',
|
||||
role: user.role,
|
||||
is_active: user.is_active
|
||||
}
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
const closeModal = () => {
|
||||
showCreateModal.value = false
|
||||
showEditModal.value = false
|
||||
editingUser.value = null
|
||||
form.value = {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'user',
|
||||
is_active: true
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = () => {
|
||||
if (showEditModal.value) {
|
||||
updateUser()
|
||||
} else {
|
||||
createUser()
|
||||
}
|
||||
}
|
||||
|
||||
// 获取角色样式
|
||||
const getRoleClass = (role: any) => {
|
||||
return role === 'admin' ? 'bg-red-100 text-red-800' : 'bg-blue-100 text-blue-800'
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString: any) => {
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkAuth()
|
||||
fetchUsers()
|
||||
})
|
||||
</script>
|
||||
@@ -1,111 +0,0 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- 页面标题 -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
<i class="fas fa-code-branch mr-3 text-blue-500"></i>
|
||||
版本信息
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
查看系统版本信息和更新状态
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 版本信息组件 -->
|
||||
<VersionInfo />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 设置页面布局
|
||||
definePageMeta({
|
||||
layout: 'admin-old',
|
||||
ssr: false
|
||||
})
|
||||
|
||||
interface VersionChange {
|
||||
type: 'feature' | 'fix' | 'improvement' | 'breaking'
|
||||
description: string
|
||||
}
|
||||
|
||||
interface VersionHistory {
|
||||
version: string
|
||||
date: string
|
||||
type: 'major' | 'minor' | 'patch'
|
||||
changes: VersionChange[]
|
||||
}
|
||||
|
||||
const versionHistory: VersionHistory[] = [
|
||||
{
|
||||
version: '1.0.0',
|
||||
date: '2024-01-15',
|
||||
type: 'major',
|
||||
changes: [
|
||||
{ type: 'feature', description: '🎉 首次发布' },
|
||||
{ type: 'feature', description: '📁 多平台网盘支持' },
|
||||
{ type: 'feature', description: '🔍 智能搜索功能' },
|
||||
{ type: 'feature', description: '📊 数据统计和分析' },
|
||||
{ type: 'feature', description: '🏷️ 标签系统' },
|
||||
{ type: 'feature', description: '👥 用户权限管理' },
|
||||
{ type: 'feature', description: '📦 批量资源管理' },
|
||||
{ type: 'feature', description: '🔄 自动处理功能' },
|
||||
{ type: 'feature', description: '📈 热播剧管理' },
|
||||
{ type: 'feature', description: '⚙️ 系统配置管理' },
|
||||
{ type: 'feature', description: '🔐 JWT认证系统' },
|
||||
{ type: 'feature', description: '📱 响应式设计' },
|
||||
{ type: 'feature', description: '🌙 深色模式支持' },
|
||||
{ type: 'feature', description: '🎨 现代化UI界面' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
// 获取版本类型样式
|
||||
const getVersionTypeClass = (type: string) => {
|
||||
switch (type) {
|
||||
case 'major':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
case 'minor':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
case 'patch':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取变更类型样式
|
||||
const getChangeTypeClass = (type: string) => {
|
||||
switch (type) {
|
||||
case 'feature':
|
||||
return 'text-green-600 dark:text-green-400'
|
||||
case 'fix':
|
||||
return 'text-red-600 dark:text-red-400'
|
||||
case 'improvement':
|
||||
return 'text-blue-600 dark:text-blue-400'
|
||||
case 'breaking':
|
||||
return 'text-orange-600 dark:text-orange-400'
|
||||
default:
|
||||
return 'text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取变更类型图标
|
||||
const getChangeTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'feature':
|
||||
return '✨'
|
||||
case 'fix':
|
||||
return '🐛'
|
||||
case 'improvement':
|
||||
return '🔧'
|
||||
case 'breaking':
|
||||
return '💥'
|
||||
default:
|
||||
return '<27><>'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -189,12 +189,34 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useConfigChangeDetection } from '~/composables/useConfigChangeDetection'
|
||||
|
||||
// 设置页面布局
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
ssr: false
|
||||
})
|
||||
|
||||
// 定义配置表单类型
|
||||
interface BotConfigForm {
|
||||
api_token: string
|
||||
}
|
||||
|
||||
// 使用配置改动检测
|
||||
const {
|
||||
setOriginalConfig,
|
||||
updateCurrentConfig,
|
||||
getChangedConfig,
|
||||
hasChanges,
|
||||
updateOriginalConfig,
|
||||
saveConfig: saveConfigWithDetection
|
||||
} = useConfigChangeDetection<BotConfigForm>({
|
||||
debug: true,
|
||||
fieldMapping: {
|
||||
api_token: 'api_token'
|
||||
}
|
||||
})
|
||||
|
||||
const notification = useNotification()
|
||||
const activeTab = ref('qq')
|
||||
|
||||
@@ -215,8 +237,13 @@ const fetchApiToken = async () => {
|
||||
const systemConfigApi = useSystemConfigApi()
|
||||
const response = await systemConfigApi.getSystemConfig()
|
||||
|
||||
if (response && (response as any).api_token) {
|
||||
apiToken.value = (response as any).api_token
|
||||
if (response) {
|
||||
const configData = {
|
||||
api_token: (response as any).api_token || ''
|
||||
}
|
||||
|
||||
apiToken.value = configData.api_token || '未配置API Token'
|
||||
setOriginalConfig(configData)
|
||||
} else {
|
||||
apiToken.value = '未配置API Token'
|
||||
}
|
||||
|
||||
@@ -42,11 +42,13 @@
|
||||
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>
|
||||
placeholder="搜索分类名称..."
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-search text-gray-400 text-sm"></i>
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
<n-button @click="refreshData" type="tertiary">
|
||||
<template #icon>
|
||||
|
||||
@@ -76,17 +76,39 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useConfigChangeDetection } from '~/composables/useConfigChangeDetection'
|
||||
|
||||
// 设置页面布局
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
ssr: false
|
||||
})
|
||||
|
||||
// 定义配置表单类型
|
||||
interface DevConfigForm {
|
||||
api_token: string
|
||||
}
|
||||
|
||||
// 使用配置改动检测
|
||||
const {
|
||||
setOriginalConfig,
|
||||
updateCurrentConfig,
|
||||
getChangedConfig,
|
||||
hasChanges,
|
||||
updateOriginalConfig,
|
||||
saveConfig: saveConfigWithDetection
|
||||
} = useConfigChangeDetection<DevConfigForm>({
|
||||
debug: true,
|
||||
fieldMapping: {
|
||||
api_token: 'api_token'
|
||||
}
|
||||
})
|
||||
|
||||
const notification = useNotification()
|
||||
const saving = ref(false)
|
||||
|
||||
// 配置表单数据
|
||||
const configForm = ref({
|
||||
const configForm = ref<DevConfigForm>({
|
||||
api_token: ''
|
||||
})
|
||||
|
||||
@@ -98,9 +120,12 @@ const fetchConfig = async () => {
|
||||
const response = await systemConfigApi.getSystemConfig()
|
||||
|
||||
if (response) {
|
||||
configForm.value = {
|
||||
api_token: response.api_token || ''
|
||||
const configData = {
|
||||
api_token: (response as any).api_token || ''
|
||||
}
|
||||
|
||||
configForm.value = { ...configData }
|
||||
setOriginalConfig(configData)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取系统配置失败:', error)
|
||||
@@ -116,28 +141,50 @@ const saveConfig = async () => {
|
||||
try {
|
||||
saving.value = true
|
||||
|
||||
const { useSystemConfigApi } = await import('~/composables/useApi')
|
||||
const systemConfigApi = useSystemConfigApi()
|
||||
|
||||
await systemConfigApi.updateSystemConfig({
|
||||
// 更新当前配置数据
|
||||
updateCurrentConfig({
|
||||
api_token: configForm.value.api_token
|
||||
})
|
||||
|
||||
notification.success({
|
||||
content: '开发配置保存成功',
|
||||
duration: 3000
|
||||
})
|
||||
const { useSystemConfigApi } = await import('~/composables/useApi')
|
||||
const systemConfigApi = useSystemConfigApi()
|
||||
|
||||
// 刷新系统配置状态,确保顶部导航同步更新
|
||||
const { useSystemConfigStore } = await import('~/stores/systemConfig')
|
||||
const systemConfigStore = useSystemConfigStore()
|
||||
await systemConfigStore.initConfig(true, true) // 强制刷新,使用管理员API
|
||||
} catch (error) {
|
||||
console.error('保存开发配置失败:', error)
|
||||
notification.error({
|
||||
content: '保存开发配置失败',
|
||||
duration: 3000
|
||||
})
|
||||
// 使用通用保存函数
|
||||
const result = await saveConfigWithDetection(
|
||||
systemConfigApi.updateSystemConfig,
|
||||
{
|
||||
onlyChanged: true,
|
||||
includeAllFields: true
|
||||
},
|
||||
// 成功回调
|
||||
async () => {
|
||||
notification.success({
|
||||
content: '开发配置保存成功',
|
||||
duration: 3000
|
||||
})
|
||||
|
||||
// 刷新系统配置状态,确保顶部导航同步更新
|
||||
const { useSystemConfigStore } = await import('~/stores/systemConfig')
|
||||
const systemConfigStore = useSystemConfigStore()
|
||||
await systemConfigStore.initConfig(true, true)
|
||||
},
|
||||
// 错误回调
|
||||
(error) => {
|
||||
console.error('保存开发配置失败:', error)
|
||||
notification.error({
|
||||
content: '保存开发配置失败',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// 如果没有改动,显示提示
|
||||
if (result && result.message === '没有检测到任何改动') {
|
||||
notification.info({
|
||||
content: '没有检测到任何改动',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
@@ -98,9 +98,18 @@
|
||||
|
||||
<!-- 广告关键词 -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<label class="text-base font-semibold text-gray-800 dark:text-gray-200">广告关键词</label>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">设置广告关键词,转存时,如果文件名包含广告关键词,则文件被删除</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<label class="text-base font-semibold text-gray-800 dark:text-gray-200">广告关键词</label>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">设置广告关键词,转存时,如果文件名包含广告关键词,则文件被删除</span>
|
||||
</div>
|
||||
<a
|
||||
href="https://raw.githubusercontent.com/ctwj/urldb/refs/heads/main/db/ad.txt"
|
||||
target="_blank"
|
||||
class="text-xs text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline"
|
||||
>
|
||||
开源广告关键词
|
||||
</a>
|
||||
</div>
|
||||
<n-input
|
||||
v-model:value="configForm.ad_keywords"
|
||||
@@ -156,18 +165,55 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useNotification } from 'naive-ui'
|
||||
import { useConfigChangeDetection } from '~/composables/useConfigChangeDetection'
|
||||
|
||||
// 设置页面布局
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
ssr: false
|
||||
})
|
||||
|
||||
// 配置表单数据类型
|
||||
interface FeatureConfigForm {
|
||||
auto_process_enabled: boolean
|
||||
auto_process_interval: string
|
||||
auto_transfer_enabled: boolean
|
||||
auto_transfer_min_space: string
|
||||
ad_keywords: string
|
||||
auto_insert_ad: string
|
||||
hot_drama_auto_fetch: boolean
|
||||
}
|
||||
|
||||
// 使用配置改动检测
|
||||
const {
|
||||
setOriginalConfig,
|
||||
updateCurrentConfig,
|
||||
getChangedConfig,
|
||||
hasChanges,
|
||||
updateOriginalConfig,
|
||||
saveConfig: saveConfigWithDetection
|
||||
} = useConfigChangeDetection<FeatureConfigForm>({
|
||||
debug: true,
|
||||
// 字段映射:前端字段名 -> 后端字段名
|
||||
fieldMapping: {
|
||||
auto_process_enabled: 'auto_process_ready_resources',
|
||||
auto_process_interval: 'auto_process_interval',
|
||||
auto_transfer_enabled: 'auto_transfer_enabled',
|
||||
auto_transfer_min_space: 'auto_transfer_min_space',
|
||||
ad_keywords: 'ad_keywords',
|
||||
auto_insert_ad: 'auto_insert_ad',
|
||||
hot_drama_auto_fetch: 'auto_fetch_hot_drama_enabled'
|
||||
}
|
||||
})
|
||||
|
||||
const notification = useNotification()
|
||||
const saving = ref(false)
|
||||
const activeTab = ref('resource')
|
||||
|
||||
// 配置表单数据
|
||||
const configForm = ref({
|
||||
const configForm = ref<FeatureConfigForm>({
|
||||
auto_process_enabled: false,
|
||||
auto_process_interval: '30',
|
||||
auto_transfer_enabled: false,
|
||||
@@ -188,7 +234,7 @@ const fetchConfig = async () => {
|
||||
const response = await systemConfigApi.getSystemConfig() as any
|
||||
|
||||
if (response) {
|
||||
configForm.value = {
|
||||
const configData = {
|
||||
auto_process_enabled: response.auto_process_ready_resources || false,
|
||||
auto_process_interval: String(response.auto_process_interval || 30),
|
||||
auto_transfer_enabled: response.auto_transfer_enabled || false,
|
||||
@@ -197,6 +243,9 @@ const fetchConfig = async () => {
|
||||
auto_insert_ad: response.auto_insert_ad || '',
|
||||
hot_drama_auto_fetch: response.auto_fetch_hot_drama_enabled || false
|
||||
}
|
||||
|
||||
configForm.value = { ...configData }
|
||||
setOriginalConfig(configData)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取系统配置失败:', error)
|
||||
@@ -212,34 +261,67 @@ const saveConfig = async () => {
|
||||
try {
|
||||
saving.value = true
|
||||
|
||||
// 更新当前配置数据
|
||||
updateCurrentConfig({
|
||||
auto_process_enabled: configForm.value.auto_process_enabled,
|
||||
auto_process_interval: configForm.value.auto_process_interval,
|
||||
auto_transfer_enabled: configForm.value.auto_transfer_enabled,
|
||||
auto_transfer_min_space: configForm.value.auto_transfer_min_space,
|
||||
ad_keywords: configForm.value.ad_keywords,
|
||||
auto_insert_ad: configForm.value.auto_insert_ad,
|
||||
hot_drama_auto_fetch: configForm.value.hot_drama_auto_fetch
|
||||
})
|
||||
|
||||
const { useSystemConfigApi } = await import('~/composables/useApi')
|
||||
const systemConfigApi = useSystemConfigApi()
|
||||
|
||||
await systemConfigApi.updateSystemConfig({
|
||||
auto_process_ready_resources: configForm.value.auto_process_enabled,
|
||||
auto_process_interval: parseInt(configForm.value.auto_process_interval) || 30,
|
||||
auto_transfer_enabled: configForm.value.auto_transfer_enabled,
|
||||
auto_transfer_min_space: parseInt(configForm.value.auto_transfer_min_space) || 500,
|
||||
ad_keywords: configForm.value.ad_keywords,
|
||||
auto_insert_ad: configForm.value.auto_insert_ad,
|
||||
auto_fetch_hot_drama_enabled: configForm.value.hot_drama_auto_fetch
|
||||
})
|
||||
// 使用通用保存函数
|
||||
const result = await saveConfigWithDetection(
|
||||
systemConfigApi.updateSystemConfig,
|
||||
{
|
||||
onlyChanged: true,
|
||||
includeAllFields: true,
|
||||
// 自定义数据转换
|
||||
transformSubmitData: (data) => {
|
||||
// 转换字符串为数字
|
||||
if (data.auto_process_interval !== undefined) {
|
||||
data.auto_process_interval = parseInt(data.auto_process_interval) || 30
|
||||
}
|
||||
if (data.auto_transfer_min_space !== undefined) {
|
||||
data.auto_transfer_min_space = parseInt(data.auto_transfer_min_space) || 500
|
||||
}
|
||||
return data
|
||||
}
|
||||
},
|
||||
// 成功回调
|
||||
async () => {
|
||||
notification.success({
|
||||
content: '功能配置保存成功',
|
||||
duration: 3000
|
||||
})
|
||||
|
||||
// 刷新系统配置状态,确保顶部导航同步更新
|
||||
const { useSystemConfigStore } = await import('~/stores/systemConfig')
|
||||
const systemConfigStore = useSystemConfigStore()
|
||||
await systemConfigStore.initConfig(true, true)
|
||||
},
|
||||
// 错误回调
|
||||
(error) => {
|
||||
console.error('保存功能配置失败:', error)
|
||||
notification.error({
|
||||
content: '保存功能配置失败',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
notification.success({
|
||||
content: '功能配置保存成功',
|
||||
duration: 3000
|
||||
})
|
||||
|
||||
// 刷新系统配置状态,确保顶部导航同步更新
|
||||
const { useSystemConfigStore } = await import('~/stores/systemConfig')
|
||||
const systemConfigStore = useSystemConfigStore()
|
||||
await systemConfigStore.initConfig(true, true) // 强制刷新,使用管理员API
|
||||
} catch (error) {
|
||||
console.error('保存功能配置失败:', error)
|
||||
notification.error({
|
||||
content: '保存功能配置失败',
|
||||
duration: 3000
|
||||
})
|
||||
// 如果没有改动,显示提示
|
||||
if (result && result.message === '没有检测到任何改动') {
|
||||
notification.info({
|
||||
content: '没有检测到任何改动',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
594
web/pages/admin/files.vue
Normal file
594
web/pages/admin/files.vue
Normal file
@@ -0,0 +1,594 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 页面标题 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">文件管理</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">管理系统中的上传文件</p>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<n-button type="primary" @click="openUploadModal">
|
||||
<template #icon>
|
||||
<i class="fas fa-upload"></i>
|
||||
</template>
|
||||
上传文件
|
||||
</n-button>
|
||||
<n-button @click="refreshData">
|
||||
<template #icon>
|
||||
<i class="fas fa-refresh"></i>
|
||||
</template>
|
||||
刷新
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<n-alert title="支持图片格式文件,最大文件大小5MB" type="info" />
|
||||
|
||||
<!-- 搜索和筛选 -->
|
||||
<n-card>
|
||||
<div class="flex gap-4">
|
||||
<n-input
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索原始文件名..."
|
||||
@keyup.enter="handleSearch"
|
||||
class="flex-1"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
</n-input>
|
||||
|
||||
<n-button type="primary" @click="handleSearch" class="w-20">
|
||||
<template #icon>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
搜索
|
||||
</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<n-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-lg font-semibold">文件列表</span>
|
||||
<span class="text-sm text-gray-500">共 {{ total }} 个文件</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<n-spin size="large" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="fileList.length === 0" class="text-center py-8">
|
||||
<i class="fas fa-file-upload text-4xl text-gray-400 mb-4"></i>
|
||||
<p class="text-gray-500">暂无文件数据</p>
|
||||
<n-button @click="openUploadModal" type="primary" class="mt-4">
|
||||
<template #icon>
|
||||
<i class="fas fa-upload"></i>
|
||||
</template>
|
||||
上传文件
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- 图片预览区域 -->
|
||||
<div class="image-preview-container">
|
||||
<n-image-group>
|
||||
<div class="image-grid">
|
||||
<div
|
||||
v-for="file in fileList"
|
||||
:key="file.id"
|
||||
class="image-item"
|
||||
:class="{ 'is-image': isImageFile(file) }"
|
||||
>
|
||||
<!-- 图片文件显示预览 -->
|
||||
<div v-if="isImageFile(file)" class="file-item cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg p-3 transition-colors border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<div class="image-preview relative">
|
||||
<n-image
|
||||
:src="getImageUrl(file.access_url)"
|
||||
:alt="file.original_name"
|
||||
:lazy="false"
|
||||
object-fit="cover"
|
||||
class="preview-image rounded"
|
||||
@error="handleImageError"
|
||||
@load="handleImageLoad"
|
||||
/>
|
||||
<div class="delete-button">
|
||||
<n-button
|
||||
size="small"
|
||||
type="error"
|
||||
circle
|
||||
@click="confirmDelete(file)"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-trash"></i>
|
||||
</template>
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-info mt-2">
|
||||
<div class="file-name text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{{ file.original_name }}
|
||||
</div>
|
||||
<div class="file-size text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatFileSize(file.file_size) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 非图片文件显示图标 -->
|
||||
<div v-else class="file-item cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg p-3 transition-colors border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 relative">
|
||||
<div class="file-icon">
|
||||
<i :class="getFileIconClass(file.file_type)"></i>
|
||||
</div>
|
||||
<div class="file-info">
|
||||
<div class="file-name text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{{ file.original_name }}
|
||||
</div>
|
||||
<div class="file-size text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatFileSize(file.file_size) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="delete-button">
|
||||
<n-button
|
||||
size="small"
|
||||
type="error"
|
||||
circle
|
||||
@click="confirmDelete(file)"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-trash"></i>
|
||||
</template>
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-image-group>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper">
|
||||
<n-pagination
|
||||
v-model:page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:page-count="Math.ceil(pagination.total / pagination.pageSize)"
|
||||
:page-sizes="pagination.pageSizes"
|
||||
show-size-picker
|
||||
@update:page="handlePageChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 上传模态框 -->
|
||||
<n-modal v-model:show="showUploadModal" preset="card" title="上传文件" style="width: 800px" @update:show="handleModalClose">
|
||||
<FileUpload ref="fileUploadRef" :key="uploadModalKey" />
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="showUploadModal = false">取消</n-button>
|
||||
<n-button type="primary" @click="handleUploadSuccess">确定</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<!-- 删除确认对话框 -->
|
||||
<n-modal v-model:show="showDeleteModal" preset="card" title="确认删除" style="width: 400px">
|
||||
<div class="text-center py-4">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-500 text-4xl mb-4"></i>
|
||||
<p class="text-lg font-medium mb-2">确定要删除这个文件吗?</p>
|
||||
<p class="text-gray-600 mb-4">{{ fileToDelete?.original_name }}</p>
|
||||
<p class="text-sm text-gray-500">此操作不可撤销,文件将被永久删除。</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="showDeleteModal = false">取消</n-button>
|
||||
<n-button type="error" @click="handleConfirmDelete">确认删除</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, h } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useFileApi } from '~/composables/useFileApi'
|
||||
import { useImageUrl } from '~/composables/useImageUrl'
|
||||
|
||||
|
||||
// 设置页面布局
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
interface FileItem {
|
||||
id: number
|
||||
original_name: string
|
||||
file_name: string
|
||||
file_path: string
|
||||
file_size: number
|
||||
file_type: string
|
||||
mime_type: string
|
||||
access_url: string
|
||||
user_id: number
|
||||
user: string
|
||||
status: string
|
||||
is_public: boolean
|
||||
is_deleted: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
const message = useMessage()
|
||||
const fileApi = useFileApi()
|
||||
const { getImageUrl } = useImageUrl()
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const fileList = ref<FileItem[]>([])
|
||||
const searchKeyword = ref('')
|
||||
const showUploadModal = ref(false)
|
||||
const fileUploadRef = ref()
|
||||
const uploadModalKey = ref(0)
|
||||
|
||||
// 删除确认相关
|
||||
const showDeleteModal = ref(false)
|
||||
const fileToDelete = ref<FileItem | null>(null)
|
||||
|
||||
// 分页
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
showSizePicker: true,
|
||||
pageSizes: [10, 20, 50, 100]
|
||||
})
|
||||
|
||||
// 总数
|
||||
const total = computed(() => pagination.value.total)
|
||||
|
||||
// 选项 - 已移除不需要的过滤条件
|
||||
|
||||
|
||||
|
||||
// 方法
|
||||
const loadFileList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.value.page,
|
||||
page_size: pagination.value.pageSize,
|
||||
search: searchKeyword.value
|
||||
}
|
||||
|
||||
console.log('发送文件列表请求参数:', params)
|
||||
|
||||
const response = await fileApi.getFileList(params)
|
||||
fileList.value = response.data.files || []
|
||||
pagination.value.total = response.data.total || 0
|
||||
|
||||
console.log('文件列表加载完成:', {
|
||||
total: pagination.value.total,
|
||||
files: fileList.value.map(f => ({
|
||||
id: f.id,
|
||||
name: f.original_name,
|
||||
type: f.file_type,
|
||||
url: f.access_url,
|
||||
isImage: isImageFile(f)
|
||||
}))
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('加载文件列表失败:', error)
|
||||
message.error('加载文件列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
console.log('执行搜索,关键词:', searchKeyword.value)
|
||||
pagination.value.page = 1
|
||||
loadFileList()
|
||||
}
|
||||
|
||||
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.value.page = page
|
||||
loadFileList()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (pageSize: number) => {
|
||||
pagination.value.pageSize = pageSize
|
||||
pagination.value.page = 1
|
||||
loadFileList()
|
||||
}
|
||||
|
||||
const copyFileUrl = async (file: FileItem) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(file.access_url)
|
||||
message.success('文件链接已复制到剪贴板')
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error)
|
||||
message.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
const openFile = (file: FileItem) => {
|
||||
window.open(file.access_url, '_blank')
|
||||
}
|
||||
|
||||
const toggleFilePublic = async (file: FileItem) => {
|
||||
try {
|
||||
await fileApi.updateFile({
|
||||
id: file.id,
|
||||
is_public: !file.is_public
|
||||
})
|
||||
message.success('文件状态更新成功')
|
||||
loadFileList()
|
||||
} catch (error) {
|
||||
console.error('更新文件状态失败:', error)
|
||||
message.error('更新文件状态失败')
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = (file: FileItem) => {
|
||||
fileToDelete.value = file
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!fileToDelete.value) return
|
||||
|
||||
try {
|
||||
await fileApi.deleteFiles([fileToDelete.value.id])
|
||||
message.success('文件删除成功')
|
||||
showDeleteModal.value = false
|
||||
fileToDelete.value = null
|
||||
loadFileList()
|
||||
} catch (error) {
|
||||
console.error('删除文件失败:', error)
|
||||
message.error('删除文件失败')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteFile = async (file: FileItem) => {
|
||||
try {
|
||||
await fileApi.deleteFiles([file.id])
|
||||
message.success('文件删除成功')
|
||||
loadFileList()
|
||||
} catch (error) {
|
||||
console.error('删除文件失败:', error)
|
||||
message.error('删除文件失败')
|
||||
}
|
||||
}
|
||||
|
||||
const refreshData = () => {
|
||||
loadFileList()
|
||||
}
|
||||
|
||||
const handleUploadSuccess = () => {
|
||||
// 重置上传组件状态
|
||||
if (fileUploadRef.value && fileUploadRef.value.resetUpload) {
|
||||
fileUploadRef.value.resetUpload()
|
||||
}
|
||||
showUploadModal.value = false
|
||||
loadFileList()
|
||||
message.success('文件上传成功')
|
||||
}
|
||||
|
||||
const openUploadModal = () => {
|
||||
uploadModalKey.value++ // 强制重新渲染组件
|
||||
showUploadModal.value = true
|
||||
}
|
||||
|
||||
const handleModalClose = (show: boolean) => {
|
||||
if (!show) {
|
||||
// 模态框关闭时重置上传组件状态
|
||||
if (fileUploadRef.value && fileUploadRef.value.resetUpload) {
|
||||
fileUploadRef.value.resetUpload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getFileIconClass = (fileType: string) => {
|
||||
const iconMap: Record<string, string> = {
|
||||
'image': 'fas fa-image text-blue-500',
|
||||
'jpeg': 'fas fa-image text-blue-500',
|
||||
'jpg': 'fas fa-image text-blue-500',
|
||||
'png': 'fas fa-image text-green-500',
|
||||
'gif': 'fas fa-image text-purple-500',
|
||||
'webp': 'fas fa-image text-orange-500',
|
||||
'bmp': 'fas fa-image text-red-500',
|
||||
'svg': 'fas fa-image text-indigo-500'
|
||||
}
|
||||
return iconMap[fileType] || 'fas fa-image text-gray-500'
|
||||
}
|
||||
|
||||
const getFileTypeLabel = (fileType: string) => {
|
||||
const labelMap: Record<string, string> = {
|
||||
'jpeg': 'JPEG',
|
||||
'jpg': 'JPEG',
|
||||
'png': 'PNG',
|
||||
'gif': 'GIF',
|
||||
'webp': 'WebP',
|
||||
'bmp': 'BMP',
|
||||
'svg': 'SVG'
|
||||
}
|
||||
return labelMap[fileType] || '图片'
|
||||
}
|
||||
|
||||
const formatFileSize = (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 isImageFile = (file: FileItem) => {
|
||||
// 后端返回的 file_type 是 "image",所以直接检查这个值
|
||||
const isImageByType = file.file_type.toLowerCase() === 'image'
|
||||
|
||||
// 检查文件名扩展名
|
||||
const imageExtensions = ['jpeg', 'jpg', 'png', 'gif', 'webp', 'bmp', 'svg']
|
||||
const fileNameLower = file.original_name.toLowerCase()
|
||||
const hasImageExtension = imageExtensions.some(ext => fileNameLower.endsWith(`.${ext}`))
|
||||
|
||||
// 检查 MIME 类型
|
||||
const mimeTypeLower = (file.mime_type || '').toLowerCase()
|
||||
const isImageByMime = mimeTypeLower.startsWith('image/')
|
||||
|
||||
// 综合判断
|
||||
const isImage = isImageByType || hasImageExtension || isImageByMime
|
||||
|
||||
console.log('isImageFile 详细检查:', {
|
||||
fileName: file.original_name,
|
||||
fileType: file.file_type,
|
||||
mimeType: file.mime_type,
|
||||
isImageByType: isImageByType,
|
||||
hasImageExtension: hasImageExtension,
|
||||
isImageByMime: isImageByMime,
|
||||
finalResult: isImage,
|
||||
accessUrl: file.access_url,
|
||||
processedUrl: getImageUrl(file.access_url)
|
||||
})
|
||||
|
||||
return isImage
|
||||
}
|
||||
|
||||
const handleImageError = (event: any) => {
|
||||
console.error('图片加载失败:', event)
|
||||
}
|
||||
|
||||
const handleImageLoad = (event: any) => {
|
||||
console.log('图片加载成功:', event)
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadFileList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 文件管理页面样式 */
|
||||
|
||||
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border: 1px solid #f3f4f6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.image-preview:hover .delete-button,
|
||||
.file-item:hover .delete-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.delete-button .n-button {
|
||||
background: rgba(239, 68, 68, 0.9);
|
||||
backdrop-filter: blur(4px);
|
||||
border: none;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
color: white;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.delete-button .n-button:hover {
|
||||
background: rgba(239, 68, 68, 1);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.delete-button .n-button i {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
.file-name {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.file-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.image-preview-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.image-preview-container::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.image-preview-container::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.image-preview-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
</style>
|
||||
@@ -30,15 +30,26 @@
|
||||
|
||||
<!-- 搜索 -->
|
||||
<n-card>
|
||||
<n-input
|
||||
v-model:value="searchQuery"
|
||||
placeholder="搜索热播剧..."
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
</n-input>
|
||||
<div class="flex gap-4">
|
||||
<n-input
|
||||
v-model:value="searchQuery"
|
||||
placeholder="搜索热播剧..."
|
||||
@keyup.enter="handleSearch"
|
||||
class="flex-1"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
</n-input>
|
||||
|
||||
<n-button type="primary" @click="handleSearch" class="w-20">
|
||||
<template #icon>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
搜索
|
||||
</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 热播剧列表 -->
|
||||
|
||||
@@ -24,15 +24,26 @@
|
||||
|
||||
<!-- 搜索 -->
|
||||
<n-card>
|
||||
<n-input
|
||||
v-model:value="searchQuery"
|
||||
placeholder="搜索平台..."
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
</n-input>
|
||||
<div class="flex gap-4">
|
||||
<n-input
|
||||
v-model:value="searchQuery"
|
||||
placeholder="搜索平台..."
|
||||
@keyup.enter="handleSearch"
|
||||
class="flex-1"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
</n-input>
|
||||
|
||||
<n-button type="primary" @click="handleSearch" class="w-20">
|
||||
<template #icon>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
搜索
|
||||
</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 平台列表 -->
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
v-model:value="searchQuery"
|
||||
placeholder="搜索资源..."
|
||||
@keyup.enter="handleSearch"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-search"></i>
|
||||
@@ -55,7 +56,7 @@
|
||||
clearable
|
||||
/>
|
||||
|
||||
<n-button type="primary" @click="handleSearch">
|
||||
<n-button type="primary" @click="handleSearch" class="w-20">
|
||||
<template #icon>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
|
||||
@@ -65,51 +65,60 @@
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 热门关键词 -->
|
||||
<n-card>
|
||||
<template #header>
|
||||
<span class="text-xl font-semibold text-gray-900 dark:text-white">热门关键词</span>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<div v-for="keyword in stats.hotKeywords" :key="keyword.keyword"
|
||||
class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<span class="inline-flex items-center justify-center w-8 h-8 bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400 rounded-full text-sm font-medium mr-3">
|
||||
{{ keyword.rank }}
|
||||
</span>
|
||||
<span class="text-gray-900 dark:text-white font-medium">{{ keyword.keyword }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-gray-600 dark:text-gray-400 mr-2">{{ keyword.count }}次</span>
|
||||
<div class="w-24 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div class="bg-blue-600 h-2 rounded-full"
|
||||
:style="{ width: getPercentage(keyword.count) + '%' }"></div>
|
||||
<!-- 热门关键词和搜索记录并排显示 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- 热门关键词 -->
|
||||
<n-card>
|
||||
<template #header>
|
||||
<span class="text-xl font-semibold text-gray-900 dark:text-white">热门关键词</span>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<div v-for="keyword in limitedHotKeywords" :key="keyword.keyword"
|
||||
class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<span class="inline-flex items-center justify-center w-8 h-8 bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400 rounded-full text-sm font-medium mr-3">
|
||||
{{ keyword.rank }}
|
||||
</span>
|
||||
<span class="text-gray-900 dark:text-white font-medium">{{ keyword.keyword }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-gray-600 dark:text-gray-400 mr-2">{{ keyword.count }}次</span>
|
||||
<div class="w-24 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div class="bg-blue-600 h-2 rounded-full"
|
||||
:style="{ width: getPercentage(keyword.count) + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!stats.hotKeywords || stats.hotKeywords.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
暂无热门关键词数据
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!stats.hotKeywords || stats.hotKeywords.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
暂无热门关键词数据
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-card>
|
||||
|
||||
<!-- 搜索记录 -->
|
||||
<n-card>
|
||||
<template #header>
|
||||
<span class="text-xl font-semibold text-gray-900 dark:text-white">搜索记录</span>
|
||||
</template>
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="searchList"
|
||||
:pagination="pagination"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
striped
|
||||
/>
|
||||
<div v-if="searchList.length === 0 && !loading" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
暂无搜索记录
|
||||
</div>
|
||||
</n-card>
|
||||
<!-- 搜索记录 -->
|
||||
<n-card>
|
||||
<template #header>
|
||||
<span class="text-xl font-semibold text-gray-900 dark:text-white">搜索记录</span>
|
||||
</template>
|
||||
<div class="space-y-3">
|
||||
<div v-for="record in limitedSearchList" :key="record.id"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-gray-900 dark:text-white">{{ record.keyword }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ formatDate(record.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">{{ record.count }}次</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="searchList.length === 0 && !loading" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
暂无搜索记录
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -160,44 +169,35 @@ const loading = ref(false)
|
||||
const trendChart = ref<HTMLCanvasElement | null>(null)
|
||||
let chart: any = null
|
||||
|
||||
// 分页配置
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
showSizePicker: true,
|
||||
pageSizes: [10, 20, 50, 100],
|
||||
onChange: (page: number) => {
|
||||
pagination.value.page = page
|
||||
loadSearchRecords()
|
||||
},
|
||||
onUpdatePageSize: (pageSize: number) => {
|
||||
pagination.value.pageSize = pageSize
|
||||
pagination.value.page = 1
|
||||
loadSearchRecords()
|
||||
}
|
||||
// 按时间排序的搜索记录(最新的在前面)
|
||||
const sortedSearchList = computed(() => {
|
||||
return [...searchList.value].sort((a, b) => {
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
})
|
||||
})
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: '关键词',
|
||||
key: 'keyword',
|
||||
width: 200
|
||||
},
|
||||
{
|
||||
title: '搜索次数',
|
||||
key: 'count',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '日期',
|
||||
key: 'date',
|
||||
width: 150,
|
||||
render: (row: any) => {
|
||||
return row.date ? new Date(row.date).toLocaleDateString() : ''
|
||||
}
|
||||
}
|
||||
]
|
||||
// 限制显示前10条热门关键词
|
||||
const limitedHotKeywords = computed(() => {
|
||||
return stats.value.hotKeywords.slice(0, 10)
|
||||
})
|
||||
|
||||
// 限制显示前10条搜索记录
|
||||
const limitedSearchList = computed(() => {
|
||||
return sortedSearchList.value.slice(0, 10)
|
||||
})
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取百分比
|
||||
const getPercentage = (count: number) => {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<n-tab-pane name="site-submit" tab="站点提交">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">站点提交</h3>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">站点提交(待开发)</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">向各大搜索引擎提交站点信息</p>
|
||||
</div>
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
<n-tab-pane name="link-building" tab="外链建设">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">外链建设</h3>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">外链建设(待开发)</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">管理和监控外部链接建设情况</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -70,6 +70,40 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 网站Logo -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<label class="text-base font-semibold text-gray-800 dark:text-gray-200">网站Logo</label>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">选择网站Logo图片,建议使用正方形图片</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div v-if="configForm.site_logo" class="flex-shrink-0">
|
||||
<n-image
|
||||
:src="getImageUrl(configForm.site_logo)"
|
||||
alt="网站Logo"
|
||||
width="80"
|
||||
height="80"
|
||||
object-fit="cover"
|
||||
class="rounded-lg border"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<n-button type="primary" @click="openLogoSelector">
|
||||
<template #icon>
|
||||
<i class="fas fa-image"></i>
|
||||
</template>
|
||||
{{ configForm.site_logo ? '更换Logo' : '选择Logo' }}
|
||||
</n-button>
|
||||
<n-button v-if="configForm.site_logo" @click="clearLogo" class="ml-2">
|
||||
<template #icon>
|
||||
<i class="fas fa-times"></i>
|
||||
</template>
|
||||
清除
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 版权信息 -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
@@ -109,9 +143,18 @@
|
||||
|
||||
<!-- 违禁词 -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<label class="text-base font-semibold text-gray-800 dark:text-gray-200">违禁词</label>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">包含这些词汇的资源将被过滤,多个词汇用逗号分隔</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<label class="text-base font-semibold text-gray-800 dark:text-gray-200">违禁词</label>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">包含这些词汇的资源将被过滤,多个词汇用逗号分隔</span>
|
||||
</div>
|
||||
<a
|
||||
href="https://raw.githubusercontent.com/ctwj/urldb/refs/heads/main/db/forbidden.txt"
|
||||
target="_blank"
|
||||
class="text-xs text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline"
|
||||
>
|
||||
开源违禁词
|
||||
</a>
|
||||
</div>
|
||||
<n-input
|
||||
v-model:value="configForm.forbidden_words"
|
||||
@@ -134,6 +177,100 @@
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-card>
|
||||
|
||||
<!-- Logo选择模态框 -->
|
||||
<n-modal v-model:show="showLogoSelector" preset="card" title="选择Logo图片" style="width: 90vw; max-width: 1200px; max-height: 80vh;">
|
||||
<div class="space-y-4">
|
||||
<!-- 搜索 -->
|
||||
<div class="flex gap-4">
|
||||
<n-input
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索文件名..."
|
||||
@keyup.enter="handleSearch"
|
||||
class="flex-1"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
</n-input>
|
||||
|
||||
<n-button type="primary" @click="handleSearch" class="w-20">
|
||||
<template #icon>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
搜索
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<n-spin size="large" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="fileList.length === 0" class="text-center py-8">
|
||||
<i class="fas fa-file-upload text-4xl text-gray-400 mb-4"></i>
|
||||
<p class="text-gray-500">暂无图片文件</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="file-grid">
|
||||
<div
|
||||
v-for="file in fileList"
|
||||
:key="file.id"
|
||||
class="file-item cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg p-3 transition-colors"
|
||||
:class="{ 'bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-300 dark:border-blue-600': selectedFileId === file.id }"
|
||||
@click="selectFile(file)"
|
||||
>
|
||||
<div class="image-preview">
|
||||
<n-image
|
||||
:src="getImageUrl(file.access_url)"
|
||||
:alt="file.original_name"
|
||||
:lazy="false"
|
||||
object-fit="cover"
|
||||
class="preview-image rounded"
|
||||
@error="handleImageError"
|
||||
@load="handleImageLoad"
|
||||
/>
|
||||
|
||||
<div class="image-info mt-2">
|
||||
<div class="file-name text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{{ file.original_name }}
|
||||
</div>
|
||||
<div class="file-size text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatFileSize(file.file_size) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper">
|
||||
<n-pagination
|
||||
v-model:page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:page-count="Math.ceil(pagination.total / pagination.pageSize)"
|
||||
:page-sizes="pagination.pageSizes"
|
||||
show-size-picker
|
||||
@update:page="handlePageChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="showLogoSelector = false">取消</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="confirmSelection"
|
||||
:disabled="!selectedFileId"
|
||||
>
|
||||
确认选择
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -144,29 +281,80 @@ definePageMeta({
|
||||
ssr: false
|
||||
})
|
||||
|
||||
|
||||
import { useImageUrl } from '~/composables/useImageUrl'
|
||||
import { useConfigChangeDetection } from '~/composables/useConfigChangeDetection'
|
||||
|
||||
const notification = useNotification()
|
||||
const { getImageUrl } = useImageUrl()
|
||||
const formRef = ref()
|
||||
const saving = ref(false)
|
||||
const activeTab = ref('basic')
|
||||
|
||||
// 配置表单数据
|
||||
const configForm = ref<{
|
||||
// Logo选择器相关数据
|
||||
const showLogoSelector = ref(false)
|
||||
const loading = ref(false)
|
||||
const fileList = ref<any[]>([])
|
||||
const selectedFileId = ref<number | null>(null)
|
||||
const searchKeyword = ref('')
|
||||
|
||||
// 分页
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
pageSizes: [10, 20, 50, 100]
|
||||
})
|
||||
|
||||
// 配置表单数据类型
|
||||
interface SiteConfigForm {
|
||||
site_title: string
|
||||
site_description: string
|
||||
keywords: string
|
||||
copyright: string
|
||||
site_logo: string
|
||||
maintenance_mode: boolean
|
||||
enable_register: boolean
|
||||
forbidden_words: string
|
||||
enable_sitemap: boolean
|
||||
sitemap_update_frequency: string
|
||||
}>({
|
||||
}
|
||||
|
||||
// 使用配置改动检测
|
||||
const {
|
||||
setOriginalConfig,
|
||||
updateCurrentConfig,
|
||||
getChangedConfig,
|
||||
hasChanges,
|
||||
getChangedDetails,
|
||||
updateOriginalConfig,
|
||||
saveConfig: saveConfigWithDetection
|
||||
} = useConfigChangeDetection<SiteConfigForm>({
|
||||
debug: true,
|
||||
// 字段映射:前端字段名 -> 后端字段名
|
||||
fieldMapping: {
|
||||
site_title: 'site_title',
|
||||
site_description: 'site_description',
|
||||
keywords: 'keywords',
|
||||
copyright: 'copyright',
|
||||
site_logo: 'site_logo',
|
||||
maintenance_mode: 'maintenance_mode',
|
||||
enable_register: 'enable_register',
|
||||
forbidden_words: 'forbidden_words',
|
||||
enable_sitemap: 'enable_sitemap',
|
||||
sitemap_update_frequency: 'sitemap_update_frequency'
|
||||
}
|
||||
})
|
||||
|
||||
// 配置表单数据
|
||||
const configForm = ref<SiteConfigForm>({
|
||||
site_title: '',
|
||||
site_description: '',
|
||||
keywords: '',
|
||||
copyright: '',
|
||||
site_logo: '',
|
||||
maintenance_mode: false,
|
||||
enable_register: false, // 新增:开启注册开关
|
||||
enable_register: false,
|
||||
forbidden_words: '',
|
||||
enable_sitemap: false,
|
||||
sitemap_update_frequency: 'daily'
|
||||
@@ -196,17 +384,22 @@ const fetchConfig = async () => {
|
||||
const response = await systemConfigApi.getSystemConfig() as any
|
||||
|
||||
if (response) {
|
||||
configForm.value = {
|
||||
const configData = {
|
||||
site_title: response.site_title || '',
|
||||
site_description: response.site_description || '',
|
||||
keywords: response.keywords || '',
|
||||
copyright: response.copyright || '',
|
||||
site_logo: response.site_logo || '',
|
||||
maintenance_mode: response.maintenance_mode || false,
|
||||
enable_register: response.enable_register || false, // 新增:获取开启注册开关
|
||||
enable_register: response.enable_register || false,
|
||||
forbidden_words: response.forbidden_words || '',
|
||||
enable_sitemap: response.enable_sitemap || false,
|
||||
sitemap_update_frequency: response.sitemap_update_frequency || 'daily'
|
||||
}
|
||||
|
||||
// 设置表单数据和原始数据
|
||||
configForm.value = { ...configData }
|
||||
setOriginalConfig(configData)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取系统配置失败:', error)
|
||||
@@ -217,47 +410,171 @@ const fetchConfig = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 保存配置
|
||||
const saveConfig = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
|
||||
saving.value = true
|
||||
|
||||
const { useSystemConfigApi } = await import('~/composables/useApi')
|
||||
const systemConfigApi = useSystemConfigApi()
|
||||
|
||||
await systemConfigApi.updateSystemConfig({
|
||||
// 更新当前配置数据
|
||||
updateCurrentConfig({
|
||||
site_title: configForm.value.site_title,
|
||||
site_description: configForm.value.site_description,
|
||||
keywords: configForm.value.keywords,
|
||||
copyright: configForm.value.copyright,
|
||||
site_logo: configForm.value.site_logo,
|
||||
maintenance_mode: configForm.value.maintenance_mode,
|
||||
enable_register: configForm.value.enable_register, // 新增:保存开启注册开关
|
||||
enable_register: configForm.value.enable_register,
|
||||
forbidden_words: configForm.value.forbidden_words,
|
||||
enable_sitemap: configForm.value.enable_sitemap,
|
||||
sitemap_update_frequency: configForm.value.sitemap_update_frequency
|
||||
})
|
||||
|
||||
notification.success({
|
||||
content: '站点配置保存成功',
|
||||
duration: 3000
|
||||
})
|
||||
const { useSystemConfigApi } = await import('~/composables/useApi')
|
||||
const systemConfigApi = useSystemConfigApi()
|
||||
|
||||
// 刷新系统配置状态,确保顶部导航同步更新
|
||||
const { useSystemConfigStore } = await import('~/stores/systemConfig')
|
||||
const systemConfigStore = useSystemConfigStore()
|
||||
await systemConfigStore.initConfig(true, true) // 强制刷新,使用管理员API
|
||||
} catch (error) {
|
||||
console.error('保存站点配置失败:', error)
|
||||
notification.error({
|
||||
content: '保存站点配置失败',
|
||||
duration: 3000
|
||||
})
|
||||
// 使用通用保存函数
|
||||
const result = await saveConfigWithDetection(
|
||||
systemConfigApi.updateSystemConfig,
|
||||
{
|
||||
onlyChanged: true,
|
||||
includeAllFields: true
|
||||
},
|
||||
// 成功回调
|
||||
async () => {
|
||||
notification.success({
|
||||
content: '站点配置保存成功',
|
||||
duration: 3000
|
||||
})
|
||||
|
||||
// 刷新系统配置状态,确保顶部导航同步更新
|
||||
const { useSystemConfigStore } = await import('~/stores/systemConfig')
|
||||
const systemConfigStore = useSystemConfigStore()
|
||||
await systemConfigStore.initConfig(true, true)
|
||||
},
|
||||
// 错误回调
|
||||
(error) => {
|
||||
console.error('保存站点配置失败:', error)
|
||||
notification.error({
|
||||
content: '保存站点配置失败',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// 如果没有改动,显示提示
|
||||
if (result && result.message === '没有检测到任何改动') {
|
||||
notification.info({
|
||||
content: '没有检测到任何改动',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Logo选择器方法
|
||||
const openLogoSelector = () => {
|
||||
showLogoSelector.value = true
|
||||
loadFileList()
|
||||
}
|
||||
|
||||
const clearLogo = () => {
|
||||
configForm.value.site_logo = ''
|
||||
}
|
||||
|
||||
const loadFileList = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const { useFileApi } = await import('~/composables/useFileApi')
|
||||
const fileApi = useFileApi()
|
||||
|
||||
const response = await fileApi.getFileList({
|
||||
page: pagination.value.page,
|
||||
pageSize: pagination.value.pageSize,
|
||||
search: searchKeyword.value,
|
||||
fileType: 'image', // 只获取图片文件
|
||||
status: 'active' // 只获取正常状态的文件
|
||||
}) as any
|
||||
|
||||
if (response && response.data) {
|
||||
fileList.value = response.data.files || []
|
||||
pagination.value.total = response.data.total || 0
|
||||
console.log('获取到的图片文件:', fileList.value) // 调试信息
|
||||
|
||||
// 添加图片URL处理调试
|
||||
fileList.value.forEach(file => {
|
||||
console.log('图片文件详情:', {
|
||||
id: file.id,
|
||||
name: file.original_name,
|
||||
accessUrl: file.access_url,
|
||||
processedUrl: getImageUrl(file.access_url),
|
||||
fileType: file.file_type,
|
||||
mimeType: file.mime_type
|
||||
})
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取文件列表失败:', error)
|
||||
notification.error({
|
||||
content: '获取文件列表失败',
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.value.page = 1
|
||||
loadFileList()
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.value.page = page
|
||||
loadFileList()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (pageSize: number) => {
|
||||
pagination.value.pageSize = pageSize
|
||||
pagination.value.page = 1
|
||||
loadFileList()
|
||||
}
|
||||
|
||||
const selectFile = (file: any) => {
|
||||
selectedFileId.value = file.id
|
||||
}
|
||||
|
||||
const confirmSelection = () => {
|
||||
if (selectedFileId.value) {
|
||||
const file = fileList.value.find(f => f.id === selectedFileId.value)
|
||||
if (file) {
|
||||
configForm.value.site_logo = file.access_url
|
||||
showLogoSelector.value = false
|
||||
selectedFileId.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const formatFileSize = (size: number) => {
|
||||
if (size < 1024) return size + ' B'
|
||||
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'
|
||||
if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
return (size / (1024 * 1024 * 1024)).toFixed(1) + ' GB'
|
||||
}
|
||||
|
||||
const handleImageError = (event: any) => {
|
||||
console.error('图片加载失败:', event)
|
||||
}
|
||||
|
||||
const handleImageLoad = (event: any) => {
|
||||
console.log('图片加载成功:', event)
|
||||
}
|
||||
|
||||
// 页面加载时获取配置
|
||||
onMounted(() => {
|
||||
fetchConfig()
|
||||
@@ -268,4 +585,37 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
/* 自定义样式 */
|
||||
.file-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
border: 1px solid #e5e7eb;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -42,11 +42,13 @@
|
||||
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>
|
||||
placeholder="搜索标签名称..."
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-search text-gray-400 text-sm"></i>
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
<n-button @click="refreshData" type="tertiary">
|
||||
<template #icon>
|
||||
|
||||
@@ -320,12 +320,33 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useConfigChangeDetection } from '~/composables/useConfigChangeDetection'
|
||||
|
||||
// 页面配置
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
// 定义配置表单类型
|
||||
interface ThirdPartyStatsForm {
|
||||
third_party_stats_code: string
|
||||
}
|
||||
|
||||
// 使用配置改动检测
|
||||
const {
|
||||
setOriginalConfig,
|
||||
updateCurrentConfig,
|
||||
getChangedConfig,
|
||||
hasChanges,
|
||||
updateOriginalConfig,
|
||||
saveConfig: saveConfigWithDetection
|
||||
} = useConfigChangeDetection<ThirdPartyStatsForm>({
|
||||
debug: true,
|
||||
fieldMapping: {
|
||||
third_party_stats_code: 'third_party_stats_code'
|
||||
}
|
||||
})
|
||||
|
||||
// 状态管理
|
||||
const message = useMessage()
|
||||
const statsCode = ref('')
|
||||
@@ -338,8 +359,13 @@ const fetchConfig = async () => {
|
||||
const systemConfigApi = useSystemConfigApi()
|
||||
const response = await systemConfigApi.getSystemConfig()
|
||||
|
||||
if (response && response.third_party_stats_code) {
|
||||
statsCode.value = response.third_party_stats_code
|
||||
if (response) {
|
||||
const configData = {
|
||||
third_party_stats_code: (response as any).third_party_stats_code || ''
|
||||
}
|
||||
|
||||
statsCode.value = configData.third_party_stats_code
|
||||
setOriginalConfig(configData)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取配置失败:', error)
|
||||
@@ -349,18 +375,39 @@ const fetchConfig = async () => {
|
||||
|
||||
// 保存配置
|
||||
const saveCode = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
const { useSystemConfigApi } = await import('~/composables/useApi')
|
||||
const systemConfigApi = useSystemConfigApi()
|
||||
await systemConfigApi.updateSystemConfig({
|
||||
saving.value = true
|
||||
|
||||
// 更新当前配置
|
||||
updateCurrentConfig({
|
||||
third_party_stats_code: statsCode.value
|
||||
})
|
||||
|
||||
message.success('配置保存成功')
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error)
|
||||
message.error('保存配置失败')
|
||||
const { useSystemConfigApi } = await import('~/composables/useApi')
|
||||
const systemConfigApi = useSystemConfigApi()
|
||||
|
||||
// 使用通用保存函数
|
||||
const result = await saveConfigWithDetection(
|
||||
systemConfigApi.updateSystemConfig,
|
||||
{
|
||||
onlyChanged: true,
|
||||
includeAllFields: true
|
||||
},
|
||||
// 成功回调
|
||||
() => {
|
||||
message.success('配置保存成功')
|
||||
},
|
||||
// 错误回调
|
||||
(error) => {
|
||||
console.error('保存配置失败:', error)
|
||||
message.error('保存配置失败')
|
||||
}
|
||||
)
|
||||
|
||||
// 如果没有改动,显示提示
|
||||
if (result && result.message === '没有检测到任何改动') {
|
||||
message.info('没有检测到任何改动')
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
v-model:value="searchQuery"
|
||||
placeholder="搜索资源标题..."
|
||||
@keyup.enter="handleSearch"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-search"></i>
|
||||
@@ -87,7 +88,7 @@
|
||||
:options="sortOptions"
|
||||
/>
|
||||
|
||||
<n-button type="primary" @click="handleSearch">
|
||||
<n-button type="primary" @click="handleSearch" class="w-20">
|
||||
<template #icon>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
|
||||
@@ -18,7 +18,14 @@
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<!-- 头部 -->
|
||||
<div class="header-container bg-slate-800 dark:bg-gray-800 text-white dark:text-gray-100 rounded-lg shadow-lg p-4 sm:p-8 mb-4 sm:mb-8 text-center relative">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold mb-4">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold mb-4 flex items-center justify-center gap-3">
|
||||
<img
|
||||
v-if="systemConfig?.site_logo"
|
||||
:src="getImageUrl(systemConfig.site_logo)"
|
||||
:alt="systemConfig?.site_title || 'Logo'"
|
||||
class="h-8 w-auto object-contain"
|
||||
@error="handleLogoError"
|
||||
/>
|
||||
<a href="/" class="text-white hover:text-gray-200 dark:hover:text-gray-300 no-underline">
|
||||
{{ systemConfig?.site_title || '老九网盘资源数据库' }}
|
||||
</a>
|
||||
@@ -62,8 +69,8 @@
|
||||
<div class="w-full max-w-3xl mx-auto mb-4 sm:mb-8 px-2 sm:px-0">
|
||||
<ClientOnly>
|
||||
<div class="relative">
|
||||
<n-input round placeholder="搜索" v-model:value="searchQuery" @blur="handleSearch" @keyup.enter="handleSearch">
|
||||
<template #suffix>
|
||||
<n-input round placeholder="搜索" v-model:value="searchQuery" @blur="handleSearch" @keyup.enter="handleSearch" clearable>
|
||||
<template #prefix>
|
||||
<i class="fas fa-search text-gray-400"></i>
|
||||
</template>
|
||||
</n-input>
|
||||
@@ -105,16 +112,16 @@
|
||||
|
||||
<!-- 资源列表 -->
|
||||
<div class="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<table class="w-full min-w-full table-fixed">
|
||||
<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-2 sm:px-6 py-3 sm:py-4 text-left text-xs sm:text-sm w-1/2 sm:w-4/6">
|
||||
<th class="px-2 sm:px-6 py-3 sm:py-4 text-left text-xs sm:text-sm">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-cloud mr-1 text-gray-300"></i> 文件名
|
||||
</div>
|
||||
</th>
|
||||
<th class="px-2 sm:px-6 py-3 sm:py-4 text-left text-xs sm:text-sm hidden sm:table-cell w-1/6">链接</th>
|
||||
<th class="px-2 sm:px-6 py-3 sm:py-4 text-left text-xs sm:text-sm hidden sm:table-cell w-1/6">更新时间</th>
|
||||
<th class="px-2 sm:px-6 py-3 sm:py-4 text-left text-xs sm:text-sm hidden sm:table-cell w-24">链接</th>
|
||||
<th class="px-2 sm:px-6 py-3 sm:py-4 text-left text-xs sm:text-sm hidden sm:table-cell w-32">更新时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@@ -136,10 +143,16 @@
|
||||
:class="isUpdatedToday(resource.updated_at) ? 'hover:bg-pink-50 dark:hover:bg-pink-900 bg-pink-50/30 dark:bg-pink-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-800'"
|
||||
:data-index="index"
|
||||
>
|
||||
<td class="px-2 sm:px-6 py-2 sm:py-4 text-xs sm:text-sm w-1/2 sm:w-2/5">
|
||||
<td class="px-2 sm:px-6 py-2 sm:py-4 text-xs sm:text-sm">
|
||||
<div class="flex items-start">
|
||||
<span class="mr-2 flex-shrink-0" v-html="getPlatformIcon(resource.pan_id || 0)"></span>
|
||||
<span class="break-words">{{ resource.title }}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="break-words font-medium">{{ resource.title }}</div>
|
||||
<!-- 显示描述 -->
|
||||
<div v-if="resource.description" class="text-xs text-gray-600 dark:text-gray-400 mt-1 break-words line-clamp-2">
|
||||
{{ resource.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm:hidden mt-1 space-y-1">
|
||||
<!-- 移动端显示更新时间 -->
|
||||
@@ -155,7 +168,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-2 sm:px-6 py-2 sm:py-4 text-xs sm:text-sm hidden sm:table-cell w-1/5">
|
||||
<td class="px-2 sm:px-6 py-2 sm:py-4 text-xs sm:text-sm hidden sm:table-cell w-32">
|
||||
<button
|
||||
class="text-blue-600 hover:text-blue-800 flex items-center gap-1 show-link-btn"
|
||||
@click="toggleLink(resource)"
|
||||
@@ -163,7 +176,7 @@
|
||||
<i class="fas fa-eye"></i> 显示链接
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-2 sm:px-6 py-2 sm:py-4 text-xs sm:text-sm text-gray-500 hidden sm:table-cell w-2/5" :title="resource.updated_at">
|
||||
<td class="px-2 sm:px-6 py-2 sm:py-4 text-xs sm:text-sm text-gray-500 hidden sm:table-cell w-32" :title="resource.updated_at">
|
||||
<span v-html="formatRelativeTime(resource.updated_at)"></span>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -224,7 +237,7 @@ useHead({
|
||||
// 获取运行时配置
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
import { useResourceApi, useStatsApi, usePanApi, useSystemConfigApi, usePublicSystemConfigApi } from '~/composables/useApi'
|
||||
import { useResourceApi, useStatsApi, usePanApi, useSystemConfigApi, usePublicSystemConfigApi, useSearchStatsApi } from '~/composables/useApi'
|
||||
|
||||
const resourceApi = useResourceApi()
|
||||
const statsApi = useStatsApi()
|
||||
@@ -244,6 +257,15 @@ const pageLoading = ref(false)
|
||||
// 用户状态管理
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 图片URL处理
|
||||
const { getImageUrl } = useImageUrl()
|
||||
|
||||
// Logo错误处理
|
||||
const handleLogoError = (event: Event) => {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.style.display = 'none'
|
||||
}
|
||||
|
||||
// 使用 useAsyncData 获取资源数据
|
||||
const { data: resourcesData, pending, refresh } = await useAsyncData(
|
||||
() => `resources-1-${route.query.search || ''}-${route.query.platform || ''}`,
|
||||
@@ -323,6 +345,20 @@ const handleSearch = () => {
|
||||
// 初始化认证状态
|
||||
onMounted(() => {
|
||||
animateCounters()
|
||||
|
||||
// 页面挂载完成时,如果有搜索关键词,请求 record 接口
|
||||
if (process.client && route.query.search) {
|
||||
const searchKeyword = route.query.search as string
|
||||
if (searchKeyword.trim()) {
|
||||
// 延迟执行,确保页面完全加载
|
||||
setTimeout(() => {
|
||||
const searchStatsApi = useSearchStatsApi()
|
||||
searchStatsApi.recordSearch({ keyword: searchKeyword }).catch(err => {
|
||||
console.error('记录搜索统计失败:', err)
|
||||
})
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -362,7 +398,7 @@ const toggleLink = async (resource: any) => {
|
||||
selectedResource.value = {
|
||||
...resource,
|
||||
loading: false,
|
||||
error: '获取链接失败,显示原始链接'
|
||||
error: '检测有效性失败,请自行验证'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -510,4 +546,27 @@ const animateCounters = () => {
|
||||
rgba(0,0,0,0.25) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* 文本截断样式 */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 表格单元格内容溢出控制 */
|
||||
table td {
|
||||
overflow: hidden;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 确保flex容器不会溢出 */
|
||||
.min-w-0 {
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -92,7 +92,7 @@
|
||||
clearable
|
||||
/>
|
||||
|
||||
<n-button type="primary" @click="handleSearch">
|
||||
<n-button type="primary" @click="handleSearch" class="w-20">
|
||||
<template #icon>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
clearable
|
||||
/>
|
||||
|
||||
<n-button type="primary" @click="handleSearch">
|
||||
<n-button type="primary" @click="handleSearch" class="w-20">
|
||||
<template #icon>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
clearable
|
||||
/>
|
||||
|
||||
<n-button type="primary" @click="handleSearch">
|
||||
<n-button type="primary" @click="handleSearch" class="w-20">
|
||||
<template #icon>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
|
||||
158
web/pnpm-lock.yaml
generated
158
web/pnpm-lock.yaml
generated
@@ -23,9 +23,6 @@ importers:
|
||||
'@pinia/nuxt':
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.5(magicast@0.3.5)(typescript@5.8.3)(vue@3.5.18(typescript@5.8.3))
|
||||
'@types/qrcode':
|
||||
specifier: ^1.5.5
|
||||
version: 1.5.5
|
||||
'@vicons/ionicons5':
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.0
|
||||
@@ -38,9 +35,6 @@ importers:
|
||||
pinia:
|
||||
specifier: ^2.1.0
|
||||
version: 2.3.1(typescript@5.8.3)(vue@3.5.18(typescript@5.8.3))
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
vfonts:
|
||||
specifier: ^0.0.3
|
||||
version: 0.0.3
|
||||
@@ -1238,9 +1232,6 @@ packages:
|
||||
'@types/pug@2.0.10':
|
||||
resolution: {integrity: sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==}
|
||||
|
||||
'@types/qrcode@1.5.5':
|
||||
resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==}
|
||||
|
||||
'@types/qs@6.14.0':
|
||||
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
|
||||
|
||||
@@ -1703,10 +1694,6 @@ packages:
|
||||
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
camelcase@5.3.1:
|
||||
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
caniuse-api@3.0.0:
|
||||
resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==}
|
||||
|
||||
@@ -1744,9 +1731,6 @@ packages:
|
||||
resolution: {integrity: sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cliui@6.0.0:
|
||||
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||
|
||||
cliui@8.0.1:
|
||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2009,10 +1993,6 @@ packages:
|
||||
decache@4.6.2:
|
||||
resolution: {integrity: sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw==}
|
||||
|
||||
decamelize@1.2.0:
|
||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
deep-equal@1.0.1:
|
||||
resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==}
|
||||
|
||||
@@ -2123,9 +2103,6 @@ packages:
|
||||
resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
dijkstrajs@1.0.3:
|
||||
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||
|
||||
dlv@1.1.3:
|
||||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||
|
||||
@@ -2380,10 +2357,6 @@ packages:
|
||||
resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
find-up@4.1.0:
|
||||
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
find-up@7.0.0:
|
||||
resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2887,10 +2860,6 @@ packages:
|
||||
resolution: {integrity: sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
locate-path@5.0.0:
|
||||
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
locate-path@7.2.0:
|
||||
resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
@@ -3271,18 +3240,10 @@ packages:
|
||||
resolution: {integrity: sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==}
|
||||
engines: {node: '>=16.17'}
|
||||
|
||||
p-limit@2.3.0:
|
||||
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
p-limit@4.0.0:
|
||||
resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
p-locate@4.1.0:
|
||||
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
p-locate@6.0.0:
|
||||
resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
@@ -3295,10 +3256,6 @@ packages:
|
||||
resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
p-try@2.2.0:
|
||||
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
p-wait-for@5.0.2:
|
||||
resolution: {integrity: sha512-lwx6u1CotQYPVju77R+D0vFomni/AqRfqLmqQ8hekklqZ6gAY9rONh7lBQ0uxWMkC2AuX9b2DVAl8To0NyP1JA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -3336,10 +3293,6 @@ packages:
|
||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
path-exists@4.0.0:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
path-exists@5.0.0:
|
||||
resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
@@ -3420,10 +3373,6 @@ packages:
|
||||
pkg-types@2.2.0:
|
||||
resolution: {integrity: sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==}
|
||||
|
||||
pngjs@5.0.0:
|
||||
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
portfinder@1.0.37:
|
||||
resolution: {integrity: sha512-yuGIEjDAYnnOex9ddMnKZEMFE0CcGo6zbfzDklkmT1m5z734ss6JMzN9rNB3+RR7iS+F10D4/BVIaXOyh8PQKw==}
|
||||
engines: {node: '>= 10.12'}
|
||||
@@ -3684,11 +3633,6 @@ packages:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
qrcode@1.5.4:
|
||||
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
|
||||
qs@6.14.0:
|
||||
resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
|
||||
engines: {node: '>=0.6'}
|
||||
@@ -3772,9 +3716,6 @@ packages:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-main-filename@2.0.0:
|
||||
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||
|
||||
require-package-name@2.0.1:
|
||||
resolution: {integrity: sha512-uuoJ1hU/k6M0779t3VMVIYpb2VMJk05cehCaABFhXaibcbvfgR8wKiozLjVFSzJPmQMRqIcO0HMyTFqfV09V6Q==}
|
||||
|
||||
@@ -3885,9 +3826,6 @@ packages:
|
||||
resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
set-blocking@2.0.0:
|
||||
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||
|
||||
setprototypeof@1.1.0:
|
||||
resolution: {integrity: sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==}
|
||||
|
||||
@@ -4600,9 +4538,6 @@ packages:
|
||||
whatwg-url@5.0.0:
|
||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||
|
||||
which-module@2.0.1:
|
||||
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -4621,10 +4556,6 @@ packages:
|
||||
resolution: {integrity: sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -4656,9 +4587,6 @@ packages:
|
||||
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
y18n@4.0.3:
|
||||
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||
|
||||
y18n@5.0.8:
|
||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -4679,18 +4607,10 @@ packages:
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
yargs-parser@21.1.1:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yargs@15.4.1:
|
||||
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
yargs@17.7.2:
|
||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -5942,10 +5862,6 @@ snapshots:
|
||||
|
||||
'@types/pug@2.0.10': {}
|
||||
|
||||
'@types/qrcode@1.5.5':
|
||||
dependencies:
|
||||
'@types/node': 20.19.9
|
||||
|
||||
'@types/qs@6.14.0': {}
|
||||
|
||||
'@types/range-parser@1.2.7': {}
|
||||
@@ -6534,8 +6450,6 @@ snapshots:
|
||||
|
||||
camelcase-css@2.0.1: {}
|
||||
|
||||
camelcase@5.3.1: {}
|
||||
|
||||
caniuse-api@3.0.0:
|
||||
dependencies:
|
||||
browserslist: 4.25.1
|
||||
@@ -6584,12 +6498,6 @@ snapshots:
|
||||
is-wsl: 3.1.0
|
||||
is64bit: 2.0.0
|
||||
|
||||
cliui@6.0.0:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 6.2.0
|
||||
|
||||
cliui@8.0.1:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
@@ -6829,8 +6737,6 @@ snapshots:
|
||||
dependencies:
|
||||
callsite: 1.0.0
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
deep-equal@1.0.1: {}
|
||||
|
||||
deepmerge@4.3.1: {}
|
||||
@@ -6926,8 +6832,6 @@ snapshots:
|
||||
|
||||
diff@8.0.2: {}
|
||||
|
||||
dijkstrajs@1.0.3: {}
|
||||
|
||||
dlv@1.1.3: {}
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
@@ -7203,11 +7107,6 @@ snapshots:
|
||||
|
||||
find-up-simple@1.0.1: {}
|
||||
|
||||
find-up@4.1.0:
|
||||
dependencies:
|
||||
locate-path: 5.0.0
|
||||
path-exists: 4.0.0
|
||||
|
||||
find-up@7.0.0:
|
||||
dependencies:
|
||||
locate-path: 7.2.0
|
||||
@@ -7751,10 +7650,6 @@ snapshots:
|
||||
pkg-types: 2.2.0
|
||||
quansync: 0.2.10
|
||||
|
||||
locate-path@5.0.0:
|
||||
dependencies:
|
||||
p-locate: 4.1.0
|
||||
|
||||
locate-path@7.2.0:
|
||||
dependencies:
|
||||
p-locate: 6.0.0
|
||||
@@ -8325,18 +8220,10 @@ snapshots:
|
||||
dependencies:
|
||||
p-timeout: 6.1.4
|
||||
|
||||
p-limit@2.3.0:
|
||||
dependencies:
|
||||
p-try: 2.2.0
|
||||
|
||||
p-limit@4.0.0:
|
||||
dependencies:
|
||||
yocto-queue: 1.2.1
|
||||
|
||||
p-locate@4.1.0:
|
||||
dependencies:
|
||||
p-limit: 2.3.0
|
||||
|
||||
p-locate@6.0.0:
|
||||
dependencies:
|
||||
p-limit: 4.0.0
|
||||
@@ -8345,8 +8232,6 @@ snapshots:
|
||||
|
||||
p-timeout@6.1.4: {}
|
||||
|
||||
p-try@2.2.0: {}
|
||||
|
||||
p-wait-for@5.0.2:
|
||||
dependencies:
|
||||
p-timeout: 6.1.4
|
||||
@@ -8385,8 +8270,6 @@ snapshots:
|
||||
|
||||
parseurl@1.3.3: {}
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
|
||||
path-exists@5.0.0: {}
|
||||
|
||||
path-is-absolute@1.0.1: {}
|
||||
@@ -8448,8 +8331,6 @@ snapshots:
|
||||
exsolve: 1.0.7
|
||||
pathe: 2.0.3
|
||||
|
||||
pngjs@5.0.0: {}
|
||||
|
||||
portfinder@1.0.37:
|
||||
dependencies:
|
||||
async: 3.2.6
|
||||
@@ -8709,12 +8590,6 @@ snapshots:
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qrcode@1.5.4:
|
||||
dependencies:
|
||||
dijkstrajs: 1.0.3
|
||||
pngjs: 5.0.0
|
||||
yargs: 15.4.1
|
||||
|
||||
qs@6.14.0:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
@@ -8808,8 +8683,6 @@ snapshots:
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
require-main-filename@2.0.0: {}
|
||||
|
||||
require-package-name@2.0.1: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
@@ -8946,8 +8819,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
set-blocking@2.0.0: {}
|
||||
|
||||
setprototypeof@1.1.0: {}
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
@@ -9689,8 +9560,6 @@ snapshots:
|
||||
tr46: 0.0.3
|
||||
webidl-conversions: 3.0.1
|
||||
|
||||
which-module@2.0.1: {}
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
@@ -9719,12 +9588,6 @@ snapshots:
|
||||
triple-beam: 1.4.1
|
||||
winston-transport: 4.9.0
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@@ -9750,8 +9613,6 @@ snapshots:
|
||||
dependencies:
|
||||
is-wsl: 3.1.0
|
||||
|
||||
y18n@4.0.3: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
yallist@3.1.1: {}
|
||||
@@ -9762,27 +9623,8 @@ snapshots:
|
||||
|
||||
yaml@2.8.0: {}
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
dependencies:
|
||||
camelcase: 5.3.1
|
||||
decamelize: 1.2.0
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
yargs@15.4.1:
|
||||
dependencies:
|
||||
cliui: 6.0.0
|
||||
decamelize: 1.2.0
|
||||
find-up: 4.1.0
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
require-main-filename: 2.0.0
|
||||
set-blocking: 2.0.0
|
||||
string-width: 4.2.3
|
||||
which-module: 2.0.1
|
||||
y18n: 4.0.3
|
||||
yargs-parser: 18.1.3
|
||||
|
||||
yargs@17.7.2:
|
||||
dependencies:
|
||||
cliui: 8.0.1
|
||||
|
||||
@@ -66,7 +66,7 @@ export const useTaskStore = defineStore('task', () => {
|
||||
const fetchTaskStats = async () => {
|
||||
try {
|
||||
const response = await taskApi.getTasks() as any
|
||||
console.log('原始任务API响应:', response)
|
||||
// console.log('原始任务API响应:', response)
|
||||
|
||||
// 处理API响应格式
|
||||
let tasks: TaskInfo[] = []
|
||||
@@ -76,7 +76,7 @@ export const useTaskStore = defineStore('task', () => {
|
||||
tasks = response
|
||||
}
|
||||
|
||||
console.log('解析后的任务列表:', tasks)
|
||||
// console.log('解析后的任务列表:', tasks)
|
||||
|
||||
if (tasks && tasks.length >= 0) {
|
||||
// 重置统计
|
||||
@@ -94,7 +94,7 @@ export const useTaskStore = defineStore('task', () => {
|
||||
|
||||
// 统计各种状态的任务
|
||||
tasks.forEach((task: TaskInfo) => {
|
||||
console.log('处理任务:', task.id, '状态:', task.status, '是否运行中:', task.is_running)
|
||||
// console.log('处理任务:', task.id, '状态:', task.status, '是否运行中:', task.is_running)
|
||||
|
||||
// 如果任务标记为运行中,优先使用running状态
|
||||
let currentStatus = task.status
|
||||
@@ -130,9 +130,9 @@ export const useTaskStore = defineStore('task', () => {
|
||||
runningTasks.value = running
|
||||
incompleteTasks.value = incomplete
|
||||
|
||||
console.log('任务统计更新:', stats)
|
||||
console.log('运行中的任务:', running)
|
||||
console.log('未完成的任务:', incomplete)
|
||||
// console.log('任务统计更新:', stats)
|
||||
// console.log('运行中的任务:', running)
|
||||
// console.log('未完成的任务:', incomplete)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取任务统计失败:', error)
|
||||
|
||||
Reference in New Issue
Block a user