32 Commits

Author SHA1 Message Date
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
Kerwin
9b0d385c52 chore: bump version to v1.2.1 2025-08-13 15:22:01 +08:00
ctwj
fae7de17d5 fix: 修复了转存删除和添加广告的问题 2025-08-13 00:28:18 +08:00
Kerwin
05930a3e70 update: Readme.md 2025-08-12 09:25:28 +08:00
104 changed files with 8494 additions and 6907 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. 遵循项目的贡献指南
---

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

153
README.md
View File

@@ -29,12 +29,38 @@
---
## 🔔 温馨提示
## 🔔 版本改动
- [文档说明](https://ecn5khs4t956.feishu.cn/wiki/PsnDwtxghiP0mLkTiruczKtxnwd?from=from_copylink)
- [服务器要求](https://ecn5khs4t956.feishu.cn/wiki/W8YBww1Mmiu4Cdkp5W4c8pFNnMf?from=from_copylink)
- [QQ机器人](https://github.com/ctwj/astrbot_plugin_urldb)
### v1.2.3
1. 添加图片上传功能
2. 添加Logo配置项首页Logo显示
3. 后台界面体验优化
### v1.2.1
1. 修复转存移除广告失败的问题和添加广告失败的问题
2. 管理后台UI优化
3. 首页添加描述显示
### v1.2.0
1. 新增手动批量转存
2. 新增QQ机器人
3. 新增任务管理功能
4. 自动转存改版(批量转存修改为,显示二维码时自动转存)
5. 新增支持第三方统计代码配置
### v1.0.0
1. 支持API手动批量录入资源
2. 支持,自动判断资源有效性
3. 支持自动转存
4. 支持平台多账号管理Quark
5. 支持简单的数据统计
---
## 📸 项目截图
@@ -85,112 +111,6 @@
---
## 🚀 快速开始
### 环境要求
- **Docker** 和 **Docker Compose**
- 或者本地环境:
- **Go** 1.23+
- **Node.js** 18+
- **PostgreSQL** 15+
- **pnpm** (推荐) 或 npm
### 方式一Docker 部署(推荐)
```bash
# 克隆项目
git clone https://github.com/ctwj/urldb.git
cd urldb
# 使用 Docker Compose 启动
docker compose up --build -d
# 访问应用
# 前端: http://localhost:3030
# 后端API: http://localhost:8080
```
### 方式二:本地开发
#### 1. 克隆项目
```bash
git clone https://github.com/ctwj/urldb.git
cd urldb
```
#### 2. 后端设置
```bash
# 复制环境变量文件
cp env.example .env
# 编辑环境变量
vim .env
# 安装Go依赖
go mod tidy
# 启动后端服务
go run main.go
```
#### 3. 前端设置
```bash
# 进入前端目录
cd web
# 安装依赖
pnpm install
# 启动开发服务器
pnpm dev
```
#### 4. 数据库设置
```sql
-- 创建数据库
CREATE DATABASE url_db;
```
---
## 📁 项目结构
```
l9pan/
├── 📁 common/ # 通用功能模块
│ ├── 📄 pan_factory.go # 网盘工厂模式
│ ├── 📄 alipan.go # 阿里云盘实现
│ ├── 📄 baidu_pan.go # 百度网盘实现
│ ├── 📄 quark_pan.go # 夸克网盘实现
│ └── 📄 uc_pan.go # UC网盘实现
├── 📁 db/ # 数据库层
│ ├── 📁 entity/ # 数据实体
│ ├── 📁 repo/ # 数据仓库
│ ├── 📁 dto/ # 数据传输对象
│ └── 📁 converter/ # 数据转换器
├── 📁 handlers/ # API处理器
├── 📁 middleware/ # 中间件
├── 📁 utils/ # 工具函数
├── 📁 web/ # 前端项目
│ ├── 📁 pages/ # 页面组件
│ ├── 📁 components/ # 通用组件
│ ├── 📁 composables/ # 组合式函数
│ └── 📁 stores/ # 状态管理
├── 📁 docs/ # 项目文档
├── 📁 nginx/ # Nginx配置
│ ├── 📄 nginx.conf # 主配置文件
│ └── 📁 conf.d/ # 站点配置
├── 📄 main.go # 主程序入口
├── 📄 Dockerfile # Docker配置
├── 📄 docker-compose.yml # Docker Compose配置
├── 📄 docker-start-nginx.sh # Nginx启动脚本
└── 📄 README.md # 项目说明
```
---
## 🔧 配置说明
### 环境变量配置
@@ -210,13 +130,6 @@ PORT=8080
TIMEZONE=Asia/Shanghai
```
### Docker 服务说明
| 服务 | 端口 | 说明 |
|------|------|------|
| server | 3030 | 应用 |
| postgres | 5431 | PostgreSQL 数据库 |
### 镜像构建
```
@@ -228,18 +141,6 @@ docker push ctwj/urldb-backend:1.0.7
---
## 📚 API 文档
### 公开统计
提供批量入库和搜索api通过 apiToken 授权
> 📖 完整API文档请访问`http://doc.l9.lc/`
## 🤝 贡献指南
我们欢迎所有形式的贡献!
## 📄 许可证
本项目采用 [GPL License](LICENSE) 许可证。

View File

@@ -1 +1 @@
1.2.0
1.2.4

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,8 +1,9 @@
package converter
import (
"reflect"
"time"
"github.com/ctwj/urldb/db/dto"
"github.com/ctwj/urldb/db/entity"
)
@@ -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))
@@ -176,7 +262,7 @@ func ToReadyResourceResponse(resource *entity.ReadyResource) dto.ReadyResourceRe
if isDeleted {
deletedAt = &resource.DeletedAt.Time
}
return dto.ReadyResourceResponse{
ID: resource.ID,
Title: resource.Title,

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,31 @@ 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"`
}
// 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"`

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,144 @@ 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").
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

@@ -18,6 +18,7 @@ type SearchStatRepository interface {
GetSearchTrend(days int) ([]entity.DailySearchStat, error)
GetKeywordTrend(keyword string, days int) ([]entity.DailySearchStat, error)
GetSummary() (map[string]int64, error)
FindWithPaginationOrdered(page, limit int) ([]entity.SearchStat, int64, error)
}
// SearchStatRepositoryImpl 搜索统计Repository实现
@@ -157,3 +158,20 @@ func (r *SearchStatRepositoryImpl) GetSummary() (map[string]int64, error) {
"keywords": keywords,
}, nil
}
// FindWithPaginationOrdered 按时间倒序分页查找搜索记录
func (r *SearchStatRepositoryImpl) FindWithPaginationOrdered(page, limit int) ([]entity.SearchStat, int64, error) {
var stats []entity.SearchStat
var total int64
offset := (page - 1) * limit
// 获取总数
if err := r.db.Model(&entity.SearchStat{}).Count(&total).Error; err != nil {
return nil, 0, err
}
// 获取分页数据,按创建时间倒序排列(最新的在前面)
err := r.db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&stats).Error
return stats, total, err
}

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.1.0
image: ctwj/urldb-backend:1.2.3
environment:
DB_HOST: postgres
DB_PORT: 5432
@@ -38,10 +38,10 @@ services:
- app-network
frontend:
image: ctwj/urldb-frontend:1.1.0
image: ctwj/urldb-frontend:1.2.3
environment:
NODE_ENV: production
NUXT_PUBLIC_API_SERVER: http://backend:8080/api
NUXT_PUBLIC_API_CLIENT: /api
depends_on:
- backend
networks:

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,29 +197,88 @@ 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 tag != "" {
params["tag"] = tag
}
if keyword != "" {
params["search"] = keyword
}
if category != "" {
params["category"] = category
}
if tag != "" {
params["tag"] = tag
}
// 执行搜索
resources, total, err := repoManager.ResourceRepository.SearchWithFilters(params)
if err != nil {
ErrorResponse(c, "搜索失败: "+err.Error(), 500)
return
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
}
}
// 过滤违禁词
@@ -242,10 +303,10 @@ func (h *PublicAPIHandler) SearchResources(c *gin.Context) {
// 构建响应数据
responseData := gin.H{
"list": resourceResponses,
"total": filteredTotal,
"page": page,
"limit": pageSize,
"data": resourceResponses,
"total": filteredTotal,
"page": page,
"page_size": pageSize,
}
// 如果存在违禁词过滤,添加提醒字段

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,51 @@ func GetResources(c *gin.Context) {
params["pan_name"] = panName
}
resources, total, err := repoManager.ResourceRepository.SearchWithFilters(params)
var resources []entity.Resource
var total int64
var err error
// 搜索统计(仅非管理员)
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)
// 如果有搜索关键词且启用了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)
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 {
@@ -170,6 +209,15 @@ func CreateResource(c *gin.Context) {
}
}
// 同步到Meilisearch
if meilisearchManager != nil {
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 +294,15 @@ func UpdateResource(c *gin.Context) {
}
}
// 同步到Meilisearch
if meilisearchManager != nil {
go func() {
if err := meilisearchManager.SyncResourceToMeilisearch(resource); err != nil {
utils.Error("同步资源到Meilisearch失败: %v", err)
}
}()
}
SuccessResponse(c, gin.H{"message": "资源更新成功"})
}
@@ -277,16 +334,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

