27 Commits

Author SHA1 Message Date
jiangrui
32bf77c3e0 chore(release): bump version to v0.1.2 2025-03-02 08:33:12 +08:00
jiangrui
9f6b811785 Merge remote-tracking branch 'origin/dev' 2025-03-02 08:29:15 +08:00
jiangrui
fe38437a7c update:群二维码 2025-03-01 08:37:51 +08:00
jiangrui
06b01bf1f9 Merge remote-tracking branch 'origin/main' into dev 2025-02-27 14:28:32 +08:00
jiangrui1994
2fffdc30f5 Update docker-build-test.yml 2025-02-26 16:10:08 +08:00
jiangrui
5b701e499a fix:转存报错没有提示 2025-02-25 13:24:01 +08:00
jiangrui
25ecdb1078 Merge remote-tracking branch 'origin/main' into dev 2025-02-25 13:04:03 +08:00
jiangrui1994
1c884eb3b9 Update docker-build-test.yml 2025-02-25 13:03:52 +08:00
jiangrui
e4d2a1db52 Merge remote-tracking branch 'origin/main' into dev 2025-02-25 13:02:28 +08:00
jiangrui1994
4553b9a7a0 Create docker-build-test.yml 2025-02-25 13:01:33 +08:00
jiangrui
c290f5a6d9 feat:增加转存资源列表展示与选择 2025-02-25 12:53:27 +08:00
jiangrui
8668bce863 fix:修复部分115账号cookie无法使用问题 2025-02-25 12:51:34 +08:00
jiangrui1994
390287876a Merge pull request #1 from jiangrui1994/dev
Dev
2025-02-25 09:37:54 +08:00
jiangrui
f860cc1f57 refactor: 重构图片代理控制器,优化代码结构 2025-02-24 16:10:46 +08:00
jiangrui
f5106e782a format:format and fix code 2025-02-24 15:22:43 +08:00
jiangrui
c784b562c4 fix:修复夸克空文件夹选中报错 2025-02-21 15:02:27 +08:00
jiangrui
cd4fa8cc2b Update README.md 2025-02-21 10:56:43 +08:00
jiangrui1994
2041a7fa11 Update README.md 2025-02-21 09:55:31 +08:00
jiangrui1994
924903bd58 Update docker-image.yml 2025-02-21 09:51:02 +08:00
jiangrui1994
e3bf2d6d6b Update README.md 2025-02-20 17:49:27 +08:00
jiangrui1994
f87ae22924 Update docker-image.yml 2025-02-20 14:26:32 +08:00
jiangrui1994
868d99367d Update docker-image.yml 2025-02-20 14:21:42 +08:00
jiangrui1994
9864eeb21d Create docker-image.yml 2025-02-20 14:18:26 +08:00
jiangrui
510fdc48f6 feat:版本迭代 2025-02-20 12:00:19 +08:00
jiangrui
fd110590af docs:更新群二维码 2024-12-24 18:26:02 +08:00
jiangrui
89caf91688 fix:change the default back-end port 2024-12-17 14:20:05 +08:00
jiangrui
16dfa1500d update 2024-12-17 12:29:05 +08:00
102 changed files with 6524 additions and 1463 deletions

View File

@@ -1,24 +1,8 @@
# # 数据库配置
# DB_HOST=localhost
# DB_USER=your_username
# DB_PASSWORD=your_password
# jwt密钥 用于生成token加密
JWT_SECRET=""
# # API密钥
# API_KEY=your_api_key
# 用户注册码
REGISTER_CODE='9527'
# # 其他敏感信息
# 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
# 服务端口
PORT=8009

4
.eslintignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
build
coverage

57
.eslintrc.js Normal file
View File

@@ -0,0 +1,57 @@
module.exports = {
root: true,
ignorePatterns: ["node_modules", "dist", "build", "coverage"],
env: {
node: true,
es6: true,
},
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"],
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
],
rules: {
"prettier/prettier": "error",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
"@typescript-eslint/explicit-function-return-type": 0,
},
overrides: [
{
files: ["frontend/**/*.{js,ts,vue}"],
env: {
browser: true,
},
parser: "vue-eslint-parser",
parserOptions: {
parser: "@typescript-eslint/parser",
ecmaVersion: 2020,
sourceType: "module",
},
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:vue/vue3-recommended",
"plugin:prettier/recommended",
],
plugins: ["@typescript-eslint", "vue"],
rules: {
"vue/multi-word-component-names": "off",
"vue/require-default-prop": "off",
"vue/no-v-html": "off",
},
},
{
files: ["backend/**/*.{js,ts}"],
env: {
node: true,
},
rules: {
"@typescript-eslint/explicit-function-return-type": 0,
"@typescript-eslint/no-non-null-assertion": "warn",
},
},
],
};

44
.github/workflows/docker-build-test.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: Build and Push Multi-Arch Docker Image for Test
on:
push:
branches:
- dev
workflow_dispatch: # 添加手动触发
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # 必须授权以推送镜像
env:
REPO_NAME: ${{ github.repository }}
steps:
- name: 检出代码
uses: actions/checkout@v4
- name: 设置小写镜像名称
run: |
LOWER_NAME=$(echo "$REPO_NAME" | tr '[:upper:]' '[:lower:]')
echo "LOWER_NAME=$LOWER_NAME" >> $GITHUB_ENV
- name: 登录到 GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: 设置 QEMU 支持多架构
uses: docker/setup-qemu-action@v2
- name: 设置 Docker Buildx
uses: docker/setup-buildx-action@v2
- name: 构建并推送多架构 Docker 镜像
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64 # 指定架构x86_64 和 ARM64
push: true
tags: |
ghcr.io/${{ env.LOWER_NAME }}:test

36
.github/workflows/docker-image.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Docker Image CI/CD
on:
push:
tags: [ "v*.*.*" ] # 支持标签触发(如 v1.0.0
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # 必须授权以推送镜像
env:
REPO_NAME: ${{ github.repository }}
steps:
- name: 检出代码
uses: actions/checkout@v4
- name: 设置小写镜像名称
run: |
LOWER_NAME=$(echo "$REPO_NAME" | tr '[:upper:]' '[:lower:]')
echo "LOWER_NAME=$LOWER_NAME" >> $GITHUB_ENV
- name: 登录到 GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: 构建并推送 Docker 镜像
uses: docker/build-push-action@v4
with:
context: .
push: ${{ github.event_name == 'push' }} # 仅推送代码时上传镜像
tags: |
ghcr.io/${{ env.LOWER_NAME }}:latest
ghcr.io/${{ env.LOWER_NAME }}:${{ github.sha }}

5
.gitignore vendored
View File

@@ -4,6 +4,11 @@ dist/
.env.local
.env.*.local
*.tar
# 数据库数据
*.sqlite
# 保留模板
!.env.example

View File

@@ -24,4 +24,7 @@ node_modules
# 系统文件
.DS_Store
Thumbs.db
Thumbs.db
# 版本控制
.git

14
.prettierrc.js Normal file
View File

@@ -0,0 +1,14 @@
module.exports = {
semi: true,
trailingComma: "es5",
singleQuote: false,
printWidth: 100,
tabWidth: 2,
useTabs: false,
endOfLine: "auto",
arrowParens: "always",
bracketSpacing: true,
embeddedLanguageFormatting: "auto",
htmlWhitespaceSensitivity: "css",
vueIndentScriptAndStyle: false,
};

View File

@@ -1,11 +0,0 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"printWidth": 100,
"trailingComma": "es5",
"bracketSpacing": true,
"endOfLine": "lf",
"arrowParens": "always",
"vueIndentScriptAndStyle": true
}

45
Dockerfile Normal file
View File

@@ -0,0 +1,45 @@
# 构建前端项目
FROM node:18-alpine as frontend-build
WORKDIR /app
COPY frontend/package*.json ./
RUN npm install -g pnpm
RUN pnpm install
COPY frontend/ ./
RUN npm run build
# 构建后端项目
FROM node:18-alpine as backend-build
WORKDIR /app
COPY backend/package*.json ./
RUN npm install -g pnpm
RUN pnpm install
COPY backend/ ./
RUN rm -f database.sqlite
RUN npm run build
# 生产环境镜像
FROM node:18-alpine
# 安装 Nginx
RUN apk add --no-cache nginx
# 设置工作目录
WORKDIR /app
# 复制前端构建产物到 Nginx
COPY --from=frontend-build /app/dist /usr/share/nginx/html
# 复制 Nginx 配置文件
COPY nginx.conf /etc/nginx/nginx.conf
# 复制后端构建产物到生产环境镜像
COPY --from=backend-build /app /app
# 安装生产环境依赖
RUN npm install --production
# 暴露端口
EXPOSE 8008
# 启动 Nginx 和后端服务
CMD ["sh", "-c", "nginx -g 'daemon off;' & npm run start && wait"]

View File

