43 Commits

Author SHA1 Message Date
Kerwin
3a90a89b08 chore: bump version to v1.2.5 2025-08-28 13:33:45 +08:00
Kerwin
80a94c0f05 fix: 页面跳转问题 2025-08-27 18:38:40 +08:00
Kerwin
d49ce77350 update:remove docs 2025-08-27 16:11:19 +08:00
ctwj
292384f281 Merge pull request #6 from ctwj/fix_res
fix: 一致问题修复
2025-08-25 11:28:17 +08:00
Kerwin
b8b0cc760d update: 优化空状态显示 2025-08-25 11:27:02 +08:00
Kerwin
002267e436 fix: 修复资源自动处理的问题 2025-08-25 09:51:45 +08:00
ctwj
0d54dffa19 Merge pull request #5 from ctwj/fix_filter
update: 完善Meilisearch的同步操作
2025-08-22 14:47:55 +08:00
Kerwin
d2c9d79658 update: 处理缓存是先检测配置项是否开启 2025-08-22 14:44:36 +08:00
Kerwin
f70850d465 update: 自动同步资源到 Meilisearch 2025-08-22 14:40:32 +08:00
ctwj
223b1af714 fix: 修复违禁词正常显示的问题
fix: 修复违禁词正常显示的问题
2025-08-22 09:32:12 +08:00
Kerwin
76a64492a2 update: 弹窗优化 2025-08-21 19:07:57 +08:00
Kerwin
d6224ab25c fix: 修复封禁词,没有过滤的问题 2025-08-21 18:51:20 +08:00
Kerwin
9708157566 chore: version to 1.2.4 2025-08-21 09:23:45 +08:00
Kerwin
8cf1575232 chore: bump version to v1.2.4 2025-08-20 17:26:25 +08:00
ctwj
17c05870a3 Merge pull request #3 from ctwj/feat_search_opt
feat: 新增搜索增加
2025-08-20 17:24:25 +08:00
ctwj
d531be3c36 Merge pull request #2 from ctwj/fix_version
fix: 修复版本显示不正确的问题
2025-08-20 17:19:41 +08:00
Kerwin
edde7afdc8 fix: 修复版本显示不正确的问题 2025-08-20 17:16:34 +08:00
Kerwin
77216cf380 feat: 新增搜索增加 2025-08-20 15:03:14 +08:00
Kerwin
da3fc11b2e fix: 修复文件管理搜索不生效的问题 2025-08-19 09:09:44 +08:00
ctwj
cbf673126e update: 首页优化 2025-08-19 01:11:09 +08:00
ctwj
aa7d6ea2fe add: sql 2025-08-18 23:02:04 +08:00
Kerwin
841eb05f68 update: AppFooter 2025-08-18 20:10:09 +08:00
Kerwin
eeca85942f update: 移除api的特殊处理,使用配置项实现 2025-08-18 19:40:10 +08:00
Kerwin
c053a17131 chore: version to 1.2.3 2025-08-18 16:02:52 +08:00
Kerwin
3d29f1bf23 chore: bump version to v1.2.3 2025-08-18 15:32:29 +08:00
Kerwin
a15a0fe2be chore: bump version to v1.2.2 2025-08-18 15:08:49 +08:00
Kerwin
05243bcfe7 fix: 修复有可能配置丢失的问题 2025-08-18 13:38:52 +08:00
Kerwin
98b94b3313 update: 完善图片上传 2025-08-18 09:41:19 +08:00
ctwj
949a328ee3 update: 添加logo的配置 2025-08-18 02:30:15 +08:00
ctwj
acb462c6d5 add: xunlei 2025-08-17 23:22:57 +08:00
ctwj
e52043505f update: 修复上传问题 2025-08-17 08:46:51 +08:00
Kerwin
9d4eb38272 add: 新增文件上传功能 2025-08-15 18:41:09 +08:00
Kerwin
14ef85801a update: 移除旧版管理后台 2025-08-15 13:55:55 +08:00
Kerwin
3f4430104d update: 优化二维码显示样式 2025-08-14 18:03:20 +08:00
Kerwin
709029a123 fix: 修复二维码不显示的问题 2025-08-14 17:51:43 +08:00
Kerwin
559d69f52b fix: 搜索记录重复的问题 2025-08-14 09:46:13 +08:00
ctwj
dcd5e0bf73 update: 更新广告关键词,添加默认的开源关键词连接 2025-08-14 00:20:47 +08:00
ctwj
4343a29bb3 fix: 修复广告配置问题 2025-08-14 00:05:35 +08:00
ctwj
3bf0d59a9c update: 完善转存的广告 2025-08-13 23:30:42 +08:00
Kerwin
c3b2979977 add: 添加默认广告词 2025-08-13 17:33:34 +08:00
Kerwin
6de20b7e13 chore: bump version to 1.2.1 2025-08-13 15:28:51 +08:00
Kerwin
2d96413a5d Merge branch 'main' of github.com:ctwj/urldb 2025-08-13 15:22:32 +08:00
ctwj
fae7de17d5 fix: 修复了转存删除和添加广告的问题 2025-08-13 00:28:18 +08:00
130 changed files with 9093 additions and 8785 deletions

130
BUILD.md Normal file
View File

@@ -0,0 +1,130 @@
# 编译说明
## 方案1使用编译脚本推荐
### 在Git Bash中执行
```bash
# 给脚本添加执行权限(首次使用)
chmod +x scripts/build.sh
# 编译Linux版本推荐用于服务器部署
./scripts/build.sh
# 或者明确指定编译Linux版本
./scripts/build.sh build-linux
# 或者指定目标文件名
./scripts/build.sh build-linux myapp
# 编译当前平台版本(用于本地测试)
./scripts/build.sh build
```
### 编译脚本功能:
- 自动读取 `VERSION` 文件中的版本号
- 自动获取Git提交信息和分支信息
- 自动获取构建时间
- 将版本信息编译到可执行文件中
- 支持跨平台编译默认编译Linux版本
- 使用静态链接,适合服务器部署
## 方案2手动编译
### Linux版本推荐
```bash
# 获取版本信息
VERSION=$(cat VERSION)
GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
GIT_BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
BUILD_TIME=$(date '+%Y-%m-%d %H:%M:%S')
# 编译Linux版本
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags "-X 'github.com/ctwj/urldb/utils.Version=${VERSION}' -X 'github.com/ctwj/urldb/utils.BuildTime=${BUILD_TIME}' -X 'github.com/ctwj/urldb/utils.GitCommit=${GIT_COMMIT}' -X 'github.com/ctwj/urldb/utils.GitBranch=${GIT_BRANCH}'" -o main .
```
### 当前平台版本:
```bash
# 获取版本信息
VERSION=$(cat VERSION)
GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
GIT_BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
BUILD_TIME=$(date '+%Y-%m-%d %H:%M:%S')
# 编译当前平台版本
go build -ldflags "-X 'github.com/ctwj/urldb/utils.Version=${VERSION}' -X 'github.com/ctwj/urldb/utils.BuildTime=${BUILD_TIME}' -X 'github.com/ctwj/urldb/utils.GitCommit=${GIT_COMMIT}' -X 'github.com/ctwj/urldb/utils.GitBranch=${GIT_BRANCH}'" -o main .
```
## 验证版本信息
编译完成后,可以通过以下方式验证版本信息:
```bash
# 命令行验证
./main version
# 启动服务器后通过API验证
curl http://localhost:8080/api/version
```
## 部署说明
使用方案1编译后部署时只需要
1. 复制可执行文件到服务器
2. 启动程序
**不再需要复制 `VERSION` 文件**,因为版本信息已经编译到程序中。
### 使用部署脚本(可选)
```bash
# 给部署脚本添加执行权限
chmod +x scripts/deploy-example.sh
# 部署到服务器
./scripts/deploy-example.sh root example.com /opt/urldb
```
### 使用Docker构建脚本
```bash
# 给脚本添加执行权限
chmod +x scripts/docker-build.sh
# 构建Docker镜像
./scripts/docker-build.sh build
# 构建指定版本镜像
./scripts/docker-build.sh build 1.2.4
# 推送镜像到Docker Hub
./scripts/docker-build.sh push 1.2.4
```
### 手动Docker构建
```bash
# 构建镜像
docker build --target backend -t ctwj/urldb-backend:1.2.3 .
docker build --target frontend -t ctwj/urldb-frontend:1.2.3 .
```
## 版本管理
更新版本号:
```bash
# 更新版本号
./scripts/version.sh patch # 修订版本
./scripts/version.sh minor # 次版本
./scripts/version.sh major # 主版本
# 然后重新编译
./scripts/build.sh
# 或者构建Docker镜像
./scripts/docker-build.sh build
```

View File

@@ -1,88 +0,0 @@
# 📝 更新日志
本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/) 规范。
## [v1.1.0]
1. 新增违禁词功能
2. 管理后台体验优化
3. bug修复
## [v1.0.0]
1. 自动转存
2. 自动资源处理
### 新增
- 项目开源准备
- 完善文档和贡献指南
- 添加LICENSE文件
### 修复
- 修复README格式问题
- 优化项目结构说明
## [100 - 202401XX
### 新增
- 🎉 首次发布
- 📁 多平台网盘支持夸克、阿里云盘、百度网盘、UC网盘
- 🔍 智能搜索功能
- 📊 数据统计和分析
- 🏷️ 标签系统
- 👥 用户权限管理
- 📦 批量资源管理
- 🔄 自动处理功能
- 📈 热播剧管理
- ⚙️ 系统配置管理
- 🔐 JWT认证系统
- 📱 响应式设计
- 🌙 深色模式支持
- 🎨 现代化UI界面
### 技术特性
- 🦀 基于Golang 1023的高性能后端
- ⚡ Nuxt.js 3 + Vue 3前端框架
- 🗄️ PostgreSQL数据库
- 🔧 GORM ORM框架
- 🐳 Docker容器化部署
- 📝 TypeScript类型安全
### 核心功能
- 资源管理:增删改查、批量操作
- 分类管理:资源分类和标签
- 平台管理:多网盘平台支持
- 搜索统计:全文搜索和数据分析
- 系统配置:灵活的参数配置
---
## 版本说明
### 版本号格式
- **主版本号**不兼容的API修改
- **次版本号**:向下兼容的功能性新增
- **修订号**:向下兼容的问题修正
### 更新类型
- 🎉 **重大更新** - 新版本发布
-**新增功能** - 新功能或特性
- 🐛 **问题修复** - Bug修复
- 🔧 **优化改进** - 性能优化或代码改进
- 📚 **文档更新** - 文档或注释更新
- 🎨 **界面优化** - UI/UX改进
-**性能提升** - 性能相关改进
- 🔒 **安全更新** - 安全相关修复
- 🧪 **测试相关** - 测试用例或测试工具
- 🚀 **部署相关** - 部署或构建相关
---
## 贡献
如果您想为更新日志做出贡献,请:
1. 在提交代码时使用规范的提交信息2. 在Pull Request中描述您的更改
3. 遵循项目的贡献指南
---

29
ChangeLog.md Normal file
View File

@@ -0,0 +1,29 @@
### v1.2.4
1. 搜索增强,毫秒级响应,关键字高亮显示
2. 修复版本显示不正确的问题
3. 配置项新增Meilisearch配置
### 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. 支持简单的数据统计

View File

@@ -28,11 +28,26 @@ WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
# 复制VERSION文件确保构建时能正确读取版本号
COPY VERSION ./
# 复制所有源代码
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
# 定义构建参数
ARG VERSION
ARG GIT_COMMIT
ARG GIT_BRANCH
ARG BUILD_TIME
# 获取版本信息并编译
RUN VERSION=${VERSION:-$(cat VERSION)} && \
GIT_COMMIT=${GIT_COMMIT:-$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")} && \
GIT_BRANCH=${GIT_BRANCH:-$(git branch --show-current 2>/dev/null || echo "unknown")} && \
BUILD_TIME=${BUILD_TIME:-$(date '+%Y-%m-%d %H:%M:%S')} && \
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo \
-ldflags "-X 'github.com/ctwj/urldb/utils.Version=${VERSION}' \
-X 'github.com/ctwj/urldb/utils.BuildTime=${BUILD_TIME}' \
-X 'github.com/ctwj/urldb/utils.GitCommit=${GIT_COMMIT}' \
-X 'github.com/ctwj/urldb/utils.GitBranch=${GIT_BRANCH}'" \
-o main .
# 后端运行阶段
FROM alpine:latest AS backend

View File

@@ -35,24 +35,21 @@
- [服务器要求](https://ecn5khs4t956.feishu.cn/wiki/W8YBww1Mmiu4Cdkp5W4c8pFNnMf?from=from_copylink)
- [QQ机器人](https://github.com/ctwj/astrbot_plugin_urldb)
### v1.2.1
1. 修复转存移除广告失败的问题和添加广告失败的问题
2. 管理后台UI优化
### v1.2.4
### v1.2.0
1. 新增手动批量转存
2. 新增QQ机器人
3. 新增任务管理功能
4. 自动转存改版(批量转存修改为,显示二维码时自动转存)
5. 新增支持第三方统计代码配置
1. 搜索增强,毫秒级响应,关键字高亮显示
2. 修复版本显示不正确的问题
3. 配置项新增Meilisearch配置
### v1.0.0
[详细改动记录](https://github.com/ctwj/urldb/blob/main/ChangeLog.md)
当前特性
1. 支持API手动批量录入资源
2. 支持,自动判断资源有效性
3. 支持自动转存
3. 支持自动转存Quark
4. 支持平台多账号管理Quark
5. 支持简单的数据统计
6. 支持Meilisearch
---
@@ -60,7 +57,6 @@
## 📸 项目截图
### 🏠 首页
![首页](https://raw.githubusercontent.com/ctwj/urldb/refs/heads/main/github/index.webp)

View File

@@ -1 +1 @@
1.2.1
1.2.5

View File

@@ -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)

View File

@@ -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
View File

@@ -0,0 +1,544 @@
// 1. 修正接口 Host增加配置项
// 2. POST/GET 区分xunleix 的 /drive/v1/share/list 是 GET不是 POST
// 3. 参数传递方式严格区分 query/body
// 4. header 应支持 AuthorizationBearer ...、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 + queryxunleix实现
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
View File

@@ -0,0 +1 @@
微信,独家,V信,v信,威信,胖狗资源,加微,会员群,q群,v群,公众号,广告,特价,最后机会,不要错过,立减,立得,赚,省,回扣,抽奖,失效,年会员,空间容量,微信群,群文件,全网资源,影视资源,扫码,最新资源,IMG_,资源汇总,緑铯粢源,.url,网盘推广,大额优惠券,资源文档,dy8.xyz,妙妙屋,资源合集,kkdm,赚收益

View File

@@ -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,8 +259,17 @@ 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},
{Key: entity.ConfigKeyEnableRegister, Value: entity.ConfigDefaultEnableRegister, Type: entity.ConfigTypeBool},
{Key: entity.ConfigKeyThirdPartyStatsCode, Value: entity.ConfigDefaultThirdPartyStatsCode, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyMeilisearchEnabled, Value: entity.ConfigDefaultMeilisearchEnabled, Type: entity.ConfigTypeBool},
{Key: entity.ConfigKeyMeilisearchHost, Value: entity.ConfigDefaultMeilisearchHost, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyMeilisearchPort, Value: entity.ConfigDefaultMeilisearchPort, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyMeilisearchMasterKey, Value: entity.ConfigDefaultMeilisearchMasterKey, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyMeilisearchIndexName, Value: entity.ConfigDefaultMeilisearchIndexName, Type: entity.ConfigTypeString},
}
for _, config := range defaultSystemConfigs {

View File

@@ -1,6 +1,7 @@
package converter
import (
"reflect"
"time"
"github.com/ctwj/urldb/db/dto"
@@ -10,22 +11,24 @@ import (
// ToResourceResponse 将Resource实体转换为ResourceResponse
func ToResourceResponse(resource *entity.Resource) dto.ResourceResponse {
response := dto.ResourceResponse{
ID: resource.ID,
Title: resource.Title,
Description: resource.Description,
URL: resource.URL,
PanID: resource.PanID,
SaveURL: resource.SaveURL,
FileSize: resource.FileSize,
CategoryID: resource.CategoryID,
ViewCount: resource.ViewCount,
IsValid: resource.IsValid,
IsPublic: resource.IsPublic,
CreatedAt: resource.CreatedAt,
UpdatedAt: resource.UpdatedAt,
Cover: resource.Cover,
Author: resource.Author,
ErrorMsg: resource.ErrorMsg,
ID: resource.ID,
Title: resource.Title,
Description: resource.Description,
URL: resource.URL,
PanID: resource.PanID,
SaveURL: resource.SaveURL,
FileSize: resource.FileSize,
CategoryID: resource.CategoryID,
ViewCount: resource.ViewCount,
IsValid: resource.IsValid,
IsPublic: resource.IsPublic,
CreatedAt: resource.CreatedAt,
UpdatedAt: resource.UpdatedAt,
Cover: resource.Cover,
Author: resource.Author,
ErrorMsg: resource.ErrorMsg,
SyncedToMeilisearch: resource.SyncedToMeilisearch,
SyncedAt: resource.SyncedAt,
}
// 设置分类名称
@@ -47,6 +50,89 @@ func ToResourceResponse(resource *entity.Resource) dto.ResourceResponse {
return response
}
// ToResourceResponseFromMeilisearch 将MeilisearchDocument转换为ResourceResponse包含高亮信息
func ToResourceResponseFromMeilisearch(doc interface{}) dto.ResourceResponse {
// 使用反射来获取MeilisearchDocument的字段
docValue := reflect.ValueOf(doc)
if docValue.Kind() == reflect.Ptr {
docValue = docValue.Elem()
}
response := dto.ResourceResponse{}
// 获取基本字段
if idField := docValue.FieldByName("ID"); idField.IsValid() {
response.ID = uint(idField.Uint())
}
if titleField := docValue.FieldByName("Title"); titleField.IsValid() {
response.Title = titleField.String()
}
if descField := docValue.FieldByName("Description"); descField.IsValid() {
response.Description = descField.String()
}
if urlField := docValue.FieldByName("URL"); urlField.IsValid() {
response.URL = urlField.String()
}
if saveURLField := docValue.FieldByName("SaveURL"); saveURLField.IsValid() {
response.SaveURL = saveURLField.String()
}
if fileSizeField := docValue.FieldByName("FileSize"); fileSizeField.IsValid() {
response.FileSize = fileSizeField.String()
}
if keyField := docValue.FieldByName("Key"); keyField.IsValid() {
// Key字段在ResourceResponse中不存在跳过
}
if categoryField := docValue.FieldByName("Category"); categoryField.IsValid() {
response.CategoryName = categoryField.String()
}
if authorField := docValue.FieldByName("Author"); authorField.IsValid() {
response.Author = authorField.String()
}
if createdAtField := docValue.FieldByName("CreatedAt"); createdAtField.IsValid() {
response.CreatedAt = createdAtField.Interface().(time.Time)
}
if updatedAtField := docValue.FieldByName("UpdatedAt"); updatedAtField.IsValid() {
response.UpdatedAt = updatedAtField.Interface().(time.Time)
}
// 处理PanID
if panIDField := docValue.FieldByName("PanID"); panIDField.IsValid() && !panIDField.IsNil() {
panIDPtr := panIDField.Interface().(*uint)
if panIDPtr != nil {
response.PanID = panIDPtr
}
}
// 处理Tags
if tagsField := docValue.FieldByName("Tags"); tagsField.IsValid() {
tags := tagsField.Interface().([]string)
response.Tags = make([]dto.TagResponse, len(tags))
for i, tagName := range tags {
response.Tags[i] = dto.TagResponse{
Name: tagName,
}
}
}
// 处理高亮字段
if titleHighlightField := docValue.FieldByName("TitleHighlight"); titleHighlightField.IsValid() {
response.TitleHighlight = titleHighlightField.String()
}
if descHighlightField := docValue.FieldByName("DescriptionHighlight"); descHighlightField.IsValid() {
response.DescriptionHighlight = descHighlightField.String()
}
if categoryHighlightField := docValue.FieldByName("CategoryHighlight"); categoryHighlightField.IsValid() {
response.CategoryHighlight = categoryHighlightField.String()
}
if tagsHighlightField := docValue.FieldByName("TagsHighlight"); tagsHighlightField.IsValid() {
tagsHighlight := tagsHighlightField.Interface().([]string)
response.TagsHighlight = make([]string, len(tagsHighlight))
copy(response.TagsHighlight, tagsHighlight)
}
return response
}
// ToResourceResponseList 将Resource实体列表转换为ResourceResponse列表
func ToResourceResponseList(resources []entity.Resource) []dto.ResourceResponse {
responses := make([]dto.ResourceResponse, len(resources))

View 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,
}
}

View File

@@ -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
@@ -72,6 +78,18 @@ func SystemConfigToResponse(configs []entity.SystemConfig) *dto.SystemConfigResp
}
case entity.ConfigKeyThirdPartyStatsCode:
response.ThirdPartyStatsCode = config.Value
case entity.ConfigKeyMeilisearchEnabled:
if val, err := strconv.ParseBool(config.Value); err == nil {
response.MeilisearchEnabled = val
}
case entity.ConfigKeyMeilisearchHost:
response.MeilisearchHost = config.Value
case entity.ConfigKeyMeilisearchPort:
response.MeilisearchPort = config.Value
case entity.ConfigKeyMeilisearchMasterKey:
response.MeilisearchMasterKey = config.Value
case entity.ConfigKeyMeilisearchIndexName:
response.MeilisearchIndexName = config.Value
}
}
@@ -91,48 +109,121 @@ 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)
}
// Meilisearch配置 - 只处理被设置的字段
if req.MeilisearchEnabled != nil {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyMeilisearchEnabled, Value: strconv.FormatBool(*req.MeilisearchEnabled), Type: entity.ConfigTypeBool})
updatedKeys = append(updatedKeys, entity.ConfigKeyMeilisearchEnabled)
}
if req.MeilisearchHost != nil {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyMeilisearchHost, Value: *req.MeilisearchHost, Type: entity.ConfigTypeString})
updatedKeys = append(updatedKeys, entity.ConfigKeyMeilisearchHost)
}
if req.MeilisearchPort != nil {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyMeilisearchPort, Value: *req.MeilisearchPort, Type: entity.ConfigTypeString})
updatedKeys = append(updatedKeys, entity.ConfigKeyMeilisearchPort)
}
if req.MeilisearchMasterKey != nil {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyMeilisearchMasterKey, Value: *req.MeilisearchMasterKey, Type: entity.ConfigTypeString})
updatedKeys = append(updatedKeys, entity.ConfigKeyMeilisearchMasterKey)
}
if req.MeilisearchIndexName != nil {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyMeilisearchIndexName, Value: *req.MeilisearchIndexName, Type: entity.ConfigTypeString})
updatedKeys = append(updatedKeys, entity.ConfigKeyMeilisearchIndexName)
}
// 记录更新的配置项
if len(updatedKeys) > 0 {
utils.Info("配置更新 - 被修改的配置项: %v", updatedKeys)
}
return configs
@@ -149,6 +240,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,9 +248,17 @@ 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, // 默认开启注册功能
entity.ConfigResponseFieldThirdPartyStatsCode: "",
entity.ConfigResponseFieldMeilisearchEnabled: false,
entity.ConfigResponseFieldMeilisearchHost: "localhost",
entity.ConfigResponseFieldMeilisearchPort: "7700",
entity.ConfigResponseFieldMeilisearchMasterKey: "",
entity.ConfigResponseFieldMeilisearchIndexName: "resources",
}
// 将键值对转换为map
@@ -174,6 +274,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 +302,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
@@ -214,6 +320,18 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
}
case entity.ConfigKeyThirdPartyStatsCode:
response[entity.ConfigResponseFieldThirdPartyStatsCode] = config.Value
case entity.ConfigKeyMeilisearchEnabled:
if val, err := strconv.ParseBool(config.Value); err == nil {
response[entity.ConfigResponseFieldMeilisearchEnabled] = val
}
case entity.ConfigKeyMeilisearchHost:
response[entity.ConfigResponseFieldMeilisearchHost] = config.Value
case entity.ConfigKeyMeilisearchPort:
response[entity.ConfigResponseFieldMeilisearchPort] = config.Value
case entity.ConfigKeyMeilisearchMasterKey:
response[entity.ConfigResponseFieldMeilisearchMasterKey] = config.Value
case entity.ConfigKeyMeilisearchIndexName:
response[entity.ConfigResponseFieldMeilisearchIndexName] = config.Value
}
}
@@ -234,6 +352,7 @@ func getDefaultConfigResponse() *dto.SystemConfigResponse {
Keywords: entity.ConfigDefaultKeywords,
Author: entity.ConfigDefaultAuthor,
Copyright: entity.ConfigDefaultCopyright,
SiteLogo: "",
AutoProcessReadyResources: false,
AutoProcessInterval: 30,
AutoTransferEnabled: false,
@@ -242,9 +361,16 @@ func getDefaultConfigResponse() *dto.SystemConfigResponse {
AutoFetchHotDramaEnabled: false,
ApiToken: entity.ConfigDefaultApiToken,
ForbiddenWords: entity.ConfigDefaultForbiddenWords,
AdKeywords: entity.ConfigDefaultAdKeywords,
AutoInsertAd: entity.ConfigDefaultAutoInsertAd,
PageSize: 100,
MaintenanceMode: false,
EnableRegister: true, // 默认开启注册功能
ThirdPartyStatsCode: entity.ConfigDefaultThirdPartyStatsCode,
MeilisearchEnabled: false,
MeilisearchHost: entity.ConfigDefaultMeilisearchHost,
MeilisearchPort: entity.ConfigDefaultMeilisearchPort,
MeilisearchMasterKey: entity.ConfigDefaultMeilisearchMasterKey,
MeilisearchIndexName: entity.ConfigDefaultMeilisearchIndexName,
}
}

73
db/dto/file.go Normal file
View 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"`
}

View File

