feat: 第一次生成

This commit is contained in:
2025-05-04 20:37:40 +08:00
commit 5c01df4a93
42 changed files with 12705 additions and 0 deletions
+25
View File
@@ -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/
+78
View File
@@ -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. 按需加载
+77
View File
@@ -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. 监控指标
+68
View File
@@ -0,0 +1,68 @@
---
description:
globs:
alwaysApply: false
---
# 数据库设计规范
## 数据库选型
- 主数据库:MySQL 8.0
- ORMPrisma
- 缓存: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. 异常监控
+60
View File
@@ -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. 提交前进行代码审查
+28
View File
@@ -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/
+10
View File
@@ -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
+2287
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -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"
}
}
+34
View File
@@ -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
+39
View File
@@ -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',
}
};
+37
View File
@@ -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);
+171
View File
@@ -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: '生成链接失败'
});
}
}
+26
View File
@@ -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}`);
});
+40
View File
@@ -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;
+7
View File
@@ -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}`)
})
+196
View File
@@ -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);
}
+28
View File
@@ -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();
+13
View File
@@ -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"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

+117
View File
@@ -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!
+172
View File
@@ -0,0 +1,172 @@
## **项目概述**
### 目标
构建一个基于Nuxt.js的前端界面,通过后端服务对接多个网盘资源搜索接口,实现资源搜索、筛选、转存及临时分享功能。后端需支持网盘账号管理、资源转存逻辑扩展、Redis缓存及MySQL数据库存储。
### 约束条件
- **技术栈**
- 前端:Nuxt.jsTypeScript模式)
- 后端:Express.jsTypeScript模式)
- 数据库:MySQL 8.0ORM使用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}`
- TTL5分钟
- **转存链接缓存**
- 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类型安全的数据库操作
- 支持复杂查询(如关联查询转存记录与账号)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

+75
View File
@@ -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.
+147
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

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

+153
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2024-11-01',
devtools: { enabled: true }
})
+17
View File
@@ -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"
}
}
+637
View File
@@ -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>
+7862
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+2
View File
@@ -0,0 +1,2 @@
User-Agent: *
Disallow:
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}
+4
View File
@@ -0,0 +1,4 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}
+23
View File
@@ -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"
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"dependencies": {
"dotenv": "^16.5.0"
}
}