@@ -1,6 +1,7 @@
# CloudSaver
一个基于 Vue 3 + Express 的网盘资源搜索与转存工具。
可通过docker 一键部署。
## 特别声明
@@ -11,33 +12,45 @@
## 注意事项
1. 此项目的资源搜索需要用到代理环境,请自行搭建。
2. 默认的代理地址为:`http://127.0.0.1`,端口为:`7890`,如需修改,请自行 `.env`中配置。
1. 此项目的资源搜索需要用到**代理环境**,请自行搭建。
2. 新用户注册,**管理员**默认注册码230713普通用户默认注册码9527
## 功能特性
- 支持多个资源订阅(电报群)源搜索
- 支持 115 网盘与夸克网盘资源转存
- 支持 115 网盘与夸克网盘**一键**资源转存
- 支持关键词搜索与资源链接解析
- 支持转存文件夹展示与选择
- 支持多用户使用
- 支持豆瓣热门榜单
- 支持热门榜单资源搜索
## 预览
### 登录/注册
<img src="./docs/images/login.png" width="400">
<img src="./docs/images/register.png" width="400">
### 最新资源搜索
<img src="./docs/images/screenshot-20241216-172442.png" width="400">
### 转存
<img src="./docs/images/screenshot-20241216-172609.png" width="400">
<img src="./docs/images/search.png" width="400">
### 关键词搜索
<img src="./docs/images/screenshot-20241216-172710.png" width="400">
<img src="./docs/images/search-movie.png" width="400">
### 直接链接解析
### 热门榜单
<img src="./docs/images/screenshot-20241216-173136.png" width="400">
<img src="./docs/images/hotmovie.png" width="400">
### 转存
<img src="./docs/images/save.png" width="400">
### 系统设置
<img src="./docs/images/setting.png" width="400">
## 技术栈
@@ -55,6 +68,7 @@
- Node.js
- Express
- TypeScript
- Sqlite3
- RSS Parser
## 环境配置
@@ -63,6 +77,8 @@
本项目需要 `Node.js` 版本 `18.x` 或更高版本。请确保在运行项目之前安装了正确的版本。
推荐使用pnpm进行依赖包安装默认支持workspace可以避免版本冲突。
### 后端配置项
复制环境变量模板文件:
@@ -72,8 +88,6 @@ cp .env.example ./backend/.env
```
- 根据 `.env.example`文件在 `backend`目录下创建 `.env`文件。
- `CLOUD115_COOKIE`115 用户 cookie
- `QUARK_COOKIE`quark cookie
## 使用
@@ -89,6 +103,25 @@ cp .env.example ./backend/.env
- 后端服务:先进入后端目录`backend``npm run build`构建,然后执行 `npm run start`启动,默认端口为`8009`
- 通过`nginx`配置代理服务,将前端api的请求映射到后端服务。
## Docker 部署
- 运行容器:
```bash
docker run -d -p 8008:8008 --name cloud-saver ghcr.io/jiangrui1994/cloudsaver
```
### docker 镜像地址
[ghcr.io/jiangrui1994/cloudsaver](ghcr.io/jiangrui1994/cloudsaver)
相关nas镜像拉取 可从此地址拉取
## 交流沟通
<img src="./docs/images/wechat.jpg" width="400">
<img src="./docs/images/20250220115710.jpg" width="400">
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

View File

@@ -9,19 +9,25 @@
},
"dependencies": {
"axios": "^1.6.7",
"bcrypt": "^5.1.1",
"cheerio": "^1.0.0",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.18.3",
"jsonwebtoken": "^9.0.2",
"rss-parser": "^3.13.0",
"sequelize": "^6.37.5",
"socket.io": "^4.8.1",
"sqlite3": "^5.1.7",
"tunnel": "^0.0.6"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.7",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^20.11.25",
"@types/tunnel": "^0.0.7",
"nodemon": "^3.1.0",

View File

@@ -1,8 +1,14 @@
// filepath: /d:/code/CloudDiskDown/backend/src/app.ts
import "./types/express";
import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import routes from "./routes/api";
import { errorHandler } from "./middleware/errorHandler";
import sequelize from "./config/database";
import { authMiddleware } from "./middleware/auth";
import GlobalSetting from "./models/GlobalSetting";
import Searcher from "./services/Searcher";
const app = express();
@@ -17,14 +23,54 @@ app.use(
app.use(cookieParser());
app.use(express.json());
// 应用 token 验证中间件,排除登录和注册接口
app.use((req, res, next) => {
if (
req.path === "/user/login" ||
req.path === "/user/register" ||
req.path.includes("tele-images")
) {
return next();
}
authMiddleware(req, res, next);
});
app.use("/", routes);
const initializeGlobalSettings = async (): Promise<void> => {
const settings = await GlobalSetting.findOne();
if (!settings) {
await GlobalSetting.create({
httpProxyHost: "127.0.0.1",
httpProxyPort: 7890,
isProxyEnabled: true,
CommonUserCode: 9527,
AdminUserCode: 230713,
});
console.log("Global settings initialized with default values.");
}
await Searcher.updateAxiosInstance();
};
// 错误处理
app.use(errorHandler);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
const PORT = process.env.PORT || 8009;
// 在同步前禁用外键约束,同步后重新启用
sequelize
.query("PRAGMA foreign_keys = OFF") // 禁用外键
.then(() => sequelize.sync({ alter: true }))
.then(() => sequelize.query("PRAGMA foreign_keys = ON")) // 重新启用外键
.then(() => {
app.listen(PORT, async () => {
await initializeGlobalSettings();
console.log(`Server is running on port ${PORT}`);
});
})
.catch((error) => {
console.error("Database sync failed:", error);
});
export default app;

View File

@@ -0,0 +1,9 @@
// backend/src/config/database.ts
import { Sequelize } from "sequelize";
const sequelize = new Sequelize({
dialect: "sqlite",
storage: "./data/database.sqlite",
});
export default sequelize;

View File

@@ -30,6 +30,8 @@ interface HttpProxyConfig {
port: string;
}
interface Config {
jwtSecret: string;
registerCode: string;
rss: {
baseUrl: string;
channels: Channel[];
@@ -44,6 +46,7 @@ interface Config {
}
export const config: Config = {
jwtSecret: process.env.JWT_SECRET || "uV7Y$k92#LkF^q1b!",
rss: {
baseUrl: process.env.RSS_BASE_URL || "https://rsshub.rssforever.com/telegram/channel",
channels: [
@@ -61,14 +64,15 @@ export const config: Config = {
},
],
},
registerCode: process.env.REGISTER_CODE || "9527",
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",
host: process.env.HTTP_PROXY_HOST || "",
port: process.env.HTTP_PROXY_PORT || "",
},
cloudPatterns: {
@@ -77,7 +81,7 @@ export const config: Config = {
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,
pan115: /https?:\/\/(?:115|anxia|115cdn)\.com\/s\/[^\s<>"]+/g,
quark: /https?:\/\/pan\.quark\.cn\/[^\s<>"]+/g,
},

View File

@@ -1,46 +1,60 @@
import { Request, Response, NextFunction } from "express";
import { Request, Response } from "express";
import { Cloud115Service } from "../services/Cloud115Service";
import { config } from "../config";
import handleError from "../utils/handleError";
import { handleResponse } from "../utils/responseHandler";
import { sendSuccess, sendError } from "../utils/response";
import UserSetting from "../models/UserSetting";
const { cookie } = config.cloud115;
const cloud115 = new Cloud115Service(cookie);
const cloud115 = new Cloud115Service();
const setCookie = async (req: Request): Promise<void> => {
const userId = req.user?.userId;
const userSetting = await UserSetting.findOne({
where: { userId },
});
if (userSetting && userSetting.dataValues.cloud115Cookie) {
cloud115.setCookie(userSetting.dataValues.cloud115Cookie);
} else {
throw new Error("请先设置115网盘cookie");
}
};
export const cloud115Controller = {
async getShareInfo(req: Request, res: Response, next: NextFunction) {
async getShareInfo(req: Request, res: Response): Promise<void> {
try {
const { shareCode, receiveCode } = req.query;
await setCookie(req);
const result = await cloud115.getShareInfo(shareCode as string, receiveCode as string);
handleResponse(res, result, true);
sendSuccess(res, result);
} catch (error) {
handleError(res, error, "获取分享信息失败", next);
console.log(error);
sendError(res, { message: (error as Error).message || "获取分享信息失败" });
}
},
async getFolderList(req: Request, res: Response, next: NextFunction) {
async getFolderList(req: Request, res: Response): Promise<void> {
try {
const { parentCid } = req.query;
await setCookie(req);
const result = await cloud115.getFolderList(parentCid as string);
handleResponse(res, result, true);
sendSuccess(res, result);
} catch (error) {
handleError(res, error, "获取目录列表失败", next);
sendError(res, { message: (error as Error).message || "获取目录列表失败" });
}
},
async saveFile(req: Request, res: Response, next: NextFunction) {
async saveFile(req: Request, res: Response): Promise<void> {
try {
const { shareCode, receiveCode, fileId, folderId } = req.body;
await setCookie(req);
const result = await cloud115.saveSharedFile({
shareCode,
receiveCode,
fileId,
cid: folderId,
});
handleResponse(res, result, true);
sendSuccess(res, result);
} catch (error) {
handleError(res, error, "保存文件失败", next);
sendError(res, { message: (error as Error).message || "保存文件失败" });
}
},
};
export const Cloud115ServiceInstance = cloud115;

View File

@@ -0,0 +1,22 @@
import { Request, Response } from "express";
import DoubanService from "../services/DoubanService";
import { sendSuccess, sendError } from "../utils/response";
const doubanService = new DoubanService();
export const doubanController = {
async getDoubanHotList(req: Request, res: Response): Promise<void> {
try {
const { type = "movie", tag = "热门", page_limit = "50", page_start = "0" } = req.query;
const result = await doubanService.getHotList({
type: type as string,
tag: tag as string,
page_limit: page_limit as string,
page_start: page_start as string,
});
sendSuccess(res, result);
} catch (error) {
sendError(res, { message: "获取热门列表失败" });
}
},
};

View File

@@ -1,40 +1,52 @@
import { Request, Response, NextFunction } from "express";
import { Request, Response } from "express";
import { QuarkService } from "../services/QuarkService";
import { config } from "../config";
import { handleResponse } from "../utils/responseHandler";
import handleError from "../utils/handleError";
import { sendSuccess, sendError } from "../utils/response";
import UserSetting from "../models/UserSetting";
const { cookie } = config.quark;
const quark = new QuarkService();
const quark = new QuarkService(cookie);
const setCookie = async (req: Request): Promise<void> => {
const userId = req.user?.userId;
const userSetting = await UserSetting.findOne({
where: { userId },
});
if (userSetting && userSetting.dataValues.quarkCookie) {
quark.setCookie(userSetting.dataValues.quarkCookie);
} else {
throw new Error("请先设置夸克网盘cookie");
}
};
export const quarkController = {
async getShareInfo(req: Request, res: Response, next: NextFunction) {
async getShareInfo(req: Request, res: Response): Promise<void> {
try {
const { pwdId, passcode } = req.query;
await setCookie(req);
const result = await quark.getShareInfo(pwdId as string, passcode as string);
handleResponse(res, result, true);
sendSuccess(res, result);
} catch (error) {
handleError(res, error, "获取分享信息失败", next);
sendError(res, { message: "获取分享信息失败" });
}
},
async getFolderList(req: Request, res: Response, next: NextFunction) {
async getFolderList(req: Request, res: Response): Promise<void> {
try {
const { parentCid } = req.query;
await setCookie(req);
const result = await quark.getFolderList(parentCid as string);
handleResponse(res, result, true);
sendSuccess(res, result);
} catch (error) {
handleError(res, error, "获取目录列表失败", next);
sendError(res, { message: (error as Error).message || "获取目录列表失败" });
}
},
async saveFile(req: Request, res: Response, next: NextFunction) {
async saveFile(req: Request, res: Response): Promise<void> {
try {
await setCookie(req);
const result = await quark.saveSharedFile(req.body);
handleResponse(res, result, true);
sendSuccess(res, result);
} catch (error) {
handleError(res, error, "保存文件失败", next);
sendError(res, { message: (error as Error).message || "保存文件失败" });
}
},
};

View File

@@ -1,32 +1,21 @@
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";
import { Request, Response } from "express";
import Searcher from "../services/Searcher";
import { sendSuccess, sendError } from "../utils/response";
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) {
async search(req: Request, res: Response): Promise<void> {
try {
const { keyword, channelId = "", lastMessageId = "" } = req.query; // Remove `: string` from here
const searcher = new Searcher();
const result = await searcher.searchAll(
const result = await Searcher.searchAll(
keyword as string,
channelId as string,
lastMessageId as string
);
handleResponse(res, result, true);
sendSuccess(res, result);
} catch (error) {
handleError(res, error, "获取资源发生未知错误", next);
sendError(res, {
message: (error as Error).message || "搜索资源失败",
});
}
},
};

View File

@@ -0,0 +1,59 @@
import { Request, Response } from "express";
import { sendSuccess, sendError } from "../utils/response";
import Searcher from "../services/Searcher";
import UserSetting from "../models/UserSetting";
import GlobalSetting from "../models/GlobalSetting";
import { iamgesInstance } from "./teleImages";
export const settingController = {
async get(req: Request, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
const role = req.user?.role;
if (userId !== null) {
let userSettings = await UserSetting.findOne({ where: { userId } });
if (!userSettings) {
userSettings = {
userId: userId,
cloud115Cookie: "",
quarkCookie: "",
} as UserSetting;
if (userSettings) {
await UserSetting.create(userSettings);
}
}
const globalSetting = await GlobalSetting.findOne();
sendSuccess(res, {
data: {
userSettings,
globalSetting: role === 1 ? globalSetting : null,
},
});
} else {
sendError(res, { message: "用户ID无效" });
}
} catch (error) {
console.log("获取设置失败:" + error);
sendError(res, { message: (error as Error).message || "获取设置失败" });
}
},
async save(req: Request, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
const role = req.user?.role;
if (userId !== null) {
const { userSettings, globalSetting } = req.body;
await UserSetting.update(userSettings, { where: { userId } });
if (role === 1 && globalSetting) await GlobalSetting.update(globalSetting, { where: {} });
Searcher.updateAxiosInstance();
iamgesInstance.updateProxyConfig();
sendSuccess(res, {
message: "保存成功",
});
}
} catch (error) {
console.log("保存设置失败:" + error);
sendError(res, { message: (error as Error).message || "保存设置失败" });
}
},
};

View File

@@ -0,0 +1,78 @@
import axios, { AxiosInstance } from "axios";
import e, { Request, Response } from "express";
import tunnel from "tunnel";
import GlobalSetting from "../models/GlobalSetting";
import { GlobalSettingAttributes } from "../models/GlobalSetting";
export class ImageControll {
private axiosInstance: AxiosInstance | null = null;
private settings: GlobalSetting | null = null;
constructor() {
this.initializeAxiosInstance();
}
private async initializeAxiosInstance(): Promise<void> {
try {
this.settings = await GlobalSetting.findOne();
} catch (error) {
console.error("Error fetching global settings:", error);
}
const globalSetting = this.settings?.dataValues || ({} as GlobalSettingAttributes);
this.axiosInstance = axios.create({
timeout: 3000,
httpsAgent: globalSetting.isProxyEnabled
? tunnel.httpsOverHttp({
proxy: {
host: globalSetting.httpProxyHost,
port: globalSetting.httpProxyPort,
headers: {
"Proxy-Authorization": "",
},
},
})
: undefined,
withCredentials: true,
});
}
public async updateProxyConfig(): Promise<void> {
try {
this.settings = await GlobalSetting.findOne();
const globalSetting = this.settings?.dataValues || ({} as GlobalSettingAttributes);
if (this.axiosInstance) {
this.axiosInstance.defaults.httpsAgent = globalSetting.isProxyEnabled
? tunnel.httpsOverHttp({
proxy: {
host: globalSetting.httpProxyHost,
port: globalSetting.httpProxyPort,
headers: {
"Proxy-Authorization": "",
},
},
})
: undefined;
}
} catch (error) {
console.error("Error updating proxy config:", error);
}
}
async getImages(req: Request, res: Response, url: string): Promise<void> {
try {
const response = await this.axiosInstance?.get(url, { responseType: "stream" });
res.set("Content-Type", response?.headers["content-type"]);
response?.data.pipe(res);
} catch (error) {
res.status(500).send("Image fetch error");
}
}
}
export const iamgesInstance = new ImageControll();
export const imageControll = {
getImages: async (req: Request, res: Response): Promise<void> => {
const url = req.query.url as string;
iamgesInstance.getImages(req, res, url);
},
};

View File

@@ -0,0 +1,62 @@
import { Request, Response } from "express";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import GlobalSetting from "../models/GlobalSetting";
import User from "../models/User";
import { config } from "../config";
import { sendSuccess, sendError } from "../utils/response";
const isValidInput = (input: string): boolean => {
// 检查是否包含空格或汉字
const regex = /^[^\s\u4e00-\u9fa5]+$/;
return regex.test(input);
};
export const userController = {
async register(req: Request, res: Response): Promise<void> {
const { username, password, registerCode } = req.body;
const globalSetting = await GlobalSetting.findOne();
const registerCodeList = [
globalSetting?.dataValues.CommonUserCode,
globalSetting?.dataValues.AdminUserCode,
];
if (!registerCode || !registerCodeList.includes(Number(registerCode))) {
return sendError(res, { message: "注册码错误" });
}
// 验证输入
if (!isValidInput(username) || !isValidInput(password)) {
return sendError(res, { message: "用户名、密码或注册码不能包含空格或汉字" });
}
// 检查用户名是否已存在
const existingUser = await User.findOne({ where: { username } });
if (existingUser) {
return sendError(res, { message: "用户名已存在" });
}
const hashedPassword = await bcrypt.hash(password, 10);
try {
const role = registerCodeList.findIndex((x) => x === Number(registerCode));
const user = await User.create({ username, password: hashedPassword, role });
sendSuccess(res, {
data: user,
message: "用户注册成功",
});
} catch (error) {
sendError(res, { message: (error as Error).message || "用户注册失败" });
}
},
async login(req: Request, res: Response): Promise<void> {
const { username, password } = req.body;
const user = await User.findOne({ where: { username } });
if (!user || !(await bcrypt.compare(password, user.password))) {
return sendError(res, { message: "用户名或密码错误" });
}
const token = jwt.sign({ userId: user.userId, role: user.role }, config.jwtSecret, {
expiresIn: "6h",
});
sendSuccess(res, {
data: {
token,
},
});
},
};

View File

@@ -0,0 +1,43 @@
// filepath: /D:/code/CloudDiskDown/backend/src/middleware/auth.ts
import { Request, Response, NextFunction } from "express";
import jwt, { JwtPayload } from "jsonwebtoken";
import User from "../models/User";
import { config } from "../config";
interface AuthenticatedRequest extends Request {
user?: {
userId: string;
role: number;
};
}
export const authMiddleware = async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): Promise<void | Response> => {
if (req.path === "/user/login" || req.path === "/user/register") {
return next();
}
const token = req.headers.authorization?.split(" ")[1];
if (!token) {
return res.status(401).json({ message: "未提供 token" });
}
try {
const decoded = jwt.verify(token, config.jwtSecret) as JwtPayload;
req.user = {
userId: decoded.userId,
role: decoded.role,
};
const user = await User.findOne({ where: { userId: decoded.userId } });
if (!user) {
return res.status(401).json({ message: "无效的 token" });
}
next();
} catch (error) {
res.status(401).json({ message: "无效的 token" });
}
};

View File

@@ -1,6 +1,10 @@
import { Request, Response, NextFunction } from "express";
import { Request, Response } from "express";
export const errorHandler = (err: any, req: Request, res: Response, next: NextFunction) => {
interface CustomError extends Error {
status?: number;
}
export const errorHandler = (err: CustomError, req: Request, res: Response): void => {
console.error(err);
res.status(err.status || 500).json({
success: false,

View File

@@ -1,6 +1,8 @@
import { Request, Response, NextFunction } from "express";
export const validateRequest = (requiredParams: string[]) => {
export const validateRequest = (
requiredParams: string[]
): ((req: Request, res: Response, next: NextFunction) => Response | void) => {
return (req: Request, res: Response, next: NextFunction) => {
const missingParams = requiredParams.filter((param) => !req.query[param] && !req.body[param]);
if (missingParams.length > 0) {

View File

@@ -0,0 +1,67 @@
import { DataTypes, Model, Optional } from "sequelize";
import sequelize from "../config/database";
export interface GlobalSettingAttributes {
id: number;
httpProxyHost: string;
httpProxyPort: number;
isProxyEnabled: boolean;
CommonUserCode: number;
AdminUserCode: number;
}
interface GlobalSettingCreationAttributes extends Optional<GlobalSettingAttributes, "id"> {}
class GlobalSetting
extends Model<GlobalSettingAttributes, GlobalSettingCreationAttributes>
implements GlobalSettingAttributes
{
public id!: number;
public httpProxyHost!: string;
public httpProxyPort!: number;
public isProxyEnabled!: boolean;
public CommonUserCode!: number;
public AdminUserCode!: number;
}
GlobalSetting.init(
{
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
httpProxyHost: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: "127.0.0.1",
},
httpProxyPort: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 7890,
},
isProxyEnabled: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
CommonUserCode: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 9527,
},
AdminUserCode: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 230713,
},
},
{
sequelize,
modelName: "GlobalSetting",
tableName: "global_settings",
}
);
export default GlobalSetting;

View File

@@ -0,0 +1,62 @@
import { DataTypes, Model, Optional } from "sequelize";
import sequelize from "../config/database";
interface UserAttributes {
id: number;
userId?: number;
username: string;
password: string;
role: number; // 修改为数字类型
}
interface UserCreationAttributes extends Optional<UserAttributes, "id"> {}
class User extends Model<UserAttributes, UserCreationAttributes> implements UserAttributes {
public id!: number;
public userId!: number;
public username!: string;
public password!: string;
public role!: number; // 实现数字类型的角色属性
}
User.init(
{
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
allowNull: false, // 显式设置为不可为空
},
userId: {
type: DataTypes.UUID, // 对外暴露的不可预测ID
defaultValue: DataTypes.UUIDV4,
unique: true,
allowNull: false, // 显式设置为不可为空
},
username: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
},
password: {
type: DataTypes.STRING,
allowNull: false,
},
role: {
type: DataTypes.INTEGER, // 修改为数字类型
allowNull: false,
defaultValue: 0, // 默认值为普通用户
},
},
{
sequelize,
modelName: "User",
tableName: "users",
}
);
// 角色映射
// 0: 普通用户
// 1: 管理员
export default User;

View File

@@ -0,0 +1,72 @@
import { DataTypes, Model, Optional } from "sequelize";
import sequelize from "../config/database";
import User from "./User";
interface UserSettingAttributes {
id: number;
userId: string;
cloud115UserId?: string;
cloud115Cookie: string;
quarkCookie: string;
}
interface UserSettingCreationAttributes extends Optional<UserSettingAttributes, "id"> {}
class UserSetting
extends Model<UserSettingAttributes, UserSettingCreationAttributes>
implements UserSettingAttributes
{
public id!: number;
public userId!: string;
public cloud115UserId?: string;
public cloud115Cookie!: string;
public quarkCookie!: string;
}
UserSetting.init(
{
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
userId: {
type: DataTypes.UUID,
allowNull: false,
unique: true,
references: {
model: User,
key: "userId",
},
onDelete: "CASCADE",
},
cloud115UserId: {
type: DataTypes.STRING,
allowNull: true,
},
cloud115Cookie: {
type: DataTypes.STRING,
allowNull: true,
},
quarkCookie: {
type: DataTypes.STRING,
allowNull: true,
},
},
{
sequelize,
modelName: "UserSetting",
tableName: "user_settings",
}
);
User.hasOne(UserSetting, {
foreignKey: "userId",
as: "settings",
});
UserSetting.belongsTo(User, {
foreignKey: "userId",
as: "user",
});
export default UserSetting;

View File

@@ -2,12 +2,23 @@ import express from "express";
import { cloud115Controller } from "../controllers/cloud115";
import { quarkController } from "../controllers/quark";
import { resourceController } from "../controllers/resource";
import { doubanController } from "../controllers/douban";
import { imageControll } from "../controllers/teleImages";
import settingRoutes from "./setting";
import userRoutes from "./user";
const router = express.Router();
// 用户相关路由
router.use("/user", userRoutes);
router.use("/tele-images", imageControll.getImages);
// 设置相关路由
router.use("/setting", settingRoutes);
// 资源搜索
router.get("/search", resourceController.search);
router.get("/rssSearch", resourceController.rssSearch);
// 115网盘相关
router.get("/cloud115/share-info", cloud115Controller.getShareInfo);
@@ -19,4 +30,7 @@ router.get("/quark/share-info", quarkController.getShareInfo);
router.get("/quark/folders", quarkController.getFolderList);
router.post("/quark/save", quarkController.saveFile);
// 获取豆瓣热门列表
router.get("/douban/hot", doubanController.getDoubanHotList);
export default router;

View File

@@ -0,0 +1,9 @@
import express from "express";
import { settingController } from "../controllers/setting";
const router = express.Router();
router.get("/get", settingController.get);
router.post("/save", settingController.save);
export default router;

View File

@@ -0,0 +1,10 @@
// backend/src/routes/user.ts
import express from "express";
import { userController } from "../controllers/user";
const router = express.Router();
router.post("/register", userController.register);
router.post("/login", userController.login);
export default router;

View File

@@ -4,14 +4,28 @@ import { Logger } from "../utils/logger";
import { config } from "../config/index";
import { ShareInfoResponse } from "../types/cloud115";
interface Cloud115ListItem {
cid: string;
n: string;
s: number;
}
interface Cloud115FolderItem {
cid: string;
n: string;
ns: number;
}
interface Cloud115PathItem {
cid: string;
name: string;
}
export class Cloud115Service {
private api: AxiosInstance;
private cookie: string = "";
constructor(cookie: string) {
if (!cookie) {
throw new Error("115网盘需要提供cookie进行身份验证");
}
constructor(cookie?: string) {
this.api = createAxiosInstance(
"https://webapi.115.com",
AxiosHeaders.from({
@@ -29,91 +43,80 @@ export class Cloud115Service {
Referer: "https://servicewechat.com/wx2c744c010a61b0fa/94/page-frame.html",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "zh-CN,zh;q=0.9",
cookie: cookie,
})
);
if (cookie) {
this.setCookie(cookie);
} else {
console.log("请注意:115网盘需要提供cookie进行身份验证");
}
this.api.interceptors.request.use((config) => {
config.headers.cookie = cookie || this.cookie;
return config;
});
}
public setCookie(cookie: string): void {
this.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,
})),
},
};
}
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: false,
error: "未找到文件信息",
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "未知错误",
data: response.data.data.list.map((item: Cloud115ListItem) => ({
fileId: item.cid,
fileName: item.n,
fileSize: item.s,
})),
};
}
throw new Error("未找到文件信息");
}
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);
async getFolderList(
parentCid = "0"
): Promise<{ data: { cid: string; name: string; path: Cloud115PathItem[] }[] }> {
const response = await this.api.get("/files", {
params: {
aid: 1,
cid: parentCid,
o: "user_ptime",
asc: 1,
offset: 0,
show_dir: 1,
limit: 50,
type: 0,
format: "json",
star: 0,
suffix: "",
natsort: 0,
snap: 0,
record_open_time: 1,
fc_mix: 0,
},
});
if (response.data?.state) {
return {
success: false,
error: "获取115pan目录列表失败",
data: response.data.data
.filter((item: Cloud115FolderItem) => item.cid && !!item.ns)
.map((folder: Cloud115FolderItem) => ({
cid: folder.cid,
name: folder.n,
path: response.data.path,
})),
};
} else {
Logger.error("获取目录列表失败:", response.data.error);
throw new Error("获取115pan目录列表失败:" + response.data.error);
}
}
@@ -122,27 +125,24 @@ export class Cloud115Service {
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());
}): Promise<{ message: string; data: unknown }> {
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());
Logger.info("保存文件:", response.data);
if (response.data.state) {
return {
success: response.data.state,
error: response.data.error,
data: response.data,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "未知错误",
message: response.data.error,
data: response.data.data,
};
} else {
Logger.error("保存文件失败:", response.data.error);
throw new Error("保存115pan文件失败:" + response.data.error);
}
}
}

View File

@@ -0,0 +1,66 @@
import { AxiosHeaders, AxiosInstance } from "axios";
import { createAxiosInstance } from "../utils/axiosInstance";
interface DoubanSubject {
id: string;
title: string;
rate: string;
cover: string;
url: string;
is_new: boolean;
}
class DoubanService {
private baseUrl: string;
private api: AxiosInstance;
constructor() {
this.baseUrl = "https://movie.douban.com/j";
this.api = createAxiosInstance(
this.baseUrl,
AxiosHeaders.from({
accept: "*/*",
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
priority: "u=1, i",
"sec-ch-ua": '"Not A(Brand";v="8", "Chromium";v="132", "Microsoft Edge";v="132"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"x-requested-with": "XMLHttpRequest",
cookie:
'll="118282"; bid=StA6AQFsAWQ; _pk_id.100001.4cf6=6448be57b1b5ca7e.1723172321.; _vwo_uuid_v2=DC15B8183560FF1E538FFE1D480723310|c08e2d213ecb5510005f90a6ff332121; __utmv=30149280.6282; _vwo_uuid_v2=DC15B8183560FF1E538FFE1D480723310|c08e2d213ecb5510005f90a6ff332121; __utmz=30149280.1731915179.21.6.utmcsr=search.douban.com|utmccn=(referral)|utmcmd=referral|utmcct=/movie/subject_search; __utmz=223695111.1731915179.21.6.utmcsr=search.douban.com|utmccn=(referral)|utmcmd=referral|utmcct=/movie/subject_search; douban-fav-remind=1; __utmc=30149280; __utmc=223695111; _pk_ref.100001.4cf6=%5B%22%22%2C%22%22%2C1739176523%2C%22https%3A%2F%2Fsearch.douban.com%2Fmovie%2Fsubject_search%3Fsearch_text%3D%E8%84%91%E6%B4%9E%E5%A4%A7%E5%BC%80%26cat%3D1002%22%5D; _pk_ses.100001.4cf6=1; ap_v=0,6.0; __utma=30149280.859303574.1723448979.1739167503.1739176523.42; __utmb=30149280.0.10.1739176523; __utma=223695111.1882744177.1723448979.1739167503.1739176523.42; __utmb=223695111.0.10.1739176523',
Referer: "https://movie.douban.com/",
"Referrer-Policy": "unsafe-url",
})
);
}
async getHotList(params: {
type: string;
tag: string;
page_limit: string;
page_start: string;
}): Promise<{ data: DoubanSubject[] }> {
try {
const response = await this.api.get("/search_subjects", {
params: params,
});
if (response.data && response.data.subjects) {
return {
data: response.data.subjects,
};
} else {
return {
data: [],
};
}
} catch (error) {
console.error("Error fetching hot list:", error);
throw error;
}
}
}
export default DoubanService;

View File

@@ -2,18 +2,33 @@ import { AxiosInstance, AxiosHeaders } from "axios";
import { Logger } from "../utils/logger";
import { createAxiosInstance } from "../utils/axiosInstance";
interface QuarkShareInfo {
stoken?: string;
pwdId?: string;
fileSize?: number;
list: {
fid: string;
file_name: string;
file_type: number;
share_fid_token: string;
}[];
}
interface QuarkFolderItem {
fid: string;
file_name: string;
file_type: number;
}
export class QuarkService {
private api: AxiosInstance;
private cookie: string = "";
constructor(cookie: string) {
if (!cookie) {
throw new Error("115网盘需要提供cookie进行身份验证");
}
constructor(cookie?: string) {
this.api = createAxiosInstance(
"https://drive-h.quark.cn",
AxiosHeaders.from({
cookie: cookie,
cookie: this.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",
@@ -26,125 +41,115 @@ export class QuarkService {
"sec-fetch-site": "same-site",
})
);
if (cookie) {
this.setCookie(cookie);
} else {
console.log("请注意:夸克网盘需要提供cookie进行身份验证");
}
this.api.interceptors.request.use((config) => {
config.headers.cookie = cookie || this.cookie;
return config;
});
}
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,
};
}
public setCookie(cookie: string): void {
this.cookie = cookie;
}
async getShareInfo(pwdId: string, passcode = ""): Promise<{ data: QuarkShareInfo }> {
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) {
const res = await this.getShareList(pwdId, fileInfo.stoken);
return {
data: res,
};
}
}
throw new Error("获取夸克分享信息失败");
}
async getShareList(pwdId: string, stoken: string): Promise<QuarkShareInfo> {
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: QuarkShareInfo["list"][0]) => item.fid)
.map((folder: QuarkShareInfo["list"][0]) => ({
fileId: folder.fid,
fileName: folder.file_name,
fileIdToken: folder.share_fid_token,
}));
return {
success: false,
error: "未找到文件信息",
list,
pwdId,
stoken,
fileSize: response.data.data.share?.size || 0,
};
} catch (error) {
} else {
return {
success: false,
error: error instanceof Error ? error.message : "未知错误",
list: [],
};
}
}
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);
async getFolderList(
parentCid = "0"
): Promise<{ data: { cid: string; name: string; path: [] }[] }> {
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) {
const data = response.data.data.list
.filter((item: QuarkFolderItem) => item.fid && item.file_type === 0)
.map((folder: QuarkFolderItem) => ({
cid: folder.fid,
name: folder.file_name,
path: [],
}));
return {
success: false,
error: "获取夸克目录列表失败",
data,
};
} else {
const message = "获取夸克目录列表失败:" + response.data.error;
Logger.error(message);
throw new Error(message);
}
}
@@ -156,7 +161,7 @@ export class QuarkService {
stoken: string;
pdir_fid: string;
scene: string;
}) {
}): Promise<{ message: string; data: unknown }> {
try {
const response = await this.api.post(
`/1/clouddrive/share/sharepage/save?pr=ucpro&fr=pc&uc_param_str=&__dt=208097&__t=${Date.now()}`,
@@ -164,15 +169,11 @@ export class QuarkService {
);
return {
success: response.data.code === 0,
error: response.data.message,
message: response.data.message,
data: response.data.data,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "未知错误",
};
throw new Error(error instanceof Error ? error.message : "未知错误");
}
}
}

View File

@@ -1,112 +0,0 @@
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: [],
};
}
}
}

View File

@@ -1,5 +1,7 @@
import { AxiosInstance, AxiosHeaders } from "axios";
import { createAxiosInstance } from "../utils/axiosInstance";
import GlobalSetting from "../models/GlobalSetting";
import { GlobalSettingAttributes } from "../models/GlobalSetting";
import * as cheerio from "cheerio";
import { config } from "../config";
import { Logger } from "../utils/logger";
@@ -7,19 +9,30 @@ import { Logger } from "../utils/logger";
interface sourceItem {
messageId?: string;
title?: string;
completeTitle?: string;
link?: string;
pubDate?: string;
content?: string;
description?: string;
image?: string;
cloudLinks?: string[];
tags?: string[];
cloudType?: string;
}
export class Searcher {
private axiosInstance: AxiosInstance;
private axiosInstance: AxiosInstance | null = null;
constructor() {
this.initializeAxiosInstance();
}
private async initializeAxiosInstance(isUpdate = false): Promise<void> {
let settings = null;
if (isUpdate) {
settings = await GlobalSetting.findOne();
}
const globalSetting = settings?.dataValues || ({} as GlobalSettingAttributes);
this.axiosInstance = createAxiosInstance(
config.telegram.baseUrl,
AxiosHeaders.from({
@@ -37,9 +50,15 @@ export class Searcher {
"sec-fetch-user": "?1",
"upgrade-insecure-requests": "1",
}),
true
globalSetting?.isProxyEnabled,
globalSetting?.isProxyEnabled
? { host: globalSetting?.httpProxyHost, port: globalSetting?.httpProxyPort }
: undefined
);
}
public async updateAxiosInstance() {
await this.initializeAxiosInstance(true);
}
private extractCloudLinks(text: string): { links: string[]; cloudType: string } {
const links: string[] = [];
@@ -59,7 +78,6 @@ export class Searcher {
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)
@@ -71,7 +89,7 @@ export class Searcher {
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);
const results = await this.searchInWeb(url);
console.log(`Found ${results.items.length} items in channel ${channel.name}`);
if (results.items.length > 0) {
const channelResults = results.items
@@ -82,22 +100,38 @@ export class Searcher {
channelId: channel.id,
}));
allResults.push(...channelResults);
allResults.push({
list: channelResults,
channelInfo: {
...channel,
channelLogo: results.channelLogo,
},
id: channel.id,
});
}
} catch (error) {
Logger.error(`搜索频道 ${channel.name} 失败:`, error);
}
}
return allResults;
return {
data: allResults,
};
}
async searchInWeb(url: string, channelId: string) {
async searchInWeb(url: string) {
try {
if (!this.axiosInstance) {
throw new Error("Axios instance is not initialized");
}
const response = await this.axiosInstance.get(url);
const html = response.data;
const $ = cheerio.load(html);
const items: sourceItem[] = [];
let channelLogo = "";
$(".tgme_header_link").each((_, element) => {
channelLogo = $(element).find("img").attr("src") || "";
});
// 遍历每个消息容器
$(".tgme_widget_message_wrap").each((_, element) => {
const messageEl = $(element);
@@ -109,8 +143,24 @@ export class Searcher {
?.toString()
.split("/")[1];
// 提取标题 (消息截取100长度)
const title = messageEl.find(".js-message_text").text().trim().substring(0, 50) + "...";
// 提取标题 (第一个<br>标签前的内容)
const title =
messageEl
.find(".js-message_text")
.html()
?.split("<br>")[0]
.replace(/<[^>]+>/g, "")
.replace(/\n/g, "") || "";
// 提取描述 (第一个<a>标签前面的内容,不包含标题)
const content =
messageEl
.find(".js-message_text")
.html()
?.replace(title, "")
.split("<a")[0]
.replace(/<br>/g, "")
.trim() || "";
// 提取链接 (消息中的链接)
// const link = messageEl.find('.tgme_widget_message').data('post');
@@ -118,23 +168,24 @@ export class Searcher {
// 提取发布时间
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 tags: string[] = [];
// 提取云盘链接
const links = messageEl
.find(".tgme_widget_message_text a")
.map((_, el) => $(el).attr("href"))
.get();
messageEl.find(".tgme_widget_message_text a").each((index, element) => {
const tagText = $(element).text();
if (tagText && tagText.startsWith("#")) {
tags.push(tagText);
}
});
const cloudInfo = this.extractCloudLinks(links.join(" "));
// 添加到数组第一位
items.unshift({
@@ -142,18 +193,21 @@ export class Searcher {
title,
pubDate,
content,
description,
image,
cloudLinks: cloudInfo.links,
cloudType: cloudInfo.cloudType,
tags,
});
});
return { items };
return { items: items, channelLogo };
} catch (error) {
Logger.error(`RSS源解析错误: ${url}`, error);
Logger.error(`搜索错误: ${url}`, error);
return {
items: [],
channelLogo: "",
};
}
}
}
export default new Searcher();

View File

@@ -5,9 +5,6 @@ export interface ShareInfo {
}
export interface ShareInfoResponse {
success: boolean;
data?: {
list: ShareInfo[];
};
error?: string;
data?: ShareInfo[];
message?: string;
}

View File

@@ -0,0 +1,11 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { Request } from "express";
declare module "express" {
interface Request {
user?: {
userId: string;
role: number;
};
}
}

View File

@@ -1,20 +1,22 @@
import axios, { AxiosInstance, AxiosRequestHeaders } from "axios";
import tunnel from "tunnel";
import { config } from "../config";
interface ProxyConfig {
host: string;
port: number;
}
export function createAxiosInstance(
baseURL: string,
headers: AxiosRequestHeaders,
useProxy: boolean = false
useProxy: boolean = false,
proxyConfig?: ProxyConfig
): AxiosInstance {
let agent;
if (useProxy) {
console.log(proxyConfig);
if (useProxy && proxyConfig) {
agent = tunnel.httpsOverHttp({
proxy: {
host: config.httpProxy.host,
port: Number(config.httpProxy.port),
},
proxy: proxyConfig,
});
}

View File

@@ -1,8 +1,15 @@
import { Response, NextFunction } from "express";
import { Logger } from "../utils/logger";
interface CustomError {
name?: string;
message: string;
success?: boolean;
}
export default function handleError(
res: Response,
error: any,
error: CustomError | unknown,
message: string,
next: NextFunction
) {

View File

@@ -0,0 +1,21 @@
import jwt from "jsonwebtoken";
import { Request } from "express";
import { config } from "../config";
interface JwtPayload {
userId: string;
}
export function getUserIdFromToken(req: Request): string | null {
try {
const token = req.headers.authorization?.split(" ")[1];
if (!token) {
throw new Error("Token not found");
}
const decoded = jwt.verify(token, config.jwtSecret) as JwtPayload;
return decoded.userId;
} catch (error) {
console.error("Invalid token:", error);
return null;
}
}

View File

@@ -0,0 +1,17 @@
import { Response } from "express";
interface ResponseData {
code?: number; // 业务状态码
message?: string;
data?: any;
}
export const sendSuccess = (res: Response, response: ResponseData, businessCode: number = 0) => {
response.code = businessCode;
res.status(200).json(response);
};
export const sendError = (res: Response, response: ResponseData, businessCode: number = 10000) => {
response.code = businessCode;
res.status(200).json(response);
};

View File

@@ -12,6 +12,7 @@
"moduleResolution": "node",
"resolveJsonModule": true,
"baseUrl": ".",
"typeRoots": ["./node_modules/@types", "./src/types"],
"paths": {
"@/*": ["src/*"]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

BIN
docs/images/hotmovie.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

BIN
docs/images/login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
docs/images/register.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
docs/images/save.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
docs/images/search.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 KiB

BIN
docs/images/setting.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
docs/images/wechat.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

View File

@@ -5,5 +5,5 @@
// Generated by unplugin-auto-import
export {}
declare global {
const ElMessage: typeof import('element-plus/es')['ElMessage']
}

View File

@@ -7,22 +7,41 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
AsideMenu: typeof import('./src/components/AsideMenu.vue')['default']
copy: typeof import('./src/components/Home/FolderSelect copy.vue')['default']
DoubanMovie: typeof import('./src/components/Home/DoubanMovie.vue')['default']
ElAside: typeof import('element-plus/es')['ElAside']
ElBacktop: typeof import('element-plus/es')['ElBacktop']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElImage: typeof import('element-plus/es')['ElImage']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTree: typeof import('element-plus/es')['ElTree']
FolderSelect: typeof import('./src/components/FolderSelect.vue')['default']
ResourceList: typeof import('./src/components/ResourceList.vue')['default']
FolderSelect: typeof import('./src/components/Home/FolderSelect.vue')['default']
ResourceCard: typeof import('./src/components/Home/ResourceCard.vue')['default']
ResourceSelect: typeof import('./src/components/Home/ResourceSelect.vue')['default']
ResourceTable: typeof import('./src/components/Home/ResourceTable.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SearchBar: typeof import('./src/components/SearchBar.vue')['default']

BIN
frontend/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -3,7 +3,12 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 移动端适配 -->
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"
/>
<meta name="referrer" content="no-referrer" />
<title>CloudSaver</title>
</head>
<body>

View File

@@ -20,6 +20,7 @@
"devDependencies": {
"@types/node": "^20.11.25",
"@vitejs/plugin-vue": "^5.0.4",
"sass": "^1.83.4",
"typescript": "^5.4.2",
"unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.26.0",

View File

@@ -8,4 +8,29 @@
#app {
height: 100vh;
}
:root {
--theme-color: #3e3e3e;
--theme-theme: #133ab3;
--theme-background: #fafafa;
--theme-other_background: #ffffff;
}
html,
body {
margin: 0;
font-size: 15px;
font-family:
v-sans,
system-ui,
-apple-system,
BlinkMacSystemFont,
Segoe UI,
sans-serif,
"Apple Color Emoji",
"Segoe UI Emoji",
Segoe UI Symbol;
line-height: 1.6;
color: var(--theme-color);
background-color: var(--theme-background);
word-wrap: break-word;
}
</style>

View File

@@ -2,22 +2,22 @@ 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", {
async getShareInfo(shareCode: string, receiveCode = "") {
const { data } = await request.get<ShareInfoResponse>("/api/cloud115/share-info", {
params: { shareCode, receiveCode },
});
return data;
return data as ShareInfoResponse;
},
async getFolderList(parentCid = "0"): Promise<{ data: Folder[] }> {
const { data } = await request.get("/api/cloud115/folders", {
async getFolderList(parentCid = "0") {
const res = await request.get<Folder[]>("/api/cloud115/folders", {
params: { parentCid },
});
return data;
return res;
},
async saveFile(params: Save115FileParams) {
const { data } = await request.post("/api/cloud115/save", params);
return data;
const res = await request.post("/api/cloud115/save", params);
return res;
},
};

View File

@@ -0,0 +1,11 @@
import request from "@/utils/request";
import { HotListItem, HotListParams } from "@/types/douban";
export const doubanApi = {
async getHotList(params: HotListParams) {
const { data } = await request.get<HotListItem[]>("/api/douban/hot", {
params,
});
return data;
},
};

View File

@@ -2,22 +2,21 @@ 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", {
async getShareInfo(pwdId: string, passcode = "") {
const { data } = await request.get<ShareInfoResponse>("/api/quark/share-info", {
params: { pwdId, passcode },
});
return data;
return data as ShareInfoResponse;
},
async getFolderList(parentCid = "0"): Promise<{ data: Folder[] }> {
const { data } = await request.get("/api/quark/folders", {
async getFolderList(parentCid = "0") {
const data = await request.get<Folder[]>("/api/quark/folders", {
params: { parentCid },
});
return data;
},
async saveFile(params: SaveQuarkFileParams) {
const { data } = await request.post("/api/quark/save", params);
return data;
return await request.post("/api/quark/save", params);
},
};

View File

@@ -2,8 +2,8 @@ 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"}`, {
search(keyword: string, channelId?: string, lastMessageId?: string) {
return request.get<Resource[]>(`/api/search`, {
params: { keyword, channelId, lastMessageId },
});
},

View File

@@ -0,0 +1,17 @@
import request from "@/utils/request";
import type { GlobalSettingAttributes, UserSettingAttributes } from "@/types";
export const settingApi = {
getSetting: () => {
return request.get<{
userSettings: UserSettingAttributes;
globalSetting: GlobalSettingAttributes;
}>("/api/setting/get");
},
saveSetting: (data: {
userSettings: UserSettingAttributes;
globalSetting?: GlobalSettingAttributes | null;
}) => {
return request.post("/api/setting/save", data);
},
};

10
frontend/src/api/user.ts Normal file
View File

@@ -0,0 +1,10 @@
import request from "@/utils/request";
export const userApi = {
login: (data: { username: string; password: string }) => {
return request.post<{ token: string }>("/api/user/login", data);
},
register: (data: { username: string; password: string }) => {
return request.post<{ token: string }>("/api/user/register", data);
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

View File

@@ -0,0 +1,149 @@
<template>
<div class="aside-menu">
<div class="logo">
<img :src="logo" class="logo-img" />
<div class="logo-text">Cloud Saver</div>
</div>
<el-menu
:default-active="currentMenu?.index || '1'"
:default-openeds="currentMenuOpen"
class="el-menu-vertical"
@open="handleOpen"
@close="handleClose"
>
<template v-for="menu in menuList">
<el-sub-menu v-if="menu.children" :key="menu.index" :index="menu.index">
<template #title>
<el-icon><component :is="menu.icon" /></el-icon>
<span>{{ menu.title }}</span>
</template>
<el-menu-item
v-for="child in menu.children"
:key="child.index"
:index="child.index"
@click="handleMenuClick(child)"
>
{{ child.title }}
</el-menu-item>
</el-sub-menu>
<el-menu-item
v-else
:key="menu.router"
:index="menu.index"
:disabled="menu.disabled"
@click="handleMenuClick(menu)"
>
<el-icon><component :is="menu.icon" /></el-icon>
<span>{{ menu.title }}</span>
</el-menu-item>
</template>
</el-menu>
</div>
</template>
<script lang="ts" setup>
import { Search, Film, Setting } from "@element-plus/icons-vue";
import logo from "@/assets/images/logo.png";
import { useRouter, useRoute } from "vue-router";
import { computed } from "vue";
const router = useRouter();
const route = useRoute();
interface MenuItem {
index: string;
title: string;
icon?: typeof Search | typeof Film | typeof Setting;
router?: string;
children?: MenuItem[];
disabled?: boolean;
}
const menuList: MenuItem[] = [
{
index: "2",
title: "资源搜索",
icon: Search,
router: "/",
},
{
index: "1",
title: "豆瓣榜单",
icon: Film,
children: [
{
index: "1-1",
title: "热门电影",
router: "/douban?type=movie",
},
{
index: "1-2",
title: "热门电视剧",
router: "/douban?type=tv",
},
{
index: "1-3",
title: "最新电影",
router: "/douban?type=movie&tag=最新",
},
{
index: "1-4",
title: "热门综艺",
router: "/douban?type=tv&tag=综艺",
},
],
},
{
index: "3",
title: "设置",
icon: Setting,
router: "/setting",
disabled: false,
},
];
const currentMenu = computed(() => {
return menuList
.reduce((pre: MenuItem[], cur: MenuItem) => {
if (!cur.children) {
pre.push(cur);
} else {
pre.push(...cur.children);
}
return pre;
}, [])
.find((x) => x.router === decodeURIComponent(route.fullPath));
});
const currentMenuOpen = computed(() => {
if (currentMenu.value && currentMenu.value.index.length > 1) {
return [currentMenu.value.index.split("-")[0]];
} else {
return [];
}
});
const handleOpen = (_key: string, _keyPath: string[]): void => {};
const handleClose = (_key: string, _keyPath: string[]): void => {};
const handleMenuClick = (menu: MenuItem): void => {
if (menu.router) {
router.push(menu.router);
}
};
</script>
<style lang="scss" scoped>
.el-menu-vertical {
width: 100%;
height: 100vh;
}
.logo {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 0;
.logo-img {
width: 30px;
height: 30px;
margin-right: 15px;
}
.logo-text {
font-size: 20px;
}
}
</style>

View File

@@ -1,134 +0,0 @@
<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>

View File

@@ -0,0 +1,138 @@
<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
highlight-current
@node-click="handleNodeClick"
>
<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 { type RequestResult } from "@/types/response";
import { useResourceStore } from "@/stores/resource";
const resourceStore = useResourceStore();
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,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const loadNode = async (node: any, resolve: (list: Folder[]) => void) => {
const api = cloudTypeApiMap[props.cloudType as keyof typeof cloudTypeApiMap];
try {
let res: RequestResult<Folder[]> = { code: 0, data: [] as Folder[], message: "" };
resourceStore.setLoadTree(true);
if (node.level === 0) {
if (api.getFolderList) {
// 使用类型保护检查方法是否存在
res = await api.getFolderList();
}
} else {
if (api.getFolderList) {
// 使用类型保护检查方法是否存在
res = await api.getFolderList(node.data.cid);
}
}
if (res?.code === 0) {
resolve(res.data.length ? res.data : []);
} else {
throw new Error(res.message);
}
resourceStore.setLoadTree(false);
} catch (error) {
ElMessage.error(error instanceof Error ? `${error.message}` : "获取目录失败");
// 关闭模态框
emit("close");
resourceStore.setLoadTree(false);
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>

View File

@@ -0,0 +1,189 @@
<template>
<div class="resource-card-list">
<div v-for="group in store.resources" :key="group.id" class="resource-list">
<div class="group-header">
<el-link :href="`https://t.me/s/${group.id}`" target="_blank" :underline="false">
<el-image :src="group.channelInfo.channelLogo" class="channel-logo" fit="cover" lazy />
{{ group.channelInfo.name }}</el-link
>
<el-icon class="header-icon" @click="group.displayList = !group.displayList"
><ArrowDown
/></el-icon>
</div>
<div v-show="group.displayList" class="card-item-list">
<div v-for="resource in group.list" :key="resource.messageId" class="card-item-content">
<el-card class="card-item">
<el-image
class="card-item-image"
:src="`/tele-images/?url=${encodeURIComponent(resource.image as string)}`"
fit="cover"
lazy
:alt="resource.title"
hide-on-click-modal
:preview-src-list="[
`${location.origin}/tele-images/?url=${encodeURIComponent(resource.image as string)}`,
]"
/>
<el-link :href="resource.cloudLinks[0]" target="_blank" :underline="false"
><div class="item-name">{{ resource.title }}</div></el-link
>
<div class="item-description" v-html="resource.content"></div>
<div v-if="resource.tags && resource.tags.length" class="tags-list">
<span>标签</span>
<el-tag
v-for="item in resource.tags"
:key="item"
class="resource_tag"
@click="searchMovieforTag(item)"
>
{{ item }}
</el-tag>
</div>
<template #footer>
<div class="item-footer">
<el-tag
:type="store.tagColor[resource.cloudType as keyof TagColor]"
effect="dark"
round
>
{{ resource.cloudType }}
</el-tag>
<el-button @click="handleSave(resource)">转存</el-button>
</div>
</template>
</el-card>
</div>
</div>
<div v-show="group.displayList" class="load-more">
<el-button @click="handleLoadMore(group.id)"> 加载更多 </el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useResourceStore } from "@/stores/resource";
import { computed } from "vue";
import type { ResourceItem, TagColor } from "@/types";
const store = useResourceStore();
const location = computed(() => window.location);
const emit = defineEmits(["save", "loadMore", "searchMovieforTag"]);
const handleSave = (resource: ResourceItem) => {
emit("save", resource);
};
const searchMovieforTag = (tag: string) => {
emit("searchMovieforTag", tag);
};
const handleLoadMore = (channelId: string) => {
emit("loadMore", channelId);
};
</script>
<style scoped>
.resource-list {
margin-bottom: 30px;
padding: 20px;
border-radius: 15px;
background-color: var(--theme-other_background);
&:last-child {
margin-bottom: 0;
}
}
.card-item-list {
display: grid;
grid-template-columns: repeat(auto-fill, 220px);
grid-row-gap: 30px;
justify-content: space-between;
margin-top: 20px;
/* grid-column-gap: auto-fill; */
/* flex-wrap: wrap; */
}
.card-item-content {
/* height: 520px; */
}
.channel-logo {
height: 40px;
width: 40px;
border-radius: 50%;
overflow: hidden;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
margin-right: 10px;
}
.load-more {
margin-top: 40px;
width: 100%;
text-align: center;
}
.card-item {
max-width: 480px;
height: 100%;
border-radius: 20px;
}
.card-item-image {
border-radius: 20px;
width: 100%;
height: 220px;
}
.item-name,
.item-description {
max-width: 100%;
margin: 15px 0;
-webkit-box-orient: vertical;
display: -webkit-box;
-webkit-line-clamp: 2;
overflow: hidden;
white-space: all;
}
.item-description {
-webkit-line-clamp: 4;
margin-top: 0;
height: 100px;
}
.item-name {
height: 58px;
font-size: 18px;
}
.tags-list {
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
height: 58px;
overflow: hidden;
}
.resource_tag {
cursor: pointer;
margin-right: 10px;
margin-bottom: 5px;
}
.group-header {
height: 50px;
line-height: 50px;
text-align: left;
padding: 0 15px;
font-size: 20px;
display: flex;
align-items: center;
justify-content: space-between;
/* text-align: center; */
.el-link {
font-size: 22px;
}
.header-icon {
cursor: pointer;
width: 50px;
height: 50px;
}
}
.item-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<div class="folder-select">
<el-tree
ref="treeRef"
:data="resourceStore.shareInfo.list"
:props="defaultProps"
:default-checked-keys="resourceStore.shareInfo.list?.map((x) => x.fileId) || []"
node-key="fileId"
show-checkbox
highlight-current
@check-change="handleCheckChange"
>
<template #default="{ node }">
<span class="folder-node">
<el-icon><Folder /></el-icon>
{{ node.data.fileName }}
<span v-if="node.data.fileSize" style="font-weight: bold"
>({{ formattedFileSize(node.data.fileSize) }})</span
>
</span>
</template>
</el-tree>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useResourceStore } from "@/stores/resource";
import { formattedFileSize } from "@/utils/index";
import type { ShareInfo } from "@/types";
const resourceStore = useResourceStore();
const selectedResource = ref<ShareInfo[]>([]);
const defaultProps = {
isLeaf: "leaf",
};
const handleCheckChange = (data: ShareInfo) => {
selectedResource.value = [...resourceStore.resourceSelect, ...selectedResource.value];
if (selectedResource.value.findIndex((x) => x.fileId === data.fileId) === -1) {
selectedResource.value.push(data);
} else {
selectedResource.value = selectedResource.value.filter((x) => x.fileId !== data.fileId);
}
resourceStore.setSelectedResource(selectedResource.value);
};
</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>

View File

@@ -0,0 +1,198 @@
<template>
<el-table
v-loading="store.loading"
class="resource-list-table"
:data="store.resources"
style="width: 100%"
row-key="id"
:default-expand-all="true"
>
<el-table-column type="expand">
<template #default="props">
<el-table :data="props.row.list" style="width: 100%">
<el-table-column label="图片" width="180">
<template #default="{ row }">
<el-image
v-if="row.image"
class="table-item-image"
:src="`/tele-images/?url=${encodeURIComponent(row.image as string)}`"
hide-on-click-modal
:preview-src-list="[
`${location.origin}/tele-images/?url=${encodeURIComponent(row.image as string)}`,
]"
: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">
<template #default="{ row }">
<el-link :href="row.cloudLinks[0]" target="_blank">
{{ row.title }}
</el-link>
</template>
</el-table-column>
<el-table-column prop="title" label="描述">
<template #default="{ row }">
<div class="item-description" v-html="row.content"></div>
</template>
</el-table-column>
<el-table-column prop="tags" label="标签">
<template #default="{ row }">
<div v-if="row.tags.length > 0" class="tags-list">
<span>标签</span>
<el-tag
v-for="item in row.tags"
:key="item"
class="resource_tag"
@click="searchMovieforTag(item)"
>
{{ item }}
</el-tag>
</div>
<span v-else></span>
</template>
</el-table-column>
<!-- <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="store.tagColor[row.cloudType as keyof 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 class="load-more">
<el-button :loading="props.row.loading" @click="handleLoadMore(props.row.id)">
加载更多
</el-button>
</div>
</template>
</el-table-column>
<el-table-column label="来源" prop="channel">
<template #default="{ row }">
<div class="group-header">
<el-image :src="row.channelInfo.channelLogo" class="channel-logo" fit="cover" lazy />
<span>{{ row.channelInfo.name }}</span>
<span class="item-count">({{ row.list.length }})</span>
</div>
</template>
</el-table-column>
</el-table>
</template>
<script setup lang="ts">
import { useResourceStore } from "@/stores/resource";
import type { Resource, TagColor } from "@/types";
import { computed } from "vue";
const store = useResourceStore();
const emit = defineEmits(["save", "loadMore", "searchMovieforTag"]);
const location = computed(() => window.location);
const handleSave = (resource: Resource) => {
emit("save", resource);
};
// 添加加载更多处理函数
const handleLoadMore = (channelId: string) => {
emit("loadMore", channelId);
};
const searchMovieforTag = (tag: string) => {
emit("searchMovieforTag", tag);
};
</script>
<style scoped>
.resource-list-table {
border-radius: 15px;
}
.group-header {
display: flex;
align-items: center;
gap: 8px;
}
.channel-logo {
width: 20px;
height: 20px;
margin-right: 10px;
border-radius: 50%;
overflow: hidden;
}
.table-item-image {
border-radius: 20px;
width: 100%;
height: 220px;
}
.item-count {
color: #909399;
font-size: 0.9em;
}
.tags-list {
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
}
.resource_tag {
cursor: pointer;
margin-right: 10px;
margin-bottom: 5px;
}
.item-description {
max-width: 100%;
margin: 15px 0;
-webkit-box-orient: vertical;
display: -webkit-box;
line-clamp: 4;
-webkit-line-clamp: 4;
overflow: hidden;
white-space: all;
}
: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>

View File

@@ -1,221 +0,0 @@
<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>

View File

@@ -1,70 +1,115 @@
<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 class="search-bar__input">
<input
v-model="keyword"
type="text"
placeholder="请输入搜索关键词或输入链接直接解析"
class="input-with-select"
@keyup.enter="handleSearch"
/>
<el-icon class="search_icon" size="28px" @click="handleSearch"><Search /></el-icon class="search_icon">
</div>
<div class="logout" alt="退出登录" @click="logout">
<el-tooltip
class="box-item"
effect="dark"
content="退出登录"
placement="bottom"
>
<el-icon><SwitchButton class="logout-icon" /></el-icon>
</el-tooltip>
</div>
</div>
</template>
<script setup lang="ts">
import { effect, ref } from "vue";
import { computed, ref,watch } from "vue";
import { useResourceStore } from "@/stores/resource";
import { useRoute,useRouter } from "vue-router";
const route = useRoute();
const router = useRouter();
const resourcStore = useResourceStore();
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 routeKeyword = computed(() => route.query.keyword as string);
const logout = () => {
localStorage.removeItem("token");
router.push({ path: "/login" });
};
const handleSearch = async () => {
// 如果搜索内容是一个https的链接则尝试解析链接
if (keyword.value.startsWith("http")) {
store.parsingCloudLink(keyword.value);
resourcStore.parsingCloudLink(keyword.value);
return;
}
if (!keyword.value.trim()) {
return;
}
await store.searchResources(keyword.value, backupPlan.value);
await resourcStore.searchResources(keyword.value);
if(route.path !== '/'){
router.push({ path: '/' });
}
};
const handleSearchNew = async () => {
keyword.value = "";
await store.searchResources("", backupPlan.value);
};
watch(() => routeKeyword.value, () => {
if(routeKeyword.value){
keyword.value = routeKeyword.value;
handleSearch();
} else {
keyword.value = ''
}
});
</script>
<style scoped>
<style scoped lang="scss">
.search-bar {
padding: 20px;
display: flex;
align-items: center;
justify-content: space-between;
.logout{
height: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 28px;
margin-left: 15px;
cursor: pointer;
}
.search-bar__input {
width: 100%;
margin: 15px auto;
position: relative;
background-color: var(--theme-other_background);
box-shadow: 0 4px 10px rgba(225, 225, 225, 0.3);
height: 40px;
border-radius: 40px;
display: flex;
align-items: center;
}
.input-with-select {
width: 100%;
height: 100%;
background: none;
padding-left: 24px;
box-sizing: border-box;
border-radius: 40px;
line-height: 100%;
border: unset;
font-size: 18px;
}
.search_icon {
width: 64px;
height: 64px;
cursor: pointer;
}
.search-bar_tips{
width: 100%;
text-align: center;
margin: 20px auto;
}
}
.search-new {
@@ -79,4 +124,7 @@
.switch-source .label {
margin-left: 5px;
}
.display-style {
margin-left: 20px;
}
</style>

View File

@@ -2,7 +2,7 @@
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>;
export default component;
}

View File

@@ -2,15 +2,15 @@ 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 * 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.component(key, component);
}
app.use(createPinia());
app.use(router);