@@ -12,24 +12,34 @@ type SearchResponse struct {
// ResourceResponse 资源响应
type ResourceResponse struct {
ID uint `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
PanID *uint `json:"pan_id"`
SaveURL string `json:"save_url"`
FileSize string `json:"file_size"`
CategoryID *uint `json:"category_id"`
CategoryName string `json:"category_name"`
ViewCount int `json:"view_count"`
IsValid bool `json:"is_valid"`
IsPublic bool `json:"is_public"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Tags []TagResponse `json:"tags"`
Cover string `json:"cover"`
Author string `json:"author"`
ErrorMsg string `json:"error_msg"`
ID uint `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
PanID *uint `json:"pan_id"`
SaveURL string `json:"save_url"`
FileSize string `json:"file_size"`
CategoryID *uint `json:"category_id"`
CategoryName string `json:"category_name"`
ViewCount int `json:"view_count"`
IsValid bool `json:"is_valid"`
IsPublic bool `json:"is_public"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Tags []TagResponse `json:"tags"`
Cover string `json:"cover"`
Author string `json:"author"`
ErrorMsg string `json:"error_msg"`
SyncedToMeilisearch bool `json:"synced_to_meilisearch"`
SyncedAt *time.Time `json:"synced_at"`
// 高亮字段
TitleHighlight string `json:"title_highlight,omitempty"`
DescriptionHighlight string `json:"description_highlight,omitempty"`
CategoryHighlight string `json:"category_highlight,omitempty"`
TagsHighlight []string `json:"tags_highlight,omitempty"`
// 违禁词相关字段
HasForbiddenWords bool `json:"has_forbidden_words"`
ForbiddenWords []string `json:"forbidden_words"`
}
// CategoryResponse 分类响应

View File

@@ -3,33 +3,45 @@ 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"` // 三方统计代码
// Meilisearch配置
MeilisearchEnabled *bool `json:"meilisearch_enabled,omitempty"`
MeilisearchHost *string `json:"meilisearch_host,omitempty"`
MeilisearchPort *string `json:"meilisearch_port,omitempty"`
MeilisearchMasterKey *string `json:"meilisearch_master_key,omitempty"`
MeilisearchIndexName *string `json:"meilisearch_index_name,omitempty"`
}
// SystemConfigResponse 系统配置响应
@@ -44,6 +56,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 +72,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"`
@@ -66,6 +83,13 @@ type SystemConfigResponse struct {
// 三方统计配置
ThirdPartyStatsCode string `json:"third_party_stats_code"` // 三方统计代码
// Meilisearch配置
MeilisearchEnabled bool `json:"meilisearch_enabled"`
MeilisearchHost string `json:"meilisearch_host"`
MeilisearchPort string `json:"meilisearch_port"`
MeilisearchMasterKey string `json:"meilisearch_master_key"`
MeilisearchIndexName string `json:"meilisearch_index_name"`
}
// SystemConfigItem 单个配置项

45
db/entity/file.go Normal file
View 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" // 已删除
)

View File

