mirror of
https://github.com/jiangrui1994/CloudSaver.git
synced 2026-01-09 14:48:47 +08:00
Initial commit for open-source version
This commit is contained in:
24
.env.example
Normal file
24
.env.example
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# # 数据库配置
|
||||||
|
# DB_HOST=localhost
|
||||||
|
# DB_USER=your_username
|
||||||
|
# DB_PASSWORD=your_password
|
||||||
|
|
||||||
|
# # API密钥
|
||||||
|
# API_KEY=your_api_key
|
||||||
|
|
||||||
|
# # 其他敏感信息
|
||||||
|
# CLOUD115_TOKEN=your_token
|
||||||
|
|
||||||
|
# 代理信息
|
||||||
|
HTTP_PROXY_HOST=127.0.0.1
|
||||||
|
HTTP_PROXY_PORT=7890
|
||||||
|
|
||||||
|
# 115网盘配置
|
||||||
|
CLOUD115_USER_ID=your_user_id # 用户ID 可以不填
|
||||||
|
CLOUD115_COOKIE=your_cookie
|
||||||
|
|
||||||
|
# 夸克网盘配置
|
||||||
|
QUARK_COOKIE='your_cookie'
|
||||||
|
|
||||||
|
# RSS配置
|
||||||
|
RSS_BASE_URL=https://rsshub.rssforever.com/telegram/channel
|
||||||
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# 保留模板
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
!frontend/.env
|
||||||
|
|
||||||
|
# 其他敏感文件
|
||||||
|
config.private.ts
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
27
.prettierignore
Normal file
27
.prettierignore
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# 构建产物
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# 依赖目录
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# 日志文件
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# 环境配置
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# 编辑器配置
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# 系统文件
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
11
.prettierrc.json
Normal file
11
.prettierrc.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"arrowParens": "always",
|
||||||
|
"vueIndentScriptAndStyle": true
|
||||||
|
}
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 CloudSaver
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
94
README.md
Normal file
94
README.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# CloudSaver
|
||||||
|
|
||||||
|
一个基于 Vue 3 + Express 的网盘资源搜索与转存工具。
|
||||||
|
|
||||||
|
## 特别声明
|
||||||
|
|
||||||
|
1. 此项目仅供学习交流使用,请勿用于非法用途。
|
||||||
|
2. 仅支持个人使用,不支持任何形式的 commercial 使用。
|
||||||
|
3. 禁止在项目页面上进行任何形式的广告宣传。
|
||||||
|
4. 所有搜索到的资源均来自第三方,本项目不对其真实性、合法性做出任何保证。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. 此项目的资源搜索需要用到代理环境,请自行搭建。
|
||||||
|
2. 默认的代理地址为:`http://127.0.0.1`,端口为:`7890`,如需修改,请自行 `.env`中配置。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- 支持多个资源订阅(电报群)源搜索
|
||||||
|
- 支持 115 网盘与夸克网盘资源转存
|
||||||
|
- 支持关键词搜索与资源链接解析
|
||||||
|
- 支持转存文件夹展示与选择
|
||||||
|
|
||||||
|
## 预览
|
||||||
|
|
||||||
|
### 最新资源搜索
|
||||||
|
|
||||||
|
<img src="./docs/images/screenshot-20241216-172442.png" width="400">
|
||||||
|
|
||||||
|
### 转存
|
||||||
|
|
||||||
|
<img src="./docs/images/screenshot-20241216-172609.png" width="400">
|
||||||
|
|
||||||
|
### 关键词搜索
|
||||||
|
|
||||||
|
<img src="./docs/images/screenshot-20241216-172710.png" width="400">
|
||||||
|
|
||||||
|
### 直接链接解析
|
||||||
|
|
||||||
|
<img src="./docs/images/screenshot-20241216-173136.png" width="400">
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
- Vue 3
|
||||||
|
- TypeScript
|
||||||
|
- Element Plus
|
||||||
|
- Pinia
|
||||||
|
- Vue Router
|
||||||
|
- Vite
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
- Node.js
|
||||||
|
- Express
|
||||||
|
- TypeScript
|
||||||
|
- RSS Parser
|
||||||
|
|
||||||
|
## 环境配置
|
||||||
|
|
||||||
|
### Node.js 版本
|
||||||
|
|
||||||
|
本项目需要 `Node.js` 版本 `18.x` 或更高版本。请确保在运行项目之前安装了正确的版本。
|
||||||
|
|
||||||
|
### 后端配置项
|
||||||
|
|
||||||
|
复制环境变量模板文件:
|
||||||
|
|
||||||
|
```
|
||||||
|
cp .env.example ./backend/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
- 根据 `.env.example`文件在 `backend`目录下创建 `.env`文件。
|
||||||
|
- `CLOUD115_COOKIE`:115 用户 cookie
|
||||||
|
- `QUARK_COOKIE`:quark cookie
|
||||||
|
|
||||||
|
## 使用
|
||||||
|
|
||||||
|
### 开发环境本地运行
|
||||||
|
|
||||||
|
- 安装依赖:`npm run install`
|
||||||
|
- 启动开发环境:`npm run dev`
|
||||||
|
|
||||||
|
### 打包部署
|
||||||
|
|
||||||
|
- 前端打包:`npm run build:frontend` 或者进入前端目录`frontend`,执行 `npm run build`
|
||||||
|
- 将前端构建产物`dist`目录下的文件复制到服务器上,例如`nginx`的`html`目录下。
|
||||||
|
- 后端服务:先进入后端目录`backend`下`npm run build`构建,然后执行 `npm run start`启动,默认端口为`8009`。
|
||||||
|
- 通过`nginx`配置代理服务,将前端api的请求映射到后端服务。
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
7
TODO.md
Normal file
7
TODO.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
- ~~资源列表增加网盘标识~~
|
||||||
|
- ~~增加对夸克网盘转存的支持~~
|
||||||
|
- ~~增加搜索框直接解析链接~~
|
||||||
|
- ~~替换rsshub改为直接从telegram获取资源信息~~
|
||||||
|
- ~~增加资源列表web源加载更多功能~~
|
||||||
1694
backend/package-lock.json
generated
Normal file
1694
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
backend/package.json
Normal file
31
backend/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "cloud-saver-server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nodemon --exec ts-node src/app.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/app.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.6.7",
|
||||||
|
"cheerio": "^1.0.0",
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.18.3",
|
||||||
|
"rss-parser": "^3.13.0",
|
||||||
|
"socket.io": "^4.8.1",
|
||||||
|
"tunnel": "^0.0.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cookie-parser": "^1.4.7",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^20.11.25",
|
||||||
|
"@types/tunnel": "^0.0.7",
|
||||||
|
"nodemon": "^3.1.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.4.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
backend/src/app.ts
Normal file
30
backend/src/app.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import express from "express";
|
||||||
|
import cors from "cors";
|
||||||
|
import cookieParser from "cookie-parser";
|
||||||
|
import routes from "./routes/api";
|
||||||
|
import { errorHandler } from "./middleware/errorHandler";
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: "*",
|
||||||
|
credentials: true,
|
||||||
|
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||||
|
allowedHeaders: ["Content-Type", "Authorization", "Cookie"],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/", routes);
|
||||||
|
|
||||||
|
// 错误处理
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server is running on port ${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
92
backend/src/config/index.ts
Normal file
92
backend/src/config/index.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
|
// 加载.env文件
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
interface Channel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CloudPatterns {
|
||||||
|
baiduPan: RegExp;
|
||||||
|
tianyi: RegExp;
|
||||||
|
weiyun: RegExp;
|
||||||
|
aliyun: RegExp;
|
||||||
|
pan115: RegExp;
|
||||||
|
quark: RegExp;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Cloud115Config {
|
||||||
|
userId: string;
|
||||||
|
cookie: string;
|
||||||
|
}
|
||||||
|
interface QuarkConfig {
|
||||||
|
userId: string;
|
||||||
|
cookie: string;
|
||||||
|
}
|
||||||
|
interface HttpProxyConfig {
|
||||||
|
host: string;
|
||||||
|
port: string;
|
||||||
|
}
|
||||||
|
interface Config {
|
||||||
|
rss: {
|
||||||
|
baseUrl: string;
|
||||||
|
channels: Channel[];
|
||||||
|
};
|
||||||
|
telegram: {
|
||||||
|
baseUrl: string;
|
||||||
|
};
|
||||||
|
httpProxy: HttpProxyConfig;
|
||||||
|
cloudPatterns: CloudPatterns;
|
||||||
|
cloud115: Cloud115Config;
|
||||||
|
quark: QuarkConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config: Config = {
|
||||||
|
rss: {
|
||||||
|
baseUrl: process.env.RSS_BASE_URL || "https://rsshub.rssforever.com/telegram/channel",
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
id: "guaguale115",
|
||||||
|
name: "115网盘资源分享",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hao115",
|
||||||
|
name: "115网盘资源分享频道",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "yunpanshare",
|
||||||
|
name: "网盘资源收藏(夸克)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
telegram: {
|
||||||
|
baseUrl: process.env.TELEGRAM_BASE_URL || "https://t.me/s",
|
||||||
|
},
|
||||||
|
|
||||||
|
httpProxy: {
|
||||||
|
host: process.env.HTTP_PROXY_HOST || "127.0.0.1",
|
||||||
|
port: process.env.HTTP_PROXY_PORT || "7890",
|
||||||
|
},
|
||||||
|
|
||||||
|
cloudPatterns: {
|
||||||
|
baiduPan: /https?:\/\/(?:pan|yun)\.baidu\.com\/[^\s<>"]+/g,
|
||||||
|
tianyi: /https?:\/\/cloud\.189\.cn\/[^\s<>"]+/g,
|
||||||
|
weiyun: /https?:\/\/share\.weiyun\.com\/[^\s<>"]+/g,
|
||||||
|
aliyun: /https?:\/\/\w+\.aliyundrive\.com\/[^\s<>"]+/g,
|
||||||
|
// pan115有两个域名 115.com 和 anxia.com
|
||||||
|
pan115: /https?:\/\/(?:115|anxia)\.com\/s\/[^\s<>"]+/g,
|
||||||
|
quark: /https?:\/\/pan\.quark\.cn\/[^\s<>"]+/g,
|
||||||
|
},
|
||||||
|
|
||||||
|
cloud115: {
|
||||||
|
userId: "",
|
||||||
|
cookie: process.env.CLOUD115_COOKIE || "",
|
||||||
|
},
|
||||||
|
quark: {
|
||||||
|
userId: process.env.QUARK_USER_ID || "",
|
||||||
|
cookie: process.env.QUARK_COOKIE || "",
|
||||||
|
},
|
||||||
|
};
|
||||||
46
backend/src/controllers/cloud115.ts
Normal file
46
backend/src/controllers/cloud115.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { Cloud115Service } from "../services/Cloud115Service";
|
||||||
|
import { config } from "../config";
|
||||||
|
import handleError from "../utils/handleError";
|
||||||
|
import { handleResponse } from "../utils/responseHandler";
|
||||||
|
|
||||||
|
const { cookie } = config.cloud115;
|
||||||
|
|
||||||
|
const cloud115 = new Cloud115Service(cookie);
|
||||||
|
|
||||||
|
export const cloud115Controller = {
|
||||||
|
async getShareInfo(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { shareCode, receiveCode } = req.query;
|
||||||
|
const result = await cloud115.getShareInfo(shareCode as string, receiveCode as string);
|
||||||
|
handleResponse(res, result, true);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error, "获取分享信息失败", next);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getFolderList(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { parentCid } = req.query;
|
||||||
|
const result = await cloud115.getFolderList(parentCid as string);
|
||||||
|
handleResponse(res, result, true);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error, "获取目录列表失败", next);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveFile(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { shareCode, receiveCode, fileId, folderId } = req.body;
|
||||||
|
const result = await cloud115.saveSharedFile({
|
||||||
|
shareCode,
|
||||||
|
receiveCode,
|
||||||
|
fileId,
|
||||||
|
cid: folderId,
|
||||||
|
});
|
||||||
|
handleResponse(res, result, true);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error, "保存文件失败", next);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
40
backend/src/controllers/quark.ts
Normal file
40
backend/src/controllers/quark.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { QuarkService } from "../services/QuarkService";
|
||||||
|
import { config } from "../config";
|
||||||
|
import { handleResponse } from "../utils/responseHandler";
|
||||||
|
import handleError from "../utils/handleError";
|
||||||
|
|
||||||
|
const { cookie } = config.quark;
|
||||||
|
|
||||||
|
const quark = new QuarkService(cookie);
|
||||||
|
|
||||||
|
export const quarkController = {
|
||||||
|
async getShareInfo(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { pwdId, passcode } = req.query;
|
||||||
|
const result = await quark.getShareInfo(pwdId as string, passcode as string);
|
||||||
|
handleResponse(res, result, true);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error, "获取分享信息失败", next);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getFolderList(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { parentCid } = req.query;
|
||||||
|
const result = await quark.getFolderList(parentCid as string);
|
||||||
|
handleResponse(res, result, true);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error, "获取目录列表失败", next);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveFile(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const result = await quark.saveSharedFile(req.body);
|
||||||
|
handleResponse(res, result, true);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error, "保存文件失败", next);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
32
backend/src/controllers/resource.ts
Normal file
32
backend/src/controllers/resource.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { RSSSearcher } from "../services/RSSSearcher";
|
||||||
|
import { Searcher } from "../services/Searcher";
|
||||||
|
import { handleResponse } from "../utils/responseHandler";
|
||||||
|
import handleError from "../utils/handleError";
|
||||||
|
|
||||||
|
export const resourceController = {
|
||||||
|
async rssSearch(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { keyword } = req.query;
|
||||||
|
const searcher = new RSSSearcher();
|
||||||
|
const result = await searcher.searchAll(keyword as string);
|
||||||
|
handleResponse(res, result, true);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error, "获取资源发生未知错误", next);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async search(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { keyword, channelId = "", lastMessageId = "" } = req.query; // Remove `: string` from here
|
||||||
|
const searcher = new Searcher();
|
||||||
|
const result = await searcher.searchAll(
|
||||||
|
keyword as string,
|
||||||
|
channelId as string,
|
||||||
|
lastMessageId as string
|
||||||
|
);
|
||||||
|
handleResponse(res, result, true);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error, "获取资源发生未知错误", next);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
9
backend/src/middleware/errorHandler.ts
Normal file
9
backend/src/middleware/errorHandler.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
|
||||||
|
export const errorHandler = (err: any, req: Request, res: Response, next: NextFunction) => {
|
||||||
|
console.error(err);
|
||||||
|
res.status(err.status || 500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message || "服务器内部错误",
|
||||||
|
});
|
||||||
|
};
|
||||||
14
backend/src/middleware/validateRequest.ts
Normal file
14
backend/src/middleware/validateRequest.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
|
||||||
|
export const validateRequest = (requiredParams: string[]) => {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const missingParams = requiredParams.filter((param) => !req.query[param] && !req.body[param]);
|
||||||
|
if (missingParams.length > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `缺少必要的参数: ${missingParams.join(", ")}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
22
backend/src/routes/api.ts
Normal file
22
backend/src/routes/api.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import express from "express";
|
||||||
|
import { cloud115Controller } from "../controllers/cloud115";
|
||||||
|
import { quarkController } from "../controllers/quark";
|
||||||
|
import { resourceController } from "../controllers/resource";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 资源搜索
|
||||||
|
router.get("/search", resourceController.search);
|
||||||
|
router.get("/rssSearch", resourceController.rssSearch);
|
||||||
|
|
||||||
|
// 115网盘相关
|
||||||
|
router.get("/cloud115/share-info", cloud115Controller.getShareInfo);
|
||||||
|
router.get("/cloud115/folders", cloud115Controller.getFolderList);
|
||||||
|
router.post("/cloud115/save", cloud115Controller.saveFile);
|
||||||
|
|
||||||
|
// 夸克网盘相关
|
||||||
|
router.get("/quark/share-info", quarkController.getShareInfo);
|
||||||
|
router.get("/quark/folders", quarkController.getFolderList);
|
||||||
|
router.post("/quark/save", quarkController.saveFile);
|
||||||
|
|
||||||
|
export default router;
|
||||||
148
backend/src/services/Cloud115Service.ts
Normal file
148
backend/src/services/Cloud115Service.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { AxiosHeaders, AxiosInstance } from "axios"; // 导入 AxiosHeaders
|
||||||
|
import { createAxiosInstance } from "../utils/axiosInstance";
|
||||||
|
import { Logger } from "../utils/logger";
|
||||||
|
import { config } from "../config/index";
|
||||||
|
import { ShareInfoResponse } from "../types/cloud115";
|
||||||
|
|
||||||
|
export class Cloud115Service {
|
||||||
|
private api: AxiosInstance;
|
||||||
|
|
||||||
|
constructor(cookie: string) {
|
||||||
|
if (!cookie) {
|
||||||
|
throw new Error("115网盘需要提供cookie进行身份验证");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.api = createAxiosInstance(
|
||||||
|
"https://webapi.115.com",
|
||||||
|
AxiosHeaders.from({
|
||||||
|
Host: "webapi.115.com",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
xweb_xhr: "1",
|
||||||
|
Origin: "",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/6.8.0(0x16080000) NetType/WIFI MiniProgramEnv/Mac MacWechat/WMPF MacWechat/3.8.9(0x13080910) XWEB/1227",
|
||||||
|
Accept: "*/*",
|
||||||
|
"Sec-Fetch-Site": "cross-site",
|
||||||
|
"Sec-Fetch-Mode": "cors",
|
||||||
|
"Sec-Fetch-Dest": "empty",
|
||||||
|
Referer: "https://servicewechat.com/wx2c744c010a61b0fa/94/page-frame.html",
|
||||||
|
"Accept-Encoding": "gzip, deflate, br",
|
||||||
|
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||||
|
cookie: cookie,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getShareInfo(shareCode: string, receiveCode = ""): Promise<ShareInfoResponse> {
|
||||||
|
try {
|
||||||
|
const response = await this.api.get("/share/snap", {
|
||||||
|
params: {
|
||||||
|
share_code: shareCode,
|
||||||
|
receive_code: receiveCode,
|
||||||
|
offset: 0,
|
||||||
|
limit: 20,
|
||||||
|
cid: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data?.state && response.data.data?.list?.length > 0) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
list: response.data.data.list.map((item: any) => ({
|
||||||
|
fileId: item.cid,
|
||||||
|
fileName: item.n,
|
||||||
|
fileSize: item.s,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "未找到文件信息",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "未知错误",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFolderList(parentCid = "0") {
|
||||||
|
try {
|
||||||
|
const response = await this.api.get("/files", {
|
||||||
|
params: {
|
||||||
|
aid: 1,
|
||||||
|
cid: parentCid,
|
||||||
|
o: "user_ptime",
|
||||||
|
asc: 0,
|
||||||
|
offset: 0,
|
||||||
|
show_dir: 1,
|
||||||
|
limit: 50,
|
||||||
|
type: 0,
|
||||||
|
format: "json",
|
||||||
|
star: 0,
|
||||||
|
suffix: "",
|
||||||
|
natsort: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data?.state) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: response.data.data
|
||||||
|
.filter((item: any) => item.cid)
|
||||||
|
.map((folder: any) => ({
|
||||||
|
cid: folder.cid,
|
||||||
|
name: folder.n,
|
||||||
|
path: response.data.path,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
Logger.error("获取目录列表失败:", response.data.error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "获取115pan目录列表失败:" + response.data.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error("获取目录列表失败:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "获取115pan目录列表失败",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSharedFile(params: {
|
||||||
|
cid: string;
|
||||||
|
shareCode: string;
|
||||||
|
receiveCode: string;
|
||||||
|
fileId: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const param = new URLSearchParams({
|
||||||
|
cid: params.cid,
|
||||||
|
user_id: config.cloud115.userId,
|
||||||
|
share_code: params.shareCode,
|
||||||
|
receive_code: params.receiveCode,
|
||||||
|
file_id: params.fileId,
|
||||||
|
});
|
||||||
|
const response = await this.api.post("/share/receive", param.toString());
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: response.data.state,
|
||||||
|
error: response.data.error,
|
||||||
|
data: response.data,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "未知错误",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
178
backend/src/services/QuarkService.ts
Normal file
178
backend/src/services/QuarkService.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { AxiosInstance, AxiosHeaders } from "axios";
|
||||||
|
import { Logger } from "../utils/logger";
|
||||||
|
import { createAxiosInstance } from "../utils/axiosInstance";
|
||||||
|
|
||||||
|
export class QuarkService {
|
||||||
|
private api: AxiosInstance;
|
||||||
|
|
||||||
|
constructor(cookie: string) {
|
||||||
|
if (!cookie) {
|
||||||
|
throw new Error("115网盘需要提供cookie进行身份验证");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.api = createAxiosInstance(
|
||||||
|
"https://drive-h.quark.cn",
|
||||||
|
AxiosHeaders.from({
|
||||||
|
cookie: cookie,
|
||||||
|
accept: "application/json, text/plain, */*",
|
||||||
|
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
|
||||||
|
"content-type": "application/json",
|
||||||
|
priority: "u=1, i",
|
||||||
|
"sec-ch-ua": '"Microsoft Edge";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
|
||||||
|
"sec-ch-ua-mobile": "?0",
|
||||||
|
"sec-ch-ua-platform": '"Windows"',
|
||||||
|
"sec-fetch-dest": "empty",
|
||||||
|
"sec-fetch-mode": "cors",
|
||||||
|
"sec-fetch-site": "same-site",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getShareInfo(pwdId: string, passcode = "") {
|
||||||
|
try {
|
||||||
|
const response = await this.api.post(
|
||||||
|
`/1/clouddrive/share/sharepage/token?pr=ucpro&fr=pc&uc_param_str=&__dt=994&__t=${Date.now()}`,
|
||||||
|
{
|
||||||
|
pwd_id: pwdId,
|
||||||
|
passcode: "",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (response.data?.status === 200 && response.data.data) {
|
||||||
|
const fileInfo = response.data.data;
|
||||||
|
if (fileInfo.stoken) {
|
||||||
|
let res = await this.getShareList(pwdId, fileInfo.stoken);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: res,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "未找到文件信息",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "未知错误",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getShareList(pwdId: string, stoken: string) {
|
||||||
|
try {
|
||||||
|
const response = await this.api.get("/1/clouddrive/share/sharepage/detail", {
|
||||||
|
params: {
|
||||||
|
pr: "ucpro",
|
||||||
|
fr: "pc",
|
||||||
|
uc_param_str: "",
|
||||||
|
pwd_id: pwdId,
|
||||||
|
stoken: stoken,
|
||||||
|
pdir_fid: "0",
|
||||||
|
force: "0",
|
||||||
|
_page: "1",
|
||||||
|
_size: "50",
|
||||||
|
_fetch_banner: "1",
|
||||||
|
_fetch_share: "1",
|
||||||
|
_fetch_total: "1",
|
||||||
|
_sort: "file_type:asc,updated_at:desc",
|
||||||
|
__dt: "1589",
|
||||||
|
__t: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (response.data?.data) {
|
||||||
|
const list = response.data.data.list
|
||||||
|
.filter((item: any) => item.fid)
|
||||||
|
.map((folder: any) => ({
|
||||||
|
fileId: folder.fid,
|
||||||
|
fileName: folder.file_name,
|
||||||
|
fileIdToken: folder.share_fid_token,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
list,
|
||||||
|
pwdId,
|
||||||
|
stoken: stoken,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
list: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error("获取目录列表失败:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFolderList(parentCid = "0") {
|
||||||
|
try {
|
||||||
|
const response = await this.api.get("/1/clouddrive/file/sort", {
|
||||||
|
params: {
|
||||||
|
pr: "ucpro",
|
||||||
|
fr: "pc",
|
||||||
|
uc_param_str: "",
|
||||||
|
pdir_fid: parentCid,
|
||||||
|
_page: "1",
|
||||||
|
_size: "100",
|
||||||
|
_fetch_total: "false",
|
||||||
|
_fetch_sub_dirs: "1",
|
||||||
|
_sort: "",
|
||||||
|
__dt: "2093126",
|
||||||
|
__t: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (response.data?.data && response.data.data.list.length) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: response.data.data.list
|
||||||
|
.filter((item: any) => item.fid)
|
||||||
|
.map((folder: any) => ({
|
||||||
|
cid: folder.fid,
|
||||||
|
name: folder.file_name,
|
||||||
|
path: [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
Logger.error("获取目录列表失败:", response.data.error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "获取夸克目录列表失败:" + response.data.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error("获取目录列表失败:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "获取夸克目录列表失败",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSharedFile(params: {
|
||||||
|
fid_list: string[];
|
||||||
|
fid_token_list: string[];
|
||||||
|
to_pdir_fid: string;
|
||||||
|
pwd_id: string;
|
||||||
|
stoken: string;
|
||||||
|
pdir_fid: string;
|
||||||
|
scene: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const response = await this.api.post(
|
||||||
|
`/1/clouddrive/share/sharepage/save?pr=ucpro&fr=pc&uc_param_str=&__dt=208097&__t=${Date.now()}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: response.data.code === 0,
|
||||||
|
error: response.data.message,
|
||||||
|
data: response.data.data,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "未知错误",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
112
backend/src/services/RSSSearcher.ts
Normal file
112
backend/src/services/RSSSearcher.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import RSSParser from "rss-parser";
|
||||||
|
import { AxiosInstance, AxiosHeaders } from "axios";
|
||||||
|
import { config } from "../config";
|
||||||
|
import { Logger } from "../utils/logger";
|
||||||
|
import { createAxiosInstance } from "../utils/axiosInstance";
|
||||||
|
|
||||||
|
interface RSSItem {
|
||||||
|
title?: string;
|
||||||
|
link?: string;
|
||||||
|
pubDate?: string;
|
||||||
|
content?: string;
|
||||||
|
description?: string;
|
||||||
|
image?: string;
|
||||||
|
cloudLinks?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RSSSearcher {
|
||||||
|
private parser: RSSParser;
|
||||||
|
private axiosInstance: AxiosInstance;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.parser = new RSSParser({
|
||||||
|
customFields: {
|
||||||
|
item: [
|
||||||
|
["content:encoded", "content"],
|
||||||
|
["description", "description"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.axiosInstance = createAxiosInstance(
|
||||||
|
config.rss.baseUrl,
|
||||||
|
AxiosHeaders.from({
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
|
Accept: "application/xml,application/xhtml+xml,text/html,application/rss+xml",
|
||||||
|
}),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractCloudLinks(text: string): { links: string[]; cloudType: string } {
|
||||||
|
const links: string[] = [];
|
||||||
|
let cloudType = "";
|
||||||
|
Object.values(config.cloudPatterns).forEach((pattern, index) => {
|
||||||
|
const matches = text.match(pattern);
|
||||||
|
if (matches) {
|
||||||
|
links.push(...matches);
|
||||||
|
cloudType = Object.keys(config.cloudPatterns)[index];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
links: [...new Set(links)],
|
||||||
|
cloudType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchAll(keyword: string) {
|
||||||
|
const allResults = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < config.rss.channels.length; i++) {
|
||||||
|
const channel = config.rss.channels[i];
|
||||||
|
try {
|
||||||
|
const rssUrl = `${config.rss.baseUrl}/${
|
||||||
|
channel.id
|
||||||
|
}${keyword ? `/searchQuery=${encodeURIComponent(keyword)}` : ""}`;
|
||||||
|
|
||||||
|
const results = await this.searchInRSSFeed(rssUrl);
|
||||||
|
if (results.items.length > 0) {
|
||||||
|
const channelResults = results.items
|
||||||
|
.filter((item: RSSItem) => item.cloudLinks && item.cloudLinks.length > 0)
|
||||||
|
.map((item: RSSItem) => ({
|
||||||
|
...item,
|
||||||
|
channel: channel.name + "(" + channel.id + ")",
|
||||||
|
}));
|
||||||
|
|
||||||
|
allResults.push(...channelResults);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`搜索频道 ${channel.name} 失败:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchInRSSFeed(rssUrl: string) {
|
||||||
|
try {
|
||||||
|
const response = await this.axiosInstance.get(rssUrl);
|
||||||
|
const feed = await this.parser.parseString(response.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: feed.items.map((item: RSSItem) => {
|
||||||
|
const linkInfo = this.extractCloudLinks(item.content || item.description || "");
|
||||||
|
return {
|
||||||
|
title: item.title || "",
|
||||||
|
link: item.link || "",
|
||||||
|
pubDate: item.pubDate || "",
|
||||||
|
image: item.image || "",
|
||||||
|
cloudLinks: linkInfo.links,
|
||||||
|
cloudType: linkInfo.cloudType,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`RSS源解析错误: ${rssUrl}`, error);
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
159
backend/src/services/Searcher.ts
Normal file
159
backend/src/services/Searcher.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { AxiosInstance, AxiosHeaders } from "axios";
|
||||||
|
import { createAxiosInstance } from "../utils/axiosInstance";
|
||||||
|
import * as cheerio from "cheerio";
|
||||||
|
import { config } from "../config";
|
||||||
|
import { Logger } from "../utils/logger";
|
||||||
|
|
||||||
|
interface sourceItem {
|
||||||
|
messageId?: string;
|
||||||
|
title?: string;
|
||||||
|
link?: string;
|
||||||
|
pubDate?: string;
|
||||||
|
content?: string;
|
||||||
|
description?: string;
|
||||||
|
image?: string;
|
||||||
|
cloudLinks?: string[];
|
||||||
|
cloudType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Searcher {
|
||||||
|
private axiosInstance: AxiosInstance;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.axiosInstance = createAxiosInstance(
|
||||||
|
config.telegram.baseUrl,
|
||||||
|
AxiosHeaders.from({
|
||||||
|
accept:
|
||||||
|
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
||||||
|
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
|
||||||
|
"cache-control": "max-age=0",
|
||||||
|
priority: "u=0, i",
|
||||||
|
"sec-ch-ua": '"Microsoft Edge";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
|
||||||
|
"sec-ch-ua-mobile": "?0",
|
||||||
|
"sec-ch-ua-platform": '"macOS"',
|
||||||
|
"sec-fetch-dest": "document",
|
||||||
|
"sec-fetch-mode": "navigate",
|
||||||
|
"sec-fetch-site": "none",
|
||||||
|
"sec-fetch-user": "?1",
|
||||||
|
"upgrade-insecure-requests": "1",
|
||||||
|
}),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractCloudLinks(text: string): { links: string[]; cloudType: string } {
|
||||||
|
const links: string[] = [];
|
||||||
|
let cloudType = "";
|
||||||
|
Object.values(config.cloudPatterns).forEach((pattern, index) => {
|
||||||
|
const matches = text.match(pattern);
|
||||||
|
if (matches) {
|
||||||
|
links.push(...matches);
|
||||||
|
cloudType = Object.keys(config.cloudPatterns)[index];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
links: [...new Set(links)],
|
||||||
|
cloudType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchAll(keyword: string, channelId?: string, messageId?: string) {
|
||||||
|
const allResults = [];
|
||||||
|
const totalChannels = config.rss.channels.length;
|
||||||
|
|
||||||
|
const channelList = channelId
|
||||||
|
? config.rss.channels.filter((channel) => channel.id === channelId)
|
||||||
|
: config.rss.channels;
|
||||||
|
|
||||||
|
for (let i = 0; i < channelList.length; i++) {
|
||||||
|
const channel = channelList[i];
|
||||||
|
try {
|
||||||
|
const messageIdparams = messageId ? `before=${messageId}` : "";
|
||||||
|
const url = `/${channel.id}${keyword ? `?q=${encodeURIComponent(keyword)}&${messageIdparams}` : `?${messageIdparams}`}`;
|
||||||
|
console.log(`Searching in channel ${channel.name} with URL: ${url}`);
|
||||||
|
const results = await this.searchInWeb(url, channel.id);
|
||||||
|
console.log(`Found ${results.items.length} items in channel ${channel.name}`);
|
||||||
|
if (results.items.length > 0) {
|
||||||
|
const channelResults = results.items
|
||||||
|
.filter((item: sourceItem) => item.cloudLinks && item.cloudLinks.length > 0)
|
||||||
|
.map((item: sourceItem) => ({
|
||||||
|
...item,
|
||||||
|
channel: channel.name,
|
||||||
|
channelId: channel.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
allResults.push(...channelResults);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`搜索频道 ${channel.name} 失败:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchInWeb(url: string, channelId: string) {
|
||||||
|
try {
|
||||||
|
const response = await this.axiosInstance.get(url);
|
||||||
|
const html = response.data;
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
const items: sourceItem[] = [];
|
||||||
|
// 遍历每个消息容器
|
||||||
|
$(".tgme_widget_message_wrap").each((_, element) => {
|
||||||
|
const messageEl = $(element);
|
||||||
|
|
||||||
|
// 通过 data-post 属性来获取消息的链接 去除channelId 获得消息id
|
||||||
|
const messageId = messageEl
|
||||||
|
.find(".tgme_widget_message")
|
||||||
|
.data("post")
|
||||||
|
?.toString()
|
||||||
|
.split("/")[1];
|
||||||
|
|
||||||
|
// 提取标题 (消息截取100长度)
|
||||||
|
const title = messageEl.find(".js-message_text").text().trim().substring(0, 50) + "...";
|
||||||
|
|
||||||
|
// 提取链接 (消息中的链接)
|
||||||
|
// const link = messageEl.find('.tgme_widget_message').data('post');
|
||||||
|
|
||||||
|
// 提取发布时间
|
||||||
|
const pubDate = messageEl.find("time").attr("datetime");
|
||||||
|
|
||||||
|
// 提取内容 (完整消息文本)
|
||||||
|
const content = messageEl.find(".js-message_text").text();
|
||||||
|
|
||||||
|
// 提取描述 (消息文本中"描述:"后的内容)
|
||||||
|
const description = content.split("描述:")[1]?.split("\n")[0]?.trim();
|
||||||
|
|
||||||
|
// 提取图片
|
||||||
|
const image = messageEl
|
||||||
|
.find(".tgme_widget_message_photo_wrap")
|
||||||
|
.attr("style")
|
||||||
|
?.match(/url\('(.+?)'\)/)?.[1];
|
||||||
|
|
||||||
|
// 提取云盘链接
|
||||||
|
const links = messageEl
|
||||||
|
.find(".tgme_widget_message_text a")
|
||||||
|
.map((_, el) => $(el).attr("href"))
|
||||||
|
.get();
|
||||||
|
const cloudInfo = this.extractCloudLinks(links.join(" "));
|
||||||
|
// 添加到数组第一位
|
||||||
|
items.unshift({
|
||||||
|
messageId,
|
||||||
|
title,
|
||||||
|
pubDate,
|
||||||
|
content,
|
||||||
|
description,
|
||||||
|
image,
|
||||||
|
cloudLinks: cloudInfo.links,
|
||||||
|
cloudType: cloudInfo.cloudType,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { items };
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`RSS源解析错误: ${url}`, error);
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/src/types/cloud115.ts
Normal file
13
backend/src/types/cloud115.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export interface ShareInfo {
|
||||||
|
fileId: string;
|
||||||
|
fileName: string;
|
||||||
|
fileSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShareInfoResponse {
|
||||||
|
success: boolean;
|
||||||
|
data?: {
|
||||||
|
list: ShareInfo[];
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
28
backend/src/utils/axiosInstance.ts
Normal file
28
backend/src/utils/axiosInstance.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import axios, { AxiosInstance, AxiosRequestHeaders } from "axios";
|
||||||
|
import tunnel from "tunnel";
|
||||||
|
import { config } from "../config";
|
||||||
|
|
||||||
|
export function createAxiosInstance(
|
||||||
|
baseURL: string,
|
||||||
|
headers: AxiosRequestHeaders,
|
||||||
|
useProxy: boolean = false
|
||||||
|
): AxiosInstance {
|
||||||
|
let agent;
|
||||||
|
|
||||||
|
if (useProxy) {
|
||||||
|
agent = tunnel.httpsOverHttp({
|
||||||
|
proxy: {
|
||||||
|
host: config.httpProxy.host,
|
||||||
|
port: Number(config.httpProxy.port),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return axios.create({
|
||||||
|
baseURL,
|
||||||
|
timeout: 30000,
|
||||||
|
headers,
|
||||||
|
httpsAgent: useProxy ? agent : undefined,
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
11
backend/src/utils/handleError.ts
Normal file
11
backend/src/utils/handleError.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Response, NextFunction } from "express";
|
||||||
|
import { Logger } from "../utils/logger";
|
||||||
|
export default function handleError(
|
||||||
|
res: Response,
|
||||||
|
error: any,
|
||||||
|
message: string,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
Logger.error(message, error);
|
||||||
|
next(error || { success: false, message });
|
||||||
|
}
|
||||||
38
backend/src/utils/logger.ts
Normal file
38
backend/src/utils/logger.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
type LogLevel = "info" | "success" | "warn" | "error";
|
||||||
|
|
||||||
|
export const Logger = {
|
||||||
|
info(...args: any[]) {
|
||||||
|
this.log("info", ...args);
|
||||||
|
},
|
||||||
|
|
||||||
|
success(...args: any[]) {
|
||||||
|
this.log("success", ...args);
|
||||||
|
},
|
||||||
|
|
||||||
|
warn(...args: any[]) {
|
||||||
|
this.log("warn", ...args);
|
||||||
|
},
|
||||||
|
|
||||||
|
error(...args: any[]) {
|
||||||
|
this.log("error", ...args);
|
||||||
|
},
|
||||||
|
|
||||||
|
log(level: LogLevel, ...args: any[]) {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
|
||||||
|
|
||||||
|
switch (level) {
|
||||||
|
case "success":
|
||||||
|
console.log(prefix, ...args);
|
||||||
|
break;
|
||||||
|
case "warn":
|
||||||
|
console.warn(prefix, ...args);
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
console.error(prefix, ...args);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log(prefix, ...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
5
backend/src/utils/responseHandler.ts
Normal file
5
backend/src/utils/responseHandler.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Response } from "express";
|
||||||
|
|
||||||
|
export const handleResponse = (res: Response, data: any, success: boolean) => {
|
||||||
|
res.json({ success, data });
|
||||||
|
};
|
||||||
21
backend/tsconfig.json
Normal file
21
backend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
BIN
docs/images/screenshot-20241216-172442.png
Normal file
BIN
docs/images/screenshot-20241216-172442.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 195 KiB |
BIN
docs/images/screenshot-20241216-172609.png
Normal file
BIN
docs/images/screenshot-20241216-172609.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
BIN
docs/images/screenshot-20241216-172710.png
Normal file
BIN
docs/images/screenshot-20241216-172710.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
BIN
docs/images/screenshot-20241216-173136.png
Normal file
BIN
docs/images/screenshot-20241216-173136.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
2
frontend/.env
Normal file
2
frontend/.env
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_BASE_URL=""
|
||||||
|
VITE_API_BASE_URL_PROXY="http://127.0.0.1:8009"
|
||||||
9
frontend/auto-imports.d.ts
vendored
Normal file
9
frontend/auto-imports.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* prettier-ignore */
|
||||||
|
// @ts-nocheck
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
// Generated by unplugin-auto-import
|
||||||
|
export {}
|
||||||
|
declare global {
|
||||||
|
|
||||||
|
}
|
||||||
33
frontend/components.d.ts
vendored
Normal file
33
frontend/components.d.ts
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* prettier-ignore */
|
||||||
|
// @ts-nocheck
|
||||||
|
// Generated by unplugin-vue-components
|
||||||
|
// Read more: https://github.com/vuejs/core/pull/3399
|
||||||
|
export {}
|
||||||
|
|
||||||
|
declare module 'vue' {
|
||||||
|
export interface GlobalComponents {
|
||||||
|
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||||
|
ElBacktop: typeof import('element-plus/es')['ElBacktop']
|
||||||
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
|
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||||
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
|
ElImage: typeof import('element-plus/es')['ElImage']
|
||||||
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
|
ElLink: typeof import('element-plus/es')['ElLink']
|
||||||
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
|
ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
|
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||||
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
|
ElTree: typeof import('element-plus/es')['ElTree']
|
||||||
|
FolderSelect: typeof import('./src/components/FolderSelect.vue')['default']
|
||||||
|
ResourceList: typeof import('./src/components/ResourceList.vue')['default']
|
||||||
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
SearchBar: typeof import('./src/components/SearchBar.vue')['default']
|
||||||
|
}
|
||||||
|
export interface ComponentCustomProperties {
|
||||||
|
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||||
|
}
|
||||||
|
}
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>CloudSaver</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2479
frontend/package-lock.json
generated
Normal file
2479
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
frontend/package.json
Normal file
29
frontend/package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "cloud-saver-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host",
|
||||||
|
"build": "vue-tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
"axios": "^1.6.7",
|
||||||
|
"element-plus": "^2.6.1",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-router": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.11.25",
|
||||||
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"typescript": "^5.4.2",
|
||||||
|
"unplugin-auto-import": "^0.17.5",
|
||||||
|
"unplugin-vue-components": "^0.26.0",
|
||||||
|
"vite": "^5.1.5",
|
||||||
|
"vue-tsc": "^2.0.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/src/App.vue
Normal file
11
frontend/src/App.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<template>
|
||||||
|
<el-config-provider>
|
||||||
|
<router-view />
|
||||||
|
</el-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#app {
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
23
frontend/src/api/cloud115.ts
Normal file
23
frontend/src/api/cloud115.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
import type { ShareInfoResponse, Folder, Save115FileParams } from "@/types";
|
||||||
|
|
||||||
|
export const cloud115Api = {
|
||||||
|
async getShareInfo(shareCode: string, receiveCode = ""): Promise<ShareInfoResponse> {
|
||||||
|
const { data } = await request.get("/api/cloud115/share-info", {
|
||||||
|
params: { shareCode, receiveCode },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getFolderList(parentCid = "0"): Promise<{ data: Folder[] }> {
|
||||||
|
const { data } = await request.get("/api/cloud115/folders", {
|
||||||
|
params: { parentCid },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveFile(params: Save115FileParams) {
|
||||||
|
const { data } = await request.post("/api/cloud115/save", params);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
23
frontend/src/api/quark.ts
Normal file
23
frontend/src/api/quark.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
import type { ShareInfoResponse, Folder, SaveQuarkFileParams } from "@/types";
|
||||||
|
|
||||||
|
export const quarkApi = {
|
||||||
|
async getShareInfo(pwdId: string, passcode = ""): Promise<ShareInfoResponse> {
|
||||||
|
const { data } = await request.get("/api/quark/share-info", {
|
||||||
|
params: { pwdId, passcode },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getFolderList(parentCid = "0"): Promise<{ data: Folder[] }> {
|
||||||
|
const { data } = await request.get("/api/quark/folders", {
|
||||||
|
params: { parentCid },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveFile(params: SaveQuarkFileParams) {
|
||||||
|
const { data } = await request.post("/api/quark/save", params);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
10
frontend/src/api/resource.ts
Normal file
10
frontend/src/api/resource.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
import type { Resource } from "@/types/index";
|
||||||
|
|
||||||
|
export const resourceApi = {
|
||||||
|
search(keyword: string, backupPlan: boolean, channelId?: string, lastMessageId?: string) {
|
||||||
|
return request.get<Resource[]>(`/api/${backupPlan ? "rssSearch" : "search"}`, {
|
||||||
|
params: { keyword, channelId, lastMessageId },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
134
frontend/src/components/FolderSelect.vue
Normal file
134
frontend/src/components/FolderSelect.vue
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<div class="folder-select">
|
||||||
|
<div class="folder-select-header">
|
||||||
|
当前位置:<el-icon style="margin: 0 5px"><Folder /></el-icon
|
||||||
|
>{{ selectedFolder?.path?.map((x: Folder) => x.name).join("/") }}
|
||||||
|
</div>
|
||||||
|
<el-tree
|
||||||
|
ref="treeRef"
|
||||||
|
:data="folders"
|
||||||
|
:props="defaultProps"
|
||||||
|
node-key="cid"
|
||||||
|
:load="loadNode"
|
||||||
|
lazy
|
||||||
|
@node-click="handleNodeClick"
|
||||||
|
highlight-current
|
||||||
|
>
|
||||||
|
<template #default="{ node }">
|
||||||
|
<span class="folder-node">
|
||||||
|
<el-icon><Folder /></el-icon>
|
||||||
|
{{ node.label }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-tree>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, defineProps } from "vue";
|
||||||
|
import { cloud115Api } from "@/api/cloud115";
|
||||||
|
import { quarkApi } from "@/api/quark";
|
||||||
|
import type { TreeInstance } from "element-plus";
|
||||||
|
import type { Folder } from "@/types";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
cloudType: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const treeRef = ref<TreeInstance>();
|
||||||
|
const folders = ref<Folder[]>([]);
|
||||||
|
const selectedFolder = ref<Folder | null>(null);
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "select", folderId: string): void;
|
||||||
|
(e: "close"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
label: "name",
|
||||||
|
children: "children",
|
||||||
|
isLeaf: "leaf",
|
||||||
|
};
|
||||||
|
|
||||||
|
const cloudTypeApiMap = {
|
||||||
|
pan115: cloud115Api,
|
||||||
|
quark: quarkApi,
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadNode = async (node: any, resolve: (data: Folder[]) => void) => {
|
||||||
|
const api = cloudTypeApiMap[props.cloudType as keyof typeof cloudTypeApiMap];
|
||||||
|
try {
|
||||||
|
let res: {
|
||||||
|
data: Folder[];
|
||||||
|
error?: string;
|
||||||
|
} = { data: [] };
|
||||||
|
if (node.level === 0) {
|
||||||
|
if (api.getFolderList) {
|
||||||
|
// 使用类型保护检查方法是否存在
|
||||||
|
res = await api.getFolderList();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (api.getFolderList) {
|
||||||
|
// 使用类型保护检查方法是否存在
|
||||||
|
res = await api.getFolderList(node.data.cid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (res.data?.length > 0) {
|
||||||
|
resolve(res.data);
|
||||||
|
} else {
|
||||||
|
resolve([]);
|
||||||
|
throw new Error(res.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? `${error.message}` : "获取目录失败");
|
||||||
|
// 关闭模态框
|
||||||
|
emit("close");
|
||||||
|
resolve([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNodeClick = (data: Folder) => {
|
||||||
|
selectedFolder.value = {
|
||||||
|
...data,
|
||||||
|
path: data.path ? [...data.path, data] : [data],
|
||||||
|
};
|
||||||
|
emit("select", data.cid);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.folder-select {
|
||||||
|
min-height: 300px;
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-node {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-path {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tree-node__content) {
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
.folder-select-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid #e5e6e8;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
221
frontend/src/components/ResourceList.vue
Normal file
221
frontend/src/components/ResourceList.vue
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
<template>
|
||||||
|
<div class="resource-list">
|
||||||
|
<el-table
|
||||||
|
v-loading="store.loading"
|
||||||
|
:data="groupedResources"
|
||||||
|
style="width: 100%"
|
||||||
|
row-key="id"
|
||||||
|
:default-expand-all="true"
|
||||||
|
>
|
||||||
|
<el-table-column type="expand">
|
||||||
|
<template #default="props">
|
||||||
|
<el-table :data="props.row.items" style="width: 100%">
|
||||||
|
<el-table-column label="图片" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-image
|
||||||
|
v-if="row.image"
|
||||||
|
:src="row.image"
|
||||||
|
:preview-src-list="[row.image]"
|
||||||
|
:zoom-rate="1.2"
|
||||||
|
:max-scale="7"
|
||||||
|
:min-scale="0.2"
|
||||||
|
:initial-index="4"
|
||||||
|
preview-teleported
|
||||||
|
:z-index="999"
|
||||||
|
fit="cover"
|
||||||
|
width="60"
|
||||||
|
height="90"
|
||||||
|
/>
|
||||||
|
<el-icon v-else size="20"><Close /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="title" label="标题" width="180" />
|
||||||
|
<el-table-column label="地址">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link :href="row.cloudLinks[0]" target="_blank">
|
||||||
|
{{ row.cloudLinks[0] }}
|
||||||
|
</el-link>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="云盘类型" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag
|
||||||
|
:type="tagColor[row.cloudType as keyof typeof tagColor]"
|
||||||
|
effect="dark"
|
||||||
|
round
|
||||||
|
>
|
||||||
|
{{ row.cloudType }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button @click="handleSave(row)">转存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div v-if="props.row.hasMore" class="load-more">
|
||||||
|
<el-button :loading="props.row.loading" @click="handleLoadMore(props.row.channel)">
|
||||||
|
加载更多
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="来源" prop="channel">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="group-header">
|
||||||
|
<span>{{ row.channel }}</span>
|
||||||
|
<span class="item-count">({{ row.items.length }})</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-dialog v-model="folderDialogVisible" title="选择保存目录" v-if="currentResource">
|
||||||
|
<template #header="{ titleId }">
|
||||||
|
<div class="my-header">
|
||||||
|
<div :id="titleId">
|
||||||
|
<el-tag
|
||||||
|
:type="tagColor[currentResource.cloudType as keyof typeof tagColor]"
|
||||||
|
effect="dark"
|
||||||
|
round
|
||||||
|
>
|
||||||
|
{{ currentResource.cloudType }}
|
||||||
|
</el-tag>
|
||||||
|
选择保存目录
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<folder-select
|
||||||
|
v-if="folderDialogVisible"
|
||||||
|
@select="handleFolderSelect"
|
||||||
|
@close="folderDialogVisible = false"
|
||||||
|
:cloudType="currentResource.cloudType"
|
||||||
|
/>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button @click="folderDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleSaveBtnClick">保存</el-button>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { useResourceStore } from "@/stores/resource";
|
||||||
|
import FolderSelect from "./FolderSelect.vue";
|
||||||
|
import type { Resource } from "@/types";
|
||||||
|
|
||||||
|
const tagColor = {
|
||||||
|
baiduPan: "primary",
|
||||||
|
weiyun: "info",
|
||||||
|
aliyun: "warning",
|
||||||
|
pan115: "danger",
|
||||||
|
quark: "success",
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = useResourceStore();
|
||||||
|
const folderDialogVisible = ref(false);
|
||||||
|
const currentResource = ref<Resource | null>(null);
|
||||||
|
const currentFolderId = ref<string | null>(null);
|
||||||
|
|
||||||
|
// 按来源分组的数据
|
||||||
|
const groupedResources = computed(() => {
|
||||||
|
const groups = store.resources.reduce(
|
||||||
|
(acc, curr) => {
|
||||||
|
const channel = curr.channel;
|
||||||
|
const channelId = curr.channelId;
|
||||||
|
if (!acc[channel]) {
|
||||||
|
acc[channel] = {
|
||||||
|
channel,
|
||||||
|
items: [],
|
||||||
|
hasMore: true,
|
||||||
|
loading: false, // 添加 loading 状态
|
||||||
|
id: channelId || "", // 用于row-key
|
||||||
|
};
|
||||||
|
}
|
||||||
|
acc[channel].items.push(curr);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<
|
||||||
|
string,
|
||||||
|
{ channel: string; items: Resource[]; id: string; hasMore: boolean; loading: boolean }
|
||||||
|
>
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.values(groups);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = (resource: Resource) => {
|
||||||
|
currentResource.value = resource;
|
||||||
|
folderDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFolderSelect = async (folderId: string) => {
|
||||||
|
if (!currentResource.value) return;
|
||||||
|
currentFolderId.value = folderId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveBtnClick = async () => {
|
||||||
|
if (!currentResource.value || !currentFolderId.value) return;
|
||||||
|
folderDialogVisible.value = false;
|
||||||
|
await store.saveResource(currentResource.value, currentFolderId.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加加载更多处理函数
|
||||||
|
const handleLoadMore = async (channel: string) => {
|
||||||
|
const group = groupedResources.value.find((g) => g.channel === channel);
|
||||||
|
if (!group || group.loading) return;
|
||||||
|
|
||||||
|
group.loading = true;
|
||||||
|
try {
|
||||||
|
const lastMessageId = group.items[group.items.length - 1].messageId;
|
||||||
|
store.searchResources("", false, true, group.id, lastMessageId);
|
||||||
|
} finally {
|
||||||
|
group.loading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.resource-list {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-count {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__expand-column) {
|
||||||
|
.cell {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__expanded-cell) {
|
||||||
|
padding: 20px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__expand-icon) {
|
||||||
|
height: 23px;
|
||||||
|
line-height: 23px;
|
||||||
|
}
|
||||||
|
.load-more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
82
frontend/src/components/SearchBar.vue
Normal file
82
frontend/src/components/SearchBar.vue
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<div class="search-bar">
|
||||||
|
<el-input
|
||||||
|
v-model="keyword"
|
||||||
|
placeholder="请输入搜索关键词与输入链接直接解析"
|
||||||
|
class="input-with-select"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
style="margin-bottom: 8px"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<el-button type="success" @click="handleSearch">{{ searchBtnText }}</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-alert
|
||||||
|
title="可直接输入链接进行资源解析,也可进行资源搜索!"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
/>
|
||||||
|
<div class="search-new">
|
||||||
|
<el-button type="primary" @click="handleSearchNew">最新资源</el-button>
|
||||||
|
<div class="switch-source">
|
||||||
|
<el-switch v-model="backupPlan" /><span class="label">使用rsshub(较慢)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { effect, ref } from "vue";
|
||||||
|
import { useResourceStore } from "@/stores/resource";
|
||||||
|
|
||||||
|
const keyword = ref("");
|
||||||
|
const backupPlan = ref(false);
|
||||||
|
const store = useResourceStore();
|
||||||
|
const searchBtnText = ref("搜索");
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
// 监听搜索关键词的变化,如果存在,则自动触发搜索
|
||||||
|
if (keyword.value && keyword.value.startsWith("http")) {
|
||||||
|
searchBtnText.value = "解析";
|
||||||
|
} else {
|
||||||
|
searchBtnText.value = "搜索";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
// 如果搜索内容是一个https的链接,则尝试解析链接
|
||||||
|
if (keyword.value.startsWith("http")) {
|
||||||
|
store.parsingCloudLink(keyword.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!keyword.value.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await store.searchResources(keyword.value, backupPlan.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchNew = async () => {
|
||||||
|
keyword.value = "";
|
||||||
|
await store.searchResources("", backupPlan.value);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.search-bar {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-new {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.switch-source {
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
.switch-source .label {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
15
frontend/src/env.d.ts
vendored
Normal file
15
frontend/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module "*.vue" {
|
||||||
|
import type { DefineComponent } from "vue";
|
||||||
|
const component: DefineComponent<{}, {}, any>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
19
frontend/src/main.ts
Normal file
19
frontend/src/main.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import { createPinia } from "pinia";
|
||||||
|
import ElementPlus from "element-plus";
|
||||||
|
import "element-plus/dist/index.css";
|
||||||
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
|
import App from "./App.vue";
|
||||||
|
import router from "./router/index";
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
|
||||||
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||||
|
app.component(key, component)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(createPinia());
|
||||||
|
app.use(router);
|
||||||
|
app.use(ElementPlus);
|
||||||
|
|
||||||
|
app.mount("#app");
|
||||||
18
frontend/src/router/index.ts
Normal file
18
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
|
import type { RouteRecordRaw } from "vue-router";
|
||||||
|
import HomeView from "@/views/HomeView.vue";
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
name: "home",
|
||||||
|
component: HomeView,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
209
frontend/src/stores/resource.ts
Normal file
209
frontend/src/stores/resource.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { cloud115Api } from "@/api/cloud115";
|
||||||
|
import { resourceApi } from "@/api/resource";
|
||||||
|
import { quarkApi } from "@/api/quark";
|
||||||
|
import type { Resource, ShareInfoResponse, Save115FileParams, SaveQuarkFileParams } from "@/types";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
|
||||||
|
// 定义云盘驱动配置类型
|
||||||
|
interface CloudDriveConfig {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
regex: RegExp;
|
||||||
|
api: {
|
||||||
|
getShareInfo: (parsedCode: any) => Promise<ShareInfoResponse>;
|
||||||
|
saveFile: (params: Record<string, any>) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
};
|
||||||
|
parseShareCode: (match: RegExpMatchArray) => Record<string, string>;
|
||||||
|
getSaveParams: (shareInfo: ShareInfoResponse, folderId: string) => Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 云盘类型配置
|
||||||
|
export const CLOUD_DRIVES: CloudDriveConfig[] = [
|
||||||
|
{
|
||||||
|
name: "115网盘",
|
||||||
|
type: "pan115",
|
||||||
|
regex: /(?:115|anxia)\.com\/s\/([^?]+)(?:\?password=([^#]+))?/,
|
||||||
|
api: {
|
||||||
|
getShareInfo: (parsedCode: { shareCode: string; receiveCode: string }) =>
|
||||||
|
cloud115Api.getShareInfo(parsedCode.shareCode, parsedCode.receiveCode),
|
||||||
|
saveFile: (params) => cloud115Api.saveFile(params as Save115FileParams),
|
||||||
|
},
|
||||||
|
parseShareCode: (match) => ({
|
||||||
|
shareCode: match[1],
|
||||||
|
receiveCode: match[2] || "",
|
||||||
|
}),
|
||||||
|
getSaveParams: (shareInfo, folderId) => ({
|
||||||
|
shareCode: shareInfo.data.shareCode,
|
||||||
|
receiveCode: shareInfo.data.receiveCode,
|
||||||
|
fileId: shareInfo.data.list[0].fileId,
|
||||||
|
folderId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "夸克网盘",
|
||||||
|
type: "quark",
|
||||||
|
regex: /pan\.quark\.cn\/s\/([a-zA-Z0-9]+)/,
|
||||||
|
api: {
|
||||||
|
getShareInfo: (parsedCode: { pwdId: string }) => quarkApi.getShareInfo(parsedCode.pwdId),
|
||||||
|
saveFile: (params) => quarkApi.saveFile(params as SaveQuarkFileParams),
|
||||||
|
},
|
||||||
|
parseShareCode: (match) => ({ pwdId: match[1] }),
|
||||||
|
getSaveParams: (shareInfo, folderId) => ({
|
||||||
|
fid_list: shareInfo.data.list.map((item) => item.fileId || ""),
|
||||||
|
fid_token_list: shareInfo.data.list.map((item) => item.fileIdToken || ""),
|
||||||
|
to_pdir_fid: folderId,
|
||||||
|
pwd_id: shareInfo.data.pwdId || "",
|
||||||
|
stoken: shareInfo.data.stoken || "",
|
||||||
|
pdir_fid: "0",
|
||||||
|
scene: "link",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const useResourceStore = defineStore("resource", {
|
||||||
|
state: () => ({
|
||||||
|
resources: [] as Resource[],
|
||||||
|
selectedResources: [] as Resource[],
|
||||||
|
loading: false,
|
||||||
|
lastKeyWord: "",
|
||||||
|
backupPlan: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
// 搜索资源
|
||||||
|
async searchResources(
|
||||||
|
keyword?: string,
|
||||||
|
backupPlan = false,
|
||||||
|
isLoadMore = false,
|
||||||
|
channelId?: string,
|
||||||
|
lastMessageId?: string
|
||||||
|
): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
if (!isLoadMore) this.resources = [];
|
||||||
|
try {
|
||||||
|
if (isLoadMore) {
|
||||||
|
if (!lastMessageId) {
|
||||||
|
ElMessage.error("当次搜索源不支持加载更多");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
keyword = this.lastKeyWord;
|
||||||
|
backupPlan = this.backupPlan;
|
||||||
|
}
|
||||||
|
const { data } = await resourceApi.search(
|
||||||
|
keyword || "",
|
||||||
|
backupPlan,
|
||||||
|
channelId,
|
||||||
|
lastMessageId
|
||||||
|
);
|
||||||
|
this.lastKeyWord = keyword || "";
|
||||||
|
if (isLoadMore) {
|
||||||
|
this.resources.push(
|
||||||
|
...data.filter(
|
||||||
|
(item) => !this.selectedResources.some((selectedItem) => selectedItem.id === item.id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.resources = data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError("搜索失败,请重试", error);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 转存资源
|
||||||
|
async saveResource(resource: Resource, folderId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const savePromises: Promise<void>[] = [];
|
||||||
|
CLOUD_DRIVES.forEach((drive) => {
|
||||||
|
if (resource.cloudLinks.some((link) => drive.regex.test(link))) {
|
||||||
|
savePromises.push(this.saveResourceToDrive(resource, folderId, drive));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(savePromises);
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError("转存失败,请重试", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 保存资源到网盘
|
||||||
|
async saveResourceToDrive(
|
||||||
|
resource: Resource,
|
||||||
|
folderId: string,
|
||||||
|
drive: CloudDriveConfig
|
||||||
|
): Promise<void> {
|
||||||
|
const link = resource.cloudLinks.find((link) => drive.regex.test(link));
|
||||||
|
if (!link) return;
|
||||||
|
|
||||||
|
const match = link.match(drive.regex);
|
||||||
|
if (!match) throw new Error("链接解析失败");
|
||||||
|
|
||||||
|
const parsedCode = drive.parseShareCode(match);
|
||||||
|
try {
|
||||||
|
let shareInfo = await drive.api.getShareInfo(parsedCode);
|
||||||
|
if (shareInfo?.data) {
|
||||||
|
shareInfo = {
|
||||||
|
...shareInfo,
|
||||||
|
data: {
|
||||||
|
...shareInfo.data,
|
||||||
|
...parsedCode,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const params = drive.getSaveParams(shareInfo, folderId);
|
||||||
|
const result = await drive.api.saveFile(params);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
ElMessage.success(`${drive.name} 转存成功`);
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(error instanceof Error ? error.message : `${drive.name} 转存失败`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 解析云盘链接
|
||||||
|
async parsingCloudLink(url: string): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
this.resources = [];
|
||||||
|
try {
|
||||||
|
const matchedDrive = CLOUD_DRIVES.find((drive) => drive.regex.test(url));
|
||||||
|
if (!matchedDrive) throw new Error("不支持的网盘链接");
|
||||||
|
|
||||||
|
const match = url.match(matchedDrive.regex);
|
||||||
|
if (!match) throw new Error("链接解析失败");
|
||||||
|
|
||||||
|
const parsedCode = matchedDrive.parseShareCode(match);
|
||||||
|
const shareInfo = await matchedDrive.api.getShareInfo(parsedCode);
|
||||||
|
|
||||||
|
if (shareInfo?.data?.list?.length) {
|
||||||
|
this.resources = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
title: shareInfo.data.list.map((item) => item.fileName).join(", "),
|
||||||
|
cloudLinks: [url],
|
||||||
|
cloudType: matchedDrive.type,
|
||||||
|
channel: matchedDrive.name,
|
||||||
|
pubDate: "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
throw new Error("解析失败,请检查链接是否正确");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError("解析失败,请重试", error);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 统一错误处理
|
||||||
|
handleError(message: string, error: unknown): void {
|
||||||
|
console.error(message, error);
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : message);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
63
frontend/src/types/index.ts
Normal file
63
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
export interface Resource {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
channel: string;
|
||||||
|
channelId?: string;
|
||||||
|
cloudLinks: string[];
|
||||||
|
pubDate: string;
|
||||||
|
cloudType: string;
|
||||||
|
messageId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShareInfo {
|
||||||
|
fileId: string;
|
||||||
|
fileName: string;
|
||||||
|
fileSize: number;
|
||||||
|
fileIdToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShareInfoResponse {
|
||||||
|
data: {
|
||||||
|
list: ShareInfo[];
|
||||||
|
pwdId?: string;
|
||||||
|
stoken?: string;
|
||||||
|
shareCode?: string;
|
||||||
|
receiveCode?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Folder {
|
||||||
|
cid: string;
|
||||||
|
name: string;
|
||||||
|
path?: Folder[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveFileParams {
|
||||||
|
shareCode: string;
|
||||||
|
receiveCode: string;
|
||||||
|
fileId: string;
|
||||||
|
folderId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Save115FileParams {
|
||||||
|
shareCode: string;
|
||||||
|
receiveCode: string;
|
||||||
|
fileId: string;
|
||||||
|
folderId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveQuarkFileParams {
|
||||||
|
fid_list: string[];
|
||||||
|
fid_token_list: string[];
|
||||||
|
to_pdir_fid: string;
|
||||||
|
pwd_id: string;
|
||||||
|
stoken: string;
|
||||||
|
pdir_fid: string;
|
||||||
|
scene: string;
|
||||||
|
}
|
||||||
28
frontend/src/utils/request.ts
Normal file
28
frontend/src/utils/request.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import axios, { AxiosResponse } from "axios";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
|
||||||
|
const request = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_BASE_URL as string,
|
||||||
|
timeout: 60000,
|
||||||
|
withCredentials: true,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
request.interceptors.response.use(
|
||||||
|
(response: AxiosResponse) => {
|
||||||
|
const res = response.data;
|
||||||
|
if (!res.success) {
|
||||||
|
ElMessage.error(res.error || "请求失败");
|
||||||
|
return Promise.reject(new Error(res.error || "请求失败"));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
ElMessage.error(error.message || "网络错误");
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default request;
|
||||||
32
frontend/src/views/HomeView.vue
Normal file
32
frontend/src/views/HomeView.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<template>
|
||||||
|
<div class="home">
|
||||||
|
<search-bar />
|
||||||
|
<resource-list />
|
||||||
|
<el-backtop :bottom="100">
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--el-bg-color-overlay);
|
||||||
|
box-shadow: var(--el-box-shadow-lighter);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 40px;
|
||||||
|
color: #1989fa;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
UP
|
||||||
|
</div>
|
||||||
|
</el-backtop>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import SearchBar from "@/components/SearchBar.vue";
|
||||||
|
import ResourceList from "@/components/ResourceList.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.home {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
55
frontend/vite.config.ts
Normal file
55
frontend/vite.config.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
import AutoImport from "unplugin-auto-import/vite";
|
||||||
|
import Components from "unplugin-vue-components/vite";
|
||||||
|
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: "/",
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
AutoImport({
|
||||||
|
resolvers: [ElementPlusResolver()],
|
||||||
|
}),
|
||||||
|
Components({
|
||||||
|
resolvers: [ElementPlusResolver()],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
port: 8008,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: process.env.VITE_API_BASE_URL_PROXY || "http://127.0.0.1:8009",
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||||
|
configure: (proxy, _options) => {
|
||||||
|
proxy.on("error", (err, _req, _res) => {
|
||||||
|
console.log("proxy error", err);
|
||||||
|
});
|
||||||
|
proxy.on("proxyReq", (proxyReq, req, _res) => {
|
||||||
|
console.log("Sending Request:", req.method, req.url);
|
||||||
|
});
|
||||||
|
proxy.on("proxyRes", (proxyRes, req, _res) => {
|
||||||
|
console.log("Received Response:", proxyRes.statusCode, req.url);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
assetsDir: "assets",
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: fileURLToPath(new URL("./index.html", import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
32
package.json
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "cloud-saver",
|
||||||
|
"version": "0.0.8",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": [
|
||||||
|
"frontend",
|
||||||
|
"backend"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"ins": "npm-run-all --parallel install:*",
|
||||||
|
"install:frontend": "cd frontend && npm install",
|
||||||
|
"install:backend": "cd backend && npm install",
|
||||||
|
"dev": "npm-run-all --parallel dev:*",
|
||||||
|
"dev:frontend": "cd frontend && npm run dev",
|
||||||
|
"dev:backend": "cd backend && npm run dev",
|
||||||
|
"build": "npm-run-all --parallel build:*",
|
||||||
|
"build:frontend": "cd frontend && npm run build",
|
||||||
|
"build:backend": "cd backend && npm run build",
|
||||||
|
"clean": "rimraf **/node_modules **/dist",
|
||||||
|
"format": "prettier --write \"**/*.{js,ts,vue,json,md}\"",
|
||||||
|
"format:check": "prettier --check \"**/*.{js,ts,vue,json,md}\""
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"npm-run-all": "^4.1.5",
|
||||||
|
"rimraf": "^5.0.5",
|
||||||
|
"prettier": "^3.2.5"
|
||||||
|
},
|
||||||
|
"dependencies": {},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
4056
pnpm-lock.yaml
generated
Normal file
4056
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- 'frontend'
|
||||||
|
- 'backend'
|
||||||
Reference in New Issue
Block a user