@@ -37,7 +37,8 @@ func GetSearchStats(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
stats, total, err := repoManager.SearchStatRepository.FindWithPagination(page, pageSize)
// 使用自定义方法获取按时间倒序排列的搜索记录
stats, total, err := repoManager.SearchStatRepository.FindWithPaginationOrdered(page, pageSize)
if err != nil {
ErrorResponse(c, "获取搜索统计失败", http.StatusInternalServerError)
return

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 {

View File

@@ -239,7 +239,7 @@ func (h *TaskHandler) GetTasks(c *gin.Context) {
taskType := c.Query("task_type")
status := c.Query("status")
utils.Info("GetTasks: 获取任务列表 page=%d, pageSize=%d, taskType=%s, status=%s", page, pageSize, taskType, status)
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 {
@@ -248,13 +248,13 @@ func (h *TaskHandler) GetTasks(c *gin.Context) {
return
}
utils.Info("GetTasks: 从数据库获取到 %d 个任务", len(tasks))
utils.Debug("GetTasks: 从数据库获取到 %d 个任务", len(tasks))
// 为每个任务添加运行状态
var result []gin.H
for _, task := range tasks {
isRunning := h.taskManager.IsTaskRunning(task.ID)
utils.Info("GetTasks: 任务 %d (%s) 数据库状态: %s, TaskManager运行状态: %v", task.ID, task.Title, task.Status, isRunning)
utils.Debug("GetTasks: 任务 %d (%s) 数据库状态: %s, TaskManager运行状态: %v", task.ID, task.Title, task.Status, isRunning)
result = append(result, gin.H{
"id": task.ID,

60
main.go
View File

@@ -1,13 +1,16 @@
package main
import (
"fmt"
"log"
"os"
"strings"
"github.com/ctwj/urldb/db"
"github.com/ctwj/urldb/db/repo"
"github.com/ctwj/urldb/handlers"
"github.com/ctwj/urldb/middleware"
"github.com/ctwj/urldb/services"
"github.com/ctwj/urldb/task"
"github.com/ctwj/urldb/utils"
@@ -17,6 +20,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 +90,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 +118,9 @@ func main() {
// 将Repository管理器注入到handlers中
handlers.SetRepositoryManager(repoManager)
// 设置Meilisearch管理器到handlers中
handlers.SetMeilisearchManager(meilisearchManager)
// 设置公开API中间件的Repository管理器
middleware.SetRepositoryManager(repoManager)
@@ -106,6 +130,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 +241,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 +267,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"

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

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

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

@@ -0,0 +1,155 @@
#!/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 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}"
# 直接使用 docker build避免 buildx 的复杂性
BUILD_CMD="docker build"
echo -e "${BLUE}使用构建命令: ${BUILD_CMD}${NC}"
# 构建前端镜像
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} \
.
# 构建后端镜像
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} \
.
echo -e "${GREEN}Docker构建完成!${NC}"
echo -e "镜像标签:"
echo -e " ${GREEN}ctwj/urldb-backend:${version}${NC}"
echo -e " ${GREEN}ctwj/urldb-frontend:${version}${NC}"
}
# 推送镜像
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] 构建Docker镜像"
echo " push [version] 推送镜像到Docker Hub"
echo " clean [version] 清理Docker镜像"
echo " help 显示此帮助信息"
echo ""
echo "示例:"
echo " $0 build # 构建当前版本镜像"
echo " $0 build 1.2.4 # 构建指定版本镜像"
echo " $0 push 1.2.4 # 推送指定版本镜像"
echo " $0 clean # 清理当前版本镜像"
}
# 主函数
main() {
case $1 in
"build")
build_docker $2
;;
"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,762 @@
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 {
if m.service == nil || !m.service.IsEnabled() {
return nil
}
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,553 @@
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 {
if !m.enabled {
return nil
}
if len(docs) == 0 {
return nil
}
// 转换为interface{}切片
var documents []interface{}
for _, doc := range docs {
documents = append(documents, doc)
}
// 批量添加文档
_, err := m.index.AddDocuments(documents, nil)
if err != nil {
return fmt.Errorf("批量添加文档失败: %v", err)
}
utils.Debug("批量添加 %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

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

8
web/components.d.ts vendored
View File

@@ -23,24 +23,30 @@ 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']
NInputNumber: typeof import('naive-ui')['NInputNumber']
NList: typeof import('naive-ui')['NList']
NListItem: typeof import('naive-ui')['NListItem']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
NP: typeof import('naive-ui')['NP']
NPagination: typeof import('naive-ui')['NPagination']
NProgress: typeof import('naive-ui')['NProgress']
NQrCode: typeof import('naive-ui')['NQrCode']
NSelect: typeof import('naive-ui')['NSelect']
NSpace: typeof import('naive-ui')['NSpace']
NSpin: typeof import('naive-ui')['NSpin']
NSwitch: typeof import('naive-ui')['NSwitch']
NTable: typeof import('naive-ui')['NTable']
NTabPane: typeof import('naive-ui')['NTabPane']
NTabs: typeof import('naive-ui')['NTabs']
NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText']
NThing: typeof import('naive-ui')['NThing']
NUpload: typeof import('naive-ui')['NUpload']
NUploadDragger: typeof import('naive-ui')['NUploadDragger']
NVirtualList: typeof import('naive-ui')['NVirtualList']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']

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

@@ -25,7 +25,7 @@ import { parseApiResponse } from '~/composables/useApi'
const { versionInfo, fetchVersionInfo } = useVersion()
// 获取系统配置
const { data: systemConfigData } = await useAsyncData('systemConfig',
const { data: systemConfigData } = await useAsyncData('footerSystemConfig',
() => useApiFetch('/system/config').then(parseApiResponse)
)

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,146 @@
<template>
<div v-if="visible" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" @click="closeModal">
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-sm w-full mx-4" @click.stop>
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ isQuarkLink ? '夸克网盘链接' : '链接二维码' }}
</h3>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<i class="fas fa-times text-xl"></i>
</button>
<n-modal :show="visible" @update:show="closeModal" preset="card" title="链接二维码" class="max-w-sm">
<div class="text-center">
<!-- 加载状态 -->
<div v-if="loading" class="space-y-4">
<div class="flex flex-col items-center justify-center py-8">
<n-spin size="large" />
<p class="text-sm text-gray-600 dark:text-gray-400 mt-4">正在获取链接...</p>
</div>
</div>
<div class="text-center">
<!-- 加载状态 -->
<div v-if="loading" class="space-y-4">
<div class="flex flex-col items-center justify-center py-8">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
<p class="text-sm text-gray-600 dark:text-gray-400">正在获取链接...</p>
</div>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="space-y-4">
<div class="bg-red-50 dark:bg-red-900/20 p-4 rounded-lg">
<div class="flex items-center">
<i class="fas fa-exclamation-triangle text-red-500 mr-2"></i>
<p class="text-sm text-red-700 dark:text-red-300">{{ error }}</p>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-lg">
<p class="text-sm text-gray-700 dark:text-gray-300 break-all">{{ url }}</p>
</div>
<div class="flex gap-2">
<button
@click="openLink"
class="flex-1 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors text-sm flex items-center justify-center gap-2"
>
<i class="fas fa-external-link-alt"></i> 跳转
</button>
<button
@click="copyUrl"
class="flex-1 px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 transition-colors text-sm flex items-center justify-center gap-2"
>
<i class="fas fa-copy"></i> 复制
</button>
</div>
</div>
<!-- 正常显示 -->
<div v-else>
<!-- 移动端所有链接都显示链接文本和操作按钮 -->
<div v-if="isMobile" class="space-y-4">
<!-- 显示链接状态信息 -->
<div v-if="message" class="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
<div class="flex items-center">
<i class="fas fa-info-circle text-blue-500 mr-2"></i>
<p class="text-sm text-blue-700 dark:text-blue-300">{{ message }}</p>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-lg">
<p class="text-sm text-gray-700 dark:text-gray-300 break-all">{{ url }}</p>
</div>
<div class="flex gap-2">
<button
@click="openLink"
class="flex-1 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors text-sm flex items-center justify-center gap-2"
>
<i class="fas fa-external-link-alt"></i> 跳转
</button>
<button
@click="copyUrl"
class="flex-1 px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 transition-colors text-sm flex items-center justify-center gap-2"
>
<i class="fas fa-copy"></i> 复制
</button>
</div>
</div>
<!-- PC端根据链接类型显示不同内容 -->
<div v-else class="space-y-4">
<!-- 显示链接状态信息 -->
<div v-if="message" class="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
<div class="flex items-center">
<i class="fas fa-info-circle text-blue-500 mr-2"></i>
<p class="text-sm text-blue-700 dark:text-blue-300">{{ message }}</p>
</div>
</div>
<!-- 夸克链接只显示二维码 -->
<div v-if="isQuarkLink" class="space-y-4">
<div class="bg-gray-100 dark:bg-gray-700 p-4 rounded-lg">
<canvas ref="qrCanvas" class="mx-auto"></canvas>
</div>
<div class="text-center">
<button
@click="closeModal"
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors text-sm flex items-center justify-center gap-2 mx-auto"
>
<i class="fas fa-check"></i> 确认
</button>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">请使用手机扫码操作</p>
</div>
</div>
<!-- 其他链接同时显示链接和二维码 -->
<div v-else class="space-y-4">
<div class="mb-4">
<div class="bg-gray-100 dark:bg-gray-700 p-4 rounded-lg">
<canvas ref="qrCanvas" class="mx-auto"></canvas>
</div>
</div>
<div class="mb-4">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">扫描二维码访问链接</p>
<div class="bg-gray-50 dark:bg-gray-700 p-3 rounded border">
<p class="text-xs text-gray-700 dark:text-gray-300 break-all">{{ url }}</p>
</div>
</div>
<div class="flex gap-2">
<button
@click="copyUrl"
class="flex-1 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors text-sm flex items-center justify-center gap-2"
>
<i class="fas fa-copy"></i>
复制链接
</button>
<button
@click="downloadQrCode"
class="flex-1 px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 transition-colors text-sm flex items-center justify-center gap-2"
>
<i class="fas fa-download"></i>
下载二维码
</button>
</div>
</div>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="space-y-4">
<n-alert type="error" :show-icon="false">
<template #icon>
<i class="fas fa-exclamation-triangle text-red-500 mr-2"></i>
</template>
{{ error }}
</n-alert>
<n-card size="small">
<p class="text-sm text-gray-700 dark:text-gray-300 break-all">{{ url }}</p>
</n-card>
<div class="flex gap-2">
<n-button type="primary" @click="openLink" class="flex-1">
<template #icon>
<i class="fas fa-external-link-alt"></i>
</template>
跳转
</n-button>
<n-button type="success" @click="copyUrl" class="flex-1">
<template #icon>
<i class="fas fa-copy"></i>
</template>
复制
</n-button>
</div>
</div>
<!-- 正常显示 -->
<div v-else>
<!-- 移动端所有链接都显示链接文本和操作按钮 -->
<div v-if="isMobile" class="space-y-4">
<!-- 显示链接状态信息 -->
<n-alert v-if="message" type="info" :show-icon="false">
<template #icon>
<i class="fas fa-info-circle text-blue-500 mr-2"></i>
</template>
{{ message }}
</n-alert>
<n-card size="small">
<p class="text-sm text-gray-700 dark:text-gray-300 break-all">{{ url }}</p>
</n-card>
<div class="flex gap-2">
<n-button type="primary" @click="openLink" class="flex-1">
<template #icon>
<i class="fas fa-external-link-alt"></i>
</template>
跳转
</n-button>
<n-button type="success" @click="copyUrl" class="flex-1">
<template #icon>
<i class="fas fa-copy"></i>
</template>
复制
</n-button>
</div>
</div>
<!-- PC端根据链接类型显示不同内容 -->
<div v-else class="space-y-4">
<!-- 显示链接状态信息 -->
<n-alert v-if="message" type="info" :show-icon="false">
<template #icon>
<i class="fas fa-info-circle text-blue-500 mr-2"></i>
</template>
{{ message }}
</n-alert>
<!-- 夸克链接只显示二维码 -->
<div v-if="isQuarkLink" class="space-y-4">
<div class=" flex justify-center">
<div class="flex qr-container items-center justify-center w-full">
<n-qr-code
:value="save_url || url"
:size="size"
:color="color"
:background-color="backgroundColor"
/>
</div>
</div>
<div class="text-center">
<n-button type="primary" @click="closeModal">
<template #icon>
<i class="fas fa-check"></i>
</template>
确认
</n-button>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">请使用手机扫码操作</p>
</div>
</div>
<!-- 其他链接同时显示链接和二维码 -->
<div v-else class="space-y-4">
<div class="mb-4 flex justify-center">
<div class="flex qr-container items-center justify-center w-full">
<n-qr-code :value="save_url || url"
:size="size"
:color="color"
:background-color="backgroundColor"
/>
</div>
</div>
<div class="mb-4">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">扫描二维码访问链接</p>
<n-card size="small">
<p class="text-xs text-gray-700 dark:text-gray-300 break-all">{{ url }}</p>
</n-card>
</div>
<div class="flex gap-2">
<n-button type="primary" @click="copyUrl" class="flex-1">
<template #icon>
<i class="fas fa-copy"></i>
</template>
复制链接
</n-button>
<n-button type="success" @click="downloadQrCode" class="flex-1">
<template #icon>
<i class="fas fa-download"></i>
</template>
下载二维码
</n-button>
</div>
</div>
</div>
</div>
</div>
</div>
</n-modal>
</template>
<script setup lang="ts">
import QRCode from 'qrcode'
interface Props {
visible: boolean
save_url?: string
@@ -163,12 +156,14 @@ interface Emits {
(e: 'close'): void
}
const props = withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<Props>(), {
url: ''
})
const emit = defineEmits<Emits>()
const qrCanvas = ref<HTMLCanvasElement>()
const size = ref(180)
const color = ref('#409eff')
const backgroundColor = ref('#F5F5F5')
// 检测是否为移动设备
const isMobile = ref(false)
@@ -185,24 +180,6 @@ const isQuarkLink = computed(() => {
return (props.url.includes('pan.quark.cn') || props.url.includes('quark.cn')) && !!props.save_url
})
// 生成二维码
const generateQrCode = async () => {
if (!qrCanvas.value || !props.url) return
try {
await QRCode.toCanvas(qrCanvas.value, props.save_url || props.url, {
width: 200,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
}
})
} catch (error) {
console.error('生成二维码失败:', error)
}
}
// 关闭模态框
const closeModal = () => {
emit('close')
@@ -237,44 +214,31 @@ const openLink = () => {
// 下载二维码
const downloadQrCode = () => {
if (!qrCanvas.value) return
// 使用 Naive UI 的二维码组件,需要获取 DOM 元素
const qrElement = document.querySelector('.n-qr-code canvas') as HTMLCanvasElement
if (!qrElement) return
try {
const link = document.createElement('a')
link.download = 'qrcode.png'
link.href = qrCanvas.value.toDataURL()
link.href = qrElement.toDataURL()
link.click()
} catch (error) {
console.error('下载失败:', error)
}
}
// 监听visible变化,生成二维码
// 监听visible变化
watch(() => props.visible, (newVisible) => {
if (newVisible) {
detectDevice()
nextTick(() => {
// PC端生成二维码包括夸克链接
if (!isMobile.value) {
generateQrCode()
}
})
}
})
// 监听url变化重新生成二维码
watch(() => props.url, () => {
if (props.visible && !isMobile.value) {
nextTick(() => {
generateQrCode()
})
}
})
</script>
<style scoped>
/* 可以添加一些动画效果 */
.fixed {
.n-modal {
animation: fadeIn 0.2s ease-out;
}
@@ -286,4 +250,13 @@ watch(() => props.url, () => {
opacity: 1;
}
}
.qr-container {
height: 200px;
width: 200px;
background-color: #F5F5F5;
}
.n-qr-code {
padding: 0 !important;
}
</style>

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 = () => {
@@ -205,4 +225,34 @@ function log(...args: any[]) {
if (process.env.NODE_ENV !== 'production') {
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.1.0',
version: '1.2.3',
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.1.0",
"version": "1.2.3",
"private": true,
"type": "module",
"scripts": {
@@ -28,12 +28,10 @@
"@juggle/resize-observer": "^3.3.1",
"@nuxtjs/tailwindcss": "^6.8.0",
"@pinia/nuxt": "^0.5.0",
"@types/qrcode": "^1.5.5",
"@vicons/ionicons5": "^0.12.0",
"chart.js": "^4.5.0",
"naive-ui": "^2.37.0",
"pinia": "^2.1.0",
"qrcode": "^1.5.4",
"vfonts": "^0.0.3",
"vue": "^3.3.0",
"vue-router": "^4.2.0"

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>

View File

@@ -24,17 +24,17 @@
<!-- 搜索和筛选 -->
<n-card>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<n-input v-model:value="searchQuery" placeholder="搜索账号..." clearable>
<div class="flex flex-col md:flex-row gap-4">
<n-input v-model:value="searchQuery" placeholder="搜索账号..." clearable class="flex-1">
<template #prefix>
<i class="fas fa-search"></i>
</template>
</n-input>
<n-select v-model:value="platform" placeholder="选择平台" :options="platformOptions" clearable
@update:value="onPlatformChange" />
@update:value="onPlatformChange" class="w-full md:w-48" />
<n-button type="primary" @click="handleSearch">
<n-button type="primary" @click="handleSearch" class="w-full md:w-auto md:min-w-[100px]">
<template #icon>
<i class="fas fa-search"></i>
</template>

View File

@@ -189,12 +189,34 @@
</template>
<script setup lang="ts">
import { useConfigChangeDetection } from '~/composables/useConfigChangeDetection'
// 设置页面布局
definePageMeta({
layout: 'admin',
ssr: false
})
// 定义配置表单类型
interface BotConfigForm {
api_token: string
}
// 使用配置改动检测
const {
setOriginalConfig,
updateCurrentConfig,
getChangedConfig,
hasChanges,
updateOriginalConfig,
saveConfig: saveConfigWithDetection
} = useConfigChangeDetection<BotConfigForm>({
debug: true,
fieldMapping: {
api_token: 'api_token'
}
})
const notification = useNotification()
const activeTab = ref('qq')
@@ -215,8 +237,13 @@ const fetchApiToken = async () => {
const systemConfigApi = useSystemConfigApi()
const response = await systemConfigApi.getSystemConfig()
if (response && (response as any).api_token) {
apiToken.value = (response as any).api_token
if (response) {
const configData = {
api_token: (response as any).api_token || ''
}
apiToken.value = configData.api_token || '未配置API Token'
setOriginalConfig(configData)
} else {
apiToken.value = '未配置API Token'
}

View File

@@ -42,11 +42,13 @@
v-model:value="searchQuery"
@input="debounceSearch"
type="text"
placeholder="搜索分类名称..."
/>
<div class="absolute right-3 top-1/2 transform -translate-y-1/2">
<i class="fas fa-search text-gray-400 text-sm"></i>
</div>
placeholder="搜索分类名称..."
clearable
>
<template #prefix>
<i class="fas fa-search text-gray-400 text-sm"></i>
</template>
</n-input>
</div>
<n-button @click="refreshData" type="tertiary">
<template #icon>

View File

@@ -76,17 +76,39 @@
</template>
<script setup lang="ts">
import { useConfigChangeDetection } from '~/composables/useConfigChangeDetection'
// 设置页面布局
definePageMeta({
layout: 'admin',
ssr: false
})
// 定义配置表单类型
interface DevConfigForm {
api_token: string
}
// 使用配置改动检测
const {
setOriginalConfig,
updateCurrentConfig,
getChangedConfig,
hasChanges,
updateOriginalConfig,
saveConfig: saveConfigWithDetection
} = useConfigChangeDetection<DevConfigForm>({
debug: true,
fieldMapping: {
api_token: 'api_token'
}
})
const notification = useNotification()
const saving = ref(false)
// 配置表单数据
const configForm = ref({
const configForm = ref<DevConfigForm>({
api_token: ''
})
@@ -98,9 +120,12 @@ const fetchConfig = async () => {
const response = await systemConfigApi.getSystemConfig()
if (response) {
configForm.value = {
api_token: response.api_token || ''
const configData = {
api_token: (response as any).api_token || ''
}
configForm.value = { ...configData }
setOriginalConfig(configData)
}
} catch (error) {
console.error('获取系统配置失败:', error)
@@ -116,28 +141,50 @@ const saveConfig = async () => {
try {
saving.value = true
const { useSystemConfigApi } = await import('~/composables/useApi')
const systemConfigApi = useSystemConfigApi()
await systemConfigApi.updateSystemConfig({
// 更新当前配置数据
updateCurrentConfig({
api_token: configForm.value.api_token
})
notification.success({
content: '开发配置保存成功',
duration: 3000
})
const { useSystemConfigApi } = await import('~/composables/useApi')
const systemConfigApi = useSystemConfigApi()
// 刷新系统配置状态,确保顶部导航同步更新
const { useSystemConfigStore } = await import('~/stores/systemConfig')
const systemConfigStore = useSystemConfigStore()
await systemConfigStore.initConfig(true, true) // 强制刷新使用管理员API
} catch (error) {
console.error('保存开发配置失败:', error)
notification.error({
content: '保存开发配置失败',
duration: 3000
})
// 使用通用保存函数
const result = await saveConfigWithDetection(
systemConfigApi.updateSystemConfig,
{
onlyChanged: true,
includeAllFields: true
},
// 成功回调
async () => {
notification.success({
content: '开发配置保存成功',
duration: 3000
})
// 刷新系统配置状态,确保顶部导航同步更新
const { useSystemConfigStore } = await import('~/stores/systemConfig')
const systemConfigStore = useSystemConfigStore()
await systemConfigStore.initConfig(true, true)
},
// 错误回调
(error) => {
console.error('保存开发配置失败:', error)
notification.error({
content: '保存开发配置失败',
duration: 3000
})
}
)
// 如果没有改动,显示提示
if (result && result.message === '没有检测到任何改动') {
notification.info({
content: '没有检测到任何改动',
duration: 3000
})
}
} finally {
saving.value = false
}

View File

@@ -51,12 +51,12 @@
clearable
/>
<n-button type="primary" @click="handleSearch">
<template #icon>
<i class="fas fa-search"></i>
</template>
搜索
</n-button>
<n-button type="primary" @click="handleSearch" class="w-full md:w-auto md:min-w-[100px]">
<template #icon>
<i class="fas fa-search"></i>
</template>
搜索
</n-button>
</div>
</n-card>

View File

@@ -33,28 +33,137 @@
label-width="auto"
require-mark-placement="right-hanging"
>
<div class="space-y-6">
<!-- 自动处理 -->
<div class="space-y-2">
<div class="flex items-center space-x-2">
<label class="text-base font-semibold text-gray-800 dark:text-gray-200">待处理资源自动处理</label>
<span class="text-xs text-gray-500 dark:text-gray-400">开启后系统将自动处理待处理的资源无需手动操作</span>
<div class="space-y-8">
<!-- 自动处理配置组 -->
<div class="space-y-4">
<div class="flex items-center space-x-2 mb-4">
<div class="w-1 h-6 bg-blue-500 rounded-full"></div>
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">自动处理配置</h3>
</div>
<!-- 自动处理 -->
<div class="space-y-2">
<div class="flex items-center space-x-2">
<label class="text-base font-semibold text-gray-800 dark:text-gray-200">待处理资源自动处理</label>
<span class="text-xs text-gray-500 dark:text-gray-400">开启后系统将自动处理待处理的资源无需手动操作</span>
</div>
<n-switch v-model:value="configForm.auto_process_enabled" />
</div>
<!-- 自动处理间隔 -->
<div class="space-y-2">
<div class="flex items-center space-x-2">
<label class="text-base font-semibold text-gray-800 dark:text-gray-200">自动处理间隔 (分钟)</label>
<span class="text-xs text-gray-500 dark:text-gray-400">建议设置 5-60 分钟避免过于频繁的处理</span>
</div>
<n-input
v-model:value="configForm.auto_process_interval"
type="text"
placeholder="30"
:disabled="!configForm.auto_process_enabled"
/>
</div>
<n-switch v-model:value="configForm.auto_process_enabled" />
</div>
<!-- 自动处理间隔 -->
<div class="space-y-2">
<div class="flex items-center space-x-2">
<label class="text-base font-semibold text-gray-800 dark:text-gray-200">自动处理间隔 (分钟)</label>
<span class="text-xs text-gray-500 dark:text-gray-400">建议设置 5-60 分钟避免过于频繁的处理</span>
<!-- Meilisearch搜索优化配置组 -->
<div class="space-y-4">
<div class="flex items-center space-x-2 mb-4">
<div class="w-1 h-6 bg-green-500 rounded-full"></div>
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">搜索优化配置</h3>
</div>
<!-- 启用Meilisearch -->
<div class="space-y-2">
<div class="flex items-center space-x-2">
<label class="text-base font-semibold text-gray-800 dark:text-gray-200">启用Meilisearch搜索优化</label>
<span class="text-xs text-gray-500 dark:text-gray-400">开启后系统将使用Meilisearch提供更快的搜索体验</span>
</div>
<n-switch v-model:value="configForm.meilisearch_enabled" />
</div>
<!-- Meilisearch服务器配置 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4" :class="{ 'opacity-50': !configForm.meilisearch_enabled }">
<!-- 服务器地址 -->
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">服务器地址</label>
<n-input
v-model:value="configForm.meilisearch_host"
placeholder="localhost"
:disabled="!configForm.meilisearch_enabled"
/>
</div>
<!-- 端口 -->
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">端口</label>
<n-input
v-model:value="configForm.meilisearch_port"
placeholder="7700"
:disabled="!configForm.meilisearch_enabled"
/>
</div>
<!-- 主密钥 -->
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">主密钥 (可选)</label>
<n-input
v-model:value="configForm.meilisearch_master_key"
placeholder="留空表示无认证"
type="password"
show-password-on="click"
:disabled="!configForm.meilisearch_enabled"
/>
</div>
<!-- 索引名称 -->
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">索引名称</label>
<n-input
v-model:value="configForm.meilisearch_index_name"
placeholder="resources"
:disabled="!configForm.meilisearch_enabled"
/>
</div>
</div>
<!-- 操作按钮组 -->
<div class="flex items-center space-x-3">
<n-button
type="info"
size="small"
:disabled="!configForm.meilisearch_enabled"
@click="testMeilisearchConnection"
:loading="testingConnection"
>
<template #icon>
<i class="fas fa-plug"></i>
</template>
测试连接
</n-button>
<n-button
type="primary"
size="small"
@click="navigateTo('/admin/meilisearch-management')"
>
<template #icon>
<i class="fas fa-cogs"></i>
</template>
搜索优化管理
</n-button>
<!-- 健康状态和未同步数量显示 -->
<div v-if="meilisearchStatus" class="flex items-center space-x-4 ml-4">
<div class="flex items-center space-x-2">
<div class="w-2 h-2 rounded-full" :class="meilisearchStatus.healthy ? 'bg-green-500' : 'bg-red-500'"></div>
<span class="text-sm text-gray-600 dark:text-gray-400">健康状态: {{ meilisearchStatus.healthy ? '正常' : '异常' }}</span>
</div>
<div class="flex items-center space-x-2">
<i class="fas fa-sync-alt text-purple-500"></i>
<span class="text-sm text-gray-600 dark:text-gray-400">未同步: {{ unsyncedCount || 0 }}</span>
</div>
</div>
</div>
<n-input
v-model:value="configForm.auto_process_interval"
type="text"
placeholder="30"
:disabled="!configForm.auto_process_enabled"
/>
</div>
</div>
</n-form>
@@ -98,9 +207,18 @@
<!-- 广告关键词 -->
<div class="space-y-2">
<div class="flex items-center space-x-2">
<label class="text-base font-semibold text-gray-800 dark:text-gray-200">广告关键词</label>
<span class="text-xs text-gray-500 dark:text-gray-400">设置广告关键词转存时如果文件名包含广告关键词则文件被删除</span>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<label class="text-base font-semibold text-gray-800 dark:text-gray-200">广告关键词</label>
<span class="text-xs text-gray-500 dark:text-gray-400">设置广告关键词转存时如果文件名包含广告关键词则文件被删除</span>
</div>
<a
href="https://raw.githubusercontent.com/ctwj/urldb/refs/heads/main/db/ad.txt"
target="_blank"
class="text-xs text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline"
>
开源广告关键词
</a>
</div>
<n-input
v-model:value="configForm.ad_keywords"
@@ -156,25 +274,82 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useNotification } from 'naive-ui'
import { useConfigChangeDetection } from '~/composables/useConfigChangeDetection'
// 设置页面布局
definePageMeta({
layout: 'admin',
ssr: false
})
// 配置表单数据类型
interface FeatureConfigForm {
auto_process_enabled: boolean
auto_process_interval: string
auto_transfer_enabled: boolean
auto_transfer_min_space: string
ad_keywords: string
auto_insert_ad: string
hot_drama_auto_fetch: boolean
meilisearch_enabled: boolean
meilisearch_host: string
meilisearch_port: string
meilisearch_master_key: string
meilisearch_index_name: string
}
// 使用配置改动检测
const {
setOriginalConfig,
updateCurrentConfig,
getChangedConfig,
hasChanges,
updateOriginalConfig,
saveConfig: saveConfigWithDetection
} = useConfigChangeDetection<FeatureConfigForm>({
debug: true,
// 字段映射:前端字段名 -> 后端字段名
fieldMapping: {
auto_process_enabled: 'auto_process_ready_resources',
auto_process_interval: 'auto_process_interval',
auto_transfer_enabled: 'auto_transfer_enabled',
auto_transfer_min_space: 'auto_transfer_min_space',
ad_keywords: 'ad_keywords',
auto_insert_ad: 'auto_insert_ad',
hot_drama_auto_fetch: 'auto_fetch_hot_drama_enabled',
meilisearch_enabled: 'meilisearch_enabled',
meilisearch_host: 'meilisearch_host',
meilisearch_port: 'meilisearch_port',
meilisearch_master_key: 'meilisearch_master_key',
meilisearch_index_name: 'meilisearch_index_name'
}
})
const notification = useNotification()
const saving = ref(false)
const activeTab = ref('resource')
const testingConnection = ref(false)
// Meilisearch状态
const meilisearchStatus = ref<any>(null)
const unsyncedCount = ref(0)
// 配置表单数据
const configForm = ref({
const configForm = ref<FeatureConfigForm>({
auto_process_enabled: false,
auto_process_interval: '30',
auto_transfer_enabled: false,
auto_transfer_min_space: '500',
ad_keywords: '',
auto_insert_ad: '',
hot_drama_auto_fetch: false
hot_drama_auto_fetch: false,
meilisearch_enabled: false,
meilisearch_host: '',
meilisearch_port: '',
meilisearch_master_key: '',
meilisearch_index_name: ''
})
// 表单验证规则
@@ -188,15 +363,23 @@ const fetchConfig = async () => {
const response = await systemConfigApi.getSystemConfig() as any
if (response) {
configForm.value = {
const configData = {
auto_process_enabled: response.auto_process_ready_resources || false,
auto_process_interval: String(response.auto_process_interval || 30),
auto_transfer_enabled: response.auto_transfer_enabled || false,
auto_transfer_min_space: String(response.auto_transfer_min_space || 500),
ad_keywords: response.ad_keywords || '',
auto_insert_ad: response.auto_insert_ad || '',
hot_drama_auto_fetch: response.auto_fetch_hot_drama_enabled || false
hot_drama_auto_fetch: response.auto_fetch_hot_drama_enabled || false,
meilisearch_enabled: response.meilisearch_enabled || false,
meilisearch_host: response.meilisearch_host || '',
meilisearch_port: String(response.meilisearch_port || 7700),
meilisearch_master_key: response.meilisearch_master_key || '',
meilisearch_index_name: response.meilisearch_index_name || 'resources'
}
configForm.value = { ...configData }
setOriginalConfig(configData)
}
} catch (error) {
console.error('获取系统配置失败:', error)
@@ -212,42 +395,141 @@ const saveConfig = async () => {
try {
saving.value = true
// 更新当前配置数据
updateCurrentConfig({
auto_process_enabled: configForm.value.auto_process_enabled,
auto_process_interval: configForm.value.auto_process_interval,
auto_transfer_enabled: configForm.value.auto_transfer_enabled,
auto_transfer_min_space: configForm.value.auto_transfer_min_space,
ad_keywords: configForm.value.ad_keywords,
auto_insert_ad: configForm.value.auto_insert_ad,
hot_drama_auto_fetch: configForm.value.hot_drama_auto_fetch,
meilisearch_enabled: configForm.value.meilisearch_enabled,
meilisearch_host: configForm.value.meilisearch_host,
meilisearch_port: configForm.value.meilisearch_port,
meilisearch_master_key: configForm.value.meilisearch_master_key,
meilisearch_index_name: configForm.value.meilisearch_index_name
})
const { useSystemConfigApi } = await import('~/composables/useApi')
const systemConfigApi = useSystemConfigApi()
await systemConfigApi.updateSystemConfig({
auto_process_ready_resources: configForm.value.auto_process_enabled,
auto_process_interval: parseInt(configForm.value.auto_process_interval) || 30,
auto_transfer_enabled: configForm.value.auto_transfer_enabled,
auto_transfer_min_space: parseInt(configForm.value.auto_transfer_min_space) || 500,
ad_keywords: configForm.value.ad_keywords,
auto_insert_ad: configForm.value.auto_insert_ad,
auto_fetch_hot_drama_enabled: configForm.value.hot_drama_auto_fetch
})
// 使用通用保存函数
const result = await saveConfigWithDetection(
systemConfigApi.updateSystemConfig,
{
onlyChanged: true,
includeAllFields: true,
// 自定义数据转换
transformSubmitData: (data) => {
// 转换字符串为数字
if (data.auto_process_interval !== undefined) {
data.auto_process_interval = parseInt(data.auto_process_interval) || 30
}
if (data.auto_transfer_min_space !== undefined) {
data.auto_transfer_min_space = parseInt(data.auto_transfer_min_space) || 500
}
return data
}
},
// 成功回调
async () => {
notification.success({
content: '功能配置保存成功',
duration: 3000
})
// 刷新系统配置状态,确保顶部导航同步更新
const { useSystemConfigStore } = await import('~/stores/systemConfig')
const systemConfigStore = useSystemConfigStore()
await systemConfigStore.initConfig(true, true)
},
// 错误回调
(error) => {
console.error('保存功能配置失败:', error)
notification.error({
content: '保存功能配置失败',
duration: 3000
})
}
)
notification.success({
content: '功能配置保存成功',
duration: 3000
})
// 刷新系统配置状态,确保顶部导航同步更新
const { useSystemConfigStore } = await import('~/stores/systemConfig')
const systemConfigStore = useSystemConfigStore()
await systemConfigStore.initConfig(true, true) // 强制刷新使用管理员API
} catch (error) {
console.error('保存功能配置失败:', error)
notification.error({
content: '保存功能配置失败',
duration: 3000
})
// 如果没有改动,显示提示
if (result && result.message === '没有检测到任何改动') {
notification.info({
content: '没有检测到任何改动',
duration: 3000
})
}
} finally {
saving.value = false
}
}
// 测试Meilisearch连接
const testMeilisearchConnection = async () => {
testingConnection.value = true
try {
const { useMeilisearchApi } = await import('~/composables/useApi')
const meilisearchApi = useMeilisearchApi()
await meilisearchApi.testConnection({
host: configForm.value.meilisearch_host,
port: parseInt(configForm.value.meilisearch_port, 10),
masterKey: configForm.value.meilisearch_master_key,
indexName: configForm.value.meilisearch_index_name || 'resources'
})
notification.success({
content: 'Meilisearch连接测试成功',
duration: 3000
})
} catch (error: any) {
console.error('Meilisearch连接测试失败:', error)
notification.error({
content: `Meilisearch连接测试失败: ${error?.message || error}`,
duration: 5000
})
} finally {
testingConnection.value = false
}
}
// 获取Meilisearch状态
const fetchMeilisearchStatus = async () => {
try {
const { useMeilisearchApi } = await import('~/composables/useApi')
const meilisearchApi = useMeilisearchApi()
const status = await meilisearchApi.getStatus()
meilisearchStatus.value = status
} catch (error: any) {
console.error('获取Meilisearch状态失败:', error)
notification.error({
content: `获取Meilisearch状态失败: ${error?.message || error}`,
duration: 5000
})
}
}
// 获取未同步文档数量
const fetchUnsyncedCount = async () => {
try {
const { useMeilisearchApi } = await import('~/composables/useApi')
const meilisearchApi = useMeilisearchApi()
const response = await meilisearchApi.getUnsyncedCount() as any
unsyncedCount.value = response?.count || 0
} catch (error: any) {
console.error('获取未同步文档数量失败:', error)
notification.error({
content: `获取未同步文档数量失败: ${error?.message || error}`,
duration: 5000
})
}
}
// 页面加载时获取配置
onMounted(() => {
fetchConfig()
fetchMeilisearchStatus()
fetchUnsyncedCount()
})

594
web/pages/admin/files.vue Normal file
View File

@@ -0,0 +1,594 @@
<template>
<div class="space-y-6">
<!-- 页面标题 -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">文件管理</h1>
<p class="text-gray-600 dark:text-gray-400">管理系统中的上传文件</p>
</div>
<div class="flex space-x-3">
<n-button type="primary" @click="openUploadModal">
<template #icon>
<i class="fas fa-upload"></i>
</template>
上传文件
</n-button>
<n-button @click="refreshData">
<template #icon>
<i class="fas fa-refresh"></i>
</template>
刷新
</n-button>
</div>
</div>
<!-- 提示信息 -->
<n-alert title="支持图片格式文件最大文件大小5MB" type="info" />
<!-- 搜索和筛选 -->
<n-card>
<div class="flex gap-4">
<n-input
v-model:value="searchKeyword"
placeholder="搜索原始文件名..."
@keyup.enter="handleSearch"
class="flex-1"
clearable
>
<template #prefix>
<i class="fas fa-search"></i>
</template>
</n-input>
<n-button type="primary" @click="handleSearch" class="w-20">
<template #icon>
<i class="fas fa-search"></i>
</template>
搜索
</n-button>
</div>
</n-card>
<!-- 文件列表 -->
<n-card>
<template #header>
<div class="flex items-center justify-between">
<span class="text-lg font-semibold">文件列表</span>
<span class="text-sm text-gray-500"> {{ total }} 个文件</span>
</div>
</template>
<div v-if="loading" class="flex items-center justify-center py-8">
<n-spin size="large" />
</div>
<div v-else-if="fileList.length === 0" class="text-center py-8">
<i class="fas fa-file-upload text-4xl text-gray-400 mb-4"></i>
<p class="text-gray-500">暂无文件数据</p>
<n-button @click="openUploadModal" type="primary" class="mt-4">
<template #icon>
<i class="fas fa-upload"></i>
</template>
上传文件
</n-button>
</div>
<div v-else>
<!-- 图片预览区域 -->
<div class="image-preview-container">
<n-image-group>
<div class="image-grid">
<div
v-for="file in fileList"
:key="file.id"
class="image-item"
:class="{ 'is-image': isImageFile(file) }"
>
<!-- 图片文件显示预览 -->
<div v-if="isImageFile(file)" class="file-item cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg p-3 transition-colors border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600">
<div class="image-preview relative">
<n-image
:src="getImageUrl(file.access_url)"
:alt="file.original_name"
:lazy="false"
object-fit="cover"
class="preview-image rounded"
@error="handleImageError"
@load="handleImageLoad"
/>
<div class="delete-button">
<n-button
size="small"
type="error"
circle
@click="confirmDelete(file)"
>
<template #icon>
<i class="fas fa-trash"></i>
</template>
</n-button>
</div>
</div>
<div class="image-info mt-2">
<div class="file-name text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{{ file.original_name }}
</div>
<div class="file-size text-xs text-gray-500 dark:text-gray-400">
{{ formatFileSize(file.file_size) }}
</div>
</div>
</div>
<!-- 非图片文件显示图标 -->
<div v-else class="file-item cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg p-3 transition-colors border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 relative">
<div class="file-icon">
<i :class="getFileIconClass(file.file_type)"></i>
</div>
<div class="file-info">
<div class="file-name text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{{ file.original_name }}
</div>
<div class="file-size text-xs text-gray-500 dark:text-gray-400">
{{ formatFileSize(file.file_size) }}
</div>
</div>
<div class="delete-button">
<n-button
size="small"
type="error"
circle
@click="confirmDelete(file)"
>
<template #icon>
<i class="fas fa-trash"></i>
</template>
</n-button>
</div>
</div>
</div>
</div>
</n-image-group>
<!-- 分页 -->
<div class="pagination-wrapper">
<n-pagination
v-model:page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-count="Math.ceil(pagination.total / pagination.pageSize)"
:page-sizes="pagination.pageSizes"
show-size-picker
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</div>
</div>
</div>
</n-card>
<!-- 上传模态框 -->
<n-modal v-model:show="showUploadModal" preset="card" title="上传文件" style="width: 800px" @update:show="handleModalClose">
<FileUpload ref="fileUploadRef" :key="uploadModalKey" />
<template #footer>
<n-space justify="end">
<n-button @click="showUploadModal = false">取消</n-button>
<n-button type="primary" @click="handleUploadSuccess">确定</n-button>
</n-space>
</template>
</n-modal>
<!-- 删除确认对话框 -->
<n-modal v-model:show="showDeleteModal" preset="card" title="确认删除" style="width: 400px">
<div class="text-center py-4">
<i class="fas fa-exclamation-triangle text-yellow-500 text-4xl mb-4"></i>
<p class="text-lg font-medium mb-2">确定要删除这个文件吗</p>
<p class="text-gray-600 mb-4">{{ fileToDelete?.original_name }}</p>
<p class="text-sm text-gray-500">此操作不可撤销文件将被永久删除</p>
</div>
<template #footer>
<n-space justify="end">
<n-button @click="showDeleteModal = false">取消</n-button>
<n-button type="error" @click="handleConfirmDelete">确认删除</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, h } from 'vue'
import { useMessage } from 'naive-ui'
import { useFileApi } from '~/composables/useFileApi'
import { useImageUrl } from '~/composables/useImageUrl'
// 设置页面布局
definePageMeta({
layout: 'admin'
})
interface FileItem {
id: number
original_name: string
file_name: string
file_path: string
file_size: number
file_type: string
mime_type: string
access_url: string
user_id: number
user: string
status: string
is_public: boolean
is_deleted: boolean
created_at: string
updated_at: string
}
const message = useMessage()
const fileApi = useFileApi()
const { getImageUrl } = useImageUrl()
// 响应式数据
const loading = ref(false)
const fileList = ref<FileItem[]>([])
const searchKeyword = ref('')
const showUploadModal = ref(false)
const fileUploadRef = ref()
const uploadModalKey = ref(0)
// 删除确认相关
const showDeleteModal = ref(false)
const fileToDelete = ref<FileItem | null>(null)
// 分页
const pagination = ref({
page: 1,
pageSize: 20,
total: 0,
showSizePicker: true,
pageSizes: [10, 20, 50, 100]
})
// 总数
const total = computed(() => pagination.value.total)
// 选项 - 已移除不需要的过滤条件
// 方法
const loadFileList = async () => {
loading.value = true
try {
const params = {
page: pagination.value.page,
page_size: pagination.value.pageSize,
search: searchKeyword.value
}
console.log('发送文件列表请求参数:', params)
const response = await fileApi.getFileList(params)
fileList.value = response.data.files || []
pagination.value.total = response.data.total || 0
console.log('文件列表加载完成:', {
total: pagination.value.total,
files: fileList.value.map(f => ({
id: f.id,
name: f.original_name,
type: f.file_type,
url: f.access_url,
isImage: isImageFile(f)
}))
})
} catch (error) {
console.error('加载文件列表失败:', error)
message.error('加载文件列表失败')
} finally {
loading.value = false
}
}
const handleSearch = () => {
console.log('执行搜索,关键词:', searchKeyword.value)
pagination.value.page = 1
loadFileList()
}
const handlePageChange = (page: number) => {
pagination.value.page = page
loadFileList()
}
const handlePageSizeChange = (pageSize: number) => {
pagination.value.pageSize = pageSize
pagination.value.page = 1
loadFileList()
}
const copyFileUrl = async (file: FileItem) => {
try {
await navigator.clipboard.writeText(file.access_url)
message.success('文件链接已复制到剪贴板')
} catch (error) {
console.error('复制失败:', error)
message.error('复制失败')
}
}
const openFile = (file: FileItem) => {
window.open(file.access_url, '_blank')
}
const toggleFilePublic = async (file: FileItem) => {
try {
await fileApi.updateFile({
id: file.id,
is_public: !file.is_public
})
message.success('文件状态更新成功')
loadFileList()
} catch (error) {
console.error('更新文件状态失败:', error)
message.error('更新文件状态失败')
}
}
const confirmDelete = (file: FileItem) => {
fileToDelete.value = file
showDeleteModal.value = true
}
const handleConfirmDelete = async () => {
if (!fileToDelete.value) return
try {
await fileApi.deleteFiles([fileToDelete.value.id])
message.success('文件删除成功')
showDeleteModal.value = false
fileToDelete.value = null
loadFileList()
} catch (error) {
console.error('删除文件失败:', error)
message.error('删除文件失败')
}
}
const deleteFile = async (file: FileItem) => {
try {
await fileApi.deleteFiles([file.id])
message.success('文件删除成功')
loadFileList()
} catch (error) {
console.error('删除文件失败:', error)
message.error('删除文件失败')
}
}
const refreshData = () => {
loadFileList()
}
const handleUploadSuccess = () => {
// 重置上传组件状态
if (fileUploadRef.value && fileUploadRef.value.resetUpload) {
fileUploadRef.value.resetUpload()
}
showUploadModal.value = false
loadFileList()
message.success('文件上传成功')
}
const openUploadModal = () => {
uploadModalKey.value++ // 强制重新渲染组件
showUploadModal.value = true
}
const handleModalClose = (show: boolean) => {
if (!show) {
// 模态框关闭时重置上传组件状态
if (fileUploadRef.value && fileUploadRef.value.resetUpload) {
fileUploadRef.value.resetUpload()
}
}
}
const getFileIconClass = (fileType: string) => {
const iconMap: Record<string, string> = {
'image': 'fas fa-image text-blue-500',
'jpeg': 'fas fa-image text-blue-500',
'jpg': 'fas fa-image text-blue-500',
'png': 'fas fa-image text-green-500',
'gif': 'fas fa-image text-purple-500',
'webp': 'fas fa-image text-orange-500',
'bmp': 'fas fa-image text-red-500',
'svg': 'fas fa-image text-indigo-500'
}
return iconMap[fileType] || 'fas fa-image text-gray-500'
}
const getFileTypeLabel = (fileType: string) => {
const labelMap: Record<string, string> = {
'jpeg': 'JPEG',
'jpg': 'JPEG',
'png': 'PNG',
'gif': 'GIF',
'webp': 'WebP',
'bmp': 'BMP',
'svg': 'SVG'
}
return labelMap[fileType] || '图片'
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const isImageFile = (file: FileItem) => {
// 后端返回的 file_type 是 "image",所以直接检查这个值
const isImageByType = file.file_type.toLowerCase() === 'image'
// 检查文件名扩展名
const imageExtensions = ['jpeg', 'jpg', 'png', 'gif', 'webp', 'bmp', 'svg']
const fileNameLower = file.original_name.toLowerCase()
const hasImageExtension = imageExtensions.some(ext => fileNameLower.endsWith(`.${ext}`))
// 检查 MIME 类型
const mimeTypeLower = (file.mime_type || '').toLowerCase()
const isImageByMime = mimeTypeLower.startsWith('image/')
// 综合判断
const isImage = isImageByType || hasImageExtension || isImageByMime
console.log('isImageFile 详细检查:', {
fileName: file.original_name,
fileType: file.file_type,
mimeType: file.mime_type,
isImageByType: isImageByType,
hasImageExtension: hasImageExtension,
isImageByMime: isImageByMime,
finalResult: isImage,
accessUrl: file.access_url,
processedUrl: getImageUrl(file.access_url)
})
return isImage
}
const handleImageError = (event: any) => {
console.error('图片加载失败:', event)
}
const handleImageLoad = (event: any) => {
console.log('图片加载成功:', event)
}
// 生命周期
onMounted(() => {
loadFileList()
})
</script>
<style scoped>
/* 文件管理页面样式 */
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
max-height: 400px;
overflow-y: auto;
}
.preview-image {
width: 100%;
height: 120px;
object-fit: cover;
border: 1px solid #f3f4f6;
border-radius: 4px;
}
.delete-button {
position: absolute;
top: 8px;
right: 8px;
opacity: 0;
transition: opacity 0.3s ease;
z-index: 10;
}
.image-preview:hover .delete-button,
.file-item:hover .delete-button {
opacity: 1;
}
.delete-button .n-button {
background: rgba(239, 68, 68, 0.9);
backdrop-filter: blur(4px);
border: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
color: white;
transition: all 0.3s ease;
}
.delete-button .n-button:hover {
background: rgba(239, 68, 68, 1);
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
}
.delete-button .n-button i {
font-size: 14px;
}
.file-name {
font-weight: 500;
font-size: 13px;
color: #333;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-size {
font-size: 11px;
color: #666;
}
.file-icon {
font-size: 48px;
margin-bottom: 12px;
color: #666;
}
.file-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 1rem;
}
/* 滚动条样式 */
.image-preview-container::-webkit-scrollbar {
width: 6px;
}
.image-preview-container::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.image-preview-container::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.image-preview-container::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>

View File

@@ -30,15 +30,26 @@
<!-- 搜索 -->
<n-card>
<n-input
v-model:value="searchQuery"
placeholder="搜索热播剧..."
@keyup.enter="handleSearch"
>
<template #prefix>
<i class="fas fa-search"></i>
</template>
</n-input>
<div class="flex gap-4">
<n-input
v-model:value="searchQuery"
placeholder="搜索热播剧..."
@keyup.enter="handleSearch"
class="flex-1"
clearable
>
<template #prefix>
<i class="fas fa-search"></i>
</template>
</n-input>
<n-button type="primary" @click="handleSearch" class="w-20">
<template #icon>
<i class="fas fa-search"></i>
</template>
搜索
</n-button>
</div>
</n-card>
<!-- 热播剧列表 -->

View File

@@ -0,0 +1,764 @@
<template>
<div class="space-y-6">
<!-- 页面标题 -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">搜索优化管理</h1>
<p class="text-gray-600 dark:text-gray-400">管理 Meilisearch 搜索服务状态和数据同步</p>
</div>
<div class="flex space-x-3">
<n-button @click="refreshStatus" :loading="refreshing" :disabled="syncProgress.is_running">
<template #icon>
<i class="fas fa-refresh"></i>
</template>
刷新状态
</n-button>
<n-button @click="navigateTo('/admin/feature-config')" type="info" :disabled="syncProgress.is_running">
<template #icon>
<i class="fas fa-cog"></i>
</template>
配置设置
</n-button>
</div>
</div>
<!-- 状态卡片 -->
<n-card class="mb-6">
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
<!-- 启用状态 -->
<div class="flex items-center space-x-2 p-2 bg-gray-50 dark:bg-gray-800 rounded">
<i class="fas fa-power-off text-sm" :class="status.enabled ? 'text-green-500' : 'text-red-500'"></i>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">启用状态</p>
<p class="text-sm font-medium" :class="status.enabled ? 'text-green-600' : 'text-red-600'">
{{ status.enabled ? '已启用' : '未启用' }}
</p>
</div>
</div>
<!-- 健康状态 -->
<div class="flex items-center space-x-2 p-2 bg-gray-50 dark:bg-gray-800 rounded">
<i class="fas fa-heartbeat text-sm" :class="status.healthy ? 'text-green-500' : 'text-red-500'"></i>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">健康状态</p>
<p class="text-sm font-medium" :class="status.healthy ? 'text-green-600' : 'text-red-600'">
{{ status.healthy ? '正常' : '异常' }}
</p>
</div>
</div>
<!-- 文档数量 -->
<div class="flex items-center space-x-2 p-2 bg-gray-50 dark:bg-gray-800 rounded">
<i class="fas fa-database text-sm text-blue-500"></i>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">索引文档</p>
<p class="text-sm font-medium text-blue-600">{{ status.documentCount || 0 }}</p>
</div>
</div>
<!-- 最后检查时间 -->
<div class="flex items-center space-x-2 p-2 bg-gray-50 dark:bg-gray-800 rounded">
<i class="fas fa-clock text-sm text-purple-500"></i>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">最后检查</p>
<p class="text-xs font-medium text-purple-600">{{ formatTime(status.lastCheck) }}</p>
</div>
</div>
</div>
<!-- 错误信息 -->
<div v-if="status.lastError" class="mt-3 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded">
<div class="flex items-start space-x-2">
<i class="fas fa-exclamation-triangle text-red-500 mt-0.5 text-sm"></i>
<div>
<p class="text-xs font-medium text-red-800 dark:text-red-200">错误信息</p>
<p class="text-xs text-red-700 dark:text-red-300">{{ status.lastError }}</p>
</div>
</div>
</div>
</n-card>
<!-- 数据同步管理 -->
<n-card class="mb-6">
<div class="space-y-4">
<!-- 标题过滤条件和操作按钮 -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h4 class="text-lg font-semibold">资源列表</h4>
<div class="flex space-x-3">
<n-button
type="primary"
@click="syncAllResources"
:loading="syncing"
:disabled="unsyncedCount === 0 || syncProgress.is_running"
size="small"
>
<template #icon>
<i class="fas fa-upload"></i>
</template>
同步所有资源
</n-button>
<!-- 停止同步按钮已隐藏 -->
<n-button
type="error"
@click="clearIndex"
:loading="clearing"
:disabled="syncProgress.is_running"
size="small"
>
<template #icon>
<i class="fas fa-trash"></i>
</template>
清空索引
</n-button>
<!-- <n-button
type="info"
@click="updateIndexSettings"
:loading="updatingSettings"
:disabled="syncProgress.is_running"
size="small"
>
<template #icon>
<i class="fas fa-cogs"></i>
</template>
更新索引设置
</n-button> -->
</div>
</div>
<!-- 过滤条件 -->
<div class="flex items-center space-x-4">
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-600 dark:text-gray-400">同步状态:</span>
<n-select
v-model:value="syncFilter"
:options="syncFilterOptions"
size="small"
style="width: 120px"
:disabled="syncProgress.is_running"
@update:value="onSyncFilterChange"
/>
</div>
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-600 dark:text-gray-400">总计: {{ totalCount }} </span>
</div>
</div>
</div>
<!-- 同步进度显示 -->
<div v-if="syncProgress.is_running" class="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div class="flex items-center justify-between mb-2">
<h5 class="text-sm font-medium text-blue-800 dark:text-blue-200">同步进度</h5>
<span class="text-xs text-blue-600 dark:text-blue-300">
批次 {{ syncProgress.current_batch }}/{{ syncProgress.total_batches }}
</span>
</div>
<!-- 进度条 -->
<div class="w-full bg-blue-200 dark:bg-blue-800 rounded-full h-2 mb-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
:style="{ width: progressPercentage + '%' }"
></div>
</div>
<!-- 进度信息 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-xs">
<div>
<span class="text-blue-600 dark:text-blue-300">已同步:</span>
<span class="font-medium">{{ syncProgress.synced_count }}/{{ syncProgress.total_count }}</span>
</div>
<div>
<span class="text-blue-600 dark:text-blue-300">进度:</span>
<span class="font-medium">{{ progressPercentage.toFixed(1) }}%</span>
</div>
<div>
<span class="text-blue-600 dark:text-blue-300">预估剩余:</span>
<span class="font-medium">{{ syncProgress.estimated_time || '计算中...' }}</span>
</div>
<div>
<span class="text-blue-600 dark:text-blue-300">开始时间:</span>
<span class="font-medium">{{ formatTime(syncProgress.start_time) }}</span>
</div>
</div>
<!-- 错误信息 -->
<div v-if="syncProgress.error_message" class="mt-2 p-2 bg-red-100 dark:bg-red-900/20 rounded text-xs text-red-700 dark:text-red-300">
<i class="fas fa-exclamation-triangle mr-1"></i>
{{ syncProgress.error_message }}
</div>
</div>
<!-- 资源列表 -->
<div v-if="resources.length > 0">
<n-data-table
:columns="columns"
:data="resources"
:pagination="pagination"
:max-height="400"
virtual-scroll
:loading="loadingResources"
/>
</div>
<div v-else-if="!loadingResources" class="text-center py-8 text-gray-500">
暂无资源数据
</div>
</div>
</n-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useNotification, useDialog } from 'naive-ui'
// 设置页面布局
definePageMeta({
layout: 'admin',
ssr: false
})
const notification = useNotification()
const dialog = useDialog()
// 状态数据
const status = ref({
enabled: false,
healthy: false,
documentCount: 0,
lastCheck: null as Date | null,
lastError: '',
errorCount: 0
})
const systemConfig = ref({
meilisearch_host: '',
meilisearch_port: '',
meilisearch_master_key: '',
meilisearch_index_name: ''
})
// 定义资源类型
interface Resource {
id: number
title: string
category?: {
name: string
}
synced_to_meilisearch: boolean
synced_at?: string
created_at: string
}
// 同步状态过滤选项
const syncFilterOptions = [
{ label: '全部', value: 'all' },
{ label: '已同步', value: 'synced' },
{ label: '未同步', value: 'unsynced' }
]
const syncFilter = ref('unsynced') // 默认显示未同步
const totalCount = ref(0)
const resources = ref<Resource[]>([])
const unsyncedCount = ref(0)
// 加载状态
const refreshing = ref(false)
const syncing = ref(false)
const clearing = ref(false)
const updatingSettings = ref(false)
const loadingResources = ref(false)
const stopping = ref(false)
// 同步进度
const syncProgress = ref({
is_running: false,
total_count: 0,
processed_count: 0,
synced_count: 0,
failed_count: 0,
start_time: null as Date | null,
estimated_time: '',
current_batch: 0,
total_batches: 0,
error_message: ''
})
// 计算进度百分比
const progressPercentage = computed(() => {
if (syncProgress.value.total_count === 0) return 0
return (syncProgress.value.synced_count / syncProgress.value.total_count) * 100
})
// 分页配置
const pagination = ref({
page: 1,
pageSize: 1000,
itemCount: 0,
showSizePicker: true,
pageSizes: [500, 1000, 2000],
onChange: (page: number) => {
pagination.value.page = page
fetchResources()
},
onUpdatePageSize: (pageSize: number) => {
pagination.value.pageSize = pageSize
pagination.value.page = 1
fetchResources()
}
})
// 表格列配置
const columns = [
{
title: 'ID',
key: 'id',
width: 80
},
{
title: '标题',
key: 'title',
ellipsis: {
tooltip: true
}
},
{
title: '分类',
key: 'category',
width: 120,
render: (row: Resource) => {
return row.category?.name || '-'
}
},
{
title: '同步状态',
key: 'synced_to_meilisearch',
width: 100,
render: (row: Resource) => {
return row.synced_to_meilisearch ? '已同步' : '未同步'
}
},
{
title: '同步时间',
key: 'synced_at',
width: 180,
render: (row: Resource) => {
return row.synced_at ? formatTime(row.synced_at) : '-'
}
},
{
title: '创建时间',
key: 'created_at',
width: 180,
render: (row: Resource) => {
return formatTime(row.created_at)
}
}
]
// 格式化时间
const formatTime = (time: Date | string | null) => {
if (!time) return '未知'
const date = new Date(time)
return date.toLocaleString('zh-CN')
}
// 获取状态
const fetchStatus = async () => {
try {
const { useMeilisearchApi } = await import('~/composables/useApi')
const meilisearchApi = useMeilisearchApi()
const response = await meilisearchApi.getStatus() as any
if (response) {
status.value = {
enabled: response.enabled || false,
healthy: response.healthy || false,
documentCount: response.document_count || response.documentCount || 0,
lastCheck: response.last_check ? new Date(response.last_check) : response.lastCheck ? new Date(response.lastCheck) : null,
lastError: response.last_error || response.lastError || '',
errorCount: response.error_count || response.errorCount || 0
}
}
} catch (error: any) {
console.error('获取状态失败:', error)
notification.error({
content: `获取状态失败: ${error?.message || error}`,
duration: 3000
})
}
}
// 获取系统配置
const fetchSystemConfig = async () => {
try {
const { useSystemConfigApi } = await import('~/composables/useApi')
const systemConfigApi = useSystemConfigApi()
const response = await systemConfigApi.getSystemConfig() as any
if (response) {
systemConfig.value = {
meilisearch_host: response.meilisearch_host || '',
meilisearch_port: response.meilisearch_port || '',
meilisearch_master_key: response.meilisearch_master_key || '',
meilisearch_index_name: response.meilisearch_index_name || ''
}
}
} catch (error: any) {
console.error('获取系统配置失败:', error)
}
}
// 获取未同步数量
const fetchUnsyncedCount = async () => {
try {
const { useMeilisearchApi } = await import('~/composables/useApi')
const meilisearchApi = useMeilisearchApi()
const response = await meilisearchApi.getUnsyncedCount() as any
if (response) {
unsyncedCount.value = response.count || 0
}
} catch (error: any) {
console.error('获取未同步数量失败:', error)
}
}
// 刷新状态
const refreshStatus = async () => {
refreshing.value = true
try {
await Promise.all([
fetchStatus(),
fetchSystemConfig(),
fetchUnsyncedCount()
])
notification.success({
content: '状态刷新成功',
duration: 2000
})
} catch (error: any) {
console.error('刷新状态失败:', error)
} finally {
refreshing.value = false
}
}
// 同步所有资源
const syncAllResources = async () => {
syncing.value = true
try {
const { useMeilisearchApi } = await import('~/composables/useApi')
const meilisearchApi = useMeilisearchApi()
await meilisearchApi.syncAllResources()
notification.success({
content: '同步已开始,请查看进度',
duration: 3000
})
// 开始轮询进度
startProgressPolling()
} catch (error: any) {
console.error('同步资源失败:', error)
notification.error({
content: `同步资源失败: ${error?.message || error}`,
duration: 5000
})
} finally {
syncing.value = false
}
}
// 停止同步
const stopSync = async () => {
stopping.value = true
try {
const { useMeilisearchApi } = await import('~/composables/useApi')
const meilisearchApi = useMeilisearchApi()
await meilisearchApi.stopSync()
notification.success({
content: '同步已停止',
duration: 3000
})
// 立即更新进度状态为已停止
syncProgress.value.is_running = false
syncProgress.value.error_message = '同步已停止'
// 停止轮询
stopProgressPolling()
// 刷新状态
await refreshStatus()
} catch (error: any) {
console.error('停止同步失败:', error)
notification.error({
content: `停止同步失败: ${error?.message || error}`,
duration: 5000
})
} finally {
stopping.value = false
}
}
// 进度轮询
let progressInterval: NodeJS.Timeout | null = null
const startProgressPolling = () => {
// 清除之前的轮询
if (progressInterval) {
clearInterval(progressInterval)
}
// 立即获取一次进度
fetchSyncProgress()
// 每2秒轮询一次
progressInterval = setInterval(() => {
fetchSyncProgress()
}, 2000)
}
const stopProgressPolling = () => {
if (progressInterval) {
clearInterval(progressInterval)
progressInterval = null
}
}
const fetchSyncProgress = async () => {
try {
const { useMeilisearchApi } = await import('~/composables/useApi')
const meilisearchApi = useMeilisearchApi()
const progress = await meilisearchApi.getSyncProgress() as any
if (progress) {
syncProgress.value = {
is_running: progress.is_running || false,
total_count: progress.total_count || 0,
processed_count: progress.processed_count || 0,
synced_count: progress.synced_count || 0,
failed_count: progress.failed_count || 0,
start_time: progress.start_time ? new Date(progress.start_time) : null,
estimated_time: progress.estimated_time || '',
current_batch: progress.current_batch || 0,
total_batches: progress.total_batches || 0,
error_message: progress.error_message || ''
}
// 如果同步完成或出错,停止轮询
if (!progress.is_running) {
stopProgressPolling()
// 只有在有同步进度时才显示完成消息
if (progress.synced_count > 0 || progress.error_message) {
if (progress.error_message) {
notification.error({
content: `同步失败: ${progress.error_message}`,
duration: 5000
})
} else {
notification.success({
content: `同步完成,共同步 ${progress.synced_count} 个资源`,
duration: 3000
})
}
}
// 刷新状态和表格
await Promise.all([
refreshStatus(),
fetchResources()
])
}
}
} catch (error: any) {
console.error('获取同步进度失败:', error)
}
}
// 静默获取同步进度,不显示任何提示
const fetchSyncProgressSilent = async () => {
try {
const { useMeilisearchApi } = await import('~/composables/useApi')
const meilisearchApi = useMeilisearchApi()
const progress = await meilisearchApi.getSyncProgress() as any
if (progress) {
syncProgress.value = {
is_running: progress.is_running || false,
total_count: progress.total_count || 0,
processed_count: progress.processed_count || 0,
synced_count: progress.synced_count || 0,
failed_count: progress.failed_count || 0,
start_time: progress.start_time ? new Date(progress.start_time) : null,
estimated_time: progress.estimated_time || '',
current_batch: progress.current_batch || 0,
total_batches: progress.total_batches || 0,
error_message: progress.error_message || ''
}
// 如果同步完成或出错,停止轮询
if (!progress.is_running) {
stopProgressPolling()
// 静默刷新状态和表格,不显示任何提示
await Promise.all([
refreshStatus(),
fetchResources()
])
}
}
} catch (error: any) {
console.error('获取同步进度失败:', error)
}
}
// 同步状态过滤变化处理
const onSyncFilterChange = () => {
pagination.value.page = 1
fetchResources()
}
// 获取资源列表
const fetchResources = async () => {
loadingResources.value = true
try {
const { useMeilisearchApi } = await import('~/composables/useApi')
const meilisearchApi = useMeilisearchApi()
let response: any
if (syncFilter.value === 'unsynced') {
// 获取未同步资源
response = await meilisearchApi.getUnsyncedResources({
page: pagination.value.page,
page_size: pagination.value.pageSize
})
} else if (syncFilter.value === 'synced') {
// 获取已同步资源
response = await meilisearchApi.getSyncedResources({
page: pagination.value.page,
page_size: pagination.value.pageSize
})
} else {
// 获取所有资源
response = await meilisearchApi.getAllResources({
page: pagination.value.page,
page_size: pagination.value.pageSize
})
}
if (response && response.resources) {
resources.value = response.resources
totalCount.value = response.total || 0
// 更新分页信息
if (response.total !== undefined) {
pagination.value.itemCount = response.total
}
}
} catch (error: any) {
console.error('获取资源失败:', error)
notification.error({
content: `获取资源失败: ${error?.message || error}`,
duration: 3000
})
} finally {
loadingResources.value = false
}
}
// 获取未同步资源(保留兼容性)
const fetchUnsyncedResources = async () => {
syncFilter.value = 'unsynced'
await fetchResources()
}
// 清空索引
const clearIndex = async () => {
try {
await new Promise((resolve, reject) => {
dialog.error({
title: '确认清空索引',
content: '此操作将清空所有 Meilisearch 索引数据,确定要继续吗?',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: resolve,
onNegativeClick: reject
})
})
clearing.value = true
const { useMeilisearchApi } = await import('~/composables/useApi')
const meilisearchApi = useMeilisearchApi()
await meilisearchApi.clearIndex()
notification.success({
content: '索引清空成功',
duration: 3000
})
// 刷新状态
await refreshStatus()
} catch (error: any) {
if (error) {
console.error('清空索引失败:', error)
notification.error({
content: `清空索引失败: ${error?.message || error}`,
duration: 5000
})
}
} finally {
clearing.value = false
}
}
// 更新索引设置
const updateIndexSettings = async () => {
updatingSettings.value = true
try {
const { useMeilisearchApi } = await import('~/composables/useApi')
const meilisearchApi = useMeilisearchApi()
await meilisearchApi.updateIndexSettings()
notification.success({
content: '索引设置已更新',
duration: 3000
})
// 刷新状态
await refreshStatus()
} catch (error: any) {
console.error('更新索引设置失败:', error)
notification.error({
content: `更新索引设置失败: ${error?.message || error}`,
duration: 5000
})
} finally {
updatingSettings.value = false
}
}
// 页面加载时获取数据
onMounted(() => {
refreshStatus()
fetchResources()
// 静默检查同步进度,不显示任何提示
fetchSyncProgressSilent().then(() => {
// 如果检测到有同步在进行,开始轮询
if (syncProgress.value.is_running) {
startProgressPolling()
}
})
})
// 页面卸载时清理轮询
onUnmounted(() => {
stopProgressPolling()
})
</script>
<style scoped>
/* 自定义样式 */
</style>

View File

@@ -24,15 +24,26 @@
<!-- 搜索 -->
<n-card>
<n-input
v-model:value="searchQuery"
placeholder="搜索平台..."
@keyup.enter="handleSearch"
>
<template #prefix>
<i class="fas fa-search"></i>
</template>
</n-input>
<div class="flex gap-4">
<n-input
v-model:value="searchQuery"
placeholder="搜索平台..."
@keyup.enter="handleSearch"
class="flex-1"
clearable
>
<template #prefix>
<i class="fas fa-search"></i>
</template>
</n-input>
<n-button type="primary" @click="handleSearch" class="w-20">
<template #icon>
<i class="fas fa-search"></i>
</template>
搜索
</n-button>
</div>
</n-card>
<!-- 平台列表 -->

View File

@@ -35,6 +35,7 @@
v-model:value="searchQuery"
placeholder="搜索资源..."
@keyup.enter="handleSearch"
clearable
>
<template #prefix>
<i class="fas fa-search"></i>
@@ -55,7 +56,7 @@
clearable
/>
<n-button type="primary" @click="handleSearch">
<n-button type="primary" @click="handleSearch" class="w-20">
<template #icon>
<i class="fas fa-search"></i>
</template>
@@ -451,8 +452,15 @@ const fetchData = async () => {
console.log('返回的资源数量:', response?.data?.length || 0)
if (response && response.data) {
resources.value = response.data
total.value = response.total || 0
// 处理嵌套的data结构{data: {data: [...], total: ...}}
if (response.data.data && Array.isArray(response.data.data)) {
resources.value = response.data.data
total.value = response.data.total || 0
} else {
// 处理直接的data结构{data: [...], total: ...}
resources.value = response.data
total.value = response.total || 0
}
// 清空选择(因为数据已更新)
selectedResources.value = []
} else {

View File

@@ -65,51 +65,60 @@
</div>
</n-card>
<!-- 热门关键词 -->
<n-card>
<template #header>
<span class="text-xl font-semibold text-gray-900 dark:text-white">热门关键词</span>
</template>
<div class="space-y-4">
<div v-for="keyword in stats.hotKeywords" :key="keyword.keyword"
class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="flex items-center">
<span class="inline-flex items-center justify-center w-8 h-8 bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400 rounded-full text-sm font-medium mr-3">
{{ keyword.rank }}
</span>
<span class="text-gray-900 dark:text-white font-medium">{{ keyword.keyword }}</span>
</div>
<div class="flex items-center">
<span class="text-gray-600 dark:text-gray-400 mr-2">{{ keyword.count }}</span>
<div class="w-24 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div class="bg-blue-600 h-2 rounded-full"
:style="{ width: getPercentage(keyword.count) + '%' }"></div>
<!-- 热门关键词和搜索记录并排显示 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 热门关键词 -->
<n-card>
<template #header>
<span class="text-xl font-semibold text-gray-900 dark:text-white">热门关键词</span>
</template>
<div class="space-y-4">
<div v-for="keyword in limitedHotKeywords" :key="keyword.keyword"
class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="flex items-center">
<span class="inline-flex items-center justify-center w-8 h-8 bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400 rounded-full text-sm font-medium mr-3">
{{ keyword.rank }}
</span>
<span class="text-gray-900 dark:text-white font-medium">{{ keyword.keyword }}</span>
</div>
<div class="flex items-center">
<span class="text-gray-600 dark:text-gray-400 mr-2">{{ keyword.count }}</span>
<div class="w-24 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div class="bg-blue-600 h-2 rounded-full"
:style="{ width: getPercentage(keyword.count) + '%' }"></div>
</div>
</div>
</div>
<div v-if="!stats.hotKeywords || stats.hotKeywords.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
暂无热门关键词数据
</div>
</div>
<div v-if="!stats.hotKeywords || stats.hotKeywords.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
暂无热门关键词数据
</div>
</div>
</n-card>
</n-card>
<!-- 搜索记录 -->
<n-card>
<template #header>
<span class="text-xl font-semibold text-gray-900 dark:text-white">搜索记录</span>
</template>
<n-data-table
:columns="columns"
:data="searchList"
:pagination="pagination"
:loading="loading"
:bordered="false"
striped
/>
<div v-if="searchList.length === 0 && !loading" class="text-center py-8 text-gray-500 dark:text-gray-400">
暂无搜索记录
</div>
</n-card>
<!-- 搜索记录 -->
<n-card>
<template #header>
<span class="text-xl font-semibold text-gray-900 dark:text-white">搜索记录</span>
</template>
<div class="space-y-3">
<div v-for="record in limitedSearchList" :key="record.id"
class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="flex-1">
<div class="font-medium text-gray-900 dark:text-white">{{ record.keyword }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ formatDate(record.created_at) }}
</div>
</div>
<div class="text-right">
<div class="text-sm font-medium text-gray-900 dark:text-white">{{ record.count }}</div>
</div>
</div>
<div v-if="searchList.length === 0 && !loading" class="text-center py-8 text-gray-500 dark:text-gray-400">
暂无搜索记录
</div>
</div>
</n-card>
</div>
</div>
</template>
@@ -160,44 +169,35 @@ const loading = ref(false)
const trendChart = ref<HTMLCanvasElement | null>(null)
let chart: any = null
// 分页配置
const pagination = ref({
page: 1,
pageSize: 20,
showSizePicker: true,
pageSizes: [10, 20, 50, 100],
onChange: (page: number) => {
pagination.value.page = page
loadSearchRecords()
},
onUpdatePageSize: (pageSize: number) => {
pagination.value.pageSize = pageSize
pagination.value.page = 1
loadSearchRecords()
}
// 按时间排序的搜索记录(最新的在前面)
const sortedSearchList = computed(() => {
return [...searchList.value].sort((a, b) => {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
})
// 表格列配置
const columns = [
{
title: '关键词',
key: 'keyword',
width: 200
},
{
title: '搜索次数',
key: 'count',
width: 120
},
{
title: '日期',
key: 'date',
width: 150,
render: (row: any) => {
return row.date ? new Date(row.date).toLocaleDateString() : ''
}
}
]
// 限制显示前10条热门关键词
const limitedHotKeywords = computed(() => {
return stats.value.hotKeywords.slice(0, 10)
})
// 限制显示前10条搜索记录
const limitedSearchList = computed(() => {
return sortedSearchList.value.slice(0, 10)
})
// 格式化日期
const formatDate = (dateString: string) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 获取百分比
const getPercentage = (count: number) => {

View File

@@ -10,7 +10,7 @@
<n-tab-pane name="site-submit" tab="站点提交">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div class="mb-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">站点提交</h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">站点提交待开发</h3>
<p class="text-gray-600 dark:text-gray-400">向各大搜索引擎提交站点信息</p>
</div>
@@ -186,7 +186,7 @@
<n-tab-pane name="link-building" tab="外链建设">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div class="mb-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">外链建设</h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">外链建设待开发</h3>
<p class="text-gray-600 dark:text-gray-400">管理和监控外部链接建设情况</p>
</div>

View File

@@ -70,6 +70,40 @@
/>
</div>
<!-- 网站Logo -->
<div class="space-y-2">
<div class="flex items-center space-x-2">
<label class="text-base font-semibold text-gray-800 dark:text-gray-200">网站Logo</label>
<span class="text-xs text-gray-500 dark:text-gray-400">选择网站Logo图片建议使用正方形图片</span>
</div>
<div class="flex items-center space-x-4">
<div v-if="configForm.site_logo" class="flex-shrink-0">
<n-image
:src="getImageUrl(configForm.site_logo)"
alt="网站Logo"
width="80"
height="80"
object-fit="cover"
class="rounded-lg border"
/>
</div>
<div class="flex-1">
<n-button type="primary" @click="openLogoSelector">
<template #icon>
<i class="fas fa-image"></i>
</template>
{{ configForm.site_logo ? '更换Logo' : '选择Logo' }}
</n-button>
<n-button v-if="configForm.site_logo" @click="clearLogo" class="ml-2">
<template #icon>
<i class="fas fa-times"></i>
</template>
清除
</n-button>
</div>
</div>
</div>
<!-- 版权信息 -->
<div class="space-y-2">
<div class="flex items-center space-x-2">
@@ -109,9 +143,18 @@
<!-- 违禁词 -->
<div class="space-y-2">
<div class="flex items-center space-x-2">
<label class="text-base font-semibold text-gray-800 dark:text-gray-200">违禁词</label>
<span class="text-xs text-gray-500 dark:text-gray-400">包含这些词汇的资源将被过滤多个词汇用逗号分隔</span>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<label class="text-base font-semibold text-gray-800 dark:text-gray-200">违禁词</label>
<span class="text-xs text-gray-500 dark:text-gray-400">包含这些词汇的资源将被过滤多个词汇用逗号分隔</span>
</div>
<a
href="https://raw.githubusercontent.com/ctwj/urldb/refs/heads/main/db/forbidden.txt"
target="_blank"
class="text-xs text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline"
>
开源违禁词
</a>
</div>
<n-input
v-model:value="configForm.forbidden_words"
@@ -134,6 +177,100 @@
</n-tab-pane>
</n-tabs>
</n-card>
<!-- Logo选择模态框 -->
<n-modal v-model:show="showLogoSelector" preset="card" title="选择Logo图片" style="width: 90vw; max-width: 1200px; max-height: 80vh;">
<div class="space-y-4">
<!-- 搜索 -->
<div class="flex gap-4">
<n-input
v-model:value="searchKeyword"
placeholder="搜索文件名..."
@keyup.enter="handleSearch"
class="flex-1"
clearable
>
<template #prefix>
<i class="fas fa-search"></i>
</template>
</n-input>
<n-button type="primary" @click="handleSearch" class="w-20">
<template #icon>
<i class="fas fa-search"></i>
</template>
搜索
</n-button>
</div>
<!-- 文件列表 -->
<div v-if="loading" class="flex items-center justify-center py-8">
<n-spin size="large" />
</div>
<div v-else-if="fileList.length === 0" class="text-center py-8">
<i class="fas fa-file-upload text-4xl text-gray-400 mb-4"></i>
<p class="text-gray-500">暂无图片文件</p>
</div>
<div v-else class="file-grid">
<div
v-for="file in fileList"
:key="file.id"
class="file-item cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg p-3 transition-colors"
:class="{ 'bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-300 dark:border-blue-600': selectedFileId === file.id }"
@click="selectFile(file)"
>
<div class="image-preview">
<n-image
:src="getImageUrl(file.access_url)"
:alt="file.original_name"
:lazy="false"
object-fit="cover"
class="preview-image rounded"
@error="handleImageError"
@load="handleImageLoad"
/>
<div class="image-info mt-2">
<div class="file-name text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{{ file.original_name }}
</div>
<div class="file-size text-xs text-gray-500 dark:text-gray-400">
{{ formatFileSize(file.file_size) }}
</div>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination-wrapper">
<n-pagination
v-model:page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-count="Math.ceil(pagination.total / pagination.pageSize)"
:page-sizes="pagination.pageSizes"
show-size-picker
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</div>
</div>
<template #footer>
<n-space justify="end">
<n-button @click="showLogoSelector = false">取消</n-button>
<n-button
type="primary"
@click="confirmSelection"
:disabled="!selectedFileId"
>
确认选择
</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
@@ -144,29 +281,80 @@ definePageMeta({
ssr: false
})
import { useImageUrl } from '~/composables/useImageUrl'
import { useConfigChangeDetection } from '~/composables/useConfigChangeDetection'
const notification = useNotification()
const { getImageUrl } = useImageUrl()
const formRef = ref()
const saving = ref(false)
const activeTab = ref('basic')
// 配置表单数据
const configForm = ref<{
// Logo选择器相关数据
const showLogoSelector = ref(false)
const loading = ref(false)
const fileList = ref<any[]>([])
const selectedFileId = ref<number | null>(null)
const searchKeyword = ref('')
// 分页
const pagination = ref({
page: 1,
pageSize: 20,
total: 0,
pageSizes: [10, 20, 50, 100]
})
// 配置表单数据类型
interface SiteConfigForm {
site_title: string
site_description: string
keywords: string
copyright: string
site_logo: string
maintenance_mode: boolean
enable_register: boolean
forbidden_words: string
enable_sitemap: boolean
sitemap_update_frequency: string
}>({
}
// 使用配置改动检测
const {
setOriginalConfig,
updateCurrentConfig,
getChangedConfig,
hasChanges,
getChangedDetails,
updateOriginalConfig,
saveConfig: saveConfigWithDetection
} = useConfigChangeDetection<SiteConfigForm>({
debug: true,
// 字段映射:前端字段名 -> 后端字段名
fieldMapping: {
site_title: 'site_title',
site_description: 'site_description',
keywords: 'keywords',
copyright: 'copyright',
site_logo: 'site_logo',
maintenance_mode: 'maintenance_mode',
enable_register: 'enable_register',
forbidden_words: 'forbidden_words',
enable_sitemap: 'enable_sitemap',
sitemap_update_frequency: 'sitemap_update_frequency'
}
})
// 配置表单数据
const configForm = ref<SiteConfigForm>({
site_title: '',
site_description: '',
keywords: '',
copyright: '',
site_logo: '',
maintenance_mode: false,
enable_register: false, // 新增:开启注册开关
enable_register: false,
forbidden_words: '',
enable_sitemap: false,
sitemap_update_frequency: 'daily'
@@ -196,17 +384,22 @@ const fetchConfig = async () => {
const response = await systemConfigApi.getSystemConfig() as any
if (response) {
configForm.value = {
const configData = {
site_title: response.site_title || '',
site_description: response.site_description || '',
keywords: response.keywords || '',
copyright: response.copyright || '',
site_logo: response.site_logo || '',
maintenance_mode: response.maintenance_mode || false,
enable_register: response.enable_register || false, // 新增:获取开启注册开关
enable_register: response.enable_register || false,
forbidden_words: response.forbidden_words || '',
enable_sitemap: response.enable_sitemap || false,
sitemap_update_frequency: response.sitemap_update_frequency || 'daily'
}
// 设置表单数据和原始数据
configForm.value = { ...configData }
setOriginalConfig(configData)
}
} catch (error) {
console.error('获取系统配置失败:', error)
@@ -217,47 +410,171 @@ const fetchConfig = async () => {
}
}
// 保存配置
const saveConfig = async () => {
try {
await formRef.value?.validate()
saving.value = true
const { useSystemConfigApi } = await import('~/composables/useApi')
const systemConfigApi = useSystemConfigApi()
await systemConfigApi.updateSystemConfig({
// 更新当前配置数据
updateCurrentConfig({
site_title: configForm.value.site_title,
site_description: configForm.value.site_description,
keywords: configForm.value.keywords,
copyright: configForm.value.copyright,
site_logo: configForm.value.site_logo,
maintenance_mode: configForm.value.maintenance_mode,
enable_register: configForm.value.enable_register, // 新增:保存开启注册开关
enable_register: configForm.value.enable_register,
forbidden_words: configForm.value.forbidden_words,
enable_sitemap: configForm.value.enable_sitemap,
sitemap_update_frequency: configForm.value.sitemap_update_frequency
})
notification.success({
content: '站点配置保存成功',
duration: 3000
})
const { useSystemConfigApi } = await import('~/composables/useApi')
const systemConfigApi = useSystemConfigApi()
// 刷新系统配置状态,确保顶部导航同步更新
const { useSystemConfigStore } = await import('~/stores/systemConfig')
const systemConfigStore = useSystemConfigStore()
await systemConfigStore.initConfig(true, true) // 强制刷新使用管理员API
} catch (error) {
console.error('保存站点配置失败:', error)
notification.error({
content: '保存站点配置失败',
duration: 3000
})
// 使用通用保存函数
const result = await saveConfigWithDetection(
systemConfigApi.updateSystemConfig,
{
onlyChanged: true,
includeAllFields: true
},
// 成功回调
async () => {
notification.success({
content: '站点配置保存成功',
duration: 3000
})
// 刷新系统配置状态,确保顶部导航同步更新
const { useSystemConfigStore } = await import('~/stores/systemConfig')
const systemConfigStore = useSystemConfigStore()
await systemConfigStore.initConfig(true, true)
},
// 错误回调
(error) => {
console.error('保存站点配置失败:', error)
notification.error({
content: '保存站点配置失败',
duration: 3000
})
}
)
// 如果没有改动,显示提示
if (result && result.message === '没有检测到任何改动') {
notification.info({
content: '没有检测到任何改动',
duration: 3000
})
}
} finally {
saving.value = false
}
}
// Logo选择器方法
const openLogoSelector = () => {
showLogoSelector.value = true
loadFileList()
}
const clearLogo = () => {
configForm.value.site_logo = ''
}
const loadFileList = async () => {
try {
loading.value = true
const { useFileApi } = await import('~/composables/useFileApi')
const fileApi = useFileApi()
const response = await fileApi.getFileList({
page: pagination.value.page,
pageSize: pagination.value.pageSize,
search: searchKeyword.value,
fileType: 'image', // 只获取图片文件
status: 'active' // 只获取正常状态的文件
}) as any
if (response && response.data) {
fileList.value = response.data.files || []
pagination.value.total = response.data.total || 0
console.log('获取到的图片文件:', fileList.value) // 调试信息
// 添加图片URL处理调试
fileList.value.forEach(file => {
console.log('图片文件详情:', {
id: file.id,
name: file.original_name,
accessUrl: file.access_url,
processedUrl: getImageUrl(file.access_url),
fileType: file.file_type,
mimeType: file.mime_type
})
})
}
} catch (error) {
console.error('获取文件列表失败:', error)
notification.error({
content: '获取文件列表失败',
duration: 3000
})
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.value.page = 1
loadFileList()
}
const handlePageChange = (page: number) => {
pagination.value.page = page
loadFileList()
}
const handlePageSizeChange = (pageSize: number) => {
pagination.value.pageSize = pageSize
pagination.value.page = 1
loadFileList()
}
const selectFile = (file: any) => {
selectedFileId.value = file.id
}
const confirmSelection = () => {
if (selectedFileId.value) {
const file = fileList.value.find(f => f.id === selectedFileId.value)
if (file) {
configForm.value.site_logo = file.access_url
showLogoSelector.value = false
selectedFileId.value = null
}
}
}
const formatFileSize = (size: number) => {
if (size < 1024) return size + ' B'
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'
if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(1) + ' MB'
return (size / (1024 * 1024 * 1024)).toFixed(1) + ' GB'
}
const handleImageError = (event: any) => {
console.error('图片加载失败:', event)
}
const handleImageLoad = (event: any) => {
console.log('图片加载成功:', event)
}
// 页面加载时获取配置
onMounted(() => {
fetchConfig()
@@ -268,4 +585,37 @@ onMounted(() => {
<style scoped>
/* 自定义样式 */
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
max-height: 400px;
overflow-y: auto;
}
.file-item {
border: 1px solid #e5e7eb;
transition: all 0.2s ease;
}
.file-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.preview-image {
width: 100%;
height: 120px;
object-fit: cover;
border: 1px solid #e5e7eb;
border-radius: 4px;
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 1rem;
}
</style>

View File

@@ -42,11 +42,13 @@
v-model:value="searchQuery"
@input="debounceSearch"
type="text"
placeholder="搜索标签名称..."
/>
<div class="absolute right-3 top-1/2 transform -translate-y-1/2">
<i class="fas fa-search text-gray-400 text-sm"></i>
</div>
placeholder="搜索标签名称..."
clearable
>
<template #prefix>
<i class="fas fa-search text-gray-400 text-sm"></i>
</template>
</n-input>
</div>
<n-button @click="refreshData" type="tertiary">
<template #icon>

View File

@@ -320,12 +320,33 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useMessage } from 'naive-ui'
import { useConfigChangeDetection } from '~/composables/useConfigChangeDetection'
// 页面配置
definePageMeta({
layout: 'admin'
})
// 定义配置表单类型
interface ThirdPartyStatsForm {
third_party_stats_code: string
}
// 使用配置改动检测
const {
setOriginalConfig,
updateCurrentConfig,
getChangedConfig,
hasChanges,
updateOriginalConfig,
saveConfig: saveConfigWithDetection
} = useConfigChangeDetection<ThirdPartyStatsForm>({
debug: true,
fieldMapping: {
third_party_stats_code: 'third_party_stats_code'
}
})
// 状态管理
const message = useMessage()
const statsCode = ref('')
@@ -338,8 +359,13 @@ const fetchConfig = async () => {
const systemConfigApi = useSystemConfigApi()
const response = await systemConfigApi.getSystemConfig()
if (response && response.third_party_stats_code) {
statsCode.value = response.third_party_stats_code
if (response) {
const configData = {
third_party_stats_code: (response as any).third_party_stats_code || ''
}
statsCode.value = configData.third_party_stats_code
setOriginalConfig(configData)
}
} catch (error) {
console.error('获取配置失败:', error)
@@ -349,18 +375,39 @@ const fetchConfig = async () => {
// 保存配置
const saveCode = async () => {
saving.value = true
try {
const { useSystemConfigApi } = await import('~/composables/useApi')
const systemConfigApi = useSystemConfigApi()
await systemConfigApi.updateSystemConfig({
saving.value = true
// 更新当前配置
updateCurrentConfig({
third_party_stats_code: statsCode.value
})
message.success('配置保存成功')
} catch (error) {
console.error('保存配置失败:', error)
message.error('保存配置失败')
const { useSystemConfigApi } = await import('~/composables/useApi')
const systemConfigApi = useSystemConfigApi()
// 使用通用保存函数
const result = await saveConfigWithDetection(
systemConfigApi.updateSystemConfig,
{
onlyChanged: true,
includeAllFields: true
},
// 成功回调
() => {
message.success('配置保存成功')
},
// 错误回调
(error) => {
console.error('保存配置失败:', error)
message.error('保存配置失败')
}
)
// 如果没有改动,显示提示
if (result && result.message === '没有检测到任何改动') {
message.info('没有检测到任何改动')
}
} finally {
saving.value = false
}

View File

@@ -68,6 +68,7 @@
v-model:value="searchQuery"
placeholder="搜索资源标题..."
@keyup.enter="handleSearch"
clearable
>
<template #prefix>
<i class="fas fa-search"></i>
@@ -87,7 +88,7 @@
:options="sortOptions"
/>
<n-button type="primary" @click="handleSearch">
<n-button type="primary" @click="handleSearch" class="w-20">
<template #icon>
<i class="fas fa-search"></i>
</template>
@@ -343,8 +344,22 @@ const fetchResources = async () => {
}
const response = await resourceApi.getResources(params)
resources.value = response.resources || []
total.value = response.total || 0
// 处理嵌套的data结构{data: {data: [...], total: ...}}
if (response && response.data && response.data.data && Array.isArray(response.data.data)) {
resources.value = response.data.data
total.value = response.data.total || 0
} else if (response && response.data && Array.isArray(response.data)) {
// 处理直接的data结构{data: [...], total: ...}
resources.value = response.data
total.value = response.total || 0
} else if (response && response.resources) {
// 兼容旧格式
resources.value = response.resources
total.value = response.total || 0
} else {
resources.value = []
total.value = 0
}
} catch (error) {
console.error('获取未转存资源失败:', error)
notification.error({

View File

@@ -18,7 +18,14 @@
<div class="max-w-7xl mx-auto">
<!-- 头部 -->
<div class="header-container bg-slate-800 dark:bg-gray-800 text-white dark:text-gray-100 rounded-lg shadow-lg p-4 sm:p-8 mb-4 sm:mb-8 text-center relative">
<h1 class="text-2xl sm:text-3xl font-bold mb-4">
<h1 class="text-2xl sm:text-3xl font-bold mb-4 flex items-center justify-center gap-3">
<img
v-if="systemConfig?.site_logo"
:src="getImageUrl(systemConfig.site_logo)"
:alt="systemConfig?.site_title || 'Logo'"
class="h-8 w-auto object-contain"
@error="handleLogoError"
/>
<a href="/" class="text-white hover:text-gray-200 dark:hover:text-gray-300 no-underline">
{{ systemConfig?.site_title || '老九网盘资源数据库' }}
</a>
@@ -40,21 +47,23 @@
<i class="fas fa-book text-xs"></i> API文档
</n-button>
</NuxtLink>
<NuxtLink v-if="authInitialized && !userStore.isAuthenticated" to="/login" class="sm:flex">
<n-button size="tiny" type="tertiary" round ghost class="!px-2 !py-1 !text-xs !text-white dark:!text-white !border-white/30 hover:!border-white">
<i class="fas fa-sign-in-alt text-xs"></i> 登录
</n-button>
</NuxtLink>
<NuxtLink v-if="authInitialized && userStore.isAuthenticated && userStore.user?.role === 'admin'" to="/admin" class="hidden sm:flex">
<n-button size="tiny" type="tertiary" round ghost class="!px-2 !py-1 !text-xs !text-white dark:!text-white !border-white/30 hover:!border-white">
<i class="fas fa-user-shield text-xs"></i> 管理后台
</n-button>
</NuxtLink>
<NuxtLink v-if="authInitialized && userStore.isAuthenticated && userStore.user?.role !== 'admin'" to="/user" class="hidden sm:flex">
<n-button size="tiny" type="tertiary" round ghost class="!px-2 !py-1 !text-xs !text-white dark:!text-white !border-white/30 hover:!border-white">
<i class="fas fa-user text-xs"></i> 用户中心
</n-button>
</NuxtLink>
<ClientOnly>
<NuxtLink v-if="authInitialized && !userStore.isAuthenticated" to="/login" class="sm:flex">
<n-button size="tiny" type="tertiary" round ghost class="!px-2 !py-1 !text-xs !text-white dark:!text-white !border-white/30 hover:!border-white">
<i class="fas fa-sign-in-alt text-xs"></i> 登录
</n-button>
</NuxtLink>
<NuxtLink v-if="authInitialized && userStore.isAuthenticated && userStore.user?.role === 'admin'" to="/admin" class="hidden sm:flex">
<n-button size="tiny" type="tertiary" round ghost class="!px-2 !py-1 !text-xs !text-white dark:!text-white !border-white/30 hover:!border-white">
<i class="fas fa-user-shield text-xs"></i> 管理后台
</n-button>
</NuxtLink>
<NuxtLink v-if="authInitialized && userStore.isAuthenticated && userStore.user?.role !== 'admin'" to="/user" class="hidden sm:flex">
<n-button size="tiny" type="tertiary" round ghost class="!px-2 !py-1 !text-xs !text-white dark:!text-white !border-white/30 hover:!border-white">
<i class="fas fa-user text-xs"></i> 用户中心
</n-button>
</NuxtLink>
</ClientOnly>
</nav>
</div>
@@ -62,8 +71,8 @@
<div class="w-full max-w-3xl mx-auto mb-4 sm:mb-8 px-2 sm:px-0">
<ClientOnly>
<div class="relative">
<n-input round placeholder="搜索" v-model:value="searchQuery" @blur="handleSearch" @keyup.enter="handleSearch">
<template #suffix>
<n-input round placeholder="搜索" v-model:value="searchQuery" @blur="handleSearch" @keyup.enter="handleSearch" clearable>
<template #prefix>
<i class="fas fa-search text-gray-400"></i>
</template>
</n-input>
@@ -105,16 +114,16 @@
<!-- 资源列表 -->
<div class="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
<table class="w-full min-w-full table-fixed">
<table class="w-full min-w-full">
<thead>
<tr class="bg-slate-800 dark:bg-gray-700 text-white dark:text-gray-100">
<th class="px-2 sm:px-6 py-3 sm:py-4 text-left text-xs sm:text-sm w-1/2 sm:w-4/6">
<th class="px-2 sm:px-6 py-3 sm:py-4 text-left text-xs sm:text-sm">
<div class="flex items-center">
<i class="fas fa-cloud mr-1 text-gray-300"></i> 文件名
</div>
</th>
<th class="px-2 sm:px-6 py-3 sm:py-4 text-left text-xs sm:text-sm hidden sm:table-cell w-1/6">链接</th>
<th class="px-2 sm:px-6 py-3 sm:py-4 text-left text-xs sm:text-sm hidden sm:table-cell w-1/6">更新时间</th>
<th class="px-2 sm:px-6 py-3 sm:py-4 text-left text-xs sm:text-sm hidden sm:table-cell w-24">链接</th>
<th class="px-2 sm:px-6 py-3 sm:py-4 text-left text-xs sm:text-sm hidden sm:table-cell w-32">更新时间</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@@ -136,10 +145,15 @@
:class="isUpdatedToday(resource.updated_at) ? 'hover:bg-pink-50 dark:hover:bg-pink-900 bg-pink-50/30 dark:bg-pink-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-800'"
:data-index="index"
>
<td class="px-2 sm:px-6 py-2 sm:py-4 text-xs sm:text-sm w-1/2 sm:w-2/5">
<td class="px-2 sm:px-6 py-2 sm:py-4 text-xs sm:text-sm">
<div class="flex items-start">
<span class="mr-2 flex-shrink-0" v-html="getPlatformIcon(resource.pan_id || 0)"></span>
<span class="break-words">{{ resource.title }}</span>
<div class="flex-1 min-w-0">
<div class="break-words font-medium" v-html="resource.title_highlight || resource.title"></div>
<!-- 显示描述 -->
<div v-if="resource.description_highlight || resource.description" class="text-xs text-gray-600 dark:text-gray-400 mt-1 break-words line-clamp-2" v-html="resource.description_highlight || resource.description">
</div>
</div>
</div>
<div class="sm:hidden mt-1 space-y-1">
<!-- 移动端显示更新时间 -->
@@ -155,7 +169,7 @@
</button>
</div>
</td>
<td class="px-2 sm:px-6 py-2 sm:py-4 text-xs sm:text-sm hidden sm:table-cell w-1/5">
<td class="px-2 sm:px-6 py-2 sm:py-4 text-xs sm:text-sm hidden sm:table-cell w-32">
<button
class="text-blue-600 hover:text-blue-800 flex items-center gap-1 show-link-btn"
@click="toggleLink(resource)"
@@ -163,7 +177,7 @@
<i class="fas fa-eye"></i> 显示链接
</button>
</td>
<td class="px-2 sm:px-6 py-2 sm:py-4 text-xs sm:text-sm text-gray-500 hidden sm:table-cell w-2/5" :title="resource.updated_at">
<td class="px-2 sm:px-6 py-2 sm:py-4 text-xs sm:text-sm text-gray-500 hidden sm:table-cell w-32" :title="resource.updated_at">
<span v-html="formatRelativeTime(resource.updated_at)"></span>
</td>
</tr>
@@ -224,7 +238,7 @@ useHead({
// 获取运行时配置
const config = useRuntimeConfig()
import { useResourceApi, useStatsApi, usePanApi, useSystemConfigApi, usePublicSystemConfigApi } from '~/composables/useApi'
import { useResourceApi, useStatsApi, usePanApi, useSystemConfigApi, usePublicSystemConfigApi, useSearchStatsApi } from '~/composables/useApi'
const resourceApi = useResourceApi()
const statsApi = useStatsApi()
@@ -238,21 +252,44 @@ const router = useRouter()
// 响应式数据
const showLinkModal = ref(false)
const selectedResource = ref<any>(null)
const authInitialized = ref(true) // 在app.vue中已经初始化这里直接设为true
const pageLoading = ref(false)
// 使用 ClientOnly 包装器来处理认证状态
const authInitialized = ref(false)
// 用户状态管理
const userStore = useUserStore()
// 图片URL处理
const { getImageUrl } = useImageUrl()
// Logo错误处理
const handleLogoError = (event: Event) => {
const img = event.target as HTMLImageElement
img.style.display = 'none'
}
// 使用 useAsyncData 获取资源数据
const { data: resourcesData, pending, refresh } = await useAsyncData(
() => `resources-1-${route.query.search || ''}-${route.query.platform || ''}`,
() => resourceApi.getResources({
page: 1,
page_size: 200,
search: route.query.search as string || '',
pan_id: route.query.platform as string || ''
})
async () => {
// 如果有搜索关键词使用带搜索参数的资源接口后端会优先使用Meilisearch
if (route.query.search) {
return await resourceApi.getResources({
page: 1,
page_size: 200,
search: route.query.search as string,
pan_id: route.query.platform as string || ''
})
} else {
// 没有搜索关键词时,使用普通资源接口获取最新数据
return await resourceApi.getResources({
page: 1,
page_size: 200,
pan_id: route.query.platform as string || ''
})
}
}
)
// 获取统计数据
@@ -302,10 +339,25 @@ watch(systemConfigError, (error) => {
})
// 从 SSR 数据中获取值
const safeResources = computed(() => (resourcesData.value as any)?.data || [])
const safeResources = computed(() => {
const data = resourcesData.value as any
// 处理嵌套的data结构{data: {data: [...], total: ...}}
if (data?.data?.data && Array.isArray(data.data.data)) {
return data.data.data
}
// 处理直接的data结构{data: [...], total: ...}
if (data?.data && Array.isArray(data.data)) {
return data.data
}
// 处理直接的数组结构
if (Array.isArray(data)) {
return data
}
return []
})
const safeStats = computed(() => (statsData.value as any) || { total_resources: 0, total_categories: 0, total_tags: 0, total_views: 0, today_resources: 0 })
const platforms = computed(() => (platformsData.value as any) || [])
const systemConfig = computed(() => (systemConfigData.value as any).data || { site_title: '老九网盘资源数据库' })
const systemConfig = computed(() => (systemConfigData.value as any)?.data || { site_title: '老九网盘资源数据库' })
const safeLoading = computed(() => pending.value)
@@ -313,6 +365,25 @@ const safeLoading = computed(() => pending.value)
const searchQuery = ref(route.query.search as string || '')
const selectedPlatform = computed(() => route.query.platform as string || '')
// 记录搜索统计的函数
const recordSearchStats = (keyword: string) => {
if (!keyword || keyword.trim().length === 0) {
// console.log('搜索关键词为空,跳过统计记录')
return
}
const trimmedKeyword = keyword.trim()
// console.log('记录搜索统计:', trimmedKeyword)
// 延迟执行,确保页面完全加载
setTimeout(() => {
const searchStatsApi = useSearchStatsApi()
searchStatsApi.recordSearch({ keyword: trimmedKeyword }).catch(err => {
console.error('记录搜索统计失败:', err)
})
}, 0)
}
const handleSearch = () => {
const params = new URLSearchParams()
if (searchQuery.value) params.set('search', searchQuery.value)
@@ -322,14 +393,25 @@ const handleSearch = () => {
// 初始化认证状态
onMounted(() => {
// 初始化认证状态
authInitialized.value = true
animateCounters()
// 页面挂载完成时,如果有搜索关键词,记录搜索统计
if (process.client && route.query.search) {
const searchKeyword = route.query.search as string
recordSearchStats(searchKeyword)
} else {
console.log('无搜索参数,跳过统计记录')
}
})
// 获取平台名称
const getPlatformIcon = (panId: string) => {
const platform = (platforms.value as any).find((p: any) => p.id === panId)
const getPlatformIcon = (panId: string | number) => {
const platform = (platforms.value as any).find((p: any) => p.id == panId)
return platform?.icon || '未知平台'
}
@@ -362,7 +444,7 @@ const toggleLink = async (resource: any) => {
selectedResource.value = {
...resource,
loading: false,
error: '获取链接失败,显示原始链接'
error: '检测有效性失败,请自行验证'
}
}
}
@@ -510,4 +592,27 @@ const animateCounters = () => {
rgba(0,0,0,0.25) 100%
);
}
/* 文本截断样式 */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-wrap: break-word;
word-break: break-word;
}
/* 表格单元格内容溢出控制 */
table td {
overflow: hidden;
word-wrap: break-word;
word-break: break-word;
}
/* 确保flex容器不会溢出 */
.min-w-0 {
min-width: 0;
}
</style>

View File

@@ -92,7 +92,7 @@
clearable
/>
<n-button type="primary" @click="handleSearch">
<n-button type="primary" @click="handleSearch" class="w-20">
<template #icon>
<i class="fas fa-search"></i>
</template>

View File

@@ -92,7 +92,7 @@
clearable
/>
<n-button type="primary" @click="handleSearch">
<n-button type="primary" @click="handleSearch" class="w-20">
<template #icon>
<i class="fas fa-search"></i>
</template>

View File

@@ -80,7 +80,7 @@
clearable
/>
<n-button type="primary" @click="handleSearch">
<n-button type="primary" @click="handleSearch" class="w-20">
<template #icon>
<i class="fas fa-search"></i>
</template>

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