@@ -8,26 +8,28 @@ import (
// Resource 资源模型
type Resource struct {
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
Title string `json:"title" gorm:"size:255;not null;comment:资源标题"`
Description string `json:"description" gorm:"type:text;comment:资源描述"`
URL string `json:"url" gorm:"size:128;comment:资源链接"`
PanID *uint `json:"pan_id" gorm:"comment:平台ID"`
SaveURL string `json:"save_url" gorm:"size:500;comment:转存后的链接"`
FileSize string `json:"file_size" gorm:"size:100;comment:文件大小"`
CategoryID *uint `json:"category_id" gorm:"comment:分类ID"`
ViewCount int `json:"view_count" gorm:"default:0;comment:浏览次数"`
IsValid bool `json:"is_valid" gorm:"default:true;comment:是否有效"`
IsPublic bool `json:"is_public" gorm:"default:true;comment:是否公开"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
Cover string `json:"cover" gorm:"size:500;comment:封面"`
Author string `json:"author" gorm:"size:100;comment:作者"`
ErrorMsg string `json:"error_msg" gorm:"size:255;comment:转存失败原因"`
CkID *uint `json:"ck_id" gorm:"comment:账号ID"`
Fid string `json:"fid" gorm:"size:128;comment:网盘文件ID"`
Key string `json:"key" gorm:"size:64;index;comment:资源组标识相同key表示同一组资源"`
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
Title string `json:"title" gorm:"size:255;not null;comment:资源标题"`
Description string `json:"description" gorm:"type:text;comment:资源描述"`
URL string `json:"url" gorm:"size:128;comment:资源链接"`
PanID *uint `json:"pan_id" gorm:"comment:平台ID"`
SaveURL string `json:"save_url" gorm:"size:500;comment:转存后的链接"`
FileSize string `json:"file_size" gorm:"size:100;comment:文件大小"`
CategoryID *uint `json:"category_id" gorm:"comment:分类ID"`
ViewCount int `json:"view_count" gorm:"default:0;comment:浏览次数"`
IsValid bool `json:"is_valid" gorm:"default:true;comment:是否有效"`
IsPublic bool `json:"is_public" gorm:"default:true;comment:是否公开"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
Cover string `json:"cover" gorm:"size:500;comment:封面"`
Author string `json:"author" gorm:"size:100;comment:作者"`
ErrorMsg string `json:"error_msg" gorm:"size:255;comment:转存失败原因"`
CkID *uint `json:"ck_id" gorm:"comment:账号ID"`
Fid string `json:"fid" gorm:"size:128;comment:网盘文件ID"`
Key string `json:"key" gorm:"size:64;index;comment:资源组标识相同key表示同一组资源"`
SyncedToMeilisearch bool `json:"synced_to_meilisearch" gorm:"default:false;comment:是否已同步到Meilisearch"`
SyncedAt *time.Time `json:"synced_at" gorm:"comment:同步时间"`
// 关联关系
Category Category `json:"category" gorm:"foreignKey:CategoryID"`
@@ -39,3 +41,23 @@ type Resource struct {
func (Resource) TableName() string {
return "resources"
}
// GetTitle 获取资源标题实现utils.Resource接口
func (r *Resource) GetTitle() string {
return r.Title
}
// GetDescription 获取资源描述实现utils.Resource接口
func (r *Resource) GetDescription() string {
return r.Description
}
// SetTitle 设置资源标题实现utils.Resource接口
func (r *Resource) SetTitle(title string) {
r.Title = title
}
// SetDescription 设置资源描述实现utils.Resource接口
func (r *Resource) SetDescription(description string) {
r.Description = description
}

View File

@@ -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"
@@ -30,6 +35,13 @@ const (
// 三方统计配置
ConfigKeyThirdPartyStatsCode = "third_party_stats_code"
// Meilisearch配置
ConfigKeyMeilisearchEnabled = "meilisearch_enabled"
ConfigKeyMeilisearchHost = "meilisearch_host"
ConfigKeyMeilisearchPort = "meilisearch_port"
ConfigKeyMeilisearchMasterKey = "meilisearch_master_key"
ConfigKeyMeilisearchIndexName = "meilisearch_index_name"
)
// ConfigType 配置类型常量
@@ -68,6 +80,10 @@ const (
// 违禁词配置字段
ConfigResponseFieldForbiddenWords = "forbidden_words"
// 广告配置字段
ConfigResponseFieldAdKeywords = "ad_keywords"
ConfigResponseFieldAutoInsertAd = "auto_insert_ad"
// 其他配置字段
ConfigResponseFieldPageSize = "page_size"
ConfigResponseFieldMaintenanceMode = "maintenance_mode"
@@ -75,6 +91,13 @@ const (
// 三方统计配置字段
ConfigResponseFieldThirdPartyStatsCode = "third_party_stats_code"
// Meilisearch配置字段
ConfigResponseFieldMeilisearchEnabled = "meilisearch_enabled"
ConfigResponseFieldMeilisearchHost = "meilisearch_host"
ConfigResponseFieldMeilisearchPort = "meilisearch_port"
ConfigResponseFieldMeilisearchMasterKey = "meilisearch_master_key"
ConfigResponseFieldMeilisearchIndexName = "meilisearch_index_name"
)
// ConfigDefaultValue 配置默认值常量
@@ -100,6 +123,10 @@ const (
// 违禁词配置默认值
ConfigDefaultForbiddenWords = ""
// 广告配置默认值
ConfigDefaultAdKeywords = ""
ConfigDefaultAutoInsertAd = ""
// 其他配置默认值
ConfigDefaultPageSize = "100"
ConfigDefaultMaintenanceMode = "false"
@@ -107,4 +134,11 @@ const (
// 三方统计配置默认值
ConfigDefaultThirdPartyStatsCode = ""
// Meilisearch配置默认值
ConfigDefaultMeilisearchEnabled = "false"
ConfigDefaultMeilisearchHost = "localhost"
ConfigDefaultMeilisearchPort = "7700"
ConfigDefaultMeilisearchMasterKey = ""
ConfigDefaultMeilisearchIndexName = "resources"
)

167
db/repo/file_repository.go Normal file
View 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("添加搜索条件: file_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
}

View File

@@ -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),
}
}

View File

@@ -34,6 +34,14 @@ type ResourceRepository interface {
GetByURL(url string) (*entity.Resource, error)
UpdateSaveURL(id uint, saveURL string) error
CreateResourceTag(resourceTag *entity.ResourceTag) error
FindByIDs(ids []uint) ([]entity.Resource, error)
FindUnsyncedToMeilisearch(page, limit int) ([]entity.Resource, int64, error)
FindSyncedToMeilisearch(page, limit int) ([]entity.Resource, int64, error)
CountUnsyncedToMeilisearch() (int64, error)
CountSyncedToMeilisearch() (int64, error)
MarkAsSyncedToMeilisearch(ids []uint) error
MarkAllAsUnsyncedToMeilisearch() error
FindAllWithPagination(page, limit int) ([]entity.Resource, int64, error)
}
// ResourceRepositoryImpl Resource的Repository实现
@@ -461,19 +469,145 @@ func (r *ResourceRepositoryImpl) GetResourcesForTransfer(panID uint, sinceTime t
// GetByURL 根据URL获取资源
func (r *ResourceRepositoryImpl) GetByURL(url string) (*entity.Resource, error) {
var resource entity.Resource
err := r.GetDB().Where("url = ?", url).First(&resource).Error
err := r.db.Where("url = ?", url).First(&resource).Error
if err != nil {
return nil, err
}
return &resource, nil
}
// UpdateSaveURL 更新资源的转存链接
// FindByIDs 根据ID列表查找资源
func (r *ResourceRepositoryImpl) FindByIDs(ids []uint) ([]entity.Resource, error) {
if len(ids) == 0 {
return []entity.Resource{}, nil
}
var resources []entity.Resource
err := r.db.Where("id IN ?", ids).Preload("Category").Preload("Pan").Preload("Tags").Find(&resources).Error
return resources, err
}
// UpdateSaveURL 更新保存URL
func (r *ResourceRepositoryImpl) UpdateSaveURL(id uint, saveURL string) error {
return r.GetDB().Model(&entity.Resource{}).Where("id = ?", id).Update("save_url", saveURL).Error
return r.db.Model(&entity.Resource{}).Where("id = ?", id).Update("save_url", saveURL).Error
}
// CreateResourceTag 创建资源与标签的关联
func (r *ResourceRepositoryImpl) CreateResourceTag(resourceTag *entity.ResourceTag) error {
return r.GetDB().Create(resourceTag).Error
return r.db.Create(resourceTag).Error
}
// FindUnsyncedToMeilisearch 查找未同步到Meilisearch的资源
func (r *ResourceRepositoryImpl) FindUnsyncedToMeilisearch(page, limit int) ([]entity.Resource, int64, error) {
var resources []entity.Resource
var total int64
offset := (page - 1) * limit
// 查询未同步的资源
db := r.db.Model(&entity.Resource{}).
Where("synced_to_meilisearch = ?", false).
Preload("Category").
Preload("Pan").
Preload("Tags"). // 添加Tags预加载
Order("updated_at DESC")
// 获取总数
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
// 获取分页数据
err := db.Offset(offset).Limit(limit).Find(&resources).Error
return resources, total, err
}
// CountUnsyncedToMeilisearch 统计未同步到Meilisearch的资源数量
func (r *ResourceRepositoryImpl) CountUnsyncedToMeilisearch() (int64, error) {
var count int64
err := r.db.Model(&entity.Resource{}).
Where("synced_to_meilisearch = ?", false).
Count(&count).Error
return count, err
}
// MarkAsSyncedToMeilisearch 标记资源为已同步到Meilisearch
func (r *ResourceRepositoryImpl) MarkAsSyncedToMeilisearch(ids []uint) error {
if len(ids) == 0 {
return nil
}
now := time.Now()
return r.db.Model(&entity.Resource{}).
Where("id IN ?", ids).
Updates(map[string]interface{}{
"synced_to_meilisearch": true,
"synced_at": now,
}).Error
}
// MarkAllAsUnsyncedToMeilisearch 标记所有资源为未同步到Meilisearch
func (r *ResourceRepositoryImpl) MarkAllAsUnsyncedToMeilisearch() error {
return r.db.Model(&entity.Resource{}).
Where("1 = 1"). // 添加WHERE条件以更新所有记录
Updates(map[string]interface{}{
"synced_to_meilisearch": false,
"synced_at": nil,
}).Error
}
// FindSyncedToMeilisearch 查找已同步到Meilisearch的资源
func (r *ResourceRepositoryImpl) FindSyncedToMeilisearch(page, limit int) ([]entity.Resource, int64, error) {
var resources []entity.Resource
var total int64
offset := (page - 1) * limit
// 查询已同步的资源
db := r.db.Model(&entity.Resource{}).
Where("synced_to_meilisearch = ?", true).
Preload("Category").
Preload("Pan").
Order("updated_at DESC")
// 获取总数
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
// 获取分页数据
err := db.Offset(offset).Limit(limit).Find(&resources).Error
return resources, total, err
}
// CountSyncedToMeilisearch 统计已同步到Meilisearch的资源数量
func (r *ResourceRepositoryImpl) CountSyncedToMeilisearch() (int64, error) {
var count int64
err := r.db.Model(&entity.Resource{}).
Where("synced_to_meilisearch = ?", true).
Count(&count).Error
return count, err
}
// FindAllWithPagination 分页查找所有资源
func (r *ResourceRepositoryImpl) FindAllWithPagination(page, limit int) ([]entity.Resource, int64, error) {
var resources []entity.Resource
var total int64
offset := (page - 1) * limit
// 查询所有资源
db := r.db.Model(&entity.Resource{}).
Preload("Category").
Preload("Pan").
Order("updated_at DESC")
// 获取总数
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
// 获取分页数据
err := db.Offset(offset).Limit(limit).Find(&resources).Error
return resources, total, err
}

View File

@@ -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,18 @@ 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.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},
{Key: entity.ConfigKeyEnableRegister, Value: entity.ConfigDefaultEnableRegister, Type: entity.ConfigTypeBool},
{Key: entity.ConfigKeyThirdPartyStatsCode, Value: entity.ConfigDefaultThirdPartyStatsCode, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyMeilisearchEnabled, Value: entity.ConfigDefaultMeilisearchEnabled, Type: entity.ConfigTypeBool},
{Key: entity.ConfigKeyMeilisearchHost, Value: entity.ConfigDefaultMeilisearchHost, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyMeilisearchPort, Value: entity.ConfigDefaultMeilisearchPort, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyMeilisearchMasterKey, Value: entity.ConfigDefaultMeilisearchMasterKey, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyMeilisearchIndexName, Value: entity.ConfigDefaultMeilisearchIndexName, Type: entity.ConfigTypeString},
}
err = r.UpsertConfigs(defaultConfigs)
@@ -133,10 +157,18 @@ 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.ConfigKeyForbiddenWords: {Key: entity.ConfigKeyForbiddenWords, Value: entity.ConfigDefaultForbiddenWords, 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},
entity.ConfigKeyThirdPartyStatsCode: {Key: entity.ConfigKeyThirdPartyStatsCode, Value: entity.ConfigDefaultThirdPartyStatsCode, Type: entity.ConfigTypeString},
entity.ConfigKeyMeilisearchEnabled: {Key: entity.ConfigKeyMeilisearchEnabled, Value: entity.ConfigDefaultMeilisearchEnabled, Type: entity.ConfigTypeBool},
entity.ConfigKeyMeilisearchHost: {Key: entity.ConfigKeyMeilisearchHost, Value: entity.ConfigDefaultMeilisearchHost, Type: entity.ConfigTypeString},
entity.ConfigKeyMeilisearchPort: {Key: entity.ConfigKeyMeilisearchPort, Value: entity.ConfigDefaultMeilisearchPort, Type: entity.ConfigTypeString},
entity.ConfigKeyMeilisearchMasterKey: {Key: entity.ConfigKeyMeilisearchMasterKey, Value: entity.ConfigDefaultMeilisearchMasterKey, Type: entity.ConfigTypeString},
entity.ConfigKeyMeilisearchIndexName: {Key: entity.ConfigKeyMeilisearchIndexName, Value: entity.ConfigDefaultMeilisearchIndexName, Type: entity.ConfigTypeString},
}
// 检查现有配置中是否有缺失的配置项
@@ -206,6 +238,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) {
// 初始化缓存

View File

@@ -20,7 +20,7 @@ services:
- app-network
backend:
image: ctwj/urldb-backend:1.2.0
image: ctwj/urldb-backend:1.2.4
environment:
DB_HOST: postgres
DB_PORT: 5432
@@ -38,10 +38,10 @@ services:
- app-network
frontend:
image: ctwj/urldb-frontend:1.2.0
image: ctwj/urldb-frontend:1.2.4
environment:
NODE_ENV: production
NUXT_PUBLIC_API_SERVER: http://backend:8080/api
NUXT_PUBLIC_API_CLIENT: /api
depends_on:
- backend
networks:

View File

@@ -1 +0,0 @@
doc.l9.lc

View File

@@ -1,51 +0,0 @@
# 🚀 urlDB - 老九网盘资源数据库
> 一个现代化的老九网盘资源数据库,支持多网盘自动化转存分享,支持百度网盘,阿里云盘,夸克网盘, 天翼云盘迅雷云盘123云盘115网盘UC网盘
<div align="center">
![Go Version](https://img.shields.io/badge/Go-1230?logo=go&logoColor=white)
![Vue Version](https://img.shields.io/badge/Vue-334FC08D?logo=vue.js&logoColor=white)
![Nuxt Version](https://img.shields.io/badge/Nuxt-300.8+-00DC82?logo=nuxt.js&logoColor=white)
![License](https://img.shields.io/badge/License-GPL%20v3-blue.svg)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791go=postgresql&logoColor=white)
</div>
## 🎯 支持的网盘平台
| 平台 | 录入 | 转存 | 分享 |
|------|-------|-----|------|
| 百度网盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
| 阿里云盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
| 夸克网盘 | ✅ 支持 | ✅ 支持 | ✅ 支持 |
| 天翼云盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
| 迅雷云盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
| UC网盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
| 123云盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
| 115网盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
## ✨ 功能特性
### 🎯 核心功能
- **📁 多平台网盘支持** - 支持多种主流网盘平台
- **🔍 公开API** - 支持API数据录入资源搜索
- **🏷️ 自动预处理** - 系统自动处理资源,对数据进行有效性判断
- **📊 自动转存分享** - 有效资源,如果属于支持类型将自动转存分享
- **📱 多账号管理** - 同平台支持多账号管理
## 📞 联系我们
- **项目地址**: [https://github.com/ctwj/urldb](https://github.com/ctwj/urldb)
- **问题反馈**: [Issues](https://github.com/ctwj/urldb/issues)
- **邮箱**: 510199617@qq.com
---
<div align="center">
**如果这个项目对您有帮助,请给我们一个 ⭐ Star**
Made with ❤️ by [老九]
</div>

View File

@@ -1,177 +0,0 @@
# 文档使用说明
## 概述
本项目使用 [docsify](https://docsify.js.org/) 生成文档网站。docsify 是一个轻量级的文档生成器,无需构建静态文件,只需要一个 `index.html` 文件即可。
## 文档结构
```
docs/
├── index.html # 文档主页
├── docsify.config.js # docsify 配置文件
├── README.md # 首页内容
├── _sidebar.md # 侧边栏导航
├── start-docs.sh # 启动脚本
├── guide/ # 使用指南
│ ├── quick-start.md # 快速开始
│ ├── local-development.md # 本地开发
│ └── docker-deployment.md # Docker 部署
├── api/ # API 文档
│ └── overview.md # API 概览
├── architecture/ # 架构文档
│ └── overview.md # 架构概览
├── faq.md # 常见问题
├── changelog.md # 更新日志
└── license.md # 许可证
```
## 快速启动
### 方法一:使用启动脚本(推荐)
```bash
# 进入文档目录
cd docs
# 运行启动脚本
./start-docs.sh
```
脚本会自动:
- 检查是否安装了 docsify-cli
- 如果没有安装,会自动安装
- 启动文档服务
- 在浏览器中打开文档
### 方法二:手动启动
```bash
# 安装 docsify-cli如果未安装
npm install -g docsify-cli
# 进入文档目录
cd docs
# 启动服务
docsify serve . --port 3000 --open
```
## 访问文档
启动成功后,文档将在以下地址可用:
- 本地访问http://localhost:3000
- 局域网访问http://[你的IP]:3000
## 文档特性
### 1. 搜索功能
- 支持全文搜索
- 搜索结果高亮显示
- 支持中文搜索
### 2. 代码高亮
支持多种编程语言的语法高亮:
- Go
- JavaScript/TypeScript
- SQL
- YAML
- JSON
- Bash
### 3. 代码复制
- 一键复制代码块
- 复制成功提示
### 4. 页面导航
- 侧边栏导航
- 页面间导航
- 自动回到顶部
### 5. 响应式设计
- 支持移动端访问
- 自适应屏幕尺寸
## 自定义配置
### 修改主题
`docsify.config.js` 中修改配置:
```javascript
window.$docsify = {
name: '你的项目名称',
repo: '你的仓库地址',
// 其他配置...
}
```
### 添加新页面
1. 在相应目录下创建 `.md` 文件
2.`_sidebar.md` 中添加导航链接
3. 刷新页面即可看到新页面
### 修改样式
可以通过添加自定义 CSS 来修改样式:
```html
<!-- 在 index.html 中添加 -->
<link rel="stylesheet" href="./custom.css">
```
## 部署到生产环境
### 静态部署
docsify 生成的文档可以部署到任何静态文件服务器:
```bash
# 构建静态文件(可选)
docsify generate docs docs/_site
# 部署到 GitHub Pages
git subtree push --prefix docs origin gh-pages
```
### Docker 部署
```bash
# 使用 nginx 镜像
docker run -d -p 80:80 -v $(pwd)/docs:/usr/share/nginx/html nginx
```
## 常见问题
### Q: 启动时提示端口被占用
A: 可以指定其他端口:
```bash
docsify serve . --port 3001
```
### Q: 搜索功能不工作
A: 确保在 `index.html` 中引入了搜索插件:
```html
<script src="//cdn.jsdelivr.net/npm/docsify@4/lib/plugins/search.min.js"></script>
```
### Q: 代码高亮不显示
A: 确保引入了相应的 Prism.js 组件:
```html
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-go.min.js"></script>
```
## 维护说明
### 更新文档
1. 修改相应的 `.md` 文件
2. 刷新浏览器即可看到更新
### 添加新功能
1.`docsify.config.js` 中添加插件配置
2.`index.html` 中引入相应的插件文件
### 版本控制
建议将文档与代码一起进行版本控制,确保文档与代码版本同步。
## 相关链接
- [docsify 官方文档](https://docsify.js.org/)
- [docsify 插件市场](https://docsify.js.org/#/plugins)
- [Markdown 语法指南](https://docsify.js.org/#/zh-cn/markdown)

View File

@@ -1,15 +0,0 @@
<!-- docs/_sidebar.md -->
* [🏠 首页](/)
* [🚀 快速开始](guide/quick-start.md)
* [🐳 Docker部署](guide/docker-deployment.md)
* [💻 本地开发](guide/local-development.md)
* 📚 API 文档
* [公开API](api/overview.md)
* 📄 其他
* [常见问题](faq.md)
* [更新日志](changelog.md)
* [许可证](license.md)
* [版本管理](github-version-management.md)

View File

@@ -1,418 +0,0 @@
# API 文档概览
## 概述
老九网盘资源数据库提供了一套完整的 RESTful API 接口,支持资源管理、搜索、热门剧获取等功能。所有 API 都需要进行认证,使用 API Token 进行身份验证。
## 基础信息
- **基础URL**: `http://localhost:8080/api`
- **认证方式**: API Token
- **数据格式**: JSON
- **字符编码**: UTF-8
## 认证说明
### 认证方式
所有 API 都需要提供 API Token 进行认证,支持两种方式:
1. **请求头方式**(推荐)
```
X-API-Token: your_token_here
```
2. **查询参数方式**
```
?api_token=your_token_here
```
### 获取 Token
请联系管理员在系统配置中设置 API Token。
## API 接口列表
### 1. 单个添加资源
**接口描述**: 添加单个资源到待处理列表
**请求信息**:
- **方法**: `POST`
- **路径**: `/api/public/resources/add`
- **认证**: 必需
**请求参数**:
```json
{
"title": "资源标题",
"description": "资源描述",
"url": "资源链接",
"category": "分类名称",
"tags": "标签1,标签2",
"img": "封面图片链接",
"source": "数据来源",
"extra": "额外信息"
}
```
**响应示例**:
```json
{
"success": true,
"message": "资源添加成功,已进入待处理列表",
"data": {
"id": 123
},
"code": 200
}
```
### 2. 批量添加资源
**接口描述**: 批量添加多个资源到待处理列表
**请求信息**:
- **方法**: `POST`
- **路径**: `/api/public/resources/batch-add`
- **认证**: 必需
**请求参数**:
```json
{
"resources": [
{
"title": "资源1",
"url": "链接1",
"description": "描述1"
},
{
"title": "资源2",
"url": "链接2",
"description": "描述2"
}
]
}
```
**响应示例**:
```json
{
"success": true,
"message": "批量添加成功,共添加 2 个资源",
"data": {
"created_count": 2,
"created_ids": [123, 124]
},
"code": 200
}
```
### 3. 资源搜索
**接口描述**: 搜索资源,支持关键词、标签、分类过滤
**请求信息**:
- **方法**: `GET`
- **路径**: `/api/public/resources/search`
- **认证**: 必需
**查询参数**:
- `keyword` - 搜索关键词
- `tag` - 标签过滤
- `category` - 分类过滤
- `page` - 页码默认1
- `page_size` - 每页数量默认20最大100
**响应示例**:
```json
{
"success": true,
"message": "搜索成功",
"data": {
"resources": [
{
"id": 1,
"title": "资源标题",
"url": "资源链接",
"description": "资源描述",
"view_count": 100,
"created_at": "2024-12-19 10:00:00",
"updated_at": "2024-12-19 10:00:00"
}
],
"total": 50,
"page": 1,
"page_size": 20
},
"code": 200
}
```
### 4. 热门剧列表
**接口描述**: 获取热门剧列表,支持分页
**请求信息**:
- **方法**: `GET`
- **路径**: `/api/public/hot-dramas`
- **认证**: 必需
**查询参数**:
- `page` - 页码默认1
- `page_size` - 每页数量默认20最大100
**响应示例**:
```json
{
"success": true,
"message": "获取热门剧成功",
"data": {
"hot_dramas": [
{
"id": 1,
"title": "剧名",
"description": "剧集描述",
"img": "封面图片",
"url": "详情链接",
"rating": 8.5,
"year": "2024",
"region": "中国大陆",
"genres": "剧情,悬疑",
"category": "电视剧",
"created_at": "2024-12-19 10:00:00",
"updated_at": "2024-12-19 10:00:00"
}
],
"total": 20,
"page": 1,
"page_size": 20
},
"code": 200
}
```
## 错误码说明
### HTTP 状态码
| 状态码 | 说明 |
|--------|------|
| 200 | 请求成功 |
| 400 | 请求参数错误 |
| 401 | 认证失败Token无效或缺失 |
| 500 | 服务器内部错误 |
| 503 | 系统维护中或API Token未配置 |
### 响应格式
所有 API 响应都遵循统一的格式:
```json
{
"success": true/false,
"message": "响应消息",
"data": {}, // 响应数据
"code": 200 // 状态码
}
```
## 使用示例
### cURL 示例
```bash
# 设置API Token
API_TOKEN="your_api_token_here"
# 单个添加资源
curl -X POST "http://localhost:8080/api/public/resources/add" \
-H "Content-Type: application/json" \
-H "X-API-Token: $API_TOKEN" \
-d '{
"title": "测试资源",
"url": "https://example.com/resource",
"description": "测试描述"
}'
# 搜索资源
curl -X GET "http://localhost:8080/api/public/resources/search?keyword=测试" \
-H "X-API-Token: $API_TOKEN"
# 获取热门剧
curl -X GET "http://localhost:8080/api/public/hot-dramas?page=1&page_size=5" \
-H "X-API-Token: $API_TOKEN"
```
### JavaScript 示例
```javascript
const API_TOKEN = 'your_api_token_here';
const BASE_URL = 'http://localhost:8080/api';
// 添加资源
async function addResource(resourceData) {
const response = await fetch(`${BASE_URL}/public/resources/add`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Token': API_TOKEN
},
body: JSON.stringify(resourceData)
});
return await response.json();
}
// 搜索资源
async function searchResources(keyword, page = 1) {
const response = await fetch(
`${BASE_URL}/public/resources/search?keyword=${encodeURIComponent(keyword)}&page=${page}`,
{
headers: {
'X-API-Token': API_TOKEN
}
}
);
return await response.json();
}
// 获取热门剧
async function getHotDramas(page = 1, pageSize = 20) {
const response = await fetch(
`${BASE_URL}/public/hot-dramas?page=${page}&page_size=${pageSize}`,
{
headers: {
'X-API-Token': API_TOKEN
}
}
);
return await response.json();
}
```
### Python 示例
```python
import requests
API_TOKEN = 'your_api_token_here'
BASE_URL = 'http://localhost:8080/api'
headers = {
'X-API-Token': API_TOKEN,
'Content-Type': 'application/json'
}
# 添加资源
def add_resource(resource_data):
response = requests.post(
f'{BASE_URL}/public/resources/add',
headers=headers,
json=resource_data
)
return response.json()
# 搜索资源
def search_resources(keyword, page=1):
params = {
'keyword': keyword,
'page': page
}
response = requests.get(
f'{BASE_URL}/public/resources/search',
headers={'X-API-Token': API_TOKEN},
params=params
)
return response.json()
# 获取热门剧
def get_hot_dramas(page=1, page_size=20):
params = {
'page': page,
'page_size': page_size
}
response = requests.get(
f'{BASE_URL}/public/hot-dramas',
headers={'X-API-Token': API_TOKEN},
params=params
)
return response.json()
```
## 最佳实践
### 1. 错误处理
始终检查响应的 `success` 字段和 HTTP 状态码:
```javascript
const response = await fetch(url, options);
const data = await response.json();
if (!response.ok || !data.success) {
console.error('API调用失败:', data.message);
// 处理错误
}
```
### 2. 分页处理
对于支持分页的接口,建议实现分页逻辑:
```javascript
async function getAllResources(keyword) {
let allResources = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const response = await searchResources(keyword, page);
if (response.success) {
allResources.push(...response.data.resources);
hasMore = response.data.resources.length > 0;
page++;
} else {
break;
}
}
return allResources;
}
```
### 3. 请求频率限制
避免过于频繁的 API 调用,建议实现请求间隔:
```javascript
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function searchWithDelay(keyword) {
const result = await searchResources(keyword);
await delay(1000); // 等待1秒
return result;
}
```
## 注意事项
1. **Token 安全**: 请妥善保管您的 API Token不要泄露给他人
2. **请求限制**: 避免过于频繁的请求,以免影响系统性能
3. **数据格式**: 确保请求数据格式正确,特别是 JSON 格式
4. **错误处理**: 始终实现适当的错误处理机制
5. **版本兼容**: API 可能会进行版本更新,请关注更新通知
## 技术支持
如果您在使用 API 过程中遇到问题,请:
1. 检查 API Token 是否正确
2. 确认请求格式是否符合要求
3. 查看错误响应中的详细信息
4. 联系技术支持团队
---
**注意**: 本站内容由网络爬虫自动抓取。本站不储存、复制、传播任何文件仅作个人公益学习请在获取后24小时内删除

View File

@@ -1,100 +0,0 @@
# 📝 更新日志
本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/) 规范。
## [未发布]
### 新增
- 自动转存调度功能
- 支持更多网盘平台
- 性能优化和监控
### 修复
- 修复已知问题
- 改进用户体验
## [1.0.0] - 2024-01-01
### 新增
- 🎉 首次发布
- ✨ 完整的网盘资源管理系统
- 🔐 JWT 用户认证系统
- 📁 多平台网盘支持
- 🔍 资源搜索和管理
- 🏷️ 分类和标签系统
- 📊 统计和监控功能
- 🐳 Docker 容器化部署
- 📱 响应式前端界面
- 🌙 深色模式支持
### 支持的网盘平台
- 百度网盘
- 阿里云盘
- 夸克网盘
- 天翼云盘
- 迅雷云盘
- UC网盘
- 123云盘
- 115网盘
### 技术特性
- **后端**: Go + Gin + GORM + PostgreSQL
- **前端**: Nuxt.js 3 + Vue 3 + TypeScript + Tailwind CSS
- **部署**: Docker + Docker Compose
- **认证**: JWT Token
- **架构**: 前后端分离
## [0.9.0] - 2024-12-15
### 新增
- 🚀 项目初始化
- 📋 基础功能开发
- 🏗️ 架构设计完成
- 🔧 开发环境搭建
### 技术栈确定
- 后端技术栈选型
- 前端技术栈选型
- 数据库设计
- API 接口设计
---
## 版本说明
### 版本号格式
- **主版本号**: 不兼容的 API 修改
- **次版本号**: 向下兼容的功能性新增
- **修订号**: 向下兼容的问题修正
### 更新类型
- 🎉 **重大更新**: 新版本发布
-**新增功能**: 新功能添加
- 🔧 **功能改进**: 现有功能优化
- 🐛 **问题修复**: Bug 修复
- 📝 **文档更新**: 文档改进
- 🚀 **性能优化**: 性能提升
- 🔒 **安全更新**: 安全相关更新
- 🎨 **界面优化**: UI/UX 改进
## 贡献指南
如果您想为项目做出贡献,请:
1. Fork 项目
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 打开 Pull Request
## 反馈
如果您发现任何问题或有建议,请:
- 提交 [Issue](https://github.com/ctwj/urldb/issues)
- 发送邮件到 510199617@qq.com
- 在 [讨论区](https://github.com/ctwj/urldb/discussions) 交流
---
**注意**: 此更新日志记录了项目的重要变更。对于详细的开发日志,请查看 Git 提交历史。

View File

@@ -1,53 +0,0 @@
// docsify 配置文件
window.$docsify = {
name: '老九网盘链接数据库',
repo: 'https://github.com/ctwj/urldb',
loadSidebar: '_sidebar.md',
subMaxLevel: 3,
auto2top: true,
// 添加侧边栏配置
sidebarDisplayLevel: 1,
// 添加错误处理
notFoundPage: true,
search: {
maxAge: 86400000,
paths: 'auto',
placeholder: '搜索文档...',
noData: '找不到结果',
depth: 6
},
copyCode: {
buttonText: '复制',
errorText: '错误',
successText: '已复制'
},
pagination: {
previousText: '上一页',
nextText: '下一页',
crossChapter: true,
crossChapterText: true,
},
plugins: [
function(hook, vm) {
hook.beforeEach(function (html) {
// 添加页面标题
var url = '#' + vm.route.path;
var title = vm.route.path === '/' ? '首页' : vm.route.path.replace('/', '');
return html + '\n\n---\n\n' +
'<div style="text-align: center; color: #666; font-size: 14px;">' +
'最后更新: ' + new Date().toLocaleDateString('zh-CN') +
'</div>';
});
// 添加侧边栏加载调试
hook.doneEach(function() {
console.log('Docsify loaded, sidebar should be visible');
if (document.querySelector('.sidebar-nav')) {
console.log('Sidebar element found');
} else {
console.log('Sidebar element not found');
}
});
}
]
};

View File

@@ -1,26 +0,0 @@
# ❓ 常见问题
## 部署相关
### Q: 默认账号密码是多少?
**A:** 可以通过以下方式解决:
1. admin/password
### Q: 批量添加了资源,但是系统里面没有出现,也搜索不到?
**A:** 可以通过以下方式解决:
1. 需要先开启自动处理待处理任务的开关
2. 定时任务每5分钟执行一次可能需要等待
3. 如果添加的链接地址无效, 会被程序过滤
### Q: 没有自动转存?
**A:** 可以通过以下方式解决:
1. 需要先添加账号
2. 开启定时任务
3. 等待任务完成
4. 只要支持的网盘地址才会被自动转存并分享

View File

@@ -1,253 +0,0 @@
# GitHub版本管理指南
本项目使用GitHub进行版本管理支持自动创建Release和标签。
## 版本管理流程
### 1. 版本号规范
遵循[语义化版本](https://semver.org/lang/zh-CN/)规范:
- **主版本号** (Major): 不兼容的API修改
- **次版本号** (Minor): 向下兼容的功能性新增
- **修订号** (Patch): 向下兼容的问题修正
### 2. 版本管理命令
#### 显示版本信息
```bash
./scripts/version.sh show
```
#### 更新版本号
```bash
# 修订版本 (1.0.0 -> 1.0.1)
./scripts/version.sh patch
# 次版本 (1.0.0 -> 1.1.0)
./scripts/version.sh minor
# 主版本 (1.0.0 -> 2.0.0)
./scripts/version.sh major
```
#### 发布版本到GitHub
```bash
./scripts/version.sh release
```
### 3. 自动发布流程
当执行版本更新命令时,脚本会:
1. **更新版本号**: 修改 `VERSION` 文件
2. **同步文件**: 更新 `package.json``docker-compose.yml``README.md`
3. **创建Git标签**: 自动创建版本标签
4. **推送代码**: 推送代码和标签到GitHub
5. **创建Release**: 自动创建GitHub Release
### 4. 手动发布流程
如果自动发布失败,可以手动发布:
#### 步骤1: 更新版本号
```bash
./scripts/version.sh patch # 或 minor, major
```
#### 步骤2: 提交更改
```bash
git add .
git commit -m "chore: bump version to v1.0.1"
```
#### 步骤3: 创建标签
```bash
git tag v1.0.1
```
#### 步骤4: 推送到GitHub
```bash
git push origin main
git push origin v1.0.1
```
#### 步骤5: 创建Release
在GitHub网页上
1. 进入项目页面
2. 点击 "Releases"
3. 点击 "Create a new release"
4. 选择标签 `v1.0.1`
5. 填写Release说明
6. 发布
### 5. GitHub CLI工具
#### 安装GitHub CLI
```bash
# macOS
brew install gh
# Ubuntu/Debian
sudo apt install gh
# Windows
winget install GitHub.cli
```
#### 登录GitHub
```bash
gh auth login
```
#### 创建Release
```bash
gh release create v1.0.1 \
--title "Release v1.0.1" \
--notes "修复了一些bug" \
--draft=false \
--prerelease=false
```
### 6. 版本检查
#### API接口
- `GET /api/version/check-update` - 检查GitHub上的最新版本
#### 前端页面
- 访问 `/version` 页面查看版本信息和更新状态
### 7. 版本历史
#### 查看所有标签
```bash
git tag -l
```
#### 查看标签详情
```bash
git show v1.0.1
```
#### 查看版本历史
```bash
git log --oneline --decorate
```
### 8. 回滚版本
如果需要回滚到之前的版本:
#### 删除本地标签
```bash
git tag -d v1.0.1
```
#### 删除远程标签
```bash
git push origin :refs/tags/v1.0.1
```
#### 回滚代码
```bash
git reset --hard v1.0.0
git push --force origin main
```
### 9. 最佳实践
#### 提交信息规范
```bash
# 功能开发
git commit -m "feat: 添加新功能"
# Bug修复
git commit -m "fix: 修复某个bug"
# 文档更新
git commit -m "docs: 更新文档"
# 版本更新
git commit -m "chore: bump version to v1.0.1"
```
#### 分支管理
- `main`: 主分支,用于发布
- `develop`: 开发分支
- `feature/*`: 功能分支
- `hotfix/*`: 热修复分支
#### Release说明模板
```markdown
## Release v1.0.1
**发布日期**: 2024-01-15
### 更新内容
- 修复了某个bug
- 添加了新功能
- 优化了性能
### 下载
- [源码 (ZIP)](https://github.com/ctwj/urldb/archive/v1.0.1.zip)
- [源码 (TAR.GZ)](https://github.com/ctwj/urldb/archive/v1.0.1.tar.gz)
### 安装
```bash
# 克隆项目
git clone https://github.com/ctwj/urldb.git
cd urldb
# 切换到指定版本
git checkout v1.0.1
# 使用Docker部署
docker-compose up --build -d
```
### 更新日志
详细更新日志请查看 [CHANGELOG.md](https://github.com/ctwj/urldb/blob/v1.0.1/CHANGELOG.md)
```
### 10. 故障排除
#### 常见问题
1. **GitHub CLI未安装**
```bash
# 安装GitHub CLI
brew install gh # macOS
```
2. **GitHub CLI未登录**
```bash
# 登录GitHub
gh auth login
```
3. **标签已存在**
```bash
# 删除本地标签
git tag -d v1.0.1
# 删除远程标签
git push origin :refs/tags/v1.0.1
```
4. **推送失败**
```bash
# 检查远程仓库
git remote -v
# 重新设置远程仓库
git remote set-url origin https://github.com/ctwj/urldb.git
```
#### 获取帮助
```bash
./scripts/version.sh help
```

View File

@@ -1,352 +0,0 @@
# 🐳 Docker 部署
## 概述
urlDB 支持使用 Docker 进行容器化部署,提供了完整的前后端分离架构。
## 系统架构
| 服务 | 端口 | 说明 |
|------|------|------|
| frontend | 3000 | Nuxt.js 前端应用 |
| backend | 8080 | Go API 后端服务 |
| postgres | 5432 | PostgreSQL 数据库 |
## 快速部署
### 1. 克隆项目
```bash
git clone https://github.com/ctwj/urldb.git
cd urldb
```
### 2. 使用启动脚本(推荐)
```bash
# 给脚本执行权限
chmod +x docker-start.sh
# 启动服务
./docker-start.sh
```
### 3. 手动启动
```bash
# 构建并启动所有服务
docker compose up --build -d
# 查看服务状态
docker compose ps
```
## 配置说明
### 环境变量
可以通过修改 `docker-compose.yml` 文件中的环境变量来配置服务:
后端 backend
```yaml
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_USER: postgres
DB_PASSWORD: password
DB_NAME: url_db
PORT: 8080
```
前端 frontend
```yaml
environment:
API_BASE: /api
```
### 端口映射
如果需要修改端口映射,可以编辑 `docker-compose.yml`
```yaml
ports:
- "3001:3000" # 前端端口
- "8081:8080" # API端口
- "5433:5432" # 数据库端口
```
## 常用命令
### 服务管理
```bash
# 启动服务
docker compose up -d
# 停止服务
docker compose down
# 重启服务
docker compose restart
# 查看服务状态
docker compose ps
# 查看日志
docker compose logs -f [service_name]
```
### 数据管理
```bash
# 备份数据库
docker compose exec postgres pg_dump -U postgres url_db > backup.sql
# 恢复数据库
docker compose exec -T postgres psql -U postgres url_db < backup.sql
# 进入数据库
docker compose exec postgres psql -U postgres url_db
```
### 容器管理
```bash
# 进入容器
docker compose exec [service_name] sh
# 查看容器资源使用
docker stats
# 清理未使用的资源
docker system prune -a
```
## 生产环境部署
### 1. 环境准备
```bash
# 安装 Docker 和 Docker Compose
# 确保服务器有足够资源(建议 4GB+ 内存)
# 创建部署目录
mkdir -p /opt/urldb
cd /opt/urldb
```
### 2. 配置文件
创建生产环境配置文件:
```bash
# 复制项目文件
git clone https://github.com/ctwj/urldb.git .
# 创建环境变量文件
cp env.example .env.prod
# 编辑生产环境配置
vim .env.prod
```
### 3. 启动服务
```bash
# 使用生产环境配置启动
docker compose -f docker-compose.yml --env-file .env.prod up -d
# 检查服务状态
docker compose ps
```
### 4. 配置反向代理
#### Nginx 配置示例
```nginx
server {
listen 80;
server_name your-domain.com;
# 前端代理
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
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;
}
# API 代理
location /api/ {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
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;
}
}
```
### 5. SSL 配置
```bash
# 使用 Let's Encrypt 获取证书
sudo certbot --nginx -d your-domain.com
# 或使用自签名证书
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/ssl/private/urldb.key \
-out /etc/ssl/certs/urldb.crt
```
## 监控和维护
### 1. 日志管理
```bash
# 查看所有服务日志
docker compose logs -f
# 查看特定服务日志
docker compose logs -f backend
# 导出日志
docker compose logs > urldb.log
```
### 2. 性能监控
```bash
# 查看容器资源使用
docker stats
# 查看系统资源
htop
df -h
free -h
```
### 3. 备份策略
```bash
#!/bin/bash
# 创建备份脚本 backup.sh
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/backup/urldb"
# 创建备份目录
mkdir -p $BACKUP_DIR
# 备份数据库
docker compose exec -T postgres pg_dump -U postgres url_db > $BACKUP_DIR/db_$DATE.sql
# 备份上传文件
tar -czf $BACKUP_DIR/uploads_$DATE.tar.gz uploads/
# 删除7天前的备份
find $BACKUP_DIR -name "*.sql" -mtime +7 -delete
find $BACKUP_DIR -name "*.tar.gz" -mtime +7 -delete
```
### 4. 自动更新
```bash
#!/bin/bash
# 创建更新脚本 update.sh
cd /opt/urldb
# 拉取最新代码
git pull origin main
# 重新构建并启动
docker compose down
docker compose up --build -d
# 检查服务状态
docker compose ps
```
## 故障排除
### 1. 服务启动失败
```bash
# 查看详细错误信息
docker compose logs [service_name]
# 检查端口占用
netstat -tulpn | grep :3000
netstat -tulpn | grep :8080
# 检查磁盘空间
df -h
```
### 2. 数据库连接问题
```bash
# 检查数据库状态
docker compose exec postgres pg_isready -U postgres
# 检查数据库日志
docker compose logs postgres
# 重启数据库服务
docker compose restart postgres
```
### 3. 前端无法访问后端
```bash
# 检查网络连接
docker compose exec frontend ping backend
# 检查 API 配置
docker compose exec frontend env | grep API_BASE
# 测试 API 连接
curl http://localhost:8080/api/health
```
### 4. 内存不足
```bash
# 查看内存使用
free -h
# 增加 swap 空间
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
```
## 安全建议
### 1. 网络安全
- 使用防火墙限制端口访问
- 配置 SSL/TLS 加密
- 定期更新系统和 Docker 版本
### 2. 数据安全
- 定期备份数据库
- 使用强密码
- 限制数据库访问权限
### 3. 容器安全
- 使用非 root 用户运行容器
- 定期更新镜像
- 扫描镜像漏洞
## 下一步
- [了解系统配置](../guide/configuration.md)
- [查看 API 文档](../api/overview.md)
- [学习监控和维护](../development/deployment.md)

View File

@@ -1,302 +0,0 @@
# 💻 本地开发
## 环境准备
### 1. 安装必需软件
#### Go 环境
```bash
# 下载并安装 Go 1.23+
# 访问 https://golang.org/dl/
# 或使用包管理器安装
# 验证安装
go version
```
#### Node.js 环境
```bash
# 下载并安装 Node.js 18+
# 访问 https://nodejs.org/
# 或使用 nvm 安装
# 验证安装
node --version
npm --version
```
#### PostgreSQL 数据库
```bash
# Ubuntu/Debian
sudo apt update
sudo apt install postgresql postgresql-contrib
# macOS (使用 Homebrew)
brew install postgresql
# 启动服务
sudo systemctl start postgresql # Linux
brew services start postgresql # macOS
```
#### pnpm (推荐)
```bash
# 安装 pnpm
npm install -g pnpm
# 验证安装
pnpm --version
```
### 2. 克隆项目
```bash
git clone https://github.com/ctwj/urldb.git
cd urldb
```
## 后端开发
### 1. 环境配置
```bash
# 复制环境变量文件
cp env.example .env
# 编辑环境变量
vim .env
```
### 2. 数据库设置
```sql
-- 登录 PostgreSQL
sudo -u postgres psql
-- 创建数据库
CREATE DATABASE url_db;
-- 创建用户(可选)
CREATE USER url_user WITH PASSWORD 'your_password';
GRANT ALL PRIVILEGES ON DATABASE url_db TO url_user;
-- 退出
\q
```
### 3. 安装依赖
```bash
# 安装 Go 依赖
go mod tidy
# 验证依赖
go mod verify
```
### 4. 启动后端服务
```bash
# 开发模式启动
go run main.go
# 或使用 air 热重载(推荐)
go install github.com/cosmtrek/air@latest
air
```
## 前端开发
### 1. 进入前端目录
```bash
cd web
```
### 2. 安装依赖
```bash
# 使用 pnpm (推荐)
pnpm install
# 或使用 npm
npm install
```
### 3. 启动开发服务器
```bash
# 开发模式
pnpm dev
# 或使用 npm
npm run dev
```
### 4. 访问前端
前端服务启动后,访问 http://localhost:3000
## 开发工具
### 推荐的 IDE 和插件
#### VS Code
- **Go** - Go 语言支持
- **Vetur** 或 **Volar** - Vue.js 支持
- **PostgreSQL** - 数据库支持
- **Docker** - Docker 支持
- **GitLens** - Git 增强
#### GoLand / IntelliJ IDEA
- 内置 Go 和 Vue.js 支持
- 数据库工具
- Docker 集成
### 代码格式化
```bash
# Go 代码格式化
go fmt ./...
# 前端代码格式化
cd web
pnpm format
```
### 代码检查
```bash
# Go 代码检查
go vet ./...
# 前端代码检查
cd web
pnpm lint
```
## 调试技巧
### 后端调试
```bash
# 使用 delve 调试器
go install github.com/go-delve/delve/cmd/dlv@latest
dlv debug main.go
# 或使用 VS Code 调试配置
```
### 前端调试
```bash
# 启动开发服务器时开启调试
cd web
pnpm dev --inspect
```
### 数据库调试
```bash
# 连接数据库
psql -h localhost -U postgres -d url_db
# 查看表结构
\dt
# 查看数据
SELECT * FROM users LIMIT 5;
```
## 测试
### 后端测试
```bash
# 运行所有测试
go test ./...
# 运行特定测试
go test ./handlers
# 生成测试覆盖率报告
go test -cover ./...
```
### 前端测试
```bash
cd web
# 运行单元测试
pnpm test
# 运行 E2E 测试
pnpm test:e2e
```
## 构建
### 后端构建
```bash
# 构建二进制文件
go build -o urlDB main.go
# 交叉编译
GOOS=linux GOARCH=amd64 go build -o urlDB-linux main.go
```
### 前端构建
```bash
cd web
# 构建生产版本
pnpm build
# 预览构建结果
pnpm preview
```
## 常见问题
### 1. 端口冲突
如果遇到端口被占用的问题:
```bash
# 查看端口占用
lsof -i :8080
lsof -i :3000
# 杀死进程
kill -9 <PID>
```
### 2. 数据库连接失败
检查 `.env` 文件中的数据库配置:
```bash
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=your_password
DB_NAME=url_db
```
### 3. 前端依赖安装失败
```bash
# 清除缓存
pnpm store prune
rm -rf node_modules
pnpm install
```
## 下一步
- [了解项目架构](../architecture/overview.md)
- [查看 API 文档](../api/overview.md)
- [学习代码规范](../development/coding-standards.md)

View File

@@ -1,36 +0,0 @@
# 🚀 快速开始
## 环境要求
在开始使用 urlDB 之前,请确保您的系统满足以下要求:
### 推荐配置
- **CPU**: 2核
- **内存**: 2GB+
- **存储**: 20GB+ 可用空间
## 🐳 Docker 部署
### 1. 克隆项目
```bash
git clone https://github.com/ctwj/urldb.git
cd urldb
docker compose up --build -d
```
### 2. 访问应用
启动成功后,您可以通过以下地址访问:
- **前端界面**: http://localhost:3030
默认用户密码: admin/password
## 🆘 遇到问题?
如果您在部署过程中遇到问题,请:
1. 查看 [常见问题](../faq.md)
2. 检查 [更新日志](../changelog.md)
3. 提交 [Issue](https://github.com/ctwj/urldb/issues)

View File

@@ -1,28 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>urlDB - 老九网盘资源数据库</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="description" content="一个现代化的网盘资源数据库,支持多网盘自动化转存分享">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/docsify@4/lib/themes/vue.css">
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/docsify@4/lib/themes/dark.css" media="(prefers-color-scheme: dark)">
<link rel="icon" href="https://img.icons8.com/color/48/000000/database.png" type="image/x-icon">
</head>
<body>
<div id="app"></div>
<script src="//cdn.jsdelivr.net/npm/docsify@4"></script>
<script src="docsify.config.js"></script>
<script src="//cdn.jsdelivr.net/npm/docsify@4/lib/plugins/search.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/docsify@4/lib/plugins/copy-code.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/docsify@4/lib/plugins/pagination.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-bash.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-go.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-javascript.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-typescript.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-sql.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-yaml.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-json.min.js"></script>
</body>
</html>

View File

@@ -1,84 +0,0 @@
# 许可证
## GNU General Public License v3.0
本项目采用 GNU General Public License v3.0 (GPL-3.0) 许可证。
### 许可证概述
GPL-3.0 是一个自由软件许可证,它确保软件保持自由和开放。该许可证的主要特点包括:
- **自由使用**: 您可以自由地运行、研究、修改和分发软件
- **源代码开放**: 修改后的代码必须同样开源
- **专利保护**: 包含专利授权条款
- **兼容性**: 与大多数开源许可证兼容
### 主要条款
1. **自由使用和分发**
- 您可以自由地使用、复制、分发和修改本软件
- 您可以商业使用本软件
2. **源代码要求**
- 如果您分发修改后的版本,必须同时提供源代码
- 源代码必须采用相同的许可证
3. **专利授权**
- 贡献者自动授予用户专利使用权
- 保护用户免受专利诉讼
4. **免责声明**
- 软件按"原样"提供,不提供任何保证
- 作者不承担任何责任
### 完整许可证文本
```
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
[... 完整许可证文本请访问 https://www.gnu.org/licenses/gpl-3.0.html ...]
```
### 如何遵守许可证
如果您使用或修改本项目:
1. **保留许可证信息**: 不要删除或修改许可证文件
2. **注明修改**: 在修改的代码中添加适当的注释
3. **分发源代码**: 如果分发修改版本,必须提供源代码
4. **使用相同许可证**: 修改版本必须使用相同的GPL-3.0许可证
### 贡献代码
当您向本项目贡献代码时,您同意:
- 您的贡献将采用GPL-3.0许可证
- 您拥有或有权许可您贡献的代码
- 您授予项目维护者使用您贡献代码的权利
### 联系方式
如果您对许可证有任何疑问,请联系项目维护者。
---
**注意**: 本许可证信息仅供参考,完整和权威的许可证文本请参考 [GNU General Public License v3.0](https://www.gnu.org/licenses/gpl-3.0.html)。

View File

@@ -1,29 +0,0 @@
#!/bin/bash
# 启动 docsify 文档服务脚本
echo "🚀 启动 docsify 文档服务..."
# 检查是否安装了 docsify-cli
if ! command -v docsify &> /dev/null; then
echo "❌ 未检测到 docsify-cli正在安装..."
npm install -g docsify-cli
if [ $? -ne 0 ]; then
echo "❌ docsify-cli 安装失败,请手动安装:"
echo " npm install -g docsify-cli"
exit 1
fi
fi
# 获取当前脚本所在目录
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "📖 文档目录: $SCRIPT_DIR"
echo "🌐 启动文档服务..."
# 启动 docsify 服务
docsify serve "$SCRIPT_DIR" --port 3000 --open
echo "✅ 文档服务已启动!"
echo "📱 访问地址: http://localhost:3000"
echo "🛑 按 Ctrl+C 停止服务"

View File

@@ -14,4 +14,4 @@ TIMEZONE=Asia/Shanghai
# 文件上传配置
UPLOAD_DIR=./uploads
MAX_FILE_SIZE=100MB
MAX_FILE_SIZE=5MB

6
go.mod
View File

@@ -10,11 +10,17 @@ require (
github.com/go-resty/resty/v2 v2.16.5
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/joho/godotenv v1.5.1
github.com/meilisearch/meilisearch-go v0.33.1
golang.org/x/crypto v0.40.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.30.0
)
require (
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
)
require (
github.com/bytedance/sonic v1.13.3 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect

8
go.sum
View File

@@ -1,3 +1,5 @@
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
@@ -37,6 +39,8 @@ github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
@@ -81,6 +85,8 @@ github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZ
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/meilisearch/meilisearch-go v0.33.1 h1:IWM8iJU7UyuIoRiTTLONvpbEgMhP/yTrnNfSnxj4wu0=
github.com/meilisearch/meilisearch-go v0.33.1/go.mod h1:dY4nxhVc0Ext8Kn7u2YohJCsEjirg80DdcOmfNezUYg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -114,6 +120,8 @@ github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/arch v0.19.0 h1:LmbDQUodHThXE+htjrnmVD73M//D9GTH6wFZjyDkjyU=
golang.org/x/arch v0.19.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=

View File

@@ -2,11 +2,18 @@ package handlers
import (
"github.com/ctwj/urldb/db/repo"
"github.com/ctwj/urldb/services"
)
var repoManager *repo.RepositoryManager
var meilisearchManager *services.MeilisearchManager
// SetRepositoryManager 设置Repository管理器
func SetRepositoryManager(rm *repo.RepositoryManager) {
repoManager = rm
func SetRepositoryManager(manager *repo.RepositoryManager) {
repoManager = manager
}
// SetMeilisearchManager 设置Meilisearch管理器
func SetMeilisearchManager(manager *services.MeilisearchManager) {
meilisearchManager = manager
}

442
handlers/file_handler.go Normal file
View File

@@ -0,0 +1,442 @@
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和角色现在总是有认证
userID := c.GetUint("user_id")
role := c.GetString("role")
utils.Info("GetFileList - 用户ID: %d, 角色: %s", userID, role)
// 根据用户角色决定查询范围
var files []entity.File
var total int64
var err error
if role == "admin" {
// 管理员可以查看所有文件
files, total, err = h.fileRepo.SearchFiles(req.Search, req.FileType, req.Status, req.UserID, req.Page, req.PageSize)
} else {
// 普通用户只能查看自己的文件
files, total, err = h.fileRepo.SearchFiles(req.Search, req.FileType, req.Status, userID, 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
}

View File

@@ -0,0 +1,270 @@
package handlers
import (
"net/http"
"strconv"
"github.com/ctwj/urldb/db/converter"
"github.com/ctwj/urldb/services"
"github.com/ctwj/urldb/utils"
"github.com/gin-gonic/gin"
)
// MeilisearchHandler Meilisearch处理器
type MeilisearchHandler struct {
meilisearchManager *services.MeilisearchManager
}
// NewMeilisearchHandler 创建Meilisearch处理器
func NewMeilisearchHandler(meilisearchManager *services.MeilisearchManager) *MeilisearchHandler {
return &MeilisearchHandler{
meilisearchManager: meilisearchManager,
}
}
// TestConnection 测试Meilisearch连接
func (h *MeilisearchHandler) TestConnection(c *gin.Context) {
var req struct {
Host string `json:"host"`
Port interface{} `json:"port"` // 支持字符串或数字
MasterKey string `json:"masterKey"`
IndexName string `json:"indexName"` // 可选字段
}
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, "请求参数错误", http.StatusBadRequest)
return
}
// 验证必要字段
if req.Host == "" {
ErrorResponse(c, "主机地址不能为空", http.StatusBadRequest)
return
}
// 转换port为字符串
var portStr string
switch v := req.Port.(type) {
case string:
portStr = v
case float64:
portStr = strconv.Itoa(int(v))
case int:
portStr = strconv.Itoa(v)
default:
portStr = "7700" // 默认端口
}
// 如果没有提供索引名称,使用默认值
indexName := req.IndexName
if indexName == "" {
indexName = "resources"
}
// 创建临时服务进行测试
service := services.NewMeilisearchService(req.Host, portStr, req.MasterKey, indexName, true)
if err := service.HealthCheck(); err != nil {
ErrorResponse(c, "连接测试失败: "+err.Error(), http.StatusBadRequest)
return
}
SuccessResponse(c, gin.H{"message": "连接测试成功"})
}
// GetStatus 获取Meilisearch状态
func (h *MeilisearchHandler) GetStatus(c *gin.Context) {
if h.meilisearchManager == nil {
SuccessResponse(c, gin.H{
"enabled": false,
"healthy": false,
"message": "Meilisearch未初始化",
})
return
}
status, err := h.meilisearchManager.GetStatusWithHealthCheck()
if err != nil {
ErrorResponse(c, "获取状态失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, status)
}
// GetUnsyncedCount 获取未同步资源数量
func (h *MeilisearchHandler) GetUnsyncedCount(c *gin.Context) {
if h.meilisearchManager == nil {
SuccessResponse(c, gin.H{"count": 0})
return
}
count, err := h.meilisearchManager.GetUnsyncedCount()
if err != nil {
ErrorResponse(c, "获取未同步数量失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, gin.H{"count": count})
}
// GetUnsyncedResources 获取未同步的资源
func (h *MeilisearchHandler) GetUnsyncedResources(c *gin.Context) {
if h.meilisearchManager == nil {
SuccessResponse(c, gin.H{
"resources": []interface{}{},
"total": 0,
})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
resources, total, err := h.meilisearchManager.GetUnsyncedResources(page, pageSize)
if err != nil {
ErrorResponse(c, "获取未同步资源失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, gin.H{
"resources": converter.ToResourceResponseList(resources),
"total": total,
"page": page,
"page_size": pageSize,
})
}
// GetSyncedResources 获取已同步的资源
func (h *MeilisearchHandler) GetSyncedResources(c *gin.Context) {
if h.meilisearchManager == nil {
SuccessResponse(c, gin.H{
"resources": []interface{}{},
"total": 0,
})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
resources, total, err := h.meilisearchManager.GetSyncedResources(page, pageSize)
if err != nil {
ErrorResponse(c, "获取已同步资源失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, gin.H{
"resources": converter.ToResourceResponseList(resources),
"total": total,
"page": page,
"page_size": pageSize,
})
}
// GetAllResources 获取所有资源
func (h *MeilisearchHandler) GetAllResources(c *gin.Context) {
if h.meilisearchManager == nil {
SuccessResponse(c, gin.H{
"resources": []interface{}{},
"total": 0,
})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
resources, total, err := h.meilisearchManager.GetAllResources(page, pageSize)
if err != nil {
ErrorResponse(c, "获取所有资源失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, gin.H{
"resources": converter.ToResourceResponseList(resources),
"total": total,
"page": page,
"page_size": pageSize,
})
}
// SyncAllResources 同步所有资源
func (h *MeilisearchHandler) SyncAllResources(c *gin.Context) {
if h.meilisearchManager == nil {
ErrorResponse(c, "Meilisearch未初始化", http.StatusInternalServerError)
return
}
utils.Info("开始同步所有资源到Meilisearch...")
_, err := h.meilisearchManager.SyncAllResources()
if err != nil {
ErrorResponse(c, "同步失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, gin.H{
"message": "同步已开始,请查看进度",
})
}
// GetSyncProgress 获取同步进度
func (h *MeilisearchHandler) GetSyncProgress(c *gin.Context) {
if h.meilisearchManager == nil {
ErrorResponse(c, "Meilisearch未初始化", http.StatusInternalServerError)
return
}
progress := h.meilisearchManager.GetSyncProgress()
SuccessResponse(c, progress)
}
// StopSync 停止同步
func (h *MeilisearchHandler) StopSync(c *gin.Context) {
if h.meilisearchManager == nil {
ErrorResponse(c, "Meilisearch未初始化", http.StatusInternalServerError)
return
}
h.meilisearchManager.StopSync()
SuccessResponse(c, gin.H{
"message": "同步已停止",
})
}
// ClearIndex 清空索引
func (h *MeilisearchHandler) ClearIndex(c *gin.Context) {
if h.meilisearchManager == nil {
ErrorResponse(c, "Meilisearch未初始化", http.StatusInternalServerError)
return
}
if err := h.meilisearchManager.ClearIndex(); err != nil {
ErrorResponse(c, "清空索引失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, gin.H{"message": "清空索引成功"})
}
// UpdateIndexSettings 更新索引设置
func (h *MeilisearchHandler) UpdateIndexSettings(c *gin.Context) {
if h.meilisearchManager == nil {
ErrorResponse(c, "Meilisearch未初始化", http.StatusInternalServerError)
return
}
service := h.meilisearchManager.GetService()
if service == nil {
ErrorResponse(c, "Meilisearch服务未初始化", http.StatusInternalServerError)
return
}
if err := service.UpdateIndexSettings(); err != nil {
ErrorResponse(c, "更新索引设置失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, gin.H{"message": "索引设置更新成功"})
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/ctwj/urldb/db/dto"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/utils"
"github.com/gin-gonic/gin"
)
@@ -182,6 +183,7 @@ func (h *PublicAPIHandler) SearchResources(c *gin.Context) {
keyword := c.Query("keyword")
tag := c.Query("tag")
category := c.Query("category")
panID := c.Query("pan_id")
pageStr := c.DefaultQuery("page", "1")
pageSizeStr := c.DefaultQuery("page_size", "20")
@@ -195,65 +197,127 @@ func (h *PublicAPIHandler) SearchResources(c *gin.Context) {
pageSize = 20
}
// 构建搜索条件
params := map[string]interface{}{
"page": page,
"page_size": pageSize,
var resources []entity.Resource
var total int64
// 如果启用了Meilisearch优先使用Meilisearch搜索
if meilisearchManager != nil && meilisearchManager.IsEnabled() {
// 构建过滤器
filters := make(map[string]interface{})
if category != "" {
filters["category"] = category
}
if tag != "" {
filters["tags"] = tag
}
if panID != "" {
if id, err := strconv.ParseUint(panID, 10, 32); err == nil {
// 根据pan_id获取pan_name
pan, err := repoManager.PanRepository.FindByID(uint(id))
if err == nil && pan != nil {
filters["pan_name"] = pan.Name
}
}
}
// 使用Meilisearch搜索
service := meilisearchManager.GetService()
if service != nil {
docs, docTotal, err := service.Search(keyword, filters, page, pageSize)
if err == nil {
// 将Meilisearch文档转换为Resource实体保持兼容性
for _, doc := range docs {
resource := entity.Resource{
ID: doc.ID,
Title: doc.Title,
Description: doc.Description,
URL: doc.URL,
SaveURL: doc.SaveURL,
FileSize: doc.FileSize,
Key: doc.Key,
PanID: doc.PanID,
CreatedAt: doc.CreatedAt,
UpdatedAt: doc.UpdatedAt,
}
resources = append(resources, resource)
}
total = docTotal
} else {
utils.Error("Meilisearch搜索失败回退到数据库搜索: %v", err)
}
}
}
if keyword != "" {
params["search"] = keyword
// 如果Meilisearch未启用或搜索失败使用数据库搜索
if meilisearchManager == nil || !meilisearchManager.IsEnabled() || err != nil {
// 构建搜索条件
params := map[string]interface{}{
"page": page,
"page_size": pageSize,
}
if keyword != "" {
params["search"] = keyword
}
if tag != "" {
params["tag"] = tag
}
if category != "" {
params["category"] = category
}
if panID != "" {
if id, err := strconv.ParseUint(panID, 10, 32); err == nil {
params["pan_id"] = uint(id)
}
}
// 执行数据库搜索
resources, total, err = repoManager.ResourceRepository.SearchWithFilters(params)
if err != nil {
ErrorResponse(c, "搜索失败: "+err.Error(), 500)
return
}
}
if tag != "" {
params["tag"] = tag
}
if category != "" {
params["category"] = category
}
// 执行搜索
resources, total, err := repoManager.ResourceRepository.SearchWithFilters(params)
// 获取违禁词配置(只获取一次)
cleanWords, err := utils.GetForbiddenWordsFromConfig(func() (string, error) {
return repoManager.SystemConfigRepository.GetConfigValue(entity.ConfigKeyForbiddenWords)
})
if err != nil {
ErrorResponse(c, "搜索失败: "+err.Error(), 500)
return
utils.Error("获取违禁词配置失败: %v", err)
cleanWords = []string{} // 如果获取失败,使用空列表
}
// 过滤违禁词
filteredResources, foundForbiddenWords := h.filterForbiddenWords(resources)
// 计算过滤后的总数
filteredTotal := len(filteredResources)
// 转换为响应格式
// 转换为响应格式并添加违禁词标记
var resourceResponses []gin.H
for _, resource := range filteredResources {
resourceResponses = append(resourceResponses, gin.H{
"id": resource.ID,
"title": resource.Title,
"url": resource.URL,
"description": resource.Description,
"view_count": resource.ViewCount,
"created_at": resource.CreatedAt.Format("2006-01-02 15:04:05"),
"updated_at": resource.UpdatedAt.Format("2006-01-02 15:04:05"),
})
for i, processedResource := range resources {
originalResource := resources[i]
forbiddenInfo := utils.CheckResourceForbiddenWords(originalResource.Title, originalResource.Description, cleanWords)
resourceResponse := gin.H{
"id": processedResource.ID,
"title": forbiddenInfo.ProcessedTitle, // 使用处理后的标题
"url": processedResource.URL,
"description": forbiddenInfo.ProcessedDesc, // 使用处理后的描述
"view_count": processedResource.ViewCount,
"created_at": processedResource.CreatedAt.Format("2006-01-02 15:04:05"),
"updated_at": processedResource.UpdatedAt.Format("2006-01-02 15:04:05"),
}
// 添加违禁词标记
resourceResponse["has_forbidden_words"] = forbiddenInfo.HasForbiddenWords
resourceResponse["forbidden_words"] = forbiddenInfo.ForbiddenWords
resourceResponses = append(resourceResponses, resourceResponse)
}
// 构建响应数据
responseData := gin.H{
"list": resourceResponses,
"total": filteredTotal,
"page": page,
"limit": pageSize,
}
// 如果存在违禁词过滤,添加提醒字段
if len(foundForbiddenWords) > 0 {
responseData["forbidden_words_filtered"] = true
responseData["filtered_forbidden_words"] = foundForbiddenWords
responseData["original_total"] = total
responseData["filtered_count"] = total - int64(filteredTotal)
"data": resourceResponses,
"total": total,
"page": page,
"page_size": pageSize,
}
SuccessResponse(c, responseData)

View File

@@ -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,
@@ -60,16 +64,74 @@ func GetResources(c *gin.Context) {
params["pan_name"] = panName
}
resources, total, err := repoManager.ResourceRepository.SearchWithFilters(params)
// 获取违禁词配置(只获取一次)
cleanWords, err := utils.GetForbiddenWordsFromConfig(func() (string, error) {
return repoManager.SystemConfigRepository.GetConfigValue(entity.ConfigKeyForbiddenWords)
})
if err != nil {
utils.Error("获取违禁词配置失败: %v", err)
cleanWords = []string{} // 如果获取失败,使用空列表
}
// 搜索统计(仅非管理员)
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)
var resources []entity.Resource
var total int64
// 如果有搜索关键词且启用了Meilisearch优先使用Meilisearch搜索
if search := c.Query("search"); search != "" && meilisearchManager != nil && meilisearchManager.IsEnabled() {
// 构建Meilisearch过滤器
filters := make(map[string]interface{})
if panID := c.Query("pan_id"); panID != "" {
if id, err := strconv.ParseUint(panID, 10, 32); err == nil {
// 直接使用pan_id进行过滤
filters["pan_id"] = id
}
}
// 使用Meilisearch搜索
service := meilisearchManager.GetService()
if service != nil {
docs, docTotal, err := service.Search(search, filters, page, pageSize)
if err == nil {
// 将Meilisearch文档转换为ResourceResponse包含高亮信息并处理违禁词
var resourceResponses []dto.ResourceResponse
for _, doc := range docs {
resourceResponse := converter.ToResourceResponseFromMeilisearch(doc)
// 处理违禁词Meilisearch场景需要处理高亮标记
if len(cleanWords) > 0 {
forbiddenInfo := utils.CheckResourceForbiddenWords(resourceResponse.Title, resourceResponse.Description, cleanWords)
if forbiddenInfo.HasForbiddenWords {
resourceResponse.Title = forbiddenInfo.ProcessedTitle
resourceResponse.Description = forbiddenInfo.ProcessedDesc
resourceResponse.TitleHighlight = forbiddenInfo.ProcessedTitle
resourceResponse.DescriptionHighlight = forbiddenInfo.ProcessedDesc
}
resourceResponse.HasForbiddenWords = forbiddenInfo.HasForbiddenWords
resourceResponse.ForbiddenWords = forbiddenInfo.ForbiddenWords
}
resourceResponses = append(resourceResponses, resourceResponse)
}
// 返回Meilisearch搜索结果包含高亮信息
SuccessResponse(c, gin.H{
"data": resourceResponses,
"total": docTotal,
"page": page,
"page_size": pageSize,
"source": "meilisearch",
})
return
} else {
utils.Error("Meilisearch搜索失败回退到数据库搜索: %v", err)
}
}
}
// 如果Meilisearch未启用、搜索失败或没有搜索关键词使用数据库搜索
if meilisearchManager == nil || !meilisearchManager.IsEnabled() || len(resources) == 0 {
resources, total, err = repoManager.ResourceRepository.SearchWithFilters(params)
}
if err != nil {
@@ -77,12 +139,48 @@ func GetResources(c *gin.Context) {
return
}
SuccessResponse(c, gin.H{
"data": converter.ToResourceResponseList(resources),
// 处理违禁词替换和标记
var processedResources []entity.Resource
if len(cleanWords) > 0 {
processedResources = utils.ProcessResourcesForbiddenWords(resources, cleanWords)
} else {
processedResources = resources
}
// 转换为响应格式并添加违禁词标记
var resourceResponses []gin.H
for i, processedResource := range processedResources {
// 使用原始资源进行检查违禁词(数据库搜索场景,使用普通处理)
originalResource := resources[i]
forbiddenInfo := utils.CheckResourceForbiddenWords(originalResource.Title, originalResource.Description, cleanWords)
resourceResponse := gin.H{
"id": processedResource.ID,
"title": forbiddenInfo.ProcessedTitle, // 使用处理后的标题
"url": processedResource.URL,
"description": forbiddenInfo.ProcessedDesc, // 使用处理后的描述
"pan_id": processedResource.PanID,
"view_count": processedResource.ViewCount,
"created_at": processedResource.CreatedAt.Format("2006-01-02 15:04:05"),
"updated_at": processedResource.UpdatedAt.Format("2006-01-02 15:04:05"),
}
// 添加违禁词标记
resourceResponse["has_forbidden_words"] = forbiddenInfo.HasForbiddenWords
resourceResponse["forbidden_words"] = forbiddenInfo.ForbiddenWords
resourceResponses = append(resourceResponses, resourceResponse)
}
// 构建响应数据
responseData := gin.H{
"data": resourceResponses,
"total": total,
"page": page,
"page_size": pageSize,
})
}
SuccessResponse(c, responseData)
}
// GetResourceByID 根据ID获取资源
@@ -170,6 +268,15 @@ func CreateResource(c *gin.Context) {
}
}
// 同步到Meilisearch
if meilisearchManager != nil && meilisearchManager.IsEnabled() {
go func() {
if err := meilisearchManager.SyncResourceToMeilisearch(resource); err != nil {
utils.Error("同步资源到Meilisearch失败: %v", err)
}
}()
}
SuccessResponse(c, gin.H{
"message": "资源创建成功",
"resource": converter.ToResourceResponse(resource),
@@ -246,6 +353,15 @@ func UpdateResource(c *gin.Context) {
}
}
// 同步到Meilisearch
if meilisearchManager != nil && meilisearchManager.IsEnabled() {
go func() {
if err := meilisearchManager.SyncResourceToMeilisearch(resource); err != nil {
utils.Error("同步资源到Meilisearch失败: %v", err)
}
}()
}
SuccessResponse(c, gin.H{"message": "资源更新成功"})
}
@@ -277,16 +393,53 @@ func SearchResources(c *gin.Context) {
var total int64
var err error
if query == "" {
// 搜索关键词为空时,返回最新记录(分页)
resources, total, err = repoManager.ResourceRepository.FindWithRelationsPaginated(page, pageSize)
} 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)
// 如果启用了Meilisearch优先使用Meilisearch搜索
if meilisearchManager != nil && meilisearchManager.IsEnabled() {
// 构建过滤器
filters := make(map[string]interface{})
if categoryID := c.Query("category_id"); categoryID != "" {
if id, err := strconv.ParseUint(categoryID, 10, 32); err == nil {
filters["category"] = uint(id)
}
}
// 使用Meilisearch搜索
service := meilisearchManager.GetService()
if service != nil {
docs, docTotal, err := service.Search(query, filters, page, pageSize)
if err == nil {
// 将Meilisearch文档转换为Resource实体
for _, doc := range docs {
resource := entity.Resource{
ID: doc.ID,
Title: doc.Title,
Description: doc.Description,
URL: doc.URL,
SaveURL: doc.SaveURL,
FileSize: doc.FileSize,
Key: doc.Key,
PanID: doc.PanID,
CreatedAt: doc.CreatedAt,
UpdatedAt: doc.UpdatedAt,
}
resources = append(resources, resource)
}
total = docTotal
} else {
utils.Error("Meilisearch搜索失败回退到数据库搜索: %v", err)
}
}
}
// 如果Meilisearch未启用或搜索失败使用数据库搜索
if meilisearchManager == nil || !meilisearchManager.IsEnabled() || err != nil {
if query == "" {
// 搜索关键词为空时,返回最新记录(分页)
resources, total, err = repoManager.ResourceRepository.FindWithRelationsPaginated(page, pageSize)
} else {
// 有搜索关键词时,执行搜索
resources, total, err = repoManager.ResourceRepository.Search(query, nil, page, pageSize)
}
}
if err != nil {

View File

@@ -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,38 @@ 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()
// 重新加载Meilisearch配置如果Meilisearch配置有变更
if req.MeilisearchEnabled != nil || req.MeilisearchHost != nil || req.MeilisearchPort != nil || req.MeilisearchMasterKey != nil || req.MeilisearchIndexName != nil {
if meilisearchManager != nil {
if err := meilisearchManager.ReloadConfig(); err != nil {
utils.Error("重新加载Meilisearch配置失败: %v", err)
} else {
utils.Debug("Meilisearch配置重新加载成功")
}
}
}
// 根据配置更新定时任务状态(错误不影响配置保存)
scheduler := scheduler.GetGlobalScheduler(
repoManager.HotDramaRepository,
@@ -167,16 +218,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 +257,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 {
@@ -227,6 +322,12 @@ func ToggleAutoProcess(c *gin.Context) {
return
}
// 确保配置缓存已刷新
if err := repoManager.SystemConfigRepository.SafeRefreshConfigCache(); err != nil {
utils.Error("刷新配置缓存失败: %v", err)
// 不返回错误,因为配置已经保存成功
}
// 更新定时任务状态
scheduler := scheduler.GetGlobalScheduler(
repoManager.HotDramaRepository,

View File

@@ -50,7 +50,7 @@ func (h *TaskHandler) CreateBatchTransferTask(c *gin.Context) {
return
}
utils.Info("创建批量转存任务: %s资源数量: %d选择账号数量: %d", req.Title, len(req.Resources), len(req.SelectedAccounts))
utils.Debug("创建批量转存任务: %s资源数量: %d选择账号数量: %d", req.Title, len(req.Resources), len(req.SelectedAccounts))
// 构建任务配置
taskConfig := map[string]interface{}{
@@ -105,7 +105,7 @@ func (h *TaskHandler) CreateBatchTransferTask(c *gin.Context) {
}
}
utils.Info("批量转存任务创建完成: %d, 共 %d 项", newTask.ID, len(req.Resources))
utils.Debug("批量转存任务创建完成: %d, 共 %d 项", newTask.ID, len(req.Resources))
SuccessResponse(c, gin.H{
"task_id": newTask.ID,
@@ -123,8 +123,6 @@ func (h *TaskHandler) StartTask(c *gin.Context) {
return
}
utils.Info("启动任务: %d", taskID)
err = h.taskManager.StartTask(uint(taskID))
if err != nil {
utils.Error("启动任务失败: %v", err)
@@ -132,6 +130,8 @@ func (h *TaskHandler) StartTask(c *gin.Context) {
return
}
utils.Debug("启动任务: %d", taskID)
SuccessResponse(c, gin.H{
"message": "任务启动成功",
})
@@ -146,8 +146,6 @@ func (h *TaskHandler) StopTask(c *gin.Context) {
return
}
utils.Info("停止任务: %d", taskID)
err = h.taskManager.StopTask(uint(taskID))
if err != nil {
utils.Error("停止任务失败: %v", err)
@@ -155,6 +153,8 @@ func (h *TaskHandler) StopTask(c *gin.Context) {
return
}
utils.Debug("停止任务: %d", taskID)
SuccessResponse(c, gin.H{
"message": "任务停止成功",
})
@@ -169,8 +169,6 @@ func (h *TaskHandler) PauseTask(c *gin.Context) {
return
}
utils.Info("暂停任务: %d", taskID)
err = h.taskManager.PauseTask(uint(taskID))
if err != nil {
utils.Error("暂停任务失败: %v", err)
@@ -178,6 +176,8 @@ func (h *TaskHandler) PauseTask(c *gin.Context) {
return
}
utils.Debug("暂停任务: %d", taskID)
SuccessResponse(c, gin.H{
"message": "任务暂停成功",
})
@@ -234,13 +234,25 @@ func (h *TaskHandler) GetTaskStatus(c *gin.Context) {
// GetTasks 获取任务列表
func (h *TaskHandler) GetTasks(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
taskType := c.Query("task_type")
// 获取查询参数
pageStr := c.DefaultQuery("page", "1")
pageSizeStr := c.DefaultQuery("pageSize", "10")
taskType := c.Query("taskType")
status := c.Query("status")
utils.Info("GetTasks: 获取任务列表 page=%d, pageSize=%d, taskType=%s, status=%s", page, pageSize, taskType, status)
page, err := strconv.Atoi(pageStr)
if err != nil || page < 1 {
page = 1
}
pageSize, err := strconv.Atoi(pageSizeStr)
if err != nil || pageSize < 1 || pageSize > 100 {
pageSize = 10
}
utils.Debug("GetTasks: 获取任务列表 page=%d, pageSize=%d, taskType=%s, status=%s", page, pageSize, taskType, status)
// 获取任务列表
tasks, total, err := h.repoMgr.TaskRepository.GetList(page, pageSize, taskType, status)
if err != nil {
utils.Error("获取任务列表失败: %v", err)
@@ -248,19 +260,19 @@ func (h *TaskHandler) GetTasks(c *gin.Context) {
return
}
utils.Info("GetTasks: 从数据库获取到 %d 个任务", len(tasks))
utils.Debug("GetTasks: 从数据库获取到 %d 个任务", len(tasks))
// 为每个任务添加运行状态
var result []gin.H
// 获取任务运行状态
var taskList []gin.H
for _, task := range tasks {
isRunning := h.taskManager.IsTaskRunning(task.ID)
utils.Info("GetTasks: 任务 %d (%s) 数据库状态: %s, TaskManager运行状态: %v", task.ID, task.Title, task.Status, isRunning)
utils.Debug("GetTasks: 任务 %d (%s) 数据库状态: %s, TaskManager运行状态: %v", task.ID, task.Title, task.Status, isRunning)
result = append(result, gin.H{
taskList = append(taskList, gin.H{
"id": task.ID,
"title": task.Title,
"description": task.Description,
"task_type": task.Type,
"type": task.Type,
"status": task.Status,
"total_items": task.TotalItems,
"processed_items": task.ProcessedItems,
@@ -273,10 +285,11 @@ func (h *TaskHandler) GetTasks(c *gin.Context) {
}
SuccessResponse(c, gin.H{
"items": result,
"total": total,
"page": page,
"size": pageSize,
"tasks": taskList,
"total": total,
"page": page,
"page_size": pageSize,
"total_pages": (total + int64(pageSize) - 1) / int64(pageSize),
})
}
@@ -348,7 +361,7 @@ func (h *TaskHandler) DeleteTask(c *gin.Context) {
// 检查任务是否在运行
if h.taskManager.IsTaskRunning(uint(taskID)) {
ErrorResponse(c, "任务正在运行,请先停止任务", http.StatusBadRequest)
ErrorResponse(c, "任务正在运行中,无法删除", http.StatusBadRequest)
return
}
@@ -368,7 +381,8 @@ func (h *TaskHandler) DeleteTask(c *gin.Context) {
return
}
utils.Info("任务删除成功: %d", taskID)
utils.Debug("任务删除成功: %d", taskID)
SuccessResponse(c, gin.H{
"message": "任务删除成功",
})

90
main.go
View File

@@ -1,13 +1,18 @@
package main
import (
"fmt"
"log"
"os"
"strings"
"github.com/ctwj/urldb/db"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/db/repo"
"github.com/ctwj/urldb/handlers"
"github.com/ctwj/urldb/middleware"
"github.com/ctwj/urldb/scheduler"
"github.com/ctwj/urldb/services"
"github.com/ctwj/urldb/task"
"github.com/ctwj/urldb/utils"
@@ -17,6 +22,18 @@ import (
)
func main() {
// 检查命令行参数
if len(os.Args) > 1 && os.Args[1] == "version" {
versionInfo := utils.GetVersionInfo()
fmt.Printf("版本: v%s\n", versionInfo.Version)
fmt.Printf("构建时间: %s\n", versionInfo.BuildTime.Format("2006-01-02 15:04:05"))
fmt.Printf("Git提交: %s\n", versionInfo.GitCommit)
fmt.Printf("Git分支: %s\n", versionInfo.GitBranch)
fmt.Printf("Go版本: %s\n", versionInfo.GoVersion)
fmt.Printf("平台: %s/%s\n", versionInfo.Platform, versionInfo.Arch)
return
}
// 初始化日志系统
if err := utils.InitLogger(nil); err != nil {
log.Fatal("初始化日志系统失败:", err)
@@ -75,6 +92,12 @@ func main() {
transferProcessor := task.NewTransferProcessor(repoManager)
taskManager.RegisterProcessor(transferProcessor)
// 初始化Meilisearch管理器
meilisearchManager := services.NewMeilisearchManager(repoManager)
if err := meilisearchManager.Initialize(); err != nil {
utils.Error("初始化Meilisearch管理器失败: %v", err)
}
// 恢复运行中的任务(服务器重启后)
if err := taskManager.RecoverRunningTasks(); err != nil {
utils.Error("恢复运行中任务失败: %v", err)
@@ -97,6 +120,37 @@ func main() {
// 将Repository管理器注入到handlers中
handlers.SetRepositoryManager(repoManager)
// 设置Meilisearch管理器到handlers中
handlers.SetMeilisearchManager(meilisearchManager)
// 设置全局调度器的Meilisearch管理器
scheduler.SetGlobalMeilisearchManager(meilisearchManager)
// 初始化并启动调度器
globalScheduler := scheduler.GetGlobalScheduler(
repoManager.HotDramaRepository,
repoManager.ReadyResourceRepository,
repoManager.ResourceRepository,
repoManager.SystemConfigRepository,
repoManager.PanRepository,
repoManager.CksRepository,
repoManager.TagRepository,
repoManager.CategoryRepository,
)
// 根据系统配置启动相应的调度任务
autoFetchHotDrama, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoFetchHotDramaEnabled)
autoProcessReadyResources, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoProcessReadyResources)
autoTransferEnabled, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoTransferEnabled)
globalScheduler.UpdateSchedulerStatusWithAutoTransfer(
autoFetchHotDrama,
autoProcessReadyResources,
autoTransferEnabled,
)
utils.Info("调度器初始化完成")
// 设置公开API中间件的Repository管理器
middleware.SetRepositoryManager(repoManager)
@@ -106,6 +160,12 @@ func main() {
// 创建任务处理器
taskHandler := handlers.NewTaskHandler(repoManager, taskManager)
// 创建文件处理器
fileHandler := handlers.NewFileHandler(repoManager.FileRepository, repoManager.SystemConfigRepository, repoManager.UserRepository)
// 创建Meilisearch处理器
meilisearchHandler := handlers.NewMeilisearchHandler(meilisearchManager)
// API路由
api := r.Group("/api")
{
@@ -211,6 +271,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 +297,40 @@ func main() {
api.GET("/version/string", handlers.GetVersionString)
api.GET("/version/full", handlers.GetFullVersionInfo)
api.GET("/version/check-update", handlers.CheckUpdate)
// Meilisearch管理路由
api.GET("/meilisearch/status", middleware.AuthMiddleware(), middleware.AdminMiddleware(), meilisearchHandler.GetStatus)
api.GET("/meilisearch/unsynced-count", middleware.AuthMiddleware(), middleware.AdminMiddleware(), meilisearchHandler.GetUnsyncedCount)
api.GET("/meilisearch/unsynced", middleware.AuthMiddleware(), middleware.AdminMiddleware(), meilisearchHandler.GetUnsyncedResources)
api.GET("/meilisearch/synced", middleware.AuthMiddleware(), middleware.AdminMiddleware(), meilisearchHandler.GetSyncedResources)
api.GET("/meilisearch/resources", middleware.AuthMiddleware(), middleware.AdminMiddleware(), meilisearchHandler.GetAllResources)
api.POST("/meilisearch/sync-all", middleware.AuthMiddleware(), middleware.AdminMiddleware(), meilisearchHandler.SyncAllResources)
api.GET("/meilisearch/sync-progress", middleware.AuthMiddleware(), middleware.AdminMiddleware(), meilisearchHandler.GetSyncProgress)
api.POST("/meilisearch/stop-sync", middleware.AuthMiddleware(), middleware.AdminMiddleware(), meilisearchHandler.StopSync)
api.POST("/meilisearch/clear-index", middleware.AuthMiddleware(), middleware.AdminMiddleware(), meilisearchHandler.ClearIndex)
api.POST("/meilisearch/test-connection", middleware.AuthMiddleware(), middleware.AdminMiddleware(), meilisearchHandler.TestConnection)
api.POST("/meilisearch/update-settings", middleware.AuthMiddleware(), middleware.AdminMiddleware(), meilisearchHandler.UpdateIndexSettings)
// 文件上传相关路由
api.POST("/files/upload", middleware.AuthMiddleware(), fileHandler.UploadFile)
api.GET("/files", middleware.AuthMiddleware(), 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"

View File

@@ -27,8 +27,8 @@ type Claims struct {
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
utils.Info("AuthMiddleware - 收到请求: %s %s", c.Request.Method, c.Request.URL.Path)
utils.Info("AuthMiddleware - Authorization头: %s", authHeader)
// utils.Info("AuthMiddleware - 收到请求: %s %s", c.Request.Method, c.Request.URL.Path)
// utils.Info("AuthMiddleware - Authorization头: %s", authHeader)
if authHeader == "" {
utils.Error("AuthMiddleware - 未提供认证令牌")
@@ -39,24 +39,24 @@ func AuthMiddleware() gin.HandlerFunc {
// 检查Bearer前缀
if !strings.HasPrefix(authHeader, "Bearer ") {
utils.Error("AuthMiddleware - 无效的认证格式: %s", authHeader)
// utils.Error("AuthMiddleware - 无效的认证格式: %s", authHeader)
c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的认证格式"})
c.Abort()
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
utils.Info("AuthMiddleware - 解析令牌: %s", tokenString[:10]+"...")
// utils.Info("AuthMiddleware - 解析令牌: %s", tokenString[:10]+"...")
claims, err := parseToken(tokenString)
if err != nil {
utils.Error("AuthMiddleware - 令牌解析失败: %v", err)
// utils.Error("AuthMiddleware - 令牌解析失败: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的令牌"})
c.Abort()
return
}
utils.Info("AuthMiddleware - 令牌验证成功,用户: %s, 角色: %s", claims.Username, claims.Role)
// utils.Info("AuthMiddleware - 令牌验证成功,用户: %s, 角色: %s", claims.Username, claims.Role)
// 将用户信息存储到上下文中
c.Set("user_id", claims.UserID)
@@ -72,13 +72,13 @@ func AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
role, exists := c.Get("role")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
// c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
c.Abort()
return
}
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
// c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
c.Abort()
return
}
@@ -106,23 +106,23 @@ func GenerateToken(user *entity.User) (string, error) {
// parseToken 解析JWT令牌
func parseToken(tokenString string) (*Claims, error) {
utils.Info("parseToken - 开始解析令牌")
// utils.Info("parseToken - 开始解析令牌")
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return jwtSecret, nil
})
if err != nil {
utils.Error("parseToken - JWT解析失败: %v", err)
// utils.Error("parseToken - JWT解析失败: %v", err)
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
utils.Info("parseToken - 令牌解析成功用户ID: %d", claims.UserID)
// utils.Info("parseToken - 令牌解析成功用户ID: %d", claims.UserID)
return claims, nil
}
utils.Error("parseToken - 令牌无效或签名错误")
// utils.Error("parseToken - 令牌无效或签名错误")
return nil, jwt.ErrSignatureInvalid
}

14
migrations/v1.x.x.sql Normal file
View File

@@ -0,0 +1,14 @@
-- 添加文件哈希字段
ALTER TABLE files ADD COLUMN file_hash VARCHAR(64) COMMENT '文件哈希值';
CREATE UNIQUE INDEX idx_files_hash ON files(file_hash);
-- 添加同步状态字段
ALTER TABLE resources ADD COLUMN synced_to_meilisearch BOOLEAN DEFAULT FALSE;
ALTER TABLE resources ADD COLUMN synced_at TIMESTAMP NULL;
-- 创建索引以提高查询性能
CREATE INDEX idx_resources_synced ON resources(synced_to_meilisearch, synced_at);
-- 添加注释
COMMENT ON COLUMN resources.synced_to_meilisearch IS '是否已同步到Meilisearch';
COMMENT ON COLUMN resources.synced_at IS '同步时间';

View File

@@ -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";
}
# 健康检查

View File

@@ -218,9 +218,9 @@ func (a *AutoTransferScheduler) processAutoTransfer() {
if shouldSkip {
// 标记为违禁词错误
resource.ErrorMsg = fmt.Sprintf("存在违禁词: %s", strings.Join(matchedWords, ", "))
resource.ErrorMsg = fmt.Sprintf("存在违禁词 (共 %d 个)", len(matchedWords))
forbiddenResources = append(forbiddenResources, resource)
utils.Info(fmt.Sprintf("标记违禁词资源: %s, 违禁词: %s", resource.Title, strings.Join(matchedWords, ", ")))
utils.Info(fmt.Sprintf("标记违禁词资源: %s (包含 %d 个违禁词)", resource.Title, len(matchedWords)))
} else {
filteredResources = append(filteredResources, resource)
}

View File

@@ -4,6 +4,7 @@ import (
"sync"
"github.com/ctwj/urldb/db/repo"
"github.com/ctwj/urldb/services"
"github.com/ctwj/urldb/utils"
)
@@ -16,8 +17,20 @@ type GlobalScheduler struct {
var (
globalScheduler *GlobalScheduler
once sync.Once
// 全局Meilisearch管理器
globalMeilisearchManager *services.MeilisearchManager
)
// SetGlobalMeilisearchManager 设置全局Meilisearch管理器
func SetGlobalMeilisearchManager(manager *services.MeilisearchManager) {
globalMeilisearchManager = manager
}
// GetGlobalMeilisearchManager 获取全局Meilisearch管理器
func GetGlobalMeilisearchManager() *services.MeilisearchManager {
return globalMeilisearchManager
}
// GetGlobalScheduler 获取全局调度器实例(单例模式)
func GetGlobalScheduler(hotDramaRepo repo.HotDramaRepository, readyResourceRepo repo.ReadyResourceRepository, resourceRepo repo.ResourceRepository, systemConfigRepo repo.SystemConfigRepository, panRepo repo.PanRepository, cksRepo repo.CksRepository, tagRepo repo.TagRepository, categoryRepo repo.CategoryRepository) *GlobalScheduler {
once.Do(func() {
@@ -34,12 +47,12 @@ func (gs *GlobalScheduler) StartHotDramaScheduler() {
defer gs.mutex.Unlock()
if gs.manager.IsHotDramaRunning() {
utils.Info("热播剧定时任务已在运行中")
utils.Debug("热播剧定时任务已在运行中")
return
}
gs.manager.StartHotDramaScheduler()
utils.Info("全局调度器已启动热播剧定时任务")
utils.Debug("全局调度器已启动热播剧定时任务")
}
// StopHotDramaScheduler 停止热播剧定时任务
@@ -48,12 +61,12 @@ func (gs *GlobalScheduler) StopHotDramaScheduler() {
defer gs.mutex.Unlock()
if !gs.manager.IsHotDramaRunning() {
utils.Info("热播剧定时任务未在运行")
utils.Debug("热播剧定时任务未在运行")
return
}
gs.manager.StopHotDramaScheduler()
utils.Info("全局调度器已停止热播剧定时任务")
utils.Debug("全局调度器已停止热播剧定时任务")
}
// IsHotDramaSchedulerRunning 检查热播剧定时任务是否在运行
@@ -74,12 +87,12 @@ func (gs *GlobalScheduler) StartReadyResourceScheduler() {
defer gs.mutex.Unlock()
if gs.manager.IsReadyResourceRunning() {
utils.Info("待处理资源自动处理任务已在运行中")
utils.Debug("待处理资源自动处理任务已在运行中")
return
}
gs.manager.StartReadyResourceScheduler()
utils.Info("全局调度器已启动待处理资源自动处理任务")
utils.Debug("全局调度器已启动待处理资源自动处理任务")
}
// StopReadyResourceScheduler 停止待处理资源自动处理任务
@@ -88,12 +101,12 @@ func (gs *GlobalScheduler) StopReadyResourceScheduler() {
defer gs.mutex.Unlock()
if !gs.manager.IsReadyResourceRunning() {
utils.Info("待处理资源自动处理任务未在运行")
utils.Debug("待处理资源自动处理任务未在运行")
return
}
gs.manager.StopReadyResourceScheduler()
utils.Info("全局调度器已停止待处理资源自动处理任务")
utils.Debug("全局调度器已停止待处理资源自动处理任务")
}
// IsReadyResourceRunning 检查待处理资源自动处理任务是否在运行
@@ -109,12 +122,12 @@ func (gs *GlobalScheduler) StartAutoTransferScheduler() {
defer gs.mutex.Unlock()
if gs.manager.IsAutoTransferRunning() {
utils.Info("自动转存定时任务已在运行中")
utils.Debug("自动转存定时任务已在运行中")
return
}
gs.manager.StartAutoTransferScheduler()
utils.Info("全局调度器已启动自动转存定时任务")
utils.Debug("全局调度器已启动自动转存定时任务")
}
// StopAutoTransferScheduler 停止自动转存定时任务
@@ -123,12 +136,12 @@ func (gs *GlobalScheduler) StopAutoTransferScheduler() {
defer gs.mutex.Unlock()
if !gs.manager.IsAutoTransferRunning() {
utils.Info("自动转存定时任务未在运行")
utils.Debug("自动转存定时任务未在运行")
return
}
gs.manager.StopAutoTransferScheduler()
utils.Info("全局调度器已停止自动转存定时任务")
utils.Debug("全局调度器已停止自动转存定时任务")
}
// IsAutoTransferRunning 检查自动转存定时任务是否在运行

View File

@@ -51,34 +51,34 @@ func NewManager(
// StartAll 启动所有调度任务
func (m *Manager) StartAll() {
utils.Info("启动所有调度任务")
utils.Debug("启动所有调度任务")
// 启动热播剧调度任务
m.hotDramaScheduler.Start()
// 启动热播剧定时任务
m.StartHotDramaScheduler()
// 启动待处理资源调度任务
m.readyResourceScheduler.Start()
// 启动待处理资源自动处理任务
m.StartReadyResourceScheduler()
// 启动自动转存调度任务
m.autoTransferScheduler.Start()
// 启动自动转存定时任务
m.StartAutoTransferScheduler()
utils.Info("所有调度任务已启动")
utils.Debug("所有调度任务已启动")
}
// StopAll 停止所有调度任务
func (m *Manager) StopAll() {
utils.Info("停止所有调度任务")
utils.Debug("停止所有调度任务")
// 停止热播剧调度任务
m.hotDramaScheduler.Stop()
// 停止热播剧定时任务
m.StopHotDramaScheduler()
// 停止待处理资源调度任务
m.readyResourceScheduler.Stop()
// 停止待处理资源自动处理任务
m.StopReadyResourceScheduler()
// 停止自动转存调度任务
m.autoTransferScheduler.Stop()
// 停止自动转存定时任务
m.StopAutoTransferScheduler()
utils.Info("所有调度任务已停止")
utils.Debug("所有调度任务已停止")
}
// StartHotDramaScheduler 启动热播剧调度任务

View File

@@ -31,7 +31,7 @@ func NewReadyResourceScheduler(base *BaseScheduler) *ReadyResourceScheduler {
// Start 启动待处理资源定时任务
func (r *ReadyResourceScheduler) Start() {
if r.readyResourceRunning {
utils.Info("待处理资源自动处理任务已在运行中")
utils.Debug("待处理资源自动处理任务已在运行中")
return
}
@@ -63,7 +63,7 @@ func (r *ReadyResourceScheduler) Start() {
r.processReadyResources()
}()
} else {
utils.Info("上一次待处理资源任务还在执行中,跳过本次执行")
utils.Debug("上一次待处理资源任务还在执行中,跳过本次执行")
}
case <-r.GetStopChan():
utils.Info("停止待处理资源自动处理任务")
@@ -76,7 +76,7 @@ func (r *ReadyResourceScheduler) Start() {
// Stop 停止待处理资源定时任务
func (r *ReadyResourceScheduler) Stop() {
if !r.readyResourceRunning {
utils.Info("待处理资源自动处理任务未在运行")
utils.Debug("待处理资源自动处理任务未在运行")
return
}
@@ -92,7 +92,7 @@ func (r *ReadyResourceScheduler) IsReadyResourceRunning() bool {
// processReadyResources 处理待处理资源
func (r *ReadyResourceScheduler) processReadyResources() {
utils.Info("开始处理待处理资源...")
utils.Debug("开始处理待处理资源...")
// 检查系统配置,确认是否启用自动处理
autoProcess, err := r.systemConfigRepo.GetConfigBool(entity.ConfigKeyAutoProcessReadyResources)
@@ -102,7 +102,7 @@ func (r *ReadyResourceScheduler) processReadyResources() {
}
if !autoProcess {
utils.Info("自动处理待处理资源功能已禁用")
utils.Debug("自动处理待处理资源功能已禁用")
return
}
@@ -115,11 +115,11 @@ func (r *ReadyResourceScheduler) processReadyResources() {
}
if len(readyResources) == 0 {
utils.Info("没有待处理的资源")
utils.Debug("没有待处理的资源")
return
}
utils.Info(fmt.Sprintf("找到 %d 个待处理资源,开始处理...", len(readyResources)))
utils.Debug(fmt.Sprintf("找到 %d 个待处理资源,开始处理...", len(readyResources)))
processedCount := 0
factory := panutils.GetInstance() // 使用单例模式
@@ -132,7 +132,7 @@ func (r *ReadyResourceScheduler) processReadyResources() {
continue
}
if exits {
utils.Info(fmt.Sprintf("资源已存在: %s", readyResource.URL))
utils.Debug(fmt.Sprintf("资源已存在: %s", readyResource.URL))
r.readyResourceRepo.Delete(readyResource.ID)
continue
}
@@ -146,7 +146,7 @@ func (r *ReadyResourceScheduler) processReadyResources() {
if updateErr := r.readyResourceRepo.Update(&readyResource); updateErr != nil {
utils.Error(fmt.Sprintf("更新错误信息失败 (ID: %d): %v", readyResource.ID, updateErr))
} else {
utils.Info(fmt.Sprintf("已保存错误信息到资源 (ID: %d): %s", readyResource.ID, err.Error()))
utils.Debug(fmt.Sprintf("已保存错误信息到资源 (ID: %d): %s", readyResource.ID, err.Error()))
}
// 处理失败后删除资源,避免重复处理
@@ -155,11 +155,13 @@ func (r *ReadyResourceScheduler) processReadyResources() {
// 处理成功删除readyResource
r.readyResourceRepo.Delete(readyResource.ID)
processedCount++
utils.Info(fmt.Sprintf("成功处理资源: %s", readyResource.URL))
utils.Debug(fmt.Sprintf("成功处理资源: %s", readyResource.URL))
}
}
utils.Info(fmt.Sprintf("待处理资源处理完成,共处理 %d 个资源", processedCount))
if processedCount > 0 {
utils.Info(fmt.Sprintf("待处理资源处理完成,共处理 %d 个资源", processedCount))
}
}
// convertReadyResourceToResource 将待处理资源转换为正式资源
@@ -187,28 +189,28 @@ func (r *ReadyResourceScheduler) convertReadyResourceToResource(readyResource en
}
// 检查违禁词
forbiddenWords, err := r.systemConfigRepo.GetConfigValue(entity.ConfigKeyForbiddenWords)
if err == nil && forbiddenWords != "" {
words := strings.Split(forbiddenWords, ",")
var matchedWords []string
title := strings.ToLower(resource.Title)
description := strings.ToLower(resource.Description)
// forbiddenWords, err := r.systemConfigRepo.GetConfigValue(entity.ConfigKeyForbiddenWords)
// if err == nil && forbiddenWords != "" {
// words := strings.Split(forbiddenWords, ",")
// var matchedWords []string
// title := strings.ToLower(resource.Title)
// description := strings.ToLower(resource.Description)
for _, word := range words {
word = strings.TrimSpace(word)
if word != "" {
wordLower := strings.ToLower(word)
if strings.Contains(title, wordLower) || strings.Contains(description, wordLower) {
matchedWords = append(matchedWords, word)
}
}
}
// for _, word := range words {
// word = strings.TrimSpace(word)
// if word != "" {
// wordLower := strings.ToLower(word)
// if strings.Contains(title, wordLower) || strings.Contains(description, wordLower) {
// matchedWords = append(matchedWords, word)
// }
// }
// }
if len(matchedWords) > 0 {
utils.Warn(fmt.Sprintf("资源包含违禁词: %s, 违禁词: %s", resource.Title, strings.Join(matchedWords, ", ")))
return fmt.Errorf("存在违禁词: %s", strings.Join(matchedWords, ", "))
}
}
// if len(matchedWords) > 0 {
// utils.Warn(fmt.Sprintf("资源包含违禁词: %s, 违禁词: %s", resource.Title, strings.Join(matchedWords, ", ")))
// return fmt.Errorf("存在违禁词: %s", strings.Join(matchedWords, ", "))
// }
// }
// 不是夸克,直接保存
if serviceType != panutils.Quark {
@@ -342,6 +344,31 @@ func (r *ReadyResourceScheduler) convertReadyResourceToResource(readyResource en
}
}
// 同步到Meilisearch
utils.Debug(fmt.Sprintf("准备同步资源到Meilisearch - 资源ID: %d, URL: %s", resource.ID, resource.URL))
utils.Debug(fmt.Sprintf("globalMeilisearchManager: %v", globalMeilisearchManager != nil))
if globalMeilisearchManager != nil {
utils.Debug(fmt.Sprintf("Meilisearch管理器已初始化检查启用状态"))
isEnabled := globalMeilisearchManager.IsEnabled()
utils.Debug(fmt.Sprintf("Meilisearch启用状态: %v", isEnabled))
if isEnabled {
utils.Debug(fmt.Sprintf("Meilisearch已启用开始同步资源"))
go func() {
if err := globalMeilisearchManager.SyncResourceToMeilisearch(resource); err != nil {
utils.Error("同步资源到Meilisearch失败: %v", err)
} else {
utils.Info(fmt.Sprintf("资源已同步到Meilisearch: %s", resource.URL))
}
}()
} else {
utils.Debug("Meilisearch未启用跳过同步")
}
} else {
utils.Debug("Meilisearch管理器未初始化跳过同步")
}
return nil
}

178
scripts/build.sh Normal file
View File

@@ -0,0 +1,178 @@
#!/bin/bash
# 编译脚本 - 自动注入版本信息
# 用法: ./scripts/build.sh [target]
set -e
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 获取当前版本
get_current_version() {
cat VERSION
}
# 获取Git信息
get_git_commit() {
git rev-parse --short HEAD 2>/dev/null || echo "unknown"
}
get_git_branch() {
git branch --show-current 2>/dev/null || echo "unknown"
}
# 获取构建时间
get_build_time() {
date '+%Y-%m-%d %H:%M:%S'
}
# 编译函数
build() {
local target=${1:-"main"}
local version=$(get_current_version)
local git_commit=$(get_git_commit)
local git_branch=$(get_git_branch)
local build_time=$(get_build_time)
echo -e "${BLUE}开始编译...${NC}"
echo -e "版本: ${GREEN}${version}${NC}"
echo -e "Git提交: ${GREEN}${git_commit}${NC}"
echo -e "Git分支: ${GREEN}${git_branch}${NC}"
echo -e "构建时间: ${GREEN}${build_time}${NC}"
# 构建 ldflags
local ldflags="-X 'github.com/ctwj/urldb/utils.Version=${version}'"
ldflags="${ldflags} -X 'github.com/ctwj/urldb/utils.BuildTime=${build_time}'"
ldflags="${ldflags} -X 'github.com/ctwj/urldb/utils.GitCommit=${git_commit}'"
ldflags="${ldflags} -X 'github.com/ctwj/urldb/utils.GitBranch=${git_branch}'"
# 编译 - 使用跨平台编译设置
echo -e "${YELLOW}编译中...${NC}"
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags "${ldflags}" -o "${target}" .
if [ $? -eq 0 ]; then
echo -e "${GREEN}编译成功!${NC}"
echo -e "可执行文件: ${GREEN}${target}${NC}"
echo -e "目标平台: ${GREEN}Linux${NC}"
# 显示版本信息在Linux环境下
echo -e "${BLUE}版本信息验证:${NC}"
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
./${target} version 2>/dev/null || echo "无法验证版本信息"
else
echo "当前非Linux环境无法直接验证版本信息"
echo "请将编译后的文件复制到Linux服务器上验证"
fi
else
echo -e "${RED}编译失败!${NC}"
exit 1
fi
}
# 清理函数
clean() {
echo -e "${YELLOW}清理编译文件...${NC}"
rm -f main
echo -e "${GREEN}清理完成${NC}"
}
# 显示帮助
show_help() {
echo -e "${BLUE}编译脚本${NC}"
echo ""
echo "用法: $0 [命令]"
echo ""
echo "命令:"
echo " build [target] 编译程序 (当前平台)"
echo " build-linux [target] 编译Linux版本 (推荐)"
echo " clean 清理编译文件"
echo " help 显示此帮助信息"
echo ""
echo "示例:"
echo " $0 # 编译Linux版本 (默认)"
echo " $0 build-linux # 编译Linux版本"
echo " $0 build-linux app # 编译Linux版本为 app"
echo " $0 build # 编译当前平台版本"
echo " $0 clean # 清理编译文件"
echo ""
echo "注意:"
echo " - Linux版本使用静态链接适合部署到服务器"
echo " - 默认编译Linux版本无需复制VERSION文件"
}
# Linux编译函数
build_linux() {
local target=${1:-"main"}
local version=$(get_current_version)
local git_commit=$(get_git_commit)
local git_branch=$(get_git_branch)
local build_time=$(get_build_time)
echo -e "${BLUE}开始Linux编译...${NC}"
echo -e "版本: ${GREEN}${version}${NC}"
echo -e "Git提交: ${GREEN}${git_commit}${NC}"
echo -e "Git分支: ${GREEN}${git_branch}${NC}"
echo -e "构建时间: ${GREEN}${build_time}${NC}"
# 构建 ldflags
local ldflags="-X 'github.com/ctwj/urldb/utils.Version=${version}'"
ldflags="${ldflags} -X 'github.com/ctwj/urldb/utils.BuildTime=${build_time}'"
ldflags="${ldflags} -X 'github.com/ctwj/urldb/utils.GitCommit=${git_commit}'"
ldflags="${ldflags} -X 'github.com/ctwj/urldb/utils.GitBranch=${git_branch}'"
# Linux编译
echo -e "${YELLOW}编译中...${NC}"
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags "${ldflags}" -o "${target}" .
if [ $? -eq 0 ]; then
echo -e "${GREEN}Linux编译成功!${NC}"
echo -e "可执行文件: ${GREEN}${target}${NC}"
echo -e "目标平台: ${GREEN}Linux${NC}"
echo -e "静态链接: ${GREEN}${NC}"
# 显示文件信息
if command -v file >/dev/null 2>&1; then
echo -e "${BLUE}文件信息:${NC}"
file "${target}"
fi
echo -e "${BLUE}注意: 请在Linux服务器上验证版本信息${NC}"
else
echo -e "${RED}Linux编译失败!${NC}"
exit 1
fi
}
# 主函数
main() {
case $1 in
"build")
build $2
;;
"build-linux")
build_linux $2
;;
"clean")
clean
;;
"help"|"-h"|"--help")
show_help
;;
"")
build_linux
;;
*)
echo -e "${RED}错误: 未知命令 '$1'${NC}"
echo "使用 '$0 help' 查看帮助信息"
exit 1
;;
esac
}
# 运行主函数
main "$@"

183
scripts/docker-build.sh Normal file
View File

@@ -0,0 +1,183 @@
#!/bin/bash
# Docker构建脚本
# 用法: ./scripts/docker-build.sh [version]
set -e
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 获取版本号
get_version() {
if [ -n "$1" ]; then
echo "$1"
else
cat VERSION
fi
}
# 获取Git信息
get_git_commit() {
git rev-parse --short HEAD 2>/dev/null || echo "unknown"
}
get_git_branch() {
git branch --show-current 2>/dev/null || echo "unknown"
}
# 构建Docker镜像
build_docker() {
local version=$(get_version $1)
local skip_frontend=$2
local git_commit=$(get_git_commit)
local git_branch=$(get_git_branch)
local build_time=$(date '+%Y-%m-%d %H:%M:%S')
echo -e "${BLUE}开始Docker构建...${NC}"
echo -e "版本: ${GREEN}${version}${NC}"
echo -e "Git提交: ${GREEN}${git_commit}${NC}"
echo -e "Git分支: ${GREEN}${git_branch}${NC}"
echo -e "构建时间: ${GREEN}${build_time}${NC}"
if [ "$skip_frontend" = "true" ]; then
echo -e "跳过前端构建: ${GREEN}${NC}"
fi
# 直接使用 docker build避免 buildx 的复杂性
BUILD_CMD="docker build"
echo -e "${BLUE}使用构建命令: ${BUILD_CMD}${NC}"
# 构建前端镜像(可选)
if [ "$skip_frontend" != "true" ]; then
echo -e "${YELLOW}构建前端镜像...${NC}"
FRONTEND_CMD="${BUILD_CMD} --build-arg VERSION=${version} --build-arg GIT_COMMIT=${git_commit} --build-arg GIT_BRANCH=${git_branch} --build-arg BUILD_TIME=${build_time} --target frontend -t ctwj/urldb-frontend:${version} ."
echo -e "${BLUE}执行命令: ${FRONTEND_CMD}${NC}"
${BUILD_CMD} \
--build-arg VERSION=${version} \
--build-arg GIT_COMMIT=${git_commit} \
--build-arg GIT_BRANCH=${git_branch} \
--build-arg "BUILD_TIME=${build_time}" \
--target frontend \
-t ctwj/urldb-frontend:${version} \
.
if [ $? -ne 0 ]; then
echo -e "${RED}前端构建失败!${NC}"
exit 1
fi
else
echo -e "${YELLOW}跳过前端构建${NC}"
fi
# 构建后端镜像
echo -e "${YELLOW}构建后端镜像...${NC}"
BACKEND_CMD="${BUILD_CMD} --build-arg VERSION=${version} --build-arg GIT_COMMIT=${git_commit} --build-arg GIT_BRANCH=${git_branch} --build-arg BUILD_TIME=${build_time} --target backend -t ctwj/urldb-backend:${version} ."
echo -e "${BLUE}执行命令: ${BACKEND_CMD}${NC}"
${BUILD_CMD} \
--build-arg VERSION=${version} \
--build-arg GIT_COMMIT=${git_commit} \
--build-arg GIT_BRANCH=${git_branch} \
--build-arg BUILD_TIME="${build_time}" \
--target backend \
-t ctwj/urldb-backend:${version} \
.
if [ $? -ne 0 ]; then
echo -e "${RED}后端构建失败!${NC}"
exit 1
fi
echo -e "${GREEN}Docker构建完成!${NC}"
echo -e "镜像标签:"
echo -e " ${GREEN}ctwj/urldb-backend:${version}${NC}"
if [ "$skip_frontend" != "true" ]; then
echo -e " ${GREEN}ctwj/urldb-frontend:${version}${NC}"
fi
}
# 推送镜像
push_images() {
local version=$(get_version $1)
echo -e "${YELLOW}推送镜像到Docker Hub...${NC}"
# 推送后端镜像
docker push ctwj/urldb-backend:${version}
# 推送前端镜像
docker push ctwj/urldb-frontend:${version}
echo -e "${GREEN}镜像推送完成!${NC}"
}
# 清理镜像
clean_images() {
local version=$(get_version $1)
echo -e "${YELLOW}清理Docker镜像...${NC}"
docker rmi ctwj/urldb-backend:${version} 2>/dev/null || true
docker rmi ctwj/urldb-frontend:${version} 2>/dev/null || true
echo -e "${GREEN}镜像清理完成${NC}"
}
# 显示帮助
show_help() {
echo -e "${BLUE}Docker构建脚本${NC}"
echo ""
echo "用法: $0 [命令] [版本] [选项]"
echo ""
echo "命令:"
echo " build [version] [--skip-frontend] 构建Docker镜像"
echo " push [version] 推送镜像到Docker Hub"
echo " clean [version] 清理Docker镜像"
echo " help 显示此帮助信息"
echo ""
echo "选项:"
echo " --skip-frontend 跳过前端构建"
echo ""
echo "示例:"
echo " $0 build # 构建当前版本镜像"
echo " $0 build 1.2.4 # 构建指定版本镜像"
echo " $0 build 1.2.4 --skip-frontend # 构建指定版本镜像,跳过前端"
echo " $0 push 1.2.4 # 推送指定版本镜像"
echo " $0 clean # 清理当前版本镜像"
}
# 主函数
main() {
case $1 in
"build")
# 检查是否有 --skip-frontend 选项
local skip_frontend="false"
if [ "$3" = "--skip-frontend" ]; then
skip_frontend="true"
fi
build_docker $2 $skip_frontend
;;
"push")
push_images $2
;;
"clean")
clean_images $2
;;
"help"|"-h"|"--help")
show_help
;;
"")
show_help
;;
*)
echo -e "${RED}错误: 未知命令 '$1'${NC}"
echo "使用 '$0 help' 查看帮助信息"
exit 1
;;
esac
}
# 运行主函数
main "$@"

View File

@@ -0,0 +1,777 @@
package services
import (
"fmt"
"strconv"
"sync"
"time"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/db/repo"
"github.com/ctwj/urldb/utils"
)
// MeilisearchManager Meilisearch管理器
type MeilisearchManager struct {
service *MeilisearchService
repoMgr *repo.RepositoryManager
configRepo repo.SystemConfigRepository
mutex sync.RWMutex
status MeilisearchStatus
stopChan chan struct{}
isRunning bool
// 同步进度控制
syncMutex sync.RWMutex
syncProgress SyncProgress
isSyncing bool
syncStopChan chan struct{}
}
// SyncProgress 同步进度
type SyncProgress struct {
IsRunning bool `json:"is_running"`
TotalCount int64 `json:"total_count"`
ProcessedCount int64 `json:"processed_count"`
SyncedCount int64 `json:"synced_count"`
FailedCount int64 `json:"failed_count"`
StartTime time.Time `json:"start_time"`
EstimatedTime string `json:"estimated_time"`
CurrentBatch int `json:"current_batch"`
TotalBatches int `json:"total_batches"`
ErrorMessage string `json:"error_message"`
}
// MeilisearchStatus Meilisearch状态
type MeilisearchStatus struct {
Enabled bool `json:"enabled"`
Healthy bool `json:"healthy"`
LastCheck time.Time `json:"last_check"`
ErrorCount int `json:"error_count"`
LastError string `json:"last_error"`
DocumentCount int64 `json:"document_count"`
}
// NewMeilisearchManager 创建Meilisearch管理器
func NewMeilisearchManager(repoMgr *repo.RepositoryManager) *MeilisearchManager {
return &MeilisearchManager{
repoMgr: repoMgr,
stopChan: make(chan struct{}),
syncStopChan: make(chan struct{}),
status: MeilisearchStatus{
Enabled: false,
Healthy: false,
LastCheck: time.Now(),
},
}
}
// Initialize 初始化Meilisearch服务
func (m *MeilisearchManager) Initialize() error {
m.mutex.Lock()
defer m.mutex.Unlock()
// 设置configRepo
m.configRepo = m.repoMgr.SystemConfigRepository
// 获取配置
enabled, err := m.configRepo.GetConfigBool(entity.ConfigKeyMeilisearchEnabled)
if err != nil {
utils.Error("获取Meilisearch启用状态失败: %v", err)
return err
}
if !enabled {
utils.Debug("Meilisearch未启用清理服务状态")
m.status.Enabled = false
m.service = nil
// 停止监控循环
if m.stopChan != nil {
close(m.stopChan)
m.stopChan = make(chan struct{})
}
return nil
}
host, err := m.configRepo.GetConfigValue(entity.ConfigKeyMeilisearchHost)
if err != nil {
utils.Error("获取Meilisearch主机配置失败: %v", err)
return err
}
port, err := m.configRepo.GetConfigValue(entity.ConfigKeyMeilisearchPort)
if err != nil {
utils.Error("获取Meilisearch端口配置失败: %v", err)
return err
}
masterKey, err := m.configRepo.GetConfigValue(entity.ConfigKeyMeilisearchMasterKey)
if err != nil {
utils.Error("获取Meilisearch主密钥配置失败: %v", err)
return err
}
indexName, err := m.configRepo.GetConfigValue(entity.ConfigKeyMeilisearchIndexName)
if err != nil {
utils.Error("获取Meilisearch索引名配置失败: %v", err)
return err
}
m.service = NewMeilisearchService(host, port, masterKey, indexName, enabled)
m.status.Enabled = enabled
// 如果启用,创建索引并更新设置
if enabled {
utils.Debug("Meilisearch已启用创建索引并更新设置")
// 创建索引
if err := m.service.CreateIndex(); err != nil {
utils.Error("创建Meilisearch索引失败: %v", err)
}
// 更新索引设置
if err := m.service.UpdateIndexSettings(); err != nil {
utils.Error("更新Meilisearch索引设置失败: %v", err)
}
// 立即进行一次健康检查
go func() {
m.checkHealth()
// 启动监控
go m.monitorLoop()
}()
} else {
utils.Debug("Meilisearch未启用")
}
utils.Debug("Meilisearch服务初始化完成")
return nil
}
// IsEnabled 检查是否启用
func (m *MeilisearchManager) IsEnabled() bool {
m.mutex.RLock()
defer m.mutex.RUnlock()
return m.status.Enabled
}
// ReloadConfig 重新加载配置
func (m *MeilisearchManager) ReloadConfig() error {
utils.Debug("重新加载Meilisearch配置")
return m.Initialize()
}
// GetService 获取Meilisearch服务
func (m *MeilisearchManager) GetService() *MeilisearchService {
m.mutex.RLock()
defer m.mutex.RUnlock()
return m.service
}
// GetStatus 获取状态
func (m *MeilisearchManager) GetStatus() (MeilisearchStatus, error) {
m.mutex.RLock()
defer m.mutex.RUnlock()
utils.Debug("获取Meilisearch状态 - 启用状态: %v, 健康状态: %v, 服务实例: %v", m.status.Enabled, m.status.Healthy, m.service != nil)
if m.service != nil && m.service.IsEnabled() {
utils.Debug("Meilisearch服务已初始化且启用尝试获取索引统计")
// 获取索引统计
stats, err := m.service.GetIndexStats()
if err != nil {
utils.Error("获取Meilisearch索引统计失败: %v", err)
// 即使获取统计失败,也返回当前状态
} else {
utils.Debug("Meilisearch索引统计: %+v", stats)
// 更新文档数量
if count, ok := stats["numberOfDocuments"].(float64); ok {
m.status.DocumentCount = int64(count)
utils.Debug("文档数量 (float64): %d", int64(count))
} else if count, ok := stats["numberOfDocuments"].(int64); ok {
m.status.DocumentCount = count
utils.Debug("文档数量 (int64): %d", count)
} else if count, ok := stats["numberOfDocuments"].(int); ok {
m.status.DocumentCount = int64(count)
utils.Debug("文档数量 (int): %d", int64(count))
} else {
utils.Error("无法解析文档数量,类型: %T, 值: %v", stats["numberOfDocuments"], stats["numberOfDocuments"])
}
// 不更新启用状态,保持配置中的状态
// 启用状态应该由配置控制,而不是由服务状态控制
}
} else {
utils.Debug("Meilisearch服务未初始化或未启用 - service: %v, enabled: %v", m.service != nil, m.service != nil && m.service.IsEnabled())
}
return m.status, nil
}
// GetStatusWithHealthCheck 获取状态并同时进行健康检查
func (m *MeilisearchManager) GetStatusWithHealthCheck() (MeilisearchStatus, error) {
// 先进行健康检查
m.checkHealth()
// 然后获取状态
return m.GetStatus()
}
// SyncResourceToMeilisearch 同步资源到Meilisearch
func (m *MeilisearchManager) SyncResourceToMeilisearch(resource *entity.Resource) error {
utils.Debug(fmt.Sprintf("开始同步资源到Meilisearch - 资源ID: %d, URL: %s", resource.ID, resource.URL))
if m.service == nil || !m.service.IsEnabled() {
utils.Debug("Meilisearch服务未初始化或未启用")
return fmt.Errorf("Meilisearch服务未初始化或未启用")
}
// 先进行健康检查
if err := m.service.HealthCheck(); err != nil {
utils.Error(fmt.Sprintf("Meilisearch健康检查失败: %v", err))
return fmt.Errorf("Meilisearch健康检查失败: %v", err)
}
// 确保索引存在
if err := m.service.CreateIndex(); err != nil {
utils.Error(fmt.Sprintf("创建Meilisearch索引失败: %v", err))
return fmt.Errorf("创建Meilisearch索引失败: %v", err)
}
doc := m.convertResourceToDocument(resource)
err := m.service.BatchAddDocuments([]MeilisearchDocument{doc})
if err != nil {
return err
}
// 标记为已同步
return m.repoMgr.ResourceRepository.MarkAsSyncedToMeilisearch([]uint{resource.ID})
}
// SyncAllResources 同步所有资源
func (m *MeilisearchManager) SyncAllResources() (int, error) {
if m.service == nil || !m.service.IsEnabled() {
return 0, fmt.Errorf("Meilisearch未启用")
}
// 检查是否已经在同步中
m.syncMutex.Lock()
if m.isSyncing {
m.syncMutex.Unlock()
return 0, fmt.Errorf("同步操作正在进行中")
}
// 初始化同步状态
m.isSyncing = true
m.syncProgress = SyncProgress{
IsRunning: true,
TotalCount: 0,
ProcessedCount: 0,
SyncedCount: 0,
FailedCount: 0,
StartTime: time.Now(),
CurrentBatch: 0,
TotalBatches: 0,
ErrorMessage: "",
}
// 重新创建停止通道
m.syncStopChan = make(chan struct{})
m.syncMutex.Unlock()
// 在goroutine中执行同步避免阻塞
go func() {
defer func() {
m.syncMutex.Lock()
m.isSyncing = false
m.syncProgress.IsRunning = false
m.syncMutex.Unlock()
}()
m.syncAllResourcesInternal()
}()
return 0, nil
}
// DebugGetAllDocuments 调试:获取所有文档
func (m *MeilisearchManager) DebugGetAllDocuments() error {
if m.service == nil || !m.service.IsEnabled() {
return fmt.Errorf("Meilisearch未启用")
}
utils.Debug("开始调试获取Meilisearch中的所有文档")
_, err := m.service.GetAllDocuments()
if err != nil {
utils.Error("调试获取所有文档失败: %v", err)
return err
}
utils.Debug("调试完成:已获取所有文档")
return nil
}
// syncAllResourcesInternal 内部同步方法
func (m *MeilisearchManager) syncAllResourcesInternal() {
// 健康检查
if err := m.service.HealthCheck(); err != nil {
m.updateSyncProgress("", "", fmt.Sprintf("Meilisearch不可用: %v", err))
return
}
// 创建索引
if err := m.service.CreateIndex(); err != nil {
m.updateSyncProgress("", "", fmt.Sprintf("创建索引失败: %v", err))
return
}
utils.Debug("开始同步所有资源到Meilisearch...")
// 获取总资源数量
totalCount, err := m.repoMgr.ResourceRepository.CountUnsyncedToMeilisearch()
if err != nil {
m.updateSyncProgress("", "", fmt.Sprintf("获取资源总数失败: %v", err))
return
}
// 分批处理
batchSize := 100
totalBatches := int((totalCount + int64(batchSize) - 1) / int64(batchSize))
// 更新总数量和总批次
m.syncMutex.Lock()
m.syncProgress.TotalCount = totalCount
m.syncProgress.TotalBatches = totalBatches
m.syncMutex.Unlock()
offset := 0
totalSynced := 0
currentBatch := 0
// 预加载所有分类和平台数据到缓存
categoryCache := make(map[uint]string)
panCache := make(map[uint]string)
// 获取所有分类
categories, err := m.repoMgr.CategoryRepository.FindAll()
if err == nil {
for _, category := range categories {
categoryCache[category.ID] = category.Name
}
}
// 获取所有平台
pans, err := m.repoMgr.PanRepository.FindAll()
if err == nil {
for _, pan := range pans {
panCache[pan.ID] = pan.Name
}
}
for {
// 检查是否需要停止
select {
case <-m.syncStopChan:
utils.Debug("同步操作被停止")
return
default:
}
currentBatch++
// 获取一批资源在goroutine中执行避免阻塞
resourcesChan := make(chan []entity.Resource, 1)
errChan := make(chan error, 1)
go func() {
// 直接查询未同步的资源,不使用分页
resources, _, err := m.repoMgr.ResourceRepository.FindUnsyncedToMeilisearch(1, batchSize)
if err != nil {
errChan <- err
return
}
resourcesChan <- resources
}()
// 等待数据库查询结果或停止信号(添加超时)
select {
case resources := <-resourcesChan:
if len(resources) == 0 {
utils.Info("资源同步完成,总共同步 %d 个资源", totalSynced)
return
}
// 检查是否需要停止
select {
case <-m.syncStopChan:
utils.Debug("同步操作被停止")
return
default:
}
// 转换为Meilisearch文档使用缓存
var docs []MeilisearchDocument
for _, resource := range resources {
doc := m.convertResourceToDocumentWithCache(&resource, categoryCache, panCache)
docs = append(docs, doc)
}
// 检查是否需要停止
select {
case <-m.syncStopChan:
utils.Debug("同步操作被停止")
return
default:
}
// 批量添加到Meilisearch在goroutine中执行避免阻塞
meilisearchErrChan := make(chan error, 1)
go func() {
err := m.service.BatchAddDocuments(docs)
meilisearchErrChan <- err
}()
// 等待Meilisearch操作结果或停止信号添加超时
select {
case err := <-meilisearchErrChan:
if err != nil {
m.updateSyncProgress("", "", fmt.Sprintf("批量添加文档失败: %v", err))
return
}
case <-time.After(60 * time.Second): // 60秒超时
m.updateSyncProgress("", "", "Meilisearch操作超时")
utils.Error("Meilisearch操作超时")
return
case <-m.syncStopChan:
utils.Debug("同步操作被停止")
return
}
// 检查是否需要停止
select {
case <-m.syncStopChan:
utils.Debug("同步操作被停止")
return
default:
}
// 标记为已同步在goroutine中执行避免阻塞
var resourceIDs []uint
for _, resource := range resources {
resourceIDs = append(resourceIDs, resource.ID)
}
markErrChan := make(chan error, 1)
go func() {
err := m.repoMgr.ResourceRepository.MarkAsSyncedToMeilisearch(resourceIDs)
markErrChan <- err
}()
// 等待标记操作结果或停止信号(添加超时)
select {
case err := <-markErrChan:
if err != nil {
utils.Error("标记资源同步状态失败: %v", err)
}
case <-time.After(30 * time.Second): // 30秒超时
utils.Error("标记资源同步状态超时")
case <-m.syncStopChan:
utils.Debug("同步操作被停止")
return
}
totalSynced += len(docs)
offset += len(resources)
// 更新进度
m.updateSyncProgress(fmt.Sprintf("%d", totalSynced), fmt.Sprintf("%d", currentBatch), "")
utils.Debug("已同步 %d 个资源到Meilisearch (批次 %d/%d)", totalSynced, currentBatch, totalBatches)
// 检查是否已经同步完所有资源
if len(resources) == 0 {
utils.Info("资源同步完成,总共同步 %d 个资源", totalSynced)
return
}
case <-time.After(30 * time.Second): // 30秒超时
m.updateSyncProgress("", "", "数据库查询超时")
utils.Error("数据库查询超时")
return
case err := <-errChan:
m.updateSyncProgress("", "", fmt.Sprintf("获取资源失败: %v", err))
return
case <-m.syncStopChan:
utils.Info("同步操作被停止")
return
}
// 避免过于频繁的请求
time.Sleep(100 * time.Millisecond)
}
utils.Info("资源同步完成,总共同步 %d 个资源", totalSynced)
}
// updateSyncProgress 更新同步进度
func (m *MeilisearchManager) updateSyncProgress(syncedCount, currentBatch, errorMessage string) {
m.syncMutex.Lock()
defer m.syncMutex.Unlock()
if syncedCount != "" {
if count, err := strconv.ParseInt(syncedCount, 10, 64); err == nil {
m.syncProgress.SyncedCount = count
}
}
if currentBatch != "" {
if batch, err := strconv.Atoi(currentBatch); err == nil {
m.syncProgress.CurrentBatch = batch
}
}
if errorMessage != "" {
m.syncProgress.ErrorMessage = errorMessage
m.syncProgress.IsRunning = false
}
// 计算预估时间
if m.syncProgress.SyncedCount > 0 {
elapsed := time.Since(m.syncProgress.StartTime)
rate := float64(m.syncProgress.SyncedCount) / elapsed.Seconds()
if rate > 0 {
remaining := float64(m.syncProgress.TotalCount-m.syncProgress.SyncedCount) / rate
m.syncProgress.EstimatedTime = fmt.Sprintf("%.0f秒", remaining)
}
}
}
// GetUnsyncedCount 获取未同步资源数量
func (m *MeilisearchManager) GetUnsyncedCount() (int64, error) {
// 直接查询未同步的资源数量
return m.repoMgr.ResourceRepository.CountUnsyncedToMeilisearch()
}
// GetUnsyncedResources 获取未同步的资源
func (m *MeilisearchManager) GetUnsyncedResources(page, pageSize int) ([]entity.Resource, int64, error) {
// 查询未同步到Meilisearch的资源
return m.repoMgr.ResourceRepository.FindUnsyncedToMeilisearch(page, pageSize)
}
// GetSyncedResources 获取已同步的资源
func (m *MeilisearchManager) GetSyncedResources(page, pageSize int) ([]entity.Resource, int64, error) {
// 查询已同步到Meilisearch的资源
return m.repoMgr.ResourceRepository.FindSyncedToMeilisearch(page, pageSize)
}
// GetAllResources 获取所有资源
func (m *MeilisearchManager) GetAllResources(page, pageSize int) ([]entity.Resource, int64, error) {
// 查询所有资源
return m.repoMgr.ResourceRepository.FindAllWithPagination(page, pageSize)
}
// GetSyncProgress 获取同步进度
func (m *MeilisearchManager) GetSyncProgress() SyncProgress {
m.syncMutex.RLock()
defer m.syncMutex.RUnlock()
return m.syncProgress
}
// StopSync 停止同步
func (m *MeilisearchManager) StopSync() {
m.syncMutex.Lock()
defer m.syncMutex.Unlock()
if m.isSyncing {
// 发送停止信号
select {
case <-m.syncStopChan:
// 通道已经关闭,不需要再次关闭
default:
close(m.syncStopChan)
}
m.isSyncing = false
m.syncProgress.IsRunning = false
m.syncProgress.ErrorMessage = "同步已停止"
utils.Debug("同步操作已停止")
}
}
// ClearIndex 清空索引
func (m *MeilisearchManager) ClearIndex() error {
if m.service == nil || !m.service.IsEnabled() {
return fmt.Errorf("Meilisearch未启用")
}
// 清空Meilisearch索引
if err := m.service.ClearIndex(); err != nil {
return err
}
// 标记所有资源为未同步
return m.repoMgr.ResourceRepository.MarkAllAsUnsyncedToMeilisearch()
}
// convertResourceToDocument 转换资源为搜索文档
func (m *MeilisearchManager) convertResourceToDocument(resource *entity.Resource) MeilisearchDocument {
// 获取关联数据
var categoryName string
if resource.CategoryID != nil {
category, err := m.repoMgr.CategoryRepository.FindByID(*resource.CategoryID)
if err == nil {
categoryName = category.Name
}
}
var panName string
if resource.PanID != nil {
pan, err := m.repoMgr.PanRepository.FindByID(*resource.PanID)
if err == nil {
panName = pan.Name
}
}
// 获取标签 - 从关联的Tags字段获取
var tagNames []string
if resource.Tags != nil {
for _, tag := range resource.Tags {
tagNames = append(tagNames, tag.Name)
}
}
return MeilisearchDocument{
ID: resource.ID,
Title: resource.Title,
Description: resource.Description,
URL: resource.URL,
SaveURL: resource.SaveURL,
FileSize: resource.FileSize,
Key: resource.Key,
Category: categoryName,
Tags: tagNames,
PanName: panName,
PanID: resource.PanID,
Author: resource.Author,
CreatedAt: resource.CreatedAt,
UpdatedAt: resource.UpdatedAt,
}
}
// convertResourceToDocumentWithCache 转换资源为搜索文档(使用缓存)
func (m *MeilisearchManager) convertResourceToDocumentWithCache(resource *entity.Resource, categoryCache map[uint]string, panCache map[uint]string) MeilisearchDocument {
// 从缓存获取关联数据
var categoryName string
if resource.CategoryID != nil {
if name, exists := categoryCache[*resource.CategoryID]; exists {
categoryName = name
}
}
var panName string
if resource.PanID != nil {
if name, exists := panCache[*resource.PanID]; exists {
panName = name
}
}
// 获取标签 - 从关联的Tags字段获取
var tagNames []string
if resource.Tags != nil {
for _, tag := range resource.Tags {
tagNames = append(tagNames, tag.Name)
}
}
return MeilisearchDocument{
ID: resource.ID,
Title: resource.Title,
Description: resource.Description,
URL: resource.URL,
SaveURL: resource.SaveURL,
FileSize: resource.FileSize,
Key: resource.Key,
Category: categoryName,
Tags: tagNames,
PanName: panName,
PanID: resource.PanID,
Author: resource.Author,
CreatedAt: resource.CreatedAt,
UpdatedAt: resource.UpdatedAt,
}
}
// monitorLoop 监控循环
func (m *MeilisearchManager) monitorLoop() {
if m.isRunning {
return
}
m.isRunning = true
ticker := time.NewTicker(30 * time.Second) // 每30秒检查一次
defer ticker.Stop()
for {
select {
case <-ticker.C:
m.checkHealth()
case <-m.stopChan:
return
}
}
}
// checkHealth 检查健康状态
func (m *MeilisearchManager) checkHealth() {
m.mutex.Lock()
defer m.mutex.Unlock()
m.status.LastCheck = time.Now()
utils.Debug("开始健康检查 - 服务实例: %v, 启用状态: %v", m.service != nil, m.service != nil && m.service.IsEnabled())
if m.service == nil || !m.service.IsEnabled() {
utils.Debug("Meilisearch服务未初始化或未启用")
m.status.Healthy = false
m.status.LastError = "Meilisearch未启用"
return
}
utils.Debug("开始检查Meilisearch健康状态")
if err := m.service.HealthCheck(); err != nil {
m.status.Healthy = false
m.status.ErrorCount++
m.status.LastError = err.Error()
utils.Error("Meilisearch健康检查失败: %v", err)
} else {
m.status.Healthy = true
m.status.ErrorCount = 0
m.status.LastError = ""
utils.Debug("Meilisearch健康检查成功")
// 健康检查通过后,更新文档数量
if stats, err := m.service.GetIndexStats(); err == nil {
if count, ok := stats["numberOfDocuments"].(float64); ok {
m.status.DocumentCount = int64(count)
} else if count, ok := stats["numberOfDocuments"].(int64); ok {
m.status.DocumentCount = count
} else if count, ok := stats["numberOfDocuments"].(int); ok {
m.status.DocumentCount = int64(count)
}
}
}
}
// Stop 停止监控
func (m *MeilisearchManager) Stop() {
if !m.isRunning {
return
}
close(m.stopChan)
m.isRunning = false
utils.Debug("Meilisearch监控服务已停止")
}

View File

@@ -0,0 +1,561 @@
package services
import (
"encoding/json"
"fmt"
"reflect"
"time"
"github.com/ctwj/urldb/utils"
"github.com/meilisearch/meilisearch-go"
)
// MeilisearchDocument 搜索文档结构
type MeilisearchDocument struct {
ID uint `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
SaveURL string `json:"save_url"`
FileSize string `json:"file_size"`
Key string `json:"key"`
Category string `json:"category"`
Tags []string `json:"tags"`
PanName string `json:"pan_name"`
PanID *uint `json:"pan_id"`
Author string `json:"author"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// 高亮字段
TitleHighlight string `json:"_title_highlight,omitempty"`
DescriptionHighlight string `json:"_description_highlight,omitempty"`
CategoryHighlight string `json:"_category_highlight,omitempty"`
TagsHighlight []string `json:"_tags_highlight,omitempty"`
}
// MeilisearchService Meilisearch服务
type MeilisearchService struct {
client meilisearch.ServiceManager
index meilisearch.IndexManager
indexName string
enabled bool
}
// NewMeilisearchService 创建Meilisearch服务
func NewMeilisearchService(host, port, masterKey, indexName string, enabled bool) *MeilisearchService {
if !enabled {
return &MeilisearchService{
enabled: false,
}
}
// 构建服务器URL
serverURL := fmt.Sprintf("http://%s:%s", host, port)
// 创建客户端
var client meilisearch.ServiceManager
if masterKey != "" {
client = meilisearch.New(serverURL, meilisearch.WithAPIKey(masterKey))
} else {
client = meilisearch.New(serverURL)
}
// 获取索引
index := client.Index(indexName)
return &MeilisearchService{
client: client,
index: index,
indexName: indexName,
enabled: enabled,
}
}
// IsEnabled 检查是否启用
func (m *MeilisearchService) IsEnabled() bool {
return m.enabled
}
// HealthCheck 健康检查
func (m *MeilisearchService) HealthCheck() error {
if !m.enabled {
utils.Debug("Meilisearch未启用跳过健康检查")
return fmt.Errorf("Meilisearch未启用")
}
utils.Debug("开始Meilisearch健康检查")
// 使用官方SDK的健康检查
_, err := m.client.Health()
if err != nil {
utils.Error("Meilisearch健康检查失败: %v", err)
return fmt.Errorf("Meilisearch健康检查失败: %v", err)
}
utils.Debug("Meilisearch健康检查成功")
return nil
}
// CreateIndex 创建索引
func (m *MeilisearchService) CreateIndex() error {
if !m.enabled {
return nil
}
// 创建索引配置
indexConfig := &meilisearch.IndexConfig{
Uid: m.indexName,
PrimaryKey: "id",
}
// 创建索引
_, err := m.client.CreateIndex(indexConfig)
if err != nil {
// 如果索引已存在,返回成功
utils.Debug("Meilisearch索引创建失败或已存在: %v", err)
return nil
}
utils.Debug("Meilisearch索引创建成功: %s", m.indexName)
// 配置索引设置
settings := &meilisearch.Settings{
// 配置可过滤的属性
FilterableAttributes: []string{
"pan_id",
"pan_name",
"category",
"tags",
},
// 配置可搜索的属性
SearchableAttributes: []string{
"title",
"description",
"category",
"tags",
},
// 配置可排序的属性
SortableAttributes: []string{
"created_at",
"updated_at",
"id",
},
}
// 更新索引设置
_, err = m.index.UpdateSettings(settings)
if err != nil {
utils.Error("更新Meilisearch索引设置失败: %v", err)
return err
}
utils.Debug("Meilisearch索引设置更新成功")
return nil
}
// UpdateIndexSettings 更新索引设置
func (m *MeilisearchService) UpdateIndexSettings() error {
if !m.enabled {
return nil
}
// 配置索引设置
settings := &meilisearch.Settings{
// 配置可过滤的属性
FilterableAttributes: []string{
"pan_id",
"pan_name",
"category",
"tags",
},
// 配置可搜索的属性
SearchableAttributes: []string{
"title",
"description",
"category",
"tags",
},
// 配置可排序的属性
SortableAttributes: []string{
"created_at",
"updated_at",
"id",
},
}
// 更新索引设置
_, err := m.index.UpdateSettings(settings)
if err != nil {
utils.Error("更新Meilisearch索引设置失败: %v", err)
return err
}
utils.Debug("Meilisearch索引设置更新成功")
return nil
}
// BatchAddDocuments 批量添加文档
func (m *MeilisearchService) BatchAddDocuments(docs []MeilisearchDocument) error {
utils.Debug(fmt.Sprintf("开始批量添加文档到Meilisearch - 文档数量: %d", len(docs)))
if !m.enabled {
utils.Debug("Meilisearch未启用跳过批量添加")
return fmt.Errorf("Meilisearch未启用")
}
if len(docs) == 0 {
utils.Debug("文档列表为空,跳过批量添加")
return nil
}
// 转换为interface{}切片
var documents []interface{}
for i, doc := range docs {
utils.Debug(fmt.Sprintf("转换文档 %d - ID: %d, 标题: %s", i+1, doc.ID, doc.Title))
documents = append(documents, doc)
}
utils.Debug(fmt.Sprintf("开始调用Meilisearch API添加 %d 个文档", len(documents)))
// 批量添加文档
_, err := m.index.AddDocuments(documents, nil)
if err != nil {
utils.Error(fmt.Sprintf("Meilisearch批量添加文档失败: %v", err))
return fmt.Errorf("Meilisearch批量添加文档失败: %v", err)
}
utils.Debug(fmt.Sprintf("成功批量添加 %d 个文档到Meilisearch", len(docs)))
return nil
}
// Search 搜索文档
func (m *MeilisearchService) Search(query string, filters map[string]interface{}, page, pageSize int) ([]MeilisearchDocument, int64, error) {
if !m.enabled {
return nil, 0, fmt.Errorf("Meilisearch未启用")
}
// 构建搜索请求
searchRequest := &meilisearch.SearchRequest{
Query: query,
Offset: int64((page - 1) * pageSize),
Limit: int64(pageSize),
// 启用高亮功能
AttributesToHighlight: []string{"title", "description", "category", "tags"},
HighlightPreTag: "<mark>",
HighlightPostTag: "</mark>",
}
// 添加过滤器
if len(filters) > 0 {
var filterStrings []string
for key, value := range filters {
switch key {
case "pan_id":
// 直接使用pan_id进行过滤
filterStrings = append(filterStrings, fmt.Sprintf("pan_id = %v", value))
case "pan_name":
// 使用pan_name进行过滤
filterStrings = append(filterStrings, fmt.Sprintf("pan_name = %q", value))
case "category":
filterStrings = append(filterStrings, fmt.Sprintf("category = %q", value))
case "tags":
filterStrings = append(filterStrings, fmt.Sprintf("tags = %q", value))
default:
filterStrings = append(filterStrings, fmt.Sprintf("%s = %q", key, value))
}
}
if len(filterStrings) > 0 {
searchRequest.Filter = filterStrings
}
}
// 执行搜索
result, err := m.index.Search(query, searchRequest)
if err != nil {
return nil, 0, fmt.Errorf("搜索失败: %v", err)
}
// 解析结果
var documents []MeilisearchDocument
// 如果没有任何结果,直接返回
if len(result.Hits) == 0 {
utils.Debug("没有搜索结果")
return documents, result.EstimatedTotalHits, nil
}
for _, hit := range result.Hits {
// 将hit转换为MeilisearchDocument
doc := MeilisearchDocument{}
// 解析JSON数据 - 使用反射
hitValue := reflect.ValueOf(hit)
if hitValue.Kind() == reflect.Map {
for _, key := range hitValue.MapKeys() {
keyStr := key.String()
value := hitValue.MapIndex(key).Interface()
// 处理_formatted字段包含所有高亮内容
if keyStr == "_formatted" {
if rawValue, ok := value.(json.RawMessage); ok {
// 解析_formatted字段中的高亮内容
var formattedData map[string]interface{}
if err := json.Unmarshal(rawValue, &formattedData); err == nil {
// 提取高亮字段
if titleHighlight, ok := formattedData["title"].(string); ok {
doc.TitleHighlight = titleHighlight
}
if descHighlight, ok := formattedData["description"].(string); ok {
doc.DescriptionHighlight = descHighlight
}
if categoryHighlight, ok := formattedData["category"].(string); ok {
doc.CategoryHighlight = categoryHighlight
}
if tagsHighlight, ok := formattedData["tags"].([]interface{}); ok {
var tags []string
for _, tag := range tagsHighlight {
if tagStr, ok := tag.(string); ok {
tags = append(tags, tagStr)
}
}
doc.TagsHighlight = tags
}
}
}
}
switch keyStr {
case "id":
if rawID, ok := value.(json.RawMessage); ok {
var id float64
if err := json.Unmarshal(rawID, &id); err == nil {
doc.ID = uint(id)
}
}
case "title":
if rawTitle, ok := value.(json.RawMessage); ok {
var title string
if err := json.Unmarshal(rawTitle, &title); err == nil {
doc.Title = title
}
}
case "description":
if rawDesc, ok := value.(json.RawMessage); ok {
var description string
if err := json.Unmarshal(rawDesc, &description); err == nil {
doc.Description = description
}
}
case "url":
if rawURL, ok := value.(json.RawMessage); ok {
var url string
if err := json.Unmarshal(rawURL, &url); err == nil {
doc.URL = url
}
}
case "save_url":
if rawSaveURL, ok := value.(json.RawMessage); ok {
var saveURL string
if err := json.Unmarshal(rawSaveURL, &saveURL); err == nil {
doc.SaveURL = saveURL
}
}
case "file_size":
if rawFileSize, ok := value.(json.RawMessage); ok {
var fileSize string
if err := json.Unmarshal(rawFileSize, &fileSize); err == nil {
doc.FileSize = fileSize
}
}
case "key":
if rawKey, ok := value.(json.RawMessage); ok {
var key string
if err := json.Unmarshal(rawKey, &key); err == nil {
doc.Key = key
}
}
case "category":
if rawCategory, ok := value.(json.RawMessage); ok {
var category string
if err := json.Unmarshal(rawCategory, &category); err == nil {
doc.Category = category
}
}
case "tags":
if rawTags, ok := value.(json.RawMessage); ok {
var tags []string
if err := json.Unmarshal(rawTags, &tags); err == nil {
doc.Tags = tags
}
}
case "pan_name":
if rawPanName, ok := value.(json.RawMessage); ok {
var panName string
if err := json.Unmarshal(rawPanName, &panName); err == nil {
doc.PanName = panName
}
}
case "pan_id":
if rawPanID, ok := value.(json.RawMessage); ok {
var panID float64
if err := json.Unmarshal(rawPanID, &panID); err == nil {
panIDUint := uint(panID)
doc.PanID = &panIDUint
}
}
case "author":
if rawAuthor, ok := value.(json.RawMessage); ok {
var author string
if err := json.Unmarshal(rawAuthor, &author); err == nil {
doc.Author = author
}
}
case "created_at":
if rawCreatedAt, ok := value.(json.RawMessage); ok {
var createdAt string
if err := json.Unmarshal(rawCreatedAt, &createdAt); err == nil {
// 尝试多种时间格式
var t time.Time
var parseErr error
formats := []string{
time.RFC3339,
"2006-01-02T15:04:05Z",
"2006-01-02 15:04:05",
"2006-01-02T15:04:05.000Z",
}
for _, format := range formats {
if t, parseErr = time.Parse(format, createdAt); parseErr == nil {
doc.CreatedAt = t
break
}
}
}
}
case "updated_at":
if rawUpdatedAt, ok := value.(json.RawMessage); ok {
var updatedAt string
if err := json.Unmarshal(rawUpdatedAt, &updatedAt); err == nil {
// 尝试多种时间格式
var t time.Time
var parseErr error
formats := []string{
time.RFC3339,
"2006-01-02T15:04:05Z",
"2006-01-02 15:04:05",
"2006-01-02T15:04:05.000Z",
}
for _, format := range formats {
if t, parseErr = time.Parse(format, updatedAt); parseErr == nil {
doc.UpdatedAt = t
break
}
}
}
}
// 高亮字段处理 - 已移除现在使用_formatted字段
}
}
} else {
utils.Error("hit不是Map类型无法解析")
}
documents = append(documents, doc)
}
return documents, result.EstimatedTotalHits, nil
}
// GetAllDocuments 获取所有文档(用于调试)
func (m *MeilisearchService) GetAllDocuments() ([]MeilisearchDocument, error) {
if !m.enabled {
return nil, fmt.Errorf("Meilisearch未启用")
}
// 构建搜索请求,获取所有文档
searchRequest := &meilisearch.SearchRequest{
Query: "",
Offset: 0,
Limit: 1000, // 获取前1000个文档
}
// 执行搜索
result, err := m.index.Search("", searchRequest)
if err != nil {
return nil, fmt.Errorf("获取所有文档失败: %v", err)
}
utils.Debug("获取所有文档,总数: %d", result.EstimatedTotalHits)
utils.Debug("获取到的文档数量: %d", len(result.Hits))
// 解析结果
var documents []MeilisearchDocument
utils.Debug("获取到 %d 个文档", len(result.Hits))
// 只显示前3个文档的字段信息
for i, hit := range result.Hits {
if i >= 3 {
break
}
utils.Debug("文档%d的字段:", i+1)
hitValue := reflect.ValueOf(hit)
if hitValue.Kind() == reflect.Map {
for _, key := range hitValue.MapKeys() {
keyStr := key.String()
value := hitValue.MapIndex(key).Interface()
if rawValue, ok := value.(json.RawMessage); ok {
utils.Debug(" %s: %s", keyStr, string(rawValue))
} else {
utils.Debug(" %s: %v", keyStr, value)
}
}
}
}
return documents, nil
}
// GetIndexStats 获取索引统计信息
func (m *MeilisearchService) GetIndexStats() (map[string]interface{}, error) {
if !m.enabled {
return map[string]interface{}{
"enabled": false,
"message": "Meilisearch未启用",
}, nil
}
// 获取索引统计
stats, err := m.index.GetStats()
if err != nil {
return nil, fmt.Errorf("获取索引统计失败: %v", err)
}
utils.Debug("Meilisearch统计 - 文档数: %d, 索引中: %v", stats.NumberOfDocuments, stats.IsIndexing)
// 转换为map
result := map[string]interface{}{
"enabled": true,
"numberOfDocuments": stats.NumberOfDocuments,
"isIndexing": stats.IsIndexing,
"fieldDistribution": stats.FieldDistribution,
}
return result, nil
}
// ClearIndex 清空索引
func (m *MeilisearchService) ClearIndex() error {
if !m.enabled {
return fmt.Errorf("Meilisearch未启用")
}
// 清空索引
_, err := m.index.DeleteAllDocuments()
if err != nil {
return fmt.Errorf("清空索引失败: %v", err)
}
utils.Debug("Meilisearch索引已清空")
return nil
}

View File

@@ -39,7 +39,7 @@ func (tm *TaskManager) RegisterProcessor(processor TaskProcessor) {
tm.mu.Lock()
defer tm.mu.Unlock()
tm.processors[processor.GetTaskType()] = processor
utils.Info("注册任务处理器: %s", processor.GetTaskType())
utils.Debug("注册任务处理器: %s", processor.GetTaskType())
}
// getRegisteredProcessors 获取已注册的处理器列表(用于调试)
@@ -56,11 +56,11 @@ func (tm *TaskManager) StartTask(taskID uint) error {
tm.mu.Lock()
defer tm.mu.Unlock()
utils.Info("StartTask: 尝试启动任务 %d", taskID)
utils.Debug("StartTask: 尝试启动任务 %d", taskID)
// 检查任务是否已在运行
if _, exists := tm.running[taskID]; exists {
utils.Info("任务 %d 已在运行中", taskID)
utils.Debug("任务 %d 已在运行中", taskID)
return fmt.Errorf("任务 %d 已在运行中", taskID)
}
@@ -71,7 +71,7 @@ func (tm *TaskManager) StartTask(taskID uint) error {
return fmt.Errorf("获取任务失败: %v", err)
}
utils.Info("StartTask: 获取到任务 %d, 类型: %s, 状态: %s", task.ID, task.Type, task.Status)
utils.Debug("StartTask: 获取到任务 %d, 类型: %s, 状态: %s", task.ID, task.Type, task.Status)
// 获取处理器
processor, exists := tm.processors[string(task.Type)]
@@ -80,13 +80,13 @@ func (tm *TaskManager) StartTask(taskID uint) error {
return fmt.Errorf("未找到任务类型 %s 的处理器", task.Type)
}
utils.Info("StartTask: 找到处理器 %s", task.Type)
utils.Debug("StartTask: 找到处理器 %s", task.Type)
// 创建上下文
ctx, cancel := context.WithCancel(context.Background())
tm.running[taskID] = cancel
utils.Info("StartTask: 启动后台任务协程")
utils.Debug("StartTask: 启动后台任务协程")
// 启动后台任务
go tm.processTask(ctx, task, processor)
@@ -189,10 +189,10 @@ func (tm *TaskManager) processTask(ctx context.Context, task *entity.Task, proce
tm.mu.Lock()
delete(tm.running, task.ID)
tm.mu.Unlock()
utils.Info("processTask: 任务 %d 处理完成,清理资源", task.ID)
utils.Debug("processTask: 任务 %d 处理完成,清理资源", task.ID)
}()
utils.Info("processTask: 开始处理任务: %d, 类型: %s", task.ID, task.Type)
utils.Debug("processTask: 开始处理任务: %d, 类型: %s", task.ID, task.Type)
// 更新任务状态为运行中
err := tm.repoMgr.TaskRepository.UpdateStatus(task.ID, "running")
@@ -230,7 +230,7 @@ func (tm *TaskManager) processTask(ctx context.Context, task *entity.Task, proce
// 如果当前批次有处理中的任务项重置它们为pending状态服务器重启恢复
if processingItems > 0 {
utils.Info("任务 %d 发现 %d 个处理中的任务项重置为pending状态", task.ID, processingItems)
utils.Debug("任务 %d 发现 %d 个处理中的任务项重置为pending状态", task.ID, processingItems)
err = tm.repoMgr.TaskItemRepository.ResetProcessingItems(task.ID)
if err != nil {
utils.Error("重置处理中任务项失败: %v", err)
@@ -249,13 +249,13 @@ func (tm *TaskManager) processTask(ctx context.Context, task *entity.Task, proce
successItems := completedItems
failedItems := initialFailedItems
utils.Info("任务 %d 统计信息: 总计=%d, 已完成=%d, 已失败=%d, 待处理=%d",
utils.Debug("任务 %d 统计信息: 总计=%d, 已完成=%d, 已失败=%d, 待处理=%d",
task.ID, totalItems, completedItems, failedItems, currentBatchItems)
for _, item := range items {
select {
case <-ctx.Done():
utils.Info("任务 %d 被取消", task.ID)
utils.Debug("任务 %d 被取消", task.ID)
return
default:
// 处理单个任务项

287
utils/forbidden_words.go Normal file
View File

@@ -0,0 +1,287 @@
package utils
import (
"regexp"
"strings"
"github.com/ctwj/urldb/db/entity"
)
// ForbiddenWordsProcessor 违禁词处理器
type ForbiddenWordsProcessor struct{}
// NewForbiddenWordsProcessor 创建违禁词处理器实例
func NewForbiddenWordsProcessor() *ForbiddenWordsProcessor {
return &ForbiddenWordsProcessor{}
}
// CheckContainsForbiddenWords 检查字符串是否包含违禁词
// 参数:
// - text: 要检查的文本
// - forbiddenWords: 违禁词列表
//
// 返回:
// - bool: 是否包含违禁词
// - []string: 匹配到的违禁词列表
func (p *ForbiddenWordsProcessor) CheckContainsForbiddenWords(text string, forbiddenWords []string) (bool, []string) {
if len(forbiddenWords) == 0 {
return false, nil
}
var matchedWords []string
textLower := strings.ToLower(text)
for _, word := range forbiddenWords {
wordLower := strings.ToLower(word)
if strings.Contains(textLower, wordLower) {
matchedWords = append(matchedWords, word)
}
}
return len(matchedWords) > 0, matchedWords
}
// ReplaceForbiddenWords 替换字符串中的违禁词为 *
// 参数:
// - text: 要处理的文本
// - forbiddenWords: 违禁词列表
//
// 返回:
// - string: 替换后的文本
func (p *ForbiddenWordsProcessor) ReplaceForbiddenWords(text string, forbiddenWords []string) string {
if len(forbiddenWords) == 0 {
return text
}
result := text
// 按长度降序排序,避免短词替换后影响长词的匹配
sortedWords := make([]string, len(forbiddenWords))
copy(sortedWords, forbiddenWords)
// 简单的长度排序(这里可以优化为更复杂的排序)
for i := 0; i < len(sortedWords)-1; i++ {
for j := i + 1; j < len(sortedWords); j++ {
if len(sortedWords[i]) < len(sortedWords[j]) {
sortedWords[i], sortedWords[j] = sortedWords[j], sortedWords[i]
}
}
}
for _, word := range sortedWords {
// 使用正则表达式进行不区分大小写的替换
// 对于中文,不使用单词边界,直接替换
re := regexp.MustCompile(`(?i)` + regexp.QuoteMeta(word))
// 使用字符长度而不是字节长度
charCount := len([]rune(word))
result = re.ReplaceAllString(result, strings.Repeat("*", charCount))
}
return result
}
// ReplaceForbiddenWordsWithHighlight 替换字符串中的违禁词为 *(处理高亮标记)
// 参数:
// - text: 要处理的文本(可能包含高亮标记)
// - forbiddenWords: 违禁词列表
//
// 返回:
// - string: 替换后的文本
func (p *ForbiddenWordsProcessor) ReplaceForbiddenWordsWithHighlight(text string, forbiddenWords []string) string {
if len(forbiddenWords) == 0 {
return text
}
// 1. 先移除所有高亮标记,获取纯文本
cleanText := regexp.MustCompile(`<mark>(.*?)</mark>`).ReplaceAllString(text, "$1")
// 2. 检查纯文本中是否包含违禁词
hasForbidden := false
for _, word := range forbiddenWords {
re := regexp.MustCompile(`(?i)` + regexp.QuoteMeta(word))
if re.MatchString(cleanText) {
hasForbidden = true
break
}
}
// 3. 如果包含违禁词,则替换非高亮文本
if hasForbidden {
return p.ReplaceForbiddenWords(text, forbiddenWords)
}
// 4. 如果不包含违禁词,直接返回原文本
return text
}
// ProcessForbiddenWords 处理违禁词:检查并替换
// 参数:
// - text: 要处理的文本
// - forbiddenWords: 违禁词列表
//
// 返回:
// - bool: 是否包含违禁词
// - []string: 匹配到的违禁词列表
// - string: 替换后的文本
func (p *ForbiddenWordsProcessor) ProcessForbiddenWords(text string, forbiddenWords []string) (bool, []string, string) {
contains, matchedWords := p.CheckContainsForbiddenWords(text, forbiddenWords)
replacedText := p.ReplaceForbiddenWords(text, forbiddenWords)
return contains, matchedWords, replacedText
}
// ParseForbiddenWordsConfig 解析违禁词配置字符串
// 参数:
// - config: 违禁词配置字符串,多个词用逗号分隔
//
// 返回:
// - []string: 处理后的违禁词列表
func (p *ForbiddenWordsProcessor) ParseForbiddenWordsConfig(config string) []string {
if config == "" {
return nil
}
words := strings.Split(config, ",")
var cleanWords []string
for _, word := range words {
word = strings.TrimSpace(word)
if word != "" {
cleanWords = append(cleanWords, word)
}
}
return cleanWords
}
// 全局实例,方便直接调用
var DefaultForbiddenWordsProcessor = NewForbiddenWordsProcessor()
// 便捷函数,直接调用全局实例
// CheckContainsForbiddenWords 检查字符串是否包含违禁词(便捷函数)
func CheckContainsForbiddenWords(text string, forbiddenWords []string) (bool, []string) {
return DefaultForbiddenWordsProcessor.CheckContainsForbiddenWords(text, forbiddenWords)
}
// ReplaceForbiddenWords 替换字符串中的违禁词为 *(便捷函数)
func ReplaceForbiddenWords(text string, forbiddenWords []string) string {
return DefaultForbiddenWordsProcessor.ReplaceForbiddenWords(text, forbiddenWords)
}
// ReplaceForbiddenWordsWithHighlight 替换字符串中的违禁词为 *(处理高亮标记,便捷函数)
func ReplaceForbiddenWordsWithHighlight(text string, forbiddenWords []string) string {
return DefaultForbiddenWordsProcessor.ReplaceForbiddenWordsWithHighlight(text, forbiddenWords)
}
// ProcessForbiddenWords 处理违禁词:检查并替换(便捷函数)
func ProcessForbiddenWords(text string, forbiddenWords []string) (bool, []string, string) {
return DefaultForbiddenWordsProcessor.ProcessForbiddenWords(text, forbiddenWords)
}
// ParseForbiddenWordsConfig 解析违禁词配置字符串(便捷函数)
func ParseForbiddenWordsConfig(config string) []string {
return DefaultForbiddenWordsProcessor.ParseForbiddenWordsConfig(config)
}
// RemoveDuplicates 去除字符串切片中的重复项
func RemoveDuplicates(slice []string) []string {
keys := make(map[string]bool)
var result []string
for _, item := range slice {
if _, value := keys[item]; !value {
keys[item] = true
result = append(result, item)
}
}
return result
}
// ResourceForbiddenInfo 资源违禁词信息
type ResourceForbiddenInfo struct {
HasForbiddenWords bool `json:"has_forbidden_words"`
ForbiddenWords []string `json:"forbidden_words"`
ProcessedTitle string `json:"-"` // 不序列化,仅内部使用
ProcessedDesc string `json:"-"` // 不序列化,仅内部使用
}
// CheckResourceForbiddenWords 检查资源是否包含违禁词(检查标题和描述)
// 参数:
// - title: 资源标题
// - description: 资源描述
// - forbiddenWords: 违禁词列表
//
// 返回:
// - ResourceForbiddenInfo: 包含检查结果和处理后的文本
func CheckResourceForbiddenWords(title, description string, forbiddenWords []string) ResourceForbiddenInfo {
if len(forbiddenWords) == 0 {
return ResourceForbiddenInfo{
HasForbiddenWords: false,
ForbiddenWords: []string{},
ProcessedTitle: title,
ProcessedDesc: description,
}
}
// 分别检查标题和描述
titleHasForbidden, titleMatchedWords := CheckContainsForbiddenWords(title, forbiddenWords)
descHasForbidden, descMatchedWords := CheckContainsForbiddenWords(description, forbiddenWords)
// 合并结果
hasForbiddenWords := titleHasForbidden || descHasForbidden
var matchedWords []string
if titleHasForbidden {
matchedWords = append(matchedWords, titleMatchedWords...)
}
if descHasForbidden {
matchedWords = append(matchedWords, descMatchedWords...)
}
// 去重
matchedWords = RemoveDuplicates(matchedWords)
// 处理文本(替换违禁词)
processedTitle := ReplaceForbiddenWords(title, forbiddenWords)
processedDesc := ReplaceForbiddenWords(description, forbiddenWords)
return ResourceForbiddenInfo{
HasForbiddenWords: hasForbiddenWords,
ForbiddenWords: matchedWords,
ProcessedTitle: processedTitle,
ProcessedDesc: processedDesc,
}
}
// GetForbiddenWordsFromConfig 从系统配置获取违禁词列表
// 参数:
// - getConfigFunc: 获取配置的函数
//
// 返回:
// - []string: 解析后的违禁词列表
// - error: 获取配置时的错误
func GetForbiddenWordsFromConfig(getConfigFunc func() (string, error)) ([]string, error) {
forbiddenWords, err := getConfigFunc()
if err != nil {
return nil, err
}
return ParseForbiddenWordsConfig(forbiddenWords), nil
}
// ProcessResourcesForbiddenWords 批量处理资源的违禁词
// 参数:
// - resources: 资源切片
// - forbiddenWords: 违禁词列表
//
// 返回:
// - 处理后的资源切片
func ProcessResourcesForbiddenWords(resources []entity.Resource, forbiddenWords []string) []entity.Resource {
if len(forbiddenWords) == 0 {
return resources
}
for i := range resources {
// 处理标题中的违禁词
resources[i].Title = ReplaceForbiddenWords(resources[i].Title, forbiddenWords)
// 处理描述中的违禁词
resources[i].Description = ReplaceForbiddenWords(resources[i].Description, forbiddenWords)
}
return resources
}

View File

@@ -23,13 +23,14 @@ type VersionInfo struct {
// 编译时注入的版本信息
var (
Version = getVersionFromFile()
// 这些变量将在编译时通过 ldflags 注入
Version = "unknown" // 默认版本,编译时会被覆盖
BuildTime = GetCurrentTimeString()
GitCommit = "unknown"
GitBranch = "unknown"
)
// getVersionFromFile 从VERSION文件读取版本号
// getVersionFromFile 从VERSION文件读取版本号(备用方案)
func getVersionFromFile() string {
data, err := os.ReadFile("VERSION")
if err != nil {
@@ -42,11 +43,29 @@ func getVersionFromFile() string {
func GetVersionInfo() *VersionInfo {
buildTime, _ := ParseTime(BuildTime)
// 检查版本信息是否通过编译时注入
version := Version
gitCommit := GitCommit
gitBranch := GitBranch
// 如果编译时注入的版本是默认值,尝试从文件读取
if version == "unknown" {
version = getVersionFromFile()
}
// 如果Git信息是默认值尝试从文件读取
if gitCommit == "unknown" {
gitCommit = "unknown"
}
if gitBranch == "unknown" {
gitBranch = "unknown"
}
return &VersionInfo{
Version: Version,
Version: version,
BuildTime: buildTime,
GitCommit: GitCommit,
GitBranch: GitBranch,
GitCommit: gitCommit,
GitBranch: gitBranch,
GoVersion: runtime.Version(),
NodeVersion: getNodeVersion(),
Platform: runtime.GOOS,

View File

@@ -32,4 +32,14 @@
.resource-card {
@apply bg-white rounded-lg shadow-md border border-gray-200 p-4 hover:shadow-lg transition-shadow duration-200;
}
/* 搜索高亮样式 */
mark {
@apply bg-yellow-200 text-yellow-900 px-1 py-0.5 rounded font-medium;
}
/* 暗色模式下的高亮样式 */
.dark mark {
@apply bg-yellow-600 text-yellow-100;
}
}

6
web/components.d.ts vendored
View File

@@ -23,14 +23,18 @@ declare module 'vue' {
NEmpty: typeof import('naive-ui')['NEmpty']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NImage: typeof import('naive-ui')['NImage']
NImageGroup: typeof import('naive-ui')['NImageGroup']
NInput: typeof import('naive-ui')['NInput']
NList: typeof import('naive-ui')['NList']
NListItem: typeof import('naive-ui')['NListItem']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
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']
@@ -40,6 +44,8 @@ declare module 'vue' {
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']

View File

@@ -23,12 +23,12 @@
</div>
<!-- 自动处理状态 -->
<div class="flex items-center space-x-2">
<NuxtLink to="/admin/feature-config" class="flex items-center space-x-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 px-2 py-1 rounded transition-colors">
<div :class="autoProcessEnabled ? 'w-2 h-2 bg-green-500 rounded-full' : 'w-2 h-2 bg-gray-400 rounded-full'"></div>
<span class="text-sm text-gray-600 dark:text-gray-300">
自动处理{{ autoProcessEnabled ? '已开启' : '已关闭' }}
</span>
</div>
</NuxtLink>
<!-- 自动转存状态 -->
<div class="flex items-center space-x-2">

View File

@@ -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>

View File

@@ -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
@@ -202,10 +178,19 @@ const fetchTransferredResources = async () => {
console.log('结果结构:', Object.keys(result || {}))
if (result && result.data) {
console.log('使用 resources 格式,数量:', result.data.length)
resources.value = result.data
total.value = result.total || 0
pagination.itemCount = result.total || 0
// 处理嵌套的data结构{data: {data: [...], total: ...}}
if (result.data.data && Array.isArray(result.data.data)) {
console.log('使用嵌套data格式数量:', result.data.data.length)
resources.value = result.data.data
total.value = result.data.total || 0
pagination.itemCount = result.data.total || 0
} else {
// 处理直接的data结构{data: [...], total: ...}
console.log('使用直接data格式数量:', result.data.length)
resources.value = result.data
total.value = result.total || 0
pagination.itemCount = result.total || 0
}
} else if (Array.isArray(result)) {
console.log('使用数组格式,数量:', result.length)
resources.value = result
@@ -257,23 +242,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()

View File

@@ -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(() => {
@@ -380,8 +382,15 @@ const fetchUntransferredResources = async () => {
console.log('未转存资源结果:', result)
if (result && result.data) {
resources.value = result.data
total.value = result.total || 0
// 处理嵌套的data结构{data: {data: [...], total: ...}}
if (result.data.data && Array.isArray(result.data.data)) {
resources.value = result.data.data
total.value = result.data.total || 0
} else {
// 处理直接的data结构{data: [...], total: ...}
resources.value = result.data
total.value = result.total || 0
}
} else if (Array.isArray(result)) {
resources.value = result
total.value = result.length
@@ -445,7 +454,7 @@ const getAccountOptions = async () => {
}))
} catch (error) {
console.error('获取网盘账号选项失败:', error)
message.error('获取网盘账号失败')
$message.error('获取网盘账号失败')
} finally {
accountsLoading.value = false
}
@@ -516,7 +525,7 @@ const toggleResourceSelection = (id: number, checked: boolean) => {
// 批量转存
const handleBatchTransfer = async () => {
if (selectedResources.value.length === 0) {
message.warning('请选择要转存的资源')
$message.warning('请选择要转存的资源')
return
}
@@ -543,6 +552,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 +570,7 @@ const formatDate = (dateString: string) => {
// 确认批量转存
const confirmBatchTransfer = async () => {
if (selectedAccounts.value.length === 0) {
message.warning('请选择至少一个网盘账号')
$message.warning('请选择至少一个网盘账号')
return
}
@@ -572,7 +591,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 +602,7 @@ const confirmBatchTransfer = async () => {
} catch (error) {
console.error('创建批量转存任务失败:', error)
message.error('创建批量转存任务失败')
$message.error('创建批量转存任务失败')
} finally {
batchTransferring.value = false
}

View File

@@ -21,15 +21,14 @@
<script setup lang="ts">
import { useApiFetch } from '~/composables/useApiFetch'
import { parseApiResponse } from '~/composables/useApi'
// 使用版本信息组合式函数
const { versionInfo, fetchVersionInfo } = useVersion()
// 获取系统配置
const { data: systemConfigData } = await useAsyncData('systemConfig',
() => useApiFetch('/system/config').then(parseApiResponse)
)
const systemConfig = computed(() => (systemConfigData.value as any) || { copyright: '© 2025 老九网盘资源数据库 By 老九' })
import { useSystemConfigStore } from '~/stores/systemConfig'
const systemConfigStore = useSystemConfigStore()
await systemConfigStore.initConfig(false, false)
const systemConfig = computed(() => systemConfigStore.config)
console.log(systemConfig.value)
// 组件挂载时获取版本信息
onMounted(() => {

View File

@@ -0,0 +1 @@
<template>

View 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>

View 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>

View File

@@ -1,153 +1,161 @@
<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 v-else-if="forbidden" class="space-y-4">
<div class="flex flex-col items-center justify-center py-4">
<!-- 使用SVG图标 -->
<div class="mb-6">
<img src="/assets/svg/forbidden.svg" alt="禁止访问" class="w-48 h-48" />
</div>
<h3 class="text-xl font-bold text-red-600 dark:text-red-400 mb-2">禁止访问</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">该资源包含违禁内容无法访问</p>
<n-button @click="closeModal" class="bg-red-500 hover:bg-red-600 text-white">
我知道了
</n-button>
</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-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-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">
<n-alert v-if="message" type="info" :show-icon="false">
<template #icon>
<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>
</template>
{{ message }}
</n-alert>
<!-- 夸克链接只显示二维码 -->
<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"
>
<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>
复制链接
</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>
</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
@@ -157,18 +165,22 @@ interface Props {
platform?: string
message?: string
error?: string
forbidden?: boolean
forbidden_words?: string[]
}
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 +197,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 +231,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 +267,13 @@ watch(() => props.url, () => {
opacity: 1;
}
}
.qr-container {
height: 200px;
width: 200px;
background-color: #F5F5F5;
}
.n-qr-code {
padding: 0 !important;
}
</style>

View File

@@ -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 = () => {
@@ -206,3 +226,33 @@ function log(...args: any[]) {
console.log(...args)
}
}
// Meilisearch管理API
export const useMeilisearchApi = () => {
const getStatus = () => useApiFetch('/meilisearch/status').then(parseApiResponse)
const getUnsyncedCount = () => useApiFetch('/meilisearch/unsynced-count').then(parseApiResponse)
const getUnsyncedResources = (params?: any) => useApiFetch('/meilisearch/unsynced', { params }).then(parseApiResponse)
const getSyncedResources = (params?: any) => useApiFetch('/meilisearch/synced', { params }).then(parseApiResponse)
const getAllResources = (params?: any) => useApiFetch('/meilisearch/resources', { params }).then(parseApiResponse)
const testConnection = (data: any) => useApiFetch('/meilisearch/test-connection', { method: 'POST', body: data }).then(parseApiResponse)
const syncAllResources = () => useApiFetch('/meilisearch/sync-all', { method: 'POST' }).then(parseApiResponse)
const stopSync = () => useApiFetch('/meilisearch/stop-sync', { method: 'POST' }).then(parseApiResponse)
const clearIndex = () => useApiFetch('/meilisearch/clear-index', { method: 'POST' }).then(parseApiResponse)
const updateIndexSettings = () => useApiFetch('/meilisearch/update-settings', { method: 'POST' }).then(parseApiResponse)
const getSyncProgress = () => useApiFetch('/meilisearch/sync-progress').then(parseApiResponse)
const debugGetAllDocuments = () => useApiFetch('/meilisearch/debug/documents').then(parseApiResponse)
return {
getStatus,
getUnsyncedCount,
getUnsyncedResources,
getSyncedResources,
getAllResources,
testConnection,
syncAllResources,
stopSync,
clearIndex,
updateIndexSettings,
getSyncProgress,
debugGetAllDocuments
}
}

View File

@@ -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 ||

View 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
}
}

View 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 || '请求失败')
}
}

View 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
}
}

View File

@@ -18,7 +18,7 @@ interface VersionResponse {
export const useVersion = () => {
const versionInfo = ref<VersionInfo>({
version: '1.2.0',
version: '1.2.4',
build_time: '',
git_commit: 'unknown',
git_branch: 'unknown',

View File

@@ -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: '机器人',
@@ -138,10 +146,11 @@ export const adminNewNavigationItems = [
key: 'system-config',
label: '系统配置',
icon: 'fas fa-cog',
to: '/admin/system-config',
active: (route: any) => route.path.startsWith('/admin/system-config'),
to: '/admin/site-config',
active: (route: any) => route.path.startsWith('/admin/site-config'),
group: 'system'
},
{
key: 'version',
label: '版本信息',

View File

@@ -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>

View File

@@ -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

View File

@@ -33,14 +33,26 @@ import { ref, onMounted } from 'vue'
const theme = lightTheme
const isDark = ref(false)
// 使用 useCookie 来确保服务端和客户端状态一致
const themeCookie = useCookie('theme', { default: () => 'light' })
// 初始化主题状态
isDark.value = themeCookie.value === 'dark'
const toggleDarkMode = () => {
isDark.value = !isDark.value
if (isDark.value) {
document.documentElement.classList.add('dark')
localStorage.setItem('theme', 'dark')
} else {
document.documentElement.classList.remove('dark')
localStorage.setItem('theme', 'light')
const newTheme = isDark.value ? 'dark' : 'light'
// 更新 cookie
themeCookie.value = newTheme
// 更新 DOM 类
if (process.client) {
if (isDark.value) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
}
@@ -94,14 +106,13 @@ const fetchStatsCode = async () => {
onMounted(async () => {
// 初始化主题
if (localStorage.getItem('theme') === 'dark') {
// 初始化主题 - 使用 cookie 而不是 localStorage
if (themeCookie.value === 'dark') {
isDark.value = true
document.documentElement.classList.add('dark')
}
// 获取三方统计代码并直接加载
await fetchStatsCode()
})
</script>

View File

@@ -27,6 +27,22 @@ export default defineNuxtConfig({
optimizeDeps: {
include: ['vueuc', 'date-fns'],
exclude: ["oxc-parser"] // 强制使用 WASM 版本
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
rewrite: (path) => path
},
'/uploads': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
rewrite: (path) => path
}
}
}
},
modules: ['@nuxtjs/tailwindcss', '@pinia/nuxt'],
@@ -51,10 +67,10 @@ export default defineNuxtConfig({
},
runtimeConfig: {
public: {
// 开发环境:直接访问后端,生产环境通过 Nginx 反代
apiBase: process.env.NODE_ENV === 'production' ? '/api' : 'http://localhost:8080/api',
// 服务端:开发环境直接访问,生产环境容器内访问
apiServer: process.env.NODE_ENV === 'production' ? 'http://backend:8080/api' : 'http://localhost:8080/api'
// 客户端API地址开发环境通过代理生产环境通过Nginx
apiBase: '/api',
// 服务端API地址通过环境变量配置支持不同部署方式
apiServer: process.env.NUXT_PUBLIC_API_SERVER || (process.env.NODE_ENV === 'production' ? 'http://backend:8080/api' : '/api')
}
},
build: {
@@ -62,7 +78,13 @@ export default defineNuxtConfig({
},
ssr: true,
nitro: {
logLevel: 'verbose',
preset: 'node-server'
logLevel: 'info',
preset: 'node-server',
storage: {
redis: {
driver: 'memory',
max: 1000
}
}
}
})

View File

@@ -1,6 +1,6 @@
{
"name": "res-db-web",
"version": "1.2.0",
"version": "1.2.4",
"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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>&nbsp;{{ 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>

View File

@@ -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>

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

Some files were not shown because too many files have changed in this diff Show More