mirror of
https://github.com/jiangrui1994/CloudSaver.git
synced 2026-01-08 14:18:46 +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