View File

@@ -1,12 +1,35 @@
import { createRouter, createWebHistory } from "vue-router";
import type { RouteRecordRaw } from "vue-router";
import HomeView from "@/views/HomeView.vue";
import Login from "@/views/Login.vue";
import Home from "@/views/Home.vue";
const routes: RouteRecordRaw[] = [
{
path: "/",
name: "home",
component: HomeView,
component: Home,
children: [
{
path: "",
name: "resource",
component: () => import("@/views/ResourceList.vue"),
},
{
path: "/douban",
name: "douban",
component: () => import("@/views/Douban.vue"),
},
{
path: "/setting",
name: "setting",
component: () => import("@/views/Setting.vue"),
},
],
},
{
path: "/login",
name: "login",
component: Login,
},
];

View File

@@ -0,0 +1,50 @@
import { defineStore } from "pinia";
import { doubanApi } from "@/api/douban";
import { HotListItem } from "@/types/douban";
import { ElMessage } from "element-plus";
interface StoreType {
hotList: HotListItem[];
currentParams: CurrentParams;
}
interface CurrentParams {
type: string;
tag?: string;
}
export const useDoubanStore = defineStore("douban", {
state: (): StoreType => ({
hotList: [],
currentParams: {
type: "movie",
tag: "热门",
},
}),
actions: {
async getHotList() {
try {
const params = {
type: this.currentParams.type,
tag: this.currentParams.tag || "热门",
page_limit: "20",
page_start: "0",
};
const result = await doubanApi.getHotList(params);
if (result && result.length > 0) {
this.hotList = result;
} else {
console.log("获取热门列表失败");
ElMessage.warning("获取热门列表失败");
}
} catch (error) {
ElMessage.error(error || "获取热门列表失败");
}
},
setCurrentParams(currentParams: CurrentParams) {
this.currentParams = currentParams;
this.getHotList();
},
},
});

