mirror of
https://github.com/ctwj/urldb.git
synced 2025-11-25 19:37:33 +08:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a90a89b08 | ||
|
|
80a94c0f05 | ||
|
|
d49ce77350 | ||
|
|
292384f281 | ||
|
|
b8b0cc760d | ||
|
|
002267e436 | ||
|
|
0d54dffa19 | ||
|
|
d2c9d79658 | ||
|
|
f70850d465 | ||
|
|
223b1af714 | ||
|
|
76a64492a2 | ||
|
|
d6224ab25c | ||
|
|
9708157566 | ||
|
|
8cf1575232 | ||
|
|
17c05870a3 | ||
|
|
d531be3c36 | ||
|
|
edde7afdc8 | ||
|
|
77216cf380 | ||
|
|
da3fc11b2e | ||
|
|
cbf673126e | ||
|
|
aa7d6ea2fe | ||
|
|
841eb05f68 | ||
|
|
eeca85942f | ||
|
|
c053a17131 | ||
|
|
3d29f1bf23 | ||
|
|
a15a0fe2be | ||
|
|
05243bcfe7 | ||
|
|
98b94b3313 | ||
|
|
949a328ee3 | ||
|
|
acb462c6d5 | ||
|
|
e52043505f | ||
|
|
9d4eb38272 | ||
|
|
14ef85801a | ||
|
|
3f4430104d | ||
|
|
709029a123 | ||
|
|
559d69f52b | ||
|
|
dcd5e0bf73 | ||
|
|
4343a29bb3 | ||
|
|
3bf0d59a9c | ||
|
|
c3b2979977 | ||
|
|
6de20b7e13 | ||
|
|
2d96413a5d | ||
|
|
9b0d385c52 | ||
|
|
fae7de17d5 | ||
|
|
05930a3e70 |
130
BUILD.md
Normal file
130
BUILD.md
Normal 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
|
||||||
|
```
|
||||||
88
CHANGELOG.md
88
CHANGELOG.md
@@ -1,88 +0,0 @@
|
|||||||
# 📝 更新日志
|
|
||||||
|
|
||||||
本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/) 规范。
|
|
||||||
|
|
||||||
## [v1.1.0]
|
|
||||||
|
|
||||||
1. 新增违禁词功能
|
|
||||||
2. 管理后台体验优化
|
|
||||||
3. bug修复
|
|
||||||
|
|
||||||
## [v1.0.0]
|
|
||||||
|
|
||||||
1. 自动转存
|
|
||||||
2. 自动资源处理
|
|
||||||
|
|
||||||
### 新增
|
|
||||||
- 项目开源准备
|
|
||||||
- 完善文档和贡献指南
|
|
||||||
- 添加LICENSE文件
|
|
||||||
|
|
||||||
### 修复
|
|
||||||
- 修复README格式问题
|
|
||||||
- 优化项目结构说明
|
|
||||||
|
|
||||||
## [100 - 202401XX
|
|
||||||
|
|
||||||
### 新增
|
|
||||||
- 🎉 首次发布
|
|
||||||
- 📁 多平台网盘支持(夸克、阿里云盘、百度网盘、UC网盘)
|
|
||||||
- 🔍 智能搜索功能
|
|
||||||
- 📊 数据统计和分析
|
|
||||||
- 🏷️ 标签系统
|
|
||||||
- 👥 用户权限管理
|
|
||||||
- 📦 批量资源管理
|
|
||||||
- 🔄 自动处理功能
|
|
||||||
- 📈 热播剧管理
|
|
||||||
- ⚙️ 系统配置管理
|
|
||||||
- 🔐 JWT认证系统
|
|
||||||
- 📱 响应式设计
|
|
||||||
- 🌙 深色模式支持
|
|
||||||
- 🎨 现代化UI界面
|
|
||||||
|
|
||||||
### 技术特性
|
|
||||||
- 🦀 基于Golang 1023的高性能后端
|
|
||||||
- ⚡ Nuxt.js 3 + Vue 3前端框架
|
|
||||||
- 🗄️ PostgreSQL数据库
|
|
||||||
- 🔧 GORM ORM框架
|
|
||||||
- 🐳 Docker容器化部署
|
|
||||||
- 📝 TypeScript类型安全
|
|
||||||
|
|
||||||
### 核心功能
|
|
||||||
- 资源管理:增删改查、批量操作
|
|
||||||
- 分类管理:资源分类和标签
|
|
||||||
- 平台管理:多网盘平台支持
|
|
||||||
- 搜索统计:全文搜索和数据分析
|
|
||||||
- 系统配置:灵活的参数配置
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 版本说明
|
|
||||||
|
|
||||||
### 版本号格式
|
|
||||||
- **主版本号**:不兼容的API修改
|
|
||||||
- **次版本号**:向下兼容的功能性新增
|
|
||||||
- **修订号**:向下兼容的问题修正
|
|
||||||
|
|
||||||
### 更新类型
|
|
||||||
- 🎉 **重大更新** - 新版本发布
|
|
||||||
- ✨ **新增功能** - 新功能或特性
|
|
||||||
- 🐛 **问题修复** - Bug修复
|
|
||||||
- 🔧 **优化改进** - 性能优化或代码改进
|
|
||||||
- 📚 **文档更新** - 文档或注释更新
|
|
||||||
- 🎨 **界面优化** - UI/UX改进
|
|
||||||
- ⚡ **性能提升** - 性能相关改进
|
|
||||||
- 🔒 **安全更新** - 安全相关修复
|
|
||||||
- 🧪 **测试相关** - 测试用例或测试工具
|
|
||||||
- 🚀 **部署相关** - 部署或构建相关
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 贡献
|
|
||||||
|
|
||||||
如果您想为更新日志做出贡献,请:
|
|
||||||
|
|
||||||
1. 在提交代码时使用规范的提交信息2. 在Pull Request中描述您的更改
|
|
||||||
3. 遵循项目的贡献指南
|
|
||||||
|
|
||||||
---
|
|
||||||
29
ChangeLog.md
Normal file
29
ChangeLog.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
### v1.2.4
|
||||||
|
|
||||||
|
1. 搜索增强,毫秒级响应,关键字高亮显示
|
||||||
|
2. 修复版本显示不正确的问题
|
||||||
|
3. 配置项新增Meilisearch配置
|
||||||
|
|
||||||
|
### v1.2.3
|
||||||
|
1. 添加图片上传功能
|
||||||
|
2. 添加Logo配置项,首页Logo显示
|
||||||
|
3. 后台界面体验优化
|
||||||
|
|
||||||
|
### v1.2.1
|
||||||
|
1. 修复转存移除广告失败的问题和添加广告失败的问题
|
||||||
|
2. 管理后台UI优化
|
||||||
|
3. 首页添加描述显示
|
||||||
|
|
||||||
|
### v1.2.0
|
||||||
|
1. 新增手动批量转存
|
||||||
|
2. 新增QQ机器人
|
||||||
|
3. 新增任务管理功能
|
||||||
|
4. 自动转存改版(批量转存修改为,显示二维码时自动转存)
|
||||||
|
5. 新增支持第三方统计代码配置
|
||||||
|
|
||||||
|
### v1.0.0
|
||||||
|
1. 支持API,手动批量录入资源
|
||||||
|
2. 支持,自动判断资源有效性
|
||||||
|
3. 支持自动转存
|
||||||
|
4. 支持平台多账号管理(Quark)
|
||||||
|
5. 支持简单的数据统计
|
||||||
23
Dockerfile
23
Dockerfile
@@ -28,11 +28,26 @@ WORKDIR /app
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# 先复制VERSION文件,确保构建时能正确读取版本号
|
# 复制所有源代码
|
||||||
COPY VERSION ./
|
|
||||||
|
|
||||||
COPY . .
|
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
|
FROM alpine:latest AS backend
|
||||||
|
|||||||
145
README.md
145
README.md
@@ -29,18 +29,34 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔔 温馨提示
|
## 🔔 版本改动
|
||||||
|
|
||||||
- [文档说明](https://ecn5khs4t956.feishu.cn/wiki/PsnDwtxghiP0mLkTiruczKtxnwd?from=from_copylink)
|
- [文档说明](https://ecn5khs4t956.feishu.cn/wiki/PsnDwtxghiP0mLkTiruczKtxnwd?from=from_copylink)
|
||||||
- [服务器要求](https://ecn5khs4t956.feishu.cn/wiki/W8YBww1Mmiu4Cdkp5W4c8pFNnMf?from=from_copylink)
|
- [服务器要求](https://ecn5khs4t956.feishu.cn/wiki/W8YBww1Mmiu4Cdkp5W4c8pFNnMf?from=from_copylink)
|
||||||
- [QQ机器人](https://github.com/ctwj/astrbot_plugin_urldb)
|
- [QQ机器人](https://github.com/ctwj/astrbot_plugin_urldb)
|
||||||
|
|
||||||
|
### v1.2.4
|
||||||
|
|
||||||
|
1. 搜索增强,毫秒级响应,关键字高亮显示
|
||||||
|
2. 修复版本显示不正确的问题
|
||||||
|
3. 配置项新增Meilisearch配置
|
||||||
|
|
||||||
|
[详细改动记录](https://github.com/ctwj/urldb/blob/main/ChangeLog.md)
|
||||||
|
|
||||||
|
当前特性
|
||||||
|
1. 支持API,手动批量录入资源
|
||||||
|
2. 支持,自动判断资源有效性
|
||||||
|
3. 支持自动转存(Quark)
|
||||||
|
4. 支持平台多账号管理(Quark)
|
||||||
|
5. 支持简单的数据统计
|
||||||
|
6. 支持Meilisearch
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📸 项目截图
|
## 📸 项目截图
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 🏠 首页
|
### 🏠 首页
|
||||||

|

|
||||||
|
|
||||||
@@ -85,112 +101,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 +120,6 @@ PORT=8080
|
|||||||
TIMEZONE=Asia/Shanghai
|
TIMEZONE=Asia/Shanghai
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker 服务说明
|
|
||||||
|
|
||||||
| 服务 | 端口 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| server | 3030 | 应用 |
|
|
||||||
| postgres | 5431 | PostgreSQL 数据库 |
|
|
||||||
|
|
||||||
### 镜像构建
|
### 镜像构建
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -228,18 +131,6 @@ docker push ctwj/urldb-backend:1.0.7
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📚 API 文档
|
|
||||||
|
|
||||||
### 公开统计
|
|
||||||
|
|
||||||
提供,批量入库和搜索api,通过 apiToken 授权
|
|
||||||
|
|
||||||
> 📖 完整API文档请访问:`http://doc.l9.lc/`
|
|
||||||
|
|
||||||
## 🤝 贡献指南
|
|
||||||
|
|
||||||
我们欢迎所有形式的贡献!
|
|
||||||
|
|
||||||
## 📄 许可证
|
## 📄 许可证
|
||||||
|
|
||||||
本项目采用 [GPL License](LICENSE) 许可证。
|
本项目采用 [GPL License](LICENSE) 许可证。
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ func (f *PanFactory) CreatePanService(url string, config *PanConfig) (PanService
|
|||||||
return NewBaiduPanService(config), nil
|
return NewBaiduPanService(config), nil
|
||||||
case UC:
|
case UC:
|
||||||
return NewUCService(config), nil
|
return NewUCService(config), nil
|
||||||
|
case Xunlei:
|
||||||
|
return NewXunleiPanService(config), nil
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("不支持的服务类型: %s", url)
|
return nil, fmt.Errorf("不支持的服务类型: %s", url)
|
||||||
}
|
}
|
||||||
@@ -145,8 +147,8 @@ func (f *PanFactory) CreatePanServiceByType(serviceType ServiceType, config *Pan
|
|||||||
return NewBaiduPanService(config), nil
|
return NewBaiduPanService(config), nil
|
||||||
case UC:
|
case UC:
|
||||||
return NewUCService(config), nil
|
return NewUCService(config), nil
|
||||||
// case Xunlei:
|
case Xunlei:
|
||||||
// return NewXunleiService(config), nil
|
return NewXunleiPanService(config), nil
|
||||||
// case Tianyi:
|
// case Tianyi:
|
||||||
// return NewTianyiService(config), nil
|
// return NewTianyiService(config), nil
|
||||||
default:
|
default:
|
||||||
@@ -178,6 +180,12 @@ func (f *PanFactory) GetUCService(config *PanConfig) PanService {
|
|||||||
return service
|
return service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetXunleiService 获取迅雷网盘服务单例
|
||||||
|
func (f *PanFactory) GetXunleiService(config *PanConfig) PanService {
|
||||||
|
service := NewXunleiPanService(config)
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
// ExtractServiceType 从URL中提取服务类型
|
// ExtractServiceType 从URL中提取服务类型
|
||||||
func ExtractServiceType(url string) ServiceType {
|
func ExtractServiceType(url string) ServiceType {
|
||||||
url = strings.ToLower(url)
|
url = strings.ToLower(url)
|
||||||
|
|||||||
@@ -5,11 +5,16 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"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"
|
"github.com/ctwj/urldb/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,10 +24,15 @@ type QuarkPanService struct {
|
|||||||
configMutex sync.RWMutex // 保护配置的读写锁
|
configMutex sync.RWMutex // 保护配置的读写锁
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 全局配置缓存刷新信号
|
||||||
|
var configRefreshChan = make(chan bool, 1)
|
||||||
|
|
||||||
// 单例相关变量
|
// 单例相关变量
|
||||||
var (
|
var (
|
||||||
quarkInstance *QuarkPanService
|
quarkInstance *QuarkPanService
|
||||||
quarkOnce sync.Once
|
quarkOnce sync.Once
|
||||||
|
systemConfigRepo repo.SystemConfigRepository
|
||||||
|
systemConfigOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewQuarkPanService 创建夸克网盘服务(单例模式)
|
// NewQuarkPanService 创建夸克网盘服务(单例模式)
|
||||||
@@ -281,8 +291,26 @@ func (q *QuarkPanService) DeleteFiles(fileList []string) (*TransferResult, error
|
|||||||
return ErrorResult("文件列表为空"), nil
|
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{}{
|
data := map[string]interface{}{
|
||||||
"fid_list": fileList,
|
"action_type": 2,
|
||||||
|
"filelist": []string{fileID},
|
||||||
|
"exclude_fids": []string{},
|
||||||
}
|
}
|
||||||
|
|
||||||
queryParams := map[string]string{
|
queryParams := map[string]string{
|
||||||
@@ -291,12 +319,41 @@ func (q *QuarkPanService) DeleteFiles(fileList []string) (*TransferResult, error
|
|||||||
"uc_param_str": "",
|
"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 {
|
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
|
// getStoken 获取stoken
|
||||||
@@ -376,12 +433,17 @@ func (q *QuarkPanService) getShare(shareID, stoken string) (*ShareResult, error)
|
|||||||
|
|
||||||
// getShareSave 转存分享
|
// getShareSave 转存分享
|
||||||
func (q *QuarkPanService) getShareSave(shareID, stoken string, fidList, fidTokenList []string) (*SaveResult, error) {
|
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{}{
|
data := map[string]interface{}{
|
||||||
"pwd_id": shareID,
|
"pwd_id": shareID,
|
||||||
"stoken": stoken,
|
"stoken": stoken,
|
||||||
"fid_list": fidList,
|
"fid_list": fidList,
|
||||||
"fid_token_list": fidTokenList,
|
"fid_token_list": fidTokenList,
|
||||||
"to_pdir_fid": "0", // 默认存储到根目录
|
"to_pdir_fid": toPdirFid, // 存储到指定目录
|
||||||
}
|
}
|
||||||
|
|
||||||
queryParams := map[string]string{
|
queryParams := map[string]string{
|
||||||
@@ -591,22 +653,20 @@ func (q *QuarkPanService) deleteAdFiles(pdirFid string) error {
|
|||||||
|
|
||||||
// containsAdKeywords 检查文件名是否包含广告关键词
|
// containsAdKeywords 检查文件名是否包含广告关键词
|
||||||
func (q *QuarkPanService) containsAdKeywords(filename string) bool {
|
func (q *QuarkPanService) containsAdKeywords(filename string) bool {
|
||||||
// 默认广告关键词列表
|
// 从系统配置中获取广告关键词
|
||||||
defaultAdKeywords := []string{
|
adKeywordsStr, err := q.getSystemConfigValue(entity.ConfigKeyAdKeywords)
|
||||||
"微信", "独家", "V信", "v信", "威信", "胖狗资源",
|
if err != nil {
|
||||||
"加微", "会员群", "q群", "v群", "公众号",
|
log.Printf("获取广告关键词配置失败: %v", err)
|
||||||
"广告", "特价", "最后机会", "不要错过", "立减",
|
return false
|
||||||
"立得", "赚", "省", "回扣", "抽奖",
|
|
||||||
"失效", "年会员", "空间容量", "微信群", "群文件", "全网资源", "影视资源", "扫码", "最新资源",
|
|
||||||
"IMG_", "资源汇总", "緑铯粢源", ".url", "网盘推广", "大额优惠券",
|
|
||||||
"资源文档", "dy8.xyz", "妙妙屋", "资源合集", "kkdm", "赚收益",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试从系统配置中获取广告关键词
|
// 如果配置为空,返回false
|
||||||
adKeywords := defaultAdKeywords
|
if adKeywordsStr == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// 这里可以添加从系统配置读取广告关键词的逻辑
|
// 按逗号分割关键词(支持中文和英文逗号)
|
||||||
// 例如:从数据库或配置文件中读取自定义的广告关键词
|
adKeywords := q.splitKeywords(adKeywordsStr)
|
||||||
|
|
||||||
return q.checkKeywordsInFilename(filename, adKeywords)
|
return q.checkKeywordsInFilename(filename, adKeywords)
|
||||||
}
|
}
|
||||||
@@ -626,24 +686,136 @@ func (q *QuarkPanService) checkKeywordsInFilename(filename string, keywords []st
|
|||||||
return false
|
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 添加个人自定义广告
|
// addAd 添加个人自定义广告
|
||||||
func (q *QuarkPanService) addAd(dirID string) error {
|
func (q *QuarkPanService) addAd(dirID string) error {
|
||||||
log.Printf("开始添加个人自定义广告到目录: %s", dirID)
|
log.Printf("开始添加个人自定义广告到目录: %s", dirID)
|
||||||
|
|
||||||
// 这里可以从配置中读取广告文件ID列表
|
// 从系统配置中获取自动插入广告内容
|
||||||
// 暂时使用硬编码的广告文件ID,后续可以从系统配置中读取
|
autoInsertAdStr, err := q.getSystemConfigValue(entity.ConfigKeyAutoInsertAd)
|
||||||
adFileIDs := []string{
|
if err != nil {
|
||||||
// 可以配置多个广告文件ID
|
log.Printf("获取自动插入广告配置失败: %v", err)
|
||||||
// "4c0381f2d1ca", // 示例广告文件ID
|
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 {
|
if len(adFileIDs) == 0 {
|
||||||
log.Printf("没有配置广告文件,跳过广告插入")
|
log.Printf("没有有效的广告文件ID,跳过广告插入")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 随机选择一个广告文件
|
// 随机选择一个广告文件
|
||||||
rand.Seed(time.Now().UnixNano())
|
rand.Seed(utils.GetCurrentTimestampNano())
|
||||||
selectedAdID := adFileIDs[rand.Intn(len(adFileIDs))]
|
selectedAdID := adFileIDs[rand.Intn(len(adFileIDs))]
|
||||||
|
|
||||||
log.Printf("选择广告文件ID: %s", selectedAdID)
|
log.Printf("选择广告文件ID: %s", selectedAdID)
|
||||||
@@ -673,7 +845,7 @@ func (q *QuarkPanService) addAd(dirID string) error {
|
|||||||
shareFidToken := adFile.ShareFidToken
|
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 {
|
if err != nil {
|
||||||
log.Printf("保存广告文件失败: %v", err)
|
log.Printf("保存广告文件失败: %v", err)
|
||||||
return err
|
return err
|
||||||
@@ -729,26 +901,8 @@ func (q *QuarkPanService) getDirFile(pdirFid string) ([]map[string]interface{},
|
|||||||
return nil, fmt.Errorf(response.Message)
|
return nil, fmt.Errorf(response.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 递归处理子目录
|
// 直接返回文件列表,不递归处理子目录(与参考代码保持一致)
|
||||||
var allFiles []map[string]interface{}
|
return response.Data.List, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 定义各种结果结构体
|
// 定义各种结果结构体
|
||||||
|
|||||||
544
common/xunlei_pan.go
Normal file
544
common/xunlei_pan.go
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
// 1. 修正接口 Host,增加配置项
|
||||||
|
// 2. POST/GET 区分(xunleix 的 /drive/v1/share/list 是 GET,不是 POST)
|
||||||
|
// 3. 参数传递方式严格区分 query/body
|
||||||
|
// 4. header 应支持 Authorization(Bearer ...)、x-device-id、x-client-id、x-captcha-token 等
|
||||||
|
// 5. 结构体返回字段需和 xunleix 100%一致(如 data 字段是 map 还是 list),注意 code 字段为 int 还是 string
|
||||||
|
// 6. 错误处理,返回体未必有 code/msg,需先判断 HTTP 状态码再判断 body
|
||||||
|
// 7. 建议增加日志和更清晰的错误提示
|
||||||
|
|
||||||
|
package pan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type XunleiPanService struct {
|
||||||
|
*BasePanService
|
||||||
|
configMutex sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
xunleiInstance *XunleiPanService
|
||||||
|
xunleiOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// 配置化 API Host
|
||||||
|
func (x *XunleiPanService) apiHost() string {
|
||||||
|
return "https://api-pan.xunlei.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具:自动补全必要 header
|
||||||
|
func (x *XunleiPanService) setCommonHeader(req *http.Request) {
|
||||||
|
for k, v := range x.headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewXunleiPanService(config *PanConfig) *XunleiPanService {
|
||||||
|
xunleiOnce.Do(func() {
|
||||||
|
xunleiInstance = &XunleiPanService{
|
||||||
|
BasePanService: NewBasePanService(config),
|
||||||
|
}
|
||||||
|
xunleiInstance.SetHeaders(map[string]string{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
|
"Cookie": config.Cookie,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
xunleiInstance.UpdateConfig(config)
|
||||||
|
return xunleiInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetXunleiInstance 获取迅雷网盘服务单例实例
|
||||||
|
func GetXunleiInstance() *XunleiPanService {
|
||||||
|
return NewXunleiPanService(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *XunleiPanService) UpdateConfig(config *PanConfig) {
|
||||||
|
if config == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
x.configMutex.Lock()
|
||||||
|
defer x.configMutex.Unlock()
|
||||||
|
x.config = config
|
||||||
|
if config.Cookie != "" {
|
||||||
|
x.SetHeader("Cookie", config.Cookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetServiceType 获取服务类型
|
||||||
|
func (x *XunleiPanService) GetServiceType() ServiceType {
|
||||||
|
return Xunlei
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer 转存分享链接 - 实现 PanService 接口
|
||||||
|
func (x *XunleiPanService) Transfer(shareID string) (*TransferResult, error) {
|
||||||
|
// 读取配置(线程安全)
|
||||||
|
x.configMutex.RLock()
|
||||||
|
config := x.config
|
||||||
|
x.configMutex.RUnlock()
|
||||||
|
|
||||||
|
log.Printf("开始处理迅雷分享: %s", shareID)
|
||||||
|
|
||||||
|
// 检查是否为检验模式
|
||||||
|
if config.IsType == 1 {
|
||||||
|
// 检验模式:直接获取分享信息
|
||||||
|
shareInfo, err := x.getShareInfo(shareID)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("获取分享信息失败: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return SuccessResult("检验成功", map[string]interface{}{
|
||||||
|
"title": shareInfo.Title,
|
||||||
|
"shareUrl": config.URL,
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转存模式:实现完整的转存流程
|
||||||
|
// 1. 获取分享详情
|
||||||
|
shareDetail, err := x.GetShareFolder(shareID, "", "")
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("获取分享详情失败: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 提取文件ID列表
|
||||||
|
fileIDs := make([]string, 0)
|
||||||
|
for _, file := range shareDetail.Data.Files {
|
||||||
|
fileIDs = append(fileIDs, file.FileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fileIDs) == 0 {
|
||||||
|
return ErrorResult("分享中没有可转存的文件"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 转存文件
|
||||||
|
restoreResult, err := x.Restore(shareID, "", fileIDs)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("转存失败: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 等待转存完成
|
||||||
|
taskID := restoreResult.Data.TaskID
|
||||||
|
_, err = x.waitForTask(taskID)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("等待转存完成失败: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 创建新的分享
|
||||||
|
shareResult, err := x.FileBatchShare(fileIDs, false, 0) // 永久分享
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("创建分享失败: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 返回结果
|
||||||
|
return SuccessResult("转存成功", map[string]interface{}{
|
||||||
|
"shareUrl": shareResult.Data.ShareURL,
|
||||||
|
"title": fmt.Sprintf("迅雷分享_%s", shareID),
|
||||||
|
"fid": strings.Join(fileIDs, ","),
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForTask 等待任务完成
|
||||||
|
func (x *XunleiPanService) waitForTask(taskID string) (*XLTaskResult, error) {
|
||||||
|
maxRetries := 50
|
||||||
|
retryDelay := 2 * time.Second
|
||||||
|
|
||||||
|
for retryIndex := 0; retryIndex < maxRetries; retryIndex++ {
|
||||||
|
result, err := x.getTaskStatus(taskID, retryIndex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Status == 2 { // 任务完成
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(retryDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("任务超时")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTaskStatus 获取任务状态
|
||||||
|
func (x *XunleiPanService) getTaskStatus(taskID string, retryIndex int) (*XLTaskResult, error) {
|
||||||
|
apiURL := x.apiHost() + "/drive/v1/task"
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("task_id", taskID)
|
||||||
|
params.Set("retry_index", fmt.Sprintf("%d", retryIndex))
|
||||||
|
apiURL = apiURL + "?" + params.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x.setCommonHeader(req)
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
result, _ := ioutil.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(result))
|
||||||
|
}
|
||||||
|
var data XLTaskResult
|
||||||
|
if err := json.Unmarshal(result, &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getShareInfo 获取分享信息(用于检验模式)
|
||||||
|
func (x *XunleiPanService) getShareInfo(shareID string) (*XLShareInfo, error) {
|
||||||
|
// 使用现有的 GetShareFolder 方法获取分享信息
|
||||||
|
shareDetail, err := x.GetShareFolder(shareID, "", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造分享信息
|
||||||
|
shareInfo := &XLShareInfo{
|
||||||
|
ShareID: shareID,
|
||||||
|
Title: fmt.Sprintf("迅雷分享_%s", shareID),
|
||||||
|
Files: make([]XLFileInfo, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理文件信息
|
||||||
|
for _, file := range shareDetail.Data.Files {
|
||||||
|
shareInfo.Files = append(shareInfo.Files, XLFileInfo{
|
||||||
|
FileID: file.FileID,
|
||||||
|
Name: file.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return shareInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFiles 获取文件列表 - 实现 PanService 接口
|
||||||
|
func (x *XunleiPanService) GetFiles(pdirFid string) (*TransferResult, error) {
|
||||||
|
log.Printf("开始获取迅雷网盘文件列表,目录ID: %s", pdirFid)
|
||||||
|
|
||||||
|
// 使用现有的 GetShareList 方法获取文件列表
|
||||||
|
shareList, err := x.GetShareList("")
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("获取文件列表失败: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为通用格式
|
||||||
|
fileList := make([]interface{}, 0)
|
||||||
|
for _, share := range shareList.Data.List {
|
||||||
|
fileList = append(fileList, map[string]interface{}{
|
||||||
|
"share_id": share.ShareID,
|
||||||
|
"title": share.Title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return SuccessResult("获取成功", fileList), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFiles 删除文件 - 实现 PanService 接口
|
||||||
|
func (x *XunleiPanService) DeleteFiles(fileList []string) (*TransferResult, error) {
|
||||||
|
log.Printf("开始删除迅雷网盘文件,文件数量: %d", len(fileList))
|
||||||
|
|
||||||
|
// 使用现有的 ShareBatchDelete 方法删除分享
|
||||||
|
result, err := x.ShareBatchDelete(fileList)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("删除文件失败: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Code != 0 {
|
||||||
|
return ErrorResult(fmt.Sprintf("删除文件失败: %s", result.Msg)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return SuccessResult("删除成功", nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserInfo 获取用户信息 - 实现 PanService 接口
|
||||||
|
func (x *XunleiPanService) GetUserInfo(cookie string) (*UserInfo, error) {
|
||||||
|
log.Printf("开始获取迅雷网盘用户信息")
|
||||||
|
|
||||||
|
// 临时设置cookie
|
||||||
|
originalCookie := x.GetHeader("Cookie")
|
||||||
|
x.SetHeader("Cookie", cookie)
|
||||||
|
defer x.SetHeader("Cookie", originalCookie) // 恢复原始cookie
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
apiURL := x.apiHost() + "/drive/v1/user/info"
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建请求失败: %v", err)
|
||||||
|
}
|
||||||
|
x.setCommonHeader(req)
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("获取用户信息失败: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
result, _ := ioutil.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
VIPStatus bool `json:"vip_status"`
|
||||||
|
UsedSpace int64 `json:"used_space"`
|
||||||
|
TotalSpace int64 `json:"total_space"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(result, &response); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析用户信息失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.Code != 0 {
|
||||||
|
return nil, fmt.Errorf("获取用户信息失败: %s", response.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &UserInfo{
|
||||||
|
Username: response.Data.Username,
|
||||||
|
VIPStatus: response.Data.VIPStatus,
|
||||||
|
UsedSpace: response.Data.UsedSpace,
|
||||||
|
TotalSpace: response.Data.TotalSpace,
|
||||||
|
ServiceType: "xunlei",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetShareList 严格对齐 GET + query(xunleix实现)
|
||||||
|
func (x *XunleiPanService) GetShareList(pageToken string) (*XLShareListResp, error) {
|
||||||
|
api := x.apiHost() + "/drive/v1/share/list"
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("limit", "100")
|
||||||
|
params.Set("thumbnail_size", "SIZE_SMALL")
|
||||||
|
if pageToken != "" {
|
||||||
|
params.Set("page_token", pageToken)
|
||||||
|
}
|
||||||
|
apiURL := api + "?" + params.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x.setCommonHeader(req)
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
result, _ := ioutil.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(result))
|
||||||
|
}
|
||||||
|
var data XLShareListResp
|
||||||
|
if err := json.Unmarshal(result, &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileBatchShare 创建分享(POST, body)
|
||||||
|
func (x *XunleiPanService) FileBatchShare(ids []string, needPassword bool, expirationDays int) (*XLBatchShareResp, error) {
|
||||||
|
apiURL := x.apiHost() + "/drive/v1/share/batch"
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"file_ids": ids,
|
||||||
|
"need_password": needPassword,
|
||||||
|
"expiration_days": expirationDays,
|
||||||
|
}
|
||||||
|
bs, _ := json.Marshal(body)
|
||||||
|
req, err := http.NewRequest("POST", apiURL, bytes.NewReader(bs))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x.setCommonHeader(req)
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
result, _ := ioutil.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(result))
|
||||||
|
}
|
||||||
|
var data XLBatchShareResp
|
||||||
|
if err := json.Unmarshal(result, &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShareBatchDelete 取消分享(POST, body)
|
||||||
|
func (x *XunleiPanService) ShareBatchDelete(ids []string) (*XLCommonResp, error) {
|
||||||
|
apiURL := x.apiHost() + "/drive/v1/share/batch/delete"
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"share_ids": ids,
|
||||||
|
}
|
||||||
|
bs, _ := json.Marshal(body)
|
||||||
|
req, err := http.NewRequest("POST", apiURL, bytes.NewReader(bs))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x.setCommonHeader(req)
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
result, _ := ioutil.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(result))
|
||||||
|
}
|
||||||
|
var data XLCommonResp
|
||||||
|
if err := json.Unmarshal(result, &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetShareFolder 获取分享内容(POST, body)
|
||||||
|
func (x *XunleiPanService) GetShareFolder(shareID, passCodeToken, parentID string) (*XLShareFolderResp, error) {
|
||||||
|
apiURL := x.apiHost() + "/drive/v1/share/detail"
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"share_id": shareID,
|
||||||
|
"pass_code_token": passCodeToken,
|
||||||
|
"parent_id": parentID,
|
||||||
|
"limit": 100,
|
||||||
|
"thumbnail_size": "SIZE_LARGE",
|
||||||
|
"order": "6",
|
||||||
|
}
|
||||||
|
bs, _ := json.Marshal(body)
|
||||||
|
req, err := http.NewRequest("POST", apiURL, bytes.NewReader(bs))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x.setCommonHeader(req)
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
result, _ := ioutil.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(result))
|
||||||
|
}
|
||||||
|
var data XLShareFolderResp
|
||||||
|
if err := json.Unmarshal(result, &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore 转存(POST, body)
|
||||||
|
func (x *XunleiPanService) Restore(shareID, passCodeToken string, fileIDs []string) (*XLRestoreResp, error) {
|
||||||
|
apiURL := x.apiHost() + "/drive/v1/share/restore"
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"share_id": shareID,
|
||||||
|
"pass_code_token": passCodeToken,
|
||||||
|
"file_ids": fileIDs,
|
||||||
|
"folder_type": "NORMAL",
|
||||||
|
"specify_parent_id": true,
|
||||||
|
"parent_id": "",
|
||||||
|
}
|
||||||
|
bs, _ := json.Marshal(body)
|
||||||
|
req, err := http.NewRequest("POST", apiURL, bytes.NewReader(bs))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x.setCommonHeader(req)
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
result, _ := ioutil.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(result))
|
||||||
|
}
|
||||||
|
var data XLRestoreResp
|
||||||
|
if err := json.Unmarshal(result, &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结构体完全对齐 xunleix
|
||||||
|
type XLShareListResp struct {
|
||||||
|
Data struct {
|
||||||
|
List []struct {
|
||||||
|
ShareID string `json:"share_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
} `json:"list"`
|
||||||
|
} `json:"data"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type XLBatchShareResp struct {
|
||||||
|
Data struct {
|
||||||
|
ShareURL string `json:"share_url"`
|
||||||
|
} `json:"data"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type XLCommonResp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type XLShareFolderResp struct {
|
||||||
|
Data struct {
|
||||||
|
Files []struct {
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"files"`
|
||||||
|
} `json:"data"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type XLRestoreResp struct {
|
||||||
|
Data struct {
|
||||||
|
TaskID string `json:"task_id"`
|
||||||
|
} `json:"data"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增辅助结构体
|
||||||
|
type XLShareInfo struct {
|
||||||
|
ShareID string `json:"share_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Files []XLFileInfo `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type XLFileInfo struct {
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type XLTaskResult struct {
|
||||||
|
Status int `json:"status"`
|
||||||
|
TaskID string `json:"task_id"`
|
||||||
|
Data struct {
|
||||||
|
ShareID string `json:"share_id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
1
db/ad.txt
Normal file
1
db/ad.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
微信,独家,V信,v信,威信,胖狗资源,加微,会员群,q群,v群,公众号,广告,特价,最后机会,不要错过,立减,立得,赚,省,回扣,抽奖,失效,年会员,空间容量,微信群,群文件,全网资源,影视资源,扫码,最新资源,IMG_,资源汇总,緑铯粢源,.url,网盘推广,大额优惠券,资源文档,dy8.xyz,妙妙屋,资源合集,kkdm,赚收益
|
||||||
@@ -81,6 +81,7 @@ func InitDB() error {
|
|||||||
&entity.ResourceView{},
|
&entity.ResourceView{},
|
||||||
&entity.Task{},
|
&entity.Task{},
|
||||||
&entity.TaskItem{},
|
&entity.TaskItem{},
|
||||||
|
&entity.File{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.Fatal("数据库迁移失败: %v", err)
|
utils.Fatal("数据库迁移失败: %v", err)
|
||||||
@@ -144,6 +145,7 @@ func autoMigrate() error {
|
|||||||
&entity.User{},
|
&entity.User{},
|
||||||
&entity.SearchStat{},
|
&entity.SearchStat{},
|
||||||
&entity.HotDrama{},
|
&entity.HotDrama{},
|
||||||
|
&entity.File{},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,8 +259,17 @@ func insertDefaultDataIfEmpty() error {
|
|||||||
{Key: entity.ConfigKeyAutoFetchHotDramaEnabled, Value: entity.ConfigDefaultAutoFetchHotDramaEnabled, Type: entity.ConfigTypeBool},
|
{Key: entity.ConfigKeyAutoFetchHotDramaEnabled, Value: entity.ConfigDefaultAutoFetchHotDramaEnabled, Type: entity.ConfigTypeBool},
|
||||||
{Key: entity.ConfigKeyApiToken, Value: entity.ConfigDefaultApiToken, Type: entity.ConfigTypeString},
|
{Key: entity.ConfigKeyApiToken, Value: entity.ConfigDefaultApiToken, Type: entity.ConfigTypeString},
|
||||||
{Key: entity.ConfigKeyForbiddenWords, Value: entity.ConfigDefaultForbiddenWords, 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.ConfigKeyPageSize, Value: entity.ConfigDefaultPageSize, Type: entity.ConfigTypeInt},
|
||||||
{Key: entity.ConfigKeyMaintenanceMode, Value: entity.ConfigDefaultMaintenanceMode, Type: entity.ConfigTypeBool},
|
{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 {
|
for _, config := range defaultSystemConfigs {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
package converter
|
package converter
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"reflect"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ctwj/urldb/db/dto"
|
"github.com/ctwj/urldb/db/dto"
|
||||||
"github.com/ctwj/urldb/db/entity"
|
"github.com/ctwj/urldb/db/entity"
|
||||||
)
|
)
|
||||||
@@ -10,22 +11,24 @@ import (
|
|||||||
// ToResourceResponse 将Resource实体转换为ResourceResponse
|
// ToResourceResponse 将Resource实体转换为ResourceResponse
|
||||||
func ToResourceResponse(resource *entity.Resource) dto.ResourceResponse {
|
func ToResourceResponse(resource *entity.Resource) dto.ResourceResponse {
|
||||||
response := dto.ResourceResponse{
|
response := dto.ResourceResponse{
|
||||||
ID: resource.ID,
|
ID: resource.ID,
|
||||||
Title: resource.Title,
|
Title: resource.Title,
|
||||||
Description: resource.Description,
|
Description: resource.Description,
|
||||||
URL: resource.URL,
|
URL: resource.URL,
|
||||||
PanID: resource.PanID,
|
PanID: resource.PanID,
|
||||||
SaveURL: resource.SaveURL,
|
SaveURL: resource.SaveURL,
|
||||||
FileSize: resource.FileSize,
|
FileSize: resource.FileSize,
|
||||||
CategoryID: resource.CategoryID,
|
CategoryID: resource.CategoryID,
|
||||||
ViewCount: resource.ViewCount,
|
ViewCount: resource.ViewCount,
|
||||||
IsValid: resource.IsValid,
|
IsValid: resource.IsValid,
|
||||||
IsPublic: resource.IsPublic,
|
IsPublic: resource.IsPublic,
|
||||||
CreatedAt: resource.CreatedAt,
|
CreatedAt: resource.CreatedAt,
|
||||||
UpdatedAt: resource.UpdatedAt,
|
UpdatedAt: resource.UpdatedAt,
|
||||||
Cover: resource.Cover,
|
Cover: resource.Cover,
|
||||||
Author: resource.Author,
|
Author: resource.Author,
|
||||||
ErrorMsg: resource.ErrorMsg,
|
ErrorMsg: resource.ErrorMsg,
|
||||||
|
SyncedToMeilisearch: resource.SyncedToMeilisearch,
|
||||||
|
SyncedAt: resource.SyncedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置分类名称
|
// 设置分类名称
|
||||||
@@ -47,6 +50,89 @@ func ToResourceResponse(resource *entity.Resource) dto.ResourceResponse {
|
|||||||
return response
|
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列表
|
// ToResourceResponseList 将Resource实体列表转换为ResourceResponse列表
|
||||||
func ToResourceResponseList(resources []entity.Resource) []dto.ResourceResponse {
|
func ToResourceResponseList(resources []entity.Resource) []dto.ResourceResponse {
|
||||||
responses := make([]dto.ResourceResponse, len(resources))
|
responses := make([]dto.ResourceResponse, len(resources))
|
||||||
@@ -176,7 +262,7 @@ func ToReadyResourceResponse(resource *entity.ReadyResource) dto.ReadyResourceRe
|
|||||||
if isDeleted {
|
if isDeleted {
|
||||||
deletedAt = &resource.DeletedAt.Time
|
deletedAt = &resource.DeletedAt.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
return dto.ReadyResourceResponse{
|
return dto.ReadyResourceResponse{
|
||||||
ID: resource.ID,
|
ID: resource.ID,
|
||||||
Title: resource.Title,
|
Title: resource.Title,
|
||||||
|
|||||||
54
db/converter/file_converter.go
Normal file
54
db/converter/file_converter.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package converter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/ctwj/urldb/db/dto"
|
||||||
|
"github.com/ctwj/urldb/db/entity"
|
||||||
|
"github.com/ctwj/urldb/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileToResponse 将文件实体转换为响应DTO
|
||||||
|
func FileToResponse(file *entity.File) dto.FileResponse {
|
||||||
|
response := dto.FileResponse{
|
||||||
|
ID: file.ID,
|
||||||
|
CreatedAt: utils.FormatTime(file.CreatedAt, "2006-01-02 15:04:05"),
|
||||||
|
UpdatedAt: utils.FormatTime(file.UpdatedAt, "2006-01-02 15:04:05"),
|
||||||
|
OriginalName: file.OriginalName,
|
||||||
|
FileName: file.FileName,
|
||||||
|
FilePath: file.FilePath,
|
||||||
|
FileSize: file.FileSize,
|
||||||
|
FileType: file.FileType,
|
||||||
|
MimeType: file.MimeType,
|
||||||
|
FileHash: file.FileHash,
|
||||||
|
AccessURL: file.AccessURL,
|
||||||
|
UserID: file.UserID,
|
||||||
|
Status: file.Status,
|
||||||
|
IsPublic: file.IsPublic,
|
||||||
|
IsDeleted: file.IsDeleted,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加用户名
|
||||||
|
if file.User.ID > 0 {
|
||||||
|
response.User = file.User.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilesToResponse 将文件实体列表转换为响应DTO列表
|
||||||
|
func FilesToResponse(files []entity.File) []dto.FileResponse {
|
||||||
|
var responses []dto.FileResponse
|
||||||
|
for _, file := range files {
|
||||||
|
responses = append(responses, FileToResponse(&file))
|
||||||
|
}
|
||||||
|
return responses
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileListToResponse 将文件列表转换为列表响应
|
||||||
|
func FileListToResponse(files []entity.File, total int64, page, pageSize int) dto.FileListResponse {
|
||||||
|
return dto.FileListResponse{
|
||||||
|
Files: FilesToResponse(files),
|
||||||
|
Total: total,
|
||||||
|
Page: page,
|
||||||
|
Size: pageSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,8 @@ func SystemConfigToResponse(configs []entity.SystemConfig) *dto.SystemConfigResp
|
|||||||
response.Author = config.Value
|
response.Author = config.Value
|
||||||
case entity.ConfigKeyCopyright:
|
case entity.ConfigKeyCopyright:
|
||||||
response.Copyright = config.Value
|
response.Copyright = config.Value
|
||||||
|
case entity.ConfigKeySiteLogo:
|
||||||
|
response.SiteLogo = config.Value
|
||||||
case entity.ConfigKeyAutoProcessReadyResources:
|
case entity.ConfigKeyAutoProcessReadyResources:
|
||||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||||
response.AutoProcessReadyResources = val
|
response.AutoProcessReadyResources = val
|
||||||
@@ -58,6 +60,10 @@ func SystemConfigToResponse(configs []entity.SystemConfig) *dto.SystemConfigResp
|
|||||||
response.ApiToken = config.Value
|
response.ApiToken = config.Value
|
||||||
case entity.ConfigKeyForbiddenWords:
|
case entity.ConfigKeyForbiddenWords:
|
||||||
response.ForbiddenWords = config.Value
|
response.ForbiddenWords = config.Value
|
||||||
|
case entity.ConfigKeyAdKeywords:
|
||||||
|
response.AdKeywords = config.Value
|
||||||
|
case entity.ConfigKeyAutoInsertAd:
|
||||||
|
response.AutoInsertAd = config.Value
|
||||||
case entity.ConfigKeyPageSize:
|
case entity.ConfigKeyPageSize:
|
||||||
if val, err := strconv.Atoi(config.Value); err == nil {
|
if val, err := strconv.Atoi(config.Value); err == nil {
|
||||||
response.PageSize = val
|
response.PageSize = val
|
||||||
@@ -72,6 +78,18 @@ func SystemConfigToResponse(configs []entity.SystemConfig) *dto.SystemConfigResp
|
|||||||
}
|
}
|
||||||
case entity.ConfigKeyThirdPartyStatsCode:
|
case entity.ConfigKeyThirdPartyStatsCode:
|
||||||
response.ThirdPartyStatsCode = config.Value
|
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 configs []entity.SystemConfig
|
||||||
|
var updatedKeys []string
|
||||||
|
|
||||||
// 只添加有值的字段
|
// 字符串字段 - 只处理被设置的字段
|
||||||
if req.SiteTitle != "" {
|
if req.SiteTitle != nil {
|
||||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeySiteTitle, Value: req.SiteTitle, Type: entity.ConfigTypeString})
|
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeySiteTitle, Value: *req.SiteTitle, Type: entity.ConfigTypeString})
|
||||||
|
updatedKeys = append(updatedKeys, entity.ConfigKeySiteTitle)
|
||||||
}
|
}
|
||||||
if req.SiteDescription != "" {
|
if req.SiteDescription != nil {
|
||||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeySiteDescription, Value: req.SiteDescription, Type: entity.ConfigTypeString})
|
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeySiteDescription, Value: *req.SiteDescription, Type: entity.ConfigTypeString})
|
||||||
|
updatedKeys = append(updatedKeys, entity.ConfigKeySiteDescription)
|
||||||
}
|
}
|
||||||
if req.Keywords != "" {
|
if req.Keywords != nil {
|
||||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyKeywords, Value: req.Keywords, Type: entity.ConfigTypeString})
|
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyKeywords, Value: *req.Keywords, Type: entity.ConfigTypeString})
|
||||||
|
updatedKeys = append(updatedKeys, entity.ConfigKeyKeywords)
|
||||||
}
|
}
|
||||||
if req.Author != "" {
|
if req.Author != nil {
|
||||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAuthor, Value: req.Author, Type: entity.ConfigTypeString})
|
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAuthor, Value: *req.Author, Type: entity.ConfigTypeString})
|
||||||
|
updatedKeys = append(updatedKeys, entity.ConfigKeyAuthor)
|
||||||
}
|
}
|
||||||
if req.Copyright != "" {
|
if req.Copyright != nil {
|
||||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyCopyright, Value: req.Copyright, Type: entity.ConfigTypeString})
|
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyCopyright, Value: *req.Copyright, Type: entity.ConfigTypeString})
|
||||||
|
updatedKeys = append(updatedKeys, entity.ConfigKeyCopyright)
|
||||||
}
|
}
|
||||||
if req.ApiToken != "" {
|
if req.SiteLogo != nil {
|
||||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyApiToken, Value: req.ApiToken, Type: entity.ConfigTypeString})
|
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeySiteLogo, Value: *req.SiteLogo, Type: entity.ConfigTypeString})
|
||||||
|
updatedKeys = append(updatedKeys, entity.ConfigKeySiteLogo)
|
||||||
}
|
}
|
||||||
if req.ForbiddenWords != "" {
|
if req.ApiToken != nil {
|
||||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyForbiddenWords, Value: req.ForbiddenWords, Type: entity.ConfigTypeString})
|
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 的零值机制,我们需要通过其他方式判断字段是否被提交
|
if req.AutoProcessReadyResources != nil {
|
||||||
// 这里暂时保持原样,但建议前端只提交有变化的字段
|
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAutoProcessReadyResources, Value: strconv.FormatBool(*req.AutoProcessReadyResources), Type: entity.ConfigTypeBool})
|
||||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAutoProcessReadyResources, Value: strconv.FormatBool(req.AutoProcessReadyResources), Type: entity.ConfigTypeBool})
|
updatedKeys = append(updatedKeys, entity.ConfigKeyAutoProcessReadyResources)
|
||||||
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})
|
if req.AutoTransferEnabled != nil {
|
||||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyMaintenanceMode, Value: strconv.FormatBool(req.MaintenanceMode), 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.ConfigKeyEnableRegister, Value: strconv.FormatBool(req.EnableRegister), 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})
|
if req.AutoProcessInterval != nil {
|
||||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAutoTransferLimitDays, Value: strconv.Itoa(req.AutoTransferLimitDays), Type: entity.ConfigTypeInt})
|
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAutoProcessInterval, Value: strconv.Itoa(*req.AutoProcessInterval), Type: entity.ConfigTypeInt})
|
||||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAutoTransferMinSpace, Value: strconv.Itoa(req.AutoTransferMinSpace), Type: entity.ConfigTypeInt})
|
updatedKeys = append(updatedKeys, entity.ConfigKeyAutoProcessInterval)
|
||||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyPageSize, Value: strconv.Itoa(req.PageSize), Type: entity.ConfigTypeInt})
|
}
|
||||||
|
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 != "" {
|
if req.ThirdPartyStatsCode != nil {
|
||||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyThirdPartyStatsCode, Value: req.ThirdPartyStatsCode, Type: entity.ConfigTypeString})
|
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
|
return configs
|
||||||
@@ -149,6 +240,7 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
|
|||||||
entity.ConfigResponseFieldKeywords: entity.ConfigDefaultKeywords,
|
entity.ConfigResponseFieldKeywords: entity.ConfigDefaultKeywords,
|
||||||
entity.ConfigResponseFieldAuthor: entity.ConfigDefaultAuthor,
|
entity.ConfigResponseFieldAuthor: entity.ConfigDefaultAuthor,
|
||||||
entity.ConfigResponseFieldCopyright: entity.ConfigDefaultCopyright,
|
entity.ConfigResponseFieldCopyright: entity.ConfigDefaultCopyright,
|
||||||
|
"site_logo": "",
|
||||||
entity.ConfigResponseFieldAutoProcessReadyResources: false,
|
entity.ConfigResponseFieldAutoProcessReadyResources: false,
|
||||||
entity.ConfigResponseFieldAutoProcessInterval: 30,
|
entity.ConfigResponseFieldAutoProcessInterval: 30,
|
||||||
entity.ConfigResponseFieldAutoTransferEnabled: false,
|
entity.ConfigResponseFieldAutoTransferEnabled: false,
|
||||||
@@ -156,9 +248,17 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
|
|||||||
entity.ConfigResponseFieldAutoTransferMinSpace: 100,
|
entity.ConfigResponseFieldAutoTransferMinSpace: 100,
|
||||||
entity.ConfigResponseFieldAutoFetchHotDramaEnabled: false,
|
entity.ConfigResponseFieldAutoFetchHotDramaEnabled: false,
|
||||||
entity.ConfigResponseFieldForbiddenWords: "",
|
entity.ConfigResponseFieldForbiddenWords: "",
|
||||||
|
entity.ConfigResponseFieldAdKeywords: "",
|
||||||
|
entity.ConfigResponseFieldAutoInsertAd: "",
|
||||||
entity.ConfigResponseFieldPageSize: 100,
|
entity.ConfigResponseFieldPageSize: 100,
|
||||||
entity.ConfigResponseFieldMaintenanceMode: false,
|
entity.ConfigResponseFieldMaintenanceMode: false,
|
||||||
entity.ConfigResponseFieldEnableRegister: true, // 默认开启注册功能
|
entity.ConfigResponseFieldEnableRegister: true, // 默认开启注册功能
|
||||||
|
entity.ConfigResponseFieldThirdPartyStatsCode: "",
|
||||||
|
entity.ConfigResponseFieldMeilisearchEnabled: false,
|
||||||
|
entity.ConfigResponseFieldMeilisearchHost: "localhost",
|
||||||
|
entity.ConfigResponseFieldMeilisearchPort: "7700",
|
||||||
|
entity.ConfigResponseFieldMeilisearchMasterKey: "",
|
||||||
|
entity.ConfigResponseFieldMeilisearchIndexName: "resources",
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将键值对转换为map
|
// 将键值对转换为map
|
||||||
@@ -174,6 +274,8 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
|
|||||||
response[entity.ConfigResponseFieldAuthor] = config.Value
|
response[entity.ConfigResponseFieldAuthor] = config.Value
|
||||||
case entity.ConfigKeyCopyright:
|
case entity.ConfigKeyCopyright:
|
||||||
response[entity.ConfigResponseFieldCopyright] = config.Value
|
response[entity.ConfigResponseFieldCopyright] = config.Value
|
||||||
|
case entity.ConfigKeySiteLogo:
|
||||||
|
response["site_logo"] = config.Value
|
||||||
case entity.ConfigKeyAutoProcessReadyResources:
|
case entity.ConfigKeyAutoProcessReadyResources:
|
||||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||||
response[entity.ConfigResponseFieldAutoProcessReadyResources] = val
|
response[entity.ConfigResponseFieldAutoProcessReadyResources] = val
|
||||||
@@ -200,6 +302,10 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
|
|||||||
}
|
}
|
||||||
case entity.ConfigKeyForbiddenWords:
|
case entity.ConfigKeyForbiddenWords:
|
||||||
response[entity.ConfigResponseFieldForbiddenWords] = config.Value
|
response[entity.ConfigResponseFieldForbiddenWords] = config.Value
|
||||||
|
case entity.ConfigKeyAdKeywords:
|
||||||
|
response[entity.ConfigResponseFieldAdKeywords] = config.Value
|
||||||
|
case entity.ConfigKeyAutoInsertAd:
|
||||||
|
response[entity.ConfigResponseFieldAutoInsertAd] = config.Value
|
||||||
case entity.ConfigKeyPageSize:
|
case entity.ConfigKeyPageSize:
|
||||||
if val, err := strconv.Atoi(config.Value); err == nil {
|
if val, err := strconv.Atoi(config.Value); err == nil {
|
||||||
response[entity.ConfigResponseFieldPageSize] = val
|
response[entity.ConfigResponseFieldPageSize] = val
|
||||||
@@ -214,6 +320,18 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
|
|||||||
}
|
}
|
||||||
case entity.ConfigKeyThirdPartyStatsCode:
|
case entity.ConfigKeyThirdPartyStatsCode:
|
||||||
response[entity.ConfigResponseFieldThirdPartyStatsCode] = config.Value
|
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,
|
Keywords: entity.ConfigDefaultKeywords,
|
||||||
Author: entity.ConfigDefaultAuthor,
|
Author: entity.ConfigDefaultAuthor,
|
||||||
Copyright: entity.ConfigDefaultCopyright,
|
Copyright: entity.ConfigDefaultCopyright,
|
||||||
|
SiteLogo: "",
|
||||||
AutoProcessReadyResources: false,
|
AutoProcessReadyResources: false,
|
||||||
AutoProcessInterval: 30,
|
AutoProcessInterval: 30,
|
||||||
AutoTransferEnabled: false,
|
AutoTransferEnabled: false,
|
||||||
@@ -242,9 +361,16 @@ func getDefaultConfigResponse() *dto.SystemConfigResponse {
|
|||||||
AutoFetchHotDramaEnabled: false,
|
AutoFetchHotDramaEnabled: false,
|
||||||
ApiToken: entity.ConfigDefaultApiToken,
|
ApiToken: entity.ConfigDefaultApiToken,
|
||||||
ForbiddenWords: entity.ConfigDefaultForbiddenWords,
|
ForbiddenWords: entity.ConfigDefaultForbiddenWords,
|
||||||
|
AdKeywords: entity.ConfigDefaultAdKeywords,
|
||||||
|
AutoInsertAd: entity.ConfigDefaultAutoInsertAd,
|
||||||
PageSize: 100,
|
PageSize: 100,
|
||||||
MaintenanceMode: false,
|
MaintenanceMode: false,
|
||||||
EnableRegister: true, // 默认开启注册功能
|
EnableRegister: true, // 默认开启注册功能
|
||||||
ThirdPartyStatsCode: entity.ConfigDefaultThirdPartyStatsCode,
|
ThirdPartyStatsCode: entity.ConfigDefaultThirdPartyStatsCode,
|
||||||
|
MeilisearchEnabled: false,
|
||||||
|
MeilisearchHost: entity.ConfigDefaultMeilisearchHost,
|
||||||
|
MeilisearchPort: entity.ConfigDefaultMeilisearchPort,
|
||||||
|
MeilisearchMasterKey: entity.ConfigDefaultMeilisearchMasterKey,
|
||||||
|
MeilisearchIndexName: entity.ConfigDefaultMeilisearchIndexName,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
73
db/dto/file.go
Normal file
73
db/dto/file.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// FileUploadRequest 文件上传请求
|
||||||
|
type FileUploadRequest struct {
|
||||||
|
IsPublic bool `json:"is_public" form:"is_public"` // 是否公开
|
||||||
|
FileHash string `json:"file_hash" form:"file_hash"` // 文件哈希值
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileResponse 文件响应
|
||||||
|
type FileResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
|
||||||
|
// 文件信息
|
||||||
|
OriginalName string `json:"original_name"`
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
FilePath string `json:"file_path"`
|
||||||
|
FileSize int64 `json:"file_size"`
|
||||||
|
FileType string `json:"file_type"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
FileHash string `json:"file_hash"`
|
||||||
|
|
||||||
|
// 访问信息
|
||||||
|
AccessURL string `json:"access_url"`
|
||||||
|
|
||||||
|
// 用户信息
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
|
User string `json:"user"` // 用户名
|
||||||
|
|
||||||
|
// 状态信息
|
||||||
|
Status string `json:"status"`
|
||||||
|
IsPublic bool `json:"is_public"`
|
||||||
|
IsDeleted bool `json:"is_deleted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileListRequest 文件列表请求
|
||||||
|
type FileListRequest struct {
|
||||||
|
Page int `json:"page" form:"page"`
|
||||||
|
PageSize int `json:"page_size" form:"page_size"`
|
||||||
|
Search string `json:"search" form:"search"`
|
||||||
|
FileType string `json:"file_type" form:"file_type"`
|
||||||
|
Status string `json:"status" form:"status"`
|
||||||
|
UserID uint `json:"user_id" form:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileListResponse 文件列表响应
|
||||||
|
type FileListResponse struct {
|
||||||
|
Files []FileResponse `json:"files"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileUploadResponse 文件上传响应
|
||||||
|
type FileUploadResponse struct {
|
||||||
|
File FileResponse `json:"file"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
IsDuplicate bool `json:"is_duplicate"` // 是否为重复文件
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileDeleteRequest 文件删除请求
|
||||||
|
type FileDeleteRequest struct {
|
||||||
|
IDs []uint `json:"ids" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileUpdateRequest 文件更新请求
|
||||||
|
type FileUpdateRequest struct {
|
||||||
|
ID uint `json:"id" binding:"required"`
|
||||||
|
IsPublic *bool `json:"is_public"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
@@ -12,24 +12,34 @@ type SearchResponse struct {
|
|||||||
|
|
||||||
// ResourceResponse 资源响应
|
// ResourceResponse 资源响应
|
||||||
type ResourceResponse struct {
|
type ResourceResponse struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
PanID *uint `json:"pan_id"`
|
PanID *uint `json:"pan_id"`
|
||||||
SaveURL string `json:"save_url"`
|
SaveURL string `json:"save_url"`
|
||||||
FileSize string `json:"file_size"`
|
FileSize string `json:"file_size"`
|
||||||
CategoryID *uint `json:"category_id"`
|
CategoryID *uint `json:"category_id"`
|
||||||
CategoryName string `json:"category_name"`
|
CategoryName string `json:"category_name"`
|
||||||
ViewCount int `json:"view_count"`
|
ViewCount int `json:"view_count"`
|
||||||
IsValid bool `json:"is_valid"`
|
IsValid bool `json:"is_valid"`
|
||||||
IsPublic bool `json:"is_public"`
|
IsPublic bool `json:"is_public"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
Tags []TagResponse `json:"tags"`
|
Tags []TagResponse `json:"tags"`
|
||||||
Cover string `json:"cover"`
|
Cover string `json:"cover"`
|
||||||
Author string `json:"author"`
|
Author string `json:"author"`
|
||||||
ErrorMsg string `json:"error_msg"`
|
ErrorMsg string `json:"error_msg"`
|
||||||
|
SyncedToMeilisearch bool `json:"synced_to_meilisearch"`
|
||||||
|
SyncedAt *time.Time `json:"synced_at"`
|
||||||
|
// 高亮字段
|
||||||
|
TitleHighlight string `json:"title_highlight,omitempty"`
|
||||||
|
DescriptionHighlight string `json:"description_highlight,omitempty"`
|
||||||
|
CategoryHighlight string `json:"category_highlight,omitempty"`
|
||||||
|
TagsHighlight []string `json:"tags_highlight,omitempty"`
|
||||||
|
// 违禁词相关字段
|
||||||
|
HasForbiddenWords bool `json:"has_forbidden_words"`
|
||||||
|
ForbiddenWords []string `json:"forbidden_words"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CategoryResponse 分类响应
|
// CategoryResponse 分类响应
|
||||||
|
|||||||
@@ -3,33 +3,45 @@ package dto
|
|||||||
// SystemConfigRequest 系统配置请求
|
// SystemConfigRequest 系统配置请求
|
||||||
type SystemConfigRequest struct {
|
type SystemConfigRequest struct {
|
||||||
// SEO 配置
|
// SEO 配置
|
||||||
SiteTitle string `json:"site_title"`
|
SiteTitle *string `json:"site_title,omitempty"`
|
||||||
SiteDescription string `json:"site_description"`
|
SiteDescription *string `json:"site_description,omitempty"`
|
||||||
Keywords string `json:"keywords"`
|
Keywords *string `json:"keywords,omitempty"`
|
||||||
Author string `json:"author"`
|
Author *string `json:"author,omitempty"`
|
||||||
Copyright string `json:"copyright"`
|
Copyright *string `json:"copyright,omitempty"`
|
||||||
|
SiteLogo *string `json:"site_logo,omitempty"`
|
||||||
|
|
||||||
// 自动处理配置组
|
// 自动处理配置组
|
||||||
AutoProcessReadyResources bool `json:"auto_process_ready_resources"` // 自动处理待处理资源
|
AutoProcessReadyResources *bool `json:"auto_process_ready_resources,omitempty"` // 自动处理待处理资源
|
||||||
AutoProcessInterval int `json:"auto_process_interval"` // 自动处理间隔(分钟)
|
AutoProcessInterval *int `json:"auto_process_interval,omitempty"` // 自动处理间隔(分钟)
|
||||||
AutoTransferEnabled bool `json:"auto_transfer_enabled"` // 开启自动转存
|
AutoTransferEnabled *bool `json:"auto_transfer_enabled,omitempty"` // 开启自动转存
|
||||||
AutoTransferLimitDays int `json:"auto_transfer_limit_days"` // 自动转存限制天数(0表示不限制)
|
AutoTransferLimitDays *int `json:"auto_transfer_limit_days,omitempty"` // 自动转存限制天数(0表示不限制)
|
||||||
AutoTransferMinSpace int `json:"auto_transfer_min_space"` // 最小存储空间(GB)
|
AutoTransferMinSpace *int `json:"auto_transfer_min_space,omitempty"` // 最小存储空间(GB)
|
||||||
AutoFetchHotDramaEnabled bool `json:"auto_fetch_hot_drama_enabled"` // 自动拉取热播剧名字
|
AutoFetchHotDramaEnabled *bool `json:"auto_fetch_hot_drama_enabled,omitempty"` // 自动拉取热播剧名字
|
||||||
|
|
||||||
// API配置
|
// 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"`
|
PageSize *int `json:"page_size,omitempty"`
|
||||||
MaintenanceMode bool `json:"maintenance_mode"`
|
MaintenanceMode *bool `json:"maintenance_mode,omitempty"`
|
||||||
EnableRegister bool `json:"enable_register"` // 开启注册功能
|
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 系统配置响应
|
// SystemConfigResponse 系统配置响应
|
||||||
@@ -44,6 +56,7 @@ type SystemConfigResponse struct {
|
|||||||
Keywords string `json:"keywords"`
|
Keywords string `json:"keywords"`
|
||||||
Author string `json:"author"`
|
Author string `json:"author"`
|
||||||
Copyright string `json:"copyright"`
|
Copyright string `json:"copyright"`
|
||||||
|
SiteLogo string `json:"site_logo"`
|
||||||
|
|
||||||
// 自动处理配置组
|
// 自动处理配置组
|
||||||
AutoProcessReadyResources bool `json:"auto_process_ready_resources"` // 自动处理待处理资源
|
AutoProcessReadyResources bool `json:"auto_process_ready_resources"` // 自动处理待处理资源
|
||||||
@@ -59,6 +72,10 @@ type SystemConfigResponse struct {
|
|||||||
// 违禁词配置
|
// 违禁词配置
|
||||||
ForbiddenWords string `json:"forbidden_words"` // 违禁词列表,用逗号分隔
|
ForbiddenWords string `json:"forbidden_words"` // 违禁词列表,用逗号分隔
|
||||||
|
|
||||||
|
// 广告配置
|
||||||
|
AdKeywords string `json:"ad_keywords"` // 广告关键词列表,用逗号分隔
|
||||||
|
AutoInsertAd string `json:"auto_insert_ad"` // 自动插入广告内容
|
||||||
|
|
||||||
// 其他配置
|
// 其他配置
|
||||||
PageSize int `json:"page_size"`
|
PageSize int `json:"page_size"`
|
||||||
MaintenanceMode bool `json:"maintenance_mode"`
|
MaintenanceMode bool `json:"maintenance_mode"`
|
||||||
@@ -66,6 +83,13 @@ type SystemConfigResponse struct {
|
|||||||
|
|
||||||
// 三方统计配置
|
// 三方统计配置
|
||||||
ThirdPartyStatsCode string `json:"third_party_stats_code"` // 三方统计代码
|
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 单个配置项
|
// SystemConfigItem 单个配置项
|
||||||
|
|||||||
45
db/entity/file.go
Normal file
45
db/entity/file.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// File 文件实体
|
||||||
|
type File struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
// 文件信息
|
||||||
|
OriginalName string `json:"original_name" gorm:"size:255;not null;comment:原始文件名"`
|
||||||
|
FileName string `json:"file_name" gorm:"size:255;not null;unique;comment:存储文件名"`
|
||||||
|
FilePath string `json:"file_path" gorm:"size:500;not null;comment:文件路径"`
|
||||||
|
FileSize int64 `json:"file_size" gorm:"not null;comment:文件大小(字节)"`
|
||||||
|
FileType string `json:"file_type" gorm:"size:100;not null;comment:文件类型"`
|
||||||
|
MimeType string `json:"mime_type" gorm:"size:100;comment:MIME类型"`
|
||||||
|
FileHash string `json:"file_hash" gorm:"size:64;uniqueIndex;comment:文件哈希值"`
|
||||||
|
|
||||||
|
// 访问信息
|
||||||
|
AccessURL string `json:"access_url" gorm:"size:500;comment:访问URL"`
|
||||||
|
|
||||||
|
// 用户信息
|
||||||
|
UserID uint `json:"user_id" gorm:"comment:上传用户ID"`
|
||||||
|
User User `json:"user" gorm:"foreignKey:UserID"`
|
||||||
|
|
||||||
|
// 状态信息
|
||||||
|
Status string `json:"status" gorm:"size:20;default:'active';comment:文件状态"`
|
||||||
|
IsPublic bool `json:"is_public" gorm:"default:true;comment:是否公开"`
|
||||||
|
IsDeleted bool `json:"is_deleted" gorm:"default:false;comment:是否已删除"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名
|
||||||
|
func (File) TableName() string {
|
||||||
|
return "files"
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileStatus 文件状态常量
|
||||||
|
const (
|
||||||
|
FileStatusActive = "active" // 正常
|
||||||
|
FileStatusInactive = "inactive" // 禁用
|
||||||
|
FileStatusDeleted = "deleted" // 已删除
|
||||||
|
)
|
||||||
@@ -8,26 +8,28 @@ import (
|
|||||||
|
|
||||||
// Resource 资源模型
|
// Resource 资源模型
|
||||||
type Resource struct {
|
type Resource struct {
|
||||||
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
Title string `json:"title" gorm:"size:255;not null;comment:资源标题"`
|
Title string `json:"title" gorm:"size:255;not null;comment:资源标题"`
|
||||||
Description string `json:"description" gorm:"type:text;comment:资源描述"`
|
Description string `json:"description" gorm:"type:text;comment:资源描述"`
|
||||||
URL string `json:"url" gorm:"size:128;comment:资源链接"`
|
URL string `json:"url" gorm:"size:128;comment:资源链接"`
|
||||||
PanID *uint `json:"pan_id" gorm:"comment:平台ID"`
|
PanID *uint `json:"pan_id" gorm:"comment:平台ID"`
|
||||||
SaveURL string `json:"save_url" gorm:"size:500;comment:转存后的链接"`
|
SaveURL string `json:"save_url" gorm:"size:500;comment:转存后的链接"`
|
||||||
FileSize string `json:"file_size" gorm:"size:100;comment:文件大小"`
|
FileSize string `json:"file_size" gorm:"size:100;comment:文件大小"`
|
||||||
CategoryID *uint `json:"category_id" gorm:"comment:分类ID"`
|
CategoryID *uint `json:"category_id" gorm:"comment:分类ID"`
|
||||||
ViewCount int `json:"view_count" gorm:"default:0;comment:浏览次数"`
|
ViewCount int `json:"view_count" gorm:"default:0;comment:浏览次数"`
|
||||||
IsValid bool `json:"is_valid" gorm:"default:true;comment:是否有效"`
|
IsValid bool `json:"is_valid" gorm:"default:true;comment:是否有效"`
|
||||||
IsPublic bool `json:"is_public" gorm:"default:true;comment:是否公开"`
|
IsPublic bool `json:"is_public" gorm:"default:true;comment:是否公开"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
||||||
Cover string `json:"cover" gorm:"size:500;comment:封面"`
|
Cover string `json:"cover" gorm:"size:500;comment:封面"`
|
||||||
Author string `json:"author" gorm:"size:100;comment:作者"`
|
Author string `json:"author" gorm:"size:100;comment:作者"`
|
||||||
ErrorMsg string `json:"error_msg" gorm:"size:255;comment:转存失败原因"`
|
ErrorMsg string `json:"error_msg" gorm:"size:255;comment:转存失败原因"`
|
||||||
CkID *uint `json:"ck_id" gorm:"comment:账号ID"`
|
CkID *uint `json:"ck_id" gorm:"comment:账号ID"`
|
||||||
Fid string `json:"fid" gorm:"size:128;comment:网盘文件ID"`
|
Fid string `json:"fid" gorm:"size:128;comment:网盘文件ID"`
|
||||||
Key string `json:"key" gorm:"size:64;index;comment:资源组标识,相同key表示同一组资源"`
|
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"`
|
Category Category `json:"category" gorm:"foreignKey:CategoryID"`
|
||||||
@@ -39,3 +41,23 @@ type Resource struct {
|
|||||||
func (Resource) TableName() string {
|
func (Resource) TableName() string {
|
||||||
return "resources"
|
return "resources"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTitle 获取资源标题(实现utils.Resource接口)
|
||||||
|
func (r *Resource) GetTitle() string {
|
||||||
|
return r.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDescription 获取资源描述(实现utils.Resource接口)
|
||||||
|
func (r *Resource) GetDescription() string {
|
||||||
|
return r.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTitle 设置资源标题(实现utils.Resource接口)
|
||||||
|
func (r *Resource) SetTitle(title string) {
|
||||||
|
r.Title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDescription 设置资源描述(实现utils.Resource接口)
|
||||||
|
func (r *Resource) SetDescription(description string) {
|
||||||
|
r.Description = description
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const (
|
|||||||
ConfigKeyKeywords = "keywords"
|
ConfigKeyKeywords = "keywords"
|
||||||
ConfigKeyAuthor = "author"
|
ConfigKeyAuthor = "author"
|
||||||
ConfigKeyCopyright = "copyright"
|
ConfigKeyCopyright = "copyright"
|
||||||
|
ConfigKeySiteLogo = "site_logo"
|
||||||
|
|
||||||
// 自动处理配置组
|
// 自动处理配置组
|
||||||
ConfigKeyAutoProcessReadyResources = "auto_process_ready_resources"
|
ConfigKeyAutoProcessReadyResources = "auto_process_ready_resources"
|
||||||
@@ -23,6 +24,10 @@ const (
|
|||||||
// 违禁词配置
|
// 违禁词配置
|
||||||
ConfigKeyForbiddenWords = "forbidden_words"
|
ConfigKeyForbiddenWords = "forbidden_words"
|
||||||
|
|
||||||
|
// 广告配置
|
||||||
|
ConfigKeyAdKeywords = "ad_keywords" // 广告关键词
|
||||||
|
ConfigKeyAutoInsertAd = "auto_insert_ad" // 自动插入广告
|
||||||
|
|
||||||
// 其他配置
|
// 其他配置
|
||||||
ConfigKeyPageSize = "page_size"
|
ConfigKeyPageSize = "page_size"
|
||||||
ConfigKeyMaintenanceMode = "maintenance_mode"
|
ConfigKeyMaintenanceMode = "maintenance_mode"
|
||||||
@@ -30,6 +35,13 @@ const (
|
|||||||
|
|
||||||
// 三方统计配置
|
// 三方统计配置
|
||||||
ConfigKeyThirdPartyStatsCode = "third_party_stats_code"
|
ConfigKeyThirdPartyStatsCode = "third_party_stats_code"
|
||||||
|
|
||||||
|
// Meilisearch配置
|
||||||
|
ConfigKeyMeilisearchEnabled = "meilisearch_enabled"
|
||||||
|
ConfigKeyMeilisearchHost = "meilisearch_host"
|
||||||
|
ConfigKeyMeilisearchPort = "meilisearch_port"
|
||||||
|
ConfigKeyMeilisearchMasterKey = "meilisearch_master_key"
|
||||||
|
ConfigKeyMeilisearchIndexName = "meilisearch_index_name"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConfigType 配置类型常量
|
// ConfigType 配置类型常量
|
||||||
@@ -68,6 +80,10 @@ const (
|
|||||||
// 违禁词配置字段
|
// 违禁词配置字段
|
||||||
ConfigResponseFieldForbiddenWords = "forbidden_words"
|
ConfigResponseFieldForbiddenWords = "forbidden_words"
|
||||||
|
|
||||||
|
// 广告配置字段
|
||||||
|
ConfigResponseFieldAdKeywords = "ad_keywords"
|
||||||
|
ConfigResponseFieldAutoInsertAd = "auto_insert_ad"
|
||||||
|
|
||||||
// 其他配置字段
|
// 其他配置字段
|
||||||
ConfigResponseFieldPageSize = "page_size"
|
ConfigResponseFieldPageSize = "page_size"
|
||||||
ConfigResponseFieldMaintenanceMode = "maintenance_mode"
|
ConfigResponseFieldMaintenanceMode = "maintenance_mode"
|
||||||
@@ -75,6 +91,13 @@ const (
|
|||||||
|
|
||||||
// 三方统计配置字段
|
// 三方统计配置字段
|
||||||
ConfigResponseFieldThirdPartyStatsCode = "third_party_stats_code"
|
ConfigResponseFieldThirdPartyStatsCode = "third_party_stats_code"
|
||||||
|
|
||||||
|
// Meilisearch配置字段
|
||||||
|
ConfigResponseFieldMeilisearchEnabled = "meilisearch_enabled"
|
||||||
|
ConfigResponseFieldMeilisearchHost = "meilisearch_host"
|
||||||
|
ConfigResponseFieldMeilisearchPort = "meilisearch_port"
|
||||||
|
ConfigResponseFieldMeilisearchMasterKey = "meilisearch_master_key"
|
||||||
|
ConfigResponseFieldMeilisearchIndexName = "meilisearch_index_name"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConfigDefaultValue 配置默认值常量
|
// ConfigDefaultValue 配置默认值常量
|
||||||
@@ -100,6 +123,10 @@ const (
|
|||||||
// 违禁词配置默认值
|
// 违禁词配置默认值
|
||||||
ConfigDefaultForbiddenWords = ""
|
ConfigDefaultForbiddenWords = ""
|
||||||
|
|
||||||
|
// 广告配置默认值
|
||||||
|
ConfigDefaultAdKeywords = ""
|
||||||
|
ConfigDefaultAutoInsertAd = ""
|
||||||
|
|
||||||
// 其他配置默认值
|
// 其他配置默认值
|
||||||
ConfigDefaultPageSize = "100"
|
ConfigDefaultPageSize = "100"
|
||||||
ConfigDefaultMaintenanceMode = "false"
|
ConfigDefaultMaintenanceMode = "false"
|
||||||
@@ -107,4 +134,11 @@ const (
|
|||||||
|
|
||||||
// 三方统计配置默认值
|
// 三方统计配置默认值
|
||||||
ConfigDefaultThirdPartyStatsCode = ""
|
ConfigDefaultThirdPartyStatsCode = ""
|
||||||
|
|
||||||
|
// Meilisearch配置默认值
|
||||||
|
ConfigDefaultMeilisearchEnabled = "false"
|
||||||
|
ConfigDefaultMeilisearchHost = "localhost"
|
||||||
|
ConfigDefaultMeilisearchPort = "7700"
|
||||||
|
ConfigDefaultMeilisearchMasterKey = ""
|
||||||
|
ConfigDefaultMeilisearchIndexName = "resources"
|
||||||
)
|
)
|
||||||
|
|||||||
167
db/repo/file_repository.go
Normal file
167
db/repo/file_repository.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/ctwj/urldb/db/entity"
|
||||||
|
"github.com/ctwj/urldb/utils"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileRepository 文件Repository接口
|
||||||
|
type FileRepository interface {
|
||||||
|
BaseRepository[entity.File]
|
||||||
|
FindByFileName(fileName string) (*entity.File, error)
|
||||||
|
FindByHash(fileHash string) (*entity.File, error)
|
||||||
|
FindByUserID(userID uint, page, pageSize int) ([]entity.File, int64, error)
|
||||||
|
FindPublicFiles(page, pageSize int) ([]entity.File, int64, error)
|
||||||
|
SearchFiles(search string, fileType, status string, userID uint, page, pageSize int) ([]entity.File, int64, error)
|
||||||
|
SoftDeleteByIDs(ids []uint) error
|
||||||
|
UpdateFileStatus(id uint, status string) error
|
||||||
|
UpdateFilePublic(id uint, isPublic bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileRepositoryImpl 文件Repository实现
|
||||||
|
type FileRepositoryImpl struct {
|
||||||
|
BaseRepositoryImpl[entity.File]
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileRepository 创建文件Repository
|
||||||
|
func NewFileRepository(db *gorm.DB) FileRepository {
|
||||||
|
return &FileRepositoryImpl{
|
||||||
|
BaseRepositoryImpl: BaseRepositoryImpl[entity.File]{db: db},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByFileName 根据文件名查找文件
|
||||||
|
func (r *FileRepositoryImpl) FindByFileName(fileName string) (*entity.File, error) {
|
||||||
|
var file entity.File
|
||||||
|
err := r.db.Where("file_name = ? AND is_deleted = ?", fileName, false).First(&file).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByUserID 根据用户ID查找文件
|
||||||
|
func (r *FileRepositoryImpl) FindByUserID(userID uint, page, pageSize int) ([]entity.File, int64, error) {
|
||||||
|
var files []entity.File
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
// 获取总数
|
||||||
|
err := r.db.Model(&entity.File{}).Where("user_id = ? AND is_deleted = ?", userID, false).Count(&total).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取文件列表
|
||||||
|
err = r.db.Where("user_id = ? AND is_deleted = ?", userID, false).
|
||||||
|
Preload("User").
|
||||||
|
Order("created_at DESC").
|
||||||
|
Offset(offset).
|
||||||
|
Limit(pageSize).
|
||||||
|
Find(&files).Error
|
||||||
|
|
||||||
|
return files, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindPublicFiles 查找公开文件
|
||||||
|
func (r *FileRepositoryImpl) FindPublicFiles(page, pageSize int) ([]entity.File, int64, error) {
|
||||||
|
var files []entity.File
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
// 获取总数
|
||||||
|
err := r.db.Model(&entity.File{}).Where("is_public = ? AND is_deleted = ? AND status = ?", true, false, entity.FileStatusActive).Count(&total).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取文件列表
|
||||||
|
err = r.db.Where("is_public = ? AND is_deleted = ? AND status = ?", true, false, entity.FileStatusActive).
|
||||||
|
Preload("User").
|
||||||
|
Order("created_at DESC").
|
||||||
|
Offset(offset).
|
||||||
|
Limit(pageSize).
|
||||||
|
Find(&files).Error
|
||||||
|
|
||||||
|
return files, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchFiles 搜索文件
|
||||||
|
func (r *FileRepositoryImpl) SearchFiles(search string, fileType, status string, userID uint, page, pageSize int) ([]entity.File, int64, error) {
|
||||||
|
var files []entity.File
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
query := r.db.Model(&entity.File{}).Where("is_deleted = ?", false)
|
||||||
|
|
||||||
|
// 添加调试日志
|
||||||
|
utils.Info("搜索文件参数: search='%s', fileType='%s', status='%s', userID=%d, page=%d, pageSize=%d",
|
||||||
|
search, fileType, status, userID, page, pageSize)
|
||||||
|
|
||||||
|
// 添加搜索条件
|
||||||
|
if search != "" {
|
||||||
|
query = query.Where("original_name LIKE ?", "%"+search+"%")
|
||||||
|
utils.Info("添加搜索条件: 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
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ type RepositoryManager struct {
|
|||||||
ResourceViewRepository ResourceViewRepository
|
ResourceViewRepository ResourceViewRepository
|
||||||
TaskRepository TaskRepository
|
TaskRepository TaskRepository
|
||||||
TaskItemRepository TaskItemRepository
|
TaskItemRepository TaskItemRepository
|
||||||
|
FileRepository FileRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRepositoryManager 创建Repository管理器
|
// NewRepositoryManager 创建Repository管理器
|
||||||
@@ -37,5 +38,6 @@ func NewRepositoryManager(db *gorm.DB) *RepositoryManager {
|
|||||||
ResourceViewRepository: NewResourceViewRepository(db),
|
ResourceViewRepository: NewResourceViewRepository(db),
|
||||||
TaskRepository: NewTaskRepository(db),
|
TaskRepository: NewTaskRepository(db),
|
||||||
TaskItemRepository: NewTaskItemRepository(db),
|
TaskItemRepository: NewTaskItemRepository(db),
|
||||||
|
FileRepository: NewFileRepository(db),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ type ResourceRepository interface {
|
|||||||
GetByURL(url string) (*entity.Resource, error)
|
GetByURL(url string) (*entity.Resource, error)
|
||||||
UpdateSaveURL(id uint, saveURL string) error
|
UpdateSaveURL(id uint, saveURL string) error
|
||||||
CreateResourceTag(resourceTag *entity.ResourceTag) 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实现
|
// ResourceRepositoryImpl Resource的Repository实现
|
||||||
@@ -461,19 +469,145 @@ func (r *ResourceRepositoryImpl) GetResourcesForTransfer(panID uint, sinceTime t
|
|||||||
// GetByURL 根据URL获取资源
|
// GetByURL 根据URL获取资源
|
||||||
func (r *ResourceRepositoryImpl) GetByURL(url string) (*entity.Resource, error) {
|
func (r *ResourceRepositoryImpl) GetByURL(url string) (*entity.Resource, error) {
|
||||||
var resource entity.Resource
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &resource, nil
|
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 {
|
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 创建资源与标签的关联
|
// CreateResourceTag 创建资源与标签的关联
|
||||||
func (r *ResourceRepositoryImpl) CreateResourceTag(resourceTag *entity.ResourceTag) error {
|
func (r *ResourceRepositoryImpl) CreateResourceTag(resourceTag *entity.ResourceTag) error {
|
||||||
return r.GetDB().Create(resourceTag).Error
|
return r.db.Create(resourceTag).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindUnsyncedToMeilisearch 查找未同步到Meilisearch的资源
|
||||||
|
func (r *ResourceRepositoryImpl) FindUnsyncedToMeilisearch(page, limit int) ([]entity.Resource, int64, error) {
|
||||||
|
var resources []entity.Resource
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
|
||||||
|
// 查询未同步的资源
|
||||||
|
db := r.db.Model(&entity.Resource{}).
|
||||||
|
Where("synced_to_meilisearch = ?", false).
|
||||||
|
Preload("Category").
|
||||||
|
Preload("Pan").
|
||||||
|
Preload("Tags"). // 添加Tags预加载
|
||||||
|
Order("updated_at DESC")
|
||||||
|
|
||||||
|
// 获取总数
|
||||||
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取分页数据
|
||||||
|
err := db.Offset(offset).Limit(limit).Find(&resources).Error
|
||||||
|
return resources, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountUnsyncedToMeilisearch 统计未同步到Meilisearch的资源数量
|
||||||
|
func (r *ResourceRepositoryImpl) CountUnsyncedToMeilisearch() (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&entity.Resource{}).
|
||||||
|
Where("synced_to_meilisearch = ?", false).
|
||||||
|
Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAsSyncedToMeilisearch 标记资源为已同步到Meilisearch
|
||||||
|
func (r *ResourceRepositoryImpl) MarkAsSyncedToMeilisearch(ids []uint) error {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
return r.db.Model(&entity.Resource{}).
|
||||||
|
Where("id IN ?", ids).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"synced_to_meilisearch": true,
|
||||||
|
"synced_at": now,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAllAsUnsyncedToMeilisearch 标记所有资源为未同步到Meilisearch
|
||||||
|
func (r *ResourceRepositoryImpl) MarkAllAsUnsyncedToMeilisearch() error {
|
||||||
|
return r.db.Model(&entity.Resource{}).
|
||||||
|
Where("1 = 1"). // 添加WHERE条件以更新所有记录
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"synced_to_meilisearch": false,
|
||||||
|
"synced_at": nil,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindSyncedToMeilisearch 查找已同步到Meilisearch的资源
|
||||||
|
func (r *ResourceRepositoryImpl) FindSyncedToMeilisearch(page, limit int) ([]entity.Resource, int64, error) {
|
||||||
|
var resources []entity.Resource
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
|
||||||
|
// 查询已同步的资源
|
||||||
|
db := r.db.Model(&entity.Resource{}).
|
||||||
|
Where("synced_to_meilisearch = ?", true).
|
||||||
|
Preload("Category").
|
||||||
|
Preload("Pan").
|
||||||
|
Order("updated_at DESC")
|
||||||
|
|
||||||
|
// 获取总数
|
||||||
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取分页数据
|
||||||
|
err := db.Offset(offset).Limit(limit).Find(&resources).Error
|
||||||
|
return resources, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountSyncedToMeilisearch 统计已同步到Meilisearch的资源数量
|
||||||
|
func (r *ResourceRepositoryImpl) CountSyncedToMeilisearch() (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&entity.Resource{}).
|
||||||
|
Where("synced_to_meilisearch = ?", true).
|
||||||
|
Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAllWithPagination 分页查找所有资源
|
||||||
|
func (r *ResourceRepositoryImpl) FindAllWithPagination(page, limit int) ([]entity.Resource, int64, error) {
|
||||||
|
var resources []entity.Resource
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
|
||||||
|
// 查询所有资源
|
||||||
|
db := r.db.Model(&entity.Resource{}).
|
||||||
|
Preload("Category").
|
||||||
|
Preload("Pan").
|
||||||
|
Order("updated_at DESC")
|
||||||
|
|
||||||
|
// 获取总数
|
||||||
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取分页数据
|
||||||
|
err := db.Offset(offset).Limit(limit).Find(&resources).Error
|
||||||
|
return resources, total, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type SearchStatRepository interface {
|
|||||||
GetSearchTrend(days int) ([]entity.DailySearchStat, error)
|
GetSearchTrend(days int) ([]entity.DailySearchStat, error)
|
||||||
GetKeywordTrend(keyword string, days int) ([]entity.DailySearchStat, error)
|
GetKeywordTrend(keyword string, days int) ([]entity.DailySearchStat, error)
|
||||||
GetSummary() (map[string]int64, error)
|
GetSummary() (map[string]int64, error)
|
||||||
|
FindWithPaginationOrdered(page, limit int) ([]entity.SearchStat, int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchStatRepositoryImpl 搜索统计Repository实现
|
// SearchStatRepositoryImpl 搜索统计Repository实现
|
||||||
@@ -157,3 +158,20 @@ func (r *SearchStatRepositoryImpl) GetSummary() (map[string]int64, error) {
|
|||||||
"keywords": keywords,
|
"keywords": keywords,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindWithPaginationOrdered 按时间倒序分页查找搜索记录
|
||||||
|
func (r *SearchStatRepositoryImpl) FindWithPaginationOrdered(page, limit int) ([]entity.SearchStat, int64, error) {
|
||||||
|
var stats []entity.SearchStat
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
|
||||||
|
// 获取总数
|
||||||
|
if err := r.db.Model(&entity.SearchStat{}).Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取分页数据,按创建时间倒序排列(最新的在前面)
|
||||||
|
err := r.db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&stats).Error
|
||||||
|
return stats, total, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/ctwj/urldb/db/entity"
|
"github.com/ctwj/urldb/db/entity"
|
||||||
|
"github.com/ctwj/urldb/utils"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -21,6 +22,8 @@ type SystemConfigRepository interface {
|
|||||||
GetConfigInt(key string) (int, error)
|
GetConfigInt(key string) (int, error)
|
||||||
GetCachedConfigs() map[string]string
|
GetCachedConfigs() map[string]string
|
||||||
ClearConfigCache()
|
ClearConfigCache()
|
||||||
|
SafeRefreshConfigCache() error
|
||||||
|
ValidateConfigIntegrity() error
|
||||||
}
|
}
|
||||||
|
|
||||||
// SystemConfigRepositoryImpl 系统配置Repository实现
|
// SystemConfigRepositoryImpl 系统配置Repository实现
|
||||||
@@ -60,27 +63,39 @@ func (r *SystemConfigRepositoryImpl) FindByKey(key string) (*entity.SystemConfig
|
|||||||
|
|
||||||
// UpsertConfigs 批量创建或更新配置
|
// UpsertConfigs 批量创建或更新配置
|
||||||
func (r *SystemConfigRepositoryImpl) UpsertConfigs(configs []entity.SystemConfig) error {
|
func (r *SystemConfigRepositoryImpl) UpsertConfigs(configs []entity.SystemConfig) error {
|
||||||
for _, config := range configs {
|
// 使用事务确保数据一致性
|
||||||
var existingConfig entity.SystemConfig
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
err := r.db.Where("key = ?", config.Key).First(&existingConfig).Error
|
// 在更新前备份当前配置
|
||||||
|
var existingConfigs []entity.SystemConfig
|
||||||
|
if err := tx.Find(&existingConfigs).Error; err != nil {
|
||||||
|
utils.Error("备份配置失败: %v", err)
|
||||||
|
// 不返回错误,继续执行更新
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
for _, config := range configs {
|
||||||
// 如果不存在,则创建
|
var existingConfig entity.SystemConfig
|
||||||
if err := r.db.Create(&config).Error; err != nil {
|
err := tx.Where("key = ?", config.Key).First(&existingConfig).Error
|
||||||
return err
|
|
||||||
}
|
if err != nil {
|
||||||
} else {
|
// 如果不存在,则创建
|
||||||
// 如果存在,则更新
|
if err := tx.Create(&config).Error; err != nil {
|
||||||
config.ID = existingConfig.ID
|
utils.Error("创建配置失败 [%s]: %v", config.Key, err)
|
||||||
if err := r.db.Save(&config).Error; err != nil {
|
return fmt.Errorf("创建配置失败 [%s]: %v", config.Key, err)
|
||||||
return 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()
|
r.refreshConfigCache()
|
||||||
return nil
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOrCreateDefault 获取配置或创建默认配置
|
// GetOrCreateDefault 获取配置或创建默认配置
|
||||||
@@ -92,6 +107,7 @@ func (r *SystemConfigRepositoryImpl) GetOrCreateDefault() ([]entity.SystemConfig
|
|||||||
|
|
||||||
// 如果没有配置,创建默认配置
|
// 如果没有配置,创建默认配置
|
||||||
if len(configs) == 0 {
|
if len(configs) == 0 {
|
||||||
|
utils.Info("未找到任何配置,创建默认配置")
|
||||||
defaultConfigs := []entity.SystemConfig{
|
defaultConfigs := []entity.SystemConfig{
|
||||||
{Key: entity.ConfigKeySiteTitle, Value: entity.ConfigDefaultSiteTitle, Type: entity.ConfigTypeString},
|
{Key: entity.ConfigKeySiteTitle, Value: entity.ConfigDefaultSiteTitle, Type: entity.ConfigTypeString},
|
||||||
{Key: entity.ConfigKeySiteDescription, Value: entity.ConfigDefaultSiteDescription, 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.ConfigKeyAutoTransferMinSpace, Value: entity.ConfigDefaultAutoTransferMinSpace, Type: entity.ConfigTypeInt},
|
||||||
{Key: entity.ConfigKeyAutoFetchHotDramaEnabled, Value: entity.ConfigDefaultAutoFetchHotDramaEnabled, Type: entity.ConfigTypeBool},
|
{Key: entity.ConfigKeyAutoFetchHotDramaEnabled, Value: entity.ConfigDefaultAutoFetchHotDramaEnabled, Type: entity.ConfigTypeBool},
|
||||||
{Key: entity.ConfigKeyApiToken, Value: entity.ConfigDefaultApiToken, Type: entity.ConfigTypeString},
|
{Key: entity.ConfigKeyApiToken, Value: entity.ConfigDefaultApiToken, Type: entity.ConfigTypeString},
|
||||||
{Key: entity.ConfigKeyPageSize, Value: entity.ConfigDefaultPageSize, Type: entity.ConfigTypeInt},
|
{Key: entity.ConfigKeyForbiddenWords, Value: entity.ConfigDefaultForbiddenWords, Type: entity.ConfigTypeString},
|
||||||
{Key: entity.ConfigKeyMaintenanceMode, Value: entity.ConfigDefaultMaintenanceMode, Type: entity.ConfigTypeBool},
|
{Key: entity.ConfigKeyAdKeywords, Value: entity.ConfigDefaultAdKeywords, Type: entity.ConfigTypeString},
|
||||||
{Key: entity.ConfigKeyEnableRegister, Value: entity.ConfigDefaultEnableRegister, Type: entity.ConfigTypeBool},
|
{Key: entity.ConfigKeyAutoInsertAd, Value: entity.ConfigDefaultAutoInsertAd, Type: entity.ConfigTypeString},
|
||||||
{Key: entity.ConfigKeyThirdPartyStatsCode, Value: entity.ConfigDefaultThirdPartyStatsCode, Type: entity.ConfigTypeString},
|
{Key: entity.ConfigKeyPageSize, Value: entity.ConfigDefaultPageSize, Type: entity.ConfigTypeInt},
|
||||||
|
{Key: entity.ConfigKeyMaintenanceMode, Value: entity.ConfigDefaultMaintenanceMode, Type: entity.ConfigTypeBool},
|
||||||
|
{Key: entity.ConfigKeyEnableRegister, Value: entity.ConfigDefaultEnableRegister, Type: entity.ConfigTypeBool},
|
||||||
|
{Key: entity.ConfigKeyThirdPartyStatsCode, Value: entity.ConfigDefaultThirdPartyStatsCode, Type: entity.ConfigTypeString},
|
||||||
|
{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)
|
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.ConfigKeyAutoTransferMinSpace: {Key: entity.ConfigKeyAutoTransferMinSpace, Value: entity.ConfigDefaultAutoTransferMinSpace, Type: entity.ConfigTypeInt},
|
||||||
entity.ConfigKeyAutoFetchHotDramaEnabled: {Key: entity.ConfigKeyAutoFetchHotDramaEnabled, Value: entity.ConfigDefaultAutoFetchHotDramaEnabled, Type: entity.ConfigTypeBool},
|
entity.ConfigKeyAutoFetchHotDramaEnabled: {Key: entity.ConfigKeyAutoFetchHotDramaEnabled, Value: entity.ConfigDefaultAutoFetchHotDramaEnabled, Type: entity.ConfigTypeBool},
|
||||||
entity.ConfigKeyApiToken: {Key: entity.ConfigKeyApiToken, Value: entity.ConfigDefaultApiToken, Type: entity.ConfigTypeString},
|
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.ConfigKeyPageSize: {Key: entity.ConfigKeyPageSize, Value: entity.ConfigDefaultPageSize, Type: entity.ConfigTypeInt},
|
||||||
entity.ConfigKeyMaintenanceMode: {Key: entity.ConfigKeyMaintenanceMode, Value: entity.ConfigDefaultMaintenanceMode, Type: entity.ConfigTypeBool},
|
entity.ConfigKeyMaintenanceMode: {Key: entity.ConfigKeyMaintenanceMode, Value: entity.ConfigDefaultMaintenanceMode, Type: entity.ConfigTypeBool},
|
||||||
entity.ConfigKeyEnableRegister: {Key: entity.ConfigKeyEnableRegister, Value: entity.ConfigDefaultEnableRegister, 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.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()
|
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 获取配置值(字符串)
|
// GetConfigValue 获取配置值(字符串)
|
||||||
func (r *SystemConfigRepositoryImpl) GetConfigValue(key string) (string, error) {
|
func (r *SystemConfigRepositoryImpl) GetConfigValue(key string) (string, error) {
|
||||||
// 初始化缓存
|
// 初始化缓存
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ services:
|
|||||||
- app-network
|
- app-network
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
image: ctwj/urldb-backend:1.1.0
|
image: ctwj/urldb-backend:1.2.4
|
||||||
environment:
|
environment:
|
||||||
DB_HOST: postgres
|
DB_HOST: postgres
|
||||||
DB_PORT: 5432
|
DB_PORT: 5432
|
||||||
@@ -38,10 +38,10 @@ services:
|
|||||||
- app-network
|
- app-network
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: ctwj/urldb-frontend:1.1.0
|
image: ctwj/urldb-frontend:1.2.4
|
||||||
environment:
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
NUXT_PUBLIC_API_SERVER: http://backend:8080/api
|
NUXT_PUBLIC_API_SERVER: http://backend:8080/api
|
||||||
NUXT_PUBLIC_API_CLIENT: /api
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
doc.l9.lc
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# 🚀 urlDB - 老九网盘资源数据库
|
|
||||||
|
|
||||||
> 一个现代化的老九网盘资源数据库,支持多网盘自动化转存分享,支持百度网盘,阿里云盘,夸克网盘, 天翼云盘,迅雷云盘,123云盘,115网盘,UC网盘
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## 🎯 支持的网盘平台
|
|
||||||
|
|
||||||
| 平台 | 录入 | 转存 | 分享 |
|
|
||||||
|------|-------|-----|------|
|
|
||||||
| 百度网盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
|
|
||||||
| 阿里云盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
|
|
||||||
| 夸克网盘 | ✅ 支持 | ✅ 支持 | ✅ 支持 |
|
|
||||||
| 天翼云盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
|
|
||||||
| 迅雷云盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
|
|
||||||
| UC网盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
|
|
||||||
| 123云盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
|
|
||||||
| 115网盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
|
|
||||||
|
|
||||||
## ✨ 功能特性
|
|
||||||
|
|
||||||
### 🎯 核心功能
|
|
||||||
- **📁 多平台网盘支持** - 支持多种主流网盘平台
|
|
||||||
- **🔍 公开API** - 支持API数据录入,资源搜索
|
|
||||||
- **🏷️ 自动预处理** - 系统自动处理资源,对数据进行有效性判断
|
|
||||||
- **📊 自动转存分享** - 有效资源,如果属于支持类型将自动转存分享
|
|
||||||
- **📱 多账号管理** - 同平台支持多账号管理
|
|
||||||
|
|
||||||
## 📞 联系我们
|
|
||||||
|
|
||||||
- **项目地址**: [https://github.com/ctwj/urldb](https://github.com/ctwj/urldb)
|
|
||||||
- **问题反馈**: [Issues](https://github.com/ctwj/urldb/issues)
|
|
||||||
- **邮箱**: 510199617@qq.com
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
**如果这个项目对您有帮助,请给我们一个 ⭐ Star!**
|
|
||||||
|
|
||||||
Made with ❤️ by [老九]
|
|
||||||
|
|
||||||
</div>
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
# 文档使用说明
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
本项目使用 [docsify](https://docsify.js.org/) 生成文档网站。docsify 是一个轻量级的文档生成器,无需构建静态文件,只需要一个 `index.html` 文件即可。
|
|
||||||
|
|
||||||
## 文档结构
|
|
||||||
|
|
||||||
```
|
|
||||||
docs/
|
|
||||||
├── index.html # 文档主页
|
|
||||||
├── docsify.config.js # docsify 配置文件
|
|
||||||
├── README.md # 首页内容
|
|
||||||
├── _sidebar.md # 侧边栏导航
|
|
||||||
├── start-docs.sh # 启动脚本
|
|
||||||
├── guide/ # 使用指南
|
|
||||||
│ ├── quick-start.md # 快速开始
|
|
||||||
│ ├── local-development.md # 本地开发
|
|
||||||
│ └── docker-deployment.md # Docker 部署
|
|
||||||
├── api/ # API 文档
|
|
||||||
│ └── overview.md # API 概览
|
|
||||||
├── architecture/ # 架构文档
|
|
||||||
│ └── overview.md # 架构概览
|
|
||||||
├── faq.md # 常见问题
|
|
||||||
├── changelog.md # 更新日志
|
|
||||||
└── license.md # 许可证
|
|
||||||
```
|
|
||||||
|
|
||||||
## 快速启动
|
|
||||||
|
|
||||||
### 方法一:使用启动脚本(推荐)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 进入文档目录
|
|
||||||
cd docs
|
|
||||||
|
|
||||||
# 运行启动脚本
|
|
||||||
./start-docs.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
脚本会自动:
|
|
||||||
- 检查是否安装了 docsify-cli
|
|
||||||
- 如果没有安装,会自动安装
|
|
||||||
- 启动文档服务
|
|
||||||
- 在浏览器中打开文档
|
|
||||||
|
|
||||||
### 方法二:手动启动
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 安装 docsify-cli(如果未安装)
|
|
||||||
npm install -g docsify-cli
|
|
||||||
|
|
||||||
# 进入文档目录
|
|
||||||
cd docs
|
|
||||||
|
|
||||||
# 启动服务
|
|
||||||
docsify serve . --port 3000 --open
|
|
||||||
```
|
|
||||||
|
|
||||||
## 访问文档
|
|
||||||
|
|
||||||
启动成功后,文档将在以下地址可用:
|
|
||||||
- 本地访问:http://localhost:3000
|
|
||||||
- 局域网访问:http://[你的IP]:3000
|
|
||||||
|
|
||||||
## 文档特性
|
|
||||||
|
|
||||||
### 1. 搜索功能
|
|
||||||
- 支持全文搜索
|
|
||||||
- 搜索结果高亮显示
|
|
||||||
- 支持中文搜索
|
|
||||||
|
|
||||||
### 2. 代码高亮
|
|
||||||
支持多种编程语言的语法高亮:
|
|
||||||
- Go
|
|
||||||
- JavaScript/TypeScript
|
|
||||||
- SQL
|
|
||||||
- YAML
|
|
||||||
- JSON
|
|
||||||
- Bash
|
|
||||||
|
|
||||||
### 3. 代码复制
|
|
||||||
- 一键复制代码块
|
|
||||||
- 复制成功提示
|
|
||||||
|
|
||||||
### 4. 页面导航
|
|
||||||
- 侧边栏导航
|
|
||||||
- 页面间导航
|
|
||||||
- 自动回到顶部
|
|
||||||
|
|
||||||
### 5. 响应式设计
|
|
||||||
- 支持移动端访问
|
|
||||||
- 自适应屏幕尺寸
|
|
||||||
|
|
||||||
## 自定义配置
|
|
||||||
|
|
||||||
### 修改主题
|
|
||||||
在 `docsify.config.js` 中修改配置:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
window.$docsify = {
|
|
||||||
name: '你的项目名称',
|
|
||||||
repo: '你的仓库地址',
|
|
||||||
// 其他配置...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 添加新页面
|
|
||||||
1. 在相应目录下创建 `.md` 文件
|
|
||||||
2. 在 `_sidebar.md` 中添加导航链接
|
|
||||||
3. 刷新页面即可看到新页面
|
|
||||||
|
|
||||||
### 修改样式
|
|
||||||
可以通过添加自定义 CSS 来修改样式:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- 在 index.html 中添加 -->
|
|
||||||
<link rel="stylesheet" href="./custom.css">
|
|
||||||
```
|
|
||||||
|
|
||||||
## 部署到生产环境
|
|
||||||
|
|
||||||
### 静态部署
|
|
||||||
docsify 生成的文档可以部署到任何静态文件服务器:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 构建静态文件(可选)
|
|
||||||
docsify generate docs docs/_site
|
|
||||||
|
|
||||||
# 部署到 GitHub Pages
|
|
||||||
git subtree push --prefix docs origin gh-pages
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker 部署
|
|
||||||
```bash
|
|
||||||
# 使用 nginx 镜像
|
|
||||||
docker run -d -p 80:80 -v $(pwd)/docs:/usr/share/nginx/html nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### Q: 启动时提示端口被占用
|
|
||||||
A: 可以指定其他端口:
|
|
||||||
```bash
|
|
||||||
docsify serve . --port 3001
|
|
||||||
```
|
|
||||||
|
|
||||||
### Q: 搜索功能不工作
|
|
||||||
A: 确保在 `index.html` 中引入了搜索插件:
|
|
||||||
```html
|
|
||||||
<script src="//cdn.jsdelivr.net/npm/docsify@4/lib/plugins/search.min.js"></script>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Q: 代码高亮不显示
|
|
||||||
A: 确保引入了相应的 Prism.js 组件:
|
|
||||||
```html
|
|
||||||
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-go.min.js"></script>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 维护说明
|
|
||||||
|
|
||||||
### 更新文档
|
|
||||||
1. 修改相应的 `.md` 文件
|
|
||||||
2. 刷新浏览器即可看到更新
|
|
||||||
|
|
||||||
### 添加新功能
|
|
||||||
1. 在 `docsify.config.js` 中添加插件配置
|
|
||||||
2. 在 `index.html` 中引入相应的插件文件
|
|
||||||
|
|
||||||
### 版本控制
|
|
||||||
建议将文档与代码一起进行版本控制,确保文档与代码版本同步。
|
|
||||||
|
|
||||||
## 相关链接
|
|
||||||
|
|
||||||
- [docsify 官方文档](https://docsify.js.org/)
|
|
||||||
- [docsify 插件市场](https://docsify.js.org/#/plugins)
|
|
||||||
- [Markdown 语法指南](https://docsify.js.org/#/zh-cn/markdown)
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<!-- docs/_sidebar.md -->
|
|
||||||
|
|
||||||
* [🏠 首页](/)
|
|
||||||
* [🚀 快速开始](guide/quick-start.md)
|
|
||||||
* [🐳 Docker部署](guide/docker-deployment.md)
|
|
||||||
* [💻 本地开发](guide/local-development.md)
|
|
||||||
|
|
||||||
* 📚 API 文档
|
|
||||||
* [公开API](api/overview.md)
|
|
||||||
|
|
||||||
* 📄 其他
|
|
||||||
* [常见问题](faq.md)
|
|
||||||
* [更新日志](changelog.md)
|
|
||||||
* [许可证](license.md)
|
|
||||||
* [版本管理](github-version-management.md)
|
|
||||||
@@ -1,418 +0,0 @@
|
|||||||
# API 文档概览
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
老九网盘资源数据库提供了一套完整的 RESTful API 接口,支持资源管理、搜索、热门剧获取等功能。所有 API 都需要进行认证,使用 API Token 进行身份验证。
|
|
||||||
|
|
||||||
## 基础信息
|
|
||||||
|
|
||||||
- **基础URL**: `http://localhost:8080/api`
|
|
||||||
- **认证方式**: API Token
|
|
||||||
- **数据格式**: JSON
|
|
||||||
- **字符编码**: UTF-8
|
|
||||||
|
|
||||||
## 认证说明
|
|
||||||
|
|
||||||
### 认证方式
|
|
||||||
|
|
||||||
所有 API 都需要提供 API Token 进行认证,支持两种方式:
|
|
||||||
|
|
||||||
1. **请求头方式**(推荐)
|
|
||||||
```
|
|
||||||
X-API-Token: your_token_here
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **查询参数方式**
|
|
||||||
```
|
|
||||||
?api_token=your_token_here
|
|
||||||
```
|
|
||||||
|
|
||||||
### 获取 Token
|
|
||||||
|
|
||||||
请联系管理员在系统配置中设置 API Token。
|
|
||||||
|
|
||||||
## API 接口列表
|
|
||||||
|
|
||||||
### 1. 单个添加资源
|
|
||||||
|
|
||||||
**接口描述**: 添加单个资源到待处理列表
|
|
||||||
|
|
||||||
**请求信息**:
|
|
||||||
- **方法**: `POST`
|
|
||||||
- **路径**: `/api/public/resources/add`
|
|
||||||
- **认证**: 必需
|
|
||||||
|
|
||||||
**请求参数**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"title": "资源标题",
|
|
||||||
"description": "资源描述",
|
|
||||||
"url": "资源链接",
|
|
||||||
"category": "分类名称",
|
|
||||||
"tags": "标签1,标签2",
|
|
||||||
"img": "封面图片链接",
|
|
||||||
"source": "数据来源",
|
|
||||||
"extra": "额外信息"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "资源添加成功,已进入待处理列表",
|
|
||||||
"data": {
|
|
||||||
"id": 123
|
|
||||||
},
|
|
||||||
"code": 200
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 批量添加资源
|
|
||||||
|
|
||||||
**接口描述**: 批量添加多个资源到待处理列表
|
|
||||||
|
|
||||||
**请求信息**:
|
|
||||||
- **方法**: `POST`
|
|
||||||
- **路径**: `/api/public/resources/batch-add`
|
|
||||||
- **认证**: 必需
|
|
||||||
|
|
||||||
**请求参数**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"resources": [
|
|
||||||
{
|
|
||||||
"title": "资源1",
|
|
||||||
"url": "链接1",
|
|
||||||
"description": "描述1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "资源2",
|
|
||||||
"url": "链接2",
|
|
||||||
"description": "描述2"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "批量添加成功,共添加 2 个资源",
|
|
||||||
"data": {
|
|
||||||
"created_count": 2,
|
|
||||||
"created_ids": [123, 124]
|
|
||||||
},
|
|
||||||
"code": 200
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 资源搜索
|
|
||||||
|
|
||||||
**接口描述**: 搜索资源,支持关键词、标签、分类过滤
|
|
||||||
|
|
||||||
**请求信息**:
|
|
||||||
- **方法**: `GET`
|
|
||||||
- **路径**: `/api/public/resources/search`
|
|
||||||
- **认证**: 必需
|
|
||||||
|
|
||||||
**查询参数**:
|
|
||||||
- `keyword` - 搜索关键词
|
|
||||||
- `tag` - 标签过滤
|
|
||||||
- `category` - 分类过滤
|
|
||||||
- `page` - 页码(默认1)
|
|
||||||
- `page_size` - 每页数量(默认20,最大100)
|
|
||||||
|
|
||||||
**响应示例**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "搜索成功",
|
|
||||||
"data": {
|
|
||||||
"resources": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"title": "资源标题",
|
|
||||||
"url": "资源链接",
|
|
||||||
"description": "资源描述",
|
|
||||||
"view_count": 100,
|
|
||||||
"created_at": "2024-12-19 10:00:00",
|
|
||||||
"updated_at": "2024-12-19 10:00:00"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 50,
|
|
||||||
"page": 1,
|
|
||||||
"page_size": 20
|
|
||||||
},
|
|
||||||
"code": 200
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 热门剧列表
|
|
||||||
|
|
||||||
**接口描述**: 获取热门剧列表,支持分页
|
|
||||||
|
|
||||||
**请求信息**:
|
|
||||||
- **方法**: `GET`
|
|
||||||
- **路径**: `/api/public/hot-dramas`
|
|
||||||
- **认证**: 必需
|
|
||||||
|
|
||||||
**查询参数**:
|
|
||||||
- `page` - 页码(默认1)
|
|
||||||
- `page_size` - 每页数量(默认20,最大100)
|
|
||||||
|
|
||||||
**响应示例**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "获取热门剧成功",
|
|
||||||
"data": {
|
|
||||||
"hot_dramas": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"title": "剧名",
|
|
||||||
"description": "剧集描述",
|
|
||||||
"img": "封面图片",
|
|
||||||
"url": "详情链接",
|
|
||||||
"rating": 8.5,
|
|
||||||
"year": "2024",
|
|
||||||
"region": "中国大陆",
|
|
||||||
"genres": "剧情,悬疑",
|
|
||||||
"category": "电视剧",
|
|
||||||
"created_at": "2024-12-19 10:00:00",
|
|
||||||
"updated_at": "2024-12-19 10:00:00"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 20,
|
|
||||||
"page": 1,
|
|
||||||
"page_size": 20
|
|
||||||
},
|
|
||||||
"code": 200
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 错误码说明
|
|
||||||
|
|
||||||
### HTTP 状态码
|
|
||||||
|
|
||||||
| 状态码 | 说明 |
|
|
||||||
|--------|------|
|
|
||||||
| 200 | 请求成功 |
|
|
||||||
| 400 | 请求参数错误 |
|
|
||||||
| 401 | 认证失败(Token无效或缺失) |
|
|
||||||
| 500 | 服务器内部错误 |
|
|
||||||
| 503 | 系统维护中或API Token未配置 |
|
|
||||||
|
|
||||||
### 响应格式
|
|
||||||
|
|
||||||
所有 API 响应都遵循统一的格式:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true/false,
|
|
||||||
"message": "响应消息",
|
|
||||||
"data": {}, // 响应数据
|
|
||||||
"code": 200 // 状态码
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 使用示例
|
|
||||||
|
|
||||||
### cURL 示例
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 设置API Token
|
|
||||||
API_TOKEN="your_api_token_here"
|
|
||||||
|
|
||||||
# 单个添加资源
|
|
||||||
curl -X POST "http://localhost:8080/api/public/resources/add" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "X-API-Token: $API_TOKEN" \
|
|
||||||
-d '{
|
|
||||||
"title": "测试资源",
|
|
||||||
"url": "https://example.com/resource",
|
|
||||||
"description": "测试描述"
|
|
||||||
}'
|
|
||||||
|
|
||||||
# 搜索资源
|
|
||||||
curl -X GET "http://localhost:8080/api/public/resources/search?keyword=测试" \
|
|
||||||
-H "X-API-Token: $API_TOKEN"
|
|
||||||
|
|
||||||
# 获取热门剧
|
|
||||||
curl -X GET "http://localhost:8080/api/public/hot-dramas?page=1&page_size=5" \
|
|
||||||
-H "X-API-Token: $API_TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
### JavaScript 示例
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const API_TOKEN = 'your_api_token_here';
|
|
||||||
const BASE_URL = 'http://localhost:8080/api';
|
|
||||||
|
|
||||||
// 添加资源
|
|
||||||
async function addResource(resourceData) {
|
|
||||||
const response = await fetch(`${BASE_URL}/public/resources/add`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-API-Token': API_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify(resourceData)
|
|
||||||
});
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 搜索资源
|
|
||||||
async function searchResources(keyword, page = 1) {
|
|
||||||
const response = await fetch(
|
|
||||||
`${BASE_URL}/public/resources/search?keyword=${encodeURIComponent(keyword)}&page=${page}`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'X-API-Token': API_TOKEN
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取热门剧
|
|
||||||
async function getHotDramas(page = 1, pageSize = 20) {
|
|
||||||
const response = await fetch(
|
|
||||||
`${BASE_URL}/public/hot-dramas?page=${page}&page_size=${pageSize}`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'X-API-Token': API_TOKEN
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Python 示例
|
|
||||||
|
|
||||||
```python
|
|
||||||
import requests
|
|
||||||
|
|
||||||
API_TOKEN = 'your_api_token_here'
|
|
||||||
BASE_URL = 'http://localhost:8080/api'
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
'X-API-Token': API_TOKEN,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
|
|
||||||
# 添加资源
|
|
||||||
def add_resource(resource_data):
|
|
||||||
response = requests.post(
|
|
||||||
f'{BASE_URL}/public/resources/add',
|
|
||||||
headers=headers,
|
|
||||||
json=resource_data
|
|
||||||
)
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
# 搜索资源
|
|
||||||
def search_resources(keyword, page=1):
|
|
||||||
params = {
|
|
||||||
'keyword': keyword,
|
|
||||||
'page': page
|
|
||||||
}
|
|
||||||
response = requests.get(
|
|
||||||
f'{BASE_URL}/public/resources/search',
|
|
||||||
headers={'X-API-Token': API_TOKEN},
|
|
||||||
params=params
|
|
||||||
)
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
# 获取热门剧
|
|
||||||
def get_hot_dramas(page=1, page_size=20):
|
|
||||||
params = {
|
|
||||||
'page': page,
|
|
||||||
'page_size': page_size
|
|
||||||
}
|
|
||||||
response = requests.get(
|
|
||||||
f'{BASE_URL}/public/hot-dramas',
|
|
||||||
headers={'X-API-Token': API_TOKEN},
|
|
||||||
params=params
|
|
||||||
)
|
|
||||||
return response.json()
|
|
||||||
```
|
|
||||||
|
|
||||||
## 最佳实践
|
|
||||||
|
|
||||||
### 1. 错误处理
|
|
||||||
|
|
||||||
始终检查响应的 `success` 字段和 HTTP 状态码:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const response = await fetch(url, options);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok || !data.success) {
|
|
||||||
console.error('API调用失败:', data.message);
|
|
||||||
// 处理错误
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 分页处理
|
|
||||||
|
|
||||||
对于支持分页的接口,建议实现分页逻辑:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
async function getAllResources(keyword) {
|
|
||||||
let allResources = [];
|
|
||||||
let page = 1;
|
|
||||||
let hasMore = true;
|
|
||||||
|
|
||||||
while (hasMore) {
|
|
||||||
const response = await searchResources(keyword, page);
|
|
||||||
if (response.success) {
|
|
||||||
allResources.push(...response.data.resources);
|
|
||||||
hasMore = response.data.resources.length > 0;
|
|
||||||
page++;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return allResources;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 请求频率限制
|
|
||||||
|
|
||||||
避免过于频繁的 API 调用,建议实现请求间隔:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
function delay(ms) {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function searchWithDelay(keyword) {
|
|
||||||
const result = await searchResources(keyword);
|
|
||||||
await delay(1000); // 等待1秒
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
1. **Token 安全**: 请妥善保管您的 API Token,不要泄露给他人
|
|
||||||
2. **请求限制**: 避免过于频繁的请求,以免影响系统性能
|
|
||||||
3. **数据格式**: 确保请求数据格式正确,特别是 JSON 格式
|
|
||||||
4. **错误处理**: 始终实现适当的错误处理机制
|
|
||||||
5. **版本兼容**: API 可能会进行版本更新,请关注更新通知
|
|
||||||
|
|
||||||
## 技术支持
|
|
||||||
|
|
||||||
如果您在使用 API 过程中遇到问题,请:
|
|
||||||
|
|
||||||
1. 检查 API Token 是否正确
|
|
||||||
2. 确认请求格式是否符合要求
|
|
||||||
3. 查看错误响应中的详细信息
|
|
||||||
4. 联系技术支持团队
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**注意**: 本站内容由网络爬虫自动抓取。本站不储存、复制、传播任何文件,仅作个人公益学习,请在获取后24小时内删除!
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
# 📝 更新日志
|
|
||||||
|
|
||||||
本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/) 规范。
|
|
||||||
|
|
||||||
## [未发布]
|
|
||||||
|
|
||||||
### 新增
|
|
||||||
- 自动转存调度功能
|
|
||||||
- 支持更多网盘平台
|
|
||||||
- 性能优化和监控
|
|
||||||
|
|
||||||
### 修复
|
|
||||||
- 修复已知问题
|
|
||||||
- 改进用户体验
|
|
||||||
|
|
||||||
## [1.0.0] - 2024-01-01
|
|
||||||
|
|
||||||
### 新增
|
|
||||||
- 🎉 首次发布
|
|
||||||
- ✨ 完整的网盘资源管理系统
|
|
||||||
- 🔐 JWT 用户认证系统
|
|
||||||
- 📁 多平台网盘支持
|
|
||||||
- 🔍 资源搜索和管理
|
|
||||||
- 🏷️ 分类和标签系统
|
|
||||||
- 📊 统计和监控功能
|
|
||||||
- 🐳 Docker 容器化部署
|
|
||||||
- 📱 响应式前端界面
|
|
||||||
- 🌙 深色模式支持
|
|
||||||
|
|
||||||
### 支持的网盘平台
|
|
||||||
- 百度网盘
|
|
||||||
- 阿里云盘
|
|
||||||
- 夸克网盘
|
|
||||||
- 天翼云盘
|
|
||||||
- 迅雷云盘
|
|
||||||
- UC网盘
|
|
||||||
- 123云盘
|
|
||||||
- 115网盘
|
|
||||||
|
|
||||||
### 技术特性
|
|
||||||
- **后端**: Go + Gin + GORM + PostgreSQL
|
|
||||||
- **前端**: Nuxt.js 3 + Vue 3 + TypeScript + Tailwind CSS
|
|
||||||
- **部署**: Docker + Docker Compose
|
|
||||||
- **认证**: JWT Token
|
|
||||||
- **架构**: 前后端分离
|
|
||||||
|
|
||||||
## [0.9.0] - 2024-12-15
|
|
||||||
|
|
||||||
### 新增
|
|
||||||
- 🚀 项目初始化
|
|
||||||
- 📋 基础功能开发
|
|
||||||
- 🏗️ 架构设计完成
|
|
||||||
- 🔧 开发环境搭建
|
|
||||||
|
|
||||||
### 技术栈确定
|
|
||||||
- 后端技术栈选型
|
|
||||||
- 前端技术栈选型
|
|
||||||
- 数据库设计
|
|
||||||
- API 接口设计
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 版本说明
|
|
||||||
|
|
||||||
### 版本号格式
|
|
||||||
- **主版本号**: 不兼容的 API 修改
|
|
||||||
- **次版本号**: 向下兼容的功能性新增
|
|
||||||
- **修订号**: 向下兼容的问题修正
|
|
||||||
|
|
||||||
### 更新类型
|
|
||||||
- 🎉 **重大更新**: 新版本发布
|
|
||||||
- ✨ **新增功能**: 新功能添加
|
|
||||||
- 🔧 **功能改进**: 现有功能优化
|
|
||||||
- 🐛 **问题修复**: Bug 修复
|
|
||||||
- 📝 **文档更新**: 文档改进
|
|
||||||
- 🚀 **性能优化**: 性能提升
|
|
||||||
- 🔒 **安全更新**: 安全相关更新
|
|
||||||
- 🎨 **界面优化**: UI/UX 改进
|
|
||||||
|
|
||||||
## 贡献指南
|
|
||||||
|
|
||||||
如果您想为项目做出贡献,请:
|
|
||||||
|
|
||||||
1. Fork 项目
|
|
||||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
|
||||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
|
||||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
|
||||||
5. 打开 Pull Request
|
|
||||||
|
|
||||||
## 反馈
|
|
||||||
|
|
||||||
如果您发现任何问题或有建议,请:
|
|
||||||
|
|
||||||
- 提交 [Issue](https://github.com/ctwj/urldb/issues)
|
|
||||||
- 发送邮件到 510199617@qq.com
|
|
||||||
- 在 [讨论区](https://github.com/ctwj/urldb/discussions) 交流
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**注意**: 此更新日志记录了项目的重要变更。对于详细的开发日志,请查看 Git 提交历史。
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
// docsify 配置文件
|
|
||||||
window.$docsify = {
|
|
||||||
name: '老九网盘链接数据库',
|
|
||||||
repo: 'https://github.com/ctwj/urldb',
|
|
||||||
loadSidebar: '_sidebar.md',
|
|
||||||
subMaxLevel: 3,
|
|
||||||
auto2top: true,
|
|
||||||
// 添加侧边栏配置
|
|
||||||
sidebarDisplayLevel: 1,
|
|
||||||
// 添加错误处理
|
|
||||||
notFoundPage: true,
|
|
||||||
search: {
|
|
||||||
maxAge: 86400000,
|
|
||||||
paths: 'auto',
|
|
||||||
placeholder: '搜索文档...',
|
|
||||||
noData: '找不到结果',
|
|
||||||
depth: 6
|
|
||||||
},
|
|
||||||
copyCode: {
|
|
||||||
buttonText: '复制',
|
|
||||||
errorText: '错误',
|
|
||||||
successText: '已复制'
|
|
||||||
},
|
|
||||||
pagination: {
|
|
||||||
previousText: '上一页',
|
|
||||||
nextText: '下一页',
|
|
||||||
crossChapter: true,
|
|
||||||
crossChapterText: true,
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
function(hook, vm) {
|
|
||||||
hook.beforeEach(function (html) {
|
|
||||||
// 添加页面标题
|
|
||||||
var url = '#' + vm.route.path;
|
|
||||||
var title = vm.route.path === '/' ? '首页' : vm.route.path.replace('/', '');
|
|
||||||
return html + '\n\n---\n\n' +
|
|
||||||
'<div style="text-align: center; color: #666; font-size: 14px;">' +
|
|
||||||
'最后更新: ' + new Date().toLocaleDateString('zh-CN') +
|
|
||||||
'</div>';
|
|
||||||
});
|
|
||||||
|
|
||||||
// 添加侧边栏加载调试
|
|
||||||
hook.doneEach(function() {
|
|
||||||
console.log('Docsify loaded, sidebar should be visible');
|
|
||||||
if (document.querySelector('.sidebar-nav')) {
|
|
||||||
console.log('Sidebar element found');
|
|
||||||
} else {
|
|
||||||
console.log('Sidebar element not found');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
26
docs/faq.md
26
docs/faq.md
@@ -1,26 +0,0 @@
|
|||||||
# ❓ 常见问题
|
|
||||||
|
|
||||||
## 部署相关
|
|
||||||
|
|
||||||
### Q: 默认账号密码是多少?
|
|
||||||
|
|
||||||
**A:** 可以通过以下方式解决:
|
|
||||||
|
|
||||||
1. admin/password
|
|
||||||
|
|
||||||
### Q: 批量添加了资源,但是系统里面没有出现,也搜索不到?
|
|
||||||
|
|
||||||
**A:** 可以通过以下方式解决:
|
|
||||||
|
|
||||||
1. 需要先开启自动处理待处理任务的开关
|
|
||||||
2. 定时任务每5分钟执行一次,可能需要等待
|
|
||||||
3. 如果添加的链接地址无效, 会被程序过滤
|
|
||||||
|
|
||||||
### Q: 没有自动转存?
|
|
||||||
|
|
||||||
**A:** 可以通过以下方式解决:
|
|
||||||
|
|
||||||
1. 需要先添加账号
|
|
||||||
2. 开启定时任务
|
|
||||||
3. 等待任务完成
|
|
||||||
4. 只要支持的网盘地址才会被自动转存并分享
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
# GitHub版本管理指南
|
|
||||||
|
|
||||||
本项目使用GitHub进行版本管理,支持自动创建Release和标签。
|
|
||||||
|
|
||||||
## 版本管理流程
|
|
||||||
|
|
||||||
### 1. 版本号规范
|
|
||||||
|
|
||||||
遵循[语义化版本](https://semver.org/lang/zh-CN/)规范:
|
|
||||||
|
|
||||||
- **主版本号** (Major): 不兼容的API修改
|
|
||||||
- **次版本号** (Minor): 向下兼容的功能性新增
|
|
||||||
- **修订号** (Patch): 向下兼容的问题修正
|
|
||||||
|
|
||||||
### 2. 版本管理命令
|
|
||||||
|
|
||||||
#### 显示版本信息
|
|
||||||
```bash
|
|
||||||
./scripts/version.sh show
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 更新版本号
|
|
||||||
```bash
|
|
||||||
# 修订版本 (1.0.0 -> 1.0.1)
|
|
||||||
./scripts/version.sh patch
|
|
||||||
|
|
||||||
# 次版本 (1.0.0 -> 1.1.0)
|
|
||||||
./scripts/version.sh minor
|
|
||||||
|
|
||||||
# 主版本 (1.0.0 -> 2.0.0)
|
|
||||||
./scripts/version.sh major
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 发布版本到GitHub
|
|
||||||
```bash
|
|
||||||
./scripts/version.sh release
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 自动发布流程
|
|
||||||
|
|
||||||
当执行版本更新命令时,脚本会:
|
|
||||||
|
|
||||||
1. **更新版本号**: 修改 `VERSION` 文件
|
|
||||||
2. **同步文件**: 更新 `package.json`、`docker-compose.yml`、`README.md`
|
|
||||||
3. **创建Git标签**: 自动创建版本标签
|
|
||||||
4. **推送代码**: 推送代码和标签到GitHub
|
|
||||||
5. **创建Release**: 自动创建GitHub Release
|
|
||||||
|
|
||||||
### 4. 手动发布流程
|
|
||||||
|
|
||||||
如果自动发布失败,可以手动发布:
|
|
||||||
|
|
||||||
#### 步骤1: 更新版本号
|
|
||||||
```bash
|
|
||||||
./scripts/version.sh patch # 或 minor, major
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 步骤2: 提交更改
|
|
||||||
```bash
|
|
||||||
git add .
|
|
||||||
git commit -m "chore: bump version to v1.0.1"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 步骤3: 创建标签
|
|
||||||
```bash
|
|
||||||
git tag v1.0.1
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 步骤4: 推送到GitHub
|
|
||||||
```bash
|
|
||||||
git push origin main
|
|
||||||
git push origin v1.0.1
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 步骤5: 创建Release
|
|
||||||
在GitHub网页上:
|
|
||||||
1. 进入项目页面
|
|
||||||
2. 点击 "Releases"
|
|
||||||
3. 点击 "Create a new release"
|
|
||||||
4. 选择标签 `v1.0.1`
|
|
||||||
5. 填写Release说明
|
|
||||||
6. 发布
|
|
||||||
|
|
||||||
### 5. GitHub CLI工具
|
|
||||||
|
|
||||||
#### 安装GitHub CLI
|
|
||||||
```bash
|
|
||||||
# macOS
|
|
||||||
brew install gh
|
|
||||||
|
|
||||||
# Ubuntu/Debian
|
|
||||||
sudo apt install gh
|
|
||||||
|
|
||||||
# Windows
|
|
||||||
winget install GitHub.cli
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 登录GitHub
|
|
||||||
```bash
|
|
||||||
gh auth login
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 创建Release
|
|
||||||
```bash
|
|
||||||
gh release create v1.0.1 \
|
|
||||||
--title "Release v1.0.1" \
|
|
||||||
--notes "修复了一些bug" \
|
|
||||||
--draft=false \
|
|
||||||
--prerelease=false
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. 版本检查
|
|
||||||
|
|
||||||
#### API接口
|
|
||||||
- `GET /api/version/check-update` - 检查GitHub上的最新版本
|
|
||||||
|
|
||||||
#### 前端页面
|
|
||||||
- 访问 `/version` 页面查看版本信息和更新状态
|
|
||||||
|
|
||||||
### 7. 版本历史
|
|
||||||
|
|
||||||
#### 查看所有标签
|
|
||||||
```bash
|
|
||||||
git tag -l
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 查看标签详情
|
|
||||||
```bash
|
|
||||||
git show v1.0.1
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 查看版本历史
|
|
||||||
```bash
|
|
||||||
git log --oneline --decorate
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. 回滚版本
|
|
||||||
|
|
||||||
如果需要回滚到之前的版本:
|
|
||||||
|
|
||||||
#### 删除本地标签
|
|
||||||
```bash
|
|
||||||
git tag -d v1.0.1
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 删除远程标签
|
|
||||||
```bash
|
|
||||||
git push origin :refs/tags/v1.0.1
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 回滚代码
|
|
||||||
```bash
|
|
||||||
git reset --hard v1.0.0
|
|
||||||
git push --force origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9. 最佳实践
|
|
||||||
|
|
||||||
#### 提交信息规范
|
|
||||||
```bash
|
|
||||||
# 功能开发
|
|
||||||
git commit -m "feat: 添加新功能"
|
|
||||||
|
|
||||||
# Bug修复
|
|
||||||
git commit -m "fix: 修复某个bug"
|
|
||||||
|
|
||||||
# 文档更新
|
|
||||||
git commit -m "docs: 更新文档"
|
|
||||||
|
|
||||||
# 版本更新
|
|
||||||
git commit -m "chore: bump version to v1.0.1"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 分支管理
|
|
||||||
- `main`: 主分支,用于发布
|
|
||||||
- `develop`: 开发分支
|
|
||||||
- `feature/*`: 功能分支
|
|
||||||
- `hotfix/*`: 热修复分支
|
|
||||||
|
|
||||||
#### Release说明模板
|
|
||||||
```markdown
|
|
||||||
## Release v1.0.1
|
|
||||||
|
|
||||||
**发布日期**: 2024-01-15
|
|
||||||
|
|
||||||
### 更新内容
|
|
||||||
|
|
||||||
- 修复了某个bug
|
|
||||||
- 添加了新功能
|
|
||||||
- 优化了性能
|
|
||||||
|
|
||||||
### 下载
|
|
||||||
|
|
||||||
- [源码 (ZIP)](https://github.com/ctwj/urldb/archive/v1.0.1.zip)
|
|
||||||
- [源码 (TAR.GZ)](https://github.com/ctwj/urldb/archive/v1.0.1.tar.gz)
|
|
||||||
|
|
||||||
### 安装
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 克隆项目
|
|
||||||
git clone https://github.com/ctwj/urldb.git
|
|
||||||
cd urldb
|
|
||||||
|
|
||||||
# 切换到指定版本
|
|
||||||
git checkout v1.0.1
|
|
||||||
|
|
||||||
# 使用Docker部署
|
|
||||||
docker-compose up --build -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### 更新日志
|
|
||||||
|
|
||||||
详细更新日志请查看 [CHANGELOG.md](https://github.com/ctwj/urldb/blob/v1.0.1/CHANGELOG.md)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 10. 故障排除
|
|
||||||
|
|
||||||
#### 常见问题
|
|
||||||
|
|
||||||
1. **GitHub CLI未安装**
|
|
||||||
```bash
|
|
||||||
# 安装GitHub CLI
|
|
||||||
brew install gh # macOS
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **GitHub CLI未登录**
|
|
||||||
```bash
|
|
||||||
# 登录GitHub
|
|
||||||
gh auth login
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **标签已存在**
|
|
||||||
```bash
|
|
||||||
# 删除本地标签
|
|
||||||
git tag -d v1.0.1
|
|
||||||
|
|
||||||
# 删除远程标签
|
|
||||||
git push origin :refs/tags/v1.0.1
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **推送失败**
|
|
||||||
```bash
|
|
||||||
# 检查远程仓库
|
|
||||||
git remote -v
|
|
||||||
|
|
||||||
# 重新设置远程仓库
|
|
||||||
git remote set-url origin https://github.com/ctwj/urldb.git
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 获取帮助
|
|
||||||
```bash
|
|
||||||
./scripts/version.sh help
|
|
||||||
```
|
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
# 🐳 Docker 部署
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
urlDB 支持使用 Docker 进行容器化部署,提供了完整的前后端分离架构。
|
|
||||||
|
|
||||||
## 系统架构
|
|
||||||
|
|
||||||
| 服务 | 端口 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| frontend | 3000 | Nuxt.js 前端应用 |
|
|
||||||
| backend | 8080 | Go API 后端服务 |
|
|
||||||
| postgres | 5432 | PostgreSQL 数据库 |
|
|
||||||
|
|
||||||
## 快速部署
|
|
||||||
|
|
||||||
### 1. 克隆项目
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/ctwj/urldb.git
|
|
||||||
cd urldb
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 使用启动脚本(推荐)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 给脚本执行权限
|
|
||||||
chmod +x docker-start.sh
|
|
||||||
|
|
||||||
# 启动服务
|
|
||||||
./docker-start.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 手动启动
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 构建并启动所有服务
|
|
||||||
docker compose up --build -d
|
|
||||||
|
|
||||||
# 查看服务状态
|
|
||||||
docker compose ps
|
|
||||||
```
|
|
||||||
|
|
||||||
## 配置说明
|
|
||||||
|
|
||||||
### 环境变量
|
|
||||||
|
|
||||||
可以通过修改 `docker-compose.yml` 文件中的环境变量来配置服务:
|
|
||||||
|
|
||||||
后端 backend
|
|
||||||
```yaml
|
|
||||||
environment:
|
|
||||||
DB_HOST: postgres
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_USER: postgres
|
|
||||||
DB_PASSWORD: password
|
|
||||||
DB_NAME: url_db
|
|
||||||
PORT: 8080
|
|
||||||
```
|
|
||||||
|
|
||||||
前端 frontend
|
|
||||||
```yaml
|
|
||||||
environment:
|
|
||||||
API_BASE: /api
|
|
||||||
```
|
|
||||||
|
|
||||||
### 端口映射
|
|
||||||
|
|
||||||
如果需要修改端口映射,可以编辑 `docker-compose.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
ports:
|
|
||||||
- "3001:3000" # 前端端口
|
|
||||||
- "8081:8080" # API端口
|
|
||||||
- "5433:5432" # 数据库端口
|
|
||||||
```
|
|
||||||
|
|
||||||
## 常用命令
|
|
||||||
|
|
||||||
### 服务管理
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 启动服务
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# 停止服务
|
|
||||||
docker compose down
|
|
||||||
|
|
||||||
# 重启服务
|
|
||||||
docker compose restart
|
|
||||||
|
|
||||||
# 查看服务状态
|
|
||||||
docker compose ps
|
|
||||||
|
|
||||||
# 查看日志
|
|
||||||
docker compose logs -f [service_name]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 数据管理
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 备份数据库
|
|
||||||
docker compose exec postgres pg_dump -U postgres url_db > backup.sql
|
|
||||||
|
|
||||||
# 恢复数据库
|
|
||||||
docker compose exec -T postgres psql -U postgres url_db < backup.sql
|
|
||||||
|
|
||||||
# 进入数据库
|
|
||||||
docker compose exec postgres psql -U postgres url_db
|
|
||||||
```
|
|
||||||
|
|
||||||
### 容器管理
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 进入容器
|
|
||||||
docker compose exec [service_name] sh
|
|
||||||
|
|
||||||
# 查看容器资源使用
|
|
||||||
docker stats
|
|
||||||
|
|
||||||
# 清理未使用的资源
|
|
||||||
docker system prune -a
|
|
||||||
```
|
|
||||||
|
|
||||||
## 生产环境部署
|
|
||||||
|
|
||||||
### 1. 环境准备
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 安装 Docker 和 Docker Compose
|
|
||||||
# 确保服务器有足够资源(建议 4GB+ 内存)
|
|
||||||
|
|
||||||
# 创建部署目录
|
|
||||||
mkdir -p /opt/urldb
|
|
||||||
cd /opt/urldb
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 配置文件
|
|
||||||
|
|
||||||
创建生产环境配置文件:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 复制项目文件
|
|
||||||
git clone https://github.com/ctwj/urldb.git .
|
|
||||||
|
|
||||||
# 创建环境变量文件
|
|
||||||
cp env.example .env.prod
|
|
||||||
|
|
||||||
# 编辑生产环境配置
|
|
||||||
vim .env.prod
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 启动服务
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 使用生产环境配置启动
|
|
||||||
docker compose -f docker-compose.yml --env-file .env.prod up -d
|
|
||||||
|
|
||||||
# 检查服务状态
|
|
||||||
docker compose ps
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 配置反向代理
|
|
||||||
|
|
||||||
#### Nginx 配置示例
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name your-domain.com;
|
|
||||||
|
|
||||||
# 前端代理
|
|
||||||
location / {
|
|
||||||
proxy_pass http://localhost:3000;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
# API 代理
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://localhost:8080;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. SSL 配置
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 使用 Let's Encrypt 获取证书
|
|
||||||
sudo certbot --nginx -d your-domain.com
|
|
||||||
|
|
||||||
# 或使用自签名证书
|
|
||||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
|
||||||
-keyout /etc/ssl/private/urldb.key \
|
|
||||||
-out /etc/ssl/certs/urldb.crt
|
|
||||||
```
|
|
||||||
|
|
||||||
## 监控和维护
|
|
||||||
|
|
||||||
### 1. 日志管理
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查看所有服务日志
|
|
||||||
docker compose logs -f
|
|
||||||
|
|
||||||
# 查看特定服务日志
|
|
||||||
docker compose logs -f backend
|
|
||||||
|
|
||||||
# 导出日志
|
|
||||||
docker compose logs > urldb.log
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 性能监控
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查看容器资源使用
|
|
||||||
docker stats
|
|
||||||
|
|
||||||
# 查看系统资源
|
|
||||||
htop
|
|
||||||
df -h
|
|
||||||
free -h
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 备份策略
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
# 创建备份脚本 backup.sh
|
|
||||||
|
|
||||||
DATE=$(date +%Y%m%d_%H%M%S)
|
|
||||||
BACKUP_DIR="/backup/urldb"
|
|
||||||
|
|
||||||
# 创建备份目录
|
|
||||||
mkdir -p $BACKUP_DIR
|
|
||||||
|
|
||||||
# 备份数据库
|
|
||||||
docker compose exec -T postgres pg_dump -U postgres url_db > $BACKUP_DIR/db_$DATE.sql
|
|
||||||
|
|
||||||
# 备份上传文件
|
|
||||||
tar -czf $BACKUP_DIR/uploads_$DATE.tar.gz uploads/
|
|
||||||
|
|
||||||
# 删除7天前的备份
|
|
||||||
find $BACKUP_DIR -name "*.sql" -mtime +7 -delete
|
|
||||||
find $BACKUP_DIR -name "*.tar.gz" -mtime +7 -delete
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 自动更新
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
# 创建更新脚本 update.sh
|
|
||||||
|
|
||||||
cd /opt/urldb
|
|
||||||
|
|
||||||
# 拉取最新代码
|
|
||||||
git pull origin main
|
|
||||||
|
|
||||||
# 重新构建并启动
|
|
||||||
docker compose down
|
|
||||||
docker compose up --build -d
|
|
||||||
|
|
||||||
# 检查服务状态
|
|
||||||
docker compose ps
|
|
||||||
```
|
|
||||||
|
|
||||||
## 故障排除
|
|
||||||
|
|
||||||
### 1. 服务启动失败
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查看详细错误信息
|
|
||||||
docker compose logs [service_name]
|
|
||||||
|
|
||||||
# 检查端口占用
|
|
||||||
netstat -tulpn | grep :3000
|
|
||||||
netstat -tulpn | grep :8080
|
|
||||||
|
|
||||||
# 检查磁盘空间
|
|
||||||
df -h
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 数据库连接问题
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 检查数据库状态
|
|
||||||
docker compose exec postgres pg_isready -U postgres
|
|
||||||
|
|
||||||
# 检查数据库日志
|
|
||||||
docker compose logs postgres
|
|
||||||
|
|
||||||
# 重启数据库服务
|
|
||||||
docker compose restart postgres
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 前端无法访问后端
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 检查网络连接
|
|
||||||
docker compose exec frontend ping backend
|
|
||||||
|
|
||||||
# 检查 API 配置
|
|
||||||
docker compose exec frontend env | grep API_BASE
|
|
||||||
|
|
||||||
# 测试 API 连接
|
|
||||||
curl http://localhost:8080/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 内存不足
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查看内存使用
|
|
||||||
free -h
|
|
||||||
|
|
||||||
# 增加 swap 空间
|
|
||||||
sudo fallocate -l 2G /swapfile
|
|
||||||
sudo chmod 600 /swapfile
|
|
||||||
sudo mkswap /swapfile
|
|
||||||
sudo swapon /swapfile
|
|
||||||
```
|
|
||||||
|
|
||||||
## 安全建议
|
|
||||||
|
|
||||||
### 1. 网络安全
|
|
||||||
|
|
||||||
- 使用防火墙限制端口访问
|
|
||||||
- 配置 SSL/TLS 加密
|
|
||||||
- 定期更新系统和 Docker 版本
|
|
||||||
|
|
||||||
### 2. 数据安全
|
|
||||||
|
|
||||||
- 定期备份数据库
|
|
||||||
- 使用强密码
|
|
||||||
- 限制数据库访问权限
|
|
||||||
|
|
||||||
### 3. 容器安全
|
|
||||||
|
|
||||||
- 使用非 root 用户运行容器
|
|
||||||
- 定期更新镜像
|
|
||||||
- 扫描镜像漏洞
|
|
||||||
|
|
||||||
## 下一步
|
|
||||||
|
|
||||||
- [了解系统配置](../guide/configuration.md)
|
|
||||||
- [查看 API 文档](../api/overview.md)
|
|
||||||
- [学习监控和维护](../development/deployment.md)
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
# 💻 本地开发
|
|
||||||
|
|
||||||
## 环境准备
|
|
||||||
|
|
||||||
### 1. 安装必需软件
|
|
||||||
|
|
||||||
#### Go 环境
|
|
||||||
```bash
|
|
||||||
# 下载并安装 Go 1.23+
|
|
||||||
# 访问 https://golang.org/dl/
|
|
||||||
# 或使用包管理器安装
|
|
||||||
|
|
||||||
# 验证安装
|
|
||||||
go version
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Node.js 环境
|
|
||||||
```bash
|
|
||||||
# 下载并安装 Node.js 18+
|
|
||||||
# 访问 https://nodejs.org/
|
|
||||||
# 或使用 nvm 安装
|
|
||||||
|
|
||||||
# 验证安装
|
|
||||||
node --version
|
|
||||||
npm --version
|
|
||||||
```
|
|
||||||
|
|
||||||
#### PostgreSQL 数据库
|
|
||||||
```bash
|
|
||||||
# Ubuntu/Debian
|
|
||||||
sudo apt update
|
|
||||||
sudo apt install postgresql postgresql-contrib
|
|
||||||
|
|
||||||
# macOS (使用 Homebrew)
|
|
||||||
brew install postgresql
|
|
||||||
|
|
||||||
# 启动服务
|
|
||||||
sudo systemctl start postgresql # Linux
|
|
||||||
brew services start postgresql # macOS
|
|
||||||
```
|
|
||||||
|
|
||||||
#### pnpm (推荐)
|
|
||||||
```bash
|
|
||||||
# 安装 pnpm
|
|
||||||
npm install -g pnpm
|
|
||||||
|
|
||||||
# 验证安装
|
|
||||||
pnpm --version
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 克隆项目
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/ctwj/urldb.git
|
|
||||||
cd urldb
|
|
||||||
```
|
|
||||||
|
|
||||||
## 后端开发
|
|
||||||
|
|
||||||
### 1. 环境配置
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 复制环境变量文件
|
|
||||||
cp env.example .env
|
|
||||||
|
|
||||||
# 编辑环境变量
|
|
||||||
vim .env
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 数据库设置
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 登录 PostgreSQL
|
|
||||||
sudo -u postgres psql
|
|
||||||
|
|
||||||
-- 创建数据库
|
|
||||||
CREATE DATABASE url_db;
|
|
||||||
|
|
||||||
-- 创建用户(可选)
|
|
||||||
CREATE USER url_user WITH PASSWORD 'your_password';
|
|
||||||
GRANT ALL PRIVILEGES ON DATABASE url_db TO url_user;
|
|
||||||
|
|
||||||
-- 退出
|
|
||||||
\q
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 安装 Go 依赖
|
|
||||||
go mod tidy
|
|
||||||
|
|
||||||
# 验证依赖
|
|
||||||
go mod verify
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 启动后端服务
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 开发模式启动
|
|
||||||
go run main.go
|
|
||||||
|
|
||||||
# 或使用 air 热重载(推荐)
|
|
||||||
go install github.com/cosmtrek/air@latest
|
|
||||||
air
|
|
||||||
```
|
|
||||||
|
|
||||||
## 前端开发
|
|
||||||
|
|
||||||
### 1. 进入前端目录
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd web
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 使用 pnpm (推荐)
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# 或使用 npm
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 启动开发服务器
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 开发模式
|
|
||||||
pnpm dev
|
|
||||||
|
|
||||||
# 或使用 npm
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 访问前端
|
|
||||||
|
|
||||||
前端服务启动后,访问 http://localhost:3000
|
|
||||||
|
|
||||||
## 开发工具
|
|
||||||
|
|
||||||
### 推荐的 IDE 和插件
|
|
||||||
|
|
||||||
#### VS Code
|
|
||||||
- **Go** - Go 语言支持
|
|
||||||
- **Vetur** 或 **Volar** - Vue.js 支持
|
|
||||||
- **PostgreSQL** - 数据库支持
|
|
||||||
- **Docker** - Docker 支持
|
|
||||||
- **GitLens** - Git 增强
|
|
||||||
|
|
||||||
#### GoLand / IntelliJ IDEA
|
|
||||||
- 内置 Go 和 Vue.js 支持
|
|
||||||
- 数据库工具
|
|
||||||
- Docker 集成
|
|
||||||
|
|
||||||
### 代码格式化
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Go 代码格式化
|
|
||||||
go fmt ./...
|
|
||||||
|
|
||||||
# 前端代码格式化
|
|
||||||
cd web
|
|
||||||
pnpm format
|
|
||||||
```
|
|
||||||
|
|
||||||
### 代码检查
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Go 代码检查
|
|
||||||
go vet ./...
|
|
||||||
|
|
||||||
# 前端代码检查
|
|
||||||
cd web
|
|
||||||
pnpm lint
|
|
||||||
```
|
|
||||||
|
|
||||||
## 调试技巧
|
|
||||||
|
|
||||||
### 后端调试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 使用 delve 调试器
|
|
||||||
go install github.com/go-delve/delve/cmd/dlv@latest
|
|
||||||
dlv debug main.go
|
|
||||||
|
|
||||||
# 或使用 VS Code 调试配置
|
|
||||||
```
|
|
||||||
|
|
||||||
### 前端调试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 启动开发服务器时开启调试
|
|
||||||
cd web
|
|
||||||
pnpm dev --inspect
|
|
||||||
```
|
|
||||||
|
|
||||||
### 数据库调试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 连接数据库
|
|
||||||
psql -h localhost -U postgres -d url_db
|
|
||||||
|
|
||||||
# 查看表结构
|
|
||||||
\dt
|
|
||||||
|
|
||||||
# 查看数据
|
|
||||||
SELECT * FROM users LIMIT 5;
|
|
||||||
```
|
|
||||||
|
|
||||||
## 测试
|
|
||||||
|
|
||||||
### 后端测试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 运行所有测试
|
|
||||||
go test ./...
|
|
||||||
|
|
||||||
# 运行特定测试
|
|
||||||
go test ./handlers
|
|
||||||
|
|
||||||
# 生成测试覆盖率报告
|
|
||||||
go test -cover ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
### 前端测试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd web
|
|
||||||
|
|
||||||
# 运行单元测试
|
|
||||||
pnpm test
|
|
||||||
|
|
||||||
# 运行 E2E 测试
|
|
||||||
pnpm test:e2e
|
|
||||||
```
|
|
||||||
|
|
||||||
## 构建
|
|
||||||
|
|
||||||
### 后端构建
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 构建二进制文件
|
|
||||||
go build -o urlDB main.go
|
|
||||||
|
|
||||||
# 交叉编译
|
|
||||||
GOOS=linux GOARCH=amd64 go build -o urlDB-linux main.go
|
|
||||||
```
|
|
||||||
|
|
||||||
### 前端构建
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd web
|
|
||||||
|
|
||||||
# 构建生产版本
|
|
||||||
pnpm build
|
|
||||||
|
|
||||||
# 预览构建结果
|
|
||||||
pnpm preview
|
|
||||||
```
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### 1. 端口冲突
|
|
||||||
|
|
||||||
如果遇到端口被占用的问题:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查看端口占用
|
|
||||||
lsof -i :8080
|
|
||||||
lsof -i :3000
|
|
||||||
|
|
||||||
# 杀死进程
|
|
||||||
kill -9 <PID>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 数据库连接失败
|
|
||||||
|
|
||||||
检查 `.env` 文件中的数据库配置:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DB_HOST=localhost
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_USER=postgres
|
|
||||||
DB_PASSWORD=your_password
|
|
||||||
DB_NAME=url_db
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 前端依赖安装失败
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 清除缓存
|
|
||||||
pnpm store prune
|
|
||||||
rm -rf node_modules
|
|
||||||
pnpm install
|
|
||||||
```
|
|
||||||
|
|
||||||
## 下一步
|
|
||||||
|
|
||||||
- [了解项目架构](../architecture/overview.md)
|
|
||||||
- [查看 API 文档](../api/overview.md)
|
|
||||||
- [学习代码规范](../development/coding-standards.md)
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# 🚀 快速开始
|
|
||||||
|
|
||||||
## 环境要求
|
|
||||||
|
|
||||||
在开始使用 urlDB 之前,请确保您的系统满足以下要求:
|
|
||||||
|
|
||||||
### 推荐配置
|
|
||||||
- **CPU**: 2核
|
|
||||||
- **内存**: 2GB+
|
|
||||||
- **存储**: 20GB+ 可用空间
|
|
||||||
|
|
||||||
## 🐳 Docker 部署
|
|
||||||
|
|
||||||
### 1. 克隆项目
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/ctwj/urldb.git
|
|
||||||
cd urldb
|
|
||||||
docker compose up --build -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 访问应用
|
|
||||||
|
|
||||||
启动成功后,您可以通过以下地址访问:
|
|
||||||
|
|
||||||
- **前端界面**: http://localhost:3030
|
|
||||||
默认用户密码: admin/password
|
|
||||||
|
|
||||||
|
|
||||||
## 🆘 遇到问题?
|
|
||||||
|
|
||||||
如果您在部署过程中遇到问题,请:
|
|
||||||
|
|
||||||
1. 查看 [常见问题](../faq.md)
|
|
||||||
2. 检查 [更新日志](../changelog.md)
|
|
||||||
3. 提交 [Issue](https://github.com/ctwj/urldb/issues)
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>urlDB - 老九网盘资源数据库</title>
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
|
||||||
<meta name="description" content="一个现代化的网盘资源数据库,支持多网盘自动化转存分享">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
|
|
||||||
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/docsify@4/lib/themes/vue.css">
|
|
||||||
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/docsify@4/lib/themes/dark.css" media="(prefers-color-scheme: dark)">
|
|
||||||
<link rel="icon" href="https://img.icons8.com/color/48/000000/database.png" type="image/x-icon">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script src="//cdn.jsdelivr.net/npm/docsify@4"></script>
|
|
||||||
<script src="docsify.config.js"></script>
|
|
||||||
<script src="//cdn.jsdelivr.net/npm/docsify@4/lib/plugins/search.min.js"></script>
|
|
||||||
<script src="//cdn.jsdelivr.net/npm/docsify@4/lib/plugins/copy-code.min.js"></script>
|
|
||||||
<script src="//cdn.jsdelivr.net/npm/docsify@4/lib/plugins/pagination.min.js"></script>
|
|
||||||
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-bash.min.js"></script>
|
|
||||||
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-go.min.js"></script>
|
|
||||||
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-javascript.min.js"></script>
|
|
||||||
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-typescript.min.js"></script>
|
|
||||||
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-sql.min.js"></script>
|
|
||||||
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-yaml.min.js"></script>
|
|
||||||
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-json.min.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
# 许可证
|
|
||||||
|
|
||||||
## GNU General Public License v3.0
|
|
||||||
|
|
||||||
本项目采用 GNU General Public License v3.0 (GPL-3.0) 许可证。
|
|
||||||
|
|
||||||
### 许可证概述
|
|
||||||
|
|
||||||
GPL-3.0 是一个自由软件许可证,它确保软件保持自由和开放。该许可证的主要特点包括:
|
|
||||||
|
|
||||||
- **自由使用**: 您可以自由地运行、研究、修改和分发软件
|
|
||||||
- **源代码开放**: 修改后的代码必须同样开源
|
|
||||||
- **专利保护**: 包含专利授权条款
|
|
||||||
- **兼容性**: 与大多数开源许可证兼容
|
|
||||||
|
|
||||||
### 主要条款
|
|
||||||
|
|
||||||
1. **自由使用和分发**
|
|
||||||
- 您可以自由地使用、复制、分发和修改本软件
|
|
||||||
- 您可以商业使用本软件
|
|
||||||
|
|
||||||
2. **源代码要求**
|
|
||||||
- 如果您分发修改后的版本,必须同时提供源代码
|
|
||||||
- 源代码必须采用相同的许可证
|
|
||||||
|
|
||||||
3. **专利授权**
|
|
||||||
- 贡献者自动授予用户专利使用权
|
|
||||||
- 保护用户免受专利诉讼
|
|
||||||
|
|
||||||
4. **免责声明**
|
|
||||||
- 软件按"原样"提供,不提供任何保证
|
|
||||||
- 作者不承担任何责任
|
|
||||||
|
|
||||||
### 完整许可证文本
|
|
||||||
|
|
||||||
```
|
|
||||||
GNU GENERAL PUBLIC LICENSE
|
|
||||||
Version 3, 29 June 2007
|
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
|
||||||
of this license document, but changing it is not allowed.
|
|
||||||
|
|
||||||
Preamble
|
|
||||||
|
|
||||||
The GNU General Public License is a free, copyleft license for
|
|
||||||
software and other kinds of works.
|
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
|
||||||
to take away your freedom to share and change the works. By contrast,
|
|
||||||
the GNU General Public License is intended to guarantee your freedom to
|
|
||||||
share and change all versions of a program--to make sure it remains free
|
|
||||||
software for all its users. We, the Free Software Foundation, use the
|
|
||||||
GNU General Public License for most of our software; it applies also to
|
|
||||||
any other work released this way by its authors. You can apply it to
|
|
||||||
your programs, too.
|
|
||||||
|
|
||||||
[... 完整许可证文本请访问 https://www.gnu.org/licenses/gpl-3.0.html ...]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 如何遵守许可证
|
|
||||||
|
|
||||||
如果您使用或修改本项目:
|
|
||||||
|
|
||||||
1. **保留许可证信息**: 不要删除或修改许可证文件
|
|
||||||
2. **注明修改**: 在修改的代码中添加适当的注释
|
|
||||||
3. **分发源代码**: 如果分发修改版本,必须提供源代码
|
|
||||||
4. **使用相同许可证**: 修改版本必须使用相同的GPL-3.0许可证
|
|
||||||
|
|
||||||
### 贡献代码
|
|
||||||
|
|
||||||
当您向本项目贡献代码时,您同意:
|
|
||||||
|
|
||||||
- 您的贡献将采用GPL-3.0许可证
|
|
||||||
- 您拥有或有权许可您贡献的代码
|
|
||||||
- 您授予项目维护者使用您贡献代码的权利
|
|
||||||
|
|
||||||
### 联系方式
|
|
||||||
|
|
||||||
如果您对许可证有任何疑问,请联系项目维护者。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**注意**: 本许可证信息仅供参考,完整和权威的许可证文本请参考 [GNU General Public License v3.0](https://www.gnu.org/licenses/gpl-3.0.html)。
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# 启动 docsify 文档服务脚本
|
|
||||||
|
|
||||||
echo "🚀 启动 docsify 文档服务..."
|
|
||||||
|
|
||||||
# 检查是否安装了 docsify-cli
|
|
||||||
if ! command -v docsify &> /dev/null; then
|
|
||||||
echo "❌ 未检测到 docsify-cli,正在安装..."
|
|
||||||
npm install -g docsify-cli
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "❌ docsify-cli 安装失败,请手动安装:"
|
|
||||||
echo " npm install -g docsify-cli"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 获取当前脚本所在目录
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
echo "📖 文档目录: $SCRIPT_DIR"
|
|
||||||
echo "🌐 启动文档服务..."
|
|
||||||
|
|
||||||
# 启动 docsify 服务
|
|
||||||
docsify serve "$SCRIPT_DIR" --port 3000 --open
|
|
||||||
|
|
||||||
echo "✅ 文档服务已启动!"
|
|
||||||
echo "📱 访问地址: http://localhost:3000"
|
|
||||||
echo "🛑 按 Ctrl+C 停止服务"
|
|
||||||
@@ -14,4 +14,4 @@ TIMEZONE=Asia/Shanghai
|
|||||||
|
|
||||||
# 文件上传配置
|
# 文件上传配置
|
||||||
UPLOAD_DIR=./uploads
|
UPLOAD_DIR=./uploads
|
||||||
MAX_FILE_SIZE=100MB
|
MAX_FILE_SIZE=5MB
|
||||||
6
go.mod
6
go.mod
@@ -10,11 +10,17 @@ require (
|
|||||||
github.com/go-resty/resty/v2 v2.16.5
|
github.com/go-resty/resty/v2 v2.16.5
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/meilisearch/meilisearch-go v0.33.1
|
||||||
golang.org/x/crypto v0.40.0
|
golang.org/x/crypto v0.40.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/gorm v1.30.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 (
|
require (
|
||||||
github.com/bytedance/sonic v1.13.3 // indirect
|
github.com/bytedance/sonic v1.13.3 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
|
|||||||
8
go.sum
8
go.sum
@@ -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 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
|
||||||
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
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=
|
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.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 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
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 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
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=
|
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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
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.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 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
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 h1:LmbDQUodHThXE+htjrnmVD73M//D9GTH6wFZjyDkjyU=
|
||||||
golang.org/x/arch v0.19.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
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=
|
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
|||||||
@@ -2,11 +2,18 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/ctwj/urldb/db/repo"
|
"github.com/ctwj/urldb/db/repo"
|
||||||
|
"github.com/ctwj/urldb/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
var repoManager *repo.RepositoryManager
|
var repoManager *repo.RepositoryManager
|
||||||
|
var meilisearchManager *services.MeilisearchManager
|
||||||
|
|
||||||
// SetRepositoryManager 设置Repository管理器
|
// SetRepositoryManager 设置Repository管理器
|
||||||
func SetRepositoryManager(rm *repo.RepositoryManager) {
|
func SetRepositoryManager(manager *repo.RepositoryManager) {
|
||||||
repoManager = rm
|
repoManager = manager
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMeilisearchManager 设置Meilisearch管理器
|
||||||
|
func SetMeilisearchManager(manager *services.MeilisearchManager) {
|
||||||
|
meilisearchManager = manager
|
||||||
}
|
}
|
||||||
|
|||||||
442
handlers/file_handler.go
Normal file
442
handlers/file_handler.go
Normal 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
|
||||||
|
}
|
||||||
270
handlers/meilisearch_handler.go
Normal file
270
handlers/meilisearch_handler.go
Normal 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": "索引设置更新成功"})
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/ctwj/urldb/db/dto"
|
"github.com/ctwj/urldb/db/dto"
|
||||||
"github.com/ctwj/urldb/db/entity"
|
"github.com/ctwj/urldb/db/entity"
|
||||||
|
|
||||||
|
"github.com/ctwj/urldb/utils"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -182,6 +183,7 @@ func (h *PublicAPIHandler) SearchResources(c *gin.Context) {
|
|||||||
keyword := c.Query("keyword")
|
keyword := c.Query("keyword")
|
||||||
tag := c.Query("tag")
|
tag := c.Query("tag")
|
||||||
category := c.Query("category")
|
category := c.Query("category")
|
||||||
|
panID := c.Query("pan_id")
|
||||||
pageStr := c.DefaultQuery("page", "1")
|
pageStr := c.DefaultQuery("page", "1")
|
||||||
pageSizeStr := c.DefaultQuery("page_size", "20")
|
pageSizeStr := c.DefaultQuery("page_size", "20")
|
||||||
|
|
||||||
@@ -195,65 +197,127 @@ func (h *PublicAPIHandler) SearchResources(c *gin.Context) {
|
|||||||
pageSize = 20
|
pageSize = 20
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建搜索条件
|
var resources []entity.Resource
|
||||||
params := map[string]interface{}{
|
var total int64
|
||||||
"page": page,
|
|
||||||
"page_size": pageSize,
|
// 如果启用了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 != "" {
|
// 如果Meilisearch未启用或搜索失败,使用数据库搜索
|
||||||
params["search"] = keyword
|
if meilisearchManager == nil || !meilisearchManager.IsEnabled() || err != nil {
|
||||||
|
// 构建搜索条件
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"page": page,
|
||||||
|
"page_size": pageSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
if keyword != "" {
|
||||||
|
params["search"] = keyword
|
||||||
|
}
|
||||||
|
|
||||||
|
if tag != "" {
|
||||||
|
params["tag"] = tag
|
||||||
|
}
|
||||||
|
|
||||||
|
if category != "" {
|
||||||
|
params["category"] = category
|
||||||
|
}
|
||||||
|
if panID != "" {
|
||||||
|
if id, err := strconv.ParseUint(panID, 10, 32); err == nil {
|
||||||
|
params["pan_id"] = uint(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行数据库搜索
|
||||||
|
resources, total, err = repoManager.ResourceRepository.SearchWithFilters(params)
|
||||||
|
if err != nil {
|
||||||
|
ErrorResponse(c, "搜索失败: "+err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if tag != "" {
|
// 获取违禁词配置(只获取一次)
|
||||||
params["tag"] = tag
|
cleanWords, err := utils.GetForbiddenWordsFromConfig(func() (string, error) {
|
||||||
}
|
return repoManager.SystemConfigRepository.GetConfigValue(entity.ConfigKeyForbiddenWords)
|
||||||
|
})
|
||||||
if category != "" {
|
|
||||||
params["category"] = category
|
|
||||||
}
|
|
||||||
|
|
||||||
// 执行搜索
|
|
||||||
resources, total, err := repoManager.ResourceRepository.SearchWithFilters(params)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorResponse(c, "搜索失败: "+err.Error(), 500)
|
utils.Error("获取违禁词配置失败: %v", err)
|
||||||
return
|
cleanWords = []string{} // 如果获取失败,使用空列表
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过滤违禁词
|
// 转换为响应格式并添加违禁词标记
|
||||||
filteredResources, foundForbiddenWords := h.filterForbiddenWords(resources)
|
|
||||||
|
|
||||||
// 计算过滤后的总数
|
|
||||||
filteredTotal := len(filteredResources)
|
|
||||||
|
|
||||||
// 转换为响应格式
|
|
||||||
var resourceResponses []gin.H
|
var resourceResponses []gin.H
|
||||||
for _, resource := range filteredResources {
|
for i, processedResource := range resources {
|
||||||
resourceResponses = append(resourceResponses, gin.H{
|
originalResource := resources[i]
|
||||||
"id": resource.ID,
|
forbiddenInfo := utils.CheckResourceForbiddenWords(originalResource.Title, originalResource.Description, cleanWords)
|
||||||
"title": resource.Title,
|
|
||||||
"url": resource.URL,
|
resourceResponse := gin.H{
|
||||||
"description": resource.Description,
|
"id": processedResource.ID,
|
||||||
"view_count": resource.ViewCount,
|
"title": forbiddenInfo.ProcessedTitle, // 使用处理后的标题
|
||||||
"created_at": resource.CreatedAt.Format("2006-01-02 15:04:05"),
|
"url": processedResource.URL,
|
||||||
"updated_at": resource.UpdatedAt.Format("2006-01-02 15:04:05"),
|
"description": forbiddenInfo.ProcessedDesc, // 使用处理后的描述
|
||||||
})
|
"view_count": processedResource.ViewCount,
|
||||||
|
"created_at": processedResource.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
"updated_at": processedResource.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加违禁词标记
|
||||||
|
resourceResponse["has_forbidden_words"] = forbiddenInfo.HasForbiddenWords
|
||||||
|
resourceResponse["forbidden_words"] = forbiddenInfo.ForbiddenWords
|
||||||
|
resourceResponses = append(resourceResponses, resourceResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建响应数据
|
// 构建响应数据
|
||||||
responseData := gin.H{
|
responseData := gin.H{
|
||||||
"list": resourceResponses,
|
"data": resourceResponses,
|
||||||
"total": filteredTotal,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"limit": pageSize,
|
"page_size": pageSize,
|
||||||
}
|
|
||||||
|
|
||||||
// 如果存在违禁词过滤,添加提醒字段
|
|
||||||
if len(foundForbiddenWords) > 0 {
|
|
||||||
responseData["forbidden_words_filtered"] = true
|
|
||||||
responseData["filtered_forbidden_words"] = foundForbiddenWords
|
|
||||||
responseData["original_total"] = total
|
|
||||||
responseData["filtered_count"] = total - int64(filteredTotal)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SuccessResponse(c, responseData)
|
SuccessResponse(c, responseData)
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ func GetResources(c *gin.Context) {
|
|||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
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{}{
|
params := map[string]interface{}{
|
||||||
"page": page,
|
"page": page,
|
||||||
@@ -60,16 +64,74 @@ func GetResources(c *gin.Context) {
|
|||||||
params["pan_name"] = panName
|
params["pan_name"] = panName
|
||||||
}
|
}
|
||||||
|
|
||||||
resources, total, err := repoManager.ResourceRepository.SearchWithFilters(params)
|
// 获取违禁词配置(只获取一次)
|
||||||
|
cleanWords, err := utils.GetForbiddenWordsFromConfig(func() (string, error) {
|
||||||
|
return repoManager.SystemConfigRepository.GetConfigValue(entity.ConfigKeyForbiddenWords)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
utils.Error("获取违禁词配置失败: %v", err)
|
||||||
|
cleanWords = []string{} // 如果获取失败,使用空列表
|
||||||
|
}
|
||||||
|
|
||||||
// 搜索统计(仅非管理员)
|
var resources []entity.Resource
|
||||||
if search, ok := params["search"].(string); ok && search != "" {
|
var total int64
|
||||||
user, _ := c.Get("user")
|
|
||||||
if user == nil || (user != nil && user.(entity.User).Role != "admin") {
|
// 如果有搜索关键词且启用了Meilisearch,优先使用Meilisearch搜索
|
||||||
ip := c.ClientIP()
|
if search := c.Query("search"); search != "" && meilisearchManager != nil && meilisearchManager.IsEnabled() {
|
||||||
userAgent := c.GetHeader("User-Agent")
|
// 构建Meilisearch过滤器
|
||||||
repoManager.SearchStatRepository.RecordSearch(search, ip, userAgent)
|
filters := make(map[string]interface{})
|
||||||
|
if panID := c.Query("pan_id"); panID != "" {
|
||||||
|
if id, err := strconv.ParseUint(panID, 10, 32); err == nil {
|
||||||
|
// 直接使用pan_id进行过滤
|
||||||
|
filters["pan_id"] = id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使用Meilisearch搜索
|
||||||
|
service := meilisearchManager.GetService()
|
||||||
|
if service != nil {
|
||||||
|
docs, docTotal, err := service.Search(search, filters, page, pageSize)
|
||||||
|
if err == nil {
|
||||||
|
|
||||||
|
// 将Meilisearch文档转换为ResourceResponse(包含高亮信息)并处理违禁词
|
||||||
|
var resourceResponses []dto.ResourceResponse
|
||||||
|
for _, doc := range docs {
|
||||||
|
resourceResponse := converter.ToResourceResponseFromMeilisearch(doc)
|
||||||
|
|
||||||
|
// 处理违禁词(Meilisearch场景,需要处理高亮标记)
|
||||||
|
if len(cleanWords) > 0 {
|
||||||
|
forbiddenInfo := utils.CheckResourceForbiddenWords(resourceResponse.Title, resourceResponse.Description, cleanWords)
|
||||||
|
if forbiddenInfo.HasForbiddenWords {
|
||||||
|
resourceResponse.Title = forbiddenInfo.ProcessedTitle
|
||||||
|
resourceResponse.Description = forbiddenInfo.ProcessedDesc
|
||||||
|
resourceResponse.TitleHighlight = forbiddenInfo.ProcessedTitle
|
||||||
|
resourceResponse.DescriptionHighlight = forbiddenInfo.ProcessedDesc
|
||||||
|
}
|
||||||
|
resourceResponse.HasForbiddenWords = forbiddenInfo.HasForbiddenWords
|
||||||
|
resourceResponse.ForbiddenWords = forbiddenInfo.ForbiddenWords
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceResponses = append(resourceResponses, resourceResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回Meilisearch搜索结果(包含高亮信息)
|
||||||
|
SuccessResponse(c, gin.H{
|
||||||
|
"data": resourceResponses,
|
||||||
|
"total": docTotal,
|
||||||
|
"page": page,
|
||||||
|
"page_size": pageSize,
|
||||||
|
"source": "meilisearch",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
utils.Error("Meilisearch搜索失败,回退到数据库搜索: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果Meilisearch未启用、搜索失败或没有搜索关键词,使用数据库搜索
|
||||||
|
if meilisearchManager == nil || !meilisearchManager.IsEnabled() || len(resources) == 0 {
|
||||||
|
resources, total, err = repoManager.ResourceRepository.SearchWithFilters(params)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -77,12 +139,48 @@ func GetResources(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
SuccessResponse(c, gin.H{
|
// 处理违禁词替换和标记
|
||||||
"data": converter.ToResourceResponseList(resources),
|
var processedResources []entity.Resource
|
||||||
|
if len(cleanWords) > 0 {
|
||||||
|
processedResources = utils.ProcessResourcesForbiddenWords(resources, cleanWords)
|
||||||
|
} else {
|
||||||
|
processedResources = resources
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为响应格式并添加违禁词标记
|
||||||
|
var resourceResponses []gin.H
|
||||||
|
for i, processedResource := range processedResources {
|
||||||
|
// 使用原始资源进行检查违禁词(数据库搜索场景,使用普通处理)
|
||||||
|
originalResource := resources[i]
|
||||||
|
forbiddenInfo := utils.CheckResourceForbiddenWords(originalResource.Title, originalResource.Description, cleanWords)
|
||||||
|
|
||||||
|
resourceResponse := gin.H{
|
||||||
|
"id": processedResource.ID,
|
||||||
|
"title": forbiddenInfo.ProcessedTitle, // 使用处理后的标题
|
||||||
|
"url": processedResource.URL,
|
||||||
|
"description": forbiddenInfo.ProcessedDesc, // 使用处理后的描述
|
||||||
|
"pan_id": processedResource.PanID,
|
||||||
|
"view_count": processedResource.ViewCount,
|
||||||
|
"created_at": processedResource.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
"updated_at": processedResource.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加违禁词标记
|
||||||
|
resourceResponse["has_forbidden_words"] = forbiddenInfo.HasForbiddenWords
|
||||||
|
resourceResponse["forbidden_words"] = forbiddenInfo.ForbiddenWords
|
||||||
|
|
||||||
|
resourceResponses = append(resourceResponses, resourceResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建响应数据
|
||||||
|
responseData := gin.H{
|
||||||
|
"data": resourceResponses,
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": pageSize,
|
"page_size": pageSize,
|
||||||
})
|
}
|
||||||
|
|
||||||
|
SuccessResponse(c, responseData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetResourceByID 根据ID获取资源
|
// GetResourceByID 根据ID获取资源
|
||||||
@@ -170,6 +268,15 @@ func CreateResource(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同步到Meilisearch
|
||||||
|
if meilisearchManager != nil && meilisearchManager.IsEnabled() {
|
||||||
|
go func() {
|
||||||
|
if err := meilisearchManager.SyncResourceToMeilisearch(resource); err != nil {
|
||||||
|
utils.Error("同步资源到Meilisearch失败: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
SuccessResponse(c, gin.H{
|
SuccessResponse(c, gin.H{
|
||||||
"message": "资源创建成功",
|
"message": "资源创建成功",
|
||||||
"resource": converter.ToResourceResponse(resource),
|
"resource": converter.ToResourceResponse(resource),
|
||||||
@@ -246,6 +353,15 @@ func UpdateResource(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同步到Meilisearch
|
||||||
|
if meilisearchManager != nil && meilisearchManager.IsEnabled() {
|
||||||
|
go func() {
|
||||||
|
if err := meilisearchManager.SyncResourceToMeilisearch(resource); err != nil {
|
||||||
|
utils.Error("同步资源到Meilisearch失败: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
SuccessResponse(c, gin.H{"message": "资源更新成功"})
|
SuccessResponse(c, gin.H{"message": "资源更新成功"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,16 +393,53 @@ func SearchResources(c *gin.Context) {
|
|||||||
var total int64
|
var total int64
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
if query == "" {
|
// 如果启用了Meilisearch,优先使用Meilisearch搜索
|
||||||
// 搜索关键词为空时,返回最新记录(分页)
|
if meilisearchManager != nil && meilisearchManager.IsEnabled() {
|
||||||
resources, total, err = repoManager.ResourceRepository.FindWithRelationsPaginated(page, pageSize)
|
// 构建过滤器
|
||||||
} else {
|
filters := make(map[string]interface{})
|
||||||
// 有搜索关键词时,执行搜索
|
if categoryID := c.Query("category_id"); categoryID != "" {
|
||||||
resources, total, err = repoManager.ResourceRepository.Search(query, nil, page, pageSize)
|
if id, err := strconv.ParseUint(categoryID, 10, 32); err == nil {
|
||||||
// 新增:记录搜索关键词
|
filters["category"] = uint(id)
|
||||||
ip := c.ClientIP()
|
}
|
||||||
userAgent := c.GetHeader("User-Agent")
|
}
|
||||||
repoManager.SearchStatRepository.RecordSearch(query, ip, userAgent)
|
|
||||||
|
// 使用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 {
|
if err != nil {
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ func GetSearchStats(c *gin.Context) {
|
|||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
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 {
|
if err != nil {
|
||||||
ErrorResponse(c, "获取搜索统计失败", http.StatusInternalServerError)
|
ErrorResponse(c, "获取搜索统计失败", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
pan "github.com/ctwj/urldb/common"
|
||||||
"github.com/ctwj/urldb/db/converter"
|
"github.com/ctwj/urldb/db/converter"
|
||||||
"github.com/ctwj/urldb/db/dto"
|
"github.com/ctwj/urldb/db/dto"
|
||||||
"github.com/ctwj/urldb/db/entity"
|
"github.com/ctwj/urldb/db/entity"
|
||||||
@@ -27,6 +28,20 @@ func NewSystemConfigHandler(systemConfigRepo repo.SystemConfigRepository) *Syste
|
|||||||
|
|
||||||
// GetConfig 获取系统配置
|
// GetConfig 获取系统配置
|
||||||
func (h *SystemConfigHandler) GetConfig(c *gin.Context) {
|
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()
|
configs, err := h.systemConfigRepo.GetOrCreateDefault()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorResponse(c, "获取系统配置失败", http.StatusInternalServerError)
|
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)
|
ErrorResponse(c, "网站标题长度必须在1-100字符之间", http.StatusBadRequest)
|
||||||
return
|
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)
|
ErrorResponse(c, "自动处理间隔必须在1-1440分钟之间", http.StatusBadRequest)
|
||||||
return
|
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)
|
ErrorResponse(c, "每页显示数量必须在10-500之间", http.StatusBadRequest)
|
||||||
return
|
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)
|
ErrorResponse(c, "最小存储空间必须在100-1024GB之间", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -80,6 +95,9 @@ func (h *SystemConfigHandler) UpdateConfig(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 刷新系统配置缓存
|
||||||
|
pan.RefreshSystemConfigCache()
|
||||||
|
|
||||||
// 返回更新后的配置
|
// 返回更新后的配置
|
||||||
updatedConfigs, err := h.systemConfigRepo.FindAll()
|
updatedConfigs, err := h.systemConfigRepo.FindAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -114,29 +132,37 @@ func UpdateSystemConfig(c *gin.Context) {
|
|||||||
// 调试信息
|
// 调试信息
|
||||||
utils.Info("接收到的配置请求: %+v", req)
|
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)
|
ErrorResponse(c, "网站标题长度必须在1-100字符之间", http.StatusBadRequest)
|
||||||
return
|
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)
|
ErrorResponse(c, "自动处理间隔必须在1-1440分钟之间", http.StatusBadRequest)
|
||||||
return
|
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)
|
ErrorResponse(c, "每页显示数量必须在10-500之间", http.StatusBadRequest)
|
||||||
return
|
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)
|
ErrorResponse(c, "自动转存限制天数必须在0-365之间", http.StatusBadRequest)
|
||||||
return
|
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)
|
ErrorResponse(c, "最小存储空间必须在100-1024GB之间", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -148,13 +174,38 @@ func UpdateSystemConfig(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
utils.Info("准备更新配置,配置项数量: %d", len(configs))
|
||||||
|
|
||||||
// 保存配置
|
// 保存配置
|
||||||
err := repoManager.SystemConfigRepository.UpsertConfigs(configs)
|
err = repoManager.SystemConfigRepository.UpsertConfigs(configs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
utils.Error("保存系统配置失败: %v", err)
|
||||||
ErrorResponse(c, "保存系统配置失败", http.StatusInternalServerError)
|
ErrorResponse(c, "保存系统配置失败", http.StatusInternalServerError)
|
||||||
return
|
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(
|
scheduler := scheduler.GetGlobalScheduler(
|
||||||
repoManager.HotDramaRepository,
|
repoManager.HotDramaRepository,
|
||||||
@@ -167,16 +218,30 @@ func UpdateSystemConfig(c *gin.Context) {
|
|||||||
repoManager.CategoryRepository,
|
repoManager.CategoryRepository,
|
||||||
)
|
)
|
||||||
if scheduler != nil {
|
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()
|
updatedConfigs, err := repoManager.SystemConfigRepository.FindAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
utils.Error("获取更新后的配置失败: %v", err)
|
||||||
ErrorResponse(c, "获取更新后的配置失败", http.StatusInternalServerError)
|
ErrorResponse(c, "获取更新后的配置失败", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
utils.Info("配置更新完成,当前配置数量: %d", len(updatedConfigs))
|
||||||
|
|
||||||
configResponse := converter.SystemConfigToResponse(updatedConfigs)
|
configResponse := converter.SystemConfigToResponse(updatedConfigs)
|
||||||
SuccessResponse(c, configResponse)
|
SuccessResponse(c, configResponse)
|
||||||
}
|
}
|
||||||
@@ -192,6 +257,36 @@ func GetPublicSystemConfig(c *gin.Context) {
|
|||||||
SuccessResponse(c, configResponse)
|
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) {
|
func ToggleAutoProcess(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
@@ -227,6 +322,12 @@ func ToggleAutoProcess(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 确保配置缓存已刷新
|
||||||
|
if err := repoManager.SystemConfigRepository.SafeRefreshConfigCache(); err != nil {
|
||||||
|
utils.Error("刷新配置缓存失败: %v", err)
|
||||||
|
// 不返回错误,因为配置已经保存成功
|
||||||
|
}
|
||||||
|
|
||||||
// 更新定时任务状态
|
// 更新定时任务状态
|
||||||
scheduler := scheduler.GetGlobalScheduler(
|
scheduler := scheduler.GetGlobalScheduler(
|
||||||
repoManager.HotDramaRepository,
|
repoManager.HotDramaRepository,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func (h *TaskHandler) CreateBatchTransferTask(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Info("创建批量转存任务: %s,资源数量: %d,选择账号数量: %d", req.Title, len(req.Resources), len(req.SelectedAccounts))
|
utils.Debug("创建批量转存任务: %s,资源数量: %d,选择账号数量: %d", req.Title, len(req.Resources), len(req.SelectedAccounts))
|
||||||
|
|
||||||
// 构建任务配置
|
// 构建任务配置
|
||||||
taskConfig := map[string]interface{}{
|
taskConfig := map[string]interface{}{
|
||||||
@@ -105,7 +105,7 @@ func (h *TaskHandler) CreateBatchTransferTask(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Info("批量转存任务创建完成: %d, 共 %d 项", newTask.ID, len(req.Resources))
|
utils.Debug("批量转存任务创建完成: %d, 共 %d 项", newTask.ID, len(req.Resources))
|
||||||
|
|
||||||
SuccessResponse(c, gin.H{
|
SuccessResponse(c, gin.H{
|
||||||
"task_id": newTask.ID,
|
"task_id": newTask.ID,
|
||||||
@@ -123,8 +123,6 @@ func (h *TaskHandler) StartTask(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Info("启动任务: %d", taskID)
|
|
||||||
|
|
||||||
err = h.taskManager.StartTask(uint(taskID))
|
err = h.taskManager.StartTask(uint(taskID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.Error("启动任务失败: %v", err)
|
utils.Error("启动任务失败: %v", err)
|
||||||
@@ -132,6 +130,8 @@ func (h *TaskHandler) StartTask(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
utils.Debug("启动任务: %d", taskID)
|
||||||
|
|
||||||
SuccessResponse(c, gin.H{
|
SuccessResponse(c, gin.H{
|
||||||
"message": "任务启动成功",
|
"message": "任务启动成功",
|
||||||
})
|
})
|
||||||
@@ -146,8 +146,6 @@ func (h *TaskHandler) StopTask(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Info("停止任务: %d", taskID)
|
|
||||||
|
|
||||||
err = h.taskManager.StopTask(uint(taskID))
|
err = h.taskManager.StopTask(uint(taskID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.Error("停止任务失败: %v", err)
|
utils.Error("停止任务失败: %v", err)
|
||||||
@@ -155,6 +153,8 @@ func (h *TaskHandler) StopTask(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
utils.Debug("停止任务: %d", taskID)
|
||||||
|
|
||||||
SuccessResponse(c, gin.H{
|
SuccessResponse(c, gin.H{
|
||||||
"message": "任务停止成功",
|
"message": "任务停止成功",
|
||||||
})
|
})
|
||||||
@@ -169,8 +169,6 @@ func (h *TaskHandler) PauseTask(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Info("暂停任务: %d", taskID)
|
|
||||||
|
|
||||||
err = h.taskManager.PauseTask(uint(taskID))
|
err = h.taskManager.PauseTask(uint(taskID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.Error("暂停任务失败: %v", err)
|
utils.Error("暂停任务失败: %v", err)
|
||||||
@@ -178,6 +176,8 @@ func (h *TaskHandler) PauseTask(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
utils.Debug("暂停任务: %d", taskID)
|
||||||
|
|
||||||
SuccessResponse(c, gin.H{
|
SuccessResponse(c, gin.H{
|
||||||
"message": "任务暂停成功",
|
"message": "任务暂停成功",
|
||||||
})
|
})
|
||||||
@@ -234,13 +234,25 @@ func (h *TaskHandler) GetTaskStatus(c *gin.Context) {
|
|||||||
|
|
||||||
// GetTasks 获取任务列表
|
// GetTasks 获取任务列表
|
||||||
func (h *TaskHandler) GetTasks(c *gin.Context) {
|
func (h *TaskHandler) GetTasks(c *gin.Context) {
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
// 获取查询参数
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
|
pageStr := c.DefaultQuery("page", "1")
|
||||||
taskType := c.Query("task_type")
|
pageSizeStr := c.DefaultQuery("pageSize", "10")
|
||||||
|
taskType := c.Query("taskType")
|
||||||
status := c.Query("status")
|
status := c.Query("status")
|
||||||
|
|
||||||
utils.Info("GetTasks: 获取任务列表 page=%d, pageSize=%d, taskType=%s, status=%s", page, pageSize, taskType, status)
|
page, err := strconv.Atoi(pageStr)
|
||||||
|
if err != nil || page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pageSize, err := strconv.Atoi(pageSizeStr)
|
||||||
|
if err != nil || pageSize < 1 || pageSize > 100 {
|
||||||
|
pageSize = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Debug("GetTasks: 获取任务列表 page=%d, pageSize=%d, taskType=%s, status=%s", page, pageSize, taskType, status)
|
||||||
|
|
||||||
|
// 获取任务列表
|
||||||
tasks, total, err := h.repoMgr.TaskRepository.GetList(page, pageSize, taskType, status)
|
tasks, total, err := h.repoMgr.TaskRepository.GetList(page, pageSize, taskType, status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.Error("获取任务列表失败: %v", err)
|
utils.Error("获取任务列表失败: %v", err)
|
||||||
@@ -248,19 +260,19 @@ func (h *TaskHandler) GetTasks(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Info("GetTasks: 从数据库获取到 %d 个任务", len(tasks))
|
utils.Debug("GetTasks: 从数据库获取到 %d 个任务", len(tasks))
|
||||||
|
|
||||||
// 为每个任务添加运行状态
|
// 获取任务运行状态
|
||||||
var result []gin.H
|
var taskList []gin.H
|
||||||
for _, task := range tasks {
|
for _, task := range tasks {
|
||||||
isRunning := h.taskManager.IsTaskRunning(task.ID)
|
isRunning := h.taskManager.IsTaskRunning(task.ID)
|
||||||
utils.Info("GetTasks: 任务 %d (%s) 数据库状态: %s, TaskManager运行状态: %v", task.ID, task.Title, task.Status, isRunning)
|
utils.Debug("GetTasks: 任务 %d (%s) 数据库状态: %s, TaskManager运行状态: %v", task.ID, task.Title, task.Status, isRunning)
|
||||||
|
|
||||||
result = append(result, gin.H{
|
taskList = append(taskList, gin.H{
|
||||||
"id": task.ID,
|
"id": task.ID,
|
||||||
"title": task.Title,
|
"title": task.Title,
|
||||||
"description": task.Description,
|
"description": task.Description,
|
||||||
"task_type": task.Type,
|
"type": task.Type,
|
||||||
"status": task.Status,
|
"status": task.Status,
|
||||||
"total_items": task.TotalItems,
|
"total_items": task.TotalItems,
|
||||||
"processed_items": task.ProcessedItems,
|
"processed_items": task.ProcessedItems,
|
||||||
@@ -273,10 +285,11 @@ func (h *TaskHandler) GetTasks(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SuccessResponse(c, gin.H{
|
SuccessResponse(c, gin.H{
|
||||||
"items": result,
|
"tasks": taskList,
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"size": pageSize,
|
"page_size": pageSize,
|
||||||
|
"total_pages": (total + int64(pageSize) - 1) / int64(pageSize),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +361,7 @@ func (h *TaskHandler) DeleteTask(c *gin.Context) {
|
|||||||
|
|
||||||
// 检查任务是否在运行
|
// 检查任务是否在运行
|
||||||
if h.taskManager.IsTaskRunning(uint(taskID)) {
|
if h.taskManager.IsTaskRunning(uint(taskID)) {
|
||||||
ErrorResponse(c, "任务正在运行,请先停止任务", http.StatusBadRequest)
|
ErrorResponse(c, "任务正在运行中,无法删除", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,7 +381,8 @@ func (h *TaskHandler) DeleteTask(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Info("任务删除成功: %d", taskID)
|
utils.Debug("任务删除成功: %d", taskID)
|
||||||
|
|
||||||
SuccessResponse(c, gin.H{
|
SuccessResponse(c, gin.H{
|
||||||
"message": "任务删除成功",
|
"message": "任务删除成功",
|
||||||
})
|
})
|
||||||
|
|||||||
90
main.go
90
main.go
@@ -1,13 +1,18 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/ctwj/urldb/db"
|
"github.com/ctwj/urldb/db"
|
||||||
|
"github.com/ctwj/urldb/db/entity"
|
||||||
"github.com/ctwj/urldb/db/repo"
|
"github.com/ctwj/urldb/db/repo"
|
||||||
"github.com/ctwj/urldb/handlers"
|
"github.com/ctwj/urldb/handlers"
|
||||||
"github.com/ctwj/urldb/middleware"
|
"github.com/ctwj/urldb/middleware"
|
||||||
|
"github.com/ctwj/urldb/scheduler"
|
||||||
|
"github.com/ctwj/urldb/services"
|
||||||
"github.com/ctwj/urldb/task"
|
"github.com/ctwj/urldb/task"
|
||||||
"github.com/ctwj/urldb/utils"
|
"github.com/ctwj/urldb/utils"
|
||||||
|
|
||||||
@@ -17,6 +22,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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 {
|
if err := utils.InitLogger(nil); err != nil {
|
||||||
log.Fatal("初始化日志系统失败:", err)
|
log.Fatal("初始化日志系统失败:", err)
|
||||||
@@ -75,6 +92,12 @@ func main() {
|
|||||||
transferProcessor := task.NewTransferProcessor(repoManager)
|
transferProcessor := task.NewTransferProcessor(repoManager)
|
||||||
taskManager.RegisterProcessor(transferProcessor)
|
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 {
|
if err := taskManager.RecoverRunningTasks(); err != nil {
|
||||||
utils.Error("恢复运行中任务失败: %v", err)
|
utils.Error("恢复运行中任务失败: %v", err)
|
||||||
@@ -97,6 +120,37 @@ func main() {
|
|||||||
// 将Repository管理器注入到handlers中
|
// 将Repository管理器注入到handlers中
|
||||||
handlers.SetRepositoryManager(repoManager)
|
handlers.SetRepositoryManager(repoManager)
|
||||||
|
|
||||||
|
// 设置Meilisearch管理器到handlers中
|
||||||
|
handlers.SetMeilisearchManager(meilisearchManager)
|
||||||
|
|
||||||
|
// 设置全局调度器的Meilisearch管理器
|
||||||
|
scheduler.SetGlobalMeilisearchManager(meilisearchManager)
|
||||||
|
|
||||||
|
// 初始化并启动调度器
|
||||||
|
globalScheduler := scheduler.GetGlobalScheduler(
|
||||||
|
repoManager.HotDramaRepository,
|
||||||
|
repoManager.ReadyResourceRepository,
|
||||||
|
repoManager.ResourceRepository,
|
||||||
|
repoManager.SystemConfigRepository,
|
||||||
|
repoManager.PanRepository,
|
||||||
|
repoManager.CksRepository,
|
||||||
|
repoManager.TagRepository,
|
||||||
|
repoManager.CategoryRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 根据系统配置启动相应的调度任务
|
||||||
|
autoFetchHotDrama, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoFetchHotDramaEnabled)
|
||||||
|
autoProcessReadyResources, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoProcessReadyResources)
|
||||||
|
autoTransferEnabled, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoTransferEnabled)
|
||||||
|
|
||||||
|
globalScheduler.UpdateSchedulerStatusWithAutoTransfer(
|
||||||
|
autoFetchHotDrama,
|
||||||
|
autoProcessReadyResources,
|
||||||
|
autoTransferEnabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
utils.Info("调度器初始化完成")
|
||||||
|
|
||||||
// 设置公开API中间件的Repository管理器
|
// 设置公开API中间件的Repository管理器
|
||||||
middleware.SetRepositoryManager(repoManager)
|
middleware.SetRepositoryManager(repoManager)
|
||||||
|
|
||||||
@@ -106,6 +160,12 @@ func main() {
|
|||||||
// 创建任务处理器
|
// 创建任务处理器
|
||||||
taskHandler := handlers.NewTaskHandler(repoManager, taskManager)
|
taskHandler := handlers.NewTaskHandler(repoManager, taskManager)
|
||||||
|
|
||||||
|
// 创建文件处理器
|
||||||
|
fileHandler := handlers.NewFileHandler(repoManager.FileRepository, repoManager.SystemConfigRepository, repoManager.UserRepository)
|
||||||
|
|
||||||
|
// 创建Meilisearch处理器
|
||||||
|
meilisearchHandler := handlers.NewMeilisearchHandler(meilisearchManager)
|
||||||
|
|
||||||
// API路由
|
// API路由
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
{
|
{
|
||||||
@@ -211,6 +271,7 @@ func main() {
|
|||||||
// 系统配置路由
|
// 系统配置路由
|
||||||
api.GET("/system/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetSystemConfig)
|
api.GET("/system/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetSystemConfig)
|
||||||
api.POST("/system/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.UpdateSystemConfig)
|
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.POST("/system/config/toggle-auto-process", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.ToggleAutoProcess)
|
||||||
api.GET("/public/system-config", handlers.GetPublicSystemConfig)
|
api.GET("/public/system-config", handlers.GetPublicSystemConfig)
|
||||||
|
|
||||||
@@ -236,11 +297,40 @@ func main() {
|
|||||||
api.GET("/version/string", handlers.GetVersionString)
|
api.GET("/version/string", handlers.GetVersionString)
|
||||||
api.GET("/version/full", handlers.GetFullVersionInfo)
|
api.GET("/version/full", handlers.GetFullVersionInfo)
|
||||||
api.GET("/version/check-update", handlers.CheckUpdate)
|
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")
|
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")
|
port := os.Getenv("PORT")
|
||||||
if port == "" {
|
if port == "" {
|
||||||
port = "8080"
|
port = "8080"
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ type Claims struct {
|
|||||||
func AuthMiddleware() gin.HandlerFunc {
|
func AuthMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
authHeader := c.GetHeader("Authorization")
|
authHeader := c.GetHeader("Authorization")
|
||||||
utils.Info("AuthMiddleware - 收到请求: %s %s", c.Request.Method, c.Request.URL.Path)
|
// utils.Info("AuthMiddleware - 收到请求: %s %s", c.Request.Method, c.Request.URL.Path)
|
||||||
utils.Info("AuthMiddleware - Authorization头: %s", authHeader)
|
// utils.Info("AuthMiddleware - Authorization头: %s", authHeader)
|
||||||
|
|
||||||
if authHeader == "" {
|
if authHeader == "" {
|
||||||
utils.Error("AuthMiddleware - 未提供认证令牌")
|
utils.Error("AuthMiddleware - 未提供认证令牌")
|
||||||
@@ -39,24 +39,24 @@ func AuthMiddleware() gin.HandlerFunc {
|
|||||||
|
|
||||||
// 检查Bearer前缀
|
// 检查Bearer前缀
|
||||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
utils.Error("AuthMiddleware - 无效的认证格式: %s", authHeader)
|
// utils.Error("AuthMiddleware - 无效的认证格式: %s", authHeader)
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的认证格式"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的认证格式"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
utils.Info("AuthMiddleware - 解析令牌: %s", tokenString[:10]+"...")
|
// utils.Info("AuthMiddleware - 解析令牌: %s", tokenString[:10]+"...")
|
||||||
|
|
||||||
claims, err := parseToken(tokenString)
|
claims, err := parseToken(tokenString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.Error("AuthMiddleware - 令牌解析失败: %v", err)
|
// utils.Error("AuthMiddleware - 令牌解析失败: %v", err)
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的令牌"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的令牌"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Info("AuthMiddleware - 令牌验证成功,用户: %s, 角色: %s", claims.Username, claims.Role)
|
// utils.Info("AuthMiddleware - 令牌验证成功,用户: %s, 角色: %s", claims.Username, claims.Role)
|
||||||
|
|
||||||
// 将用户信息存储到上下文中
|
// 将用户信息存储到上下文中
|
||||||
c.Set("user_id", claims.UserID)
|
c.Set("user_id", claims.UserID)
|
||||||
@@ -72,13 +72,13 @@ func AdminMiddleware() gin.HandlerFunc {
|
|||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
role, exists := c.Get("role")
|
role, exists := c.Get("role")
|
||||||
if !exists {
|
if !exists {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
// c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if role != "admin" {
|
if role != "admin" {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
// c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -106,23 +106,23 @@ func GenerateToken(user *entity.User) (string, error) {
|
|||||||
|
|
||||||
// parseToken 解析JWT令牌
|
// parseToken 解析JWT令牌
|
||||||
func parseToken(tokenString string) (*Claims, error) {
|
func parseToken(tokenString string) (*Claims, error) {
|
||||||
utils.Info("parseToken - 开始解析令牌")
|
// utils.Info("parseToken - 开始解析令牌")
|
||||||
|
|
||||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
return jwtSecret, nil
|
return jwtSecret, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.Error("parseToken - JWT解析失败: %v", err)
|
// utils.Error("parseToken - JWT解析失败: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||||
utils.Info("parseToken - 令牌解析成功,用户ID: %d", claims.UserID)
|
// utils.Info("parseToken - 令牌解析成功,用户ID: %d", claims.UserID)
|
||||||
return claims, nil
|
return claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Error("parseToken - 令牌无效或签名错误")
|
// utils.Error("parseToken - 令牌无效或签名错误")
|
||||||
return nil, jwt.ErrSignatureInvalid
|
return nil, jwt.ErrSignatureInvalid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
14
migrations/v1.x.x.sql
Normal file
14
migrations/v1.x.x.sql
Normal 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 '同步时间';
|
||||||
@@ -60,6 +60,15 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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";
|
||||||
}
|
}
|
||||||
|
|
||||||
# 健康检查
|
# 健康检查
|
||||||
|
|||||||
@@ -218,9 +218,9 @@ func (a *AutoTransferScheduler) processAutoTransfer() {
|
|||||||
|
|
||||||
if shouldSkip {
|
if shouldSkip {
|
||||||
// 标记为违禁词错误
|
// 标记为违禁词错误
|
||||||
resource.ErrorMsg = fmt.Sprintf("存在违禁词: %s", strings.Join(matchedWords, ", "))
|
resource.ErrorMsg = fmt.Sprintf("存在违禁词 (共 %d 个)", len(matchedWords))
|
||||||
forbiddenResources = append(forbiddenResources, resource)
|
forbiddenResources = append(forbiddenResources, resource)
|
||||||
utils.Info(fmt.Sprintf("标记违禁词资源: %s, 违禁词: %s", resource.Title, strings.Join(matchedWords, ", ")))
|
utils.Info(fmt.Sprintf("标记违禁词资源: %s (包含 %d 个违禁词)", resource.Title, len(matchedWords)))
|
||||||
} else {
|
} else {
|
||||||
filteredResources = append(filteredResources, resource)
|
filteredResources = append(filteredResources, resource)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/ctwj/urldb/db/repo"
|
"github.com/ctwj/urldb/db/repo"
|
||||||
|
"github.com/ctwj/urldb/services"
|
||||||
"github.com/ctwj/urldb/utils"
|
"github.com/ctwj/urldb/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,8 +17,20 @@ type GlobalScheduler struct {
|
|||||||
var (
|
var (
|
||||||
globalScheduler *GlobalScheduler
|
globalScheduler *GlobalScheduler
|
||||||
once sync.Once
|
once sync.Once
|
||||||
|
// 全局Meilisearch管理器
|
||||||
|
globalMeilisearchManager *services.MeilisearchManager
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SetGlobalMeilisearchManager 设置全局Meilisearch管理器
|
||||||
|
func SetGlobalMeilisearchManager(manager *services.MeilisearchManager) {
|
||||||
|
globalMeilisearchManager = manager
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGlobalMeilisearchManager 获取全局Meilisearch管理器
|
||||||
|
func GetGlobalMeilisearchManager() *services.MeilisearchManager {
|
||||||
|
return globalMeilisearchManager
|
||||||
|
}
|
||||||
|
|
||||||
// GetGlobalScheduler 获取全局调度器实例(单例模式)
|
// GetGlobalScheduler 获取全局调度器实例(单例模式)
|
||||||
func GetGlobalScheduler(hotDramaRepo repo.HotDramaRepository, readyResourceRepo repo.ReadyResourceRepository, resourceRepo repo.ResourceRepository, systemConfigRepo repo.SystemConfigRepository, panRepo repo.PanRepository, cksRepo repo.CksRepository, tagRepo repo.TagRepository, categoryRepo repo.CategoryRepository) *GlobalScheduler {
|
func GetGlobalScheduler(hotDramaRepo repo.HotDramaRepository, readyResourceRepo repo.ReadyResourceRepository, resourceRepo repo.ResourceRepository, systemConfigRepo repo.SystemConfigRepository, panRepo repo.PanRepository, cksRepo repo.CksRepository, tagRepo repo.TagRepository, categoryRepo repo.CategoryRepository) *GlobalScheduler {
|
||||||
once.Do(func() {
|
once.Do(func() {
|
||||||
@@ -34,12 +47,12 @@ func (gs *GlobalScheduler) StartHotDramaScheduler() {
|
|||||||
defer gs.mutex.Unlock()
|
defer gs.mutex.Unlock()
|
||||||
|
|
||||||
if gs.manager.IsHotDramaRunning() {
|
if gs.manager.IsHotDramaRunning() {
|
||||||
utils.Info("热播剧定时任务已在运行中")
|
utils.Debug("热播剧定时任务已在运行中")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
gs.manager.StartHotDramaScheduler()
|
gs.manager.StartHotDramaScheduler()
|
||||||
utils.Info("全局调度器已启动热播剧定时任务")
|
utils.Debug("全局调度器已启动热播剧定时任务")
|
||||||
}
|
}
|
||||||
|
|
||||||
// StopHotDramaScheduler 停止热播剧定时任务
|
// StopHotDramaScheduler 停止热播剧定时任务
|
||||||
@@ -48,12 +61,12 @@ func (gs *GlobalScheduler) StopHotDramaScheduler() {
|
|||||||
defer gs.mutex.Unlock()
|
defer gs.mutex.Unlock()
|
||||||
|
|
||||||
if !gs.manager.IsHotDramaRunning() {
|
if !gs.manager.IsHotDramaRunning() {
|
||||||
utils.Info("热播剧定时任务未在运行")
|
utils.Debug("热播剧定时任务未在运行")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
gs.manager.StopHotDramaScheduler()
|
gs.manager.StopHotDramaScheduler()
|
||||||
utils.Info("全局调度器已停止热播剧定时任务")
|
utils.Debug("全局调度器已停止热播剧定时任务")
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsHotDramaSchedulerRunning 检查热播剧定时任务是否在运行
|
// IsHotDramaSchedulerRunning 检查热播剧定时任务是否在运行
|
||||||
@@ -74,12 +87,12 @@ func (gs *GlobalScheduler) StartReadyResourceScheduler() {
|
|||||||
defer gs.mutex.Unlock()
|
defer gs.mutex.Unlock()
|
||||||
|
|
||||||
if gs.manager.IsReadyResourceRunning() {
|
if gs.manager.IsReadyResourceRunning() {
|
||||||
utils.Info("待处理资源自动处理任务已在运行中")
|
utils.Debug("待处理资源自动处理任务已在运行中")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
gs.manager.StartReadyResourceScheduler()
|
gs.manager.StartReadyResourceScheduler()
|
||||||
utils.Info("全局调度器已启动待处理资源自动处理任务")
|
utils.Debug("全局调度器已启动待处理资源自动处理任务")
|
||||||
}
|
}
|
||||||
|
|
||||||
// StopReadyResourceScheduler 停止待处理资源自动处理任务
|
// StopReadyResourceScheduler 停止待处理资源自动处理任务
|
||||||
@@ -88,12 +101,12 @@ func (gs *GlobalScheduler) StopReadyResourceScheduler() {
|
|||||||
defer gs.mutex.Unlock()
|
defer gs.mutex.Unlock()
|
||||||
|
|
||||||
if !gs.manager.IsReadyResourceRunning() {
|
if !gs.manager.IsReadyResourceRunning() {
|
||||||
utils.Info("待处理资源自动处理任务未在运行")
|
utils.Debug("待处理资源自动处理任务未在运行")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
gs.manager.StopReadyResourceScheduler()
|
gs.manager.StopReadyResourceScheduler()
|
||||||
utils.Info("全局调度器已停止待处理资源自动处理任务")
|
utils.Debug("全局调度器已停止待处理资源自动处理任务")
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsReadyResourceRunning 检查待处理资源自动处理任务是否在运行
|
// IsReadyResourceRunning 检查待处理资源自动处理任务是否在运行
|
||||||
@@ -109,12 +122,12 @@ func (gs *GlobalScheduler) StartAutoTransferScheduler() {
|
|||||||
defer gs.mutex.Unlock()
|
defer gs.mutex.Unlock()
|
||||||
|
|
||||||
if gs.manager.IsAutoTransferRunning() {
|
if gs.manager.IsAutoTransferRunning() {
|
||||||
utils.Info("自动转存定时任务已在运行中")
|
utils.Debug("自动转存定时任务已在运行中")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
gs.manager.StartAutoTransferScheduler()
|
gs.manager.StartAutoTransferScheduler()
|
||||||
utils.Info("全局调度器已启动自动转存定时任务")
|
utils.Debug("全局调度器已启动自动转存定时任务")
|
||||||
}
|
}
|
||||||
|
|
||||||
// StopAutoTransferScheduler 停止自动转存定时任务
|
// StopAutoTransferScheduler 停止自动转存定时任务
|
||||||
@@ -123,12 +136,12 @@ func (gs *GlobalScheduler) StopAutoTransferScheduler() {
|
|||||||
defer gs.mutex.Unlock()
|
defer gs.mutex.Unlock()
|
||||||
|
|
||||||
if !gs.manager.IsAutoTransferRunning() {
|
if !gs.manager.IsAutoTransferRunning() {
|
||||||
utils.Info("自动转存定时任务未在运行")
|
utils.Debug("自动转存定时任务未在运行")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
gs.manager.StopAutoTransferScheduler()
|
gs.manager.StopAutoTransferScheduler()
|
||||||
utils.Info("全局调度器已停止自动转存定时任务")
|
utils.Debug("全局调度器已停止自动转存定时任务")
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsAutoTransferRunning 检查自动转存定时任务是否在运行
|
// IsAutoTransferRunning 检查自动转存定时任务是否在运行
|
||||||
|
|||||||
@@ -51,34 +51,34 @@ func NewManager(
|
|||||||
|
|
||||||
// StartAll 启动所有调度任务
|
// StartAll 启动所有调度任务
|
||||||
func (m *Manager) StartAll() {
|
func (m *Manager) StartAll() {
|
||||||
utils.Info("启动所有调度任务")
|
utils.Debug("启动所有调度任务")
|
||||||
|
|
||||||
// 启动热播剧调度任务
|
// 启动热播剧定时任务
|
||||||
m.hotDramaScheduler.Start()
|
m.StartHotDramaScheduler()
|
||||||
|
|
||||||
// 启动待处理资源调度任务
|
// 启动待处理资源自动处理任务
|
||||||
m.readyResourceScheduler.Start()
|
m.StartReadyResourceScheduler()
|
||||||
|
|
||||||
// 启动自动转存调度任务
|
// 启动自动转存定时任务
|
||||||
m.autoTransferScheduler.Start()
|
m.StartAutoTransferScheduler()
|
||||||
|
|
||||||
utils.Info("所有调度任务已启动")
|
utils.Debug("所有调度任务已启动")
|
||||||
}
|
}
|
||||||
|
|
||||||
// StopAll 停止所有调度任务
|
// StopAll 停止所有调度任务
|
||||||
func (m *Manager) StopAll() {
|
func (m *Manager) StopAll() {
|
||||||
utils.Info("停止所有调度任务")
|
utils.Debug("停止所有调度任务")
|
||||||
|
|
||||||
// 停止热播剧调度任务
|
// 停止热播剧定时任务
|
||||||
m.hotDramaScheduler.Stop()
|
m.StopHotDramaScheduler()
|
||||||
|
|
||||||
// 停止待处理资源调度任务
|
// 停止待处理资源自动处理任务
|
||||||
m.readyResourceScheduler.Stop()
|
m.StopReadyResourceScheduler()
|
||||||
|
|
||||||
// 停止自动转存调度任务
|
// 停止自动转存定时任务
|
||||||
m.autoTransferScheduler.Stop()
|
m.StopAutoTransferScheduler()
|
||||||
|
|
||||||
utils.Info("所有调度任务已停止")
|
utils.Debug("所有调度任务已停止")
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartHotDramaScheduler 启动热播剧调度任务
|
// StartHotDramaScheduler 启动热播剧调度任务
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func NewReadyResourceScheduler(base *BaseScheduler) *ReadyResourceScheduler {
|
|||||||
// Start 启动待处理资源定时任务
|
// Start 启动待处理资源定时任务
|
||||||
func (r *ReadyResourceScheduler) Start() {
|
func (r *ReadyResourceScheduler) Start() {
|
||||||
if r.readyResourceRunning {
|
if r.readyResourceRunning {
|
||||||
utils.Info("待处理资源自动处理任务已在运行中")
|
utils.Debug("待处理资源自动处理任务已在运行中")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ func (r *ReadyResourceScheduler) Start() {
|
|||||||
r.processReadyResources()
|
r.processReadyResources()
|
||||||
}()
|
}()
|
||||||
} else {
|
} else {
|
||||||
utils.Info("上一次待处理资源任务还在执行中,跳过本次执行")
|
utils.Debug("上一次待处理资源任务还在执行中,跳过本次执行")
|
||||||
}
|
}
|
||||||
case <-r.GetStopChan():
|
case <-r.GetStopChan():
|
||||||
utils.Info("停止待处理资源自动处理任务")
|
utils.Info("停止待处理资源自动处理任务")
|
||||||
@@ -76,7 +76,7 @@ func (r *ReadyResourceScheduler) Start() {
|
|||||||
// Stop 停止待处理资源定时任务
|
// Stop 停止待处理资源定时任务
|
||||||
func (r *ReadyResourceScheduler) Stop() {
|
func (r *ReadyResourceScheduler) Stop() {
|
||||||
if !r.readyResourceRunning {
|
if !r.readyResourceRunning {
|
||||||
utils.Info("待处理资源自动处理任务未在运行")
|
utils.Debug("待处理资源自动处理任务未在运行")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ func (r *ReadyResourceScheduler) IsReadyResourceRunning() bool {
|
|||||||
|
|
||||||
// processReadyResources 处理待处理资源
|
// processReadyResources 处理待处理资源
|
||||||
func (r *ReadyResourceScheduler) processReadyResources() {
|
func (r *ReadyResourceScheduler) processReadyResources() {
|
||||||
utils.Info("开始处理待处理资源...")
|
utils.Debug("开始处理待处理资源...")
|
||||||
|
|
||||||
// 检查系统配置,确认是否启用自动处理
|
// 检查系统配置,确认是否启用自动处理
|
||||||
autoProcess, err := r.systemConfigRepo.GetConfigBool(entity.ConfigKeyAutoProcessReadyResources)
|
autoProcess, err := r.systemConfigRepo.GetConfigBool(entity.ConfigKeyAutoProcessReadyResources)
|
||||||
@@ -102,7 +102,7 @@ func (r *ReadyResourceScheduler) processReadyResources() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !autoProcess {
|
if !autoProcess {
|
||||||
utils.Info("自动处理待处理资源功能已禁用")
|
utils.Debug("自动处理待处理资源功能已禁用")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,11 +115,11 @@ func (r *ReadyResourceScheduler) processReadyResources() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(readyResources) == 0 {
|
if len(readyResources) == 0 {
|
||||||
utils.Info("没有待处理的资源")
|
utils.Debug("没有待处理的资源")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Info(fmt.Sprintf("找到 %d 个待处理资源,开始处理...", len(readyResources)))
|
utils.Debug(fmt.Sprintf("找到 %d 个待处理资源,开始处理...", len(readyResources)))
|
||||||
|
|
||||||
processedCount := 0
|
processedCount := 0
|
||||||
factory := panutils.GetInstance() // 使用单例模式
|
factory := panutils.GetInstance() // 使用单例模式
|
||||||
@@ -132,7 +132,7 @@ func (r *ReadyResourceScheduler) processReadyResources() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if exits {
|
if exits {
|
||||||
utils.Info(fmt.Sprintf("资源已存在: %s", readyResource.URL))
|
utils.Debug(fmt.Sprintf("资源已存在: %s", readyResource.URL))
|
||||||
r.readyResourceRepo.Delete(readyResource.ID)
|
r.readyResourceRepo.Delete(readyResource.ID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -146,7 +146,7 @@ func (r *ReadyResourceScheduler) processReadyResources() {
|
|||||||
if updateErr := r.readyResourceRepo.Update(&readyResource); updateErr != nil {
|
if updateErr := r.readyResourceRepo.Update(&readyResource); updateErr != nil {
|
||||||
utils.Error(fmt.Sprintf("更新错误信息失败 (ID: %d): %v", readyResource.ID, updateErr))
|
utils.Error(fmt.Sprintf("更新错误信息失败 (ID: %d): %v", readyResource.ID, updateErr))
|
||||||
} else {
|
} else {
|
||||||
utils.Info(fmt.Sprintf("已保存错误信息到资源 (ID: %d): %s", readyResource.ID, err.Error()))
|
utils.Debug(fmt.Sprintf("已保存错误信息到资源 (ID: %d): %s", readyResource.ID, err.Error()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理失败后删除资源,避免重复处理
|
// 处理失败后删除资源,避免重复处理
|
||||||
@@ -155,11 +155,13 @@ func (r *ReadyResourceScheduler) processReadyResources() {
|
|||||||
// 处理成功,删除readyResource
|
// 处理成功,删除readyResource
|
||||||
r.readyResourceRepo.Delete(readyResource.ID)
|
r.readyResourceRepo.Delete(readyResource.ID)
|
||||||
processedCount++
|
processedCount++
|
||||||
utils.Info(fmt.Sprintf("成功处理资源: %s", readyResource.URL))
|
utils.Debug(fmt.Sprintf("成功处理资源: %s", readyResource.URL))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Info(fmt.Sprintf("待处理资源处理完成,共处理 %d 个资源", processedCount))
|
if processedCount > 0 {
|
||||||
|
utils.Info(fmt.Sprintf("待处理资源处理完成,共处理 %d 个资源", processedCount))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// convertReadyResourceToResource 将待处理资源转换为正式资源
|
// convertReadyResourceToResource 将待处理资源转换为正式资源
|
||||||
@@ -187,28 +189,28 @@ func (r *ReadyResourceScheduler) convertReadyResourceToResource(readyResource en
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查违禁词
|
// 检查违禁词
|
||||||
forbiddenWords, err := r.systemConfigRepo.GetConfigValue(entity.ConfigKeyForbiddenWords)
|
// forbiddenWords, err := r.systemConfigRepo.GetConfigValue(entity.ConfigKeyForbiddenWords)
|
||||||
if err == nil && forbiddenWords != "" {
|
// if err == nil && forbiddenWords != "" {
|
||||||
words := strings.Split(forbiddenWords, ",")
|
// words := strings.Split(forbiddenWords, ",")
|
||||||
var matchedWords []string
|
// var matchedWords []string
|
||||||
title := strings.ToLower(resource.Title)
|
// title := strings.ToLower(resource.Title)
|
||||||
description := strings.ToLower(resource.Description)
|
// description := strings.ToLower(resource.Description)
|
||||||
|
|
||||||
for _, word := range words {
|
// for _, word := range words {
|
||||||
word = strings.TrimSpace(word)
|
// word = strings.TrimSpace(word)
|
||||||
if word != "" {
|
// if word != "" {
|
||||||
wordLower := strings.ToLower(word)
|
// wordLower := strings.ToLower(word)
|
||||||
if strings.Contains(title, wordLower) || strings.Contains(description, wordLower) {
|
// if strings.Contains(title, wordLower) || strings.Contains(description, wordLower) {
|
||||||
matchedWords = append(matchedWords, word)
|
// matchedWords = append(matchedWords, word)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
if len(matchedWords) > 0 {
|
// if len(matchedWords) > 0 {
|
||||||
utils.Warn(fmt.Sprintf("资源包含违禁词: %s, 违禁词: %s", resource.Title, strings.Join(matchedWords, ", ")))
|
// utils.Warn(fmt.Sprintf("资源包含违禁词: %s, 违禁词: %s", resource.Title, strings.Join(matchedWords, ", ")))
|
||||||
return fmt.Errorf("存在违禁词: %s", strings.Join(matchedWords, ", "))
|
// return fmt.Errorf("存在违禁词: %s", strings.Join(matchedWords, ", "))
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
// 不是夸克,直接保存
|
// 不是夸克,直接保存
|
||||||
if serviceType != panutils.Quark {
|
if serviceType != panutils.Quark {
|
||||||
@@ -342,6 +344,31 @@ func (r *ReadyResourceScheduler) convertReadyResourceToResource(readyResource en
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同步到Meilisearch
|
||||||
|
utils.Debug(fmt.Sprintf("准备同步资源到Meilisearch - 资源ID: %d, URL: %s", resource.ID, resource.URL))
|
||||||
|
utils.Debug(fmt.Sprintf("globalMeilisearchManager: %v", globalMeilisearchManager != nil))
|
||||||
|
|
||||||
|
if globalMeilisearchManager != nil {
|
||||||
|
utils.Debug(fmt.Sprintf("Meilisearch管理器已初始化,检查启用状态"))
|
||||||
|
isEnabled := globalMeilisearchManager.IsEnabled()
|
||||||
|
utils.Debug(fmt.Sprintf("Meilisearch启用状态: %v", isEnabled))
|
||||||
|
|
||||||
|
if isEnabled {
|
||||||
|
utils.Debug(fmt.Sprintf("Meilisearch已启用,开始同步资源"))
|
||||||
|
go func() {
|
||||||
|
if err := globalMeilisearchManager.SyncResourceToMeilisearch(resource); err != nil {
|
||||||
|
utils.Error("同步资源到Meilisearch失败: %v", err)
|
||||||
|
} else {
|
||||||
|
utils.Info(fmt.Sprintf("资源已同步到Meilisearch: %s", resource.URL))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
utils.Debug("Meilisearch未启用,跳过同步")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
utils.Debug("Meilisearch管理器未初始化,跳过同步")
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
178
scripts/build.sh
Normal file
178
scripts/build.sh
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 编译脚本 - 自动注入版本信息
|
||||||
|
# 用法: ./scripts/build.sh [target]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 颜色定义
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# 获取当前版本
|
||||||
|
get_current_version() {
|
||||||
|
cat VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
# 获取Git信息
|
||||||
|
get_git_commit() {
|
||||||
|
git rev-parse --short HEAD 2>/dev/null || echo "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
get_git_branch() {
|
||||||
|
git branch --show-current 2>/dev/null || echo "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 获取构建时间
|
||||||
|
get_build_time() {
|
||||||
|
date '+%Y-%m-%d %H:%M:%S'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 编译函数
|
||||||
|
build() {
|
||||||
|
local target=${1:-"main"}
|
||||||
|
local version=$(get_current_version)
|
||||||
|
local git_commit=$(get_git_commit)
|
||||||
|
local git_branch=$(get_git_branch)
|
||||||
|
local build_time=$(get_build_time)
|
||||||
|
|
||||||
|
echo -e "${BLUE}开始编译...${NC}"
|
||||||
|
echo -e "版本: ${GREEN}${version}${NC}"
|
||||||
|
echo -e "Git提交: ${GREEN}${git_commit}${NC}"
|
||||||
|
echo -e "Git分支: ${GREEN}${git_branch}${NC}"
|
||||||
|
echo -e "构建时间: ${GREEN}${build_time}${NC}"
|
||||||
|
|
||||||
|
# 构建 ldflags
|
||||||
|
local ldflags="-X 'github.com/ctwj/urldb/utils.Version=${version}'"
|
||||||
|
ldflags="${ldflags} -X 'github.com/ctwj/urldb/utils.BuildTime=${build_time}'"
|
||||||
|
ldflags="${ldflags} -X 'github.com/ctwj/urldb/utils.GitCommit=${git_commit}'"
|
||||||
|
ldflags="${ldflags} -X 'github.com/ctwj/urldb/utils.GitBranch=${git_branch}'"
|
||||||
|
|
||||||
|
# 编译 - 使用跨平台编译设置
|
||||||
|
echo -e "${YELLOW}编译中...${NC}"
|
||||||
|
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags "${ldflags}" -o "${target}" .
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}编译成功!${NC}"
|
||||||
|
echo -e "可执行文件: ${GREEN}${target}${NC}"
|
||||||
|
echo -e "目标平台: ${GREEN}Linux${NC}"
|
||||||
|
|
||||||
|
# 显示版本信息(在Linux环境下)
|
||||||
|
echo -e "${BLUE}版本信息验证:${NC}"
|
||||||
|
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||||
|
./${target} version 2>/dev/null || echo "无法验证版本信息"
|
||||||
|
else
|
||||||
|
echo "当前非Linux环境,无法直接验证版本信息"
|
||||||
|
echo "请将编译后的文件复制到Linux服务器上验证"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${RED}编译失败!${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 清理函数
|
||||||
|
clean() {
|
||||||
|
echo -e "${YELLOW}清理编译文件...${NC}"
|
||||||
|
rm -f main
|
||||||
|
echo -e "${GREEN}清理完成${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 显示帮助
|
||||||
|
show_help() {
|
||||||
|
echo -e "${BLUE}编译脚本${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "用法: $0 [命令]"
|
||||||
|
echo ""
|
||||||
|
echo "命令:"
|
||||||
|
echo " build [target] 编译程序 (当前平台)"
|
||||||
|
echo " build-linux [target] 编译Linux版本 (推荐)"
|
||||||
|
echo " clean 清理编译文件"
|
||||||
|
echo " help 显示此帮助信息"
|
||||||
|
echo ""
|
||||||
|
echo "示例:"
|
||||||
|
echo " $0 # 编译Linux版本 (默认)"
|
||||||
|
echo " $0 build-linux # 编译Linux版本"
|
||||||
|
echo " $0 build-linux app # 编译Linux版本为 app"
|
||||||
|
echo " $0 build # 编译当前平台版本"
|
||||||
|
echo " $0 clean # 清理编译文件"
|
||||||
|
echo ""
|
||||||
|
echo "注意:"
|
||||||
|
echo " - Linux版本使用静态链接,适合部署到服务器"
|
||||||
|
echo " - 默认编译Linux版本,无需复制VERSION文件"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Linux编译函数
|
||||||
|
build_linux() {
|
||||||
|
local target=${1:-"main"}
|
||||||
|
local version=$(get_current_version)
|
||||||
|
local git_commit=$(get_git_commit)
|
||||||
|
local git_branch=$(get_git_branch)
|
||||||
|
local build_time=$(get_build_time)
|
||||||
|
|
||||||
|
echo -e "${BLUE}开始Linux编译...${NC}"
|
||||||
|
echo -e "版本: ${GREEN}${version}${NC}"
|
||||||
|
echo -e "Git提交: ${GREEN}${git_commit}${NC}"
|
||||||
|
echo -e "Git分支: ${GREEN}${git_branch}${NC}"
|
||||||
|
echo -e "构建时间: ${GREEN}${build_time}${NC}"
|
||||||
|
|
||||||
|
# 构建 ldflags
|
||||||
|
local ldflags="-X 'github.com/ctwj/urldb/utils.Version=${version}'"
|
||||||
|
ldflags="${ldflags} -X 'github.com/ctwj/urldb/utils.BuildTime=${build_time}'"
|
||||||
|
ldflags="${ldflags} -X 'github.com/ctwj/urldb/utils.GitCommit=${git_commit}'"
|
||||||
|
ldflags="${ldflags} -X 'github.com/ctwj/urldb/utils.GitBranch=${git_branch}'"
|
||||||
|
|
||||||
|
# Linux编译
|
||||||
|
echo -e "${YELLOW}编译中...${NC}"
|
||||||
|
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags "${ldflags}" -o "${target}" .
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}Linux编译成功!${NC}"
|
||||||
|
echo -e "可执行文件: ${GREEN}${target}${NC}"
|
||||||
|
echo -e "目标平台: ${GREEN}Linux${NC}"
|
||||||
|
echo -e "静态链接: ${GREEN}是${NC}"
|
||||||
|
|
||||||
|
# 显示文件信息
|
||||||
|
if command -v file >/dev/null 2>&1; then
|
||||||
|
echo -e "${BLUE}文件信息:${NC}"
|
||||||
|
file "${target}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${BLUE}注意: 请在Linux服务器上验证版本信息${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}Linux编译失败!${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 主函数
|
||||||
|
main() {
|
||||||
|
case $1 in
|
||||||
|
"build")
|
||||||
|
build $2
|
||||||
|
;;
|
||||||
|
"build-linux")
|
||||||
|
build_linux $2
|
||||||
|
;;
|
||||||
|
"clean")
|
||||||
|
clean
|
||||||
|
;;
|
||||||
|
"help"|"-h"|"--help")
|
||||||
|
show_help
|
||||||
|
;;
|
||||||
|
"")
|
||||||
|
build_linux
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}错误: 未知命令 '$1'${NC}"
|
||||||
|
echo "使用 '$0 help' 查看帮助信息"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# 运行主函数
|
||||||
|
main "$@"
|
||||||
183
scripts/docker-build.sh
Normal file
183
scripts/docker-build.sh
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Docker构建脚本
|
||||||
|
# 用法: ./scripts/docker-build.sh [version]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 颜色定义
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# 获取版本号
|
||||||
|
get_version() {
|
||||||
|
if [ -n "$1" ]; then
|
||||||
|
echo "$1"
|
||||||
|
else
|
||||||
|
cat VERSION
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 获取Git信息
|
||||||
|
get_git_commit() {
|
||||||
|
git rev-parse --short HEAD 2>/dev/null || echo "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
get_git_branch() {
|
||||||
|
git branch --show-current 2>/dev/null || echo "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 构建Docker镜像
|
||||||
|
build_docker() {
|
||||||
|
local version=$(get_version $1)
|
||||||
|
local skip_frontend=$2
|
||||||
|
local git_commit=$(get_git_commit)
|
||||||
|
local git_branch=$(get_git_branch)
|
||||||
|
local build_time=$(date '+%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
echo -e "${BLUE}开始Docker构建...${NC}"
|
||||||
|
echo -e "版本: ${GREEN}${version}${NC}"
|
||||||
|
echo -e "Git提交: ${GREEN}${git_commit}${NC}"
|
||||||
|
echo -e "Git分支: ${GREEN}${git_branch}${NC}"
|
||||||
|
echo -e "构建时间: ${GREEN}${build_time}${NC}"
|
||||||
|
if [ "$skip_frontend" = "true" ]; then
|
||||||
|
echo -e "跳过前端构建: ${GREEN}是${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 直接使用 docker build,避免 buildx 的复杂性
|
||||||
|
BUILD_CMD="docker build"
|
||||||
|
echo -e "${BLUE}使用构建命令: ${BUILD_CMD}${NC}"
|
||||||
|
|
||||||
|
# 构建前端镜像(可选)
|
||||||
|
if [ "$skip_frontend" != "true" ]; then
|
||||||
|
echo -e "${YELLOW}构建前端镜像...${NC}"
|
||||||
|
FRONTEND_CMD="${BUILD_CMD} --build-arg VERSION=${version} --build-arg GIT_COMMIT=${git_commit} --build-arg GIT_BRANCH=${git_branch} --build-arg BUILD_TIME=${build_time} --target frontend -t ctwj/urldb-frontend:${version} ."
|
||||||
|
echo -e "${BLUE}执行命令: ${FRONTEND_CMD}${NC}"
|
||||||
|
${BUILD_CMD} \
|
||||||
|
--build-arg VERSION=${version} \
|
||||||
|
--build-arg GIT_COMMIT=${git_commit} \
|
||||||
|
--build-arg GIT_BRANCH=${git_branch} \
|
||||||
|
--build-arg "BUILD_TIME=${build_time}" \
|
||||||
|
--target frontend \
|
||||||
|
-t ctwj/urldb-frontend:${version} \
|
||||||
|
.
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}前端构建失败!${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}跳过前端构建${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 构建后端镜像
|
||||||
|
echo -e "${YELLOW}构建后端镜像...${NC}"
|
||||||
|
BACKEND_CMD="${BUILD_CMD} --build-arg VERSION=${version} --build-arg GIT_COMMIT=${git_commit} --build-arg GIT_BRANCH=${git_branch} --build-arg BUILD_TIME=${build_time} --target backend -t ctwj/urldb-backend:${version} ."
|
||||||
|
echo -e "${BLUE}执行命令: ${BACKEND_CMD}${NC}"
|
||||||
|
${BUILD_CMD} \
|
||||||
|
--build-arg VERSION=${version} \
|
||||||
|
--build-arg GIT_COMMIT=${git_commit} \
|
||||||
|
--build-arg GIT_BRANCH=${git_branch} \
|
||||||
|
--build-arg BUILD_TIME="${build_time}" \
|
||||||
|
--target backend \
|
||||||
|
-t ctwj/urldb-backend:${version} \
|
||||||
|
.
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}后端构建失败!${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}Docker构建完成!${NC}"
|
||||||
|
echo -e "镜像标签:"
|
||||||
|
echo -e " ${GREEN}ctwj/urldb-backend:${version}${NC}"
|
||||||
|
if [ "$skip_frontend" != "true" ]; then
|
||||||
|
echo -e " ${GREEN}ctwj/urldb-frontend:${version}${NC}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 推送镜像
|
||||||
|
push_images() {
|
||||||
|
local version=$(get_version $1)
|
||||||
|
|
||||||
|
echo -e "${YELLOW}推送镜像到Docker Hub...${NC}"
|
||||||
|
|
||||||
|
# 推送后端镜像
|
||||||
|
docker push ctwj/urldb-backend:${version}
|
||||||
|
|
||||||
|
# 推送前端镜像
|
||||||
|
docker push ctwj/urldb-frontend:${version}
|
||||||
|
|
||||||
|
echo -e "${GREEN}镜像推送完成!${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 清理镜像
|
||||||
|
clean_images() {
|
||||||
|
local version=$(get_version $1)
|
||||||
|
|
||||||
|
echo -e "${YELLOW}清理Docker镜像...${NC}"
|
||||||
|
docker rmi ctwj/urldb-backend:${version} 2>/dev/null || true
|
||||||
|
docker rmi ctwj/urldb-frontend:${version} 2>/dev/null || true
|
||||||
|
|
||||||
|
echo -e "${GREEN}镜像清理完成${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 显示帮助
|
||||||
|
show_help() {
|
||||||
|
echo -e "${BLUE}Docker构建脚本${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "用法: $0 [命令] [版本] [选项]"
|
||||||
|
echo ""
|
||||||
|
echo "命令:"
|
||||||
|
echo " build [version] [--skip-frontend] 构建Docker镜像"
|
||||||
|
echo " push [version] 推送镜像到Docker Hub"
|
||||||
|
echo " clean [version] 清理Docker镜像"
|
||||||
|
echo " help 显示此帮助信息"
|
||||||
|
echo ""
|
||||||
|
echo "选项:"
|
||||||
|
echo " --skip-frontend 跳过前端构建"
|
||||||
|
echo ""
|
||||||
|
echo "示例:"
|
||||||
|
echo " $0 build # 构建当前版本镜像"
|
||||||
|
echo " $0 build 1.2.4 # 构建指定版本镜像"
|
||||||
|
echo " $0 build 1.2.4 --skip-frontend # 构建指定版本镜像,跳过前端"
|
||||||
|
echo " $0 push 1.2.4 # 推送指定版本镜像"
|
||||||
|
echo " $0 clean # 清理当前版本镜像"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 主函数
|
||||||
|
main() {
|
||||||
|
case $1 in
|
||||||
|
"build")
|
||||||
|
# 检查是否有 --skip-frontend 选项
|
||||||
|
local skip_frontend="false"
|
||||||
|
if [ "$3" = "--skip-frontend" ]; then
|
||||||
|
skip_frontend="true"
|
||||||
|
fi
|
||||||
|
build_docker $2 $skip_frontend
|
||||||
|
;;
|
||||||
|
"push")
|
||||||
|
push_images $2
|
||||||
|
;;
|
||||||
|
"clean")
|
||||||
|
clean_images $2
|
||||||
|
;;
|
||||||
|
"help"|"-h"|"--help")
|
||||||
|
show_help
|
||||||
|
;;
|
||||||
|
"")
|
||||||
|
show_help
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}错误: 未知命令 '$1'${NC}"
|
||||||
|
echo "使用 '$0 help' 查看帮助信息"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# 运行主函数
|
||||||
|
main "$@"
|
||||||
777
services/meilisearch_manager.go
Normal file
777
services/meilisearch_manager.go
Normal file
@@ -0,0 +1,777 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ctwj/urldb/db/entity"
|
||||||
|
"github.com/ctwj/urldb/db/repo"
|
||||||
|
"github.com/ctwj/urldb/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MeilisearchManager Meilisearch管理器
|
||||||
|
type MeilisearchManager struct {
|
||||||
|
service *MeilisearchService
|
||||||
|
repoMgr *repo.RepositoryManager
|
||||||
|
configRepo repo.SystemConfigRepository
|
||||||
|
mutex sync.RWMutex
|
||||||
|
status MeilisearchStatus
|
||||||
|
stopChan chan struct{}
|
||||||
|
isRunning bool
|
||||||
|
|
||||||
|
// 同步进度控制
|
||||||
|
syncMutex sync.RWMutex
|
||||||
|
syncProgress SyncProgress
|
||||||
|
isSyncing bool
|
||||||
|
syncStopChan chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncProgress 同步进度
|
||||||
|
type SyncProgress struct {
|
||||||
|
IsRunning bool `json:"is_running"`
|
||||||
|
TotalCount int64 `json:"total_count"`
|
||||||
|
ProcessedCount int64 `json:"processed_count"`
|
||||||
|
SyncedCount int64 `json:"synced_count"`
|
||||||
|
FailedCount int64 `json:"failed_count"`
|
||||||
|
StartTime time.Time `json:"start_time"`
|
||||||
|
EstimatedTime string `json:"estimated_time"`
|
||||||
|
CurrentBatch int `json:"current_batch"`
|
||||||
|
TotalBatches int `json:"total_batches"`
|
||||||
|
ErrorMessage string `json:"error_message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MeilisearchStatus Meilisearch状态
|
||||||
|
type MeilisearchStatus struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Healthy bool `json:"healthy"`
|
||||||
|
LastCheck time.Time `json:"last_check"`
|
||||||
|
ErrorCount int `json:"error_count"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
DocumentCount int64 `json:"document_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMeilisearchManager 创建Meilisearch管理器
|
||||||
|
func NewMeilisearchManager(repoMgr *repo.RepositoryManager) *MeilisearchManager {
|
||||||
|
return &MeilisearchManager{
|
||||||
|
repoMgr: repoMgr,
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
syncStopChan: make(chan struct{}),
|
||||||
|
status: MeilisearchStatus{
|
||||||
|
Enabled: false,
|
||||||
|
Healthy: false,
|
||||||
|
LastCheck: time.Now(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize 初始化Meilisearch服务
|
||||||
|
func (m *MeilisearchManager) Initialize() error {
|
||||||
|
m.mutex.Lock()
|
||||||
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
|
// 设置configRepo
|
||||||
|
m.configRepo = m.repoMgr.SystemConfigRepository
|
||||||
|
|
||||||
|
// 获取配置
|
||||||
|
enabled, err := m.configRepo.GetConfigBool(entity.ConfigKeyMeilisearchEnabled)
|
||||||
|
if err != nil {
|
||||||
|
utils.Error("获取Meilisearch启用状态失败: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !enabled {
|
||||||
|
utils.Debug("Meilisearch未启用,清理服务状态")
|
||||||
|
m.status.Enabled = false
|
||||||
|
m.service = nil
|
||||||
|
// 停止监控循环
|
||||||
|
if m.stopChan != nil {
|
||||||
|
close(m.stopChan)
|
||||||
|
m.stopChan = make(chan struct{})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
host, err := m.configRepo.GetConfigValue(entity.ConfigKeyMeilisearchHost)
|
||||||
|
if err != nil {
|
||||||
|
utils.Error("获取Meilisearch主机配置失败: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
port, err := m.configRepo.GetConfigValue(entity.ConfigKeyMeilisearchPort)
|
||||||
|
if err != nil {
|
||||||
|
utils.Error("获取Meilisearch端口配置失败: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
masterKey, err := m.configRepo.GetConfigValue(entity.ConfigKeyMeilisearchMasterKey)
|
||||||
|
if err != nil {
|
||||||
|
utils.Error("获取Meilisearch主密钥配置失败: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
indexName, err := m.configRepo.GetConfigValue(entity.ConfigKeyMeilisearchIndexName)
|
||||||
|
if err != nil {
|
||||||
|
utils.Error("获取Meilisearch索引名配置失败: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.service = NewMeilisearchService(host, port, masterKey, indexName, enabled)
|
||||||
|
m.status.Enabled = enabled
|
||||||
|
|
||||||
|
// 如果启用,创建索引并更新设置
|
||||||
|
if enabled {
|
||||||
|
utils.Debug("Meilisearch已启用,创建索引并更新设置")
|
||||||
|
|
||||||
|
// 创建索引
|
||||||
|
if err := m.service.CreateIndex(); err != nil {
|
||||||
|
utils.Error("创建Meilisearch索引失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新索引设置
|
||||||
|
if err := m.service.UpdateIndexSettings(); err != nil {
|
||||||
|
utils.Error("更新Meilisearch索引设置失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即进行一次健康检查
|
||||||
|
go func() {
|
||||||
|
m.checkHealth()
|
||||||
|
// 启动监控
|
||||||
|
go m.monitorLoop()
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
utils.Debug("Meilisearch未启用")
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Debug("Meilisearch服务初始化完成")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEnabled 检查是否启用
|
||||||
|
func (m *MeilisearchManager) IsEnabled() bool {
|
||||||
|
m.mutex.RLock()
|
||||||
|
defer m.mutex.RUnlock()
|
||||||
|
return m.status.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReloadConfig 重新加载配置
|
||||||
|
func (m *MeilisearchManager) ReloadConfig() error {
|
||||||
|
utils.Debug("重新加载Meilisearch配置")
|
||||||
|
return m.Initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetService 获取Meilisearch服务
|
||||||
|
func (m *MeilisearchManager) GetService() *MeilisearchService {
|
||||||
|
m.mutex.RLock()
|
||||||
|
defer m.mutex.RUnlock()
|
||||||
|
return m.service
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus 获取状态
|
||||||
|
func (m *MeilisearchManager) GetStatus() (MeilisearchStatus, error) {
|
||||||
|
m.mutex.RLock()
|
||||||
|
defer m.mutex.RUnlock()
|
||||||
|
|
||||||
|
utils.Debug("获取Meilisearch状态 - 启用状态: %v, 健康状态: %v, 服务实例: %v", m.status.Enabled, m.status.Healthy, m.service != nil)
|
||||||
|
|
||||||
|
if m.service != nil && m.service.IsEnabled() {
|
||||||
|
utils.Debug("Meilisearch服务已初始化且启用,尝试获取索引统计")
|
||||||
|
|
||||||
|
// 获取索引统计
|
||||||
|
stats, err := m.service.GetIndexStats()
|
||||||
|
if err != nil {
|
||||||
|
utils.Error("获取Meilisearch索引统计失败: %v", err)
|
||||||
|
// 即使获取统计失败,也返回当前状态
|
||||||
|
} else {
|
||||||
|
utils.Debug("Meilisearch索引统计: %+v", stats)
|
||||||
|
|
||||||
|
// 更新文档数量
|
||||||
|
if count, ok := stats["numberOfDocuments"].(float64); ok {
|
||||||
|
m.status.DocumentCount = int64(count)
|
||||||
|
utils.Debug("文档数量 (float64): %d", int64(count))
|
||||||
|
} else if count, ok := stats["numberOfDocuments"].(int64); ok {
|
||||||
|
m.status.DocumentCount = count
|
||||||
|
utils.Debug("文档数量 (int64): %d", count)
|
||||||
|
} else if count, ok := stats["numberOfDocuments"].(int); ok {
|
||||||
|
m.status.DocumentCount = int64(count)
|
||||||
|
utils.Debug("文档数量 (int): %d", int64(count))
|
||||||
|
} else {
|
||||||
|
utils.Error("无法解析文档数量,类型: %T, 值: %v", stats["numberOfDocuments"], stats["numberOfDocuments"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不更新启用状态,保持配置中的状态
|
||||||
|
// 启用状态应该由配置控制,而不是由服务状态控制
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
utils.Debug("Meilisearch服务未初始化或未启用 - service: %v, enabled: %v", m.service != nil, m.service != nil && m.service.IsEnabled())
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatusWithHealthCheck 获取状态并同时进行健康检查
|
||||||
|
func (m *MeilisearchManager) GetStatusWithHealthCheck() (MeilisearchStatus, error) {
|
||||||
|
// 先进行健康检查
|
||||||
|
m.checkHealth()
|
||||||
|
|
||||||
|
// 然后获取状态
|
||||||
|
return m.GetStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncResourceToMeilisearch 同步资源到Meilisearch
|
||||||
|
func (m *MeilisearchManager) SyncResourceToMeilisearch(resource *entity.Resource) error {
|
||||||
|
utils.Debug(fmt.Sprintf("开始同步资源到Meilisearch - 资源ID: %d, URL: %s", resource.ID, resource.URL))
|
||||||
|
|
||||||
|
if m.service == nil || !m.service.IsEnabled() {
|
||||||
|
utils.Debug("Meilisearch服务未初始化或未启用")
|
||||||
|
return fmt.Errorf("Meilisearch服务未初始化或未启用")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先进行健康检查
|
||||||
|
if err := m.service.HealthCheck(); err != nil {
|
||||||
|
utils.Error(fmt.Sprintf("Meilisearch健康检查失败: %v", err))
|
||||||
|
return fmt.Errorf("Meilisearch健康检查失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保索引存在
|
||||||
|
if err := m.service.CreateIndex(); err != nil {
|
||||||
|
utils.Error(fmt.Sprintf("创建Meilisearch索引失败: %v", err))
|
||||||
|
return fmt.Errorf("创建Meilisearch索引失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
doc := m.convertResourceToDocument(resource)
|
||||||
|
err := m.service.BatchAddDocuments([]MeilisearchDocument{doc})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记为已同步
|
||||||
|
return m.repoMgr.ResourceRepository.MarkAsSyncedToMeilisearch([]uint{resource.ID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncAllResources 同步所有资源
|
||||||
|
func (m *MeilisearchManager) SyncAllResources() (int, error) {
|
||||||
|
if m.service == nil || !m.service.IsEnabled() {
|
||||||
|
return 0, fmt.Errorf("Meilisearch未启用")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已经在同步中
|
||||||
|
m.syncMutex.Lock()
|
||||||
|
if m.isSyncing {
|
||||||
|
m.syncMutex.Unlock()
|
||||||
|
return 0, fmt.Errorf("同步操作正在进行中")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化同步状态
|
||||||
|
m.isSyncing = true
|
||||||
|
m.syncProgress = SyncProgress{
|
||||||
|
IsRunning: true,
|
||||||
|
TotalCount: 0,
|
||||||
|
ProcessedCount: 0,
|
||||||
|
SyncedCount: 0,
|
||||||
|
FailedCount: 0,
|
||||||
|
StartTime: time.Now(),
|
||||||
|
CurrentBatch: 0,
|
||||||
|
TotalBatches: 0,
|
||||||
|
ErrorMessage: "",
|
||||||
|
}
|
||||||
|
// 重新创建停止通道
|
||||||
|
m.syncStopChan = make(chan struct{})
|
||||||
|
m.syncMutex.Unlock()
|
||||||
|
|
||||||
|
// 在goroutine中执行同步,避免阻塞
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
m.syncMutex.Lock()
|
||||||
|
m.isSyncing = false
|
||||||
|
m.syncProgress.IsRunning = false
|
||||||
|
m.syncMutex.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
m.syncAllResourcesInternal()
|
||||||
|
}()
|
||||||
|
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DebugGetAllDocuments 调试:获取所有文档
|
||||||
|
func (m *MeilisearchManager) DebugGetAllDocuments() error {
|
||||||
|
if m.service == nil || !m.service.IsEnabled() {
|
||||||
|
return fmt.Errorf("Meilisearch未启用")
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Debug("开始调试:获取Meilisearch中的所有文档")
|
||||||
|
_, err := m.service.GetAllDocuments()
|
||||||
|
if err != nil {
|
||||||
|
utils.Error("调试获取所有文档失败: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Debug("调试完成:已获取所有文档")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncAllResourcesInternal 内部同步方法
|
||||||
|
func (m *MeilisearchManager) syncAllResourcesInternal() {
|
||||||
|
// 健康检查
|
||||||
|
if err := m.service.HealthCheck(); err != nil {
|
||||||
|
m.updateSyncProgress("", "", fmt.Sprintf("Meilisearch不可用: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建索引
|
||||||
|
if err := m.service.CreateIndex(); err != nil {
|
||||||
|
m.updateSyncProgress("", "", fmt.Sprintf("创建索引失败: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Debug("开始同步所有资源到Meilisearch...")
|
||||||
|
|
||||||
|
// 获取总资源数量
|
||||||
|
totalCount, err := m.repoMgr.ResourceRepository.CountUnsyncedToMeilisearch()
|
||||||
|
if err != nil {
|
||||||
|
m.updateSyncProgress("", "", fmt.Sprintf("获取资源总数失败: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分批处理
|
||||||
|
batchSize := 100
|
||||||
|
totalBatches := int((totalCount + int64(batchSize) - 1) / int64(batchSize))
|
||||||
|
|
||||||
|
// 更新总数量和总批次
|
||||||
|
m.syncMutex.Lock()
|
||||||
|
m.syncProgress.TotalCount = totalCount
|
||||||
|
m.syncProgress.TotalBatches = totalBatches
|
||||||
|
m.syncMutex.Unlock()
|
||||||
|
|
||||||
|
offset := 0
|
||||||
|
totalSynced := 0
|
||||||
|
currentBatch := 0
|
||||||
|
|
||||||
|
// 预加载所有分类和平台数据到缓存
|
||||||
|
categoryCache := make(map[uint]string)
|
||||||
|
panCache := make(map[uint]string)
|
||||||
|
|
||||||
|
// 获取所有分类
|
||||||
|
categories, err := m.repoMgr.CategoryRepository.FindAll()
|
||||||
|
if err == nil {
|
||||||
|
for _, category := range categories {
|
||||||
|
categoryCache[category.ID] = category.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有平台
|
||||||
|
pans, err := m.repoMgr.PanRepository.FindAll()
|
||||||
|
if err == nil {
|
||||||
|
for _, pan := range pans {
|
||||||
|
panCache[pan.ID] = pan.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
// 检查是否需要停止
|
||||||
|
select {
|
||||||
|
case <-m.syncStopChan:
|
||||||
|
utils.Debug("同步操作被停止")
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
currentBatch++
|
||||||
|
|
||||||
|
// 获取一批资源(在goroutine中执行,避免阻塞)
|
||||||
|
resourcesChan := make(chan []entity.Resource, 1)
|
||||||
|
errChan := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// 直接查询未同步的资源,不使用分页
|
||||||
|
resources, _, err := m.repoMgr.ResourceRepository.FindUnsyncedToMeilisearch(1, batchSize)
|
||||||
|
if err != nil {
|
||||||
|
errChan <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resourcesChan <- resources
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 等待数据库查询结果或停止信号(添加超时)
|
||||||
|
select {
|
||||||
|
case resources := <-resourcesChan:
|
||||||
|
if len(resources) == 0 {
|
||||||
|
utils.Info("资源同步完成,总共同步 %d 个资源", totalSynced)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否需要停止
|
||||||
|
select {
|
||||||
|
case <-m.syncStopChan:
|
||||||
|
utils.Debug("同步操作被停止")
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为Meilisearch文档(使用缓存)
|
||||||
|
var docs []MeilisearchDocument
|
||||||
|
for _, resource := range resources {
|
||||||
|
doc := m.convertResourceToDocumentWithCache(&resource, categoryCache, panCache)
|
||||||
|
docs = append(docs, doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否需要停止
|
||||||
|
select {
|
||||||
|
case <-m.syncStopChan:
|
||||||
|
utils.Debug("同步操作被停止")
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量添加到Meilisearch(在goroutine中执行,避免阻塞)
|
||||||
|
meilisearchErrChan := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
err := m.service.BatchAddDocuments(docs)
|
||||||
|
meilisearchErrChan <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 等待Meilisearch操作结果或停止信号(添加超时)
|
||||||
|
select {
|
||||||
|
case err := <-meilisearchErrChan:
|
||||||
|
if err != nil {
|
||||||
|
m.updateSyncProgress("", "", fmt.Sprintf("批量添加文档失败: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-time.After(60 * time.Second): // 60秒超时
|
||||||
|
m.updateSyncProgress("", "", "Meilisearch操作超时")
|
||||||
|
utils.Error("Meilisearch操作超时")
|
||||||
|
return
|
||||||
|
case <-m.syncStopChan:
|
||||||
|
utils.Debug("同步操作被停止")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否需要停止
|
||||||
|
select {
|
||||||
|
case <-m.syncStopChan:
|
||||||
|
utils.Debug("同步操作被停止")
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记为已同步(在goroutine中执行,避免阻塞)
|
||||||
|
var resourceIDs []uint
|
||||||
|
for _, resource := range resources {
|
||||||
|
resourceIDs = append(resourceIDs, resource.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
markErrChan := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
err := m.repoMgr.ResourceRepository.MarkAsSyncedToMeilisearch(resourceIDs)
|
||||||
|
markErrChan <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 等待标记操作结果或停止信号(添加超时)
|
||||||
|
select {
|
||||||
|
case err := <-markErrChan:
|
||||||
|
if err != nil {
|
||||||
|
utils.Error("标记资源同步状态失败: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(30 * time.Second): // 30秒超时
|
||||||
|
utils.Error("标记资源同步状态超时")
|
||||||
|
case <-m.syncStopChan:
|
||||||
|
utils.Debug("同步操作被停止")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totalSynced += len(docs)
|
||||||
|
offset += len(resources)
|
||||||
|
|
||||||
|
// 更新进度
|
||||||
|
m.updateSyncProgress(fmt.Sprintf("%d", totalSynced), fmt.Sprintf("%d", currentBatch), "")
|
||||||
|
|
||||||
|
utils.Debug("已同步 %d 个资源到Meilisearch (批次 %d/%d)", totalSynced, currentBatch, totalBatches)
|
||||||
|
|
||||||
|
// 检查是否已经同步完所有资源
|
||||||
|
if len(resources) == 0 {
|
||||||
|
utils.Info("资源同步完成,总共同步 %d 个资源", totalSynced)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-time.After(30 * time.Second): // 30秒超时
|
||||||
|
m.updateSyncProgress("", "", "数据库查询超时")
|
||||||
|
utils.Error("数据库查询超时")
|
||||||
|
return
|
||||||
|
|
||||||
|
case err := <-errChan:
|
||||||
|
m.updateSyncProgress("", "", fmt.Sprintf("获取资源失败: %v", err))
|
||||||
|
return
|
||||||
|
case <-m.syncStopChan:
|
||||||
|
utils.Info("同步操作被停止")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 避免过于频繁的请求
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Info("资源同步完成,总共同步 %d 个资源", totalSynced)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateSyncProgress 更新同步进度
|
||||||
|
func (m *MeilisearchManager) updateSyncProgress(syncedCount, currentBatch, errorMessage string) {
|
||||||
|
m.syncMutex.Lock()
|
||||||
|
defer m.syncMutex.Unlock()
|
||||||
|
|
||||||
|
if syncedCount != "" {
|
||||||
|
if count, err := strconv.ParseInt(syncedCount, 10, 64); err == nil {
|
||||||
|
m.syncProgress.SyncedCount = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentBatch != "" {
|
||||||
|
if batch, err := strconv.Atoi(currentBatch); err == nil {
|
||||||
|
m.syncProgress.CurrentBatch = batch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if errorMessage != "" {
|
||||||
|
m.syncProgress.ErrorMessage = errorMessage
|
||||||
|
m.syncProgress.IsRunning = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算预估时间
|
||||||
|
if m.syncProgress.SyncedCount > 0 {
|
||||||
|
elapsed := time.Since(m.syncProgress.StartTime)
|
||||||
|
rate := float64(m.syncProgress.SyncedCount) / elapsed.Seconds()
|
||||||
|
if rate > 0 {
|
||||||
|
remaining := float64(m.syncProgress.TotalCount-m.syncProgress.SyncedCount) / rate
|
||||||
|
m.syncProgress.EstimatedTime = fmt.Sprintf("%.0f秒", remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUnsyncedCount 获取未同步资源数量
|
||||||
|
func (m *MeilisearchManager) GetUnsyncedCount() (int64, error) {
|
||||||
|
// 直接查询未同步的资源数量
|
||||||
|
return m.repoMgr.ResourceRepository.CountUnsyncedToMeilisearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUnsyncedResources 获取未同步的资源
|
||||||
|
func (m *MeilisearchManager) GetUnsyncedResources(page, pageSize int) ([]entity.Resource, int64, error) {
|
||||||
|
// 查询未同步到Meilisearch的资源
|
||||||
|
return m.repoMgr.ResourceRepository.FindUnsyncedToMeilisearch(page, pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSyncedResources 获取已同步的资源
|
||||||
|
func (m *MeilisearchManager) GetSyncedResources(page, pageSize int) ([]entity.Resource, int64, error) {
|
||||||
|
// 查询已同步到Meilisearch的资源
|
||||||
|
return m.repoMgr.ResourceRepository.FindSyncedToMeilisearch(page, pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllResources 获取所有资源
|
||||||
|
func (m *MeilisearchManager) GetAllResources(page, pageSize int) ([]entity.Resource, int64, error) {
|
||||||
|
// 查询所有资源
|
||||||
|
return m.repoMgr.ResourceRepository.FindAllWithPagination(page, pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSyncProgress 获取同步进度
|
||||||
|
func (m *MeilisearchManager) GetSyncProgress() SyncProgress {
|
||||||
|
m.syncMutex.RLock()
|
||||||
|
defer m.syncMutex.RUnlock()
|
||||||
|
return m.syncProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopSync 停止同步
|
||||||
|
func (m *MeilisearchManager) StopSync() {
|
||||||
|
m.syncMutex.Lock()
|
||||||
|
defer m.syncMutex.Unlock()
|
||||||
|
|
||||||
|
if m.isSyncing {
|
||||||
|
// 发送停止信号
|
||||||
|
select {
|
||||||
|
case <-m.syncStopChan:
|
||||||
|
// 通道已经关闭,不需要再次关闭
|
||||||
|
default:
|
||||||
|
close(m.syncStopChan)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.isSyncing = false
|
||||||
|
m.syncProgress.IsRunning = false
|
||||||
|
m.syncProgress.ErrorMessage = "同步已停止"
|
||||||
|
utils.Debug("同步操作已停止")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearIndex 清空索引
|
||||||
|
func (m *MeilisearchManager) ClearIndex() error {
|
||||||
|
if m.service == nil || !m.service.IsEnabled() {
|
||||||
|
return fmt.Errorf("Meilisearch未启用")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空Meilisearch索引
|
||||||
|
if err := m.service.ClearIndex(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记所有资源为未同步
|
||||||
|
return m.repoMgr.ResourceRepository.MarkAllAsUnsyncedToMeilisearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertResourceToDocument 转换资源为搜索文档
|
||||||
|
func (m *MeilisearchManager) convertResourceToDocument(resource *entity.Resource) MeilisearchDocument {
|
||||||
|
// 获取关联数据
|
||||||
|
var categoryName string
|
||||||
|
if resource.CategoryID != nil {
|
||||||
|
category, err := m.repoMgr.CategoryRepository.FindByID(*resource.CategoryID)
|
||||||
|
if err == nil {
|
||||||
|
categoryName = category.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var panName string
|
||||||
|
if resource.PanID != nil {
|
||||||
|
pan, err := m.repoMgr.PanRepository.FindByID(*resource.PanID)
|
||||||
|
if err == nil {
|
||||||
|
panName = pan.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取标签 - 从关联的Tags字段获取
|
||||||
|
var tagNames []string
|
||||||
|
if resource.Tags != nil {
|
||||||
|
for _, tag := range resource.Tags {
|
||||||
|
tagNames = append(tagNames, tag.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MeilisearchDocument{
|
||||||
|
ID: resource.ID,
|
||||||
|
Title: resource.Title,
|
||||||
|
Description: resource.Description,
|
||||||
|
URL: resource.URL,
|
||||||
|
SaveURL: resource.SaveURL,
|
||||||
|
FileSize: resource.FileSize,
|
||||||
|
Key: resource.Key,
|
||||||
|
Category: categoryName,
|
||||||
|
Tags: tagNames,
|
||||||
|
PanName: panName,
|
||||||
|
PanID: resource.PanID,
|
||||||
|
Author: resource.Author,
|
||||||
|
CreatedAt: resource.CreatedAt,
|
||||||
|
UpdatedAt: resource.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertResourceToDocumentWithCache 转换资源为搜索文档(使用缓存)
|
||||||
|
func (m *MeilisearchManager) convertResourceToDocumentWithCache(resource *entity.Resource, categoryCache map[uint]string, panCache map[uint]string) MeilisearchDocument {
|
||||||
|
// 从缓存获取关联数据
|
||||||
|
var categoryName string
|
||||||
|
if resource.CategoryID != nil {
|
||||||
|
if name, exists := categoryCache[*resource.CategoryID]; exists {
|
||||||
|
categoryName = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var panName string
|
||||||
|
if resource.PanID != nil {
|
||||||
|
if name, exists := panCache[*resource.PanID]; exists {
|
||||||
|
panName = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取标签 - 从关联的Tags字段获取
|
||||||
|
var tagNames []string
|
||||||
|
if resource.Tags != nil {
|
||||||
|
for _, tag := range resource.Tags {
|
||||||
|
tagNames = append(tagNames, tag.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MeilisearchDocument{
|
||||||
|
ID: resource.ID,
|
||||||
|
Title: resource.Title,
|
||||||
|
Description: resource.Description,
|
||||||
|
URL: resource.URL,
|
||||||
|
SaveURL: resource.SaveURL,
|
||||||
|
FileSize: resource.FileSize,
|
||||||
|
Key: resource.Key,
|
||||||
|
Category: categoryName,
|
||||||
|
Tags: tagNames,
|
||||||
|
PanName: panName,
|
||||||
|
PanID: resource.PanID,
|
||||||
|
Author: resource.Author,
|
||||||
|
CreatedAt: resource.CreatedAt,
|
||||||
|
UpdatedAt: resource.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// monitorLoop 监控循环
|
||||||
|
func (m *MeilisearchManager) monitorLoop() {
|
||||||
|
if m.isRunning {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.isRunning = true
|
||||||
|
ticker := time.NewTicker(30 * time.Second) // 每30秒检查一次
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
m.checkHealth()
|
||||||
|
case <-m.stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkHealth 检查健康状态
|
||||||
|
func (m *MeilisearchManager) checkHealth() {
|
||||||
|
m.mutex.Lock()
|
||||||
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
|
m.status.LastCheck = time.Now()
|
||||||
|
|
||||||
|
utils.Debug("开始健康检查 - 服务实例: %v, 启用状态: %v", m.service != nil, m.service != nil && m.service.IsEnabled())
|
||||||
|
|
||||||
|
if m.service == nil || !m.service.IsEnabled() {
|
||||||
|
utils.Debug("Meilisearch服务未初始化或未启用")
|
||||||
|
m.status.Healthy = false
|
||||||
|
m.status.LastError = "Meilisearch未启用"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Debug("开始检查Meilisearch健康状态")
|
||||||
|
|
||||||
|
if err := m.service.HealthCheck(); err != nil {
|
||||||
|
m.status.Healthy = false
|
||||||
|
m.status.ErrorCount++
|
||||||
|
m.status.LastError = err.Error()
|
||||||
|
utils.Error("Meilisearch健康检查失败: %v", err)
|
||||||
|
} else {
|
||||||
|
m.status.Healthy = true
|
||||||
|
m.status.ErrorCount = 0
|
||||||
|
m.status.LastError = ""
|
||||||
|
utils.Debug("Meilisearch健康检查成功")
|
||||||
|
|
||||||
|
// 健康检查通过后,更新文档数量
|
||||||
|
if stats, err := m.service.GetIndexStats(); err == nil {
|
||||||
|
if count, ok := stats["numberOfDocuments"].(float64); ok {
|
||||||
|
m.status.DocumentCount = int64(count)
|
||||||
|
} else if count, ok := stats["numberOfDocuments"].(int64); ok {
|
||||||
|
m.status.DocumentCount = count
|
||||||
|
} else if count, ok := stats["numberOfDocuments"].(int); ok {
|
||||||
|
m.status.DocumentCount = int64(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop 停止监控
|
||||||
|
func (m *MeilisearchManager) Stop() {
|
||||||
|
if !m.isRunning {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
close(m.stopChan)
|
||||||
|
m.isRunning = false
|
||||||
|
utils.Debug("Meilisearch监控服务已停止")
|
||||||
|
}
|
||||||
561
services/meilisearch_service.go
Normal file
561
services/meilisearch_service.go
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ctwj/urldb/utils"
|
||||||
|
"github.com/meilisearch/meilisearch-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MeilisearchDocument 搜索文档结构
|
||||||
|
type MeilisearchDocument struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
SaveURL string `json:"save_url"`
|
||||||
|
FileSize string `json:"file_size"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
PanName string `json:"pan_name"`
|
||||||
|
PanID *uint `json:"pan_id"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
// 高亮字段
|
||||||
|
TitleHighlight string `json:"_title_highlight,omitempty"`
|
||||||
|
DescriptionHighlight string `json:"_description_highlight,omitempty"`
|
||||||
|
CategoryHighlight string `json:"_category_highlight,omitempty"`
|
||||||
|
TagsHighlight []string `json:"_tags_highlight,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MeilisearchService Meilisearch服务
|
||||||
|
type MeilisearchService struct {
|
||||||
|
client meilisearch.ServiceManager
|
||||||
|
index meilisearch.IndexManager
|
||||||
|
indexName string
|
||||||
|
enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMeilisearchService 创建Meilisearch服务
|
||||||
|
func NewMeilisearchService(host, port, masterKey, indexName string, enabled bool) *MeilisearchService {
|
||||||
|
if !enabled {
|
||||||
|
return &MeilisearchService{
|
||||||
|
enabled: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建服务器URL
|
||||||
|
serverURL := fmt.Sprintf("http://%s:%s", host, port)
|
||||||
|
|
||||||
|
// 创建客户端
|
||||||
|
var client meilisearch.ServiceManager
|
||||||
|
|
||||||
|
if masterKey != "" {
|
||||||
|
client = meilisearch.New(serverURL, meilisearch.WithAPIKey(masterKey))
|
||||||
|
} else {
|
||||||
|
client = meilisearch.New(serverURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取索引
|
||||||
|
index := client.Index(indexName)
|
||||||
|
|
||||||
|
return &MeilisearchService{
|
||||||
|
client: client,
|
||||||
|
index: index,
|
||||||
|
indexName: indexName,
|
||||||
|
enabled: enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEnabled 检查是否启用
|
||||||
|
func (m *MeilisearchService) IsEnabled() bool {
|
||||||
|
return m.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthCheck 健康检查
|
||||||
|
func (m *MeilisearchService) HealthCheck() error {
|
||||||
|
if !m.enabled {
|
||||||
|
utils.Debug("Meilisearch未启用,跳过健康检查")
|
||||||
|
return fmt.Errorf("Meilisearch未启用")
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Debug("开始Meilisearch健康检查")
|
||||||
|
|
||||||
|
// 使用官方SDK的健康检查
|
||||||
|
_, err := m.client.Health()
|
||||||
|
if err != nil {
|
||||||
|
utils.Error("Meilisearch健康检查失败: %v", err)
|
||||||
|
return fmt.Errorf("Meilisearch健康检查失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Debug("Meilisearch健康检查成功")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateIndex 创建索引
|
||||||
|
func (m *MeilisearchService) CreateIndex() error {
|
||||||
|
if !m.enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建索引配置
|
||||||
|
indexConfig := &meilisearch.IndexConfig{
|
||||||
|
Uid: m.indexName,
|
||||||
|
PrimaryKey: "id",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建索引
|
||||||
|
_, err := m.client.CreateIndex(indexConfig)
|
||||||
|
if err != nil {
|
||||||
|
// 如果索引已存在,返回成功
|
||||||
|
utils.Debug("Meilisearch索引创建失败或已存在: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Debug("Meilisearch索引创建成功: %s", m.indexName)
|
||||||
|
|
||||||
|
// 配置索引设置
|
||||||
|
settings := &meilisearch.Settings{
|
||||||
|
// 配置可过滤的属性
|
||||||
|
FilterableAttributes: []string{
|
||||||
|
"pan_id",
|
||||||
|
"pan_name",
|
||||||
|
"category",
|
||||||
|
"tags",
|
||||||
|
},
|
||||||
|
// 配置可搜索的属性
|
||||||
|
SearchableAttributes: []string{
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"category",
|
||||||
|
"tags",
|
||||||
|
},
|
||||||
|
// 配置可排序的属性
|
||||||
|
SortableAttributes: []string{
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"id",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新索引设置
|
||||||
|
_, err = m.index.UpdateSettings(settings)
|
||||||
|
if err != nil {
|
||||||
|
utils.Error("更新Meilisearch索引设置失败: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Debug("Meilisearch索引设置更新成功")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateIndexSettings 更新索引设置
|
||||||
|
func (m *MeilisearchService) UpdateIndexSettings() error {
|
||||||
|
if !m.enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置索引设置
|
||||||
|
settings := &meilisearch.Settings{
|
||||||
|
// 配置可过滤的属性
|
||||||
|
FilterableAttributes: []string{
|
||||||
|
"pan_id",
|
||||||
|
"pan_name",
|
||||||
|
"category",
|
||||||
|
"tags",
|
||||||
|
},
|
||||||
|
// 配置可搜索的属性
|
||||||
|
SearchableAttributes: []string{
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"category",
|
||||||
|
"tags",
|
||||||
|
},
|
||||||
|
// 配置可排序的属性
|
||||||
|
SortableAttributes: []string{
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"id",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新索引设置
|
||||||
|
_, err := m.index.UpdateSettings(settings)
|
||||||
|
if err != nil {
|
||||||
|
utils.Error("更新Meilisearch索引设置失败: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Debug("Meilisearch索引设置更新成功")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchAddDocuments 批量添加文档
|
||||||
|
func (m *MeilisearchService) BatchAddDocuments(docs []MeilisearchDocument) error {
|
||||||
|
utils.Debug(fmt.Sprintf("开始批量添加文档到Meilisearch - 文档数量: %d", len(docs)))
|
||||||
|
|
||||||
|
if !m.enabled {
|
||||||
|
utils.Debug("Meilisearch未启用,跳过批量添加")
|
||||||
|
return fmt.Errorf("Meilisearch未启用")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(docs) == 0 {
|
||||||
|
utils.Debug("文档列表为空,跳过批量添加")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为interface{}切片
|
||||||
|
var documents []interface{}
|
||||||
|
for i, doc := range docs {
|
||||||
|
utils.Debug(fmt.Sprintf("转换文档 %d - ID: %d, 标题: %s", i+1, doc.ID, doc.Title))
|
||||||
|
documents = append(documents, doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Debug(fmt.Sprintf("开始调用Meilisearch API添加 %d 个文档", len(documents)))
|
||||||
|
|
||||||
|
// 批量添加文档
|
||||||
|
_, err := m.index.AddDocuments(documents, nil)
|
||||||
|
if err != nil {
|
||||||
|
utils.Error(fmt.Sprintf("Meilisearch批量添加文档失败: %v", err))
|
||||||
|
return fmt.Errorf("Meilisearch批量添加文档失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Debug(fmt.Sprintf("成功批量添加 %d 个文档到Meilisearch", len(docs)))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search 搜索文档
|
||||||
|
func (m *MeilisearchService) Search(query string, filters map[string]interface{}, page, pageSize int) ([]MeilisearchDocument, int64, error) {
|
||||||
|
|
||||||
|
if !m.enabled {
|
||||||
|
return nil, 0, fmt.Errorf("Meilisearch未启用")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建搜索请求
|
||||||
|
searchRequest := &meilisearch.SearchRequest{
|
||||||
|
Query: query,
|
||||||
|
Offset: int64((page - 1) * pageSize),
|
||||||
|
Limit: int64(pageSize),
|
||||||
|
// 启用高亮功能
|
||||||
|
AttributesToHighlight: []string{"title", "description", "category", "tags"},
|
||||||
|
HighlightPreTag: "<mark>",
|
||||||
|
HighlightPostTag: "</mark>",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加过滤器
|
||||||
|
if len(filters) > 0 {
|
||||||
|
var filterStrings []string
|
||||||
|
for key, value := range filters {
|
||||||
|
switch key {
|
||||||
|
case "pan_id":
|
||||||
|
// 直接使用pan_id进行过滤
|
||||||
|
filterStrings = append(filterStrings, fmt.Sprintf("pan_id = %v", value))
|
||||||
|
case "pan_name":
|
||||||
|
// 使用pan_name进行过滤
|
||||||
|
filterStrings = append(filterStrings, fmt.Sprintf("pan_name = %q", value))
|
||||||
|
case "category":
|
||||||
|
filterStrings = append(filterStrings, fmt.Sprintf("category = %q", value))
|
||||||
|
case "tags":
|
||||||
|
filterStrings = append(filterStrings, fmt.Sprintf("tags = %q", value))
|
||||||
|
default:
|
||||||
|
filterStrings = append(filterStrings, fmt.Sprintf("%s = %q", key, value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(filterStrings) > 0 {
|
||||||
|
searchRequest.Filter = filterStrings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行搜索
|
||||||
|
result, err := m.index.Search(query, searchRequest)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("搜索失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析结果
|
||||||
|
var documents []MeilisearchDocument
|
||||||
|
|
||||||
|
// 如果没有任何结果,直接返回
|
||||||
|
if len(result.Hits) == 0 {
|
||||||
|
utils.Debug("没有搜索结果")
|
||||||
|
return documents, result.EstimatedTotalHits, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, hit := range result.Hits {
|
||||||
|
// 将hit转换为MeilisearchDocument
|
||||||
|
doc := MeilisearchDocument{}
|
||||||
|
|
||||||
|
// 解析JSON数据 - 使用反射
|
||||||
|
hitValue := reflect.ValueOf(hit)
|
||||||
|
|
||||||
|
if hitValue.Kind() == reflect.Map {
|
||||||
|
for _, key := range hitValue.MapKeys() {
|
||||||
|
keyStr := key.String()
|
||||||
|
value := hitValue.MapIndex(key).Interface()
|
||||||
|
|
||||||
|
// 处理_formatted字段(包含所有高亮内容)
|
||||||
|
if keyStr == "_formatted" {
|
||||||
|
if rawValue, ok := value.(json.RawMessage); ok {
|
||||||
|
// 解析_formatted字段中的高亮内容
|
||||||
|
var formattedData map[string]interface{}
|
||||||
|
if err := json.Unmarshal(rawValue, &formattedData); err == nil {
|
||||||
|
// 提取高亮字段
|
||||||
|
if titleHighlight, ok := formattedData["title"].(string); ok {
|
||||||
|
doc.TitleHighlight = titleHighlight
|
||||||
|
}
|
||||||
|
if descHighlight, ok := formattedData["description"].(string); ok {
|
||||||
|
doc.DescriptionHighlight = descHighlight
|
||||||
|
}
|
||||||
|
if categoryHighlight, ok := formattedData["category"].(string); ok {
|
||||||
|
doc.CategoryHighlight = categoryHighlight
|
||||||
|
}
|
||||||
|
if tagsHighlight, ok := formattedData["tags"].([]interface{}); ok {
|
||||||
|
var tags []string
|
||||||
|
for _, tag := range tagsHighlight {
|
||||||
|
if tagStr, ok := tag.(string); ok {
|
||||||
|
tags = append(tags, tagStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doc.TagsHighlight = tags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch keyStr {
|
||||||
|
case "id":
|
||||||
|
if rawID, ok := value.(json.RawMessage); ok {
|
||||||
|
var id float64
|
||||||
|
if err := json.Unmarshal(rawID, &id); err == nil {
|
||||||
|
doc.ID = uint(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "title":
|
||||||
|
if rawTitle, ok := value.(json.RawMessage); ok {
|
||||||
|
var title string
|
||||||
|
if err := json.Unmarshal(rawTitle, &title); err == nil {
|
||||||
|
doc.Title = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "description":
|
||||||
|
if rawDesc, ok := value.(json.RawMessage); ok {
|
||||||
|
var description string
|
||||||
|
if err := json.Unmarshal(rawDesc, &description); err == nil {
|
||||||
|
doc.Description = description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "url":
|
||||||
|
if rawURL, ok := value.(json.RawMessage); ok {
|
||||||
|
var url string
|
||||||
|
if err := json.Unmarshal(rawURL, &url); err == nil {
|
||||||
|
doc.URL = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "save_url":
|
||||||
|
if rawSaveURL, ok := value.(json.RawMessage); ok {
|
||||||
|
var saveURL string
|
||||||
|
if err := json.Unmarshal(rawSaveURL, &saveURL); err == nil {
|
||||||
|
doc.SaveURL = saveURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "file_size":
|
||||||
|
if rawFileSize, ok := value.(json.RawMessage); ok {
|
||||||
|
var fileSize string
|
||||||
|
if err := json.Unmarshal(rawFileSize, &fileSize); err == nil {
|
||||||
|
doc.FileSize = fileSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "key":
|
||||||
|
if rawKey, ok := value.(json.RawMessage); ok {
|
||||||
|
var key string
|
||||||
|
if err := json.Unmarshal(rawKey, &key); err == nil {
|
||||||
|
doc.Key = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "category":
|
||||||
|
if rawCategory, ok := value.(json.RawMessage); ok {
|
||||||
|
var category string
|
||||||
|
if err := json.Unmarshal(rawCategory, &category); err == nil {
|
||||||
|
doc.Category = category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "tags":
|
||||||
|
if rawTags, ok := value.(json.RawMessage); ok {
|
||||||
|
var tags []string
|
||||||
|
if err := json.Unmarshal(rawTags, &tags); err == nil {
|
||||||
|
doc.Tags = tags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "pan_name":
|
||||||
|
if rawPanName, ok := value.(json.RawMessage); ok {
|
||||||
|
var panName string
|
||||||
|
if err := json.Unmarshal(rawPanName, &panName); err == nil {
|
||||||
|
doc.PanName = panName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "pan_id":
|
||||||
|
if rawPanID, ok := value.(json.RawMessage); ok {
|
||||||
|
var panID float64
|
||||||
|
if err := json.Unmarshal(rawPanID, &panID); err == nil {
|
||||||
|
panIDUint := uint(panID)
|
||||||
|
doc.PanID = &panIDUint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "author":
|
||||||
|
if rawAuthor, ok := value.(json.RawMessage); ok {
|
||||||
|
var author string
|
||||||
|
if err := json.Unmarshal(rawAuthor, &author); err == nil {
|
||||||
|
doc.Author = author
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "created_at":
|
||||||
|
if rawCreatedAt, ok := value.(json.RawMessage); ok {
|
||||||
|
var createdAt string
|
||||||
|
if err := json.Unmarshal(rawCreatedAt, &createdAt); err == nil {
|
||||||
|
// 尝试多种时间格式
|
||||||
|
var t time.Time
|
||||||
|
var parseErr error
|
||||||
|
formats := []string{
|
||||||
|
time.RFC3339,
|
||||||
|
"2006-01-02T15:04:05Z",
|
||||||
|
"2006-01-02 15:04:05",
|
||||||
|
"2006-01-02T15:04:05.000Z",
|
||||||
|
}
|
||||||
|
for _, format := range formats {
|
||||||
|
if t, parseErr = time.Parse(format, createdAt); parseErr == nil {
|
||||||
|
doc.CreatedAt = t
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "updated_at":
|
||||||
|
if rawUpdatedAt, ok := value.(json.RawMessage); ok {
|
||||||
|
var updatedAt string
|
||||||
|
if err := json.Unmarshal(rawUpdatedAt, &updatedAt); err == nil {
|
||||||
|
// 尝试多种时间格式
|
||||||
|
var t time.Time
|
||||||
|
var parseErr error
|
||||||
|
formats := []string{
|
||||||
|
time.RFC3339,
|
||||||
|
"2006-01-02T15:04:05Z",
|
||||||
|
"2006-01-02 15:04:05",
|
||||||
|
"2006-01-02T15:04:05.000Z",
|
||||||
|
}
|
||||||
|
for _, format := range formats {
|
||||||
|
if t, parseErr = time.Parse(format, updatedAt); parseErr == nil {
|
||||||
|
doc.UpdatedAt = t
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 高亮字段处理 - 已移除,现在使用_formatted字段
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
utils.Error("hit不是Map类型,无法解析")
|
||||||
|
}
|
||||||
|
|
||||||
|
documents = append(documents, doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return documents, result.EstimatedTotalHits, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllDocuments 获取所有文档(用于调试)
|
||||||
|
func (m *MeilisearchService) GetAllDocuments() ([]MeilisearchDocument, error) {
|
||||||
|
if !m.enabled {
|
||||||
|
return nil, fmt.Errorf("Meilisearch未启用")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建搜索请求,获取所有文档
|
||||||
|
searchRequest := &meilisearch.SearchRequest{
|
||||||
|
Query: "",
|
||||||
|
Offset: 0,
|
||||||
|
Limit: 1000, // 获取前1000个文档
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行搜索
|
||||||
|
result, err := m.index.Search("", searchRequest)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("获取所有文档失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Debug("获取所有文档,总数: %d", result.EstimatedTotalHits)
|
||||||
|
utils.Debug("获取到的文档数量: %d", len(result.Hits))
|
||||||
|
|
||||||
|
// 解析结果
|
||||||
|
var documents []MeilisearchDocument
|
||||||
|
utils.Debug("获取到 %d 个文档", len(result.Hits))
|
||||||
|
|
||||||
|
// 只显示前3个文档的字段信息
|
||||||
|
for i, hit := range result.Hits {
|
||||||
|
if i >= 3 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
utils.Debug("文档%d的字段:", i+1)
|
||||||
|
hitValue := reflect.ValueOf(hit)
|
||||||
|
if hitValue.Kind() == reflect.Map {
|
||||||
|
for _, key := range hitValue.MapKeys() {
|
||||||
|
keyStr := key.String()
|
||||||
|
value := hitValue.MapIndex(key).Interface()
|
||||||
|
if rawValue, ok := value.(json.RawMessage); ok {
|
||||||
|
utils.Debug(" %s: %s", keyStr, string(rawValue))
|
||||||
|
} else {
|
||||||
|
utils.Debug(" %s: %v", keyStr, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return documents, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIndexStats 获取索引统计信息
|
||||||
|
func (m *MeilisearchService) GetIndexStats() (map[string]interface{}, error) {
|
||||||
|
if !m.enabled {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"enabled": false,
|
||||||
|
"message": "Meilisearch未启用",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取索引统计
|
||||||
|
stats, err := m.index.GetStats()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("获取索引统计失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Debug("Meilisearch统计 - 文档数: %d, 索引中: %v", stats.NumberOfDocuments, stats.IsIndexing)
|
||||||
|
|
||||||
|
// 转换为map
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"enabled": true,
|
||||||
|
"numberOfDocuments": stats.NumberOfDocuments,
|
||||||
|
"isIndexing": stats.IsIndexing,
|
||||||
|
"fieldDistribution": stats.FieldDistribution,
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearIndex 清空索引
|
||||||
|
func (m *MeilisearchService) ClearIndex() error {
|
||||||
|
if !m.enabled {
|
||||||
|
return fmt.Errorf("Meilisearch未启用")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空索引
|
||||||
|
_, err := m.index.DeleteAllDocuments()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("清空索引失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Debug("Meilisearch索引已清空")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ func (tm *TaskManager) RegisterProcessor(processor TaskProcessor) {
|
|||||||
tm.mu.Lock()
|
tm.mu.Lock()
|
||||||
defer tm.mu.Unlock()
|
defer tm.mu.Unlock()
|
||||||
tm.processors[processor.GetTaskType()] = processor
|
tm.processors[processor.GetTaskType()] = processor
|
||||||
utils.Info("注册任务处理器: %s", processor.GetTaskType())
|
utils.Debug("注册任务处理器: %s", processor.GetTaskType())
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRegisteredProcessors 获取已注册的处理器列表(用于调试)
|
// getRegisteredProcessors 获取已注册的处理器列表(用于调试)
|
||||||
@@ -56,11 +56,11 @@ func (tm *TaskManager) StartTask(taskID uint) error {
|
|||||||
tm.mu.Lock()
|
tm.mu.Lock()
|
||||||
defer tm.mu.Unlock()
|
defer tm.mu.Unlock()
|
||||||
|
|
||||||
utils.Info("StartTask: 尝试启动任务 %d", taskID)
|
utils.Debug("StartTask: 尝试启动任务 %d", taskID)
|
||||||
|
|
||||||
// 检查任务是否已在运行
|
// 检查任务是否已在运行
|
||||||
if _, exists := tm.running[taskID]; exists {
|
if _, exists := tm.running[taskID]; exists {
|
||||||
utils.Info("任务 %d 已在运行中", taskID)
|
utils.Debug("任务 %d 已在运行中", taskID)
|
||||||
return fmt.Errorf("任务 %d 已在运行中", taskID)
|
return fmt.Errorf("任务 %d 已在运行中", taskID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ func (tm *TaskManager) StartTask(taskID uint) error {
|
|||||||
return fmt.Errorf("获取任务失败: %v", err)
|
return fmt.Errorf("获取任务失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Info("StartTask: 获取到任务 %d, 类型: %s, 状态: %s", task.ID, task.Type, task.Status)
|
utils.Debug("StartTask: 获取到任务 %d, 类型: %s, 状态: %s", task.ID, task.Type, task.Status)
|
||||||
|
|
||||||
// 获取处理器
|
// 获取处理器
|
||||||
processor, exists := tm.processors[string(task.Type)]
|
processor, exists := tm.processors[string(task.Type)]
|
||||||
@@ -80,13 +80,13 @@ func (tm *TaskManager) StartTask(taskID uint) error {
|
|||||||
return fmt.Errorf("未找到任务类型 %s 的处理器", task.Type)
|
return fmt.Errorf("未找到任务类型 %s 的处理器", task.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Info("StartTask: 找到处理器 %s", task.Type)
|
utils.Debug("StartTask: 找到处理器 %s", task.Type)
|
||||||
|
|
||||||
// 创建上下文
|
// 创建上下文
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
tm.running[taskID] = cancel
|
tm.running[taskID] = cancel
|
||||||
|
|
||||||
utils.Info("StartTask: 启动后台任务协程")
|
utils.Debug("StartTask: 启动后台任务协程")
|
||||||
// 启动后台任务
|
// 启动后台任务
|
||||||
go tm.processTask(ctx, task, processor)
|
go tm.processTask(ctx, task, processor)
|
||||||
|
|
||||||
@@ -189,10 +189,10 @@ func (tm *TaskManager) processTask(ctx context.Context, task *entity.Task, proce
|
|||||||
tm.mu.Lock()
|
tm.mu.Lock()
|
||||||
delete(tm.running, task.ID)
|
delete(tm.running, task.ID)
|
||||||
tm.mu.Unlock()
|
tm.mu.Unlock()
|
||||||
utils.Info("processTask: 任务 %d 处理完成,清理资源", task.ID)
|
utils.Debug("processTask: 任务 %d 处理完成,清理资源", task.ID)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
utils.Info("processTask: 开始处理任务: %d, 类型: %s", task.ID, task.Type)
|
utils.Debug("processTask: 开始处理任务: %d, 类型: %s", task.ID, task.Type)
|
||||||
|
|
||||||
// 更新任务状态为运行中
|
// 更新任务状态为运行中
|
||||||
err := tm.repoMgr.TaskRepository.UpdateStatus(task.ID, "running")
|
err := tm.repoMgr.TaskRepository.UpdateStatus(task.ID, "running")
|
||||||
@@ -230,7 +230,7 @@ func (tm *TaskManager) processTask(ctx context.Context, task *entity.Task, proce
|
|||||||
|
|
||||||
// 如果当前批次有处理中的任务项,重置它们为pending状态(服务器重启恢复)
|
// 如果当前批次有处理中的任务项,重置它们为pending状态(服务器重启恢复)
|
||||||
if processingItems > 0 {
|
if processingItems > 0 {
|
||||||
utils.Info("任务 %d 发现 %d 个处理中的任务项,重置为pending状态", task.ID, processingItems)
|
utils.Debug("任务 %d 发现 %d 个处理中的任务项,重置为pending状态", task.ID, processingItems)
|
||||||
err = tm.repoMgr.TaskItemRepository.ResetProcessingItems(task.ID)
|
err = tm.repoMgr.TaskItemRepository.ResetProcessingItems(task.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.Error("重置处理中任务项失败: %v", err)
|
utils.Error("重置处理中任务项失败: %v", err)
|
||||||
@@ -249,13 +249,13 @@ func (tm *TaskManager) processTask(ctx context.Context, task *entity.Task, proce
|
|||||||
successItems := completedItems
|
successItems := completedItems
|
||||||
failedItems := initialFailedItems
|
failedItems := initialFailedItems
|
||||||
|
|
||||||
utils.Info("任务 %d 统计信息: 总计=%d, 已完成=%d, 已失败=%d, 待处理=%d",
|
utils.Debug("任务 %d 统计信息: 总计=%d, 已完成=%d, 已失败=%d, 待处理=%d",
|
||||||
task.ID, totalItems, completedItems, failedItems, currentBatchItems)
|
task.ID, totalItems, completedItems, failedItems, currentBatchItems)
|
||||||
|
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
utils.Info("任务 %d 被取消", task.ID)
|
utils.Debug("任务 %d 被取消", task.ID)
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
// 处理单个任务项
|
// 处理单个任务项
|
||||||
|
|||||||
287
utils/forbidden_words.go
Normal file
287
utils/forbidden_words.go
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ctwj/urldb/db/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ForbiddenWordsProcessor 违禁词处理器
|
||||||
|
type ForbiddenWordsProcessor struct{}
|
||||||
|
|
||||||
|
// NewForbiddenWordsProcessor 创建违禁词处理器实例
|
||||||
|
func NewForbiddenWordsProcessor() *ForbiddenWordsProcessor {
|
||||||
|
return &ForbiddenWordsProcessor{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckContainsForbiddenWords 检查字符串是否包含违禁词
|
||||||
|
// 参数:
|
||||||
|
// - text: 要检查的文本
|
||||||
|
// - forbiddenWords: 违禁词列表
|
||||||
|
//
|
||||||
|
// 返回:
|
||||||
|
// - bool: 是否包含违禁词
|
||||||
|
// - []string: 匹配到的违禁词列表
|
||||||
|
func (p *ForbiddenWordsProcessor) CheckContainsForbiddenWords(text string, forbiddenWords []string) (bool, []string) {
|
||||||
|
if len(forbiddenWords) == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var matchedWords []string
|
||||||
|
textLower := strings.ToLower(text)
|
||||||
|
|
||||||
|
for _, word := range forbiddenWords {
|
||||||
|
wordLower := strings.ToLower(word)
|
||||||
|
if strings.Contains(textLower, wordLower) {
|
||||||
|
matchedWords = append(matchedWords, word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(matchedWords) > 0, matchedWords
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceForbiddenWords 替换字符串中的违禁词为 *
|
||||||
|
// 参数:
|
||||||
|
// - text: 要处理的文本
|
||||||
|
// - forbiddenWords: 违禁词列表
|
||||||
|
//
|
||||||
|
// 返回:
|
||||||
|
// - string: 替换后的文本
|
||||||
|
func (p *ForbiddenWordsProcessor) ReplaceForbiddenWords(text string, forbiddenWords []string) string {
|
||||||
|
if len(forbiddenWords) == 0 {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
result := text
|
||||||
|
// 按长度降序排序,避免短词替换后影响长词的匹配
|
||||||
|
sortedWords := make([]string, len(forbiddenWords))
|
||||||
|
copy(sortedWords, forbiddenWords)
|
||||||
|
|
||||||
|
// 简单的长度排序(这里可以优化为更复杂的排序)
|
||||||
|
for i := 0; i < len(sortedWords)-1; i++ {
|
||||||
|
for j := i + 1; j < len(sortedWords); j++ {
|
||||||
|
if len(sortedWords[i]) < len(sortedWords[j]) {
|
||||||
|
sortedWords[i], sortedWords[j] = sortedWords[j], sortedWords[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, word := range sortedWords {
|
||||||
|
// 使用正则表达式进行不区分大小写的替换
|
||||||
|
// 对于中文,不使用单词边界,直接替换
|
||||||
|
re := regexp.MustCompile(`(?i)` + regexp.QuoteMeta(word))
|
||||||
|
// 使用字符长度而不是字节长度
|
||||||
|
charCount := len([]rune(word))
|
||||||
|
result = re.ReplaceAllString(result, strings.Repeat("*", charCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceForbiddenWordsWithHighlight 替换字符串中的违禁词为 *(处理高亮标记)
|
||||||
|
// 参数:
|
||||||
|
// - text: 要处理的文本(可能包含高亮标记)
|
||||||
|
// - forbiddenWords: 违禁词列表
|
||||||
|
//
|
||||||
|
// 返回:
|
||||||
|
// - string: 替换后的文本
|
||||||
|
func (p *ForbiddenWordsProcessor) ReplaceForbiddenWordsWithHighlight(text string, forbiddenWords []string) string {
|
||||||
|
if len(forbiddenWords) == 0 {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 先移除所有高亮标记,获取纯文本
|
||||||
|
cleanText := regexp.MustCompile(`<mark>(.*?)</mark>`).ReplaceAllString(text, "$1")
|
||||||
|
|
||||||
|
// 2. 检查纯文本中是否包含违禁词
|
||||||
|
hasForbidden := false
|
||||||
|
for _, word := range forbiddenWords {
|
||||||
|
re := regexp.MustCompile(`(?i)` + regexp.QuoteMeta(word))
|
||||||
|
if re.MatchString(cleanText) {
|
||||||
|
hasForbidden = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 如果包含违禁词,则替换非高亮文本
|
||||||
|
if hasForbidden {
|
||||||
|
return p.ReplaceForbiddenWords(text, forbiddenWords)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 如果不包含违禁词,直接返回原文本
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessForbiddenWords 处理违禁词:检查并替换
|
||||||
|
// 参数:
|
||||||
|
// - text: 要处理的文本
|
||||||
|
// - forbiddenWords: 违禁词列表
|
||||||
|
//
|
||||||
|
// 返回:
|
||||||
|
// - bool: 是否包含违禁词
|
||||||
|
// - []string: 匹配到的违禁词列表
|
||||||
|
// - string: 替换后的文本
|
||||||
|
func (p *ForbiddenWordsProcessor) ProcessForbiddenWords(text string, forbiddenWords []string) (bool, []string, string) {
|
||||||
|
contains, matchedWords := p.CheckContainsForbiddenWords(text, forbiddenWords)
|
||||||
|
replacedText := p.ReplaceForbiddenWords(text, forbiddenWords)
|
||||||
|
return contains, matchedWords, replacedText
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseForbiddenWordsConfig 解析违禁词配置字符串
|
||||||
|
// 参数:
|
||||||
|
// - config: 违禁词配置字符串,多个词用逗号分隔
|
||||||
|
//
|
||||||
|
// 返回:
|
||||||
|
// - []string: 处理后的违禁词列表
|
||||||
|
func (p *ForbiddenWordsProcessor) ParseForbiddenWordsConfig(config string) []string {
|
||||||
|
if config == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
words := strings.Split(config, ",")
|
||||||
|
var cleanWords []string
|
||||||
|
for _, word := range words {
|
||||||
|
word = strings.TrimSpace(word)
|
||||||
|
if word != "" {
|
||||||
|
cleanWords = append(cleanWords, word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanWords
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局实例,方便直接调用
|
||||||
|
var DefaultForbiddenWordsProcessor = NewForbiddenWordsProcessor()
|
||||||
|
|
||||||
|
// 便捷函数,直接调用全局实例
|
||||||
|
|
||||||
|
// CheckContainsForbiddenWords 检查字符串是否包含违禁词(便捷函数)
|
||||||
|
func CheckContainsForbiddenWords(text string, forbiddenWords []string) (bool, []string) {
|
||||||
|
return DefaultForbiddenWordsProcessor.CheckContainsForbiddenWords(text, forbiddenWords)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceForbiddenWords 替换字符串中的违禁词为 *(便捷函数)
|
||||||
|
func ReplaceForbiddenWords(text string, forbiddenWords []string) string {
|
||||||
|
return DefaultForbiddenWordsProcessor.ReplaceForbiddenWords(text, forbiddenWords)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceForbiddenWordsWithHighlight 替换字符串中的违禁词为 *(处理高亮标记,便捷函数)
|
||||||
|
func ReplaceForbiddenWordsWithHighlight(text string, forbiddenWords []string) string {
|
||||||
|
return DefaultForbiddenWordsProcessor.ReplaceForbiddenWordsWithHighlight(text, forbiddenWords)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessForbiddenWords 处理违禁词:检查并替换(便捷函数)
|
||||||
|
func ProcessForbiddenWords(text string, forbiddenWords []string) (bool, []string, string) {
|
||||||
|
return DefaultForbiddenWordsProcessor.ProcessForbiddenWords(text, forbiddenWords)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseForbiddenWordsConfig 解析违禁词配置字符串(便捷函数)
|
||||||
|
func ParseForbiddenWordsConfig(config string) []string {
|
||||||
|
return DefaultForbiddenWordsProcessor.ParseForbiddenWordsConfig(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveDuplicates 去除字符串切片中的重复项
|
||||||
|
func RemoveDuplicates(slice []string) []string {
|
||||||
|
keys := make(map[string]bool)
|
||||||
|
var result []string
|
||||||
|
for _, item := range slice {
|
||||||
|
if _, value := keys[item]; !value {
|
||||||
|
keys[item] = true
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResourceForbiddenInfo 资源违禁词信息
|
||||||
|
type ResourceForbiddenInfo struct {
|
||||||
|
HasForbiddenWords bool `json:"has_forbidden_words"`
|
||||||
|
ForbiddenWords []string `json:"forbidden_words"`
|
||||||
|
ProcessedTitle string `json:"-"` // 不序列化,仅内部使用
|
||||||
|
ProcessedDesc string `json:"-"` // 不序列化,仅内部使用
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckResourceForbiddenWords 检查资源是否包含违禁词(检查标题和描述)
|
||||||
|
// 参数:
|
||||||
|
// - title: 资源标题
|
||||||
|
// - description: 资源描述
|
||||||
|
// - forbiddenWords: 违禁词列表
|
||||||
|
//
|
||||||
|
// 返回:
|
||||||
|
// - ResourceForbiddenInfo: 包含检查结果和处理后的文本
|
||||||
|
func CheckResourceForbiddenWords(title, description string, forbiddenWords []string) ResourceForbiddenInfo {
|
||||||
|
|
||||||
|
if len(forbiddenWords) == 0 {
|
||||||
|
return ResourceForbiddenInfo{
|
||||||
|
HasForbiddenWords: false,
|
||||||
|
ForbiddenWords: []string{},
|
||||||
|
ProcessedTitle: title,
|
||||||
|
ProcessedDesc: description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分别检查标题和描述
|
||||||
|
titleHasForbidden, titleMatchedWords := CheckContainsForbiddenWords(title, forbiddenWords)
|
||||||
|
descHasForbidden, descMatchedWords := CheckContainsForbiddenWords(description, forbiddenWords)
|
||||||
|
|
||||||
|
// 合并结果
|
||||||
|
hasForbiddenWords := titleHasForbidden || descHasForbidden
|
||||||
|
var matchedWords []string
|
||||||
|
if titleHasForbidden {
|
||||||
|
matchedWords = append(matchedWords, titleMatchedWords...)
|
||||||
|
}
|
||||||
|
if descHasForbidden {
|
||||||
|
matchedWords = append(matchedWords, descMatchedWords...)
|
||||||
|
}
|
||||||
|
// 去重
|
||||||
|
matchedWords = RemoveDuplicates(matchedWords)
|
||||||
|
|
||||||
|
// 处理文本(替换违禁词)
|
||||||
|
processedTitle := ReplaceForbiddenWords(title, forbiddenWords)
|
||||||
|
processedDesc := ReplaceForbiddenWords(description, forbiddenWords)
|
||||||
|
|
||||||
|
return ResourceForbiddenInfo{
|
||||||
|
HasForbiddenWords: hasForbiddenWords,
|
||||||
|
ForbiddenWords: matchedWords,
|
||||||
|
ProcessedTitle: processedTitle,
|
||||||
|
ProcessedDesc: processedDesc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetForbiddenWordsFromConfig 从系统配置获取违禁词列表
|
||||||
|
// 参数:
|
||||||
|
// - getConfigFunc: 获取配置的函数
|
||||||
|
//
|
||||||
|
// 返回:
|
||||||
|
// - []string: 解析后的违禁词列表
|
||||||
|
// - error: 获取配置时的错误
|
||||||
|
func GetForbiddenWordsFromConfig(getConfigFunc func() (string, error)) ([]string, error) {
|
||||||
|
forbiddenWords, err := getConfigFunc()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ParseForbiddenWordsConfig(forbiddenWords), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessResourcesForbiddenWords 批量处理资源的违禁词
|
||||||
|
// 参数:
|
||||||
|
// - resources: 资源切片
|
||||||
|
// - forbiddenWords: 违禁词列表
|
||||||
|
//
|
||||||
|
// 返回:
|
||||||
|
// - 处理后的资源切片
|
||||||
|
func ProcessResourcesForbiddenWords(resources []entity.Resource, forbiddenWords []string) []entity.Resource {
|
||||||
|
if len(forbiddenWords) == 0 {
|
||||||
|
return resources
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range resources {
|
||||||
|
// 处理标题中的违禁词
|
||||||
|
resources[i].Title = ReplaceForbiddenWords(resources[i].Title, forbiddenWords)
|
||||||
|
// 处理描述中的违禁词
|
||||||
|
resources[i].Description = ReplaceForbiddenWords(resources[i].Description, forbiddenWords)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resources
|
||||||
|
}
|
||||||
@@ -23,13 +23,14 @@ type VersionInfo struct {
|
|||||||
|
|
||||||
// 编译时注入的版本信息
|
// 编译时注入的版本信息
|
||||||
var (
|
var (
|
||||||
Version = getVersionFromFile()
|
// 这些变量将在编译时通过 ldflags 注入
|
||||||
|
Version = "unknown" // 默认版本,编译时会被覆盖
|
||||||
BuildTime = GetCurrentTimeString()
|
BuildTime = GetCurrentTimeString()
|
||||||
GitCommit = "unknown"
|
GitCommit = "unknown"
|
||||||
GitBranch = "unknown"
|
GitBranch = "unknown"
|
||||||
)
|
)
|
||||||
|
|
||||||
// getVersionFromFile 从VERSION文件读取版本号
|
// getVersionFromFile 从VERSION文件读取版本号(备用方案)
|
||||||
func getVersionFromFile() string {
|
func getVersionFromFile() string {
|
||||||
data, err := os.ReadFile("VERSION")
|
data, err := os.ReadFile("VERSION")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -42,11 +43,29 @@ func getVersionFromFile() string {
|
|||||||
func GetVersionInfo() *VersionInfo {
|
func GetVersionInfo() *VersionInfo {
|
||||||
buildTime, _ := ParseTime(BuildTime)
|
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{
|
return &VersionInfo{
|
||||||
Version: Version,
|
Version: version,
|
||||||
BuildTime: buildTime,
|
BuildTime: buildTime,
|
||||||
GitCommit: GitCommit,
|
GitCommit: gitCommit,
|
||||||
GitBranch: GitBranch,
|
GitBranch: gitBranch,
|
||||||
GoVersion: runtime.Version(),
|
GoVersion: runtime.Version(),
|
||||||
NodeVersion: getNodeVersion(),
|
NodeVersion: getNodeVersion(),
|
||||||
Platform: runtime.GOOS,
|
Platform: runtime.GOOS,
|
||||||
|
|||||||
@@ -32,4 +32,14 @@
|
|||||||
.resource-card {
|
.resource-card {
|
||||||
@apply bg-white rounded-lg shadow-md border border-gray-200 p-4 hover:shadow-lg transition-shadow duration-200;
|
@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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
7
web/components.d.ts
vendored
7
web/components.d.ts
vendored
@@ -23,15 +23,18 @@ declare module 'vue' {
|
|||||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
NEmpty: typeof import('naive-ui')['NEmpty']
|
||||||
NForm: typeof import('naive-ui')['NForm']
|
NForm: typeof import('naive-ui')['NForm']
|
||||||
NFormItem: typeof import('naive-ui')['NFormItem']
|
NFormItem: typeof import('naive-ui')['NFormItem']
|
||||||
|
NImage: typeof import('naive-ui')['NImage']
|
||||||
|
NImageGroup: typeof import('naive-ui')['NImageGroup']
|
||||||
NInput: typeof import('naive-ui')['NInput']
|
NInput: typeof import('naive-ui')['NInput']
|
||||||
NInputNumber: typeof import('naive-ui')['NInputNumber']
|
|
||||||
NList: typeof import('naive-ui')['NList']
|
NList: typeof import('naive-ui')['NList']
|
||||||
NListItem: typeof import('naive-ui')['NListItem']
|
NListItem: typeof import('naive-ui')['NListItem']
|
||||||
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
||||||
NModal: typeof import('naive-ui')['NModal']
|
NModal: typeof import('naive-ui')['NModal']
|
||||||
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
||||||
|
NP: typeof import('naive-ui')['NP']
|
||||||
NPagination: typeof import('naive-ui')['NPagination']
|
NPagination: typeof import('naive-ui')['NPagination']
|
||||||
NProgress: typeof import('naive-ui')['NProgress']
|
NProgress: typeof import('naive-ui')['NProgress']
|
||||||
|
NQrCode: typeof import('naive-ui')['NQrCode']
|
||||||
NSelect: typeof import('naive-ui')['NSelect']
|
NSelect: typeof import('naive-ui')['NSelect']
|
||||||
NSpace: typeof import('naive-ui')['NSpace']
|
NSpace: typeof import('naive-ui')['NSpace']
|
||||||
NSpin: typeof import('naive-ui')['NSpin']
|
NSpin: typeof import('naive-ui')['NSpin']
|
||||||
@@ -41,6 +44,8 @@ declare module 'vue' {
|
|||||||
NTag: typeof import('naive-ui')['NTag']
|
NTag: typeof import('naive-ui')['NTag']
|
||||||
NText: typeof import('naive-ui')['NText']
|
NText: typeof import('naive-ui')['NText']
|
||||||
NThing: typeof import('naive-ui')['NThing']
|
NThing: typeof import('naive-ui')['NThing']
|
||||||
|
NUpload: typeof import('naive-ui')['NUpload']
|
||||||
|
NUploadDragger: typeof import('naive-ui')['NUploadDragger']
|
||||||
NVirtualList: typeof import('naive-ui')['NVirtualList']
|
NVirtualList: typeof import('naive-ui')['NVirtualList']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
|||||||
@@ -23,12 +23,12 @@
|
|||||||
</div>
|
</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>
|
<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">
|
<span class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
自动处理{{ autoProcessEnabled ? '已开启' : '已关闭' }}
|
自动处理{{ autoProcessEnabled ? '已开启' : '已关闭' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</NuxtLink>
|
||||||
|
|
||||||
<!-- 自动转存状态 -->
|
<!-- 自动转存状态 -->
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
|
|||||||
@@ -12,6 +12,25 @@
|
|||||||
<span>{{ dashboardItem.label }}</span>
|
<span>{{ dashboardItem.label }}</span>
|
||||||
</NuxtLink>
|
</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="mt-6">
|
||||||
<div class="px-4 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<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'
|
active: (route: any) => route.path === '/admin'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 运营管理分组
|
// 数据管理分组
|
||||||
const operationItems = ref([
|
const dataItems = ref([
|
||||||
{
|
{
|
||||||
to: '/admin/resources',
|
to: '/admin/resources',
|
||||||
label: '资源管理',
|
label: '资源管理',
|
||||||
@@ -108,6 +127,16 @@ const operationItems = ref([
|
|||||||
icon: 'fas fa-tags',
|
icon: 'fas fa-tags',
|
||||||
active: (route: any) => route.path.startsWith('/admin/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',
|
to: '/admin/platforms',
|
||||||
label: '平台管理',
|
label: '平台管理',
|
||||||
@@ -126,6 +155,12 @@ const operationItems = ref([
|
|||||||
icon: 'fas fa-film',
|
icon: 'fas fa-film',
|
||||||
active: (route: any) => route.path.startsWith('/admin/hot-dramas')
|
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',
|
to: '/admin/seo',
|
||||||
label: 'SEO',
|
label: 'SEO',
|
||||||
@@ -175,6 +210,12 @@ const systemItems = ref([
|
|||||||
label: '系统配置',
|
label: '系统配置',
|
||||||
icon: 'fas fa-cog',
|
icon: 'fas fa-cog',
|
||||||
active: (route: any) => route.path.startsWith('/admin/system-config')
|
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>
|
</script>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted, h } from 'vue'
|
import { ref, reactive, computed, onMounted, h } from 'vue'
|
||||||
import { useResourceApi } from '~/composables/useApi'
|
import { useResourceApi, usePanApi } from '~/composables/useApi'
|
||||||
import { useMessage } from 'naive-ui'
|
import { useMessage } from 'naive-ui'
|
||||||
|
|
||||||
// 消息提示
|
// 消息提示
|
||||||
@@ -76,6 +76,26 @@ const selectedTag = ref(null)
|
|||||||
|
|
||||||
// API实例
|
// API实例
|
||||||
const resourceApi = useResourceApi()
|
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({
|
const pagination = reactive({
|
||||||
@@ -109,18 +129,6 @@ const columns: any[] = [
|
|||||||
key: 'category_name',
|
key: 'category_name',
|
||||||
width: 80
|
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: '转存链接',
|
title: '转存链接',
|
||||||
key: 'save_url',
|
key: 'save_url',
|
||||||
@@ -143,41 +151,9 @@ const columns: any[] = [
|
|||||||
render: (row: any) => {
|
render: (row: any) => {
|
||||||
return new Date(row.updated_at).toLocaleDateString()
|
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 () => {
|
const fetchTransferredResources = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -202,10 +178,19 @@ const fetchTransferredResources = async () => {
|
|||||||
console.log('结果结构:', Object.keys(result || {}))
|
console.log('结果结构:', Object.keys(result || {}))
|
||||||
|
|
||||||
if (result && result.data) {
|
if (result && result.data) {
|
||||||
console.log('使用 resources 格式,数量:', result.data.length)
|
// 处理嵌套的data结构:{data: {data: [...], total: ...}}
|
||||||
resources.value = result.data
|
if (result.data.data && Array.isArray(result.data.data)) {
|
||||||
total.value = result.total || 0
|
console.log('使用嵌套data格式,数量:', result.data.data.length)
|
||||||
pagination.itemCount = result.total || 0
|
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)) {
|
} else if (Array.isArray(result)) {
|
||||||
console.log('使用数组格式,数量:', result.length)
|
console.log('使用数组格式,数量:', result.length)
|
||||||
resources.value = result
|
resources.value = result
|
||||||
@@ -257,23 +242,6 @@ const handlePageSizeChange = (size: number) => {
|
|||||||
fetchTransferredResources()
|
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(() => {
|
onMounted(() => {
|
||||||
fetchTransferredResources()
|
fetchTransferredResources()
|
||||||
|
|||||||
@@ -135,10 +135,6 @@
|
|||||||
<i class="fas fa-folder mr-1"></i>
|
<i class="fas fa-folder mr-1"></i>
|
||||||
{{ item.category_name || '未分类' }}
|
{{ item.category_name || '未分类' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="flex items-center">
|
|
||||||
<i class="fas fa-cloud mr-1"></i>
|
|
||||||
夸克网盘
|
|
||||||
</span>
|
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<i class="fas fa-eye mr-1"></i>
|
<i class="fas fa-eye mr-1"></i>
|
||||||
{{ item.view_count || 0 }} 次浏览
|
{{ item.view_count || 0 }} 次浏览
|
||||||
@@ -296,9 +292,12 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
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'
|
import { useMessage } from 'naive-ui'
|
||||||
|
|
||||||
|
// 消息提示
|
||||||
|
const $message = useMessage()
|
||||||
|
|
||||||
// 数据状态
|
// 数据状态
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const resources = ref([])
|
const resources = ref([])
|
||||||
@@ -339,7 +338,10 @@ const categoryApi = useCategoryApi()
|
|||||||
const tagApi = useTagApi()
|
const tagApi = useTagApi()
|
||||||
const cksApi = useCksApi()
|
const cksApi = useCksApi()
|
||||||
const taskApi = useTaskApi()
|
const taskApi = useTaskApi()
|
||||||
const message = useMessage()
|
const panApi = usePanApi()
|
||||||
|
|
||||||
|
// 获取平台数据
|
||||||
|
const { data: platformsData } = await useAsyncData('untransferredPlatforms', () => panApi.getPans())
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const isAllSelected = computed(() => {
|
const isAllSelected = computed(() => {
|
||||||
@@ -380,8 +382,15 @@ const fetchUntransferredResources = async () => {
|
|||||||
console.log('未转存资源结果:', result)
|
console.log('未转存资源结果:', result)
|
||||||
|
|
||||||
if (result && result.data) {
|
if (result && result.data) {
|
||||||
resources.value = result.data
|
// 处理嵌套的data结构:{data: {data: [...], total: ...}}
|
||||||
total.value = result.total || 0
|
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)) {
|
} else if (Array.isArray(result)) {
|
||||||
resources.value = result
|
resources.value = result
|
||||||
total.value = result.length
|
total.value = result.length
|
||||||
@@ -445,7 +454,7 @@ const getAccountOptions = async () => {
|
|||||||
}))
|
}))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取网盘账号选项失败:', error)
|
console.error('获取网盘账号选项失败:', error)
|
||||||
message.error('获取网盘账号失败')
|
$message.error('获取网盘账号失败')
|
||||||
} finally {
|
} finally {
|
||||||
accountsLoading.value = false
|
accountsLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -516,7 +525,7 @@ const toggleResourceSelection = (id: number, checked: boolean) => {
|
|||||||
// 批量转存
|
// 批量转存
|
||||||
const handleBatchTransfer = async () => {
|
const handleBatchTransfer = async () => {
|
||||||
if (selectedResources.value.length === 0) {
|
if (selectedResources.value.length === 0) {
|
||||||
message.warning('请选择要转存的资源')
|
$message.warning('请选择要转存的资源')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,6 +552,16 @@ const getStatusText = (resource: any) => {
|
|||||||
return '待验证'
|
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) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleDateString()
|
return new Date(dateString).toLocaleDateString()
|
||||||
@@ -551,7 +570,7 @@ const formatDate = (dateString: string) => {
|
|||||||
// 确认批量转存
|
// 确认批量转存
|
||||||
const confirmBatchTransfer = async () => {
|
const confirmBatchTransfer = async () => {
|
||||||
if (selectedAccounts.value.length === 0) {
|
if (selectedAccounts.value.length === 0) {
|
||||||
message.warning('请选择至少一个网盘账号')
|
$message.warning('请选择至少一个网盘账号')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,7 +591,7 @@ const confirmBatchTransfer = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await taskApi.createBatchTransferTask(taskData) as any
|
const response = await taskApi.createBatchTransferTask(taskData) as any
|
||||||
message.success(`批量转存任务已创建,共 ${selectedItems.length} 个资源`)
|
$message.success(`批量转存任务已创建,共 ${selectedItems.length} 个资源`)
|
||||||
|
|
||||||
// 关闭模态框
|
// 关闭模态框
|
||||||
showAccountSelectionModal.value = false
|
showAccountSelectionModal.value = false
|
||||||
@@ -583,7 +602,7 @@ const confirmBatchTransfer = async () => {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('创建批量转存任务失败:', error)
|
console.error('创建批量转存任务失败:', error)
|
||||||
message.error('创建批量转存任务失败')
|
$message.error('创建批量转存任务失败')
|
||||||
} finally {
|
} finally {
|
||||||
batchTransferring.value = false
|
batchTransferring.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,15 +21,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useApiFetch } from '~/composables/useApiFetch'
|
import { useApiFetch } from '~/composables/useApiFetch'
|
||||||
import { parseApiResponse } from '~/composables/useApi'
|
import { parseApiResponse } from '~/composables/useApi'
|
||||||
|
|
||||||
// 使用版本信息组合式函数
|
// 使用版本信息组合式函数
|
||||||
const { versionInfo, fetchVersionInfo } = useVersion()
|
const { versionInfo, fetchVersionInfo } = useVersion()
|
||||||
|
import { useSystemConfigStore } from '~/stores/systemConfig'
|
||||||
// 获取系统配置
|
const systemConfigStore = useSystemConfigStore()
|
||||||
const { data: systemConfigData } = await useAsyncData('systemConfig',
|
await systemConfigStore.initConfig(false, false)
|
||||||
() => useApiFetch('/system/config').then(parseApiResponse)
|
const systemConfig = computed(() => systemConfigStore.config)
|
||||||
)
|
console.log(systemConfig.value)
|
||||||
|
|
||||||
const systemConfig = computed(() => (systemConfigData.value as any) || { copyright: '© 2025 老九网盘资源数据库 By 老九' })
|
|
||||||
|
|
||||||
// 组件挂载时获取版本信息
|
// 组件挂载时获取版本信息
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
1
web/components/FileSelector.vue
Normal file
1
web/components/FileSelector.vue
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<template>
|
||||||
367
web/components/FileUpload.vue
Normal file
367
web/components/FileUpload.vue
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
<template>
|
||||||
|
<div class="file-upload-container">
|
||||||
|
<n-upload
|
||||||
|
multiple
|
||||||
|
directory-dnd
|
||||||
|
:custom-request="customRequest"
|
||||||
|
:on-before-upload="handleBeforeUpload"
|
||||||
|
:on-finish="handleUploadFinish"
|
||||||
|
:on-error="handleUploadError"
|
||||||
|
:on-remove="handleFileRemove"
|
||||||
|
:max="5"
|
||||||
|
ref="uploadRef"
|
||||||
|
>
|
||||||
|
<n-upload-dragger>
|
||||||
|
<div style="margin-bottom: 12px">
|
||||||
|
<i class="fas fa-cloud-upload-alt text-4xl text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
<n-text style="font-size: 16px">
|
||||||
|
点击或者拖动文件到该区域来上传
|
||||||
|
</n-text>
|
||||||
|
<n-p depth="3" style="margin: 8px 0 0 0">
|
||||||
|
支持极速上传,相同文件将直接返回已上传的文件信息
|
||||||
|
</n-p>
|
||||||
|
</n-upload-dragger>
|
||||||
|
</n-upload>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useMessage } from 'naive-ui'
|
||||||
|
import { useFileApi } from '~/composables/useFileApi'
|
||||||
|
|
||||||
|
interface FileItem {
|
||||||
|
id: number
|
||||||
|
original_name: string
|
||||||
|
file_name: string
|
||||||
|
file_path: string
|
||||||
|
file_size: number
|
||||||
|
file_type: string
|
||||||
|
mime_type: string
|
||||||
|
file_hash: string
|
||||||
|
access_url: string
|
||||||
|
user_id: number
|
||||||
|
user: string
|
||||||
|
status: string
|
||||||
|
is_public: boolean
|
||||||
|
is_deleted: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadFileInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
status: 'pending' | 'uploading' | 'finished' | 'error' | 'removed'
|
||||||
|
url?: string
|
||||||
|
file?: File
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const fileApi = useFileApi()
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const uploadRef = ref()
|
||||||
|
const fileList = ref<FileItem[]>([])
|
||||||
|
const isPublic = ref(true) // 默认公开
|
||||||
|
const maxFiles = ref(10)
|
||||||
|
const maxFileSize = ref(5 * 1024 * 1024) // 5MB
|
||||||
|
const acceptTypes = ref('image/*')
|
||||||
|
|
||||||
|
// 添加状态标记:用于跟踪已上传的文件
|
||||||
|
const uploadedFiles = ref<Map<string, boolean>>(new Map()) // 文件哈希 -> 是否已上传
|
||||||
|
const uploadingFiles = ref<Set<string>>(new Set()) // 正在上传的文件哈希
|
||||||
|
|
||||||
|
// 计算文件SHA256哈希值
|
||||||
|
const calculateFileHash = async (file: File): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const arrayBuffer = e.target?.result as ArrayBuffer
|
||||||
|
crypto.subtle.digest('SHA-256', arrayBuffer).then(hashBuffer => {
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||||
|
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||||
|
resolve(hashHex)
|
||||||
|
}).catch(reject)
|
||||||
|
} catch (error) {
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.onerror = reject
|
||||||
|
reader.readAsArrayBuffer(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成文件哈希值(基于文件名、大小和修改时间,用于前端去重)
|
||||||
|
const generateFileHash = (file: File): string => {
|
||||||
|
return `${file.name}_${file.size}_${file.lastModified}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件是否已经上传过
|
||||||
|
const isFileAlreadyUploaded = (file: File): boolean => {
|
||||||
|
const fileHash = generateFileHash(file)
|
||||||
|
return uploadedFiles.value.has(fileHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件是否正在上传
|
||||||
|
const isFileUploading = (file: File): boolean => {
|
||||||
|
const fileHash = generateFileHash(file)
|
||||||
|
return uploadingFiles.value.has(fileHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记文件为已上传
|
||||||
|
const markFileAsUploaded = (file: File) => {
|
||||||
|
const fileHash = generateFileHash(file)
|
||||||
|
uploadedFiles.value.set(fileHash, true)
|
||||||
|
uploadingFiles.value.delete(fileHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记文件为正在上传
|
||||||
|
const markFileAsUploading = (file: File) => {
|
||||||
|
const fileHash = generateFileHash(file)
|
||||||
|
uploadingFiles.value.add(fileHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记文件上传失败
|
||||||
|
const markFileAsFailed = (file: File) => {
|
||||||
|
const fileHash = generateFileHash(file)
|
||||||
|
uploadingFiles.value.delete(fileHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义上传请求
|
||||||
|
const customRequest = async (options: any) => {
|
||||||
|
const { file, onProgress, onSuccess, onError } = options
|
||||||
|
|
||||||
|
// 检查文件是否已经上传过
|
||||||
|
if (isFileAlreadyUploaded(file.file)) {
|
||||||
|
message.warning(`${file.name} 已经上传过了,跳过重复上传`)
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess({ message: '文件已存在,跳过上传' })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件是否正在上传
|
||||||
|
if (isFileUploading(file.file)) {
|
||||||
|
message.warning(`${file.name} 正在上传中,请稍候`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记文件为正在上传
|
||||||
|
markFileAsUploading(file.file)
|
||||||
|
|
||||||
|
console.log('开始上传文件:', file.name, file.file)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 计算文件哈希值
|
||||||
|
const fileHash = await calculateFileHash(file.file)
|
||||||
|
console.log('文件哈希值:', fileHash)
|
||||||
|
|
||||||
|
// 创建FormData
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file.file)
|
||||||
|
formData.append('is_public', isPublic.value.toString())
|
||||||
|
formData.append('file_hash', fileHash)
|
||||||
|
|
||||||
|
// 调用统一的API接口
|
||||||
|
const response = await fileApi.uploadFile(formData)
|
||||||
|
|
||||||
|
console.log('文件上传成功:', file.name, response)
|
||||||
|
|
||||||
|
// 标记文件为已上传
|
||||||
|
markFileAsUploaded(file.file)
|
||||||
|
|
||||||
|
// 检查是否为重复文件
|
||||||
|
if (response.data && response.data.is_duplicate) {
|
||||||
|
message.success(`${file.name} 极速上传成功(文件已存在)`)
|
||||||
|
} else {
|
||||||
|
message.success(`${file.name} 上传成功`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess(response)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('文件上传失败:', file.name, error)
|
||||||
|
// 标记文件上传失败
|
||||||
|
markFileAsFailed(file.file)
|
||||||
|
if (onError) {
|
||||||
|
onError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认文件列表(从props传入)
|
||||||
|
const defaultFileList = ref<UploadFileInfo[]>([])
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const handleBeforeUpload = (data: { file: Required<UploadFileInfo> }) => {
|
||||||
|
const { file } = data
|
||||||
|
|
||||||
|
// 检查文件是否已经上传过
|
||||||
|
if (file.file && isFileAlreadyUploaded(file.file)) {
|
||||||
|
//message.warning(`${file.name} 已经上传过了,请勿重复上传`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件是否正在上传
|
||||||
|
if (file.file && isFileUploading(file.file)) {
|
||||||
|
message.warning(`${file.name} 正在上传中,请稍候`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件大小
|
||||||
|
if (file.file && file.file.size > maxFileSize.value) {
|
||||||
|
message.error(`文件大小不能超过 ${formatFileSize(maxFileSize.value)}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件类型
|
||||||
|
if (file.file) {
|
||||||
|
const fileName = file.file.name.toLowerCase()
|
||||||
|
const acceptedTypes = acceptTypes.value.split(',')
|
||||||
|
const isAccepted = acceptedTypes.some(type => {
|
||||||
|
if (type === 'image/*') {
|
||||||
|
return file.file!.type.startsWith('image/')
|
||||||
|
}
|
||||||
|
if (type.startsWith('.')) {
|
||||||
|
return fileName.endsWith(type)
|
||||||
|
}
|
||||||
|
return file.file!.type === type
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isAccepted) {
|
||||||
|
message.error('只支持图片格式文件')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUploadFinish = (data: { file: Required<UploadFileInfo> }) => {
|
||||||
|
const { file } = data
|
||||||
|
|
||||||
|
if (file.status === 'finished') {
|
||||||
|
// 确保文件被标记为已上传
|
||||||
|
if (file.file) {
|
||||||
|
markFileAsUploaded(file.file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUploadError = (data: { file: Required<UploadFileInfo> }) => {
|
||||||
|
const { file } = data
|
||||||
|
message.error(`${file.name} 上传失败`)
|
||||||
|
// 标记文件上传失败
|
||||||
|
if (file.file) {
|
||||||
|
markFileAsFailed(file.file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileRemove = (data: { file: Required<UploadFileInfo> }) => {
|
||||||
|
const { file } = data
|
||||||
|
message.info(`已移除 ${file.name}`)
|
||||||
|
// 从上传状态中移除文件
|
||||||
|
if (file.file) {
|
||||||
|
const fileHash = generateFileHash(file.file)
|
||||||
|
uploadingFiles.value.delete(fileHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFileList = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fileApi.getFileList({
|
||||||
|
page: 1,
|
||||||
|
page_size: 50
|
||||||
|
})
|
||||||
|
fileList.value = response.data.files || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载文件列表失败:', error)
|
||||||
|
message.error('加载文件列表失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyFileUrl = async (file: FileItem) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(file.access_url)
|
||||||
|
message.success('文件链接已复制到剪贴板')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('复制失败:', error)
|
||||||
|
message.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openFile = (file: FileItem) => {
|
||||||
|
window.open(file.access_url, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteFile = async (file: FileItem) => {
|
||||||
|
try {
|
||||||
|
await fileApi.deleteFiles([file.id])
|
||||||
|
message.success('文件删除成功')
|
||||||
|
loadFileList()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除文件失败:', error)
|
||||||
|
message.error('删除文件失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFileIconClass = (fileType: string) => {
|
||||||
|
const iconMap: Record<string, string> = {
|
||||||
|
'image': 'fas fa-image text-blue-500',
|
||||||
|
'document': 'fas fa-file-alt text-green-500',
|
||||||
|
'video': 'fas fa-video text-red-500',
|
||||||
|
'audio': 'fas fa-music text-purple-500',
|
||||||
|
'archive': 'fas fa-archive text-orange-500',
|
||||||
|
'other': 'fas fa-file text-gray-500'
|
||||||
|
}
|
||||||
|
return iconMap[fileType] || iconMap.other
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFileDescription = (file: FileItem) => {
|
||||||
|
return `${formatFileSize(file.file_size)} | ${file.file_type} | ${file.created_at}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生命周期
|
||||||
|
// 不在组件挂载时加载文件列表,由父组件管理
|
||||||
|
|
||||||
|
// 重置上传组件状态
|
||||||
|
const resetUpload = () => {
|
||||||
|
if (uploadRef.value) {
|
||||||
|
uploadRef.value.clear()
|
||||||
|
}
|
||||||
|
// 清空上传状态
|
||||||
|
uploadedFiles.value.clear()
|
||||||
|
uploadingFiles.value.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空已上传文件状态(用于重新开始上传)
|
||||||
|
const clearUploadedFiles = () => {
|
||||||
|
uploadedFiles.value.clear()
|
||||||
|
uploadingFiles.value.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露方法给父组件
|
||||||
|
defineExpose({
|
||||||
|
loadFileList,
|
||||||
|
fileList,
|
||||||
|
resetUpload,
|
||||||
|
clearUploadedFiles,
|
||||||
|
uploadedFiles,
|
||||||
|
uploadingFiles
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
||||||
22
web/components/ProxyImage.vue
Normal file
22
web/components/ProxyImage.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<template>
|
||||||
|
<n-image
|
||||||
|
:src="proxyUrl"
|
||||||
|
v-bind="$attrs"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useImageUrl } from '~/composables/useImageUrl'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
src: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const { getImageUrl } = useImageUrl()
|
||||||
|
|
||||||
|
const proxyUrl = computed(() => {
|
||||||
|
return getImageUrl(props.src)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,153 +1,161 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="visible" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" @click="closeModal">
|
<n-modal :show="visible" @update:show="closeModal" preset="card" title="链接二维码" class="max-w-sm">
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-sm w-full mx-4" @click.stop>
|
<div class="text-center">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<!-- 加载状态 -->
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<div v-if="loading" class="space-y-4">
|
||||||
{{ isQuarkLink ? '夸克网盘链接' : '链接二维码' }}
|
<div class="flex flex-col items-center justify-center py-8">
|
||||||
</h3>
|
<n-spin size="large" />
|
||||||
<button
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-4">正在获取链接...</p>
|
||||||
@click="closeModal"
|
</div>
|
||||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
|
||||||
>
|
|
||||||
<i class="fas fa-times text-xl"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-center">
|
<!-- 违禁词禁止访问状态 -->
|
||||||
<!-- 加载状态 -->
|
<div v-else-if="forbidden" class="space-y-4">
|
||||||
<div v-if="loading" class="space-y-4">
|
<div class="flex flex-col items-center justify-center py-4">
|
||||||
<div class="flex flex-col items-center justify-center py-8">
|
<!-- 使用SVG图标 -->
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
|
<div class="mb-6">
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">正在获取链接...</p>
|
<img src="/assets/svg/forbidden.svg" alt="禁止访问" class="w-48 h-48" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<h3 class="text-xl font-bold text-red-600 dark:text-red-400 mb-2">禁止访问</h3>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-4">该资源包含违禁内容,无法访问</p>
|
||||||
<!-- 错误状态 -->
|
<n-button @click="closeModal" class="bg-red-500 hover:bg-red-600 text-white">
|
||||||
<div v-else-if="error" class="space-y-4">
|
我知道了
|
||||||
<div class="bg-red-50 dark:bg-red-900/20 p-4 rounded-lg">
|
</n-button>
|
||||||
<div class="flex items-center">
|
</div>
|
||||||
<i class="fas fa-exclamation-triangle text-red-500 mr-2"></i>
|
</div>
|
||||||
<p class="text-sm text-red-700 dark:text-red-300">{{ error }}</p>
|
|
||||||
</div>
|
<!-- 错误状态 -->
|
||||||
</div>
|
<div v-else-if="error" class="space-y-4">
|
||||||
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-lg">
|
<n-alert type="error" :show-icon="false">
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300 break-all">{{ url }}</p>
|
<template #icon>
|
||||||
</div>
|
<i class="fas fa-exclamation-triangle text-red-500 mr-2"></i>
|
||||||
<div class="flex gap-2">
|
</template>
|
||||||
<button
|
{{ error }}
|
||||||
@click="openLink"
|
</n-alert>
|
||||||
class="flex-1 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors text-sm flex items-center justify-center gap-2"
|
<n-card size="small">
|
||||||
>
|
<p class="text-sm text-gray-700 dark:text-gray-300 break-all">{{ url }}</p>
|
||||||
<i class="fas fa-external-link-alt"></i> 跳转
|
</n-card>
|
||||||
</button>
|
<div class="flex gap-2">
|
||||||
<button
|
<n-button type="primary" @click="openLink" class="flex-1">
|
||||||
@click="copyUrl"
|
<template #icon>
|
||||||
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-external-link-alt"></i>
|
||||||
>
|
</template>
|
||||||
<i class="fas fa-copy"></i> 复制
|
跳转
|
||||||
</button>
|
</n-button>
|
||||||
</div>
|
<n-button type="success" @click="copyUrl" class="flex-1">
|
||||||
</div>
|
<template #icon>
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
<!-- 正常显示 -->
|
</template>
|
||||||
<div v-else>
|
复制
|
||||||
<!-- 移动端:所有链接都显示链接文本和操作按钮 -->
|
</n-button>
|
||||||
<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>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</n-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import QRCode from 'qrcode'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
visible: boolean
|
visible: boolean
|
||||||
save_url?: string
|
save_url?: string
|
||||||
@@ -157,18 +165,22 @@ interface Props {
|
|||||||
platform?: string
|
platform?: string
|
||||||
message?: string
|
message?: string
|
||||||
error?: string
|
error?: string
|
||||||
|
forbidden?: boolean
|
||||||
|
forbidden_words?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'close'): void
|
(e: 'close'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
url: ''
|
url: ''
|
||||||
})
|
})
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
const qrCanvas = ref<HTMLCanvasElement>()
|
const size = ref(180)
|
||||||
|
const color = ref('#409eff')
|
||||||
|
const backgroundColor = ref('#F5F5F5')
|
||||||
|
|
||||||
// 检测是否为移动设备
|
// 检测是否为移动设备
|
||||||
const isMobile = ref(false)
|
const isMobile = ref(false)
|
||||||
@@ -185,24 +197,6 @@ const isQuarkLink = computed(() => {
|
|||||||
return (props.url.includes('pan.quark.cn') || props.url.includes('quark.cn')) && !!props.save_url
|
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 = () => {
|
const closeModal = () => {
|
||||||
emit('close')
|
emit('close')
|
||||||
@@ -237,44 +231,31 @@ const openLink = () => {
|
|||||||
|
|
||||||
// 下载二维码
|
// 下载二维码
|
||||||
const downloadQrCode = () => {
|
const downloadQrCode = () => {
|
||||||
if (!qrCanvas.value) return
|
// 使用 Naive UI 的二维码组件,需要获取 DOM 元素
|
||||||
|
const qrElement = document.querySelector('.n-qr-code canvas') as HTMLCanvasElement
|
||||||
|
if (!qrElement) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.download = 'qrcode.png'
|
link.download = 'qrcode.png'
|
||||||
link.href = qrCanvas.value.toDataURL()
|
link.href = qrElement.toDataURL()
|
||||||
link.click()
|
link.click()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('下载失败:', error)
|
console.error('下载失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听visible变化,生成二维码
|
// 监听visible变化
|
||||||
watch(() => props.visible, (newVisible) => {
|
watch(() => props.visible, (newVisible) => {
|
||||||
if (newVisible) {
|
if (newVisible) {
|
||||||
detectDevice()
|
detectDevice()
|
||||||
nextTick(() => {
|
|
||||||
// PC端生成二维码(包括夸克链接)
|
|
||||||
if (!isMobile.value) {
|
|
||||||
generateQrCode()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 监听url变化,重新生成二维码
|
|
||||||
watch(() => props.url, () => {
|
|
||||||
if (props.visible && !isMobile.value) {
|
|
||||||
nextTick(() => {
|
|
||||||
generateQrCode()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 可以添加一些动画效果 */
|
/* 可以添加一些动画效果 */
|
||||||
.fixed {
|
.n-modal {
|
||||||
animation: fadeIn 0.2s ease-out;
|
animation: fadeIn 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,4 +267,13 @@ watch(() => props.url, () => {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.qr-container {
|
||||||
|
height: 200px;
|
||||||
|
width: 200px;
|
||||||
|
background-color: #F5F5F5;
|
||||||
|
}
|
||||||
|
.n-qr-code {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -28,7 +28,7 @@ export const parseApiResponse = <T>(response: any): T => {
|
|||||||
if (response.success) {
|
if (response.success) {
|
||||||
// 特殊处理登录接口,直接返回data部分(包含token和user)
|
// 特殊处理登录接口,直接返回data部分(包含token和user)
|
||||||
if (response.data && response.data.token && response.data.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
|
return response.data
|
||||||
}
|
}
|
||||||
// 特殊处理删除操作响应,直接返回data部分
|
// 特殊处理删除操作响应,直接返回data部分
|
||||||
@@ -148,11 +148,31 @@ export const useStatsApi = () => {
|
|||||||
return { getStats }
|
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 = () => {
|
export const useSystemConfigApi = () => {
|
||||||
const getSystemConfig = () => useApiFetch('/system/config').then(parseApiResponse)
|
const getSystemConfig = () => useApiFetch('/system/config').then(parseApiResponse)
|
||||||
const updateSystemConfig = (data: any) => useApiFetch('/system/config', { method: 'POST', body: data }).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)
|
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 = () => {
|
export const useHotDramaApi = () => {
|
||||||
@@ -205,4 +225,34 @@ function log(...args: any[]) {
|
|||||||
if (process.env.NODE_ENV !== 'production') {
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
console.log(...args)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -22,11 +22,11 @@ export function useApiFetch<T = any>(
|
|||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
onResponse({ response }) {
|
onResponse({ response }) {
|
||||||
console.log('API响应:', {
|
// console.log('API响应:', {
|
||||||
status: response.status,
|
// status: response.status,
|
||||||
data: response._data,
|
// data: response._data,
|
||||||
url: url
|
// url: url
|
||||||
})
|
// })
|
||||||
|
|
||||||
// 处理401认证错误
|
// 处理401认证错误
|
||||||
if (response.status === 401 ||
|
if (response.status === 401 ||
|
||||||
|
|||||||
307
web/composables/useConfigChangeDetection.ts
Normal file
307
web/composables/useConfigChangeDetection.ts
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
|
export interface ConfigChangeDetectionOptions {
|
||||||
|
// 是否启用自动检测
|
||||||
|
autoDetect?: boolean
|
||||||
|
// 是否在控制台输出调试信息
|
||||||
|
debug?: boolean
|
||||||
|
// 自定义比较函数
|
||||||
|
customCompare?: (key: string, currentValue: any, originalValue: any) => boolean
|
||||||
|
// 配置项映射(前端字段名 -> 后端字段名)
|
||||||
|
fieldMapping?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigSubmitOptions {
|
||||||
|
// 是否只提交改动的字段
|
||||||
|
onlyChanged?: boolean
|
||||||
|
// 是否包含所有配置项(用于后端识别)
|
||||||
|
includeAllFields?: boolean
|
||||||
|
// 自定义提交数据转换
|
||||||
|
transformSubmitData?: (data: any) => any
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useConfigChangeDetection = <T extends Record<string, any>>(
|
||||||
|
options: ConfigChangeDetectionOptions = {}
|
||||||
|
) => {
|
||||||
|
const { autoDetect = true, debug = false, customCompare, fieldMapping = {} } = options
|
||||||
|
|
||||||
|
// 原始配置数据
|
||||||
|
const originalConfig = ref<T>({} as T)
|
||||||
|
|
||||||
|
// 当前配置数据
|
||||||
|
const currentConfig = ref<T>({} as T)
|
||||||
|
|
||||||
|
// 是否已初始化
|
||||||
|
const isInitialized = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置原始配置数据
|
||||||
|
*/
|
||||||
|
const setOriginalConfig = (config: T) => {
|
||||||
|
originalConfig.value = { ...config }
|
||||||
|
currentConfig.value = { ...config }
|
||||||
|
isInitialized.value = true
|
||||||
|
|
||||||
|
if (debug) {
|
||||||
|
console.log('useConfigChangeDetection - 设置原始配置:', config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新当前配置数据
|
||||||
|
*/
|
||||||
|
const updateCurrentConfig = (config: Partial<T>) => {
|
||||||
|
currentConfig.value = { ...currentConfig.value, ...config }
|
||||||
|
|
||||||
|
if (debug) {
|
||||||
|
console.log('useConfigChangeDetection - 更新当前配置:', config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测配置改动
|
||||||
|
*/
|
||||||
|
const getChangedConfig = (): Partial<T> => {
|
||||||
|
if (!isInitialized.value) {
|
||||||
|
if (debug) {
|
||||||
|
console.warn('useConfigChangeDetection - 配置未初始化')
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const changedConfig: Partial<T> = {}
|
||||||
|
|
||||||
|
// 遍历所有配置项
|
||||||
|
for (const key in currentConfig.value) {
|
||||||
|
const currentValue = currentConfig.value[key]
|
||||||
|
const originalValue = originalConfig.value[key]
|
||||||
|
|
||||||
|
// 使用自定义比较函数或默认比较
|
||||||
|
const hasChanged = customCompare
|
||||||
|
? customCompare(key, currentValue, originalValue)
|
||||||
|
: currentValue !== originalValue
|
||||||
|
|
||||||
|
if (hasChanged) {
|
||||||
|
changedConfig[key as keyof T] = currentValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debug) {
|
||||||
|
console.log('useConfigChangeDetection - 检测到的改动:', changedConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
return changedConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有改动
|
||||||
|
*/
|
||||||
|
const hasChanges = (): boolean => {
|
||||||
|
const changedConfig = getChangedConfig()
|
||||||
|
return Object.keys(changedConfig).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取改动的字段列表
|
||||||
|
*/
|
||||||
|
const getChangedFields = (): string[] => {
|
||||||
|
const changedConfig = getChangedConfig()
|
||||||
|
return Object.keys(changedConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取改动的详细信息
|
||||||
|
*/
|
||||||
|
const getChangedDetails = (): Array<{
|
||||||
|
key: string
|
||||||
|
originalValue: any
|
||||||
|
currentValue: any
|
||||||
|
}> => {
|
||||||
|
if (!isInitialized.value) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const details: Array<{
|
||||||
|
key: string
|
||||||
|
originalValue: any
|
||||||
|
currentValue: any
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
for (const key in currentConfig.value) {
|
||||||
|
const currentValue = currentConfig.value[key]
|
||||||
|
const originalValue = originalConfig.value[key]
|
||||||
|
|
||||||
|
const hasChanged = customCompare
|
||||||
|
? customCompare(key, currentValue, originalValue)
|
||||||
|
: currentValue !== originalValue
|
||||||
|
|
||||||
|
if (hasChanged) {
|
||||||
|
details.push({
|
||||||
|
key,
|
||||||
|
originalValue,
|
||||||
|
currentValue
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return details
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置为原始配置
|
||||||
|
*/
|
||||||
|
const resetToOriginal = () => {
|
||||||
|
currentConfig.value = { ...originalConfig.value }
|
||||||
|
|
||||||
|
if (debug) {
|
||||||
|
console.log('useConfigChangeDetection - 重置为原始配置')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新原始配置(通常在保存成功后调用)
|
||||||
|
*/
|
||||||
|
const updateOriginalConfig = () => {
|
||||||
|
originalConfig.value = { ...currentConfig.value }
|
||||||
|
|
||||||
|
if (debug) {
|
||||||
|
console.log('useConfigChangeDetection - 更新原始配置')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取配置快照
|
||||||
|
*/
|
||||||
|
const getSnapshot = () => {
|
||||||
|
return {
|
||||||
|
original: { ...originalConfig.value },
|
||||||
|
current: { ...currentConfig.value },
|
||||||
|
changed: getChangedConfig(),
|
||||||
|
hasChanges: hasChanges()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 准备提交数据
|
||||||
|
*/
|
||||||
|
const prepareSubmitData = (submitOptions: ConfigSubmitOptions = {}): any => {
|
||||||
|
const { onlyChanged = true, includeAllFields = true, transformSubmitData } = submitOptions
|
||||||
|
|
||||||
|
let submitData: any = {}
|
||||||
|
|
||||||
|
if (onlyChanged) {
|
||||||
|
// 只提交改动的字段
|
||||||
|
submitData = getChangedConfig()
|
||||||
|
} else {
|
||||||
|
// 提交所有字段
|
||||||
|
submitData = { ...currentConfig.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用字段映射
|
||||||
|
if (Object.keys(fieldMapping).length > 0) {
|
||||||
|
const mappedData: any = {}
|
||||||
|
for (const [frontendKey, backendKey] of Object.entries(fieldMapping)) {
|
||||||
|
if (submitData[frontendKey] !== undefined) {
|
||||||
|
mappedData[backendKey] = submitData[frontendKey]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
submitData = mappedData
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果包含所有字段,添加未改动的字段(值为undefined,让后端知道这些字段存在但未改动)
|
||||||
|
if (includeAllFields && onlyChanged) {
|
||||||
|
for (const key in originalConfig.value) {
|
||||||
|
if (submitData[key] === undefined) {
|
||||||
|
submitData[key] = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用自定义转换
|
||||||
|
if (transformSubmitData) {
|
||||||
|
submitData = transformSubmitData(submitData)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debug) {
|
||||||
|
console.log('useConfigChangeDetection - 准备提交数据:', submitData)
|
||||||
|
}
|
||||||
|
|
||||||
|
return submitData
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用配置保存函数
|
||||||
|
*/
|
||||||
|
const saveConfig = async (
|
||||||
|
apiFunction: (data: any) => Promise<any>,
|
||||||
|
submitOptions: ConfigSubmitOptions = {},
|
||||||
|
onSuccess?: () => void,
|
||||||
|
onError?: (error: any) => void
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
// 检测是否有改动
|
||||||
|
if (!hasChanges()) {
|
||||||
|
if (debug) {
|
||||||
|
console.log('useConfigChangeDetection - 没有检测到改动,跳过保存')
|
||||||
|
}
|
||||||
|
return { success: true, message: '没有检测到任何改动' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 准备提交数据
|
||||||
|
const submitData = prepareSubmitData(submitOptions)
|
||||||
|
|
||||||
|
if (debug) {
|
||||||
|
console.log('useConfigChangeDetection - 提交数据:', submitData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用API
|
||||||
|
const response = await apiFunction(submitData)
|
||||||
|
|
||||||
|
// 更新原始配置
|
||||||
|
updateOriginalConfig()
|
||||||
|
|
||||||
|
if (debug) {
|
||||||
|
console.log('useConfigChangeDetection - 保存成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用成功回调
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, response }
|
||||||
|
} catch (error) {
|
||||||
|
if (debug) {
|
||||||
|
console.error('useConfigChangeDetection - 保存失败:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用错误回调
|
||||||
|
if (onError) {
|
||||||
|
onError(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 响应式数据
|
||||||
|
originalConfig: originalConfig as Ref<T>,
|
||||||
|
currentConfig: currentConfig as Ref<T>,
|
||||||
|
isInitialized,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
setOriginalConfig,
|
||||||
|
updateCurrentConfig,
|
||||||
|
getChangedConfig,
|
||||||
|
hasChanges,
|
||||||
|
getChangedFields,
|
||||||
|
getChangedDetails,
|
||||||
|
resetToOriginal,
|
||||||
|
updateOriginalConfig,
|
||||||
|
getSnapshot,
|
||||||
|
prepareSubmitData,
|
||||||
|
saveConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
36
web/composables/useFileApi.ts
Normal file
36
web/composables/useFileApi.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { useApiFetch } from './useApiFetch'
|
||||||
|
|
||||||
|
export const useFileApi = () => {
|
||||||
|
const getFileList = (params?: any) => useApiFetch('/files', { params }).then(parseApiResponse)
|
||||||
|
const uploadFile = (data: FormData) => useApiFetch('/files/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: data,
|
||||||
|
headers: {
|
||||||
|
// 不设置Content-Type,让浏览器自动设置multipart/form-data
|
||||||
|
}
|
||||||
|
}).then(parseApiResponse)
|
||||||
|
const deleteFiles = (ids: number[]) => useApiFetch('/files', {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: { ids }
|
||||||
|
}).then(parseApiResponse)
|
||||||
|
const updateFile = (data: any) => useApiFetch('/files', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: data
|
||||||
|
}).then(parseApiResponse)
|
||||||
|
|
||||||
|
return {
|
||||||
|
getFileList,
|
||||||
|
uploadFile,
|
||||||
|
deleteFiles,
|
||||||
|
updateFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析API响应
|
||||||
|
function parseApiResponse(response: any) {
|
||||||
|
if (response.success) {
|
||||||
|
return response
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '请求失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
25
web/composables/useImageUrl.ts
Normal file
25
web/composables/useImageUrl.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export const useImageUrl = () => {
|
||||||
|
const getImageUrl = (url: string) => {
|
||||||
|
if (!url) return ''
|
||||||
|
|
||||||
|
// 如果已经是完整URL,直接返回
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是相对路径,在开发环境中添加后端地址
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
const fullUrl = `http://localhost:8080${url}`
|
||||||
|
// console.log('useImageUrl - 开发环境:', { original: url, processed: fullUrl })
|
||||||
|
return fullUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生产环境中直接返回相对路径(通过Nginx代理)
|
||||||
|
// console.log('useImageUrl - 生产环境:', { original: url, processed: url })
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getImageUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ interface VersionResponse {
|
|||||||
|
|
||||||
export const useVersion = () => {
|
export const useVersion = () => {
|
||||||
const versionInfo = ref<VersionInfo>({
|
const versionInfo = ref<VersionInfo>({
|
||||||
version: '1.1.0',
|
version: '1.2.4',
|
||||||
build_time: '',
|
build_time: '',
|
||||||
git_commit: 'unknown',
|
git_commit: 'unknown',
|
||||||
git_branch: 'unknown',
|
git_branch: 'unknown',
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const adminNewNavigationItems = [
|
|||||||
icon: 'fas fa-database',
|
icon: 'fas fa-database',
|
||||||
to: '/admin/resources',
|
to: '/admin/resources',
|
||||||
active: (route: any) => route.path.startsWith('/admin/resources'),
|
active: (route: any) => route.path.startsWith('/admin/resources'),
|
||||||
group: 'operation'
|
group: 'data'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'ready-resources',
|
key: 'ready-resources',
|
||||||
@@ -34,7 +34,7 @@ export const adminNewNavigationItems = [
|
|||||||
icon: 'fas fa-clock',
|
icon: 'fas fa-clock',
|
||||||
to: '/admin/ready-resources',
|
to: '/admin/ready-resources',
|
||||||
active: (route: any) => route.path.startsWith('/admin/ready-resources'),
|
active: (route: any) => route.path.startsWith('/admin/ready-resources'),
|
||||||
group: 'operation'
|
group: 'data'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'categories',
|
key: 'categories',
|
||||||
@@ -42,7 +42,7 @@ export const adminNewNavigationItems = [
|
|||||||
icon: 'fas fa-folder',
|
icon: 'fas fa-folder',
|
||||||
to: '/admin/categories',
|
to: '/admin/categories',
|
||||||
active: (route: any) => route.path.startsWith('/admin/categories'),
|
active: (route: any) => route.path.startsWith('/admin/categories'),
|
||||||
group: 'operation'
|
group: 'data'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'tags',
|
key: 'tags',
|
||||||
@@ -50,7 +50,7 @@ export const adminNewNavigationItems = [
|
|||||||
icon: 'fas fa-tags',
|
icon: 'fas fa-tags',
|
||||||
to: '/admin/tags',
|
to: '/admin/tags',
|
||||||
active: (route: any) => route.path.startsWith('/admin/tags'),
|
active: (route: any) => route.path.startsWith('/admin/tags'),
|
||||||
group: 'operation'
|
group: 'data'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'platforms',
|
key: 'platforms',
|
||||||
@@ -100,6 +100,14 @@ export const adminNewNavigationItems = [
|
|||||||
active: (route: any) => route.path.startsWith('/admin/data-push'),
|
active: (route: any) => route.path.startsWith('/admin/data-push'),
|
||||||
group: 'operation'
|
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',
|
key: 'bot',
|
||||||
label: '机器人',
|
label: '机器人',
|
||||||
@@ -138,10 +146,11 @@ export const adminNewNavigationItems = [
|
|||||||
key: 'system-config',
|
key: 'system-config',
|
||||||
label: '系统配置',
|
label: '系统配置',
|
||||||
icon: 'fas fa-cog',
|
icon: 'fas fa-cog',
|
||||||
to: '/admin/system-config',
|
to: '/admin/site-config',
|
||||||
active: (route: any) => route.path.startsWith('/admin/system-config'),
|
active: (route: any) => route.path.startsWith('/admin/site-config'),
|
||||||
group: 'system'
|
group: 'system'
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
key: 'version',
|
key: 'version',
|
||||||
label: '版本信息',
|
label: '版本信息',
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-100">
|
|
||||||
<!-- 全局加载状态 -->
|
|
||||||
<div v-if="pageLoading" class="fixed inset-0 bg-gray-900 bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-8 shadow-xl">
|
|
||||||
<div class="flex flex-col items-center space-y-4">
|
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
|
||||||
<div class="text-center">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">正在加载...</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">请稍候,正在初始化管理后台</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 管理页面头部 -->
|
|
||||||
<div class="p-3 sm:p-5">
|
|
||||||
<AdminHeader :title="pageTitle" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 主要内容区域 -->
|
|
||||||
<div class="p-3 sm:p-5">
|
|
||||||
<div class="max-w-7xl mx-auto">
|
|
||||||
<ClientOnly>
|
|
||||||
<n-notification-provider>
|
|
||||||
<n-dialog-provider>
|
|
||||||
<!-- 页面内容插槽 -->
|
|
||||||
<slot />
|
|
||||||
</n-dialog-provider>
|
|
||||||
</n-notification-provider>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 页脚 -->
|
|
||||||
<AppFooter />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useSystemConfigStore } from '~/stores/systemConfig'
|
|
||||||
import { useUserLayout } from '~/composables/useUserLayout'
|
|
||||||
|
|
||||||
// 使用用户布局组合式函数
|
|
||||||
const { checkAuth, checkPermission } = useUserLayout()
|
|
||||||
|
|
||||||
// 页面加载状态
|
|
||||||
const pageLoading = ref(false)
|
|
||||||
|
|
||||||
// 页面标题
|
|
||||||
const route = useRoute()
|
|
||||||
const pageTitle = computed(() => {
|
|
||||||
const titles: Record<string, string> = {
|
|
||||||
'/admin-old': '管理后台',
|
|
||||||
'/admin-old/users': '用户管理',
|
|
||||||
'/admin-old/categories': '分类管理',
|
|
||||||
'/admin-old/tags': '标签管理',
|
|
||||||
'/admin-old/tasks': '任务管理',
|
|
||||||
'/admin-old/system-config': '系统配置',
|
|
||||||
'/admin-old/resources': '资源管理',
|
|
||||||
'/admin-old/cks': '平台账号管理',
|
|
||||||
'/admin-old/ready-resources': '待处理资源',
|
|
||||||
'/admin-old/search-stats': '搜索统计',
|
|
||||||
'/admin-old/hot-dramas': '热播剧管理',
|
|
||||||
'/admin-old/monitor': '系统监控',
|
|
||||||
'/admin-old/add-resource': '添加资源',
|
|
||||||
'/admin-old/api-docs': 'API文档',
|
|
||||||
'/admin-old/version': '版本信息'
|
|
||||||
}
|
|
||||||
return titles[route.path] || '管理后台'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 监听路由变化,显示加载状态
|
|
||||||
watch(() => route.path, () => {
|
|
||||||
pageLoading.value = true
|
|
||||||
setTimeout(() => {
|
|
||||||
pageLoading.value = false
|
|
||||||
}, 300)
|
|
||||||
})
|
|
||||||
|
|
||||||
const systemConfigStore = useSystemConfigStore()
|
|
||||||
onMounted(() => {
|
|
||||||
// 检查用户认证和权限
|
|
||||||
if (!checkAuth()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否为管理员
|
|
||||||
if (!checkPermission('admin')) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
systemConfigStore.initConfig()
|
|
||||||
pageLoading.value = true
|
|
||||||
setTimeout(() => {
|
|
||||||
pageLoading.value = false
|
|
||||||
}, 300)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 管理后台专用样式 */
|
|
||||||
</style>
|
|
||||||
@@ -464,6 +464,12 @@ const dataManagementItems = ref([
|
|||||||
label: '平台账号',
|
label: '平台账号',
|
||||||
icon: 'fas fa-user-shield',
|
icon: 'fas fa-user-shield',
|
||||||
active: (route: any) => route.path.startsWith('/admin/accounts')
|
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
|
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
|
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')) {
|
} 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
|
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
|
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')) {
|
} 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
|
expandedGroups.value.systemConfig = true
|
||||||
|
|||||||
@@ -33,14 +33,26 @@ import { ref, onMounted } from 'vue'
|
|||||||
const theme = lightTheme
|
const theme = lightTheme
|
||||||
const isDark = ref(false)
|
const isDark = ref(false)
|
||||||
|
|
||||||
|
// 使用 useCookie 来确保服务端和客户端状态一致
|
||||||
|
const themeCookie = useCookie('theme', { default: () => 'light' })
|
||||||
|
|
||||||
|
// 初始化主题状态
|
||||||
|
isDark.value = themeCookie.value === 'dark'
|
||||||
|
|
||||||
const toggleDarkMode = () => {
|
const toggleDarkMode = () => {
|
||||||
isDark.value = !isDark.value
|
isDark.value = !isDark.value
|
||||||
if (isDark.value) {
|
const newTheme = isDark.value ? 'dark' : 'light'
|
||||||
document.documentElement.classList.add('dark')
|
|
||||||
localStorage.setItem('theme', 'dark')
|
// 更新 cookie
|
||||||
} else {
|
themeCookie.value = newTheme
|
||||||
document.documentElement.classList.remove('dark')
|
|
||||||
localStorage.setItem('theme', 'light')
|
// 更新 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 () => {
|
onMounted(async () => {
|
||||||
// 初始化主题
|
// 初始化主题 - 使用 cookie 而不是 localStorage
|
||||||
if (localStorage.getItem('theme') === 'dark') {
|
if (themeCookie.value === 'dark') {
|
||||||
isDark.value = true
|
isDark.value = true
|
||||||
document.documentElement.classList.add('dark')
|
document.documentElement.classList.add('dark')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取三方统计代码并直接加载
|
// 获取三方统计代码并直接加载
|
||||||
await fetchStatsCode()
|
await fetchStatsCode()
|
||||||
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -27,6 +27,22 @@ export default defineNuxtConfig({
|
|||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: ['vueuc', 'date-fns'],
|
include: ['vueuc', 'date-fns'],
|
||||||
exclude: ["oxc-parser"] // 强制使用 WASM 版本
|
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'],
|
modules: ['@nuxtjs/tailwindcss', '@pinia/nuxt'],
|
||||||
@@ -51,10 +67,10 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
public: {
|
public: {
|
||||||
// 开发环境:直接访问后端,生产环境:通过 Nginx 反代
|
// 客户端API地址:开发环境通过代理,生产环境通过Nginx
|
||||||
apiBase: process.env.NODE_ENV === 'production' ? '/api' : 'http://localhost:8080/api',
|
apiBase: '/api',
|
||||||
// 服务端:开发环境直接访问,生产环境容器内访问
|
// 服务端API地址:通过环境变量配置,支持不同部署方式
|
||||||
apiServer: process.env.NODE_ENV === 'production' ? 'http://backend:8080/api' : 'http://localhost:8080/api'
|
apiServer: process.env.NUXT_PUBLIC_API_SERVER || (process.env.NODE_ENV === 'production' ? 'http://backend:8080/api' : '/api')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
@@ -62,7 +78,13 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
ssr: true,
|
ssr: true,
|
||||||
nitro: {
|
nitro: {
|
||||||
logLevel: 'verbose',
|
logLevel: 'info',
|
||||||
preset: 'node-server'
|
preset: 'node-server',
|
||||||
|
storage: {
|
||||||
|
redis: {
|
||||||
|
driver: 'memory',
|
||||||
|
max: 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "res-db-web",
|
"name": "res-db-web",
|
||||||
"version": "1.1.0",
|
"version": "1.2.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -28,12 +28,10 @@
|
|||||||
"@juggle/resize-observer": "^3.3.1",
|
"@juggle/resize-observer": "^3.3.1",
|
||||||
"@nuxtjs/tailwindcss": "^6.8.0",
|
"@nuxtjs/tailwindcss": "^6.8.0",
|
||||||
"@pinia/nuxt": "^0.5.0",
|
"@pinia/nuxt": "^0.5.0",
|
||||||
"@types/qrcode": "^1.5.5",
|
|
||||||
"@vicons/ionicons5": "^0.12.0",
|
"@vicons/ionicons5": "^0.12.0",
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
"naive-ui": "^2.37.0",
|
"naive-ui": "^2.37.0",
|
||||||
"pinia": "^2.1.0",
|
"pinia": "^2.1.0",
|
||||||
"qrcode": "^1.5.4",
|
|
||||||
"vfonts": "^0.0.3",
|
"vfonts": "^0.0.3",
|
||||||
"vue": "^3.3.0",
|
"vue": "^3.3.0",
|
||||||
"vue-router": "^4.2.0"
|
"vue-router": "^4.2.0"
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-100">
|
|
||||||
|
|
||||||
<!-- 主要内容 -->
|
|
||||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg">
|
|
||||||
<!-- Tab 切换 -->
|
|
||||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<div class="flex">
|
|
||||||
<button
|
|
||||||
v-for="tab in tabs"
|
|
||||||
:key="tab.value"
|
|
||||||
:class="[
|
|
||||||
'px-6 py-4 text-sm font-medium border-b-2 transition-colors',
|
|
||||||
mode === tab.value
|
|
||||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
|
||||||
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
|
||||||
]"
|
|
||||||
@click="mode = tab.value"
|
|
||||||
>
|
|
||||||
{{ tab.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 内容区域 -->
|
|
||||||
<div class="p-6">
|
|
||||||
<!-- 批量添加 -->
|
|
||||||
<AdminBatchAddResource
|
|
||||||
v-if="mode === 'batch'"
|
|
||||||
@success="handleSuccess"
|
|
||||||
@error="handleError"
|
|
||||||
@cancel="handleCancel"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 单个添加 -->
|
|
||||||
<AdminSingleAddResource
|
|
||||||
v-else-if="mode === 'single'"
|
|
||||||
@success="handleSuccess"
|
|
||||||
@error="handleError"
|
|
||||||
@cancel="handleCancel"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
// 设置页面布局
|
|
||||||
definePageMeta({
|
|
||||||
layout: 'admin-old',
|
|
||||||
ssr: false
|
|
||||||
})
|
|
||||||
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
// 根据 Nuxt 3 组件规则,位于 components/Admin/ 的组件会自动以 Admin 前缀导入
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ label: '批量添加', value: 'batch' },
|
|
||||||
{ label: '单个添加', value: 'single' },
|
|
||||||
]
|
|
||||||
const mode = ref('batch')
|
|
||||||
const notification = useNotification()
|
|
||||||
|
|
||||||
// 检查用户权限
|
|
||||||
onMounted(() => {
|
|
||||||
const userStore = useUserStore()
|
|
||||||
if (!userStore.isAuthenticated) {
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 事件处理
|
|
||||||
const handleSuccess = (message: string) => {
|
|
||||||
notification.success({
|
|
||||||
content: message,
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleError = (message: string) => {
|
|
||||||
notification.error({
|
|
||||||
content: message,
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
router.back()
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 自定义样式 */
|
|
||||||
</style>
|
|
||||||
@@ -1,433 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-100">
|
|
||||||
<!-- 全局加载状态 -->
|
|
||||||
<div v-if="pageLoading" class="fixed inset-0 bg-gray-900 bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-8 shadow-xl">
|
|
||||||
<div class="flex flex-col items-center space-y-4">
|
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
|
||||||
<div class="text-center">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">正在加载...</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">请稍候,正在加载分类数据</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-6">
|
|
||||||
<n-alert class="mb-4" title="分类用于对资源进行分类管理,可以关联多个标签" type="info" />
|
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<n-button @click="showAddModal = true" type="success">
|
|
||||||
<i class="fas fa-plus"></i> 添加分类
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<div class="relative">
|
|
||||||
<n-input v-model:value="searchQuery" @input="debounceSearch" type="text"
|
|
||||||
placeholder="搜索分类名称..." />
|
|
||||||
<div class="absolute right-3 top-1/2 transform -translate-y-1/2">
|
|
||||||
<i class="fas fa-search text-gray-400 text-sm"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<n-button @click="refreshData" type="tertiary">
|
|
||||||
<i class="fas fa-refresh"></i> 刷新
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 分类列表 -->
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full min-w-full">
|
|
||||||
<thead>
|
|
||||||
<tr class="bg-slate-800 dark:bg-gray-700 text-white dark:text-gray-100">
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">ID</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">分类名称</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">描述</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">资源数量</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">关联标签</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
<tr v-if="loading" class="text-center py-8">
|
|
||||||
<td colspan="6" class="text-gray-500 dark:text-gray-400">
|
|
||||||
<i class="fas fa-spinner fa-spin mr-2"></i>加载中...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-else-if="categories.length === 0" class="text-center py-8">
|
|
||||||
<td colspan="6" class="text-gray-500 dark:text-gray-400">
|
|
||||||
<div class="flex flex-col items-center justify-center py-12">
|
|
||||||
<svg class="w-16 h-16 text-gray-300 dark:text-gray-600 mb-4" fill="none" stroke="currentColor"
|
|
||||||
viewBox="0 0 48 48">
|
|
||||||
<circle cx="24" cy="24" r="20" stroke-width="3" stroke-dasharray="6 6" />
|
|
||||||
<path d="M16 24h16M24 16v16" stroke-width="3" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
<div class="text-lg font-semibold text-gray-400 dark:text-gray-500 mb-2">暂无分类</div>
|
|
||||||
<div class="text-sm text-gray-400 dark:text-gray-600 mb-4">你可以点击上方"添加分类"按钮创建新分类</div>
|
|
||||||
<n-button @click="showAddModal = true" type="primary">
|
|
||||||
<i class="fas fa-plus"></i> 添加分类
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-for="category in categories" :key="category.id"
|
|
||||||
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 font-medium">{{ category.id }}</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
<span :title="category.name">{{ category.name }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<span v-if="category.description" :title="category.description">{{ category.description }}</span>
|
|
||||||
<span v-else class="text-gray-400 dark:text-gray-500 italic">无描述</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<span
|
|
||||||
class="px-2 py-1 bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300 rounded-full text-xs">
|
|
||||||
{{ category.resource_count || 0 }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<span v-if="category.tag_names && category.tag_names.length > 0" class="text-gray-800 dark:text-gray-200">
|
|
||||||
{{ category.tag_names.join(', ') }}
|
|
||||||
</span>
|
|
||||||
<span v-else class="text-gray-400 dark:text-gray-500 italic text-xs">无标签</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<n-button @click="editCategory(category)" type="info" size="small">
|
|
||||||
<i class="fas fa-edit"></i>
|
|
||||||
</n-button>
|
|
||||||
<n-button @click="deleteCategory(category.id)" type="error" size="small">
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 分页 -->
|
|
||||||
<div v-if="totalPages > 1" class="flex flex-wrap justify-center gap-1 sm:gap-2 mt-6">
|
|
||||||
<button v-if="currentPage > 1" @click="goToPage(currentPage - 1)"
|
|
||||||
class="bg-white text-gray-700 hover:bg-gray-50 px-2 py-1 sm:px-4 sm:py-2 rounded border transition-colors text-sm flex items-center">
|
|
||||||
<i class="fas fa-chevron-left mr-1"></i> 上一页
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button @click="goToPage(1)"
|
|
||||||
:class="currentPage === 1 ? 'bg-slate-800 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'"
|
|
||||||
class="px-2 py-1 sm:px-4 sm:py-2 rounded border transition-colors text-sm">
|
|
||||||
1
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button v-if="totalPages > 1" @click="goToPage(2)"
|
|
||||||
:class="currentPage === 2 ? 'bg-slate-800 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'"
|
|
||||||
class="px-2 py-1 sm:px-4 sm:py-2 rounded border transition-colors text-sm">
|
|
||||||
2
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span v-if="currentPage > 2" class="px-2 py-1 sm:px-3 sm:py-2 text-gray-500 text-sm">...</span>
|
|
||||||
|
|
||||||
<button v-if="currentPage !== 1 && currentPage !== 2 && currentPage > 2"
|
|
||||||
class="bg-slate-800 text-white px-2 py-1 sm:px-4 sm:py-2 rounded border transition-colors text-sm">
|
|
||||||
{{ currentPage }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button v-if="currentPage < totalPages" @click="goToPage(currentPage + 1)"
|
|
||||||
class="bg-white text-gray-700 hover:bg-gray-50 px-2 py-1 sm:px-4 sm:py-2 rounded border transition-colors text-sm flex items-center">
|
|
||||||
下一页 <i class="fas fa-chevron-right ml-1"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 统计信息 -->
|
|
||||||
<div v-if="totalPages <= 1" class="mt-4 text-center">
|
|
||||||
<div class="inline-flex items-center bg-white dark:bg-gray-800 rounded-lg shadow px-6 py-3">
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
共 <span class="font-semibold text-gray-900 dark:text-gray-100">{{ totalCount }}</span> 个分类
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 添加/编辑分类模态框 -->
|
|
||||||
<div v-if="showAddModal" class="fixed inset-0 bg-gray-900 bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full">
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
{{ editingCategory ? '编辑分类' : '添加分类' }}
|
|
||||||
</h3>
|
|
||||||
<n-button @click="closeModal" type="tertiary" size="small">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form @submit.prevent="handleSubmit">
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">分类名称:</label>
|
|
||||||
<n-input v-model:value="formData.name" type="text" required
|
|
||||||
placeholder="请输入分类名称" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">描述:</label>
|
|
||||||
<n-input v-model:value="formData.description" type="textarea"
|
|
||||||
placeholder="请输入分类描述(可选)" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3">
|
|
||||||
<n-button type="tertiary" @click="closeModal">
|
|
||||||
取消
|
|
||||||
</n-button>
|
|
||||||
<n-button type="primary" :disabled="submitting" @click="handleSubmit">
|
|
||||||
{{ submitting ? '提交中...' : (editingCategory ? '更新' : '添加') }}
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
// 设置页面布局
|
|
||||||
definePageMeta({
|
|
||||||
layout: 'admin-old',
|
|
||||||
ssr: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const userStore = useUserStore()
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
import { useCategoryApi } from '~/composables/useApi'
|
|
||||||
const categoryApi = useCategoryApi()
|
|
||||||
|
|
||||||
// 页面状态
|
|
||||||
const pageLoading = ref(true)
|
|
||||||
const loading = ref(false)
|
|
||||||
const categories = ref<any[]>([])
|
|
||||||
|
|
||||||
// 分页状态
|
|
||||||
const currentPage = ref(1)
|
|
||||||
const pageSize = ref(20)
|
|
||||||
const totalCount = ref(0)
|
|
||||||
const totalPages = ref(0)
|
|
||||||
|
|
||||||
// 搜索状态
|
|
||||||
const searchQuery = ref('')
|
|
||||||
let searchTimeout: NodeJS.Timeout | null = null
|
|
||||||
|
|
||||||
// 模态框状态
|
|
||||||
const showAddModal = ref(false)
|
|
||||||
const submitting = ref(false)
|
|
||||||
const editingCategory = ref<any>(null)
|
|
||||||
const dialog = useDialog()
|
|
||||||
|
|
||||||
// 表单数据
|
|
||||||
const formData = ref({
|
|
||||||
name: '',
|
|
||||||
description: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// 获取认证头
|
|
||||||
const getAuthHeaders = () => {
|
|
||||||
return userStore.authHeaders
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查认证状态
|
|
||||||
const checkAuth = () => {
|
|
||||||
userStore.initAuth()
|
|
||||||
if (!userStore.isAuthenticated) {
|
|
||||||
router.push('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取分类列表
|
|
||||||
const fetchCategories = async () => {
|
|
||||||
try {
|
|
||||||
loading.value = true
|
|
||||||
const params = {
|
|
||||||
page: currentPage.value,
|
|
||||||
page_size: pageSize.value,
|
|
||||||
search: searchQuery.value
|
|
||||||
}
|
|
||||||
console.log('获取分类列表参数:', params)
|
|
||||||
const response = await categoryApi.getCategories(params)
|
|
||||||
console.log('分类接口响应:', response)
|
|
||||||
console.log('响应类型:', typeof response)
|
|
||||||
console.log('响应是否为数组:', Array.isArray(response))
|
|
||||||
|
|
||||||
// 适配后端API响应格式
|
|
||||||
if (response && (response as any).items && Array.isArray((response as any).items)) {
|
|
||||||
console.log('使用 items 格式:', (response as any).items)
|
|
||||||
categories.value = (response as any).items
|
|
||||||
totalCount.value = (response as any).total || 0
|
|
||||||
totalPages.value = Math.ceil(totalCount.value / pageSize.value)
|
|
||||||
} else if (Array.isArray(response)) {
|
|
||||||
console.log('使用数组格式:', response)
|
|
||||||
// 兼容旧格式
|
|
||||||
categories.value = response
|
|
||||||
totalCount.value = response.length
|
|
||||||
totalPages.value = 1
|
|
||||||
} else {
|
|
||||||
console.log('使用默认格式:', response)
|
|
||||||
categories.value = []
|
|
||||||
totalCount.value = 0
|
|
||||||
totalPages.value = 1
|
|
||||||
}
|
|
||||||
console.log('最终分类数据:', categories.value)
|
|
||||||
console.log('分类数据长度:', categories.value.length)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取分类列表失败:', error)
|
|
||||||
categories.value = []
|
|
||||||
totalCount.value = 0
|
|
||||||
totalPages.value = 1
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 搜索防抖
|
|
||||||
const debounceSearch = () => {
|
|
||||||
if (searchTimeout) {
|
|
||||||
clearTimeout(searchTimeout)
|
|
||||||
}
|
|
||||||
searchTimeout = setTimeout(() => {
|
|
||||||
currentPage.value = 1
|
|
||||||
fetchCategories()
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新数据
|
|
||||||
const refreshData = () => {
|
|
||||||
fetchCategories()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分页跳转
|
|
||||||
const goToPage = (page: number) => {
|
|
||||||
currentPage.value = page
|
|
||||||
fetchCategories()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 编辑分类
|
|
||||||
const editCategory = (category: any) => {
|
|
||||||
console.log('编辑分类:', category)
|
|
||||||
editingCategory.value = category
|
|
||||||
formData.value = {
|
|
||||||
name: category.name,
|
|
||||||
description: category.description || ''
|
|
||||||
}
|
|
||||||
console.log('设置表单数据:', formData.value)
|
|
||||||
showAddModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除分类
|
|
||||||
const deleteCategory = async (categoryId: number) => {
|
|
||||||
dialog.warning({
|
|
||||||
title: '警告',
|
|
||||||
content: '确定要删除分类吗?',
|
|
||||||
positiveText: '确定',
|
|
||||||
negativeText: '取消',
|
|
||||||
draggable: true,
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
try {
|
|
||||||
await categoryApi.deleteCategory(categoryId)
|
|
||||||
await fetchCategories()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除分类失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提交表单
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
try {
|
|
||||||
submitting.value = true
|
|
||||||
let response: any
|
|
||||||
if (editingCategory.value) {
|
|
||||||
response = await categoryApi.updateCategory(editingCategory.value.id, formData.value)
|
|
||||||
} else {
|
|
||||||
response = await categoryApi.createCategory(formData.value)
|
|
||||||
}
|
|
||||||
console.log('分类操作响应:', response)
|
|
||||||
|
|
||||||
// 检查是否是恢复操作
|
|
||||||
if (response && response.message && response.message.includes('恢复成功')) {
|
|
||||||
console.log('检测到分类恢复操作,延迟刷新数据')
|
|
||||||
console.log('恢复的分类信息:', response.category)
|
|
||||||
closeModal()
|
|
||||||
// 延迟一点时间再刷新,确保数据库状态已更新
|
|
||||||
setTimeout(async () => {
|
|
||||||
console.log('开始刷新分类数据...')
|
|
||||||
await fetchCategories()
|
|
||||||
console.log('分类数据刷新完成')
|
|
||||||
}, 500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
closeModal()
|
|
||||||
await fetchCategories()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('提交分类失败:', error)
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭模态框
|
|
||||||
const closeModal = () => {
|
|
||||||
showAddModal.value = false
|
|
||||||
editingCategory.value = null
|
|
||||||
formData.value = {
|
|
||||||
name: '',
|
|
||||||
description: ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化时间
|
|
||||||
const formatTime = (timestamp: string) => {
|
|
||||||
if (!timestamp) return '-'
|
|
||||||
const date = new Date(timestamp)
|
|
||||||
const year = date.getFullYear()
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
||||||
const day = String(date.getDate()).padStart(2, '0')
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0')
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 退出登录
|
|
||||||
const handleLogout = () => {
|
|
||||||
userStore.logout()
|
|
||||||
navigateTo('/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 页面加载
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
checkAuth()
|
|
||||||
await fetchCategories()
|
|
||||||
|
|
||||||
// 检查URL参数,如果action=add则自动打开新增弹窗
|
|
||||||
const route = useRoute()
|
|
||||||
if (route.query.action === 'add') {
|
|
||||||
showAddModal.value = true
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('分类管理页面初始化失败:', error)
|
|
||||||
} finally {
|
|
||||||
pageLoading.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 自定义样式 */
|
|
||||||
</style>
|
|
||||||
@@ -1,655 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-100 p-3 sm:p-5">
|
|
||||||
<!-- 全局加载状态 -->
|
|
||||||
<div v-if="pageLoading" class="fixed inset-0 bg-gray-900 bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-8 shadow-xl">
|
|
||||||
<div class="flex flex-col items-center space-y-4">
|
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
|
||||||
<div class="text-center">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">正在加载...</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">请稍候,正在加载账号数据</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<n-alert class="mb-4" title="平台账号管理当前只支持夸克" type="warning" />
|
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<n-button
|
|
||||||
@click="showCreateModal = true"
|
|
||||||
type="success"
|
|
||||||
>
|
|
||||||
<i class="fas fa-plus"></i> 添加账号
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<div class="relative w-40">
|
|
||||||
<n-select v-model:value="platform" :options="platformOptions" @update:value="onPlatformChange" />
|
|
||||||
</div>
|
|
||||||
<n-button
|
|
||||||
@click="refreshData"
|
|
||||||
type="tertiary"
|
|
||||||
>
|
|
||||||
<i class="fas fa-refresh"></i> 刷新
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 账号列表 -->
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full min-w-full">
|
|
||||||
<thead>
|
|
||||||
<tr class="bg-slate-800 dark:bg-gray-700 text-white dark:text-gray-100">
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">ID</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">平台</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">用户名</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">状态</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">总空间</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">已使用</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">剩余空间</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">备注</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
<tr v-if="loading" class="text-center py-8">
|
|
||||||
<td colspan="9" class="text-gray-500 dark:text-gray-400">
|
|
||||||
<i class="fas fa-spinner fa-spin mr-2"></i>加载中...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-else-if="filteredCksList.length === 0" class="text-center py-8">
|
|
||||||
<td colspan="9" class="text-gray-500 dark:text-gray-400">
|
|
||||||
<div class="flex flex-col items-center justify-center py-12">
|
|
||||||
<svg class="w-16 h-16 text-gray-300 dark:text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
|
||||||
<circle cx="24" cy="24" r="20" stroke-width="3" stroke-dasharray="6 6" />
|
|
||||||
<path d="M16 24h16M24 16v16" stroke-width="3" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
<div class="text-lg font-semibold text-gray-400 dark:text-gray-500 mb-2">暂无账号</div>
|
|
||||||
<div class="text-sm text-gray-400 dark:text-gray-600 mb-4">你可以点击上方"添加账号"按钮创建新账号</div>
|
|
||||||
<n-button
|
|
||||||
@click="showCreateModal = true"
|
|
||||||
type="primary"
|
|
||||||
>
|
|
||||||
<i class="fas fa-plus"></i> 添加账号
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr
|
|
||||||
v-for="cks in filteredCksList"
|
|
||||||
:key="cks.id"
|
|
||||||
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
|
||||||
>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 font-medium">{{ cks.id }}</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<span v-html="getPlatformIcon(cks.pan?.name || '')" class="mr-2"></span>
|
|
||||||
{{ cks.pan?.name || '未知平台' }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
<span v-if="cks.username" :title="cks.username">{{ cks.username }}</span>
|
|
||||||
<span v-else class="text-gray-400 dark:text-gray-500 italic">未知用户</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<span :class="cks.is_valid ? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-200' : 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-200'"
|
|
||||||
class="px-2 py-1 text-xs font-medium rounded-full">
|
|
||||||
{{ cks.is_valid ? '有效' : '无效' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
{{ formatFileSize(cks.space) }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
{{ formatFileSize(Math.max(0, cks.used_space || (cks.space - cks.left_space))) }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
{{ formatFileSize(Math.max(0, cks.left_space)) }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<span v-if="cks.remark" :title="cks.remark">{{ cks.remark }}</span>
|
|
||||||
<span v-else class="text-gray-400 dark:text-gray-500 italic">无备注</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
@click="toggleStatus(cks)"
|
|
||||||
:class="cks.is_valid ? 'text-orange-600 hover:text-orange-800 dark:text-orange-400 dark:hover:text-orange-300' : 'text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300'"
|
|
||||||
class="transition-colors"
|
|
||||||
:title="cks.is_valid ? '禁用账号' : '启用账号'"
|
|
||||||
>
|
|
||||||
<i :class="cks.is_valid ? 'fas fa-ban' : 'fas fa-check'"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="refreshCapacity(cks.id)"
|
|
||||||
class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 transition-colors"
|
|
||||||
title="刷新容量"
|
|
||||||
>
|
|
||||||
<i class="fas fa-sync-alt"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="editCks(cks)"
|
|
||||||
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
|
|
||||||
title="编辑账号"
|
|
||||||
>
|
|
||||||
<i class="fas fa-edit"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="deleteCks(cks.id)"
|
|
||||||
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
|
||||||
title="删除账号"
|
|
||||||
>
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 分页 -->
|
|
||||||
<div v-if="totalPages > 1" class="flex flex-wrap justify-center gap-1 sm:gap-2 mt-6">
|
|
||||||
<button
|
|
||||||
v-if="currentPage > 1"
|
|
||||||
@click="goToPage(currentPage - 1)"
|
|
||||||
class="bg-white text-gray-700 hover:bg-gray-50 px-2 py-1 sm:px-4 sm:py-2 rounded border transition-colors text-sm flex items-center"
|
|
||||||
>
|
|
||||||
<i class="fas fa-chevron-left mr-1"></i> 上一页
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
@click="goToPage(1)"
|
|
||||||
:class="currentPage === 1 ? 'bg-slate-800 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'"
|
|
||||||
class="px-2 py-1 sm:px-4 sm:py-2 rounded border transition-colors text-sm"
|
|
||||||
>
|
|
||||||
1
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="totalPages > 1"
|
|
||||||
@click="goToPage(2)"
|
|
||||||
:class="currentPage === 2 ? 'bg-slate-800 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'"
|
|
||||||
class="px-2 py-1 sm:px-4 sm:py-2 rounded border transition-colors text-sm"
|
|
||||||
>
|
|
||||||
2
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span v-if="currentPage > 2" class="px-2 py-1 sm:px-3 sm:py-2 text-gray-500 text-sm">...</span>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="currentPage !== 1 && currentPage !== 2 && currentPage > 2"
|
|
||||||
class="bg-slate-800 text-white px-2 py-1 sm:px-4 sm:py-2 rounded border transition-colors text-sm"
|
|
||||||
>
|
|
||||||
{{ currentPage }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="currentPage < totalPages"
|
|
||||||
@click="goToPage(currentPage + 1)"
|
|
||||||
class="bg-white text-gray-700 hover:bg-gray-50 px-2 py-1 sm:px-4 sm:py-2 rounded border transition-colors text-sm flex items-center"
|
|
||||||
>
|
|
||||||
下一页 <i class="fas fa-chevron-right ml-1"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 统计信息 -->
|
|
||||||
<div v-if="totalPages <= 1" class="mt-4 text-center">
|
|
||||||
<div class="inline-flex items-center bg-white dark:bg-gray-800 rounded-lg shadow px-6 py-3">
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
共 <span class="font-semibold text-gray-900 dark:text-gray-100">{{ filteredCksList.length }}</span> 个账号
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 创建/编辑账号模态框 -->
|
|
||||||
<div v-if="showCreateModal || showEditModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
|
||||||
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white dark:bg-gray-800">
|
|
||||||
<div class="mt-3">
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
|
|
||||||
{{ showEditModal ? '编辑账号' : '添加账号' }}
|
|
||||||
</h3>
|
|
||||||
<form @submit.prevent="handleSubmit">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">平台类型 <span class="text-red-500">*</span></label>
|
|
||||||
<select
|
|
||||||
v-model="form.pan_id"
|
|
||||||
required
|
|
||||||
:disabled="showEditModal"
|
|
||||||
:class="showEditModal ? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed' : ''"
|
|
||||||
class="mt-1 block w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:text-gray-100"
|
|
||||||
>
|
|
||||||
<option value="">请选择平台</option>
|
|
||||||
<option v-for="pan in platforms.filter(pan => pan.name === 'quark')" :key="pan.id" :value="pan.id">
|
|
||||||
{{ pan.remark }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<p v-if="showEditModal" class="mt-1 text-xs text-gray-500 dark:text-gray-400">编辑时不允许修改平台类型</p>
|
|
||||||
</div>
|
|
||||||
<div v-if="showEditModal && editingCks?.username">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">用户名</label>
|
|
||||||
<div class="mt-1 px-3 py-2 bg-gray-100 dark:bg-gray-600 rounded-md text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
{{ editingCks.username }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Cookie <span class="text-red-500">*</span></label>
|
|
||||||
<n-input
|
|
||||||
v-model:value="form.ck"
|
|
||||||
required
|
|
||||||
type="textarea"
|
|
||||||
placeholder="请输入Cookie内容,系统将自动识别容量"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">备注</label>
|
|
||||||
<n-input
|
|
||||||
v-model:value="form.remark"
|
|
||||||
type="text"
|
|
||||||
placeholder="可选,备注信息"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-if="showEditModal">
|
|
||||||
<label class="flex items-center">
|
|
||||||
<n-checkbox
|
|
||||||
v-model:checked="form.is_valid"
|
|
||||||
/>
|
|
||||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">账号有效</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-6 flex justify-end space-x-3">
|
|
||||||
<n-button
|
|
||||||
type="tertiary"
|
|
||||||
@click="closeModal"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</n-button>
|
|
||||||
<n-button
|
|
||||||
type="primary"
|
|
||||||
:disabled="submitting"
|
|
||||||
@click="handleSubmit"
|
|
||||||
>
|
|
||||||
{{ submitting ? '处理中...' : (showEditModal ? '更新' : '创建') }}
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
definePageMeta({
|
|
||||||
layout: 'admin-old',
|
|
||||||
ssr: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const notification = useNotification()
|
|
||||||
const router = useRouter()
|
|
||||||
const userStore = useUserStore()
|
|
||||||
|
|
||||||
const cksList = ref([])
|
|
||||||
const platforms = ref([])
|
|
||||||
const showCreateModal = ref(false)
|
|
||||||
const showEditModal = ref(false)
|
|
||||||
const editingCks = ref(null)
|
|
||||||
const form = ref({
|
|
||||||
pan_id: '',
|
|
||||||
ck: '',
|
|
||||||
is_valid: true,
|
|
||||||
remark: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// 搜索和分页逻辑
|
|
||||||
const searchQuery = ref('')
|
|
||||||
const currentPage = ref(1)
|
|
||||||
const itemsPerPage = ref(10)
|
|
||||||
const totalPages = ref(1)
|
|
||||||
const loading = ref(true)
|
|
||||||
const pageLoading = ref(true)
|
|
||||||
const submitting = ref(false)
|
|
||||||
const platform = ref(null)
|
|
||||||
const dialog = useDialog()
|
|
||||||
|
|
||||||
import { useCksApi, usePanApi } from '~/composables/useApi'
|
|
||||||
const cksApi = useCksApi()
|
|
||||||
const panApi = usePanApi()
|
|
||||||
|
|
||||||
const { data: pansData } = await useAsyncData('pans', () => panApi.getPans())
|
|
||||||
const pans = computed(() => {
|
|
||||||
// 统一接口格式后直接为数组
|
|
||||||
return Array.isArray(pansData.value) ? pansData.value : (pansData.value?.list || [])
|
|
||||||
})
|
|
||||||
const platformOptions = computed(() => {
|
|
||||||
const options = [
|
|
||||||
{ label: '全部平台', value: null }
|
|
||||||
]
|
|
||||||
|
|
||||||
pans.value.forEach(pan => {
|
|
||||||
options.push({
|
|
||||||
label: pan.remark || pan.name || `平台${pan.id}`,
|
|
||||||
value: pan.id
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return options
|
|
||||||
})
|
|
||||||
|
|
||||||
// 检查认证
|
|
||||||
const checkAuth = () => {
|
|
||||||
userStore.initAuth()
|
|
||||||
if (!userStore.isAuthenticated) {
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取账号列表
|
|
||||||
const fetchCks = async () => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
console.log('开始获取账号列表...')
|
|
||||||
const response = await cksApi.getCks()
|
|
||||||
cksList.value = Array.isArray(response) ? response : []
|
|
||||||
console.log('获取账号列表成功,数据:', cksList.value)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取账号列表失败:', error)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
pageLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取平台列表
|
|
||||||
const fetchPlatforms = async () => {
|
|
||||||
try {
|
|
||||||
const response = await panApi.getPans()
|
|
||||||
platforms.value = Array.isArray(response) ? response : []
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取平台列表失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建账号
|
|
||||||
const createCks = async () => {
|
|
||||||
submitting.value = true
|
|
||||||
try {
|
|
||||||
await cksApi.createCks(form.value)
|
|
||||||
await fetchCks()
|
|
||||||
closeModal()
|
|
||||||
} catch (error) {
|
|
||||||
dialog.error({
|
|
||||||
title: '错误',
|
|
||||||
content: '创建账号失败: ' + (error.message || '未知错误'),
|
|
||||||
positiveText: '确定'
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新账号
|
|
||||||
const updateCks = async () => {
|
|
||||||
submitting.value = true
|
|
||||||
try {
|
|
||||||
await cksApi.updateCks(editingCks.value.id, form.value)
|
|
||||||
await fetchCks()
|
|
||||||
closeModal()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('更新账号失败:', error)
|
|
||||||
notification.error({
|
|
||||||
title: '失败',
|
|
||||||
content: '更新账号失败: ' + (error.message || '未知错误'),
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除账号
|
|
||||||
const deleteCks = async (id) => {
|
|
||||||
dialog.warning({
|
|
||||||
title: '警告',
|
|
||||||
content: '确定要删除这个账号吗?',
|
|
||||||
positiveText: '确定',
|
|
||||||
negativeText: '取消',
|
|
||||||
draggable: true,
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
try {
|
|
||||||
await cksApi.deleteCks(id)
|
|
||||||
await fetchCks()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除账号失败:', error)
|
|
||||||
notification.error({
|
|
||||||
title: '失败',
|
|
||||||
content: '删除账号失败: ' + (error.message || '未知错误'),
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新容量
|
|
||||||
const refreshCapacity = async (id) => {
|
|
||||||
dialog.warning({
|
|
||||||
title: '警告',
|
|
||||||
content: '确定要刷新此账号的容量信息吗?',
|
|
||||||
positiveText: '确定',
|
|
||||||
negativeText: '取消',
|
|
||||||
draggable: true,
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
try {
|
|
||||||
await cksApi.refreshCapacity(id)
|
|
||||||
await fetchCks()
|
|
||||||
notification.success({
|
|
||||||
title: '成功',
|
|
||||||
content: '容量信息已刷新!',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('刷新容量失败:', error)
|
|
||||||
notification.error({
|
|
||||||
title: '失败',
|
|
||||||
content: '刷新容量失败: ' + (error.message || '未知错误'),
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换账号状态
|
|
||||||
const toggleStatus = async (cks) => {
|
|
||||||
const newStatus = !cks.is_valid
|
|
||||||
dialog.warning({
|
|
||||||
title: '警告',
|
|
||||||
content: `确定要${cks.is_valid ? '禁用' : '启用'}此账号吗?`,
|
|
||||||
positiveText: '确定',
|
|
||||||
negativeText: '取消',
|
|
||||||
draggable: true,
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
try {
|
|
||||||
console.log('切换状态 - 账号ID:', cks.id, '当前状态:', cks.is_valid, '新状态:', newStatus)
|
|
||||||
await cksApi.updateCks(cks.id, { is_valid: newStatus })
|
|
||||||
console.log('状态更新成功,正在刷新数据...')
|
|
||||||
await fetchCks()
|
|
||||||
console.log('数据刷新完成')
|
|
||||||
notification.success({
|
|
||||||
title: '成功',
|
|
||||||
content: `账号已${newStatus ? '启用' : '禁用'}!`,
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('切换账号状态失败:', error)
|
|
||||||
notification.error({
|
|
||||||
title: '失败',
|
|
||||||
content: `切换账号状态失败: ${error.message || '未知错误'}`,
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 编辑账号
|
|
||||||
const editCks = (cks) => {
|
|
||||||
editingCks.value = cks
|
|
||||||
form.value = {
|
|
||||||
pan_id: cks.pan_id,
|
|
||||||
ck: cks.ck,
|
|
||||||
is_valid: cks.is_valid,
|
|
||||||
remark: cks.remark || ''
|
|
||||||
}
|
|
||||||
showEditModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭模态框
|
|
||||||
const closeModal = () => {
|
|
||||||
showCreateModal.value = false
|
|
||||||
showEditModal.value = false
|
|
||||||
editingCks.value = null
|
|
||||||
form.value = {
|
|
||||||
pan_id: '',
|
|
||||||
ck: '',
|
|
||||||
is_valid: true,
|
|
||||||
remark: ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提交表单
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (showEditModal.value) {
|
|
||||||
await updateCks()
|
|
||||||
} else {
|
|
||||||
await createCks()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取平台图标
|
|
||||||
const getPlatformIcon = (platformName) => {
|
|
||||||
const defaultIcons = {
|
|
||||||
'unknown': '<i class="fas fa-question-circle text-gray-400"></i>',
|
|
||||||
'other': '<i class="fas fa-cloud text-gray-500"></i>',
|
|
||||||
'magnet': '<i class="fas fa-magnet text-red-600"></i>',
|
|
||||||
'uc': '<i class="fas fa-cloud-download-alt text-purple-600"></i>',
|
|
||||||
'夸克网盘': '<i class="fas fa-cloud text-blue-600"></i>',
|
|
||||||
'阿里云盘': '<i class="fas fa-cloud text-orange-600"></i>',
|
|
||||||
'百度网盘': '<i class="fas fa-cloud text-blue-500"></i>',
|
|
||||||
'天翼云盘': '<i class="fas fa-cloud text-red-500"></i>',
|
|
||||||
'OneDrive': '<i class="fas fa-cloud text-blue-700"></i>',
|
|
||||||
'Google Drive': '<i class="fas fa-cloud text-green-600"></i>'
|
|
||||||
}
|
|
||||||
|
|
||||||
return defaultIcons[platformName] || defaultIcons['unknown']
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化文件大小
|
|
||||||
const formatFileSize = (bytes) => {
|
|
||||||
if (!bytes || bytes <= 0) return '0 B'
|
|
||||||
|
|
||||||
const tb = bytes / (1024 * 1024 * 1024 * 1024)
|
|
||||||
if (tb >= 1) {
|
|
||||||
return tb.toFixed(2) + ' TB'
|
|
||||||
}
|
|
||||||
|
|
||||||
const gb = bytes / (1024 * 1024 * 1024)
|
|
||||||
if (gb >= 1) {
|
|
||||||
return gb.toFixed(2) + ' GB'
|
|
||||||
}
|
|
||||||
|
|
||||||
const mb = bytes / (1024 * 1024)
|
|
||||||
if (mb >= 1) {
|
|
||||||
return mb.toFixed(2) + ' MB'
|
|
||||||
}
|
|
||||||
|
|
||||||
const kb = bytes / 1024
|
|
||||||
if (kb >= 1) {
|
|
||||||
return kb.toFixed(2) + ' KB'
|
|
||||||
}
|
|
||||||
|
|
||||||
return bytes + ' B'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 过滤和分页计算
|
|
||||||
const filteredCksList = computed(() => {
|
|
||||||
let filtered = cksList.value
|
|
||||||
console.log('原始账号数量:', filtered.length)
|
|
||||||
|
|
||||||
// 平台过滤
|
|
||||||
if (platform.value !== null && platform.value !== undefined) {
|
|
||||||
filtered = filtered.filter(cks => cks.pan_id === platform.value)
|
|
||||||
console.log('平台过滤后数量:', filtered.length, '平台ID:', platform.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 搜索过滤
|
|
||||||
if (searchQuery.value) {
|
|
||||||
const query = searchQuery.value.toLowerCase()
|
|
||||||
filtered = filtered.filter(cks =>
|
|
||||||
cks.pan?.name?.toLowerCase().includes(query) ||
|
|
||||||
cks.remark?.toLowerCase().includes(query)
|
|
||||||
)
|
|
||||||
console.log('搜索过滤后数量:', filtered.length, '搜索词:', searchQuery.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
totalPages.value = Math.ceil(filtered.length / itemsPerPage.value)
|
|
||||||
const start = (currentPage.value - 1) * itemsPerPage.value
|
|
||||||
const end = start + itemsPerPage.value
|
|
||||||
return filtered.slice(start, end)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 防抖搜索
|
|
||||||
let searchTimeout = null
|
|
||||||
const debounceSearch = () => {
|
|
||||||
if (searchTimeout) {
|
|
||||||
clearTimeout(searchTimeout)
|
|
||||||
}
|
|
||||||
searchTimeout = setTimeout(() => {
|
|
||||||
currentPage.value = 1
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 平台变化处理
|
|
||||||
const onPlatformChange = () => {
|
|
||||||
currentPage.value = 1
|
|
||||||
console.log('平台过滤条件变化:', platform.value)
|
|
||||||
console.log('当前过滤后的账号数量:', filteredCksList.value.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新数据
|
|
||||||
const refreshData = () => {
|
|
||||||
currentPage.value = 1
|
|
||||||
// 保持当前的过滤条件,只刷新数据
|
|
||||||
fetchCks()
|
|
||||||
fetchPlatforms()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分页跳转
|
|
||||||
const goToPage = (page) => {
|
|
||||||
currentPage.value = page
|
|
||||||
}
|
|
||||||
|
|
||||||
// 页面加载
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
checkAuth()
|
|
||||||
await Promise.all([
|
|
||||||
fetchCks(),
|
|
||||||
fetchPlatforms()
|
|
||||||
])
|
|
||||||
} catch (error) {
|
|
||||||
console.error('页面初始化失败:', error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,733 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-100 p-3 sm:p-5">
|
|
||||||
<!-- 全局加载状态 -->
|
|
||||||
<div v-if="pageLoading" class="fixed inset-0 bg-gray-900 bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-8 shadow-xl">
|
|
||||||
<div class="flex flex-col items-center space-y-4">
|
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-red-600"></div>
|
|
||||||
<div class="text-center">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">正在加载...</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">请稍候,正在加载失败资源列表</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="max-w-7xl mx-auto">
|
|
||||||
<!-- 页面标题 -->
|
|
||||||
<div class="mb-6">
|
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">失败资源列表</h1>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">显示处理失败的资源,包含错误信息</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button
|
|
||||||
@click="retryAllFailed"
|
|
||||||
:disabled="!errorFilter.trim() || isProcessing"
|
|
||||||
:class="[
|
|
||||||
'w-full sm:w-auto px-4 py-2 rounded-md transition-colors text-center flex items-center justify-center gap-2',
|
|
||||||
errorFilter.trim() && !isProcessing
|
|
||||||
? 'bg-green-600 hover:bg-green-700'
|
|
||||||
: 'bg-gray-400 text-gray-200 cursor-not-allowed'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<i v-if="isProcessing" class="fas fa-spinner fa-spin"></i>
|
|
||||||
<i v-else class="fas fa-redo"></i>
|
|
||||||
{{ isProcessing ? '处理中...' : '重新放入待处理池' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="clearAllErrors"
|
|
||||||
:disabled="!errorFilter.trim() || isProcessing"
|
|
||||||
:class="[
|
|
||||||
'w-full sm:w-auto px-4 py-2 rounded-md transition-colors text-center flex items-center justify-center gap-2',
|
|
||||||
errorFilter.trim() && !isProcessing
|
|
||||||
? 'bg-yellow-600 hover:bg-yellow-700'
|
|
||||||
: 'bg-gray-400 text-gray-200 cursor-not-allowed'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<i v-if="isProcessing" class="fas fa-spinner fa-spin"></i>
|
|
||||||
<i v-else class="fas fa-trash"></i>
|
|
||||||
{{ isProcessing ? '处理中...' : '删除失败资源' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 items-center">
|
|
||||||
<!-- 错误信息过滤 -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<n-input
|
|
||||||
v-model:value="errorFilter"
|
|
||||||
type="text"
|
|
||||||
placeholder="过滤错误信息..."
|
|
||||||
class="w-48"
|
|
||||||
clearable
|
|
||||||
@input="onErrorFilterChange"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
v-if="errorFilter"
|
|
||||||
@click="clearErrorFilter"
|
|
||||||
class="px-2 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
|
||||||
title="清除过滤条件"
|
|
||||||
>
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
@click="refreshData"
|
|
||||||
class="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<i class="fas fa-refresh"></i> 刷新
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- 失败资源列表 -->
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full">
|
|
||||||
<thead class="bg-red-800 dark:bg-red-900 text-white dark:text-gray-100 sticky top-0 z-10">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">ID</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">状态</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">标题</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">URL</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">错误信息</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">创建时间</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">IP地址</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700 max-h-96 overflow-y-auto">
|
|
||||||
<tr v-if="loading" class="text-center py-8">
|
|
||||||
<td colspan="8" class="text-gray-500 dark:text-gray-400">
|
|
||||||
<i class="fas fa-spinner fa-spin mr-2"></i>加载中...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-else-if="failedResources.length === 0">
|
|
||||||
<td colspan="8">
|
|
||||||
<div class="flex flex-col items-center justify-center py-12">
|
|
||||||
<svg class="w-16 h-16 text-gray-300 dark:text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
|
||||||
<circle cx="24" cy="24" r="20" stroke-width="3" stroke-dasharray="6 6" />
|
|
||||||
<path d="M16 24h16M24 16v16" stroke-width="3" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
<div class="text-lg font-semibold text-gray-400 dark:text-gray-500 mb-2">暂无失败资源</div>
|
|
||||||
<div class="text-sm text-gray-400 dark:text-gray-600">所有资源处理成功</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr
|
|
||||||
v-for="resource in failedResources"
|
|
||||||
:key="resource.id"
|
|
||||||
:class="[
|
|
||||||
'hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors',
|
|
||||||
resource.is_deleted ? 'bg-gray-100 dark:bg-gray-700' : ''
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 font-medium">{{ resource.id }}</td>
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<span
|
|
||||||
v-if="resource.is_deleted"
|
|
||||||
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
|
|
||||||
title="已删除"
|
|
||||||
>
|
|
||||||
<i class="fas fa-trash mr-1"></i>已删除
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
|
||||||
title="正常"
|
|
||||||
>
|
|
||||||
<i class="fas fa-check mr-1"></i>正常
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
<span v-if="resource.title && resource.title !== null" :title="resource.title">{{ escapeHtml(resource.title) }}</span>
|
|
||||||
<span v-else class="text-gray-400 dark:text-gray-500 italic">未设置</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<a
|
|
||||||
:href="checkUrlSafety(resource.url)"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:underline break-all"
|
|
||||||
:title="resource.url"
|
|
||||||
>
|
|
||||||
{{ escapeHtml(resource.url) }}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<div class="max-w-xs">
|
|
||||||
<span
|
|
||||||
class="text-red-600 dark:text-red-400 text-xs bg-red-50 dark:bg-red-900/20 px-2 py-1 rounded"
|
|
||||||
:title="resource.error_msg"
|
|
||||||
>
|
|
||||||
{{ truncateError(resource.error_msg) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{{ formatTime(resource.create_time) }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{{ escapeHtml(resource.ip || '-') }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button
|
|
||||||
@click="retryResource(resource.id)"
|
|
||||||
class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 transition-colors"
|
|
||||||
title="重试此资源"
|
|
||||||
>
|
|
||||||
<i class="fas fa-redo"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="clearError(resource.id)"
|
|
||||||
class="text-yellow-600 hover:text-yellow-800 dark:text-yellow-400 dark:hover:text-yellow-300 transition-colors"
|
|
||||||
title="清除错误信息"
|
|
||||||
>
|
|
||||||
<i class="fas fa-broom"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="deleteResource(resource.id)"
|
|
||||||
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
|
||||||
title="删除此资源"
|
|
||||||
>
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 分页组件 -->
|
|
||||||
<div v-if="totalPages > 1" class="mt-6 flex justify-center">
|
|
||||||
<div class="flex items-center space-x-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4">
|
|
||||||
<!-- 总资源数 -->
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
共 <span class="font-semibold text-gray-900 dark:text-gray-100">{{ totalCount }}</span> 个失败资源
|
|
||||||
<span v-if="errorFilter" class="ml-2 text-blue-600 dark:text-blue-400">
|
|
||||||
(已过滤)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-px h-6 bg-gray-300 dark:bg-gray-600"></div>
|
|
||||||
|
|
||||||
<!-- 上一页 -->
|
|
||||||
<button
|
|
||||||
@click="goToPage(currentPage - 1)"
|
|
||||||
:disabled="currentPage <= 1"
|
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-500 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<i class="fas fa-chevron-left"></i>
|
|
||||||
<span>上一页</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- 页码 -->
|
|
||||||
<template v-for="page in visiblePages" :key="page">
|
|
||||||
<button
|
|
||||||
v-if="typeof page === 'number'"
|
|
||||||
@click="goToPage(page)"
|
|
||||||
:class="[
|
|
||||||
'px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 min-w-[40px]',
|
|
||||||
page === currentPage
|
|
||||||
? 'bg-red-600 text-white shadow-md'
|
|
||||||
: 'text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ page }}
|
|
||||||
</button>
|
|
||||||
<span v-else class="px-3 py-2 text-sm text-gray-500">...</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 下一页 -->
|
|
||||||
<button
|
|
||||||
@click="goToPage(currentPage + 1)"
|
|
||||||
:disabled="currentPage >= totalPages"
|
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-500 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<span>下一页</span>
|
|
||||||
<i class="fas fa-chevron-right"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 统计信息 -->
|
|
||||||
<div v-if="totalPages <= 1" class="mt-4 text-center">
|
|
||||||
<div class="inline-flex items-center bg-white dark:bg-gray-800 rounded-lg shadow px-6 py-3">
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
共 <span class="font-semibold text-gray-900 dark:text-gray-100">{{ totalCount }}</span> 个失败资源
|
|
||||||
<span v-if="errorFilter" class="ml-2 text-blue-600 dark:text-blue-400">
|
|
||||||
(已过滤)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
// 设置页面布局
|
|
||||||
definePageMeta({
|
|
||||||
layout: 'admin-old',
|
|
||||||
ssr: false
|
|
||||||
})
|
|
||||||
|
|
||||||
interface FailedResource {
|
|
||||||
id: number
|
|
||||||
title?: string | null
|
|
||||||
url: string
|
|
||||||
error_msg: string
|
|
||||||
create_time: string
|
|
||||||
ip?: string | null
|
|
||||||
deleted_at?: string | null
|
|
||||||
is_deleted: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const notification = useNotification()
|
|
||||||
const failedResources = ref<FailedResource[]>([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const pageLoading = ref(true)
|
|
||||||
|
|
||||||
// 分页相关状态
|
|
||||||
const currentPage = ref(1)
|
|
||||||
const pageSize = ref(100)
|
|
||||||
const totalCount = ref(0)
|
|
||||||
const totalPages = ref(0)
|
|
||||||
|
|
||||||
|
|
||||||
const dialog = useDialog()
|
|
||||||
|
|
||||||
// 过滤相关状态
|
|
||||||
const errorFilter = ref('')
|
|
||||||
|
|
||||||
// 获取失败资源API
|
|
||||||
import { useReadyResourceApi } from '~/composables/useApi'
|
|
||||||
const readyResourceApi = useReadyResourceApi()
|
|
||||||
|
|
||||||
// 获取数据
|
|
||||||
const fetchData = async () => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const params: any = {
|
|
||||||
page: currentPage.value,
|
|
||||||
page_size: pageSize.value
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果有过滤条件,添加到查询参数中
|
|
||||||
if (errorFilter.value.trim()) {
|
|
||||||
params.error_filter = errorFilter.value.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('fetchData - 开始获取失败资源,参数:', params)
|
|
||||||
|
|
||||||
const response = await readyResourceApi.getFailedResources(params) as any
|
|
||||||
|
|
||||||
console.log('fetchData - 原始响应:', response)
|
|
||||||
|
|
||||||
if (response && response.data && Array.isArray(response.data)) {
|
|
||||||
console.log('fetchData - 使用response.data格式(数组)')
|
|
||||||
failedResources.value = response.data
|
|
||||||
totalCount.value = response.total || 0
|
|
||||||
totalPages.value = Math.ceil((response.total || 0) / pageSize.value)
|
|
||||||
} else {
|
|
||||||
console.log('fetchData - 使用空数据格式')
|
|
||||||
failedResources.value = []
|
|
||||||
totalCount.value = 0
|
|
||||||
totalPages.value = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('fetchData - 处理后的数据:', {
|
|
||||||
failedResourcesCount: failedResources.value.length,
|
|
||||||
totalCount: totalCount.value,
|
|
||||||
totalPages: totalPages.value
|
|
||||||
})
|
|
||||||
|
|
||||||
// 打印第一个资源的数据结构(如果存在)
|
|
||||||
if (failedResources.value.length > 0) {
|
|
||||||
console.log('fetchData - 第一个资源的数据结构:', failedResources.value[0])
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取失败资源失败:', error)
|
|
||||||
failedResources.value = []
|
|
||||||
totalCount.value = 0
|
|
||||||
totalPages.value = 1
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 跳转到指定页面
|
|
||||||
const goToPage = (page: number) => {
|
|
||||||
if (page >= 1 && page <= totalPages.value) {
|
|
||||||
currentPage.value = page
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算可见的页码
|
|
||||||
const visiblePages = computed(() => {
|
|
||||||
const pages: (number | string)[] = []
|
|
||||||
const maxVisible = 5
|
|
||||||
|
|
||||||
if (totalPages.value <= maxVisible) {
|
|
||||||
for (let i = 1; i <= totalPages.value; i++) {
|
|
||||||
pages.push(i)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (currentPage.value <= 3) {
|
|
||||||
for (let i = 1; i <= 4; i++) {
|
|
||||||
pages.push(i)
|
|
||||||
}
|
|
||||||
pages.push('...')
|
|
||||||
pages.push(totalPages.value)
|
|
||||||
} else if (currentPage.value >= totalPages.value - 2) {
|
|
||||||
pages.push(1)
|
|
||||||
pages.push('...')
|
|
||||||
for (let i = totalPages.value - 3; i <= totalPages.value; i++) {
|
|
||||||
pages.push(i)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
pages.push(1)
|
|
||||||
pages.push('...')
|
|
||||||
for (let i = currentPage.value - 1; i <= currentPage.value + 1; i++) {
|
|
||||||
pages.push(i)
|
|
||||||
}
|
|
||||||
pages.push('...')
|
|
||||||
pages.push(totalPages.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return pages
|
|
||||||
})
|
|
||||||
|
|
||||||
// 防抖函数
|
|
||||||
const debounce = (func: Function, delay: number) => {
|
|
||||||
let timeoutId: NodeJS.Timeout
|
|
||||||
return (...args: any[]) => {
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
timeoutId = setTimeout(() => func.apply(null, args), delay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 错误过滤输入变化处理(防抖)
|
|
||||||
const onErrorFilterChange = debounce(() => {
|
|
||||||
currentPage.value = 1 // 重置到第一页
|
|
||||||
fetchData()
|
|
||||||
}, 300)
|
|
||||||
|
|
||||||
// 清除错误过滤
|
|
||||||
const clearErrorFilter = () => {
|
|
||||||
errorFilter.value = ''
|
|
||||||
currentPage.value = 1 // 重置到第一页
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新数据
|
|
||||||
const refreshData = () => {
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重试单个资源
|
|
||||||
const retryResource = async (id: number) => {
|
|
||||||
dialog.warning({
|
|
||||||
title: '警告',
|
|
||||||
content: '确定要重试这个资源吗?',
|
|
||||||
positiveText: '确定',
|
|
||||||
negativeText: '取消',
|
|
||||||
draggable: true,
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
try {
|
|
||||||
await readyResourceApi.clearErrorMsg(id)
|
|
||||||
notification.success({
|
|
||||||
title: '成功',
|
|
||||||
content: '错误信息已清除,资源将在下次调度时重新处理',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
fetchData()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('重试失败:', error)
|
|
||||||
notification.error({
|
|
||||||
title: '失败',
|
|
||||||
content: '重试失败',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除单个资源错误
|
|
||||||
const clearError = async (id: number) => {
|
|
||||||
dialog.warning({
|
|
||||||
title: '警告',
|
|
||||||
content: '确定要清除这个资源的错误信息吗?',
|
|
||||||
positiveText: '确定',
|
|
||||||
negativeText: '取消',
|
|
||||||
draggable: true,
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
try {
|
|
||||||
await readyResourceApi.clearErrorMsg(id)
|
|
||||||
notification.success({
|
|
||||||
title: '成功',
|
|
||||||
content: '错误信息已清除',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
fetchData()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('清除错误失败:', error)
|
|
||||||
notification.error({
|
|
||||||
title: '失败',
|
|
||||||
content: '清除错误失败',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除资源
|
|
||||||
const deleteResource = async (id: number) => {
|
|
||||||
dialog.warning({
|
|
||||||
title: '警告',
|
|
||||||
content: '确定要删除这个失败资源吗?',
|
|
||||||
positiveText: '确定',
|
|
||||||
negativeText: '取消',
|
|
||||||
draggable: true,
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
try {
|
|
||||||
await readyResourceApi.deleteReadyResource(id)
|
|
||||||
if (failedResources.value.length === 1 && currentPage.value > 1) {
|
|
||||||
currentPage.value--
|
|
||||||
}
|
|
||||||
fetchData()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除失败:', error)
|
|
||||||
notification.error({
|
|
||||||
title: '失败',
|
|
||||||
content: '删除失败',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理状态
|
|
||||||
const isProcessing = ref(false)
|
|
||||||
|
|
||||||
// 重新放入待处理池
|
|
||||||
const retryAllFailed = async () => {
|
|
||||||
if (totalCount.value === 0) {
|
|
||||||
notification.error({
|
|
||||||
title: '失败',
|
|
||||||
content: '没有可处理的资源',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否有过滤条件
|
|
||||||
if (!errorFilter.value.trim()) {
|
|
||||||
notification.error({
|
|
||||||
title: '失败',
|
|
||||||
content: '请先设置过滤条件,以避免处理所有失败资源',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建查询条件
|
|
||||||
const queryParams: any = {}
|
|
||||||
|
|
||||||
// 如果有过滤条件,添加到查询参数中
|
|
||||||
if (errorFilter.value.trim()) {
|
|
||||||
queryParams.error_filter = errorFilter.value.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = totalCount.value
|
|
||||||
|
|
||||||
dialog.warning({
|
|
||||||
title: '确认操作',
|
|
||||||
content: `确定要将 ${count} 个资源重新放入待处理池吗?`,
|
|
||||||
positiveText: '确定',
|
|
||||||
negativeText: '取消',
|
|
||||||
draggable: true,
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
if (isProcessing.value) return // 防止重复点击
|
|
||||||
|
|
||||||
isProcessing.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await readyResourceApi.batchRestoreToReadyPoolByQuery(queryParams) as any
|
|
||||||
notification.success({
|
|
||||||
title: '成功',
|
|
||||||
content: `操作完成:\n总数量:${response.total_count}\n成功处理:${response.success_count}\n失败:${response.failed_count}`,
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
fetchData()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('重新放入待处理池失败:', error)
|
|
||||||
notification.error({
|
|
||||||
title: '失败',
|
|
||||||
content: '操作失败',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
isProcessing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除所有错误
|
|
||||||
const clearAllErrors = async () => {
|
|
||||||
// 检查是否有过滤条件
|
|
||||||
if (!errorFilter.value.trim()) {
|
|
||||||
notification.error({
|
|
||||||
title: '失败',
|
|
||||||
content: '请先设置过滤条件,以避免删除所有失败资源',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建查询条件
|
|
||||||
const queryParams: any = {}
|
|
||||||
|
|
||||||
// 如果有过滤条件,添加到查询参数中
|
|
||||||
if (errorFilter.value.trim()) {
|
|
||||||
queryParams.error_filter = errorFilter.value.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = totalCount.value
|
|
||||||
|
|
||||||
dialog.warning({
|
|
||||||
title: '警告',
|
|
||||||
content: `确定要删除 ${count} 个失败资源吗?此操作将永久删除这些资源,不可恢复!`,
|
|
||||||
positiveText: '确定删除',
|
|
||||||
negativeText: '取消',
|
|
||||||
draggable: true,
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
if (isProcessing.value) return // 防止重复点击
|
|
||||||
|
|
||||||
isProcessing.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('开始调用删除API,参数:', queryParams)
|
|
||||||
const response = await readyResourceApi.clearAllErrorsByQuery(queryParams) as any
|
|
||||||
// console.log('删除API响应:', response)
|
|
||||||
notification.success({
|
|
||||||
title: '成功',
|
|
||||||
content: `操作完成:\n删除失败资源:${response.affected_rows} 个资源`,
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
fetchData()
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('删除失败资源失败:', error)
|
|
||||||
console.error('错误详情:', {
|
|
||||||
message: error?.message,
|
|
||||||
stack: error?.stack,
|
|
||||||
response: error?.response
|
|
||||||
})
|
|
||||||
notification.error({
|
|
||||||
title: '失败',
|
|
||||||
content: '删除失败',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
isProcessing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化时间
|
|
||||||
const formatTime = (timeString: string) => {
|
|
||||||
const date = new Date(timeString)
|
|
||||||
return date.toLocaleString('zh-CN')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转义HTML防止XSS
|
|
||||||
const escapeHtml = (text: string) => {
|
|
||||||
if (!text) return text
|
|
||||||
const div = document.createElement('div')
|
|
||||||
div.textContent = text
|
|
||||||
return div.innerHTML
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证URL安全性
|
|
||||||
const checkUrlSafety = (url: string) => {
|
|
||||||
if (!url) return '#'
|
|
||||||
try {
|
|
||||||
const urlObj = new URL(url)
|
|
||||||
if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') {
|
|
||||||
return '#'
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
} catch {
|
|
||||||
return '#'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 截断错误信息
|
|
||||||
const truncateError = (errorMsg: string) => {
|
|
||||||
if (!errorMsg) return ''
|
|
||||||
return errorMsg.length > 50 ? errorMsg.substring(0, 50) + '...' : errorMsg
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 页面加载时获取数据
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
await fetchData()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('页面初始化失败:', error)
|
|
||||||
} finally {
|
|
||||||
pageLoading.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 表格滚动样式 */
|
|
||||||
.overflow-x-auto {
|
|
||||||
max-height: 500px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 表格头部固定 */
|
|
||||||
thead {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 分页按钮悬停效果 */
|
|
||||||
.pagination-button:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 当前页码按钮效果 */
|
|
||||||
.current-page {
|
|
||||||
box-shadow: 0 4px 6px -1px rgba(220, 38, 38, 0.3), 0 2px 4px -1px rgba(220, 38, 38, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 表格行悬停效果 */
|
|
||||||
tbody tr:hover {
|
|
||||||
background-color: rgba(220, 38, 38, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 暗黑模式下的表格行悬停 */
|
|
||||||
.dark tbody tr:hover {
|
|
||||||
background-color: rgba(220, 38, 38, 0.1);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
<template>
|
|
||||||
<!-- 管理功能区域 -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
|
||||||
<!-- 资源管理 -->
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<div class="p-3 bg-blue-100 rounded-lg">
|
|
||||||
<i class="fas fa-cloud text-blue-600 text-xl"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">资源管理</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">管理所有资源</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<NuxtLink to="/admin-old/resources" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors block">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">查看所有资源</span>
|
|
||||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
|
||||||
</div>
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink to="/admin-old/add-resource" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors block">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">批量添加资源</span>
|
|
||||||
<i class="fas fa-plus text-gray-400"></i>
|
|
||||||
</div>
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 平台管理 -->
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<div class="p-3 bg-green-100 rounded-lg">
|
|
||||||
<i class="fas fa-server text-green-600 text-xl"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">平台管理</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">系统支持的网盘平台</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex flex-wrap gap-1 w-full text-left rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer">
|
|
||||||
<div v-for="pan in pans" :key="pan.id" class="h-6 px-1 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center">
|
|
||||||
<span v-html="pan.icon"></span> {{ pan.name }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 第三方平台账号管理 -->
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<div class="p-3 bg-teal-100 rounded-lg">
|
|
||||||
<i class="fas fa-key text-teal-600 text-xl"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">平台账号管理</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">管理第三方平台账号</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<NuxtLink to="/admin-old/cks" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors block">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">管理账号</span>
|
|
||||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
|
||||||
</div>
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink to="/admin-old/cks" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors block">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">添加账号</span>
|
|
||||||
<i class="fas fa-plus text-gray-400"></i>
|
|
||||||
</div>
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 分类管理 -->
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<div class="p-3 bg-purple-100 rounded-lg">
|
|
||||||
<i class="fas fa-folder text-purple-600 text-xl"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">分类管理</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">管理资源分类</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<button @click="goToCategoryManagement" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">管理分类</span>
|
|
||||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button @click="goToAddCategory" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">添加分类</span>
|
|
||||||
<i class="fas fa-plus text-gray-400"></i>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 标签管理 -->
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<div class="p-3 bg-orange-100 rounded-lg">
|
|
||||||
<i class="fas fa-tags text-orange-600 text-xl"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">标签管理</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">管理资源标签</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<button @click="goToTagManagement" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">管理标签</span>
|
|
||||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button @click="goToAddTag" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">添加标签</span>
|
|
||||||
<i class="fas fa-plus text-gray-400"></i>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 统计信息 -->
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<div class="p-3 bg-red-100 rounded-lg">
|
|
||||||
<i class="fas fa-chart-bar text-red-600 text-xl"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">统计信息</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">系统统计数据</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<NuxtLink to="/admin-old/search-stats" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors block">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">搜索统计</span>
|
|
||||||
<i class="fas fa-chart-line text-gray-400"></i>
|
|
||||||
</div>
|
|
||||||
</NuxtLink>
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400">总资源数</span>
|
|
||||||
<span class="text-lg font-semibold text-gray-900 dark:text-gray-100">{{ stats?.total_resources || 0 }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400">总浏览量</span>
|
|
||||||
<span class="text-lg font-semibold text-gray-900 dark:text-gray-100">{{ stats?.total_views || 0 }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<div class="p-3 bg-yellow-100 rounded-lg">
|
|
||||||
<i class="fas fa-clock text-yellow-600 text-xl"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">待处理资源</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">批量添加和管理</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<NuxtLink to="/admin-old/ready-resources" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors block">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">管理待处理资源</span>
|
|
||||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
|
||||||
</div>
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink to="/admin-old/failed-resources" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors block">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">失败列表</span>
|
|
||||||
<i class="fas fa-exclamation-triangle text-red-400"></i>
|
|
||||||
</div>
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 系统配置 -->
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<div class="p-3 bg-indigo-100 rounded-lg">
|
|
||||||
<i class="fas fa-cog text-indigo-600 text-xl"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">系统配置</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">系统参数设置</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<NuxtLink to="/admin-old/users" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors block">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">用户管理</span>
|
|
||||||
<i class="fas fa-users text-gray-400"></i>
|
|
||||||
</div>
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink to="/admin-old/system-config" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors block">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">系统设置</span>
|
|
||||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
|
||||||
</div>
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 版本信息 -->
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<div class="p-3 bg-green-100 rounded-lg">
|
|
||||||
<i class="fas fa-code-branch text-green-600 text-xl"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">版本信息</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">系统版本和文档</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<NuxtLink to="/admin-old/version" class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors block">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">版本信息</span>
|
|
||||||
<i class="fas fa-code-branch text-gray-400"></i>
|
|
||||||
</div>
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
// 设置页面布局
|
|
||||||
definePageMeta({
|
|
||||||
layout: 'admin-old',
|
|
||||||
ssr: false
|
|
||||||
})
|
|
||||||
|
|
||||||
// 用户状态管理
|
|
||||||
const userStore = useUserStore()
|
|
||||||
|
|
||||||
// 统计数据
|
|
||||||
import { useStatsApi, usePanApi } from '~/composables/useApi'
|
|
||||||
|
|
||||||
const statsApi = useStatsApi()
|
|
||||||
const panApi = usePanApi()
|
|
||||||
|
|
||||||
const { data: statsData, error: statsError } = await useAsyncData('stats', () => statsApi.getStats())
|
|
||||||
const stats = computed(() => (statsData.value as any) || {})
|
|
||||||
|
|
||||||
// 平台数据
|
|
||||||
const { data: pansData, error: pansError } = await useAsyncData('pans', () => panApi.getPans())
|
|
||||||
const pans = computed(() => (pansData.value as any) || [])
|
|
||||||
|
|
||||||
// 错误处理
|
|
||||||
const notification = useNotification()
|
|
||||||
|
|
||||||
// 监听错误
|
|
||||||
watch(statsError, (error) => {
|
|
||||||
if (error) {
|
|
||||||
console.error('获取统计数据失败:', error)
|
|
||||||
notification.error({
|
|
||||||
content: error.message || '获取统计数据失败',
|
|
||||||
duration: 5000
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(pansError, (error) => {
|
|
||||||
if (error) {
|
|
||||||
console.error('获取平台数据失败:', error)
|
|
||||||
notification.error({
|
|
||||||
content: error.message || '获取平台数据失败',
|
|
||||||
duration: 5000
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 分类管理相关
|
|
||||||
const goToCategoryManagement = () => {
|
|
||||||
navigateTo('/admin-old/categories')
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToAddCategory = () => {
|
|
||||||
navigateTo('/admin-old/categories')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 标签管理相关
|
|
||||||
const goToTagManagement = () => {
|
|
||||||
navigateTo('/admin-old/tags')
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToAddTag = () => {
|
|
||||||
navigateTo('/admin-old/tags')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 权限检查已在 admin 布局中处理
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 可以添加自定义样式 */
|
|
||||||
</style>
|
|
||||||
@@ -1,587 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-100 p-3 sm:p-5">
|
|
||||||
<!-- 全局加载状态 -->
|
|
||||||
<div v-if="pageLoading" class="fixed inset-0 bg-gray-900 bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-8 shadow-xl">
|
|
||||||
<div class="flex flex-col items-center space-y-4">
|
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
|
||||||
<div class="text-center">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">正在加载...</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">请稍候,正在加载待处理资源</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="max-w-7xl mx-auto">
|
|
||||||
|
|
||||||
<!-- 自动处理配置状态 -->
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 mb-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<i class="fas fa-cog text-gray-600 dark:text-gray-400"></i>
|
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">自动处理配置:</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div
|
|
||||||
:class="[
|
|
||||||
'w-3 h-3 rounded-full',
|
|
||||||
systemConfig?.auto_process_ready_resources
|
|
||||||
? 'bg-green-500 animate-pulse'
|
|
||||||
: 'bg-red-500'
|
|
||||||
]"
|
|
||||||
></div>
|
|
||||||
<span
|
|
||||||
:class="[
|
|
||||||
'text-sm font-medium',
|
|
||||||
systemConfig?.auto_process_ready_resources
|
|
||||||
? 'text-green-600 dark:text-green-400'
|
|
||||||
: 'text-red-600 dark:text-red-400'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ systemConfig?.auto_process_ready_resources ? '已开启' : '已关闭' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
<i class="fas fa-info-circle mr-1"></i>
|
|
||||||
{{ systemConfig?.auto_process_ready_resources
|
|
||||||
? '系统会自动处理待处理资源并入库'
|
|
||||||
: '需要手动处理待处理资源'
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
@click="refreshConfig"
|
|
||||||
:disabled="updatingConfig"
|
|
||||||
class="px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 rounded-md transition-colors"
|
|
||||||
title="刷新配置"
|
|
||||||
>
|
|
||||||
<i class="fas fa-sync-alt"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="toggleAutoProcess"
|
|
||||||
:disabled="updatingConfig"
|
|
||||||
:class="[
|
|
||||||
'px-3 py-1 text-xs rounded-md transition-colors flex items-center gap-1',
|
|
||||||
systemConfig?.auto_process_ready_resources
|
|
||||||
? 'bg-red-100 hover:bg-red-200 text-red-700 dark:bg-red-900/20 dark:text-red-400'
|
|
||||||
: 'bg-green-100 hover:bg-green-200 text-green-700 dark:bg-green-900/20 dark:text-green-400'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<i v-if="updatingConfig" class="fas fa-spinner fa-spin"></i>
|
|
||||||
<i v-else :class="systemConfig?.auto_process_ready_resources ? 'fas fa-pause' : 'fas fa-play'"></i>
|
|
||||||
{{ systemConfig?.auto_process_ready_resources ? '关闭' : '开启' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<NuxtLink
|
|
||||||
to="/admin-old/failed-resources"
|
|
||||||
class="w-full sm:w-auto px-4 py-2 bg-red-600 hover:bg-red-700 rounded-md transition-colors text-center flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<i class="fas fa-plus"></i> 错误资源
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
|
||||||
to="/admin-old/add-resource"
|
|
||||||
class="w-full sm:w-auto px-4 py-2 bg-green-600 hover:bg-green-700 rounded-md transition-colors text-center flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<i class="fas fa-plus"></i> 添加资源
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<n-button
|
|
||||||
@click="refreshData"
|
|
||||||
type="tertiary"
|
|
||||||
>
|
|
||||||
<i class="fas fa-refresh"></i> 刷新
|
|
||||||
</n-button>
|
|
||||||
<n-button
|
|
||||||
@click="clearAll"
|
|
||||||
type="error"
|
|
||||||
>
|
|
||||||
<i class="fas fa-trash"></i> 清空全部
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 资源列表 -->
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full">
|
|
||||||
<thead class="bg-slate-800 dark:bg-gray-700 text-white dark:text-gray-100 sticky top-0 z-10">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">ID</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">标题</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">URL</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">创建时间</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">IP地址</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700 max-h-96 overflow-y-auto">
|
|
||||||
<tr v-if="loading" class="text-center py-8">
|
|
||||||
<td colspan="6" class="text-gray-500 dark:text-gray-400">
|
|
||||||
<i class="fas fa-spinner fa-spin mr-2"></i>加载中...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-else-if="readyResources.length === 0">
|
|
||||||
<td colspan="6">
|
|
||||||
<div class="flex flex-col items-center justify-center py-12">
|
|
||||||
<svg class="w-16 h-16 text-gray-300 dark:text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
|
||||||
<circle cx="24" cy="24" r="20" stroke-width="3" stroke-dasharray="6 6" />
|
|
||||||
<path d="M16 24h16M24 16v16" stroke-width="3" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
<div class="text-lg font-semibold text-gray-400 dark:text-gray-500 mb-2">暂无待处理资源</div>
|
|
||||||
<div class="text-sm text-gray-400 dark:text-gray-600 mb-4">你可以点击上方"添加资源"按钮快速导入资源</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<NuxtLink
|
|
||||||
to="/admin-old/add-resource"
|
|
||||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors text-sm flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<i class="fas fa-plus"></i> 添加资源
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr
|
|
||||||
v-for="resource in readyResources"
|
|
||||||
:key="resource.id"
|
|
||||||
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
|
||||||
>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 font-medium">{{ resource.id }}</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
<span v-if="resource.title" :title="resource.title">{{ escapeHtml(resource.title) }}</span>
|
|
||||||
<span v-else class="text-gray-400 dark:text-gray-500 italic">未设置</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<a
|
|
||||||
:href="checkUrlSafety(resource.url)"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:underline break-all"
|
|
||||||
:title="resource.url"
|
|
||||||
>
|
|
||||||
{{ escapeHtml(resource.url) }}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{{ formatTime(resource.create_time) }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{{ escapeHtml(resource.ip || '-') }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<button
|
|
||||||
@click="deleteResource(resource.id)"
|
|
||||||
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
|
||||||
title="删除此资源"
|
|
||||||
>
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 分页组件 -->
|
|
||||||
<div v-if="totalPages > 1" class="mt-6 flex justify-center">
|
|
||||||
<div class="flex items-center space-x-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4">
|
|
||||||
<!-- 总资源数 -->
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
共 <span class="font-semibold text-gray-900 dark:text-gray-100">{{ totalCount }}</span> 个待处理资源
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-px h-6 bg-gray-300 dark:bg-gray-600"></div>
|
|
||||||
|
|
||||||
<!-- 上一页 -->
|
|
||||||
<button
|
|
||||||
@click="goToPage(currentPage - 1)"
|
|
||||||
:disabled="currentPage <= 1"
|
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-500 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<i class="fas fa-chevron-left"></i>
|
|
||||||
<span>上一页</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- 页码 -->
|
|
||||||
<template v-for="page in visiblePages" :key="page">
|
|
||||||
<button
|
|
||||||
v-if="typeof page === 'number'"
|
|
||||||
@click="goToPage(page)"
|
|
||||||
:class="[
|
|
||||||
'px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 min-w-[40px]',
|
|
||||||
page === currentPage
|
|
||||||
? 'bg-blue-600 text-white shadow-md'
|
|
||||||
: 'text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ page }}
|
|
||||||
</button>
|
|
||||||
<span v-else class="px-3 py-2 text-sm text-gray-500">...</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 下一页 -->
|
|
||||||
<button
|
|
||||||
@click="goToPage(currentPage + 1)"
|
|
||||||
:disabled="currentPage >= totalPages"
|
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-500 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<span>下一页</span>
|
|
||||||
<i class="fas fa-chevron-right"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 统计信息 -->
|
|
||||||
<div v-if="totalPages <= 1" class="mt-4 text-center">
|
|
||||||
<div class="inline-flex items-center bg-white dark:bg-gray-800 rounded-lg shadow px-6 py-3">
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
共 <span class="font-semibold text-gray-900 dark:text-gray-100">{{ totalCount }}</span> 个待处理资源
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
// 设置页面布局
|
|
||||||
definePageMeta({
|
|
||||||
layout: 'admin-old',
|
|
||||||
ssr: false
|
|
||||||
})
|
|
||||||
|
|
||||||
interface ReadyResource {
|
|
||||||
id: number
|
|
||||||
title?: string
|
|
||||||
url: string
|
|
||||||
create_time: string
|
|
||||||
ip?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const notification = useNotification()
|
|
||||||
const readyResources = ref<ReadyResource[]>([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const pageLoading = ref(true) // 添加页面加载状态
|
|
||||||
|
|
||||||
// 分页相关状态
|
|
||||||
const currentPage = ref(1)
|
|
||||||
const pageSize = ref(100)
|
|
||||||
const totalCount = ref(0)
|
|
||||||
const totalPages = ref(0)
|
|
||||||
|
|
||||||
// 获取待处理资源API
|
|
||||||
import { useReadyResourceApi, useSystemConfigApi } from '~/composables/useApi'
|
|
||||||
import { useSystemConfigStore } from '~/stores/systemConfig'
|
|
||||||
const readyResourceApi = useReadyResourceApi()
|
|
||||||
const systemConfigApi = useSystemConfigApi()
|
|
||||||
const systemConfigStore = useSystemConfigStore()
|
|
||||||
|
|
||||||
// 获取系统配置
|
|
||||||
const systemConfig = ref<any>(null)
|
|
||||||
const updatingConfig = ref(false) // 添加配置更新状态
|
|
||||||
const dialog = useDialog()
|
|
||||||
const fetchSystemConfig = async () => {
|
|
||||||
try {
|
|
||||||
const response = await systemConfigApi.getSystemConfig()
|
|
||||||
systemConfig.value = response
|
|
||||||
// 同时更新 Pinia store
|
|
||||||
systemConfigStore.setConfig(response)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取系统配置失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取数据
|
|
||||||
const fetchData = async () => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const response = await readyResourceApi.getReadyResources({
|
|
||||||
page: currentPage.value,
|
|
||||||
page_size: pageSize.value
|
|
||||||
}) as any
|
|
||||||
|
|
||||||
// 适配后端API响应格式
|
|
||||||
if (response && response.data) {
|
|
||||||
readyResources.value = response.data
|
|
||||||
// 后端返回格式: {data: [...], page: 1, page_size: 100, total: 123}
|
|
||||||
totalCount.value = response.total || 0
|
|
||||||
totalPages.value = Math.ceil((response.total || 0) / pageSize.value)
|
|
||||||
} else if (Array.isArray(response)) {
|
|
||||||
// 如果直接返回数组
|
|
||||||
readyResources.value = response
|
|
||||||
totalCount.value = response.length
|
|
||||||
totalPages.value = 1
|
|
||||||
} else {
|
|
||||||
readyResources.value = []
|
|
||||||
totalCount.value = 0
|
|
||||||
totalPages.value = 1
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取待处理资源失败:', error)
|
|
||||||
readyResources.value = []
|
|
||||||
totalCount.value = 0
|
|
||||||
totalPages.value = 1
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 跳转到指定页面
|
|
||||||
const goToPage = (page: number) => {
|
|
||||||
if (page >= 1 && page <= totalPages.value) {
|
|
||||||
currentPage.value = page
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算可见的页码
|
|
||||||
const visiblePages = computed(() => {
|
|
||||||
const pages: (number | string)[] = []
|
|
||||||
const maxVisible = 5
|
|
||||||
|
|
||||||
if (totalPages.value <= maxVisible) {
|
|
||||||
// 如果总页数不多,显示所有页码
|
|
||||||
for (let i = 1; i <= totalPages.value; i++) {
|
|
||||||
pages.push(i)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 如果总页数很多,显示部分页码
|
|
||||||
if (currentPage.value <= 3) {
|
|
||||||
// 当前页在前几页
|
|
||||||
for (let i = 1; i <= 4; i++) {
|
|
||||||
pages.push(i)
|
|
||||||
}
|
|
||||||
pages.push('...')
|
|
||||||
pages.push(totalPages.value)
|
|
||||||
} else if (currentPage.value >= totalPages.value - 2) {
|
|
||||||
// 当前页在后几页
|
|
||||||
pages.push(1)
|
|
||||||
pages.push('...')
|
|
||||||
for (let i = totalPages.value - 3; i <= totalPages.value; i++) {
|
|
||||||
pages.push(i)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 当前页在中间
|
|
||||||
pages.push(1)
|
|
||||||
pages.push('...')
|
|
||||||
for (let i = currentPage.value - 1; i <= currentPage.value + 1; i++) {
|
|
||||||
pages.push(i)
|
|
||||||
}
|
|
||||||
pages.push('...')
|
|
||||||
pages.push(totalPages.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return pages
|
|
||||||
})
|
|
||||||
|
|
||||||
// 刷新数据
|
|
||||||
const refreshData = () => {
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新配置
|
|
||||||
const refreshConfig = () => {
|
|
||||||
fetchSystemConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 删除资源
|
|
||||||
const deleteResource = async (id: number) => {
|
|
||||||
dialog.warning({
|
|
||||||
title: '警告',
|
|
||||||
content: '确定要删除这个待处理资源吗?',
|
|
||||||
positiveText: '确定',
|
|
||||||
negativeText: '取消',
|
|
||||||
draggable: true,
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
try {
|
|
||||||
await readyResourceApi.deleteReadyResource(id)
|
|
||||||
// 如果当前页没有数据了,回到上一页
|
|
||||||
if (readyResources.value.length === 1 && currentPage.value > 1) {
|
|
||||||
currentPage.value--
|
|
||||||
}
|
|
||||||
fetchData()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除失败:', error)
|
|
||||||
notification.error({
|
|
||||||
title: '失败',
|
|
||||||
content: '删除失败',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清空全部
|
|
||||||
const clearAll = async () => {
|
|
||||||
dialog.warning({
|
|
||||||
title: '警告',
|
|
||||||
content: '确定要清空所有待处理资源吗?此操作不可恢复!',
|
|
||||||
positiveText: '确定',
|
|
||||||
negativeText: '取消',
|
|
||||||
draggable: true,
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
try {
|
|
||||||
const response = await readyResourceApi.clearReadyResources() as any
|
|
||||||
console.log('清空成功:', response)
|
|
||||||
currentPage.value = 1 // 清空后回到第一页
|
|
||||||
fetchData()
|
|
||||||
notification.success({
|
|
||||||
title: '成功',
|
|
||||||
content: `成功清空 ${response.data.deleted_count} 个资源`,
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('清空失败:', error)
|
|
||||||
notification.error({
|
|
||||||
title: '失败',
|
|
||||||
content: '清空失败',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化时间
|
|
||||||
const formatTime = (timeString: string) => {
|
|
||||||
const date = new Date(timeString)
|
|
||||||
return date.toLocaleString('zh-CN')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转义HTML防止XSS
|
|
||||||
const escapeHtml = (text: string) => {
|
|
||||||
if (!text) return text
|
|
||||||
const div = document.createElement('div')
|
|
||||||
div.textContent = text
|
|
||||||
return div.innerHTML
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证URL安全性
|
|
||||||
const checkUrlSafety = (url: string) => {
|
|
||||||
if (!url) return '#'
|
|
||||||
try {
|
|
||||||
const urlObj = new URL(url)
|
|
||||||
// 只允许http和https协议
|
|
||||||
if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') {
|
|
||||||
return '#'
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
} catch {
|
|
||||||
return '#'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换自动处理配置
|
|
||||||
const toggleAutoProcess = async () => {
|
|
||||||
if (updatingConfig.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
updatingConfig.value = true
|
|
||||||
try {
|
|
||||||
const newValue = !systemConfig.value?.auto_process_ready_resources
|
|
||||||
console.log('切换自动处理配置:', newValue)
|
|
||||||
|
|
||||||
// 使用专门的切换API
|
|
||||||
const response = await systemConfigApi.toggleAutoProcess(newValue)
|
|
||||||
console.log('切换响应:', response)
|
|
||||||
|
|
||||||
// 更新本地配置状态
|
|
||||||
systemConfig.value = response
|
|
||||||
|
|
||||||
// 同时更新 Pinia store 中的系统配置
|
|
||||||
systemConfigStore.setConfig(response)
|
|
||||||
|
|
||||||
notification.success({
|
|
||||||
title: '成功',
|
|
||||||
content: `自动处理配置已${newValue ? '开启' : '关闭'}`,
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
} catch (error: any) {
|
|
||||||
notification.error({
|
|
||||||
title: '失败',
|
|
||||||
content: `切换自动处理配置失败`,
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
updatingConfig.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 页面加载时获取数据
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
await fetchData()
|
|
||||||
await fetchSystemConfig()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('页面初始化失败:', error)
|
|
||||||
} finally {
|
|
||||||
// 数据加载完成后,关闭加载状态
|
|
||||||
pageLoading.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 表格滚动样式 */
|
|
||||||
.overflow-x-auto {
|
|
||||||
max-height: 500px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 表格头部固定 */
|
|
||||||
thead {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 分页按钮悬停效果 */
|
|
||||||
.pagination-button:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 当前页码按钮效果 */
|
|
||||||
.current-page {
|
|
||||||
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3), 0 2px 4px -1px rgba(59, 130, 246, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 表格行悬停效果 */
|
|
||||||
tbody tr:hover {
|
|
||||||
background-color: rgba(59, 130, 246, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 暗黑模式下的表格行悬停 */
|
|
||||||
.dark tbody tr:hover {
|
|
||||||
background-color: rgba(59, 130, 246, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 统计信息卡片效果 */
|
|
||||||
.stats-card {
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
background-color: rgba(255, 255, 255, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .stats-card {
|
|
||||||
background-color: rgba(31, 41, 55, 0.9);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,828 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-100">
|
|
||||||
<!-- 全局加载状态 -->
|
|
||||||
<div v-if="pageLoading" class="fixed inset-0 bg-gray-900 bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-8 shadow-xl">
|
|
||||||
<div class="flex flex-col items-center space-y-4">
|
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
|
||||||
<div class="text-center">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">正在加载...</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">请稍候,正在加载资源数据</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-6">
|
|
||||||
<n-alert class="mb-4" title="资源管理功能,可以查看、搜索、筛选、编辑和删除资源" type="info" />
|
|
||||||
|
|
||||||
<div class="max-w-7xl mx-auto">
|
|
||||||
|
|
||||||
<!-- 搜索和筛选区域 -->
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 mb-6">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
<!-- 搜索框 -->
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">搜索资源</label>
|
|
||||||
<div class="relative">
|
|
||||||
<n-input
|
|
||||||
v-model:value="searchQuery"
|
|
||||||
@keyup.enter="handleSearch"
|
|
||||||
type="text"
|
|
||||||
placeholder="输入文件名或链接进行搜索..."
|
|
||||||
/>
|
|
||||||
<div class="absolute right-3 top-1/2 transform -translate-y-1/2">
|
|
||||||
<i class="fas fa-search text-gray-400"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 平台筛选 -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">平台筛选</label>
|
|
||||||
<select
|
|
||||||
v-model="selectedPlatform"
|
|
||||||
@change="handleSearch"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-700 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-900 dark:text-gray-100"
|
|
||||||
>
|
|
||||||
<option value="">全部平台</option>
|
|
||||||
<option v-for="platform in platforms" :key="platform.id" :value="platform.id">
|
|
||||||
{{ platform.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 分类筛选 -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">分类筛选</label>
|
|
||||||
<select
|
|
||||||
v-model="selectedCategory"
|
|
||||||
@change="handleSearch"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-700 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-900 dark:text-gray-100"
|
|
||||||
>
|
|
||||||
<option value="">全部分类</option>
|
|
||||||
<option v-for="category in categories" :key="category.id" :value="category.id">
|
|
||||||
{{ category.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 搜索按钮 -->
|
|
||||||
<div class="mt-4 flex justify-between items-center">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<n-button
|
|
||||||
@click="handleSearch"
|
|
||||||
type="primary"
|
|
||||||
>
|
|
||||||
<i class="fas fa-search"></i> 搜索
|
|
||||||
</n-button>
|
|
||||||
<n-button
|
|
||||||
@click="clearFilters"
|
|
||||||
type="tertiary"
|
|
||||||
>
|
|
||||||
<i class="fas fa-times"></i> 清除筛选
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
共找到 <span class="font-semibold text-gray-900 dark:text-gray-100">{{ totalCount }}</span> 个资源
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<n-button
|
|
||||||
@click="showBatchModal = true"
|
|
||||||
type="primary"
|
|
||||||
>
|
|
||||||
<i class="fas fa-list"></i> 批量操作
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<n-button
|
|
||||||
@click="refreshData"
|
|
||||||
type="tertiary"
|
|
||||||
>
|
|
||||||
<i class="fas fa-refresh"></i> 刷新
|
|
||||||
</n-button>
|
|
||||||
<n-button
|
|
||||||
@click="exportData"
|
|
||||||
type="info"
|
|
||||||
>
|
|
||||||
<i class="fas fa-download"></i> 导出
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 批量操作模态框 -->
|
|
||||||
<div v-if="showBatchModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-xl p-6 max-w-2xl w-full mx-4 text-gray-900 dark:text-gray-100">
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h3 class="text-lg font-bold">批量操作</h3>
|
|
||||||
<n-button @click="closeBatchModal" type="tertiary" size="small">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">选择操作:</label>
|
|
||||||
<select
|
|
||||||
v-model="batchAction"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-900 dark:text-gray-100"
|
|
||||||
>
|
|
||||||
<option value="">请选择操作</option>
|
|
||||||
<option value="delete">批量删除</option>
|
|
||||||
<option value="update_category">批量更新分类</option>
|
|
||||||
<option value="update_tags">批量更新标签</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="batchAction === 'update_category'">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">选择分类:</label>
|
|
||||||
<select
|
|
||||||
v-model="batchCategory"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-900 dark:text-gray-100"
|
|
||||||
>
|
|
||||||
<option value="">请选择分类</option>
|
|
||||||
<option v-for="category in categories" :key="category.id" :value="category.id">
|
|
||||||
{{ category.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="batchAction === 'update_tags'">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">选择标签:</label>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div v-for="tag in tags" :key="tag.id" class="flex items-center">
|
|
||||||
<n-checkbox
|
|
||||||
:value="tag.id"
|
|
||||||
:checked="batchTags.includes(tag.id)"
|
|
||||||
@update:checked="(checked) => {
|
|
||||||
if (checked) {
|
|
||||||
batchTags.push(tag.id)
|
|
||||||
} else {
|
|
||||||
const index = batchTags.indexOf(tag.id)
|
|
||||||
if (index > -1) {
|
|
||||||
batchTags.splice(index, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
<span class="text-sm">{{ tag.name }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-2 mt-6">
|
|
||||||
<n-button @click="closeBatchModal" type="tertiary">
|
|
||||||
取消
|
|
||||||
</n-button>
|
|
||||||
<n-button @click="handleBatchAction" type="primary">
|
|
||||||
执行操作
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 资源列表 -->
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full">
|
|
||||||
<thead class="bg-slate-800 dark:bg-gray-700 text-white dark:text-gray-100 sticky top-0 z-10">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">
|
|
||||||
<n-checkbox
|
|
||||||
v-model:checked="selectAll"
|
|
||||||
@update:checked="toggleSelectAll"
|
|
||||||
/>
|
|
||||||
ID
|
|
||||||
</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">标题</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">平台</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">分类</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">链接</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">浏览量</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">更新时间</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700 max-h-96 overflow-y-auto">
|
|
||||||
<tr v-if="loading" class="text-center py-8">
|
|
||||||
<td colspan="8" class="text-gray-500 dark:text-gray-400">
|
|
||||||
<i class="fas fa-spinner fa-spin mr-2"></i>加载中...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-else-if="resources.length === 0">
|
|
||||||
<td colspan="8">
|
|
||||||
<div class="flex flex-col items-center justify-center py-12">
|
|
||||||
<svg class="w-16 h-16 text-gray-300 dark:text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
|
||||||
<circle cx="24" cy="24" r="20" stroke-width="3" stroke-dasharray="6 6" />
|
|
||||||
<path d="M16 24h16M24 16v16" stroke-width="3" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
<div class="text-lg font-semibold text-gray-400 dark:text-gray-500 mb-2">暂无资源数据</div>
|
|
||||||
<div class="text-sm text-gray-400 dark:text-gray-600 mb-4">你可以点击上方"添加资源"按钮快速导入资源</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<NuxtLink
|
|
||||||
to="/admin-old/add-resource"
|
|
||||||
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors text-sm flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<i class="fas fa-plus"></i> 添加资源
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr
|
|
||||||
v-for="resource in resources"
|
|
||||||
:key="resource.id"
|
|
||||||
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
|
||||||
>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 font-medium">
|
|
||||||
<n-checkbox
|
|
||||||
:value="resource.id"
|
|
||||||
:checked="selectedResources.includes(resource.id)"
|
|
||||||
@update:checked="(checked) => {
|
|
||||||
if (checked) {
|
|
||||||
selectedResources.push(resource.id)
|
|
||||||
} else {
|
|
||||||
const index = selectedResources.indexOf(resource.id)
|
|
||||||
if (index > -1) {
|
|
||||||
selectedResources.splice(index, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
{{ resource.id }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
<span :title="resource.title">{{ escapeHtml(resource.title) }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
|
||||||
{{ getPlatformName(resource.pan_id) }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{{ getCategoryName(resource.category_id) || '-' }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<a
|
|
||||||
:href="checkUrlSafety(resource.url)"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:underline break-all"
|
|
||||||
:title="resource.url"
|
|
||||||
>
|
|
||||||
{{ escapeHtml(resource.url) }}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{{ resource.view_count || 0 }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{{ formatTime(resource.updated_at) }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button
|
|
||||||
@click="editResource(resource)"
|
|
||||||
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
|
|
||||||
title="编辑资源"
|
|
||||||
>
|
|
||||||
<i class="fas fa-edit"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="deleteResource(resource.id)"
|
|
||||||
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
|
||||||
title="删除资源"
|
|
||||||
>
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 分页组件 -->
|
|
||||||
<div v-if="totalPages > 1" class="mt-6 flex justify-center">
|
|
||||||
<div class="flex items-center space-x-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4">
|
|
||||||
<!-- 总资源数 -->
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
共 <span class="font-semibold text-gray-900 dark:text-gray-100">{{ totalCount }}</span> 个资源
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-px h-6 bg-gray-300 dark:bg-gray-600"></div>
|
|
||||||
|
|
||||||
<!-- 上一页 -->
|
|
||||||
<button
|
|
||||||
@click="goToPage(currentPage - 1)"
|
|
||||||
:disabled="currentPage <= 1"
|
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-500 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<i class="fas fa-chevron-left"></i>
|
|
||||||
<span>上一页</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- 页码 -->
|
|
||||||
<template v-for="page in visiblePages" :key="page">
|
|
||||||
<button
|
|
||||||
v-if="typeof page === 'number'"
|
|
||||||
@click="goToPage(page)"
|
|
||||||
:class="[
|
|
||||||
'px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 min-w-[40px]',
|
|
||||||
page === currentPage
|
|
||||||
? 'bg-blue-600 text-white shadow-md'
|
|
||||||
: 'text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ page }}
|
|
||||||
</button>
|
|
||||||
<span v-else class="px-3 py-2 text-sm text-gray-500">...</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 下一页 -->
|
|
||||||
<button
|
|
||||||
@click="goToPage(currentPage + 1)"
|
|
||||||
:disabled="currentPage >= totalPages"
|
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-500 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<span>下一页</span>
|
|
||||||
<i class="fas fa-chevron-right"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 统计信息 -->
|
|
||||||
<div v-if="totalPages <= 1" class="mt-4 text-center">
|
|
||||||
<div class="inline-flex items-center bg-white dark:bg-gray-800 rounded-lg shadow px-6 py-3">
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
共 <span class="font-semibold text-gray-900 dark:text-gray-100">{{ totalCount }}</span> 个资源
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
// 设置页面布局
|
|
||||||
definePageMeta({
|
|
||||||
layout: 'admin-old',
|
|
||||||
ssr: false
|
|
||||||
})
|
|
||||||
|
|
||||||
interface Resource {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
url: string
|
|
||||||
pan_id?: number
|
|
||||||
category_id?: number
|
|
||||||
view_count: number
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Platform {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Category {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Tag {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const notification = useNotification()
|
|
||||||
const resources = ref<Resource[]>([])
|
|
||||||
const platforms = ref<Platform[]>([])
|
|
||||||
const categories = ref<Category[]>([])
|
|
||||||
const tags = ref<Tag[]>([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const pageLoading = ref(true)
|
|
||||||
|
|
||||||
// 搜索和筛选状态
|
|
||||||
const searchQuery = ref('')
|
|
||||||
const selectedPlatform = ref('')
|
|
||||||
const selectedCategory = ref('')
|
|
||||||
|
|
||||||
// 分页相关状态
|
|
||||||
const currentPage = ref(1)
|
|
||||||
const pageSize = ref(50)
|
|
||||||
const totalCount = ref(0)
|
|
||||||
const totalPages = ref(0)
|
|
||||||
|
|
||||||
// 批量操作状态
|
|
||||||
const showBatchModal = ref(false)
|
|
||||||
const batchAction = ref('')
|
|
||||||
const batchCategory = ref('')
|
|
||||||
const batchTags = ref<number[]>([])
|
|
||||||
const selectedResources = ref<number[]>([])
|
|
||||||
const selectAll = ref(false)
|
|
||||||
const dialog = useDialog()
|
|
||||||
|
|
||||||
// API
|
|
||||||
import { useResourceApi, usePanApi, useCategoryApi, useTagApi } from '~/composables/useApi'
|
|
||||||
|
|
||||||
const resourceApi = useResourceApi()
|
|
||||||
const panApi = usePanApi()
|
|
||||||
const categoryApi = useCategoryApi()
|
|
||||||
const tagApi = useTagApi()
|
|
||||||
|
|
||||||
// 获取数据
|
|
||||||
const fetchData = async () => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const params: any = {
|
|
||||||
page: currentPage.value,
|
|
||||||
page_size: pageSize.value
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchQuery.value) {
|
|
||||||
params.search = searchQuery.value
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedPlatform.value) {
|
|
||||||
params.pan_id = selectedPlatform.value
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedCategory.value) {
|
|
||||||
params.category_id = selectedCategory.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await resourceApi.getResources(params) as any
|
|
||||||
console.log('DEBUG - 资源API响应:', response)
|
|
||||||
|
|
||||||
// 适配后端API响应格式
|
|
||||||
if (response && response.data && Array.isArray(response.data)) {
|
|
||||||
console.log('使用 data 格式:', response.data)
|
|
||||||
resources.value = response.data
|
|
||||||
totalCount.value = response.total || 0
|
|
||||||
totalPages.value = Math.ceil((response.total || 0) / pageSize.value)
|
|
||||||
} else if (Array.isArray(response)) {
|
|
||||||
console.log('使用数组格式:', response)
|
|
||||||
resources.value = response
|
|
||||||
totalCount.value = response.length
|
|
||||||
totalPages.value = 1
|
|
||||||
} else {
|
|
||||||
console.log('使用默认格式:', response)
|
|
||||||
resources.value = []
|
|
||||||
totalCount.value = 0
|
|
||||||
totalPages.value = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('最终资源数据:', resources.value)
|
|
||||||
console.log('资源数据长度:', resources.value.length)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取资源失败:', error)
|
|
||||||
resources.value = []
|
|
||||||
totalCount.value = 0
|
|
||||||
totalPages.value = 1
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取平台列表
|
|
||||||
const fetchPlatforms = async () => {
|
|
||||||
try {
|
|
||||||
const response = await panApi.getPans() as any
|
|
||||||
console.log('平台API响应:', response)
|
|
||||||
|
|
||||||
// 适配后端API响应格式
|
|
||||||
if (response && response.data && Array.isArray(response.data)) {
|
|
||||||
console.log('使用 data 格式:', response.data)
|
|
||||||
platforms.value = response.data
|
|
||||||
} else if (Array.isArray(response)) {
|
|
||||||
console.log('使用数组格式:', response)
|
|
||||||
platforms.value = response
|
|
||||||
} else {
|
|
||||||
console.log('使用默认格式:', response)
|
|
||||||
platforms.value = []
|
|
||||||
}
|
|
||||||
console.log('最终平台数据:', platforms.value)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取平台列表失败:', error)
|
|
||||||
platforms.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取分类列表
|
|
||||||
const fetchCategories = async () => {
|
|
||||||
try {
|
|
||||||
const response = await categoryApi.getCategories() as any
|
|
||||||
console.log('分类API响应:', response)
|
|
||||||
|
|
||||||
// 适配后端API响应格式
|
|
||||||
if (response && response.data && Array.isArray(response.data)) {
|
|
||||||
console.log('使用 data 格式:', response.data)
|
|
||||||
categories.value = response.data
|
|
||||||
} else if (Array.isArray(response)) {
|
|
||||||
console.log('使用数组格式:', response)
|
|
||||||
categories.value = response
|
|
||||||
} else {
|
|
||||||
console.log('使用默认格式:', response)
|
|
||||||
categories.value = []
|
|
||||||
}
|
|
||||||
console.log('最终分类数据:', categories.value)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取分类列表失败:', error)
|
|
||||||
categories.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取标签列表
|
|
||||||
const fetchTags = async () => {
|
|
||||||
try {
|
|
||||||
const response = await tagApi.getTags() as any
|
|
||||||
console.log('标签API响应:', response)
|
|
||||||
|
|
||||||
// 适配后端API响应格式
|
|
||||||
if (response && response.data && Array.isArray(response.data)) {
|
|
||||||
console.log('使用 data 格式:', response.data)
|
|
||||||
tags.value = response.data
|
|
||||||
} else if (Array.isArray(response)) {
|
|
||||||
console.log('使用数组格式:', response)
|
|
||||||
tags.value = response
|
|
||||||
} else {
|
|
||||||
console.log('使用默认格式:', response)
|
|
||||||
tags.value = []
|
|
||||||
}
|
|
||||||
console.log('最终标签数据:', tags.value)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取标签列表失败:', error)
|
|
||||||
tags.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 搜索处理
|
|
||||||
const handleSearch = () => {
|
|
||||||
currentPage.value = 1
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除筛选
|
|
||||||
const clearFilters = () => {
|
|
||||||
searchQuery.value = ''
|
|
||||||
selectedPlatform.value = ''
|
|
||||||
selectedCategory.value = ''
|
|
||||||
currentPage.value = 1
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新数据
|
|
||||||
const refreshData = () => {
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导出数据
|
|
||||||
const exportData = () => {
|
|
||||||
// 实现导出功能
|
|
||||||
console.log('导出数据功能待实现')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分页处理
|
|
||||||
const goToPage = (page: number) => {
|
|
||||||
currentPage.value = page
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算可见页码
|
|
||||||
const visiblePages = computed(() => {
|
|
||||||
const pages = []
|
|
||||||
const maxVisible = 5
|
|
||||||
|
|
||||||
if (totalPages.value <= maxVisible) {
|
|
||||||
for (let i = 1; i <= totalPages.value; i++) {
|
|
||||||
pages.push(i)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (currentPage.value <= 3) {
|
|
||||||
for (let i = 1; i <= 4; i++) {
|
|
||||||
pages.push(i)
|
|
||||||
}
|
|
||||||
pages.push('...')
|
|
||||||
pages.push(totalPages.value)
|
|
||||||
} else if (currentPage.value >= totalPages.value - 2) {
|
|
||||||
pages.push(1)
|
|
||||||
pages.push('...')
|
|
||||||
for (let i = totalPages.value - 3; i <= totalPages.value; i++) {
|
|
||||||
pages.push(i)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
pages.push(1)
|
|
||||||
pages.push('...')
|
|
||||||
for (let i = currentPage.value - 1; i <= currentPage.value + 1; i++) {
|
|
||||||
pages.push(i)
|
|
||||||
}
|
|
||||||
pages.push('...')
|
|
||||||
pages.push(totalPages.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return pages
|
|
||||||
})
|
|
||||||
|
|
||||||
// 全选/取消全选
|
|
||||||
const toggleSelectAll = (checked: boolean) => {
|
|
||||||
if (checked) {
|
|
||||||
selectedResources.value = resources.value.map(r => r.id)
|
|
||||||
} else {
|
|
||||||
selectedResources.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 批量操作
|
|
||||||
const handleBatchAction = async () => {
|
|
||||||
if (selectedResources.value.length === 0) {
|
|
||||||
notification.error({
|
|
||||||
title: '失败',
|
|
||||||
content: '请选择要操作的资源',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!batchAction.value) {
|
|
||||||
notification.error({
|
|
||||||
title: '失败',
|
|
||||||
content: '请选择操作类型',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (batchAction.value) {
|
|
||||||
case 'delete':
|
|
||||||
dialog.warning({
|
|
||||||
title: '警告',
|
|
||||||
content: `确定要删除选中的 ${selectedResources.value.length} 个资源吗?`,
|
|
||||||
positiveText: '确定',
|
|
||||||
negativeText: '取消',
|
|
||||||
draggable: true,
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
await resourceApi.batchDeleteResources(selectedResources.value)
|
|
||||||
notification.success({
|
|
||||||
title: '成功',
|
|
||||||
content: '批量删除成功',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return
|
|
||||||
break
|
|
||||||
case 'update_category':
|
|
||||||
if (!batchCategory.value) {
|
|
||||||
notification.error({
|
|
||||||
title: '失败',
|
|
||||||
content: '请选择分类',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await Promise.all(selectedResources.value.map(id =>
|
|
||||||
resourceApi.updateResource(id, { category_id: batchCategory.value })
|
|
||||||
))
|
|
||||||
notification.success({
|
|
||||||
title: '成功',
|
|
||||||
content: '批量更新分类成功',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case 'update_tags':
|
|
||||||
await Promise.all(selectedResources.value.map(id =>
|
|
||||||
resourceApi.updateResource(id, { tag_ids: batchTags.value })
|
|
||||||
))
|
|
||||||
notification.success({
|
|
||||||
title: '成功',
|
|
||||||
content: '批量更新标签成功',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
closeBatchModal()
|
|
||||||
fetchData()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('批量操作失败:', error)
|
|
||||||
notification.error({
|
|
||||||
title: '失败',
|
|
||||||
content: '批量操作失败',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭批量操作模态框
|
|
||||||
const closeBatchModal = () => {
|
|
||||||
showBatchModal.value = false
|
|
||||||
batchAction.value = ''
|
|
||||||
batchCategory.value = ''
|
|
||||||
batchTags.value = []
|
|
||||||
selectedResources.value = []
|
|
||||||
selectAll.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 编辑资源
|
|
||||||
const editResource = (resource: Resource) => {
|
|
||||||
// 跳转到编辑页面或打开编辑模态框
|
|
||||||
console.log('编辑资源:', resource)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除资源
|
|
||||||
const deleteResource = async (id: number) => {
|
|
||||||
dialog.warning({
|
|
||||||
title: '警告',
|
|
||||||
content: '确定要删除这个资源吗?',
|
|
||||||
positiveText: '确定',
|
|
||||||
negativeText: '取消',
|
|
||||||
draggable: true,
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
try {
|
|
||||||
await resourceApi.deleteResource(id)
|
|
||||||
notification.success({
|
|
||||||
title: '成功',
|
|
||||||
content: '删除成功',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
fetchData()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除失败:', error)
|
|
||||||
notification.error({
|
|
||||||
title: '失败',
|
|
||||||
content: '删除失败',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 工具函数
|
|
||||||
const escapeHtml = (text: string) => {
|
|
||||||
if (!text) return ''
|
|
||||||
return text
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkUrlSafety = (url: string) => {
|
|
||||||
if (!url) return '#'
|
|
||||||
// 检查URL安全性,这里可以添加更多检查逻辑
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatTime = (timeString: string) => {
|
|
||||||
if (!timeString) return '-'
|
|
||||||
const date = new Date(timeString)
|
|
||||||
return date.toLocaleString('zh-CN')
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPlatformName = (panId?: number) => {
|
|
||||||
if (!panId) return '未知'
|
|
||||||
const platform = platforms.value.find(p => p.id === panId)
|
|
||||||
return platform?.name || '未知'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCategoryName = (categoryId?: number) => {
|
|
||||||
if (!categoryId) return null
|
|
||||||
const category = categories.value.find(c => c.id === categoryId)
|
|
||||||
return category?.name || null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 页面初始化
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
await Promise.all([
|
|
||||||
fetchData(),
|
|
||||||
fetchPlatforms(),
|
|
||||||
fetchCategories(),
|
|
||||||
fetchTags()
|
|
||||||
])
|
|
||||||
} catch (error) {
|
|
||||||
console.error('页面初始化失败:', error)
|
|
||||||
} finally {
|
|
||||||
pageLoading.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 可以添加自定义样式 */
|
|
||||||
</style>
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="min-h-screen bg-gray-50">
|
|
||||||
<div class="container mx-auto px-4 py-8">
|
|
||||||
<!-- 页面标题 -->
|
|
||||||
<div class="mb-8">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900">搜索统计</h1>
|
|
||||||
<p class="text-gray-600 mt-2">查看搜索量统计和热门关键词分析</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 统计卡片 -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="p-3 rounded-full bg-blue-100 text-blue-600">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<p class="text-sm font-medium text-gray-600">今日搜索</p>
|
|
||||||
<p class="text-2xl font-bold text-gray-900">{{ stats.todaySearches }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="p-3 rounded-full bg-green-100 text-green-600">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<p class="text-sm font-medium text-gray-600">本周搜索</p>
|
|
||||||
<p class="text-2xl font-bold text-gray-900">{{ stats.weekSearches }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="p-3 rounded-full bg-purple-100 text-purple-600">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<p class="text-sm font-medium text-gray-600">本月搜索</p>
|
|
||||||
<p class="text-2xl font-bold text-gray-900">{{ stats.monthSearches }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 搜索趋势图表 -->
|
|
||||||
<div class="bg-white rounded-lg shadow p-6 mb-8">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">搜索趋势</h2>
|
|
||||||
<div class="h-64">
|
|
||||||
<canvas ref="trendChart"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 热门关键词 -->
|
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">热门关键词</h2>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div v-for="keyword in stats.hotKeywords" :key="keyword.keyword"
|
|
||||||
class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<span class="inline-flex items-center justify-center w-8 h-8 bg-blue-100 text-blue-600 rounded-full text-sm font-medium mr-3">
|
|
||||||
{{ keyword.rank }}
|
|
||||||
</span>
|
|
||||||
<span class="text-gray-900 font-medium">{{ keyword.keyword }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<span class="text-gray-600 mr-2">{{ keyword.count }}次</span>
|
|
||||||
<div class="w-24 bg-gray-200 rounded-full h-2">
|
|
||||||
<div class="bg-blue-600 h-2 rounded-full"
|
|
||||||
:style="{ width: getPercentage(keyword.count) + '%' }"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 搜索记录 -->
|
|
||||||
<div class="bg-white rounded-lg shadow p-6 mt-8">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">搜索记录</h2>
|
|
||||||
<table class="w-full table-auto text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="px-2 py-2 text-left">关键词</th>
|
|
||||||
<th class="px-2 py-2 text-left">次数</th>
|
|
||||||
<th class="px-2 py-2 text-left">日期</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="item in searchList" :key="item.id">
|
|
||||||
<td class="px-2 py-2">{{ item.keyword }}</td>
|
|
||||||
<td class="px-2 py-2">{{ item.count }}</td>
|
|
||||||
<td class="px-2 py-2">{{ item.date ? (new Date(item.date)).toLocaleDateString() : '' }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div v-if="searchList.length === 0" class="text-gray-400 text-center py-8">暂无搜索记录</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
// 设置页面布局
|
|
||||||
definePageMeta({
|
|
||||||
layout: 'admin-old',
|
|
||||||
ssr: false
|
|
||||||
})
|
|
||||||
|
|
||||||
import { ref, onMounted, computed } from 'vue'
|
|
||||||
import Chart from 'chart.js/auto'
|
|
||||||
import { useApiFetch } from '~/composables/useApiFetch'
|
|
||||||
import { parseApiResponse } from '~/composables/useApi'
|
|
||||||
|
|
||||||
const stats = ref({
|
|
||||||
todaySearches: 0,
|
|
||||||
weekSearches: 0,
|
|
||||||
monthSearches: 0,
|
|
||||||
hotKeywords: [],
|
|
||||||
searchTrend: {
|
|
||||||
days: [],
|
|
||||||
values: []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const searchList = ref([])
|
|
||||||
|
|
||||||
const trendChart = ref(null)
|
|
||||||
let chart = null
|
|
||||||
|
|
||||||
// 获取百分比
|
|
||||||
const getPercentage = (count) => {
|
|
||||||
if (stats.value.hotKeywords.length === 0) return 0
|
|
||||||
const maxCount = Math.max(...stats.value.hotKeywords.map(k => k.count))
|
|
||||||
return Math.round((count / maxCount) * 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载搜索统计
|
|
||||||
const loadSearchStats = async () => {
|
|
||||||
try {
|
|
||||||
// 1. 汇总卡片
|
|
||||||
const summary = await useApiFetch('/search-stats/summary').then(parseApiResponse)
|
|
||||||
stats.value.todaySearches = summary.today || 0
|
|
||||||
stats.value.weekSearches = summary.week || 0
|
|
||||||
stats.value.monthSearches = summary.month || 0
|
|
||||||
// 2. 热门关键词
|
|
||||||
const hotKeywords = await useApiFetch('/search-stats/hot-keywords').then(parseApiResponse)
|
|
||||||
stats.value.hotKeywords = hotKeywords || []
|
|
||||||
// 3. 趋势
|
|
||||||
const trend = await useApiFetch('/search-stats/trend').then(parseApiResponse)
|
|
||||||
stats.value.searchTrend.days = (trend || []).map(item => item.date ? (new Date(item.date)).toLocaleDateString() : '')
|
|
||||||
stats.value.searchTrend.values = (trend || []).map(item => item.total_searches)
|
|
||||||
// 4. 搜索记录
|
|
||||||
const data = await useApiFetch('/search-stats').then(parseApiResponse)
|
|
||||||
searchList.value = data || []
|
|
||||||
// 5. 更新图表
|
|
||||||
setTimeout(updateChart, 100)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载搜索统计失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新图表
|
|
||||||
const updateChart = () => {
|
|
||||||
if (chart) {
|
|
||||||
chart.destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = trendChart.value.getContext('2d')
|
|
||||||
chart = new Chart(ctx, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: stats.value.searchTrend.days,
|
|
||||||
datasets: [{
|
|
||||||
label: '搜索量',
|
|
||||||
data: stats.value.searchTrend.values,
|
|
||||||
borderColor: 'rgb(59, 130, 246)',
|
|
||||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
|
||||||
tension: 0.4,
|
|
||||||
fill: true
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
display: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
beginAtZero: true,
|
|
||||||
grid: {
|
|
||||||
color: 'rgba(0, 0, 0, 0.1)'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
x: {
|
|
||||||
grid: {
|
|
||||||
color: 'rgba(0, 0, 0, 0.1)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadSearchStats()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,587 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-100">
|
|
||||||
<!-- 全局加载状态 -->
|
|
||||||
<div v-if="pageLoading" class="fixed inset-0 bg-gray-900 bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-8 shadow-xl">
|
|
||||||
<div class="flex flex-col items-center space-y-4">
|
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
|
||||||
<div class="text-center">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">正在加载...</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">请稍候,正在加载系统配置</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="">
|
|
||||||
<div class="max-w-7xl mx-auto">
|
|
||||||
<!-- 配置表单 -->
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
||||||
<form @submit.prevent="saveConfig" class="space-y-6">
|
|
||||||
|
|
||||||
<n-tabs type="line" animated>
|
|
||||||
<n-tab-pane name="站点配置" tab="站点配置">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<!-- 网站标题 -->
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
网站标题 *
|
|
||||||
</label>
|
|
||||||
<n-input
|
|
||||||
v-model:value="config.siteTitle"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
placeholder="老九网盘资源数据库"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 网站描述 -->
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
网站描述
|
|
||||||
</label>
|
|
||||||
<n-input
|
|
||||||
v-model:value="config.siteDescription"
|
|
||||||
type="text"
|
|
||||||
placeholder="专业的老九网盘资源数据库"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 关键词 -->
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
关键词 (用逗号分隔)
|
|
||||||
</label>
|
|
||||||
<n-input
|
|
||||||
v-model:value="config.keywords"
|
|
||||||
type="text"
|
|
||||||
placeholder="网盘,资源管理,文件分享"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- 版权信息 -->
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
版权信息
|
|
||||||
</label>
|
|
||||||
<n-input
|
|
||||||
v-model:value="config.copyright"
|
|
||||||
type="text"
|
|
||||||
placeholder="© 2024 老九网盘资源数据库"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 禁止词 -->
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
违禁词
|
|
||||||
</label>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<n-button
|
|
||||||
type="default"
|
|
||||||
size="small"
|
|
||||||
@click="openForbiddenWordsSource"
|
|
||||||
>
|
|
||||||
开源违禁词
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<n-input
|
|
||||||
v-model:value="config.forbiddenWords"
|
|
||||||
type="textarea"
|
|
||||||
placeholder=""
|
|
||||||
:autosize="{ minRows: 4, maxRows: 8 }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- <div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
每页显示数量
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
v-model.number="config.pageSize"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
>
|
|
||||||
<option value="20">20 条</option>
|
|
||||||
<option value="50">50 条</option>
|
|
||||||
<option value="100">100 条</option>
|
|
||||||
<option value="200">200 条</option>
|
|
||||||
</select>
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<!-- 系统维护模式 -->
|
|
||||||
<div class="md:col-span-2 flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
|
||||||
<div class="flex-1">
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
|
|
||||||
维护模式
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
开启后,普通用户无法访问系统
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<label class="relative inline-flex items-center cursor-pointer">
|
|
||||||
<n-switch v-model:value="config.maintenanceMode" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane name="功能配置" tab="功能配置">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<!-- 待处理资源自动处理 -->
|
|
||||||
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
|
||||||
<div class="flex-1">
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
|
|
||||||
待处理资源自动处理
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
开启后,系统将自动处理待处理的资源,无需手动操作
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<label class="relative inline-flex items-center cursor-pointer">
|
|
||||||
<n-switch v-model:value="config.autoProcessReadyResources" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="config.autoProcessReadyResources" class="ml-6">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
自动处理间隔 (分钟)
|
|
||||||
</label>
|
|
||||||
<n-input
|
|
||||||
v-model:value="config.autoProcessInterval"
|
|
||||||
type="number"
|
|
||||||
placeholder="30"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
建议设置 5-60 分钟,避免过于频繁的处理
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- 自动转存 -->
|
|
||||||
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
|
||||||
<div class="flex-1">
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
|
|
||||||
自动转存
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
开启后,系统将自动转存资源到其他网盘平台
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<label class="relative inline-flex items-center cursor-pointer">
|
|
||||||
<n-switch v-model:value="config.autoTransferEnabled" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 自动转存配置(仅在开启时显示) -->
|
|
||||||
<div v-if="config.autoTransferEnabled" class="ml-6 space-y-4">
|
|
||||||
<!-- 自动转存限制天数 -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
自动转存限制(n天内资源)
|
|
||||||
</label>
|
|
||||||
<n-input
|
|
||||||
v-model:value="config.autoTransferLimitDays"
|
|
||||||
type="number"
|
|
||||||
placeholder="30"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
只转存指定天数内的资源,0表示不限制时间
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 最小存储空间 -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
最小存储空间(GB)
|
|
||||||
</label>
|
|
||||||
<n-input
|
|
||||||
v-model:value="config.autoTransferMinSpace"
|
|
||||||
type="number"
|
|
||||||
placeholder="500"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
当网盘剩余空间小于此值时,停止自动转存(100-1024GB)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 自动拉取热播剧 -->
|
|
||||||
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
|
||||||
<div class="flex-1">
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
|
|
||||||
自动拉取热播剧
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
开启后,系统将自动从豆瓣获取热播剧信息
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<label class="relative inline-flex items-center cursor-pointer">
|
|
||||||
<n-switch v-model:value="config.autoFetchHotDramaEnabled" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 自动处理间隔 -->
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane name="API配置" tab="API配置">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<!-- API Token -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
公开API访问令牌
|
|
||||||
</label>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<n-input
|
|
||||||
v-model:value="config.apiToken"
|
|
||||||
type="password"
|
|
||||||
placeholder="输入API Token,用于公开API访问认证"
|
|
||||||
:show-password-on="'click'"
|
|
||||||
/>
|
|
||||||
<n-button
|
|
||||||
v-if="!config.apiToken"
|
|
||||||
type="primary"
|
|
||||||
@click="generateApiToken"
|
|
||||||
>
|
|
||||||
生成
|
|
||||||
</n-button>
|
|
||||||
<template v-else>
|
|
||||||
<n-button
|
|
||||||
type="primary"
|
|
||||||
@click="copyApiToken"
|
|
||||||
>
|
|
||||||
复制
|
|
||||||
</n-button>
|
|
||||||
<n-button
|
|
||||||
type="default"
|
|
||||||
@click="generateApiToken"
|
|
||||||
>
|
|
||||||
重新生成
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
用于公开API的访问认证,建议使用随机字符串
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- API使用说明 -->
|
|
||||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
|
||||||
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
|
|
||||||
<i class="fas fa-info-circle mr-1"></i>
|
|
||||||
API使用说明
|
|
||||||
</h3>
|
|
||||||
<div class="text-xs text-blue-700 dark:text-blue-300 space-y-1">
|
|
||||||
<p>• 批量添加资源: POST /api/public/resources/batch-add</p>
|
|
||||||
<p>• 资源搜索: GET /api/public/resources/search</p>
|
|
||||||
<p>• 热门剧: GET /api/public/hot-dramas</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</n-tab-pane>
|
|
||||||
</n-tabs>
|
|
||||||
|
|
||||||
<!-- 保存按钮 -->
|
|
||||||
<div class="flex justify-end space-x-4 pt-6">
|
|
||||||
<n-button
|
|
||||||
type="tertiary"
|
|
||||||
@click="resetForm"
|
|
||||||
>
|
|
||||||
重置
|
|
||||||
</n-button>
|
|
||||||
<n-button
|
|
||||||
type="primary"
|
|
||||||
:disabled="saving"
|
|
||||||
@click="saveConfig"
|
|
||||||
>
|
|
||||||
<i v-if="saving" class="fas fa-spinner fa-spin mr-2"></i>
|
|
||||||
{{ saving ? '保存中...' : '保存配置' }}
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
// 设置页面布局
|
|
||||||
definePageMeta({
|
|
||||||
layout: 'admin-old',
|
|
||||||
ssr: false
|
|
||||||
})
|
|
||||||
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { useSystemConfigApi } from '~/composables/useApi'
|
|
||||||
import { useSystemConfigStore } from '~/stores/systemConfig'
|
|
||||||
|
|
||||||
// 权限检查已在 admin 布局中处理
|
|
||||||
|
|
||||||
const systemConfigStore = useSystemConfigStore()
|
|
||||||
|
|
||||||
// API
|
|
||||||
const systemConfigApi = useSystemConfigApi()
|
|
||||||
const notification = useNotification()
|
|
||||||
|
|
||||||
// 响应式数据
|
|
||||||
const loading = ref(false)
|
|
||||||
const loadingForbiddenWords = ref(false)
|
|
||||||
const config = ref({
|
|
||||||
// SEO 配置
|
|
||||||
siteTitle: '老九网盘资源数据库',
|
|
||||||
siteDescription: '专业的老九网盘资源数据库',
|
|
||||||
keywords: '网盘,资源管理,文件分享',
|
|
||||||
author: '系统管理员',
|
|
||||||
copyright: '© 2024 老九网盘资源数据库',
|
|
||||||
|
|
||||||
// 自动处理配置
|
|
||||||
autoProcessReadyResources: false,
|
|
||||||
autoProcessInterval: 30,
|
|
||||||
autoTransferEnabled: false, // 新增
|
|
||||||
autoTransferLimitDays: 30, // 新增:自动转存限制天数
|
|
||||||
autoTransferMinSpace: 500, // 新增:最小存储空间(GB)
|
|
||||||
autoFetchHotDramaEnabled: false, // 新增
|
|
||||||
|
|
||||||
// 其他配置
|
|
||||||
pageSize: 100,
|
|
||||||
maintenanceMode: false,
|
|
||||||
apiToken: '' // 新增
|
|
||||||
})
|
|
||||||
|
|
||||||
// 系统配置状态(用于SEO)
|
|
||||||
const systemConfig = ref({
|
|
||||||
site_title: '老九网盘资源数据库',
|
|
||||||
site_description: '系统配置管理页面',
|
|
||||||
keywords: '系统配置,管理',
|
|
||||||
author: '系统管理员'
|
|
||||||
})
|
|
||||||
const originalConfig = ref(null)
|
|
||||||
|
|
||||||
// 加载配置
|
|
||||||
const loadConfig = async () => {
|
|
||||||
try {
|
|
||||||
loading.value = true
|
|
||||||
const response = await systemConfigApi.getSystemConfig()
|
|
||||||
console.log('系统配置响应:', response)
|
|
||||||
|
|
||||||
// 使用新的统一响应格式,直接使用response
|
|
||||||
if (response) {
|
|
||||||
const newConfig = {
|
|
||||||
siteTitle: response.site_title || '老九网盘资源数据库',
|
|
||||||
siteDescription: response.site_description || '专业的老九网盘资源数据库',
|
|
||||||
keywords: response.keywords || '网盘,资源管理,文件分享',
|
|
||||||
author: response.author || '系统管理员',
|
|
||||||
copyright: response.copyright || '© 2024 老九网盘资源数据库',
|
|
||||||
autoProcessReadyResources: response.auto_process_ready_resources || false,
|
|
||||||
autoProcessInterval: String(response.auto_process_interval || 30),
|
|
||||||
autoTransferEnabled: response.auto_transfer_enabled || false, // 新增
|
|
||||||
autoTransferLimitDays: String(response.auto_transfer_limit_days || 30), // 新增:自动转存限制天数
|
|
||||||
autoTransferMinSpace: String(response.auto_transfer_min_space || 500), // 新增:最小存储空间(GB)
|
|
||||||
autoFetchHotDramaEnabled: response.auto_fetch_hot_drama_enabled || false, // 新增
|
|
||||||
forbiddenWords: formatForbiddenWordsForDisplay(response.forbidden_words || ''),
|
|
||||||
pageSize: String(response.page_size || 100),
|
|
||||||
maintenanceMode: response.maintenance_mode || false,
|
|
||||||
apiToken: response.api_token || '' // 加载API Token
|
|
||||||
}
|
|
||||||
config.value = newConfig
|
|
||||||
originalConfig.value = JSON.parse(JSON.stringify(newConfig)) // 深拷贝保存原始数据
|
|
||||||
systemConfig.value = response // 更新系统配置状态
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载配置失败:', error)
|
|
||||||
// 显示错误提示
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存配置
|
|
||||||
const saveConfig = async () => {
|
|
||||||
try {
|
|
||||||
loading.value = true
|
|
||||||
|
|
||||||
const changes = {}
|
|
||||||
const currentConfig = config.value
|
|
||||||
const original = originalConfig.value
|
|
||||||
|
|
||||||
// 检查每个字段是否有变化
|
|
||||||
if (currentConfig.siteTitle !== original.siteTitle) {
|
|
||||||
changes.site_title = currentConfig.siteTitle
|
|
||||||
}
|
|
||||||
if (currentConfig.siteDescription !== original.siteDescription) {
|
|
||||||
changes.site_description = currentConfig.siteDescription
|
|
||||||
}
|
|
||||||
if (currentConfig.keywords !== original.keywords) {
|
|
||||||
changes.keywords = currentConfig.keywords
|
|
||||||
}
|
|
||||||
if (currentConfig.author !== original.author) {
|
|
||||||
changes.author = currentConfig.author
|
|
||||||
}
|
|
||||||
if (currentConfig.copyright !== original.copyright) {
|
|
||||||
changes.copyright = currentConfig.copyright
|
|
||||||
}
|
|
||||||
if (currentConfig.autoProcessReadyResources !== original.autoProcessReadyResources) {
|
|
||||||
changes.auto_process_ready_resources = currentConfig.autoProcessReadyResources
|
|
||||||
}
|
|
||||||
if (currentConfig.autoProcessInterval !== original.autoProcessInterval) {
|
|
||||||
changes.auto_process_interval = parseInt(currentConfig.autoProcessInterval) || 0
|
|
||||||
}
|
|
||||||
if (currentConfig.autoTransferEnabled !== original.autoTransferEnabled) {
|
|
||||||
changes.auto_transfer_enabled = currentConfig.autoTransferEnabled
|
|
||||||
}
|
|
||||||
if (currentConfig.autoTransferLimitDays !== original.autoTransferLimitDays) {
|
|
||||||
changes.auto_transfer_limit_days = parseInt(currentConfig.autoTransferLimitDays) || 0
|
|
||||||
}
|
|
||||||
if (currentConfig.autoTransferMinSpace !== original.autoTransferMinSpace) {
|
|
||||||
changes.auto_transfer_min_space = parseInt(currentConfig.autoTransferMinSpace) || 0
|
|
||||||
}
|
|
||||||
if (currentConfig.autoFetchHotDramaEnabled !== original.autoFetchHotDramaEnabled) {
|
|
||||||
changes.auto_fetch_hot_drama_enabled = currentConfig.autoFetchHotDramaEnabled
|
|
||||||
}
|
|
||||||
if (currentConfig.forbiddenWords !== original.forbiddenWords) {
|
|
||||||
changes.forbidden_words = formatForbiddenWordsForSave(currentConfig.forbiddenWords)
|
|
||||||
}
|
|
||||||
if (currentConfig.pageSize !== original.pageSize) {
|
|
||||||
changes.page_size = parseInt(currentConfig.pageSize) || 0
|
|
||||||
}
|
|
||||||
if (currentConfig.maintenanceMode !== original.maintenanceMode) {
|
|
||||||
changes.maintenance_mode = currentConfig.maintenanceMode
|
|
||||||
}
|
|
||||||
if (currentConfig.apiToken !== original.apiToken) {
|
|
||||||
changes.api_token = currentConfig.apiToken
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('检测到的变化:', changes)
|
|
||||||
if (Object.keys(changes).length === 0) {
|
|
||||||
notification.warning({
|
|
||||||
content: '没有需要保存的配置',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const response = await systemConfigApi.updateSystemConfig(changes)
|
|
||||||
// 使用新的统一响应格式,直接检查response是否存在
|
|
||||||
if (response) {
|
|
||||||
notification.success({
|
|
||||||
content: '配置保存成功!',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
await loadConfig()
|
|
||||||
// 自动更新 systemConfig store(强制刷新)
|
|
||||||
await systemConfigStore.initConfig(true)
|
|
||||||
} else {
|
|
||||||
notification.error({
|
|
||||||
content: '保存配置失败:未知错误',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
notification.error({
|
|
||||||
content: '保存配置失败:' + (error.message || '未知错误'),
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置表单
|
|
||||||
const resetForm = () => {
|
|
||||||
notification.confirm({
|
|
||||||
title: '确定要重置所有配置吗?',
|
|
||||||
content: '重置后,所有配置将恢复为修改前配置',
|
|
||||||
duration: 3000,
|
|
||||||
onOk: () => {
|
|
||||||
loadConfig()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成API Token
|
|
||||||
const generateApiToken = () => {
|
|
||||||
const newToken = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
|
||||||
config.value.apiToken = newToken;
|
|
||||||
notification.success({
|
|
||||||
content: '新API Token已生成: ' + newToken,
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
// 复制API Token
|
|
||||||
const copyApiToken = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(config.value.apiToken);
|
|
||||||
notification.success({
|
|
||||||
content: 'API Token已复制到剪贴板',
|
|
||||||
duration: 3000
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
// 降级方案:使用传统的复制方法
|
|
||||||
const textArea = document.createElement('textarea');
|
|
||||||
textArea.value = config.value.apiToken;
|
|
||||||
document.body.appendChild(textArea);
|
|
||||||
textArea.select();
|
|
||||||
try {
|
|
||||||
document.execCommand('copy');
|
|
||||||
notification.success({
|
|
||||||
content: 'API Token已复制到剪贴板',
|
|
||||||
duration: 3000
|
|
||||||
});
|
|
||||||
} catch (fallbackErr) {
|
|
||||||
notification.error({
|
|
||||||
content: '复制失败,请手动复制',
|
|
||||||
duration: 3000
|
|
||||||
});
|
|
||||||
}
|
|
||||||
document.body.removeChild(textArea);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// 打开违禁词源文件
|
|
||||||
const openForbiddenWordsSource = () => {
|
|
||||||
const url = 'https://raw.githubusercontent.com/ctwj/urldb/refs/heads/main/db/forbidden.txt'
|
|
||||||
window.open(url, '_blank', 'noopener,noreferrer')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化违禁词用于显示(逗号分隔转为多行)
|
|
||||||
const formatForbiddenWordsForDisplay = (forbiddenWords) => {
|
|
||||||
if (!forbiddenWords) return ''
|
|
||||||
|
|
||||||
// 按逗号分割,过滤空字符串,然后按行显示
|
|
||||||
return forbiddenWords.split(',')
|
|
||||||
.map(word => word.trim())
|
|
||||||
.filter(word => word.length > 0)
|
|
||||||
.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化违禁词用于保存(多行转为逗号分隔)
|
|
||||||
const formatForbiddenWordsForSave = (forbiddenWords) => {
|
|
||||||
if (!forbiddenWords) return ''
|
|
||||||
|
|
||||||
// 按行分割,过滤空行,然后用逗号连接
|
|
||||||
return forbiddenWords.split('\n')
|
|
||||||
.map(line => line.trim())
|
|
||||||
.filter(line => line.length > 0)
|
|
||||||
.join(',')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 页面加载时获取配置
|
|
||||||
onMounted(() => {
|
|
||||||
loadConfig()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,571 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-100">
|
|
||||||
<!-- 全局加载状态 -->
|
|
||||||
<div v-if="pageLoading" class="fixed inset-0 bg-gray-900 bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-8 shadow-xl">
|
|
||||||
<div class="flex flex-col items-center space-y-4">
|
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
|
||||||
<div class="text-center">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">正在加载...</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">请稍候,正在加载标签数据</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="">
|
|
||||||
<div class="max-w-7xl mx-auto">
|
|
||||||
<n-alert class="mb-4" title="提交的数据中,如果包含标签,数据添加成功,会自动添加标签" type="info" />
|
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button
|
|
||||||
@click="showAddModal = true"
|
|
||||||
class="px-4 py-2 bg-green-600 hover:bg-green-700 rounded-md transition-colors text-white text-sm flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<i class="fas fa-plus"></i> 添加标签
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<div class="relative">
|
|
||||||
<n-input
|
|
||||||
v-model:value="searchQuery"
|
|
||||||
@input="debounceSearch"
|
|
||||||
type="text"
|
|
||||||
placeholder="搜索标签名称..."
|
|
||||||
/>
|
|
||||||
<div class="absolute right-3 top-1/2 transform -translate-y-1/2">
|
|
||||||
<i class="fas fa-search text-gray-400 text-sm"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
@click="refreshData"
|
|
||||||
class="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<i class="fas fa-refresh"></i> 刷新
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 标签列表 -->
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full min-w-full">
|
|
||||||
<thead>
|
|
||||||
<tr class="bg-slate-800 dark:bg-gray-700 text-white dark:text-gray-100">
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">ID</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">标签名称</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">分类</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">描述</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">资源数量</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">创建时间</th>
|
|
||||||
<th class="px-4 py-3 text-left text-sm font-medium">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
<tr v-if="loading" class="text-center py-8">
|
|
||||||
<td colspan="7" class="text-gray-500 dark:text-gray-400">
|
|
||||||
<i class="fas fa-spinner fa-spin mr-2"></i>加载中...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-else-if="tags.length === 0" class="text-center py-8">
|
|
||||||
<td colspan="7" class="text-gray-500 dark:text-gray-400">
|
|
||||||
<div class="flex flex-col items-center justify-center py-12">
|
|
||||||
<svg class="w-16 h-16 text-gray-300 dark:text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
|
||||||
<circle cx="24" cy="24" r="20" stroke-width="3" stroke-dasharray="6 6" />
|
|
||||||
<path d="M16 24h16M24 16v16" stroke-width="3" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
<div class="text-lg font-semibold text-gray-400 dark:text-gray-500 mb-2">暂无标签</div>
|
|
||||||
<div class="text-sm text-gray-400 dark:text-gray-600 mb-4">你可以点击上方"添加标签"按钮创建新标签</div>
|
|
||||||
<button
|
|
||||||
@click="showAddModal = true"
|
|
||||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors text-sm flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<i class="fas fa-plus"></i> 添加标签
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr
|
|
||||||
v-for="tag in tags"
|
|
||||||
:key="tag.id"
|
|
||||||
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
|
||||||
>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 font-medium">{{ tag.id }}</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
<span :title="tag.name">{{ tag.name }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<span v-if="tag.category_name" class="px-2 py-1 bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300 rounded-full text-xs">
|
|
||||||
{{ tag.category_name }}
|
|
||||||
</span>
|
|
||||||
<span v-else class="text-gray-400 dark:text-gray-500 italic text-xs">未分类</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<span v-if="tag.description" :title="tag.description">{{ tag.description }}</span>
|
|
||||||
<span v-else class="text-gray-400 dark:text-gray-500 italic">无描述</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<span class="px-2 py-1 bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300 rounded-full text-xs">
|
|
||||||
{{ tag.resource_count || 0 }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{{ formatTime(tag.created_at) }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
@click="editTag(tag)"
|
|
||||||
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
|
|
||||||
title="编辑标签"
|
|
||||||
>
|
|
||||||
<i class="fas fa-edit"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="deleteTag(tag.id)"
|
|
||||||
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
|
||||||
title="删除标签"
|
|
||||||
>
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 分页 -->
|
|
||||||
<div v-if="totalPages > 1" class="flex flex-wrap justify-center gap-1 sm:gap-2 mt-6">
|
|
||||||
<button
|
|
||||||
v-if="currentPage > 1"
|
|
||||||
@click="goToPage(currentPage - 1)"
|
|
||||||
class="bg-white text-gray-700 hover:bg-gray-50 px-2 py-1 sm:px-4 sm:py-2 rounded border transition-colors text-sm flex items-center"
|
|
||||||
>
|
|
||||||
<i class="fas fa-chevron-left mr-1"></i> 上一页
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
@click="goToPage(1)"
|
|
||||||
:class="currentPage === 1 ? 'bg-slate-800 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'"
|
|
||||||
class="px-2 py-1 sm:px-4 sm:py-2 rounded border transition-colors text-sm"
|
|
||||||
>
|
|
||||||
1
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="totalPages > 1"
|
|
||||||
@click="goToPage(2)"
|
|
||||||
:class="currentPage === 2 ? 'bg-slate-800 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'"
|
|
||||||
class="px-2 py-1 sm:px-4 sm:py-2 rounded border transition-colors text-sm"
|
|
||||||
>
|
|
||||||
2
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span v-if="currentPage > 2" class="px-2 py-1 sm:px-3 sm:py-2 text-gray-500 text-sm">...</span>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="currentPage !== 1 && currentPage !== 2 && currentPage > 2"
|
|
||||||
class="bg-slate-800 text-white px-2 py-1 sm:px-4 sm:py-2 rounded border transition-colors text-sm"
|
|
||||||
>
|
|
||||||
{{ currentPage }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="currentPage < totalPages"
|
|
||||||
@click="goToPage(currentPage + 1)"
|
|
||||||
class="bg-white text-gray-700 hover:bg-gray-50 px-2 py-1 sm:px-4 sm:py-2 rounded border transition-colors text-sm flex items-center"
|
|
||||||
>
|
|
||||||
下一页 <i class="fas fa-chevron-right ml-1"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 统计信息 -->
|
|
||||||
<div v-if="totalPages <= 1" class="mt-4 text-center">
|
|
||||||
<div class="inline-flex items-center bg-white dark:bg-gray-800 rounded-lg shadow px-6 py-3">
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
共 <span class="font-semibold text-gray-900 dark:text-gray-100">{{ totalCount }}</span> 个标签
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 添加/编辑标签模态框 -->
|
|
||||||
<div v-if="showAddModal" class="fixed inset-0 bg-gray-900 bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full">
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
{{ editingTag ? '编辑标签' : '添加标签' }}
|
|
||||||
</h3>
|
|
||||||
<n-button @click="closeModal" type="tertiary" size="small">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form @submit.prevent="handleSubmit">
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">标签名称:</label>
|
|
||||||
<n-input
|
|
||||||
v-model:value="formData.name"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
placeholder="请输入标签名称"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">分类:</label>
|
|
||||||
<select
|
|
||||||
v-model="formData.category_id"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-100"
|
|
||||||
>
|
|
||||||
<option value="">选择分类(可选)</option>
|
|
||||||
<option v-for="category in categories" :key="category.id" :value="category.id">
|
|
||||||
{{ category.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">描述:</label>
|
|
||||||
<n-input
|
|
||||||
v-model:value="formData.description"
|
|
||||||
type="textarea"
|
|
||||||
placeholder="请输入标签描述(可选)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3">
|
|
||||||
<n-button
|
|
||||||
type="tertiary"
|
|
||||||
@click="closeModal"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</n-button>
|
|
||||||
<n-button
|
|
||||||
type="primary"
|
|
||||||
:disabled="submitting"
|
|
||||||
@click="handleSubmit"
|
|
||||||
>
|
|
||||||
{{ submitting ? '提交中...' : (editingTag ? '更新' : '添加') }}
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
// API 导入
|
|
||||||
import { useTagApi, useCategoryApi } from '~/composables/useApi'
|
|
||||||
|
|
||||||
// 设置页面布局
|
|
||||||
definePageMeta({
|
|
||||||
layout: 'admin-old',
|
|
||||||
ssr: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const userStore = useUserStore()
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
|
|
||||||
const tagApi = useTagApi()
|
|
||||||
const categoryApi = useCategoryApi()
|
|
||||||
|
|
||||||
// 调试信息
|
|
||||||
console.log('tagApi:', tagApi)
|
|
||||||
console.log('categoryApi:', categoryApi)
|
|
||||||
|
|
||||||
// 页面状态
|
|
||||||
const pageLoading = ref(true)
|
|
||||||
const loading = ref(false)
|
|
||||||
const tags = ref<any[]>([])
|
|
||||||
const categories = ref<any[]>([])
|
|
||||||
|
|
||||||
// 分页状态
|
|
||||||
const currentPage = ref(1)
|
|
||||||
const pageSize = ref(20)
|
|
||||||
const totalCount = ref(0)
|
|
||||||
const totalPages = ref(0)
|
|
||||||
|
|
||||||
// 搜索和筛选状态
|
|
||||||
const searchQuery = ref('')
|
|
||||||
const selectedCategory = ref('')
|
|
||||||
let searchTimeout: NodeJS.Timeout | null = null
|
|
||||||
|
|
||||||
// 模态框状态
|
|
||||||
const showAddModal = ref(false)
|
|
||||||
const submitting = ref(false)
|
|
||||||
const editingTag = ref<any>(null)
|
|
||||||
const dialog = useDialog()
|
|
||||||
|
|
||||||
// 表单数据
|
|
||||||
const formData = ref({
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
category_id: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// 获取认证头
|
|
||||||
const getAuthHeaders = () => {
|
|
||||||
return userStore.authHeaders
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查认证状态
|
|
||||||
const checkAuth = () => {
|
|
||||||
userStore.initAuth()
|
|
||||||
if (!userStore.isAuthenticated) {
|
|
||||||
router.push('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取分类列表
|
|
||||||
const fetchCategories = async () => {
|
|
||||||
try {
|
|
||||||
console.log('获取分类列表...')
|
|
||||||
const response = await categoryApi.getCategories()
|
|
||||||
console.log('分类接口响应:', response)
|
|
||||||
|
|
||||||
// 适配后端API响应格式
|
|
||||||
if (response && (response as any).items) {
|
|
||||||
console.log('使用 items 格式:', (response as any).items)
|
|
||||||
categories.value = (response as any).items
|
|
||||||
} else if (Array.isArray(response)) {
|
|
||||||
console.log('使用数组格式:', response)
|
|
||||||
categories.value = response
|
|
||||||
} else {
|
|
||||||
console.log('使用默认格式:', response)
|
|
||||||
categories.value = []
|
|
||||||
}
|
|
||||||
console.log('最终分类数据:', categories.value)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取分类列表失败:', error)
|
|
||||||
categories.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取标签列表
|
|
||||||
const fetchTags = async () => {
|
|
||||||
try {
|
|
||||||
loading.value = true
|
|
||||||
const params = {
|
|
||||||
page: currentPage.value,
|
|
||||||
page_size: pageSize.value,
|
|
||||||
search: searchQuery.value
|
|
||||||
}
|
|
||||||
console.log('获取标签列表参数:', params)
|
|
||||||
console.log('搜索查询值:', searchQuery.value)
|
|
||||||
console.log('搜索查询类型:', typeof searchQuery.value)
|
|
||||||
|
|
||||||
let response: any
|
|
||||||
if (selectedCategory.value) {
|
|
||||||
response = await tagApi.getTagsByCategory(parseInt(selectedCategory.value), params)
|
|
||||||
} else {
|
|
||||||
response = await tagApi.getTags(params)
|
|
||||||
}
|
|
||||||
console.log('标签接口响应:', response)
|
|
||||||
|
|
||||||
// 适配后端API响应格式
|
|
||||||
if (response && (response as any).items && Array.isArray((response as any).items)) {
|
|
||||||
console.log('使用 items 格式:', (response as any).items)
|
|
||||||
tags.value = (response as any).items
|
|
||||||
totalCount.value = (response as any).total || 0
|
|
||||||
totalPages.value = Math.ceil(totalCount.value / pageSize.value)
|
|
||||||
} else if (Array.isArray(response)) {
|
|
||||||
console.log('使用数组格式:', response)
|
|
||||||
tags.value = response
|
|
||||||
totalCount.value = response.length
|
|
||||||
totalPages.value = 1
|
|
||||||
} else {
|
|
||||||
console.log('使用默认格式:', response)
|
|
||||||
tags.value = []
|
|
||||||
totalCount.value = 0
|
|
||||||
totalPages.value = 1
|
|
||||||
}
|
|
||||||
console.log('最终标签数据:', tags.value)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取标签列表失败:', error)
|
|
||||||
tags.value = []
|
|
||||||
totalCount.value = 0
|
|
||||||
totalPages.value = 1
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分类变化处理
|
|
||||||
const onCategoryChange = () => {
|
|
||||||
currentPage.value = 1
|
|
||||||
fetchTags()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 搜索防抖
|
|
||||||
const debounceSearch = () => {
|
|
||||||
console.log('搜索防抖触发,当前搜索值:', searchQuery.value)
|
|
||||||
if (searchTimeout) {
|
|
||||||
clearTimeout(searchTimeout)
|
|
||||||
}
|
|
||||||
searchTimeout = setTimeout(() => {
|
|
||||||
console.log('执行搜索,搜索值:', searchQuery.value)
|
|
||||||
currentPage.value = 1
|
|
||||||
fetchTags()
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新数据
|
|
||||||
const refreshData = () => {
|
|
||||||
fetchTags()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分页跳转
|
|
||||||
const goToPage = (page: number) => {
|
|
||||||
currentPage.value = page
|
|
||||||
fetchTags()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 编辑标签
|
|
||||||
const editTag = (tag: any) => {
|
|
||||||
editingTag.value = tag
|
|
||||||
formData.value = {
|
|
||||||
name: tag.name,
|
|
||||||
description: tag.description || '',
|
|
||||||
category_id: tag.category_id || ''
|
|
||||||
}
|
|
||||||
showAddModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除标签
|
|
||||||
const deleteTag = async (tagId: number) => {
|
|
||||||
dialog.warning({
|
|
||||||
title: '警告',
|
|
||||||
content: '确定要删除标签吗?',
|
|
||||||
positiveText: '确定',
|
|
||||||
negativeText: '取消',
|
|
||||||
draggable: true,
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
try {
|
|
||||||
await tagApi.deleteTag(tagId)
|
|
||||||
await fetchTags()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除标签失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提交表单
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
try {
|
|
||||||
submitting.value = true
|
|
||||||
|
|
||||||
// 正确处理category_id,空字符串应该转换为null
|
|
||||||
let categoryId = null
|
|
||||||
if (formData.value.category_id && formData.value.category_id !== '') {
|
|
||||||
categoryId = parseInt(formData.value.category_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitData = {
|
|
||||||
name: formData.value.name,
|
|
||||||
description: formData.value.description,
|
|
||||||
category_id: categoryId
|
|
||||||
}
|
|
||||||
|
|
||||||
let response: any
|
|
||||||
if (editingTag.value) {
|
|
||||||
response = await tagApi.updateTag(editingTag.value.id, submitData)
|
|
||||||
} else {
|
|
||||||
response = await tagApi.createTag(submitData)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('标签操作响应:', response)
|
|
||||||
|
|
||||||
// 检查是否是恢复操作
|
|
||||||
if (response && response.message && response.message.includes('恢复成功')) {
|
|
||||||
console.log('检测到标签恢复操作,延迟刷新数据')
|
|
||||||
closeModal()
|
|
||||||
// 延迟一点时间再刷新,确保数据库状态已更新
|
|
||||||
setTimeout(async () => {
|
|
||||||
await fetchTags()
|
|
||||||
}, 500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
closeModal()
|
|
||||||
await fetchTags()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('提交标签失败:', error)
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭模态框
|
|
||||||
const closeModal = () => {
|
|
||||||
showAddModal.value = false
|
|
||||||
editingTag.value = null
|
|
||||||
formData.value = {
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
category_id: ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化时间
|
|
||||||
const formatTime = (timestamp: string) => {
|
|
||||||
if (!timestamp) return '-'
|
|
||||||
const date = new Date(timestamp)
|
|
||||||
const year = date.getFullYear()
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
||||||
const day = String(date.getDate()).padStart(2, '0')
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0')
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 退出登录
|
|
||||||
const handleLogout = () => {
|
|
||||||
userStore.logout()
|
|
||||||
navigateTo('/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 页面加载
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
console.log('页面开始加载...')
|
|
||||||
checkAuth()
|
|
||||||
console.log('认证检查完成')
|
|
||||||
|
|
||||||
console.log('开始获取分类列表...')
|
|
||||||
await fetchCategories()
|
|
||||||
console.log('分类列表获取完成')
|
|
||||||
|
|
||||||
console.log('开始获取标签列表...')
|
|
||||||
await fetchTags()
|
|
||||||
console.log('标签列表获取完成')
|
|
||||||
|
|
||||||
// 检查URL参数,如果action=add则自动打开新增弹窗
|
|
||||||
const route = useRoute()
|
|
||||||
if (route.query.action === 'add') {
|
|
||||||
showAddModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('页面加载完成')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('标签管理页面初始化失败:', error)
|
|
||||||
} finally {
|
|
||||||
pageLoading.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 自定义样式 */
|
|
||||||
</style>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user