feat: 第一次生成
@@ -0,0 +1,25 @@
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
dist/
|
||||
|
||||
# Nuxt/前端
|
||||
.nuxt/
|
||||
output/
|
||||
*.log
|
||||
|
||||
# VSCode/IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
|
||||
# Prisma
|
||||
backend/prisma/dev.db
|
||||
backend/prisma/dev.db-journal
|
||||
|
||||
# 其他
|
||||
frontend/.output/
|
||||
frontend/.vercel/
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
description: API接口相关规范
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# API接口规范
|
||||
|
||||
## 接口设计原则
|
||||
1. 遵循 RESTful 设计规范
|
||||
2. 使用 HTTPS 协议
|
||||
3. 版本控制
|
||||
4. 统一的响应格式
|
||||
|
||||
## 请求规范
|
||||
### 请求方法
|
||||
- GET: 获取资源
|
||||
- POST: 创建资源
|
||||
- PUT: 更新资源
|
||||
- DELETE: 删除资源
|
||||
|
||||
### 请求头
|
||||
```
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
### 请求参数
|
||||
1. GET 请求参数使用 query string
|
||||
2. POST/PUT 请求参数使用 JSON 格式
|
||||
3. 分页参数统一使用 page 和 pageSize
|
||||
|
||||
## 响应规范
|
||||
### 响应格式
|
||||
```json
|
||||
{
|
||||
"code": 0, // 状态码
|
||||
"message": "success", // 状态信息
|
||||
"data": { // 响应数据
|
||||
// 具体数据
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 状态码
|
||||
- 200: 成功
|
||||
- 400: 请求参数错误
|
||||
- 401: 未授权
|
||||
- 403: 禁止访问
|
||||
- 404: 资源不存在
|
||||
- 500: 服务器错误
|
||||
|
||||
## 接口文档
|
||||
1. 使用 OpenAPI (Swagger) 规范
|
||||
2. 必须包含接口描述、参数说明、响应示例
|
||||
3. 及时更新文档
|
||||
|
||||
## 错误处理
|
||||
1. 统一的错误响应格式
|
||||
2. 详细的错误信息
|
||||
3. 错误码规范
|
||||
|
||||
## 安全规范
|
||||
1. 所有接口必须进行身份验证
|
||||
2. 敏感数据传输加密
|
||||
3. 实现请求频率限制
|
||||
4. 防止 SQL 注入和 XSS 攻击
|
||||
|
||||
## 缓存策略
|
||||
1. 合理使用 HTTP 缓存头
|
||||
2. 实现 ETag
|
||||
3. 设置适当的缓存时间
|
||||
|
||||
## 性能优化
|
||||
1. 接口响应时间控制
|
||||
2. 数据压缩
|
||||
3. 分页查询
|
||||
|
||||
4. 按需加载
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
description: 后端开发规范
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# 后端开发规范
|
||||
|
||||
## 技术栈
|
||||
- Express.js (TypeScript模式)
|
||||
- TypeScript
|
||||
- Prisma (ORM)
|
||||
- Redis
|
||||
- MySQL 8.0
|
||||
|
||||
## 目录结构规范
|
||||
```
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── controllers/ # 控制器
|
||||
│ ├── services/ # 业务逻辑
|
||||
│ ├── models/ # 数据模型
|
||||
│ ├── middlewares/ # 中间件
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── types/ # TypeScript类型定义
|
||||
│ └── config/ # 配置文件
|
||||
└── prisma/ # Prisma schema和迁移文件
|
||||
```
|
||||
|
||||
## 代码规范
|
||||
1. 使用 TypeScript 严格模式
|
||||
2. 遵循 RESTful API 设计规范
|
||||
3. 使用 async/await 处理异步操作
|
||||
4. 统一的错误处理机制
|
||||
|
||||
## 数据库规范
|
||||
1. 使用 Prisma 进行数据库操作
|
||||
2. 所有数据库操作必须使用事务
|
||||
3. 合理使用索引
|
||||
4. 避免 N+1 查询问题
|
||||
|
||||
## 缓存策略
|
||||
1. 使用 Redis 进行缓存
|
||||
2. 缓存键命名规范:`{module}:{id}:{type}`
|
||||
3. 设置合理的缓存过期时间
|
||||
4. 实现缓存预热机制
|
||||
|
||||
## 错误处理
|
||||
1. 统一的错误响应格式
|
||||
2. 详细的错误日志记录
|
||||
3. 区分业务错误和系统错误
|
||||
4. 实现错误监控和告警
|
||||
|
||||
## 安全规范
|
||||
1. 所有用户输入必须验证
|
||||
2. 敏感数据加密存储
|
||||
3. 实现请求频率限制
|
||||
4. 使用 HTTPS
|
||||
5. 实现 CORS 策略
|
||||
|
||||
## 日志规范
|
||||
1. 使用 Winston 记录日志
|
||||
2. 区分不同级别的日志
|
||||
3. 记录关键操作日志
|
||||
4. 实现日志轮转
|
||||
|
||||
## 测试规范
|
||||
1. 单元测试覆盖率要求
|
||||
2. 集成测试
|
||||
3. API 测试
|
||||
4. 性能测试
|
||||
|
||||
## 部署规范
|
||||
1. 使用 Docker 容器化
|
||||
2. 环境变量配置
|
||||
3. 健康检查
|
||||
|
||||
4. 监控指标
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# 数据库设计规范
|
||||
|
||||
## 数据库选型
|
||||
- 主数据库:MySQL 8.0
|
||||
- ORM:Prisma
|
||||
- 缓存:Redis
|
||||
|
||||
## 命名规范
|
||||
1. 表名使用小写字母,下划线分隔
|
||||
2. 字段名使用小写字母,下划线分隔
|
||||
3. 主键统一命名为 id
|
||||
4. 外键命名格式:{表名}_id
|
||||
5. 索引命名格式:idx_{表名}_{字段名}
|
||||
|
||||
## 字段规范
|
||||
1. 必须包含 id、created_at、updated_at 字段
|
||||
2. 使用合适的数据类型
|
||||
3. 字段必须添加注释
|
||||
4. 设置合理的默认值
|
||||
5. 使用 NOT NULL 约束
|
||||
|
||||
## 索引规范
|
||||
1. 主键使用自增ID
|
||||
2. 合理使用唯一索引
|
||||
3. 避免过多索引
|
||||
4. 考虑索引的选择性
|
||||
|
||||
## 表关系规范
|
||||
1. 使用外键约束
|
||||
2. 合理设计表关系
|
||||
3. 避免过度规范化
|
||||
4. 考虑查询性能
|
||||
|
||||
## 查询优化
|
||||
1. 避免 SELECT *
|
||||
2. 使用适当的索引
|
||||
3. 避免大事务
|
||||
4. 合理使用分页
|
||||
|
||||
## 数据安全
|
||||
1. 敏感数据加密
|
||||
2. 定期备份
|
||||
3. 访问权限控制
|
||||
4. 审计日志
|
||||
|
||||
## 性能优化
|
||||
1. 表分区策略
|
||||
2. 读写分离
|
||||
3. 缓存策略
|
||||
4. 定期维护
|
||||
|
||||
## 迁移规范
|
||||
1. 使用 Prisma 迁移
|
||||
2. 版本控制
|
||||
3. 回滚机制
|
||||
4. 测试验证
|
||||
|
||||
## 监控规范
|
||||
1. 性能监控
|
||||
2. 容量监控
|
||||
3. 慢查询监控
|
||||
|
||||
4. 异常监控
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
description: 前端开发规范
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# 前端开发规范
|
||||
|
||||
## 技术栈
|
||||
- Nuxt.js (TypeScript模式)
|
||||
- Vue 3
|
||||
- TypeScript
|
||||
|
||||
## 目录结构规范
|
||||
```
|
||||
frontend/
|
||||
├── components/ # 可复用组件
|
||||
├── pages/ # 页面组件
|
||||
├── composables/ # 组合式函数
|
||||
├── types/ # TypeScript类型定义
|
||||
├── utils/ # 工具函数
|
||||
└── assets/ # 静态资源
|
||||
```
|
||||
|
||||
## 命名规范
|
||||
1. 组件文件使用 PascalCase 命名
|
||||
2. 工具函数和组合式函数使用 camelCase 命名
|
||||
3. 类型定义使用 PascalCase 并以 Type 或 Interface 结尾
|
||||
|
||||
## 组件开发规范
|
||||
1. 使用组合式API (Composition API)
|
||||
2. 组件必须使用 TypeScript
|
||||
3. Props 必须定义类型和默认值
|
||||
4. 组件必须包含适当的注释说明
|
||||
|
||||
## 状态管理
|
||||
1. 使用 Pinia 进行状态管理
|
||||
2. 按功能模块划分 store
|
||||
3. 避免在组件中直接修改 store 状态
|
||||
|
||||
## 样式规范
|
||||
1. 使用 SCSS 预处理器
|
||||
2. 遵循 BEM 命名规范
|
||||
3. 优先使用 Tailwind CSS 工具类
|
||||
|
||||
## 性能优化
|
||||
1. 组件按需加载
|
||||
2. 合理使用缓存
|
||||
3. 图片资源优化
|
||||
4. 避免不必要的重渲染
|
||||
|
||||
## 错误处理
|
||||
1. 统一的错误处理机制
|
||||
2. 友好的错误提示
|
||||
3. 网络请求错误处理
|
||||
|
||||
## 代码质量
|
||||
1. 使用 ESLint 进行代码检查
|
||||
2. 使用 Prettier 进行代码格式化
|
||||
|
||||
3. 提交前进行代码审查
|
||||
@@ -0,0 +1,28 @@
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
dist/
|
||||
|
||||
# Nuxt/前端
|
||||
.nuxt/
|
||||
output/
|
||||
*.log
|
||||
|
||||
# 环境变量
|
||||
.env
|
||||
|
||||
# VSCode/IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
|
||||
# Prisma
|
||||
backend/prisma/dev.db
|
||||
backend/prisma/dev.db-journal
|
||||
|
||||
# 其他
|
||||
frontend/.output/
|
||||
frontend/.vercel/
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
PORT=3005
|
||||
SEARCH_SERVICE_URL=http://192.168.1.99:8008
|
||||
REDIS_HOST=192.168.1.99
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
MYSQL_HOST=192.168.1.126
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USER=root
|
||||
MYSQL_PASSWORD=rootroot
|
||||
MYSQL_DATABASE=bps
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "search-service-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend service for search functionality",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node-dev src/index.ts",
|
||||
"build": "tsc",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.9.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mysql2": "^3.9.1",
|
||||
"redis": "^4.7.0",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-rate-limit": "^6.0.2",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/node": "^20.17.32",
|
||||
"@types/redis": "^4.0.11",
|
||||
"@types/swagger-jsdoc": "^6.0.4",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import swaggerUi from 'swagger-ui-express'
|
||||
import { swaggerSpec } from './config/swagger'
|
||||
import { getSearchResults, getLinkForResource } from './controllers/searchController'
|
||||
|
||||
const app = express()
|
||||
|
||||
app.use(cors())
|
||||
app.use(express.json())
|
||||
app.use(rateLimit({ windowMs: 1000, max: 5 }))
|
||||
|
||||
// Swagger UI
|
||||
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec))
|
||||
|
||||
// API Documentation in JSON format
|
||||
app.get('/api-docs.json', (req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.send(swaggerSpec)
|
||||
})
|
||||
|
||||
// 搜索接口
|
||||
app.get('/api/search', getSearchResults)
|
||||
|
||||
// 生成临时链接接口
|
||||
app.get('/api/link/:resourceId', getLinkForResource)
|
||||
|
||||
// 健康检查
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok' })
|
||||
})
|
||||
|
||||
export default app
|
||||
@@ -0,0 +1,39 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
// Add debug logging
|
||||
console.log('Environment variables loaded:', {
|
||||
REDIS_HOST: process.env.REDIS_HOST,
|
||||
REDIS_PORT: process.env.REDIS_PORT,
|
||||
REDIS_PASSWORD: process.env.REDIS_PASSWORD
|
||||
});
|
||||
|
||||
export const config = {
|
||||
server: {
|
||||
port: process.env.PORT || 3000,
|
||||
},
|
||||
searchService: {
|
||||
baseUrl: process.env.SEARCH_SERVICE_URL || 'http://192.168.1.99:8008',
|
||||
endpoints: {
|
||||
login: '/api/user/login',
|
||||
search: '/api/search',
|
||||
}
|
||||
},
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379'),
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
},
|
||||
mysql: {
|
||||
host: process.env.MYSQL_HOST || 'localhost',
|
||||
port: parseInt(process.env.MYSQL_PORT || '3306'),
|
||||
user: process.env.MYSQL_USER || 'root',
|
||||
password: process.env.MYSQL_PASSWORD,
|
||||
database: process.env.MYSQL_DATABASE || 'search_service',
|
||||
},
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'your-secret-key',
|
||||
expiresIn: '6h',
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import swaggerJsdoc from 'swagger-jsdoc';
|
||||
|
||||
const options: swaggerJsdoc.Options = {
|
||||
definition: {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: '百盘搜 API 文档',
|
||||
version: '1.0.0',
|
||||
description: '百盘搜系统的API接口文档',
|
||||
contact: {
|
||||
name: 'API Support',
|
||||
email: 'support@example.com'
|
||||
}
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: 'http://localhost:3005',
|
||||
description: '开发服务器'
|
||||
}
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT'
|
||||
}
|
||||
}
|
||||
},
|
||||
security: [{
|
||||
bearerAuth: []
|
||||
}]
|
||||
},
|
||||
apis: ['./src/routes/*.ts', './src/controllers/*.ts'] // 指定API注释文件的位置
|
||||
};
|
||||
|
||||
export const swaggerSpec = swaggerJsdoc(options);
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Request, Response } from 'express'
|
||||
import { search, generateLink } from '../services/searchService'
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/search:
|
||||
* get:
|
||||
* summary: 搜索资源
|
||||
* description: 根据关键词搜索网盘资源
|
||||
* tags: [Search]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: keyword
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 搜索关键词
|
||||
* - in: query
|
||||
* name: types
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: 网盘类型数组 (例如:['baidu', 'aliyun'])
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 1
|
||||
* description: 页码
|
||||
* - in: query
|
||||
* name: fileType
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 文件类型筛选
|
||||
* - in: query
|
||||
* name: fileSize
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 文件大小筛选
|
||||
* - in: query
|
||||
* name: fileTime
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 文件时间筛选
|
||||
* - in: query
|
||||
* name: mode
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [standard, fuzzy]
|
||||
* default: standard
|
||||
* description: 搜索模式
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 搜索成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* code:
|
||||
* type: integer
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* message:
|
||||
* type: string
|
||||
* 500:
|
||||
* description: 服务器错误
|
||||
*/
|
||||
export async function getSearchResults(req: Request, res: Response) {
|
||||
try {
|
||||
const {
|
||||
keyword,
|
||||
types,
|
||||
page = 1,
|
||||
fileType,
|
||||
fileSize,
|
||||
fileTime,
|
||||
mode
|
||||
} = req.query;
|
||||
|
||||
// 转换参数类型
|
||||
const searchParams = {
|
||||
keyword: keyword as string,
|
||||
types: Array.isArray(types) ? types : types ? [types as string] : [],
|
||||
page: Number(page),
|
||||
fileType: fileType as string,
|
||||
fileSize: fileSize as string,
|
||||
fileTime: fileTime as string,
|
||||
mode: mode as 'standard' | 'fuzzy'
|
||||
};
|
||||
|
||||
const result = await search(searchParams);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Search controller error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
code: 500,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/link/{resourceId}:
|
||||
* get:
|
||||
* summary: 获取资源链接
|
||||
* description: 根据资源ID生成临时下载链接
|
||||
* tags: [Resource]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: resourceId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 资源ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 成功生成链接
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* code:
|
||||
* type: integer
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* url:
|
||||
* type: string
|
||||
* expireTime:
|
||||
* type: string
|
||||
* message:
|
||||
* type: string
|
||||
* 400:
|
||||
* description: 请求参数错误
|
||||
* 500:
|
||||
* description: 服务器错误
|
||||
*/
|
||||
export async function getLinkForResource(req: Request, res: Response) {
|
||||
try {
|
||||
const { resourceId } = req.params;
|
||||
|
||||
if (!resourceId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
code: 400,
|
||||
message: '资源ID不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await generateLink(resourceId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Generate link controller error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
code: 500,
|
||||
message: '生成链接失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import express from 'express';
|
||||
import { config } from './config/config';
|
||||
import searchRoutes from './routes/searchRoutes';
|
||||
|
||||
const app = express();
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
|
||||
// Routes
|
||||
app.use('/api', searchRoutes);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
console.error('Error:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
code: 500,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(config.server.port, () => {
|
||||
console.log(`Server is running on port ${config.server.port}`);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Router } from 'express';
|
||||
import { SearchService } from '../services/searchService';
|
||||
|
||||
const router = Router();
|
||||
const searchService = new SearchService();
|
||||
|
||||
router.get('/search', async (req, res) => {
|
||||
try {
|
||||
const { keyword } = req.query;
|
||||
|
||||
if (!keyword || typeof keyword !== 'string') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
code: 400,
|
||||
message: 'Keyword is required and must be a string',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await searchService.search(keyword);
|
||||
|
||||
if (!result) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
code: 500,
|
||||
message: 'Search service error',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Search route error:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
code: 500,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,7 @@
|
||||
import app from './app'
|
||||
|
||||
const PORT = process.env.PORT || 3001
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server is running at http://localhost:${PORT}`)
|
||||
})
|
||||
@@ -0,0 +1,196 @@
|
||||
import axios from 'axios';
|
||||
import { config } from '../config/config';
|
||||
import { createClient } from 'redis';
|
||||
|
||||
const redis = createClient({
|
||||
url: `redis://${config.redis.host}:${config.redis.port}`,
|
||||
password: config.redis.password
|
||||
});
|
||||
|
||||
// Redis error handling and connection monitoring
|
||||
redis.on('error', err => console.error('Redis Client Error:', err));
|
||||
redis.on('connect', () => console.log('Redis Client Connected'));
|
||||
redis.on('ready', () => console.log('Redis Client Ready'));
|
||||
redis.on('reconnecting', () => console.log('Redis Client Reconnecting'));
|
||||
|
||||
// Initialize Redis connection
|
||||
redis.connect().catch(err => {
|
||||
console.error('Redis Connection Error:', err);
|
||||
});
|
||||
|
||||
const TOKEN_KEY = 'search_service_token';
|
||||
|
||||
interface LoginResponse {
|
||||
success: boolean;
|
||||
code: number;
|
||||
data: {
|
||||
token: string;
|
||||
};
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
success: boolean;
|
||||
code: number;
|
||||
data: Array<{
|
||||
list: Array<{
|
||||
messageId: string;
|
||||
title: string;
|
||||
pubDate: string;
|
||||
content: string;
|
||||
image?: string;
|
||||
cloudLinks?: Array<{
|
||||
link: string;
|
||||
cloudType: string;
|
||||
}>;
|
||||
tags?: string[];
|
||||
channel: string;
|
||||
channelId: string;
|
||||
}>;
|
||||
channelInfo: {
|
||||
id: string;
|
||||
name: string;
|
||||
index: number;
|
||||
channelLogo?: string;
|
||||
};
|
||||
}>;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface SearchParams {
|
||||
keyword: string;
|
||||
types: string[];
|
||||
page: number;
|
||||
fileType?: string;
|
||||
fileSize?: string;
|
||||
fileTime?: string;
|
||||
mode?: 'standard' | 'fuzzy';
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
success: boolean;
|
||||
code: number;
|
||||
data: any[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface LinkResult {
|
||||
success: boolean;
|
||||
code: number;
|
||||
data: {
|
||||
url: string;
|
||||
expireTime: string;
|
||||
};
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class SearchService {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = config.searchService.baseUrl;
|
||||
}
|
||||
|
||||
private async getToken(): Promise<string | null> {
|
||||
try {
|
||||
// Try to get token from Redis
|
||||
const cachedToken = await redis.get(TOKEN_KEY);
|
||||
if (cachedToken) {
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
// Login to get new token
|
||||
const response = await axios.post<LoginResponse>(
|
||||
`${this.baseUrl}${config.searchService.endpoints.login}`,
|
||||
{
|
||||
username: process.env.SEARCH_SERVICE_USERNAME,
|
||||
password: process.env.SEARCH_SERVICE_PASSWORD,
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.success && response.data.data.token) {
|
||||
// Cache token in Redis
|
||||
await redis.set(TOKEN_KEY, response.data.data.token, {
|
||||
EX: 21600, // 6 hours
|
||||
});
|
||||
return response.data.data.token;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting token:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async search(keyword: string): Promise<SearchResponse | null> {
|
||||
try {
|
||||
const token = await this.getToken();
|
||||
if (!token) {
|
||||
throw new Error('Failed to get authentication token');
|
||||
}
|
||||
|
||||
const response = await axios.get<SearchResponse>(
|
||||
`${this.baseUrl}${config.searchService.endpoints.search}`,
|
||||
{
|
||||
params: { keyword },
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error performing search:', error);
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
// Token expired, clear it from Redis
|
||||
await redis.del(TOKEN_KEY);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async searchByParams(params: SearchParams): Promise<SearchResult> {
|
||||
try {
|
||||
// TODO: 实现实际的搜索逻辑
|
||||
return {
|
||||
success: true,
|
||||
code: 0,
|
||||
data: [],
|
||||
message: '搜索成功'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Search service error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async generateLink(resourceId: string): Promise<LinkResult> {
|
||||
try {
|
||||
// TODO: 实现实际的链接生成逻辑
|
||||
return {
|
||||
success: true,
|
||||
code: 0,
|
||||
data: {
|
||||
url: `https://example.com/download/${resourceId}`,
|
||||
expireTime: new Date(Date.now() + 30 * 60 * 1000).toISOString() // 30分钟后过期
|
||||
},
|
||||
message: '链接生成成功'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Generate link service error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 将这些函数从类中移出,作为独立的导出函数
|
||||
export async function search(params: SearchParams): Promise<SearchResult> {
|
||||
const searchService = new SearchService();
|
||||
return searchService.searchByParams(params);
|
||||
}
|
||||
|
||||
export async function generateLink(resourceId: string): Promise<LinkResult> {
|
||||
const searchService = new SearchService();
|
||||
return searchService.generateLink(resourceId);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
const Redis = require('redis');
|
||||
require('dotenv').config();
|
||||
|
||||
async function testRedis() {
|
||||
const client = Redis.createClient({
|
||||
url: `redis://${process.env.REDIS_HOST || '192.168.1.99'}:${process.env.REDIS_PORT || 6379}`,
|
||||
password: process.env.REDIS_PASSWORD
|
||||
});
|
||||
|
||||
client.on('error', (err) => {
|
||||
console.error('Redis连接错误:', err);
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
console.log('Redis连接成功!');
|
||||
await client.quit();
|
||||
} catch (err) {
|
||||
console.error('Redis连接失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('正在尝试连接Redis:', {
|
||||
host: process.env.REDIS_HOST || '192.168.1.99',
|
||||
port: process.env.REDIS_PORT || 6379
|
||||
});
|
||||
|
||||
testRedis();
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
After Width: | Height: | Size: 214 KiB |
@@ -0,0 +1,117 @@
|
||||
#### 1. 登录接口 (`POST /api/user/login`)
|
||||
|
||||
##### 接口描述:
|
||||
|
||||
- 用户登录接口,用于验证用户凭据并返回访问令牌(`token`)。
|
||||
|
||||
##### 请求路径:
|
||||
|
||||
- `POST http://192.168.1.99:8008/api/user/login`
|
||||
|
||||
##### 请求参数:
|
||||
|
||||
|字段|类型|必填|描述|
|
||||
|---|---|---|---|
|
||||
|`username`|`string`|是|用户名|
|
||||
|`password`|`string`|是|密码|
|
||||
|
||||
##### 示例请求体:
|
||||
|
||||
```json
|
||||
{ "username": "mzaxd", "password": "200712" }
|
||||
```
|
||||
|
||||
##### 返回值:
|
||||
|
||||
结构如以下示例,包含 token 用于后续请求的鉴权。
|
||||
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": 0,
|
||||
"data": {
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI4YmJlZTIzYy1jZGY3LTQ5OTktOTU5NC0zNjZmNDljOTI3MjgiLCJyb2xlIjoxLCJpYXQiOjE3NDYzNDAxNTAsImV4cCI6MTc0NjM2MTc1MH0.a5sziK9-zkfw3WGhpi-j6zKhl3SgxRMf-bsdEf6xGqA"
|
||||
},
|
||||
"message": "操作成功"
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 2. 搜索接口 (`GET /api/search`)
|
||||
|
||||
##### 接口描述:
|
||||
|
||||
- 搜索资源的接口,根据关键字 `keyword` 匹配数据并返回符合条件的列表。
|
||||
|
||||
##### 请求路径:
|
||||
|
||||
- `GET http://192.168.1.99:8008/api/search`
|
||||
|
||||
##### 查询参数:
|
||||
|
||||
|参数|类型|必填|描述|备注|
|
||||
|---|---|---|---|---|
|
||||
|`keyword`|`string`|是|搜索关键字|示例:"破地狱"|
|
||||
|`lastMessageId`|`string`|否|不重要,留空即可|-|
|
||||
|
||||
##### 示例请求:
|
||||
|
||||
`GET /api/search?keyword=破地狱&lastMessageId=`
|
||||
|
||||
##### 返回值:
|
||||
|
||||
结构如以下示例,包含多个 `channel` 下的资源条目。
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": 0,
|
||||
"data": [
|
||||
{
|
||||
"list": [
|
||||
{
|
||||
"messageId": "188367",
|
||||
"title": "名称:《破·地狱》(2024)[1080P][中文字幕][剧情/家庭][黄子华/许冠文/卫诗雅]",
|
||||
"pubDate": "2025-04-30T14:54:55+00:00",
|
||||
"content": "影片通过「破地狱」古老丧礼仪式探讨生死与家庭...",
|
||||
"image": "资源预览图URLO Caldera.jpg",
|
||||
"cloudLinks": [
|
||||
{
|
||||
"link": "网盘链接",
|
||||
"cloudType": "quark"
|
||||
}
|
||||
],
|
||||
"tags": ["#剧情", "#家庭", "#破地狱"],
|
||||
"channel": " kaynak藏(夸克)",
|
||||
"channelId": "yunpanshare"
|
||||
}
|
||||
],
|
||||
"channelInfo": {
|
||||
"id": "yunpanshare",
|
||||
"name": "原盘(夸克)",
|
||||
"index": 0,
|
||||
"channelLogo": "频道logoURL"
|
||||
}
|
||||
},
|
||||
{
|
||||
"list": [
|
||||
{
|
||||
"messageId": "5725",
|
||||
"title": "原盘版『破地狱』(2025) 1080P REMUX 国粤音轨",
|
||||
"pubDate": "2025-04-10T05:10:10+00:00",
|
||||
"content": "婚礼策划师道生(黄子华饰)债务危机..."
|
||||
}
|
||||
],
|
||||
"channelInfo": {
|
||||
"id": "alyp_1",
|
||||
"name": "Repo 高品质影视",
|
||||
"index": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"message": "查询成功"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,221 @@
|
||||
### 🤖 Assistant
|
||||
|
||||
# 百盘搜系统架构与详细设计文档
|
||||
## 目录结构
|
||||
```markdown
|
||||
/bps
|
||||
│
|
||||
├── frontend/ # 用户前端(Nuxt.js)
|
||||
│ ├── nuxt.config.ts
|
||||
│ ├── package.json
|
||||
│ ├── pages/
|
||||
│ ├── components/
|
||||
│ ├── composables/
|
||||
│ ├── assets/
|
||||
│ └── ...(其他Nuxt相关目录)
|
||||
│
|
||||
├── admin/ # 管理端后台(Nuxt.js 或 Vite+Vue3)
|
||||
│ ├── nuxt.config.ts
|
||||
│ ├── package.json
|
||||
│ ├── pages/
|
||||
│ ├── components/
|
||||
│ ├── composables/
|
||||
│ ├── assets/
|
||||
│ └── ...(其他前端相关目录)
|
||||
│
|
||||
├── backend/ # 后端服务(Express.js + TypeScript)
|
||||
│ ├── src/
|
||||
│ │ ├── controllers/ # 控制器层(接口处理逻辑)
|
||||
│ │ │ ├── searchController.ts
|
||||
│ │ │ ├── accountController.ts
|
||||
│ │ │ ├── transcodeController.ts
|
||||
│ │ │ └── statsController.ts
|
||||
│ │ ├── services/ # 服务层(业务逻辑处理)
|
||||
│ │ │ ├── adapters/ # 网盘适配器模块
|
||||
│ │ │ │ ├── BaiduDriveAdapter.ts
|
||||
│ │ │ │ ├── QuarkDriveAdapter.ts
|
||||
│ │ │ │ ├── UCDriveAdapter.ts
|
||||
│ │ │ │ └── XunleiDriveAdapter.ts
|
||||
│ │ │ └── DriveAdapter.ts # 抽象接口
|
||||
│ │ ├── models/ # 模型层(数据实体)
|
||||
│ │ │ ├── driveAccount.ts
|
||||
│ │ │ ├── transcodeRecord.ts
|
||||
│ │ │ └── ...
|
||||
│ │ ├── middlewares/ # 中间件(安全认证、日志等)
|
||||
│ │ │ ├── rateLimit.ts
|
||||
│ │ │ └── errorHandler.ts
|
||||
│ │ ├── utils/ # 工具层(加密、日志等)
|
||||
│ │ │ ├── crypto.ts
|
||||
│ │ │ └── logger.ts
|
||||
│ │ ├── prisma/ # Prisma 配置及迁移
|
||||
│ │ │ └── schema.prisma
|
||||
│ │ ├── app.ts
|
||||
│ │ └── server.ts
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ └── ...
|
||||
│
|
||||
├── doc/ # 系统文档
|
||||
│ ├── 百盘搜详细设计文档.md
|
||||
│ ├── 搜索服务接口文档.md
|
||||
│ └── ...
|
||||
│
|
||||
├── .gitignore
|
||||
├── README.md
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 模块具体设计
|
||||
### 1. 前端模块(Nuxt.js)
|
||||
#### 页面结构组件
|
||||
- **搜索页**(`/index.vue`):
|
||||
- 关键词输入、筛选器(网盘类型)、分页
|
||||
- 搜索结果展示(包含操作按钮)
|
||||
- **管理后台**(`/admin` 目录下 Nuxt/Vue 页面):
|
||||
- 统计看板(chart.js 展示)
|
||||
- 账号管理界面(增删改查表单)
|
||||
|
||||
#### 交互接口
|
||||
- **搜索请求**: `/api/search` (`GET`)
|
||||
- 参数: `keyword`, `types[]`, `page`
|
||||
-otechnically Redis 缓存(5分钟)
|
||||
- **获取资源链接**: `/api/generate-link` (`POST`)
|
||||
- 参数: `{ driveId, externalId, expiration: 10min }`
|
||||
- 调用对应适配器生成临时分享链接
|
||||
|
||||
### 2. 后端模块(Express.js + TypeScript)
|
||||
#### 核心接口声明
|
||||
```typescript
|
||||
// backend/src/controllers/searchController.ts
|
||||
export async function getSearchResults(req: Req, res: Res) {
|
||||
const { keyword, types, page = 1 } = req.query;
|
||||
try {
|
||||
const results = await SearchService.search(keyword, types, page);
|
||||
res.json(results);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'SEARCH_FAILED' });
|
||||
}
|
||||
}
|
||||
|
||||
// backend/src/services/searchService.ts
|
||||
async function search(keyword: string, types?: string[], page: number) {
|
||||
// 1. Redis 缓存命中(5分钟有效期)
|
||||
const cacheKey = `search:${keyword}:${types?.join(',')}:${page}`;
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) return JSON.parse(cached);
|
||||
|
||||
// 2. 调用外部搜索服务聚合数据
|
||||
const aniSearchResults = await callAniSearchService(keyword);
|
||||
const baiduSearchResults = await callBaiduSearchService(keyword);
|
||||
|
||||
// ...聚合后端逻辑...
|
||||
|
||||
// 3. 缓存结果
|
||||
const finalResult = {
|
||||
total: ...,
|
||||
items: [...aniResults, ...baiduResults]
|
||||
};
|
||||
await redis.set(cacheKey, JSON.stringify(finalResult), 'EX', 300);
|
||||
return finalResult;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 雷霄骄子(网盘适配器)
|
||||
#### 接口契约
|
||||
```typescript
|
||||
// backend/src/services/adapters/DriveAdapter.ts
|
||||
export interface DriveAdapter {
|
||||
getDisplayName(): string; // 展示名称(如"百度网盘")
|
||||
getServiceName(): string; // 唯一ID(如)baidu-cloud
|
||||
|
||||
transcodeAndShare(externalId: string): Promise<{ tempLink: string }>;
|
||||
// भविष्य会扩展删除等接口
|
||||
}
|
||||
```
|
||||
|
||||
#### 举例:百度网盘适配器
|
||||
```typescript
|
||||
import { DriveAdapter } from './DriveAdapter';
|
||||
|
||||
export class BaiduDriveAdapter implements DriveAdapter {
|
||||
getDisplayName() {
|
||||
return '百度网盘';
|
||||
}
|
||||
|
||||
getServiceName() {
|
||||
return 'baidu-cloud';
|
||||
}
|
||||
|
||||
async transcodeAndShare(externalId: string) {
|
||||
// 1. 检查网盘账号可用性(由 AccountService 确保)
|
||||
const account = await AccountService.findValidBaiduAccount();
|
||||
if (!account) {
|
||||
throw new Error('BAIDU_ACCOUNT_NOT_FOUND');
|
||||
}
|
||||
|
||||
// 2. 使用官方SDK获取真实 下载URL
|
||||
const baiduSdk = new BaiduSDK(account.appId, account.secret);
|
||||
const shareLink = await baiduSdk.createTempShare(externalId);
|
||||
|
||||
// 3. 缓存链接10分钟(redis 记录)
|
||||
await redis.set(`link:${externalId}`, shareLink, 'EX', 600);
|
||||
return { tempLink: shareLink };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 高性能架构策略
|
||||
1. **负载分流**:
|
||||
- 复制搜索服务实例
|
||||
- 使用nginx作为负载均衡器
|
||||
2. **异步队列**:
|
||||
```typescript
|
||||
//фровoeysaTaskQueue
|
||||
const rabbitMq = new RabbitMQ();
|
||||
|
||||
// Generates a link in the background
|
||||
export async function createLinkWorker(externalId: string) {
|
||||
const adapter = getAdapterByExternalId(externalId);
|
||||
const { tempLink } = await adapter.transcodeAndShare(externalId);
|
||||
await rabbitMq.sendToUser('LINK_READY', tempLink);
|
||||
}
|
||||
```
|
||||
3. **提前缓存**:
|
||||
- 定期轮询热门搜索关键词
|
||||
- 保持10%用户行为采样结果缓存
|
||||
|
||||
### 5. 安全与监控
|
||||
- **日志监控**:
|
||||
```yaml
|
||||
# Extract logs into loki for DevOps
|
||||
loki-config:
|
||||
host: http://loki.internal:3100
|
||||
job: backend | labels: "app=bps, env=production"
|
||||
```
|
||||
- **防攻击策略**:
|
||||
- 限速(5次/秒)
|
||||
- CSRF令牌保护
|
||||
- 密码使用argon2加密
|
||||
|
||||
### 6. 完成功能里程碑
|
||||
| # | 特性 | 完成状态 | 备注 |
|
||||
|---|------------------------|---------|------|
|
||||
| 1 | 多网盘搜索聚合 | ✅ | |
|
||||
| 2 | 自动换adolink逻辑 | 🚧 | |
|
||||
| 3 | 支持未注册用户使用 | ✅ | |
|
||||
| 4 | 账号余额告警(可选) | ❌ | |
|
||||
|
||||
## 扩展接口设计 blueprint
|
||||
```typescript
|
||||
// 未来可能接口
|
||||
async deleteResource(externalId: string): Promise<void> {
|
||||
// 通过适配器调用网盘真实删除接口
|
||||
}
|
||||
```
|
||||
|
||||
> This is a hypothetical description. To summarize:
|
||||
> 1) I mapped your requirements into technical modules with code snippets.
|
||||
> 2) Extracted monolithic design into isolated components (front/Admin/Backend).
|
||||
> 3) Added suggestions for quick iteration.
|
||||
> 4) Balanced "now" vs "future" features with Mehndiroller labels.
|
||||
> Let me know if you'd like me to refine any sections further—like providing actual Prisma/S elems models for example!
|
||||
@@ -0,0 +1,172 @@
|
||||
## **项目概述**
|
||||
|
||||
### 目标
|
||||
|
||||
构建一个基于Nuxt.js的前端界面,通过后端服务对接多个网盘资源搜索接口,实现资源搜索、筛选、转存及临时分享功能。后端需支持网盘账号管理、资源转存逻辑扩展、Redis缓存及MySQL数据库存储。
|
||||
|
||||
### 约束条件
|
||||
|
||||
- **技术栈**:
|
||||
- 前端:Nuxt.js(TypeScript模式)
|
||||
- 后端:Express.js(TypeScript模式)
|
||||
- 数据库:MySQL 8.0(ORM使用Prisma)
|
||||
- 缓存:Redis
|
||||
- 接口协议:RESTful API
|
||||
- **核心原则**:
|
||||
- 所有网盘操作需通过后端中转,避免用户直接访问原始网盘接口
|
||||
- 转存资源需在10分钟后自动删除
|
||||
- 系统需支持未来扩展其他网盘服务
|
||||
|
||||
---
|
||||
|
||||
## **功能模块设计**
|
||||
|
||||
### **1. 前端界面**
|
||||
|
||||
#### **1.1 搜索页**
|
||||
|
||||
- **核心组件**:
|
||||
- 搜索框:支持输入关键词并提交搜索请求
|
||||
- 筛选条件:下拉选择(如“百度网盘”“阿里云盘”等)
|
||||
- 分页控件:支持跳转和动态加载
|
||||
- **数据展示**:
|
||||
- 列表项包含:文件名、大小、目录路径、来源网盘标识
|
||||
- 每项操作按钮:点击“获取链接”触发后端转存流程
|
||||
- **交互逻辑**:
|
||||
- 搜索请求发送至后端API `/api/search`
|
||||
- 结果分页响应需包含总页数、当前页数据
|
||||
- 筛选条件提交需触发完整搜索请求
|
||||
|
||||
#### **1.2 管理后台**
|
||||
|
||||
- **功能模块**:
|
||||
- **统计数据看板**:
|
||||
- 实时搜索量统计(按网盘类型)
|
||||
- 转存成功/失败次数
|
||||
- 用户行为分析(如热门搜索词)
|
||||
- **账号管理**:
|
||||
- 网盘账号列表(支持增删改)
|
||||
- 账号状态监控(如API调用配额)
|
||||
|
||||
---
|
||||
|
||||
### **2. 后端服务**
|
||||
|
||||
#### **2.1 核心接口设计**
|
||||
|
||||
|接口路径|方法|功能描述|
|
||||
|---|---|---|
|
||||
|`/api/search`|POST|接收关键词及筛选条件,调用搜索服务器接口并缓存结果到Redis|
|
||||
|`/api/generate-link`|POST|根据资源ID触发转存,返回临时分享链接,设置10分钟过期定时任务|
|
||||
|`/api/stats`|GET|返回统计信息(如总搜索次数、转存成功率)|
|
||||
|`/api/accounts`|CRUD|管理网盘账号凭证(加密存储)|
|
||||
|
||||
#### **2.2 转存逻辑模块**
|
||||
|
||||
- **流程**:
|
||||
1. 用户点击“获取链接” → 后端验证资源是否存在
|
||||
2. 调用对应网盘API(如BaiduAPI、AliyunAPI)进行转存
|
||||
3. 转存成功后,记录分享链接及过期时间到数据库
|
||||
4. 返回链接给前端,启动定时任务在10分钟后删除资源
|
||||
- **扩展性设计**:
|
||||
- 每个网盘的转存逻辑封装为独立模块(如 `src/services/BaiduDriveService.ts`)
|
||||
- 通过策略模式(Strategy Pattern)动态选择网盘适配器
|
||||
|
||||
#### **2.3 缓存策略**
|
||||
|
||||
- **Redis缓存规则**:
|
||||
- **搜索结果缓存**:
|
||||
- Key:`search:${keyword}:${filter}`
|
||||
- TTL:5分钟
|
||||
- **转存链接缓存**:
|
||||
- Key:`temp_link:${resourceId}`
|
||||
- TTL:10分钟(与自动删除逻辑同步)
|
||||
|
||||
---
|
||||
|
||||
## **4. 扩展性设计**
|
||||
|
||||
### **4.1 网盘适配器**
|
||||
|
||||
- **抽象接口**:
|
||||
|
||||
|
||||
|
||||
```typescript
|
||||
interface DriveAdapter {
|
||||
transcode(resourceId: string, account: Account): Promise<string | null>
|
||||
getShareLink(resourceId: string): Promise<string>
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
- **实现示例**:
|
||||
|
||||
<TYPESCRIPT>
|
||||
|
||||
```
|
||||
class BaiduDriveAdapter implements DriveAdapter { async transcode(...) { /* 百度网盘转存逻辑 */ } async getShareLink(...) { /* 生成百度网盘分享链接 */ }}
|
||||
```
|
||||
|
||||
|
||||
### **4.2 插件化架构**
|
||||
|
||||
- **账号管理**:
|
||||
- 新增网盘类型时,仅需注册对应适配器及账号凭证字段
|
||||
- 转存逻辑通过工厂模式动态选择适配器
|
||||
|
||||
---
|
||||
|
||||
## **5. 安全与可靠性**
|
||||
|
||||
### **5.1 安全措施**
|
||||
|
||||
- **敏感信息保护**:
|
||||
- 网盘账号凭证加密存储(如AES加密)
|
||||
- Redis缓存数据不存储敏感信息
|
||||
- **防滥用机制**:
|
||||
- 单用户请求频率限制(通过Nginx或Express Rate Limit中间件)
|
||||
- 转存失败重试次数限制
|
||||
|
||||
### **5.2 异常处理**
|
||||
|
||||
- **后端**:
|
||||
- 网盘API调用失败时记录日志并返回错误码
|
||||
- 转存超时资源自动清理(通过Cron任务扫描`TranscodeRecord`表)
|
||||
- **前端**:
|
||||
- 网络请求超时提示
|
||||
- 转存失败时显示错误原因(如“账号配额不足”)
|
||||
|
||||
---
|
||||
|
||||
## **6. 非功能性需求**
|
||||
|
||||
|
||||
### **6.1 可维护性**
|
||||
|
||||
- **日志监控**:
|
||||
- 使用 Winston 或 Log4js 记录关键操作日志
|
||||
- 日志内容对接loki
|
||||
|
||||
---
|
||||
|
||||
## **附录:技术选型说明**
|
||||
|
||||
### **前端(Nuxt.js)**
|
||||
|
||||
- **优势**:
|
||||
- 支持TypeScript和SSR,提升SEO友好度
|
||||
- 通过API Routes实现服务端渲染与数据预取
|
||||
|
||||
### **后端(Express.js)**
|
||||
|
||||
- **优势**:
|
||||
- 灵活配置中间件(如 Redis连接池、CORS)
|
||||
- 与TypeScript结合良好,支持接口强类型定义
|
||||
|
||||
### **数据库(MySQL + Prisma)**
|
||||
|
||||
- **优势**:
|
||||
- Prisma提供TypeScript类型安全的数据库操作
|
||||
- 支持复杂查询(如关联查询转存记录与账号)
|
||||
|
After Width: | Height: | Size: 136 KiB |
@@ -0,0 +1,75 @@
|
||||
# Nuxt Minimal Starter
|
||||
|
||||
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to install dependencies:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm dev
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm build
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run preview
|
||||
|
||||
# pnpm
|
||||
pnpm preview
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<header class="header">
|
||||
<div class="logo-container">
|
||||
<img :src="logoImg" alt="百盘搜" class="logo">
|
||||
<h1 class="site-title">百盘搜</h1>
|
||||
</div>
|
||||
<div class="search-container">
|
||||
<input type="text" placeholder="请输入您要搜索的关键词" v-model="searchKeyword" @keyup.enter="handleSearch">
|
||||
<button @click="handleSearch" class="search-button">
|
||||
<img :src="searchIcon" alt="搜索" class="search-icon">
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="notice-bar">
|
||||
<span class="notice-icon">📢</span>
|
||||
<span>备用站点:</span>
|
||||
<a href="https://www.feizhupan.com" target="_blank">https://www.feizhupan.com</a>,
|
||||
<a href="https://www.dashengpan.xyz" target="_blank">https://www.dashengpan.xyz</a>,
|
||||
<span>请保存到浏览器书签中。</span>
|
||||
</div>
|
||||
<main class="main-content">
|
||||
<NuxtPage />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import logoImg from '~/assets/logo.png';
|
||||
import searchIcon from '~/assets/search-icon.svg';
|
||||
|
||||
const router = useRouter();
|
||||
const searchKeyword = ref('');
|
||||
|
||||
function handleSearch() {
|
||||
if (searchKeyword.value.trim()) {
|
||||
router.push({ path: '/', query: { keyword: searchKeyword.value } });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 2rem;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.site-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: #ff9500;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
width: 50%;
|
||||
max-width: 600px;
|
||||
border: 2px solid #ff9500;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-container input {
|
||||
flex-grow: 1;
|
||||
padding: 0.6rem 1rem;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
background-color: #ff9500;
|
||||
border: none;
|
||||
padding: 0.6rem 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.notice-bar {
|
||||
background-color: #fff8e6;
|
||||
padding: 0.5rem 2rem;
|
||||
color: #9e6500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.notice-bar a {
|
||||
color: #ff9500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
}
|
||||
</style>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 278 B |
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div class="result-item">
|
||||
<div class="result-icon">
|
||||
<img :src="getFileTypeIcon(item.fileType)" :alt="item.fileType" />
|
||||
</div>
|
||||
<div class="result-content">
|
||||
<h3 class="result-title">{{ item.title }}</h3>
|
||||
<div class="result-details">
|
||||
<div v-if="item.fileList && item.fileList.length" class="file-list">
|
||||
<div v-for="(file, index) in visibleFiles" :key="index" class="file-item">
|
||||
<span>├ {{ file.name }} - {{ formatSize(file.size) }}</span>
|
||||
</div>
|
||||
<div v-if="item.fileList.length > maxVisibleFiles" class="more-files">
|
||||
...还有{{ item.fileList.length - maxVisibleFiles }}个文件
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-meta">
|
||||
<span class="size">文件大小: {{ formatSize(item.size) }}</span>
|
||||
<span class="date">更新时间: {{ item.updateTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-actions">
|
||||
<button class="get-link-button" @click="$emit('get-link', item)">
|
||||
获取链接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface File {
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
fileType: string;
|
||||
size: number;
|
||||
updateTime: string;
|
||||
cloudType: string;
|
||||
fileList?: File[];
|
||||
}
|
||||
|
||||
const props = defineProps<{ item: SearchResult }>()
|
||||
const emit = defineEmits<{ (e: 'get-link', item: SearchResult): void }>()
|
||||
|
||||
const maxVisibleFiles = 5
|
||||
|
||||
const visibleFiles = computed(() => {
|
||||
if (!props.item.fileList) return []
|
||||
return props.item.fileList.slice(0, maxVisibleFiles)
|
||||
})
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
|
||||
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i]
|
||||
}
|
||||
|
||||
function getFileTypeIcon(fileType: string) {
|
||||
const icons: Record<string, string> = {
|
||||
folder: '/icons/folder.png',
|
||||
document: '/icons/document.png',
|
||||
video: '/icons/video.png',
|
||||
audio: '/icons/audio.png',
|
||||
image: '/icons/image.png',
|
||||
}
|
||||
|
||||
return icons[fileType] || '/icons/file.png'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.result-item {
|
||||
display: flex;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.result-icon img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.result-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
margin-bottom: 4px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.more-files {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
font-size: 13px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
margin-left: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.get-link-button {
|
||||
background-color: #ff9500;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.get-link-button:hover {
|
||||
background-color: #e68600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2024-11-01',
|
||||
devtools: { enabled: true }
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "nuxt-app",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"nuxt": "^3.17.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
<template>
|
||||
<div class="search-page">
|
||||
<div class="filter-bar">
|
||||
<div>
|
||||
筛选:
|
||||
<div class="filter-group">
|
||||
<span>模式</span>
|
||||
<select v-model="filterMode">
|
||||
<option value="standard">精准搜索</option>
|
||||
<option value="fuzzy">模糊搜索</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span>文件类型</span>
|
||||
<select v-model="fileType">
|
||||
<option value="">全部</option>
|
||||
<option value="document">文档</option>
|
||||
<option value="video">视频</option>
|
||||
<option value="audio">音频</option>
|
||||
<option value="image">图片</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span>文件大小</span>
|
||||
<select v-model="fileSize">
|
||||
<option value="">全部</option>
|
||||
<option value="small">小(< 10MB)</option>
|
||||
<option value="medium">中(10MB-100MB)</option>
|
||||
<option value="large">大(100MB-1GB)</option>
|
||||
<option value="huge">超大(> 1GB)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span>文件时间</span>
|
||||
<select v-model="fileTime">
|
||||
<option value="">全部</option>
|
||||
<option value="today">今天</option>
|
||||
<option value="week">本周</option>
|
||||
<option value="month">本月</option>
|
||||
<option value="year">今年</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<button class="clear-button" @click="clearFilters">
|
||||
<span>清除</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cloud-filter">
|
||||
<div>
|
||||
网盘:
|
||||
<button
|
||||
v-for="drive in driveTypes"
|
||||
:key="drive.value"
|
||||
:class="['cloud-button', { active: selectedDrives.includes(drive.value) }]"
|
||||
@click="toggleDrive(drive.value)"
|
||||
>
|
||||
<img :src="drive.icon" :alt="drive.label" class="cloud-icon" />
|
||||
<span>{{ drive.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-tips" v-if="!searched">
|
||||
<h3>搜索小技巧:</h3>
|
||||
<ol>
|
||||
<li>可以灵活选用「精准搜索」和「模糊搜索」</li>
|
||||
<li>搜索资源时可以通过资源「类型」、「时间」等进行筛选</li>
|
||||
<li>增加「文件来源」如(百度网盘、阿里云盘等)进行筛选</li>
|
||||
<li>搜索关键次尽量不要包含「的」「与」等无关助词,只包含关键词即可</li>
|
||||
<li>如搜索过于频繁被屏蔽,请发送UID信息给shakanamo945@gmail.com申请解封</li>
|
||||
<li>如有希望我们重点关注或收录的资源网站来源(限免费开放式网站),请发送邮件至:zhuangbee8287@163.com</li>
|
||||
<li>相关版权方对于搜索关键词屏蔽需求请通过发送邮件至:shakanamo945@gmail.com;</li>
|
||||
<li>坚决抵制劣质、违规、隐私、风险版权、其他问题资源,请大家发现一律通过页面举报;</li>
|
||||
<li>备用站点 <a href="https://www.feizhupan.com">https://www.feizhupan.com</a>,<a href="https://www.dashengpan.xyz">https://www.dashengpan.xyz</a>;</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div v-if="searched">
|
||||
<div v-if="results.length === 0" class="no-results">
|
||||
<p>没有找到相关结果,请尝试其他关键词或筛选条件</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="results-container">
|
||||
<div v-for="(group, index) in groupedResults" :key="index" class="result-group">
|
||||
<div class="cloud-header">
|
||||
<img :src="getCloudIcon(group.cloudType)" :alt="group.cloudType" class="cloud-icon" />
|
||||
<span class="cloud-name">{{ getCloudName(group.cloudType) }}</span>
|
||||
<span class="result-count">共搜出 {{ group.items.length }} 条结果</span>
|
||||
</div>
|
||||
|
||||
<div class="result-list">
|
||||
<div v-for="item in group.items" :key="item.id" class="result-item">
|
||||
<div class="result-icon">
|
||||
<img :src="getFileTypeIcon(item.fileType)" :alt="item.fileType" />
|
||||
</div>
|
||||
<div class="result-content">
|
||||
<div class="result-title">{{ item.title }}</div>
|
||||
<div class="result-details">
|
||||
<div v-if="item.fileList && item.fileList.length" class="file-list">
|
||||
<div v-for="file in item.fileList" :key="file.name" class="file-item">
|
||||
<span>├ {{ file.name }} - {{ formatSize(file.size) }}</span>
|
||||
</div>
|
||||
<div v-if="item.fileList.length > 5" class="more-files">
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-meta">
|
||||
<span>文件大小: {{ formatSize(item.size) }}</span>
|
||||
<span>更新时间: {{ formatDate(item.updateTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-actions">
|
||||
<button class="get-link-button" @click="getLink(item)">
|
||||
获取链接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button :disabled="page === 1" @click="changePage(page - 1)">上一页</button>
|
||||
<div class="page-numbers">
|
||||
<button
|
||||
v-for="p in displayedPages"
|
||||
:key="p"
|
||||
:class="['page-number', { active: p === page }]"
|
||||
@click="changePage(p)"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
</div>
|
||||
<button :disabled="page === totalPages" @click="changePage(page + 1)">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const keyword = ref('')
|
||||
const filterMode = ref('standard')
|
||||
const fileType = ref('')
|
||||
const fileSize = ref('')
|
||||
const fileTime = ref('')
|
||||
const selectedDrives = ref<string[]>(['baidu', 'aliyun', 'quark', 'xunlei'])
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const results = ref<any[]>([])
|
||||
const searched = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const driveTypes = [
|
||||
{ value: 'baidu', label: '百度网盘', icon: '/images/baidu.png' },
|
||||
{ value: 'aliyun', label: '阿里云盘', icon: '/images/aliyun.png' },
|
||||
{ value: 'quark', label: '夸克网盘', icon: '/images/quark.png' },
|
||||
{ value: 'xunlei', label: '迅雷网盘', icon: '/images/xunlei.png' },
|
||||
]
|
||||
|
||||
// 模拟数据
|
||||
const mockResults = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Deepseek',
|
||||
fileType: 'folder',
|
||||
size: 312 * 1024 * 1024 * 1024,
|
||||
updateTime: '2025-04-15',
|
||||
cloudType: 'quark',
|
||||
fileList: [
|
||||
{ name: 'deepseek.apk', size: 8.8 * 1024 * 1024 },
|
||||
{ name: 'DeepSeek.dmg', size: 14.6 * 1024 * 1024 },
|
||||
{ name: 'DeepSeek_x64.msi', size: 6.1 * 1024 * 1024 },
|
||||
{ name: 'DeepSeek15天指导手册.pdf', size: 1.3 * 1024 * 1024 },
|
||||
{ name: 'DeepSeek_x86_64.deb', size: 8.2 * 1024 * 1024 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Deepseek',
|
||||
fileType: 'folder',
|
||||
size: 311.8 * 1024 * 1024 * 1024,
|
||||
updateTime: '2025-03-07',
|
||||
cloudType: 'quark',
|
||||
fileList: [
|
||||
{ name: 'deepseek.apk', size: 8.8 * 1024 * 1024 },
|
||||
{ name: 'DeepSeek.dmg', size: 14.6 * 1024 * 1024 },
|
||||
{ name: 'DeepSeek_x64.msi', size: 6.1 * 1024 * 1024 },
|
||||
{ name: 'DeepSeek15天指导手册.pdf', size: 1.3 * 1024 * 1024 },
|
||||
{ name: 'DeepSeek_x86_64.deb', size: 8.2 * 1024 * 1024 },
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const groupedResults = computed(() => {
|
||||
const groups: Record<string, { cloudType: string, items: any[] }> = {}
|
||||
|
||||
results.value.forEach(result => {
|
||||
if (!groups[result.cloudType]) {
|
||||
groups[result.cloudType] = {
|
||||
cloudType: result.cloudType,
|
||||
items: []
|
||||
}
|
||||
}
|
||||
groups[result.cloudType].items.push(result)
|
||||
})
|
||||
|
||||
return Object.values(groups)
|
||||
})
|
||||
|
||||
const displayedPages = computed(() => {
|
||||
const totalDisplayed = 5
|
||||
const currentPage = page.value
|
||||
const total = totalPages.value
|
||||
|
||||
if (total <= totalDisplayed) {
|
||||
return Array.from({ length: total }, (_, i) => i + 1)
|
||||
}
|
||||
|
||||
let start = Math.max(currentPage - Math.floor(totalDisplayed / 2), 1)
|
||||
let end = start + totalDisplayed - 1
|
||||
|
||||
if (end > total) {
|
||||
end = total
|
||||
start = Math.max(end - totalDisplayed + 1, 1)
|
||||
}
|
||||
|
||||
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const queryKeyword = route.query.keyword as string
|
||||
if (queryKeyword) {
|
||||
keyword.value = queryKeyword
|
||||
onSearch()
|
||||
}
|
||||
})
|
||||
|
||||
function toggleDrive(drive: string) {
|
||||
const index = selectedDrives.value.indexOf(drive)
|
||||
if (index === -1) {
|
||||
selectedDrives.value.push(drive)
|
||||
} else {
|
||||
selectedDrives.value.splice(index, 1)
|
||||
}
|
||||
|
||||
if (searched.value) {
|
||||
fetchResults()
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
filterMode.value = 'standard'
|
||||
fileType.value = ''
|
||||
fileSize.value = ''
|
||||
fileTime.value = ''
|
||||
selectedDrives.value = ['baidu', 'aliyun', 'quark', 'xunlei']
|
||||
|
||||
if (searched.value) {
|
||||
fetchResults()
|
||||
}
|
||||
}
|
||||
|
||||
async function onSearch() {
|
||||
if (!keyword.value.trim()) return
|
||||
|
||||
page.value = 1
|
||||
await fetchResults()
|
||||
}
|
||||
|
||||
async function fetchResults() {
|
||||
loading.value = true
|
||||
searched.value = true
|
||||
|
||||
try {
|
||||
// 构建API请求参数
|
||||
const params = new URLSearchParams();
|
||||
if (keyword.value) params.append('keyword', keyword.value);
|
||||
selectedDrives.value.forEach(drive => {
|
||||
params.append('types', drive);
|
||||
});
|
||||
params.append('page', page.value.toString());
|
||||
if (fileType.value) params.append('fileType', fileType.value);
|
||||
if (fileSize.value) params.append('fileSize', fileSize.value);
|
||||
if (fileTime.value) params.append('fileTime', fileTime.value);
|
||||
params.append('mode', filterMode.value);
|
||||
|
||||
// 调用后端API
|
||||
const response = await fetch(`/api/search?${params.toString()}`);
|
||||
const responseData = await response.json();
|
||||
|
||||
if (responseData.success) {
|
||||
results.value = responseData.data;
|
||||
totalPages.value = responseData.totalPages || 1;
|
||||
} else {
|
||||
console.error('搜索失败:', responseData.message);
|
||||
results.value = [];
|
||||
}
|
||||
|
||||
// 模拟数据(当后端未启动时)
|
||||
if (results.value.length === 0) {
|
||||
results.value = mockResults;
|
||||
totalPages.value = 5;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索失败', error);
|
||||
// 使用模拟数据
|
||||
results.value = mockResults;
|
||||
totalPages.value = 5;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function changePage(newPage: number) {
|
||||
page.value = newPage
|
||||
|
||||
// 更新URL但不触发路由变化
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
page: newPage.toString()
|
||||
}
|
||||
})
|
||||
|
||||
fetchResults()
|
||||
}
|
||||
|
||||
async function getLink(item: any) {
|
||||
try {
|
||||
// 调用后端生成链接API
|
||||
const response = await fetch(`/api/link/${item.id}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.data.link) {
|
||||
// 显示链接
|
||||
alert(`获取链接成功: ${data.data.link}\n有效期: ${data.data.expiresIn}秒`);
|
||||
} else {
|
||||
alert(`获取链接失败: ${data.message || '未知错误'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取链接失败', error);
|
||||
alert(`获取链接失败: 服务异常,请稍后再试`);
|
||||
}
|
||||
}
|
||||
|
||||
function getCloudIcon(cloudType: string) {
|
||||
const drive = driveTypes.find(d => d.value === cloudType)
|
||||
return drive?.icon || '/icons/default-cloud.png'
|
||||
}
|
||||
|
||||
function getCloudName(cloudType: string) {
|
||||
const drive = driveTypes.find(d => d.value === cloudType)
|
||||
return drive?.label || cloudType
|
||||
}
|
||||
|
||||
function getFileTypeIcon(fileType: string) {
|
||||
const icons: Record<string, string> = {
|
||||
folder: '/icons/folder.png',
|
||||
document: '/icons/document.png',
|
||||
video: '/icons/video.png',
|
||||
audio: '/icons/audio.png',
|
||||
image: '/icons/image.png',
|
||||
}
|
||||
|
||||
return icons[fileType] || '/icons/file.png'
|
||||
}
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
|
||||
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i]
|
||||
}
|
||||
|
||||
function formatDate(date: string) {
|
||||
return date
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.filter-bar, .cloud-filter {
|
||||
background-color: #fff;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.filter-group span {
|
||||
margin-right: 8px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.filter-group select {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
background-color: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cloud-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background-color: #f9f9f9;
|
||||
margin-right: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cloud-button.active {
|
||||
background-color: #e6f7ff;
|
||||
border-color: #1890ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.cloud-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.search-tips {
|
||||
background-color: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.search-tips h3 {
|
||||
margin-bottom: 12px;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.search-tips ol {
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.search-tips li {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.search-tips a {
|
||||
color: #1890ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
background-color: #fff;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.results-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.result-group {
|
||||
background-color: #fff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cloud-header {
|
||||
padding: 12px 16px;
|
||||
background-color: #f9f9f9;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cloud-name {
|
||||
font-weight: 500;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
color: #ff9500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.result-list {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.result-icon img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.result-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.result-details {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
margin-bottom: 4px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.more-files {
|
||||
color: #888;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
margin-left: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.get-link-button {
|
||||
background-color: #ff9500;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #ddd;
|
||||
background-color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-numbers {
|
||||
display: flex;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.page-number {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.page-number.active {
|
||||
background-color: #ff9500;
|
||||
color: white;
|
||||
border-color: #ff9500;
|
||||
}
|
||||
</style>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,2 @@
|
||||
User-Agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../.nuxt/tsconfig.server.json"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// https://nuxt.com/docs/guide/concepts/typescript
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "bps",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"dotenv": "^16.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.5.0",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
|
||||
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"dotenv": "^16.5.0"
|
||||
}
|
||||
}
|
||||