View File

@@ -0,0 +1,17 @@
import { defineStore } from "pinia";
interface StoreType {
scrollTop: boolean;
}
export const useStore = defineStore("global", {
state: (): StoreType => ({
scrollTop: true,
}),
actions: {
setScrollTop(top: boolean) {
this.scrollTop = top;
},
},
});

View File

@@ -2,41 +2,67 @@ 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 type {
Resource,
ShareInfoResponse,
Save115FileParams,
SaveQuarkFileParams,
ShareInfo,
ResourceItem,
} from "@/types";
import { ElMessage } from "element-plus";
interface StorageListObject {
list: Resource[];
lastUpdateTime?: string;
}
const lastResource = (
localStorage.getItem("last_resource_list")
? JSON.parse(localStorage.getItem("last_resource_list") as string)
: { list: [] }
) as StorageListObject;
// 定义云盘驱动配置类型
interface CloudDriveConfig {
interface CloudDriveConfig<
T extends Record<string, string>,
P extends Save115FileParams | SaveQuarkFileParams,
> {
name: string;
type: string;
regex: RegExp;
api: {
getShareInfo: (parsedCode: any) => Promise<ShareInfoResponse>;
saveFile: (params: Record<string, any>) => Promise<{ success: boolean; error?: string }>;
getShareInfo: (parsedCode: T) => Promise<ShareInfoResponse>;
saveFile: (params: P) => Promise<{ code: number; message?: string }>;
};
parseShareCode: (match: RegExpMatchArray) => Record<string, string>;
getSaveParams: (shareInfo: ShareInfoResponse, folderId: string) => Record<string, any>;
parseShareCode: (match: RegExpMatchArray) => T;
getSaveParams: (shareInfo: ShareInfoResponse, folderId: string) => P;
}
// 云盘类型配置
export const CLOUD_DRIVES: CloudDriveConfig[] = [
export const CLOUD_DRIVES: [
CloudDriveConfig<{ shareCode: string; receiveCode: string }, Save115FileParams>,
CloudDriveConfig<{ pwdId: string }, SaveQuarkFileParams>,
] = [
{
name: "115网盘",
type: "pan115",
regex: /(?:115|anxia)\.com\/s\/([^?]+)(?:\?password=([^#]+))?/,
regex: /(?:115|anxia|115cdn)\.com\/s\/([^?]+)(?:\?password=([^&#]+))?/,
api: {
getShareInfo: (parsedCode: { shareCode: string; receiveCode: string }) =>
cloud115Api.getShareInfo(parsedCode.shareCode, parsedCode.receiveCode),
saveFile: (params) => cloud115Api.saveFile(params as Save115FileParams),
saveFile: async (params: Save115FileParams) => {
return await cloud115Api.saveFile(params as Save115FileParams);
},
},
parseShareCode: (match) => ({
parseShareCode: (match: RegExpMatchArray) => ({
shareCode: match[1],
receiveCode: match[2] || "",
}),
getSaveParams: (shareInfo, folderId) => ({
shareCode: shareInfo.data.shareCode,
receiveCode: shareInfo.data.receiveCode,
fileId: shareInfo.data.list[0].fileId,
getSaveParams: (shareInfo: ShareInfoResponse, folderId: string) => ({
shareCode: shareInfo.shareCode || "",
receiveCode: shareInfo.receiveCode || "",
fileId: shareInfo.list[0].fileId,
folderId,
}),
},
@@ -46,15 +72,19 @@ export const CLOUD_DRIVES: CloudDriveConfig[] = [
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),
saveFile: async (params: SaveQuarkFileParams) => {
return await 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 || ""),
parseShareCode: (match: RegExpMatchArray) => ({ pwdId: match[1] }),
getSaveParams: (shareInfo: ShareInfoResponse, folderId: string) => ({
fid_list: shareInfo.list.map((item: { fileId?: string }) => item.fileId || ""),
fid_token_list: shareInfo.list.map(
(item: { fileIdToken?: string }) => item.fileIdToken || ""
),
to_pdir_fid: folderId,
pwd_id: shareInfo.data.pwdId || "",
stoken: shareInfo.data.stoken || "",
pwd_id: shareInfo.pwdId || "",
stoken: shareInfo.stoken || "",
pdir_fid: "0",
scene: "link",
}),
@@ -63,76 +93,95 @@ export const CLOUD_DRIVES: CloudDriveConfig[] = [
export const useResourceStore = defineStore("resource", {
state: () => ({
resources: [] as Resource[],
selectedResources: [] as Resource[],
tagColor: {
baiduPan: "primary",
weiyun: "info",
aliyun: "warning",
pan115: "danger",
quark: "success",
},
resources: lastResource.list,
lastUpdateTime: lastResource.lastUpdateTime || "",
shareInfo: {} as ShareInfoResponse,
resourceSelect: [] as ShareInfo[],
loading: false,
lastKeyWord: "",
backupPlan: false,
loadTree: false,
}),
actions: {
setLoadTree(loadTree: boolean) {
this.loadTree = loadTree;
},
// 搜索资源
async searchResources(
keyword?: string,
backupPlan = false,
isLoadMore = false,
channelId?: string,
lastMessageId?: string
): Promise<void> {
async searchResources(keyword?: string, isLoadMore = false, channelId?: string): Promise<void> {
this.loading = true;
if (!isLoadMore) this.resources = [];
try {
let lastMessageId = "";
if (isLoadMore) {
const list = this.resources.find((x) => x.id === channelId)?.list || [];
lastMessageId = list[list.length - 1].messageId || "";
if (!lastMessageId) {
ElMessage.error("当次搜索源不支持加载更多");
return;
}
keyword = this.lastKeyWord;
backupPlan = this.backupPlan;
}
const { data } = await resourceApi.search(
keyword || "",
backupPlan,
channelId,
lastMessageId
);
let { data = [] } = await resourceApi.search(keyword || "", channelId, lastMessageId);
data = data.filter((item) => item.list.length > 0);
this.lastKeyWord = keyword || "";
if (isLoadMore) {
this.resources.push(
...data.filter(
(item) => !this.selectedResources.some((selectedItem) => selectedItem.id === item.id)
)
);
const findedIndex = this.resources.findIndex((item) => item.id === data[0]?.id);
if (findedIndex !== -1) {
this.resources[findedIndex].list.push(...data[0].list);
}
if (data.length === 0) {
ElMessage.warning("没有更多了~");
}
} else {
this.resources = data;
this.resources = data.map((item) => ({ ...item, displayList: true }));
if (this.resources.length === 0) {
ElMessage.warning("未搜索到相关资源");
}
}
// 获取当前时间字符串 用于存储到本地
this.lastUpdateTime = new Date().toLocaleString();
localStorage.setItem(
"last_resource_list",
JSON.stringify({ list: this.resources, lastUpdateTime: this.lastUpdateTime })
);
} catch (error) {
this.handleError("搜索失败,请重试", error);
this.handleError("搜索失败,请重试", null);
} finally {
this.loading = false;
}
},
// 设置选择资源
async setSelectedResource(resourceSelect: ShareInfo[]) {
this.resourceSelect = resourceSelect;
},
// 转存资源
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 saveResource(resource: ResourceItem, folderId: string): Promise<void> {
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);
},
// 保存资源到网盘
async saveResourceToDrive(
resource: Resource,
resource: ResourceItem,
folderId: string,
drive: CloudDriveConfig
drive:
| CloudDriveConfig<{ shareCode: string; receiveCode: string }, Save115FileParams>
| CloudDriveConfig<{ pwdId: string }, SaveQuarkFileParams>
): Promise<void> {
const link = resource.cloudLinks.find((link) => drive.regex.test(link));
if (!link) return;
@@ -140,28 +189,29 @@ export const useResourceStore = defineStore("resource", {
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 shareInfo = {
...this.shareInfo,
list: this.resourceSelect,
};
if (this.is115Drive(drive)) {
const params = drive.getSaveParams(shareInfo, folderId);
const result = await drive.api.saveFile(params);
if (result.success) {
if (result.code === 0) {
ElMessage.success(`${drive.name} 转存成功`);
} else {
throw new Error(result.error);
ElMessage.error(result.message);
}
} else {
const params = drive.getSaveParams(shareInfo, folderId);
const result = await drive.api.saveFile(params);
if (result.code === 0) {
ElMessage.success(`${drive.name} 转存成功`);
} else {
ElMessage.error(result.message);
}
} catch (error) {
throw new Error(error instanceof Error ? error.message : `${drive.name} 转存失败`);
}
},
@@ -177,17 +227,38 @@ export const useResourceStore = defineStore("resource", {
if (!match) throw new Error("链接解析失败");
const parsedCode = matchedDrive.parseShareCode(match);
const shareInfo = await matchedDrive.api.getShareInfo(parsedCode);
let shareInfo = this.is115Drive(matchedDrive)
? await matchedDrive.api.getShareInfo(
parsedCode as { shareCode: string; receiveCode: string }
)
: await matchedDrive.api.getShareInfo(parsedCode as { pwdId: string });
if (shareInfo?.data?.list?.length) {
if (Array.isArray(shareInfo)) {
shareInfo = {
list: shareInfo,
};
}
if (shareInfo?.list?.length) {
this.resources = [
{
id: "1",
title: shareInfo.data.list.map((item) => item.fileName).join(", "),
cloudLinks: [url],
cloudType: matchedDrive.type,
channel: matchedDrive.name,
pubDate: "",
id: "",
channelInfo: {
name: "自定义搜索",
channelLogo: "",
channelId: "",
},
displayList: true,
list: [
{
id: "1",
title: shareInfo.list.map((item) => item.fileName).join(", "),
cloudLinks: [url],
cloudType: matchedDrive.type,
channel: matchedDrive.name,
pubDate: "",
},
],
},
];
} else {
@@ -200,10 +271,65 @@ export const useResourceStore = defineStore("resource", {
}
},
// 获取资源列表并选择
async getResourceListAndSelect(resource: ResourceItem): Promise<boolean> {
const { cloudType } = resource;
const drive = CLOUD_DRIVES.find((x) => x.type === cloudType);
if (!drive) {
return false;
}
const link = resource.cloudLinks.find((link) => drive.regex.test(link));
if (!link) return false;
const match = link.match(drive.regex);
if (!match) throw new Error("链接解析失败");
const parsedCode = drive.parseShareCode(match);
let shareInfo = {} as ShareInfoResponse;
this.setLoadTree(true);
if (this.is115Drive(drive)) {
shareInfo = await drive.api.getShareInfo(
parsedCode as { shareCode: string; receiveCode: string }
);
} else {
shareInfo = this.is115Drive(drive)
? await drive.api.getShareInfo(parsedCode as { shareCode: string; receiveCode: string })
: await drive.api.getShareInfo(parsedCode as { pwdId: string });
}
this.setLoadTree(false);
if (shareInfo) {
if (Array.isArray(shareInfo)) {
shareInfo = {
list: shareInfo,
...parsedCode,
};
} else {
shareInfo = {
...shareInfo,
...parsedCode,
};
}
this.shareInfo = shareInfo;
this.setSelectedResource(this.shareInfo.list);
return true;
} else {
ElMessage.error("获取资源信息失败,请先检查cookie!");
return false;
}
},
// 统一错误处理
handleError(message: string, error: unknown): void {
console.error(message, error);
ElMessage.error(error instanceof Error ? error.message : message);
},
is115Drive(
drive:
| CloudDriveConfig<{ shareCode: string; receiveCode: string }, Save115FileParams>
| CloudDriveConfig<{ pwdId: string }, SaveQuarkFileParams>
): drive is CloudDriveConfig<{ shareCode: string; receiveCode: string }, Save115FileParams> {
return drive.type === "pan115";
},
},
});

View File

@@ -0,0 +1,42 @@
import { defineStore } from "pinia";
import type { UserSettingStore } from "@/types/user";
import { settingApi } from "@/api/setting";
import { ElMessage } from "element-plus";
export const useUserSettingStore = defineStore("user", {
state: (): UserSettingStore => ({
globalSetting: null,
userSettings: {
cloud115Cookie: "",
quarkCookie: "",
},
displayStyle: "card",
}),
actions: {
async getSettings() {
const { data } = await settingApi.getSetting();
if (data) {
this.globalSetting = data.globalSetting;
this.userSettings = data.userSettings;
}
},
async saveSettings() {
if (this.userSettings) {
const setting: UserSettingStore = {
userSettings: this.userSettings,
};
if (this.globalSetting) setting.globalSetting = this.globalSetting;
const res = await settingApi.saveSetting(setting);
if (res) {
this.getSettings();
ElMessage.success("保存成功");
}
}
},
setDisplayStyle(style: "table" | "card") {
this.displayStyle = style;
ElMessage.success(`切换成功,当前为${style}模式`);
},
},
});

View File

@@ -0,0 +1,11 @@
:root {
--theme-color: #3e3e3e;
--theme-theme: #133ab3;
--theme-background: #fafafa;
--theme-other_background: #ffffff;
}
input {
border: none;
outline: none;
}

View File

@@ -0,0 +1,18 @@
export interface HotListParams {
type: string;
tag?: string;
page_limit?: string;
page_start?: string;
}
export interface HotListItem {
cover: string;
cover_x: number;
cover_y: number;
episodes_info: string;
id: string;
is_new: boolean;
playable: boolean;
rate: string;
title: string;
url: string;
}

15
frontend/src/types/globals.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
declare global {
interface Location {
// 根据你的需求定义 location 的属性和方法
pathname: string;
search: string;
hash: string;
host: string;
// 其他属性和方法...
}
interface Window {
location: Location;
}
}
export {};

View File

@@ -1,29 +1,42 @@
export interface Resource {
export interface ResourceItem {
id: string;
title: string;
channel: string;
channelId?: string;
image?: string;
cloudLinks: string[];
tags?: string[];
content?: string;
pubDate: string;
cloudType: string;
messageId?: string;
}
export interface Resource {
list: ResourceItem[];
displayList?: boolean;
channelInfo: {
channelId: string;
name: string;
channelLogo: string;
};
id: string;
}
export interface ShareInfo {
fileId: string;
fileName: string;
fileSize: number;
fileSize?: number;
fileIdToken?: string;
}
export interface ShareInfoResponse {
data: {
list: ShareInfo[];
pwdId?: string;
stoken?: string;
shareCode?: string;
receiveCode?: string;
};
list: ShareInfo[];
pwdId?: string;
stoken?: string;
shareCode?: string;
receiveCode?: string;
fileSize?: number;
}
export interface Folder {
@@ -39,7 +52,7 @@ export interface SaveFileParams {
folderId: string;
}
export interface ApiResponse<T = any> {
export interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: string;
@@ -61,3 +74,23 @@ export interface SaveQuarkFileParams {
pdir_fid: string;
scene: string;
}
export interface TagColor {
baiduPan: string;
weiyun: string;
aliyun: string;
pan115: string;
quark: string;
}
export interface GlobalSettingAttributes {
httpProxyHost: string;
httpProxyPort: number | string;
isProxyEnabled: boolean;
AdminUserCode: number;
CommonUserCode: number;
}
export interface UserSettingAttributes {
cloud115Cookie: string;
quarkCookie: string;
}

View File

@@ -0,0 +1,15 @@
export type RequestErrorCode = -1 | 400 | 401 | 402 | 403 | 500 | 501;
export interface RequestSuccess<T> {
code: 0;
data: T;
message: string;
}
export interface RequestError<T> {
code: RequestErrorCode;
message: string;
data?: T;
}
export type RequestResult<T> = RequestSuccess<T> | RequestError<T>;

View File

@@ -0,0 +1,6 @@
import { GlobalSettingAttributes, UserSettingAttributes } from "@/types";
export interface UserSettingStore {
globalSetting?: GlobalSettingAttributes | null;
userSettings: UserSettingAttributes;
displayStyle?: "table" | "card";
}

View File

@@ -0,0 +1,9 @@
export const formattedFileSize = (size: number): string => {
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(2)}KB`;
}
if (size < 1024 * 1024 * 1024) {
return `${(size / 1024 / 1024).toFixed(2)}MB`;
}
return `${(size / 1024 / 1024 / 1024).toFixed(2)}GB`;
};

View File

@@ -1,28 +1,67 @@
import axios, { AxiosResponse } from "axios";
import axios, { AxiosResponse, AxiosRequestConfig } from "axios";
import { ElMessage } from "element-plus";
import { RequestResult } from "../types/response";
const request = axios.create({
const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL as string,
timeout: 60000,
timeout: 16000,
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 || "请求失败"));
function isLoginAndRedirect(url: string) {
return url.includes("/api/user/login") || url.includes("/api/user/register");
}
axiosInstance.interceptors.request.use(
(config) => {
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
} else if (!isLoginAndRedirect(config.url || "")) {
ElMessage.error("请先登录");
window.location.href = "/login";
}
return res;
return config;
},
(error) => {
ElMessage.error(error.message || "网络错误");
return Promise.reject(error);
}
);
axiosInstance.interceptors.response.use(
(response: AxiosResponse) => {
const res = response.data;
return res;
},
(error) => {
if (error.response.status === 401) {
ElMessage.error("登录过期,请重新登录");
localStorage.removeItem("token");
window.location.href = "/login";
return Promise.reject(new Error("登录过期,请重新登录"));
}
ElMessage.error(error.response.statusText);
return Promise.reject(new Error(error.response.statusText));
}
);
const request = {
get: <T>(url: string, config?: AxiosRequestConfig): Promise<RequestResult<T>> => {
return axiosInstance.get(url, { ...config });
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
post: <T, D = any>(
url: string,
data: D,
config?: AxiosRequestConfig
): Promise<RequestResult<T>> => {
return axiosInstance.post(url, data, { ...config });
},
put: axiosInstance.put,
delete: axiosInstance.delete,
};
export default request;

View File

@@ -0,0 +1,147 @@
<template>
<div class="movie-wall">
<div v-for="movie in doubanStore.hotList" :key="movie.id" class="movie-item">
<div class="movie-poster">
<el-image
class="movie-poster-img"
:src="movie.cover"
fit="cover"
lazy
:alt="movie.title"
hide-on-click-modal
:preview-src-list="[movie.cover]"
/>
<div class="movie-rate">
{{ movie.rate }}
</div>
<div class="movie-poster-hover" @click="searchMovie(movie.title)">
<div class="movie-search">
<el-icon class="search_icon" size="28px"><Search /></el-icon>
</div>
</div>
</div>
<div class="movie-info">
<el-link :href="movie.url" target="_blank" :underline="false" class="movie-title">{{
movie.title
}}</el-link>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, watch } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useDoubanStore } from "@/stores/douban";
interface CurrentParams {
type: string;
tag?: string;
}
const router = useRouter();
const route = useRoute();
const routeParams = computed((): CurrentParams => ({ ...route.query }) as unknown as CurrentParams);
const doubanStore = useDoubanStore();
if (routeParams.value) {
doubanStore.setCurrentParams(routeParams.value);
}
watch(
() => routeParams.value,
() => {
console.log(routeParams.value);
doubanStore.setCurrentParams(routeParams.value);
}
);
const searchMovie = (title: string) => {
router.push({ path: "/", query: { keyword: title } });
};
</script>
<style scoped>
.movie-wall {
display: grid;
grid-template-columns: repeat(auto-fill, 200px);
grid-row-gap: 15px;
justify-content: space-between;
}
.movie-item {
width: 200px; /* 设置固定宽度 */
overflow: hidden; /* 确保内容不会超出卡片 */
text-align: center;
background-color: #f9f9f9; /* 可选:设置背景颜色 */
box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.12); /* 可选:设置阴影效果 */
border-radius: 15px; /* 设置图片圆角 */
box-sizing: border-box;
padding-bottom: 0px;
position: relative;
padding: 15px;
padding-bottom: 0;
}
.movie-poster-img {
width: 100%;
height: 220px;
object-fit: cover; /* 确保图片使用cover模式 */
border-radius: 15px; /* 设置图片圆角 */
overflow: hidden;
}
.movie-info {
/* margin-top: 8px; */
.movie-title {
font-size: 16px;
font-weight: bold;
padding: 10px 0;
}
}
.movie-poster {
width: 100%;
height: 220px;
position: relative;
overflow: hidden;
border-radius: 15px;
box-sizing: border-box;
padding: 0;
margin: 0;
overflow: hidden;
}
.movie-poster-hover {
opacity: 0; /* 默认情况下隐藏 */
transition: opacity 0.3s ease; /* 添加过渡效果 */
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
/* height: 100%; */
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.movie-poster:hover .movie-poster-hover {
opacity: 1; /* 鼠标移入时显示 */
}
.movie-rate {
position: absolute;
top: 10px;
right: 10px;
background-color: rgba(88, 83, 250, 0.8);
color: white;
padding: 0px 8px;
border-radius: 5px;
font-size: 14px;
}
.movie-search {
color: white;
border-radius: 5px;
font-size: 14px;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,93 @@
<template>
<div v-loading="resourcStore.loading" class="home" element-loading-background="rgba(0,0,0,0.6)">
<el-container>
<el-aside width="200px"><aside-menu /></el-aside>
<el-container class="home-main">
<el-header :class="{ 'home-header': true, 'search-bar-active': !store.scrollTop }">
<search-bar />
</el-header>
<el-main class="home-main-main">
<router-view />
</el-main>
<!-- <el-aside class="home-aside"></el-aside> -->
</el-container>
</el-container>
<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>
<!-- <login v-else /> -->
</template>
<script setup lang="ts">
import { useResourceStore } from "@/stores/resource";
import { useStore } from "@/stores/index";
import { useUserSettingStore } from "@/stores/userSetting";
import { onUnmounted } from "vue";
import AsideMenu from "@/components/AsideMenu.vue";
import SearchBar from "@/components/SearchBar.vue";
const resourcStore = useResourceStore();
const store = useStore();
const settingStore = useUserSettingStore();
settingStore.getSettings();
const handleScroll = () => {
const scrollTop = window.scrollY;
if (scrollTop > 50) {
store.scrollTop && store.setScrollTop(false);
} else {
!store.scrollTop && store.setScrollTop(true);
}
};
window.addEventListener("scroll", handleScroll);
onUnmounted(() => {
window.removeEventListener("scroll", handleScroll);
});
</script>
<style scoped lang="scss">
.home {
// padding: 20px;
min-width: 1000px;
height: 100vh;
overflow: hidden;
margin: 0 auto;
}
.home-header {
height: auto;
position: sticky;
top: 0;
z-index: 10;
// padding: 0;
background-color: rgba(231, 235, 239, 0.7) !important;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-radius: 0 0 5px 5px;
// background-color: var(--theme-other_background);
// box-shadow: 0 4px 10px rgba(225, 225, 225, 0.3);
// border-radius: 20px;
}
.home-main {
width: 1000px;
height: 100vh;
overflow: auto;
}
.home-main-main {
padding: 10px 15px;
}
.home-aside {
width: 300px;
}
</style>

View File

@@ -1,32 +0,0 @@
<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>

View File

@@ -0,0 +1,260 @@
<template>
<div class="login-register">
<div class="login-bg"></div>
<el-card class="card">
<!-- 登录与注册切换 -->
<el-tabs v-model="activeTab" class="tabs">
<el-tab-pane label="登录" name="login"></el-tab-pane>
<el-tab-pane label="注册" name="register"> </el-tab-pane>
</el-tabs>
<!-- 登录表单 -->
<el-form
v-if="activeTab === 'login'"
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
label-position="top"
>
<el-form-item prop="username" class="form-item">
<el-input
v-model="loginForm.username"
placeholder="用户名"
name="username"
autocomplete="on"
class="form-input"
/>
</el-form-item>
<el-form-item prop="password" class="form-item">
<el-input
v-model="loginForm.password"
type="password"
placeholder="密码"
class="form-input"
show-password="true"
autocomplete="on"
name="password"
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-button type="primary" class="form-submit" @click="loginFormRefValidate">
登录
</el-button>
</el-form>
<!-- 注册表单 -->
<el-form
v-if="activeTab === 'register'"
ref="registerFormRef"
:model="registerForm"
:rules="registerRules"
label-position="top"
><el-form-item prop="username" class="form-item">
<el-input v-model="registerForm.username" placeholder="用户名" class="form-input" />
</el-form-item>
<el-form-item prop="password" class="form-item">
<el-input
v-model="registerForm.password"
type="password"
placeholder="密码"
class="form-input"
/>
</el-form-item>
<el-form-item prop="password" class="form-item">
<el-input
v-model="password2"
type="password"
placeholder="再次输入密码"
class="form-input"
/>
</el-form-item>
<el-form-item prop="registerCode" class="form-item">
<el-input v-model="registerForm.registerCode" placeholder="注册码" class="form-input" />
</el-form-item>
<el-button type="primary" class="form-submit" @click="handleRegister"> 注册 </el-button>
</el-form>
</el-card>
</div>
</template>
<script setup type="ts">
import { ref } from "vue";
import { userApi } from "@/api/user";
import router from "@/router";
import { ElMessage } from "element-plus";
const activeTab = ref("login"); // 默认显示登录表单
const loginForm = ref({
username: "",
password: "",
});
const registerForm = ref({
username: "",
password: "",
registerCode: "",
});
const password2 = ref("");
const loginRules = {
username: [{ required: true, message: "请输入用户名", trigger: "blur" }],
password: [{ required: true, message: "请输入密码", trigger: "blur" }],
};
const registerRules = {
username: [{ required: true, message: "请输入用户名", trigger: "blur" }],
password: [{ required: true, message: "请输入密码", trigger: "blur" }],
registerCode: [{ required: true, message: "请输入注册码", trigger: "blur" }],
};
const loginFormRef = ref(null);
const registerFormRef = ref(null);
const handleLogin = async () => {
try {
const res = await userApi.login(loginForm.value);
if (res.code === 0) {
const { token } = res.data;
localStorage.setItem("token", token);
// 路由跳转首页
router.push("/");
} else {
ElMessage.error(res.message);
}
} catch (error) {
ElMessage.error("登录失败", error);
}
};
const loginFormRefValidate = () => {
loginFormRef.value.validate((valid) => {
if (valid) {
handleLogin();
} else {
ElMessage.error("登录表单验证失败");
}
});
};
const handleRegister = async () => {
registerFormRef.value.validate(async (valid) => {
if (valid) {
if(password2.value !== registerForm.value.password){
return ElMessage.error("两次输入的密码不一致");
}
try {
const res = await userApi.register(registerForm.value);
if (res.code === 0) {
ElMessage.success("注册成功");
loginForm.value.username = registerForm.value.username
loginForm.value.password = registerForm.value.password
handleLogin()
} else {
ElMessage.error(res.message || "注册失败");
}
} catch (error) {
ElMessage.error(error.message || "注册失败");
}
} else {
console.error("注册表单验证失败");
}
});
};
</script>
<style scoped lang="scss">
.login-register {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.login-bg {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url("../assets/images/login-bg.jpg");
background-size: cover;
background-position: center center;
-webkit-filter: blur(3px);
-moz-filter: blur(3px);
-o-filter: blur(3px);
-ms-filter: blur(3px);
filter: blur(3px);
z-index: 0;
}
.card {
position: relative;
z-index: 10;
width: 480px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.8);
box-shadow: 0px 20px 60px rgba(123, 61, 224, 0.1);
}
.tabs {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
.tab-button {
flex: 1;
text-align: center;
}
.form-item {
width: 100%;
margin-bottom: 30px;
}
.form-input {
height: 48px;
border-radius: 10px;
}
.options {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.forgot-password {
color: #6366f1;
text-decoration: none;
font-size: 14px;
}
.form-submit {
margin-bottom: 10px;
background-color: #6366f1;
width: 100%;
height: 48px;
}
.google-login {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
}
.agreement {
text-align: center;
margin-top: 10px;
font-size: 14px;
}
.user-agreement {
color: #6366f1;
text-decoration: none;
}
</style>

View File

@@ -0,0 +1,236 @@
<template>
<div class="resource-list">
<div :class="{ 'resource-list__header': true }">
<div class="header_left">
<div class="refresh_btn" @click="refreshResources">
<el-icon class="type_icon" size="20px"><Refresh /></el-icon>最新资源<span
class="item-count"
>(上次刷新时间{{ resourceStore.lastUpdateTime }})</span
>
</div>
</div>
<div class="header_right">
<el-icon
v-if="userStore.displayStyle === 'card'"
class="type_icon"
@click="setDisplayStyle('table')"
><Menu
/></el-icon>
<el-icon v-else class="type_icon" @click="setDisplayStyle('card')"><Fold /></el-icon>
</div>
</div>
<ResourceTable
v-if="userStore.displayStyle === 'table'"
@load-more="handleLoadMore"
@search-moviefor-tag="searchMovieforTag"
@save="handleSave"
></ResourceTable>
<ResourceCard
v-else
@load-more="handleLoadMore"
@search-moviefor-tag="searchMovieforTag"
@save="handleSave"
></ResourceCard>
<el-empty v-if="resourceStore.resources.length === 0" :image-size="200" />
<el-dialog
v-if="currentResource"
v-model="saveDialogVisible"
:title="saveDialogMap[saveDialogStep].title"
>
<template #header="{ titleId }">
<div class="my-header">
<div :id="titleId">
<el-tag
:type="resourceStore.tagColor[currentResource.cloudType as keyof TagColor]"
effect="dark"
round
>
{{ currentResource.cloudType }}
</el-tag>
{{ saveDialogMap[saveDialogStep].title }}
<span
v-if="resourceStore.shareInfo.fileSize && saveDialogStep === 1"
style="font-weight: bold"
>
({{ formattedFileSize(resourceStore.shareInfo.fileSize || 0) }})
</span>
</div>
</div>
</template>
<div v-loading="resourceStore.loadTree">
<resource-select
v-if="saveDialogVisible && saveDialogStep === 1"
:cloud-type="currentResource.cloudType"
/>
<folder-select
v-if="saveDialogVisible && saveDialogStep === 2"
:cloud-type="currentResource.cloudType"
@select="handleFolderSelect"
@close="saveDialogVisible = false"
/>
</div>
<div class="dialog-footer">
<el-button @click="saveDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleConfirmClick">{{
saveDialogMap[saveDialogStep].buttonText
}}</el-button>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useResourceStore } from "@/stores/resource";
import { useUserSettingStore } from "@/stores/userSetting";
import FolderSelect from "@/components/Home/FolderSelect.vue";
import ResourceSelect from "@/components/Home/ResourceSelect.vue";
import ResourceTable from "@/components/Home/ResourceTable.vue";
import { formattedFileSize } from "@/utils/index";
import type { ResourceItem, TagColor } from "@/types";
import ResourceCard from "@/components/Home/ResourceCard.vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
const router = useRouter();
const resourceStore = useResourceStore();
const userStore = useUserSettingStore();
const saveDialogVisible = ref(false);
const currentResource = ref<ResourceItem | null>(null);
const currentFolderId = ref<string | null>(null);
const saveDialogStep = ref<1 | 2>(1);
const refreshResources = async () => {
resourceStore.searchResources("", false);
};
const saveDialogMap = {
1: {
title: "选择资源",
buttonText: "下一步",
},
2: {
title: "选择保存目录",
buttonText: "保存",
},
};
const handleSave = async (resource: ResourceItem) => {
currentResource.value = resource;
saveDialogVisible.value = true;
saveDialogStep.value = 1;
if (!(await resourceStore.getResourceListAndSelect(currentResource.value))) {
saveDialogVisible.value = false;
}
};
const handleFolderSelect = async (folderId: string) => {
if (!currentResource.value) return;
currentFolderId.value = folderId;
};
const handleConfirmClick = async () => {
if (saveDialogStep.value === 1) {
if (resourceStore.resourceSelect.length === 0) {
ElMessage.warning("请选择要保存的资源");
return;
}
saveDialogStep.value = 2;
} else {
handleSaveBtnClick();
}
};
const handleSaveBtnClick = async () => {
if (!currentResource.value || !currentFolderId.value) return;
saveDialogVisible.value = false;
await resourceStore.saveResource(currentResource.value, currentFolderId.value);
};
const setDisplayStyle = (style: string) => {
console.log(userStore);
userStore.setDisplayStyle(style as "card" | "table");
};
// 添加加载更多处理函数
const handleLoadMore = (channelId: string) => {
resourceStore.searchResources("", true, channelId);
};
const searchMovieforTag = (tag: string) => {
router.push({ path: "/", query: { keyword: tag } });
};
</script>
<style scoped>
.resource-list {
/* margin-top: 20px; */
position: relative;
}
.resource-list__header {
height: 48px;
background-color: var(--theme-other_background);
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
box-sizing: border-box;
border-radius: 15px;
padding: 0 15px;
}
.header_right {
cursor: pointer;
}
.type_icon {
width: 48px;
height: 48px;
size: 48px;
}
.refresh_btn {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
.item-count {
color: #909399;
font-size: 0.9em;
}
}
.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>

View File

@@ -0,0 +1,162 @@
<template>
<div class="settings">
<el-card v-if="settingStore.globalSetting" class="setting-card">
<h2>网络配置</h2>
<div class="section">
<div class="form-group">
<label for="proxyDomain">代理域名:</label>
<el-input
id="proxyDomain"
v-model="settingStore.globalSetting.httpProxyHost"
class="form-input"
type="text"
placeholder="127.0.0.1"
/>
</div>
<div class="form-group">
<label for="proxyPort">代理端口:</label>
<el-input
id="proxyPort"
v-model="settingStore.globalSetting.httpProxyPort"
class="form-input"
type="text"
placeholder="7890"
/>
</div>
<div class="form-group">
<label for="AdminUserCode">管理员注册码:</label>
<el-input-number
id="AdminUserCode"
v-model="settingStore.globalSetting.AdminUserCode"
class="form-input"
type="text"
:controls="false"
:precision="0"
placeholder="设置管理员注册码"
/>
</div>
<div class="form-group">
<label for="CommonUserCode">普通用户注册码:</label>
<el-input-number
id="CommonUserCode"
v-model="settingStore.globalSetting.CommonUserCode"
class="form-input"
type="text"
:precision="0"
:controls="false"
placeholder="设置普通用户注册码"
/>
</div>
</div>
<div class="section">
<div class="form-group">
<label for="isProxyEnabled">启用代理:</label>
<el-switch v-model="settingStore.globalSetting.isProxyEnabled" @change="saveSettings" />
</div>
</div>
</el-card>
<el-card class="setting-card">
<h2>用户配置</h2>
<div class="section">
<div class="form-group">
<label for="cookie115">115网盘Cookie:</label>
<el-input
id="cookie115"
v-model="settingStore.userSettings.cloud115Cookie"
class="form-input"
type="text"
/>
</div>
<div class="form-group">
<label for="cookieQuark">夸克网盘Cookie:</label>
<el-input
id="cookieQuark"
v-model="settingStore.userSettings.quarkCookie"
class="form-input"
type="text"
/>
</div>
</div>
<div class="user-setting-tips">
<h3>帮助</h3>
<div>
<el-link
target="_blank"
href="https://alist.nn.ci/zh/guide/drivers/115.html#cookie%E8%8E%B7%E5%8F%96%E6%96%B9%E5%BC%8F"
>如何获取115网盘cookie</el-link
>
</div>
<div>
<el-link target="_blank" href="https://alist.nn.ci/zh/guide/drivers/quark.html#cookie"
>如何获取夸克网盘cookie</el-link
>
</div>
</div>
</el-card>
<el-button @click="saveSettings">保存设置</el-button>
</div>
</template>
<script setup lang="ts">
import { useUserSettingStore } from "@/stores/userSetting";
const settingStore = useUserSettingStore();
settingStore.getSettings();
const saveSettings = () => {
settingStore.saveSettings();
// Add your save logic here
};
</script>
<style scoped lang="scss">
.settings {
padding: 20px;
}
.setting-card {
margin-bottom: 20px;
border-radius: 15px;
}
.section {
margin-bottom: 20px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.form-group {
margin-bottom: 10px;
width: 48%;
}
.form-input {
text-align: left;
width: 100%;
}
::v-deep .el-input__inner {
text-align: left;
}
label {
display: block;
margin-bottom: 5px;
}
input {
width: 100%;
padding: 8px;
box-sizing: border-box;
}
button {
padding: 10px 20px;
background-color: #007bff;
color: white;
border: none;
cursor: pointer;
}
button:hover {
background-color: #0056b3;
}
</style>

View File

@@ -16,6 +16,7 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"typeRoots": ["./node_modules/@types", "./types"],
"paths": {
"@/*": ["src/*"]
}

View File

@@ -16,6 +16,13 @@ export default defineConfig({
resolvers: [ElementPlusResolver()],
}),
],
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "@/styles/global.scss";`,
},
},
},
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
@@ -41,6 +48,10 @@ export default defineConfig({
});
},
},
"/tele-images": {
target: process.env.VITE_API_BASE_URL_PROXY || "http://127.0.0.1:8009",
changeOrigin: true,
},
},
},
build: {

35
nginx.conf Normal file
View File

@@ -0,0 +1,35 @@
# nginx.conf
user nginx; # 定义 Nginx 进程的运行用户
worker_processes 1; # 设置 Nginx 进程数
events {
worker_connections 1024; # 每个 worker 进程最大连接数
}
http {
include mime.types;
default_type application/octet-stream;
server {
listen 8008;
server_name localhost;
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
location /api/ {
rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://localhost:8009;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /tele-images/ {
proxy_pass http://localhost:8009;
proxy_set_header Host $host;
}
}
}

Some files were not shown because too many files have changed in this diff Show More