93 Commits
v0.1.2 ... main

Author SHA1 Message Date
jiangrui1994
55b534da6d 更新 README.md 2025-10-10 14:52:05 +08:00
jiangrui
10f45cbb7c docs:更新qq群图片 2025-08-06 13:27:32 +08:00
jiangrui1994
f939f0a35b 更新 README.md 2025-08-06 13:25:00 +08:00
jiangrui1994
185a2bc9e4 Update README.md 2025-07-03 11:29:21 +08:00
jiangrui1994
c619675820 更新 README.md 2025-05-03 08:46:46 +08:00
jiangrui
ee6c37d8c2 feat:去除内置源 2025-04-22 18:38:17 +08:00
jiangrui1994
a0fef49d7a Update README.md 2025-04-12 17:53:25 +08:00
jiangrui
5cc253f697 doc:更新群二维码 2025-04-11 14:06:37 +08:00
jiangrui1994
7c6c443a29 更新 README.md 2025-04-11 11:42:27 +08:00
jiangrui1994
5f22bea2c9 更新 README.md 2025-04-11 11:40:15 +08:00
jiangrui1994
275f7210f6 Update README.md 2025-04-10 17:45:52 +08:00
jiangrui1994
d0f0c5a1bc 更新 README.md 2025-04-06 00:38:07 +08:00
jiangrui1994
bb0b4b26c3 更新 README.md 2025-04-05 22:24:36 +08:00
jiangrui1994
f4b7603949 更新 README.md 2025-04-05 22:24:01 +08:00
jiangrui1994
5abc5370fd Update README.md 2025-03-28 12:20:12 +08:00
jiangrui1994
b746306d67 Update README.md 2025-03-28 10:12:54 +08:00
jiangrui1994
21f512c447 更新 README.md 2025-03-27 23:55:20 +08:00
jiangrui1994
c4613ef874 Update README.md 2025-03-27 14:51:06 +08:00
jiangrui1994
8693146d8f Update README.md 2025-03-27 14:49:53 +08:00
jiangrui1994
e9747821f4 Update README.md 2025-03-27 09:58:39 +08:00
jiangrui
9c4ce83256 update README.md 2025-03-27 09:52:43 +08:00
jiangrui1994
b36e8ea9d7 Update README.md 2025-03-24 15:37:59 +08:00
jiangrui1994
390ef06bbb Update README.md 2025-03-24 11:47:29 +08:00
jiangrui1994
6f668dcf14 Update README.md 2025-03-24 11:35:06 +08:00
jiangrui1994
beb060542a 更新 README.md 2025-03-23 19:27:39 +08:00
jiangrui1994
da1a180ce9 更新 README.md 2025-03-23 19:25:58 +08:00
jiangrui1994
79ecec5ce9 Update README.md 2025-03-20 22:34:14 +08:00
jiangrui1994
f75d4688bd Update README.md 2025-03-17 12:30:46 +08:00
jiangrui1994
1738f7f791 更新 README.md 2025-03-16 14:55:07 +08:00
jiangrui
b645d3ae83 Merge remote-tracking branch 'origin/dev' 2025-03-15 11:55:59 +08:00
jiangrui
82048c3b73 Update nginx.conf 2025-03-15 11:54:36 +08:00
jiangrui
1601364f3f fix:去除悬浮效果 2025-03-14 13:51:48 +08:00
jiangrui
2b1dbeaf42 Update README.md 2025-03-13 21:55:57 +08:00
jiangrui
4b5e50b0d9 updeta version 0.2.5 2025-03-13 17:28:18 +08:00
jiangrui
e02422b774 update 2025-03-13 16:37:07 +08:00
jiangrui
676bf5cd40 fix:logger 2025-03-13 16:33:19 +08:00
jiangrui
16a2b586fd feat:增加项目鸣谢页 2025-03-13 16:26:41 +08:00
jiangrui
c1927a0ea9 fix:去掉标签文字 2025-03-12 16:16:12 +08:00
jiangrui
cc7cdbb4a6 fix:表格布局图片缩小,使布局更紧凑 2025-03-12 16:03:12 +08:00
jiangrui
5f53d30be1 fix:优化卡片布局,增加空间利用率 2025-03-12 15:50:11 +08:00
jiangrui
bf2d7c70c7 fix:优化前端图片展示添加默认图片 2025-03-12 15:23:11 +08:00
jiangrui
bc38acded3 feat:去除获取资源信息需要cookie的限制 2025-03-12 15:22:30 +08:00
jiangrui
f32b6cd5d0 fix:优化移动端登录注册页面 2025-03-12 14:03:53 +08:00
jiangrui
28f894f988 fix:默认只取第一个网盘链接类型为标签 2025-03-12 13:04:33 +08:00
jiangrui
ef0de7ad8c feat:设置页cookie默认不显示明文与资源标题点击复制链接 2025-03-12 12:58:09 +08:00
jiangrui
8fa1ed8fc2 feat:移动端增加注册模块 2025-03-12 12:27:34 +08:00
jiangrui
dca0f1f0c1 feat:增加跳转按钮 2025-03-11 21:57:45 +08:00
jiangrui
8a5b084915 update README.md 2025-03-11 21:26:39 +08:00
jiangrui
e6171fb34c update version 2025-03-11 18:08:29 +08:00
jiangrui
5a625e5c0e Remove useless code 2025-03-11 17:59:00 +08:00
jiangrui
37c25a9307 Refactor the backend 2025-03-11 17:42:59 +08:00
jiangrui
615149c83f Refactor the backend 2025-03-11 00:06:10 +08:00
jiangrui
a78ea7e5bd Refactoring the backend 2025-03-10 18:33:47 +08:00
jiangrui
755a424530 backend:优化后端启动文件 2025-03-10 11:21:52 +08:00
jiangrui
c1948a888e Merge remote-tracking branch 'origin/dev' 2025-03-09 23:10:27 +08:00
jiangrui1994
7d8f4b32f7 更新 README.md 2025-03-09 23:08:37 +08:00
jiangrui
0a9b053dff Optimized image loading 2025-03-08 20:04:57 +08:00
jiangrui
73440cca45 fix:mobile scroll to more 2025-03-08 18:26:39 +08:00
jiangrui
5987e1fb3e caiyun RegExp 2025-03-08 15:16:57 +08:00
jiangrui
42722ca1d8 Update the version number 2025-03-08 12:39:15 +08:00
jiangrui
6efdea55aa Optimized the PC experience 2025-03-08 12:33:42 +08:00
jiangrui
f23e78e8dd refactor:优化展开收起 2025-03-07 23:48:48 +08:00
jiangrui
26381fa6b0 fix:优化触底逻辑 2025-03-07 23:24:28 +08:00
jiangrui
eedd68d137 fix:优化搜索逻辑 2025-03-07 23:21:24 +08:00
jiangrui
a04f16bfa4 修改123网盘匹配正则 2025-03-07 16:52:14 +08:00
jiangrui
93d6a1276a update 123 RegExp 2025-03-07 14:11:52 +08:00
jiangrui
505b1d6c67 Update README.md 2025-03-06 22:42:08 +08:00
jiangrui
212ca3a879 Update README.md 2025-03-06 22:35:34 +08:00
jiangrui
65a35225d0 build:update actions 2025-03-06 22:13:56 +08:00
jiangrui
7ed0c04111 build:修改启动命令 2025-03-06 21:30:44 +08:00
jiangrui
e8f70b286e build:修改docker构建配置与简化后端config 2025-03-06 18:24:01 +08:00
jiangrui
e708524a41 build:完善构建流程 2025-03-06 16:41:54 +08:00
jiangrui
a01dd06ef2 fix:解决ios设备兼容问题 2025-03-06 16:40:43 +08:00
jiangrui
3c656eb880 feat:增加pwa支持 2025-03-06 11:27:31 +08:00
jiangrui
89879e8c05 fix:解决ios设备弹窗被tabbar遮挡问题 2025-03-06 10:58:53 +08:00
jiangrui1994
7c7ad6ba1f 更新 README.md 2025-03-06 08:33:46 +08:00
jiangrui1994
d0115c78f5 Update README.md 2025-03-05 22:21:27 +08:00
jiangrui
90e708c25f update:version 0.2.0 2025-03-05 22:13:53 +08:00
jiangrui
eb4aa1bb8d update:readme 2025-03-05 21:47:23 +08:00
jiangrui
1f3a83b84d refactor:pc views 2025-03-05 18:20:54 +08:00
jiangrui
7bcec7e3b4 fix:组件懒加载解决生成文件没分离的问题 2025-03-05 13:30:20 +08:00
jiangrui
604ba2eec6 refactor:优化移动端页面 2025-03-05 12:29:47 +08:00
jiangrui
680108f499 refactor:优化移动端页面 2025-03-04 23:37:16 +08:00
jiangrui
301ed5648e feat:mobile view update 2025-03-04 18:19:28 +08:00
jiangrui
b755b6b186 Merge remote-tracking branch 'origin/dev' into mobile 2025-03-04 14:38:08 +08:00
jiangrui
fa8da9ae25 refactor:优化搜索资源的请求速度 2025-03-04 14:34:30 +08:00
jiangrui
03509eb723 feat:add mobile views 2025-03-03 18:06:19 +08:00
jiangrui
4cd71a72e5 Merge remote-tracking branch 'origin/dev' 2025-03-02 12:24:13 +08:00
jiangrui
626313434a fix:非管理员设置界面为空修复 2025-03-02 12:24:01 +08:00
jiangrui
1a562a612c Merge remote-tracking branch 'origin/dev' 2025-03-02 12:10:03 +08:00
jiangrui
ddfb00298f fix:修改构建过程中设置页面ts报错 2025-03-02 12:07:41 +08:00
jiangrui
cd7183f206 update:Docker Image CI/CD 2025-03-02 08:40:55 +08:00
jiangrui
6c9034570c Merge remote-tracking branch 'origin/main' into dev 2025-03-01 11:45:32 +08:00
138 changed files with 28768 additions and 2511 deletions

View File

@@ -1,8 +0,0 @@
# jwt密钥 用于生成token加密
JWT_SECRET=""
# 用户注册码
REGISTER_CODE='9527'
# 服务端口
PORT=8009

View File

@@ -1,8 +1,5 @@
name: Build and Push Multi-Arch Docker Image for Test
on:
push:
branches:
- dev
workflow_dispatch: # 添加手动触发
jobs:
build-and-push:
@@ -12,6 +9,8 @@ jobs:
packages: write # 必须授权以推送镜像
env:
REPO_NAME: ${{ github.repository }}
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
IMAGE_NAME: cloudsaver
steps:
- name: 检出代码
uses: actions/checkout@v4
@@ -28,6 +27,12 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: 登录到 Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: 设置 QEMU 支持多架构
uses: docker/setup-qemu-action@v2
@@ -42,3 +47,4 @@ jobs:
push: true
tags: |
ghcr.io/${{ env.LOWER_NAME }}:test
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:test

View File

@@ -2,6 +2,7 @@ name: Docker Image CI/CD
on:
push:
tags: ["v*.*.*"] # 支持标签触发(如 v1.0.0
workflow_dispatch: # 添加手动触发
jobs:
build-and-push:
runs-on: ubuntu-latest
@@ -10,14 +11,18 @@ jobs:
packages: write # 必须授权以推送镜像
env:
REPO_NAME: ${{ github.repository }}
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
IMAGE_NAME: cloudsaver
steps:
- name: 检出代码
uses: actions/checkout@v4
- name: 设置小写镜像名称
- name: 设置小写镜像名称和版本
run: |
LOWER_NAME=$(echo "$REPO_NAME" | tr '[:upper:]' '[:lower:]')
echo "LOWER_NAME=$LOWER_NAME" >> $GITHUB_ENV
VERSION=${GITHUB_REF#refs/tags/v}
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: 登录到 GitHub Container Registry
uses: docker/login-action@v2
@@ -26,11 +31,26 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: 登录到 Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_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: .
push: ${{ github.event_name == 'push' }} # 仅推送代码时上传镜像
platforms: linux/amd64,linux/arm64 # 指定架构x86_64 和 ARM64
push: true
tags: |
ghcr.io/${{ env.LOWER_NAME }}:latest
ghcr.io/${{ env.LOWER_NAME }}:${{ github.sha }}
ghcr.io/${{ env.LOWER_NAME }}:${{ env.VERSION }}
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:latest
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }}

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
node_modules/
logs/
dist/
.env
.env.local

View File

@@ -26,6 +26,9 @@ RUN apk add --no-cache nginx
# 设置工作目录
WORKDIR /app
# 创建配置和数据目录
RUN mkdir -p /app/config /app/data
# 复制前端构建产物到 Nginx
COPY --from=frontend-build /app/dist /usr/share/nginx/html
@@ -38,8 +41,15 @@ COPY --from=backend-build /app /app
# 安装生产环境依赖
RUN npm install --production
# 设置数据卷
VOLUME ["/app/config", "/app/data"]
# 暴露端口
EXPOSE 8008
# 启动 Nginx 和后端服务
CMD ["sh", "-c", "nginx -g 'daemon off;' & npm run start && wait"]
# 启动脚本
COPY docker-entrypoint.sh /app/
RUN chmod +x /app/docker-entrypoint.sh
# 启动服务
ENTRYPOINT ["/app/docker-entrypoint.sh"]

397
README.md
View File

@@ -1,127 +1,366 @@
# CloudSaver
一个基于 Vue 3 + Express 的网盘资源搜索与转存工具。
可通过docker 一键部署。
![License](https://img.shields.io/badge/license-MIT-blue.svg)
![Vue](https://img.shields.io/badge/vue-3.x-brightgreen.svg)
![TypeScript](https://img.shields.io/badge/typescript-5.x-blue.svg)
[![GitHub Stars](https://img.shields.io/github/stars/jiangrui1994/CloudSaver.svg?style=flat&logo=github)](https://github.com/jiangrui1994/CloudSaver/stargazers)
![Docker](https://img.shields.io/docker/pulls/jiangrui1994/cloudsaver.svg)
<a href="https://hellogithub.com/repository/d13663fb959345e7923ecaccc3387571" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=d13663fb959345e7923ecaccc3387571&claim_uid=xP1MT4mSvN6wn5K&theme=small" alt="FeaturedHelloGitHub" /></a>
## 特别声明
一个基于 Vue 3 + Express 的网盘资源搜索与转存工具支持响应式布局移动端与PC完美适配可通过 Docker 一键部署。
1. 此项目仅供学习交流使用,请勿用于非法用途。
2. 仅支持个人使用,不支持任何形式的 commercial 使用。
3. 禁止在项目页面上进行任何形式的广告宣传。
4. 所有搜索到的资源均来自第三方,本项目不对其真实性、合法性做出任何保证。
官方Telegram群组[https://t.me/cloud_saver](https://t.me/cloud_saver)
## 注意事项
官方QQ交流群[二维码](https://github.com/jiangrui1994/CloudSaver?tab=readme-ov-file#%E8%81%94%E7%B3%BB%E6%96%B9%E5%BC%8F)
1. 此项目的资源搜索需要用到**代理环境**,请自行搭建。
2. 新用户注册,**管理员**默认注册码230713普通用户默认注册码9527
1039610300(满了)
389429056(满了)
版本更新日志:[https://www.yuque.com/xiaoruihenbangde/ggogn3/vxoqxkx4rkcz3g94](https://www.yuque.com/xiaoruihenbangde/ggogn3/vxoqxkx4rkcz3g94)
CloudSaver部署与使用常见问题(包含更多搜索频道)[https://www.yuque.com/xiaoruihenbangde/ggogn3/ga6gaaiy5fsyw62l](https://www.yuque.com/xiaoruihenbangde/ggogn3/ga6gaaiy5fsyw62l)
密码 me16 点个Star呗~
⚠️关于项目更新与需求处理的核心声明:[https://www.yuque.com/xiaoruihenbangde/ggogn3/gt9cgqn2n3vergxx](https://www.yuque.com/xiaoruihenbangde/ggogn3/gt9cgqn2n3vergxx)
---
⚠️**由于某些原因,[新版本](https://www.yuque.com/xiaoruihenbangde/ggogn3/vxoqxkx4rkcz3g94)内容不包含在此开源仓库(停留在V0.2.5版本)如需使用请使用docker镜像进行部署使用。**
---
**🔒 重要安全提醒|关于本项目私有化部署的强制建议**
为保障您的数据安全与隐私权益,请务必**通过Docker自行私有化部署本项目**。我们**强烈反对**使用任何第三方提供的在线网站或他人部署的服务,原因如下:
⚠️ **高风险预警**
- 本项目涉及**网盘Cookie等敏感凭据**,若使用他人服务:
▶ 您的账号密码、隐私文件可能遭恶意窃取或篡改
▶ 攻击者可利用Cookie直接登录您的网盘实施破坏
▶ 数据泄露、资产损失等后果需完全由使用者自行承担
🚫 **严正声明**
1. 本项目**从未且不会**提供任何形式的在线服务、公开Demo或托管平台
2. **任何声称与本项目相关的在线网站均为未授权第三方搭建**,存在蓄意作恶的高风险
3. 如因使用非自建服务导致损失,本项目开发者**不承担任何法律责任**
**常见问题**
Q: 是否有在线Demo可直接试用
A: **绝无可能!** 任何在线服务都与本项目无关,请立即关闭避免信息泄露
Q: 为何不能使用他人部署好的服务?
A: Cookie等同于账号密码交出Cookie=交出家门钥匙,请勿将身家安全托付陌生人
Q: 如何确保100%安全?
A: 唯一可信方案:通过官方仓库代码+自主服务器部署,全程数据闭环
---
**🛡️ 最后一次严肃提醒**
您的账号安全只应掌握在自己手中!
请立即执行私有化部署 ▶ 避免无法挽回的数据灾难
---
## 功能特性
- 支持多个资源订阅(电报群)源搜索
- 支持 115 网盘与夸克网盘**一键**资源转存
- 🔍 多源资源搜索
- 支持多个资源订阅源搜索
- 支持关键词搜索与资源链接解析
- 支持豆瓣热门榜单展示
- 💾 网盘资源转存
- 支持**115 网盘夸克网盘天翼网盘123云盘**一键转存
- 支持转存文件夹展示与选择
- 支持多用户使用
- 支持豆瓣热门榜单
- 支持热门榜单资源搜索
- 👥 多用户系统
- 支持用户注册登录
- 支持管理员与普通用户权限区分
- 📱 响应式设计
- 支持 PC 端与移动端自适应布局
- 针对不同设备优化的交互体验
## 预览
## 产品展示
### 登录/注册
<details>
<summary>点击展开截图预览</summary>
<img src="./docs/images/login.png" width="400">
<img src="./docs/images/register.png" width="400">
### PC 端
### 最新资源搜索
<div align="center">
<img src="./docs/images/pc/login.png" width="400" alt="PC登录页面">
<img src="./docs/images/pc/douban.png" width="400" alt="PC豆瓣榜单">
<p>登录页面/榜单</p>
</div>
<img src="./docs/images/search.png" width="400">
<div align="center">
<img src="./docs/images/pc/search.png" width="400" alt="PC资源搜索">
<img src="./docs/images/pc/detail.png" width="400" alt="PC资源详情">
<p>资源搜索/资源详情</p>
</div>
### 关键词搜索
<div align="center">
<img src="./docs/images/pc/save.png" width="400" alt="PC资源转存">
<img src="./docs/images/pc/save1.png" width="400" alt="PC资源转存">
<p>资源转存</p>
</div>
<img src="./docs/images/search-movie.png" width="400">
### 移动端
### 热门榜单
<div align="center">
<div style="display: inline-block; margin: 0 20px;">
<img src="./docs/images/mobile/login.png" width="200" alt="移动端登录页面">
<img src="./docs/images/mobile/search.png" width="200" alt="移动端资源搜索">
<img src="./docs/images/mobile/save.png" width="200" alt="移动端资源转存">
<img src="./docs/images/mobile/save1.png" width="200" alt="移动端资源转存">
</div>
</div>
<img src="./docs/images/hotmovie.png" width="400">
### 转存
<img src="./docs/images/save.png" width="400">
### 系统设置
<img src="./docs/images/setting.png" width="400">
</details>
## 技术栈
### 前端
- 核心框架
- Vue 3
- TypeScript
- Element Plus
- Pinia
- Vue Router
- Vite
- 状态管理
- Pinia
- 路由管理
- Vue Router
- UI 组件库
- Element Plus (PC)
- Vant (Mobile)
- 工具库
- Axios
### 后端
- 运行环境
- Node.js
- Express
- TypeScript
- Sqlite3
- RSS Parser
- 数据存储
- SQLite3
## 环境配置
## 环境要求
### Node.js 版本
- Node.js >= 18.x
- pnpm >= 8.x (推荐)
本项目需要 `Node.js` 版本 `18.x` 或更高版本。请确保在运行项目之前安装了正确的版本。
## 快速开始
推荐使用pnpm进行依赖包安装默认支持workspace可以避免版本冲突。
### 开发环境
### 后端配置项
复制环境变量模板文件:
```
cp .env.example ./backend/.env
```
- 根据 `.env.example`文件在 `backend`目录下创建 `.env`文件。
## 使用
### 开发环境本地运行
- 安装依赖:`npm run install`
- 启动开发环境:`npm run dev`
### 打包部署
- 前端打包:`npm run build:frontend` 或者进入前端目录`frontend`,执行 `npm run build`
- 将前端构建产物`dist`目录下的文件复制到服务器上,例如`nginx``html`目录下。
- 后端服务:先进入后端目录`backend``npm run build`构建,然后执行 `npm run start`启动,默认端口为`8009`
- 通过`nginx`配置代理服务,将前端api的请求映射到后端服务。
## Docker 部署
- 运行容器:
1. 克隆项目
```bash
docker run -d -p 8008:8008 --name cloud-saver ghcr.io/jiangrui1994/cloudsaver
git clone https://github.com/jiangrui1994/CloudSaver.git
cd CloudSaver
```
### docker 镜像地址
2. 安装依赖
[ghcr.io/jiangrui1994/cloudsaver](ghcr.io/jiangrui1994/cloudsaver)
```bash
pnpm install
```
相关nas镜像拉取 可从此地址拉取
3. 配置环境变量
## 交流沟通
```bash
cp ./backend/.env.example ./backend/.env
```
<img src="./docs/images/wechat.jpg" width="400">
<img src="./docs/images/20250220115710.jpg" width="400">
根据 `.env.example` 文件说明配置必要的环境变量。
## License
4. 启动开发服务器
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
```bash
pnpm dev
```
### 生产环境部署
1. 构建前端
```bash
pnpm build:frontend
```
2. 构建后端
```bash
cd backend
pnpm build
```
3. 启动服务
```bash
pnpm start
```
### Docker 部署
说明:镜像源有**两个地址**供选择下面部署命令中使用的是dockerhub托管的地址为例github托管的地址请自行替换
- dockerhub托管
- `jiangrui1994/cloudsaver:latest` 稳定版
- `jiangrui1994/cloudsaver:test` 测试版 包含最新功能和bug修复但可能不如稳定版稳定
- github托管
- `ghcr.io/jiangrui1994/cloudsaver:latest` 稳定版
- `ghcr.io/jiangrui1994/cloudsaver:test` 测试版 包含最新功能和bug修复但可能不如稳定版稳定
#### 单容器部署
稳定版:
```bash
docker run -d \
-p 8008:8008 \
-v /your/local/path/data:/app/data \
-v /your/local/path/config:/app/config \
--name cloud-saver \
jiangrui1994/cloudsaver:latest
```
测试版包含最新功能和bug修复但可能不如稳定版稳定
```bash
docker run -d \
-p 8008:8008 \
-v /your/local/path/data:/app/data \
-v /your/local/path/config:/app/config \
--name cloud-saver \
jiangrui1994/cloudsaver:test
```
#### Docker Compose 部署
创建 `docker-compose.yml` 文件:
稳定版:
```yaml
version: "3"
services:
cloudsaver:
image: jiangrui1994/cloudsaver:latest
container_name: cloud-saver
ports:
- "8008:8008"
volumes:
- /your/local/path/data:/app/data
- /your/local/path/config:/app/config
restart: unless-stopped
```
测试版:
```yaml
version: "3"
services:
cloudsaver:
image: jiangrui1994/cloudsaver:test
container_name: cloud-saver
ports:
- "8008:8008"
volumes:
- /your/local/path/data:/app/data
- /your/local/path/config:/app/config
restart: unless-stopped
```
#### /app/config 目录说明
- `env` 文件:包含后端环境变量配置
```bash
# JWT配置
JWT_SECRET=your_jwt_secret_here
# Telegram配置
TELEGRAM_BASE_URL=https://t.me/s
# Telegram频道配置(0.3.0及之后版本无效)
TELE_CHANNELS=[{"id":"xxxx","name":"xxxx资源分享"}]
```
运行:
```bash
docker-compose up -d
```
> **注意**: 测试版(:test标签包含最新的功能开发和bug修复但可能存在不稳定因素。建议生产环境使用稳定版:latest标签
## 注意事项
1. 资源搜索需要配置代理环境
2. 默认注册码
- 管理员230713
- 普通用户9527
## 联系方式
<div align="center">
<div>
<p>qq交流群(4群)(1058040657)</p>
</div>
</div>
## 支持项目
如果您觉得这个项目对您有帮助,可以考虑给予一点支持,这将帮助我们持续改进项目 ❤️
您可以:
- ⭐ 给项目点个 Star
- 🎉 分享给更多有需要的朋友
- ☕ 请作者喝杯冰阔乐或咖啡
- 💰 **赞赏了一定记得和我联系**
<div align="center">
<div style="display: inline-block; margin: 0 20px;">
<img src="./docs/images/wechat_pay.jpg" height="300" alt="微信打赏">
<img src="./docs/images/alipay.png" height="300" alt="支付宝打赏">
</div>
</div>
## 特别声明
1. 本项目仅供学习交流使用,请勿用于非法用途
2. 仅支持个人使用,不支持任何形式的商业使用
3. 禁止在项目页面进行任何形式的广告宣传
4. 所有搜索到的资源均来自第三方,本项目不对其真实性、合法性做出任何保证
## 贡献指南
1. Fork 本仓库
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 提交 Pull Request
## 开源协议
本项目基于 MIT 协议开源 - 查看 [LICENSE](LICENSE) 文件了解更多细节
## 鸣谢
- 👨‍💻 感谢所有为这个项目做出贡献的开发者们!
- 👥 感谢所有使用本项目并提供反馈的用户!
- 感谢所有给予支持和鼓励的朋友们!
## 赞助
本项目 CDN 加速及安全防护由 Tencent EdgeOne 赞助
<a href="https://edgeone.ai/?from=github" target="_blank">亚洲最佳CDN、边缘和安全解决方案 - Tencent EdgeOne</a>
<img title="亚洲最佳CDN、边缘和安全解决方案 - Tencent EdgeOne" src="https://edgeone.ai/media/34fe3a45-492d-4ea4-ae5d-ea1087ca7b4b.png" width="300">
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=jiangrui1994/cloudsaver&type=Date)](https://www.star-history.com/#jiangrui1994/cloudsaver&Date)

View File

@@ -1,7 +0,0 @@
# TODO
- ~~资源列表增加网盘标识~~
- ~~增加对夸克网盘转存的支持~~
- ~~增加搜索框直接解析链接~~
- ~~替换rsshub改为直接从telegram获取资源信息~~
- ~~增加资源列表web源加载更多功能~~

9
backend/.env.example Normal file
View File

@@ -0,0 +1,9 @@
# JWT配置
JWT_SECRET=your_jwt_secret_here
# Telegram配置
TELEGRAM_BASE_URL=https://t.me/s
# Telegram频道配置
TELE_CHANNELS=[]

View File

@@ -15,12 +15,14 @@
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.18.3",
"inversify": "^7.1.0",
"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"
"tunnel": "^0.0.6",
"winston": "^3.17.0"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",

View File

@@ -1,76 +1,57 @@
// 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 { container } from "./inversify.config";
import { TYPES } from "./core/types";
import { DatabaseService } from "./services/DatabaseService";
import { setupMiddlewares } from "./middleware";
import routes from "./routes/api";
import { logger } from "./utils/logger";
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";
class App {
private app = express();
private databaseService = container.get<DatabaseService>(TYPES.DatabaseService);
const app = express();
app.use(
cors({
origin: "*",
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "Cookie"],
})
);
app.use(cookieParser());
app.use(express.json());
// 应用 token 验证中间件,排除登录和注册接口
app.use((req, res, next) => {
if (
req.path === "/user/login" ||
req.path === "/user/register" ||
req.path.includes("tele-images")
) {
return next();
constructor() {
this.setupExpress();
}
authMiddleware(req, res, next);
});
app.use("/", routes);
private setupExpress(): void {
// 设置中间件
setupMiddlewares(this.app);
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.");
// 设置路由
this.app.use("/", routes);
this.app.use(errorHandler);
}
await Searcher.updateAxiosInstance();
};
// 错误处理
app.use(errorHandler);
public async start(): Promise<void> {
try {
// 初始化数据库
await this.databaseService.initialize();
logger.info("数据库初始化成功");
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}`);
// 启动服务器
const port = process.env.PORT || 8009;
this.app.listen(port, () => {
logger.info(`
🚀 服务器启动成功
🌍 监听端口: ${port}
🔧 运行环境: ${process.env.NODE_ENV || "development"}
`);
});
})
.catch((error) => {
console.error("Database sync failed:", error);
} catch (error) {
logger.error("服务器启动失败:", error);
process.exit(1);
}
}
}
// 创建并启动应用
const application = new App();
application.start().catch((error) => {
logger.error("应用程序启动失败:", error);
process.exit(1);
});
export default app;
export default application;

View File

@@ -11,86 +11,78 @@ interface Channel {
interface CloudPatterns {
baiduPan: RegExp;
tianyi: RegExp;
weiyun: RegExp;
aliyun: RegExp;
pan115: RegExp;
pan123: RegExp;
quark: RegExp;
yidong: RegExp;
}
interface Cloud115Config {
userId: string;
cookie: string;
}
interface QuarkConfig {
userId: string;
cookie: string;
}
interface HttpProxyConfig {
host: string;
port: string;
}
interface Config {
jwtSecret: string;
registerCode: string;
rss: {
telegram: {
baseUrl: string;
channels: Channel[];
};
telegram: {
baseUrl: string;
};
httpProxy: HttpProxyConfig;
cloudPatterns: CloudPatterns;
cloud115: Cloud115Config;
quark: QuarkConfig;
app: {
port: number;
env: string;
};
database: {
type: string;
path: string;
};
jwt: {
secret: string;
expiresIn: string;
};
}
// 从环境变量读取频道配置
const getTeleChannels = (): Channel[] => {
try {
const channelsStr = process.env.TELE_CHANNELS;
if (channelsStr) {
return JSON.parse(channelsStr);
}
} catch (error) {
console.warn("无法解析 TELE_CHANNELS 环境变量,使用默认配置");
}
// 默认配置
return [];
};
export const config: Config = {
app: {
port: parseInt(process.env.PORT || "8009"),
env: process.env.NODE_ENV || "development",
},
database: {
type: "sqlite",
path: "./data/database.sqlite",
},
jwt: {
secret: process.env.JWT_SECRET || "your-secret-key",
expiresIn: "6h",
},
jwtSecret: process.env.JWT_SECRET || "uV7Y$k92#LkF^q1b!",
rss: {
baseUrl: process.env.RSS_BASE_URL || "https://rsshub.rssforever.com/telegram/channel",
channels: [
{
id: "guaguale115",
name: "115网盘资源分享",
},
{
id: "hao115",
name: "115网盘资源分享频道",
},
{
id: "yunpanshare",
name: "网盘资源收藏(夸克)",
},
],
},
registerCode: process.env.REGISTER_CODE || "9527",
telegram: {
baseUrl: process.env.TELEGRAM_BASE_URL || "https://t.me/s",
channels: getTeleChannels(),
},
httpProxy: {
host: process.env.HTTP_PROXY_HOST || "",
port: process.env.HTTP_PROXY_PORT || "",
},
cloudPatterns: {
baiduPan: /https?:\/\/(?:pan|yun)\.baidu\.com\/[^\s<>"]+/g,
tianyi: /https?:\/\/cloud\.189\.cn\/[^\s<>"]+/g,
weiyun: /https?:\/\/share\.weiyun\.com\/[^\s<>"]+/g,
aliyun: /https?:\/\/\w+\.aliyundrive\.com\/[^\s<>"]+/g,
// pan115有两个域名 115.com 和 anxia.com
aliyun: /https?:\/\/\w+\.(?:alipan|aliyundrive)\.com\/[^\s<>"]+/g,
// pan115有两个域名 115.com 和 anxia.com 和 115cdn.com
pan115: /https?:\/\/(?:115|anxia|115cdn)\.com\/s\/[^\s<>"]+/g,
// 修改为匹配所有以123开头的域名
// eslint-disable-next-line no-useless-escape
pan123: /https?:\/\/(?:www\.)?123[^\/\s<>"]+\.com\/s\/[^\s<>"]+/g,
quark: /https?:\/\/pan\.quark\.cn\/[^\s<>"]+/g,
},
cloud115: {
userId: "",
cookie: process.env.CLOUD115_COOKIE || "",
},
quark: {
userId: process.env.QUARK_USER_ID || "",
cookie: process.env.QUARK_COOKIE || "",
yidong: /https?:\/\/caiyun\.139\.com\/[^\s<>"]+/g,
},
};

View File

@@ -0,0 +1,32 @@
import { Request, Response } from "express";
import { BaseController } from "./BaseController";
import { ICloudStorageService } from "@/types/services";
export abstract class BaseCloudController extends BaseController {
constructor(protected cloudService: ICloudStorageService) {
super();
}
async getShareInfo(req: Request, res: Response): Promise<void> {
await this.handleRequest(req, res, async () => {
const { shareCode, receiveCode } = req.query;
// await this.cloudService.setCookie(req);
return await this.cloudService.getShareInfo(shareCode as string, receiveCode as string);
});
}
async getFolderList(req: Request, res: Response): Promise<void> {
await this.handleRequest(req, res, async () => {
const { parentCid } = req.query;
await this.cloudService.setCookie(req);
return await this.cloudService.getFolderList(parentCid as string);
});
}
async saveFile(req: Request, res: Response): Promise<void> {
await this.handleRequest(req, res, async () => {
await this.cloudService.setCookie(req);
return await this.cloudService.saveSharedFile(req.body);
});
}
}

View File

@@ -0,0 +1,24 @@
import { Request, Response } from "express";
import { ApiResponse } from "../core/ApiResponse";
interface ApiResponseData<T> {
data?: T;
message?: string;
}
export abstract class BaseController {
protected async handleRequest<T>(
req: Request,
res: Response,
action: () => Promise<ApiResponseData<T> | void>
): Promise<void> {
try {
const result = await action();
if (result) {
res.json(ApiResponse.success(result.data, result.message));
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "未知错误";
res.status(200).json(ApiResponse.error(errorMessage));
}
}
}

View File

@@ -1,60 +1,11 @@
import { Request, Response } from "express";
import { Cloud115Service } from "../services/Cloud115Service";
import { sendSuccess, sendError } from "../utils/response";
import UserSetting from "../models/UserSetting";
import { injectable, inject } from "inversify";
import { TYPES } from "../core/types";
import { BaseCloudController } from "./BaseCloudController";
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");
@injectable()
export class Cloud115Controller extends BaseCloudController {
constructor(@inject(TYPES.Cloud115Service) cloud115Service: Cloud115Service) {
super(cloud115Service);
}
};
export const cloud115Controller = {
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);
sendSuccess(res, result);
} catch (error) {
console.log(error);
sendError(res, { message: (error as Error).message || "获取分享信息失败" });
}
},
async getFolderList(req: Request, res: Response): Promise<void> {
try {
const { parentCid } = req.query;
await setCookie(req);
const result = await cloud115.getFolderList(parentCid as string);
sendSuccess(res, result);
} catch (error) {
sendError(res, { message: (error as Error).message || "获取目录列表失败" });
}
},
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,
});
sendSuccess(res, result);
} catch (error) {
sendError(res, { message: (error as Error).message || "保存文件失败" });
}
},
};
export const Cloud115ServiceInstance = cloud115;

View File

@@ -1,22 +1,25 @@
import { Request, Response } from "express";
import DoubanService from "../services/DoubanService";
import { sendSuccess, sendError } from "../utils/response";
import { injectable, inject } from "inversify";
import { TYPES } from "../core/types";
import { DoubanService } from "../services/DoubanService";
import { BaseController } from "./BaseController";
const doubanService = new DoubanService();
@injectable()
export class DoubanController extends BaseController {
constructor(@inject(TYPES.DoubanService) private doubanService: DoubanService) {
super();
}
export const doubanController = {
async getDoubanHotList(req: Request, res: Response): Promise<void> {
try {
await this.handleRequest(req, res, async () => {
const { type = "movie", tag = "热门", page_limit = "50", page_start = "0" } = req.query;
const result = await doubanService.getHotList({
const result = await this.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: "获取热门列表失败" });
return result;
});
}
}
},
};

View File

@@ -1,52 +1,12 @@
import { Request, Response } from "express";
import { injectable, inject } from "inversify";
import { TYPES } from "../core/types";
import { QuarkService } from "../services/QuarkService";
import { sendSuccess, sendError } from "../utils/response";
import UserSetting from "../models/UserSetting";
import { BaseCloudController } from "./BaseCloudController";
const quark = new QuarkService();
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");
@injectable()
export class QuarkController extends BaseCloudController {
constructor(@inject(TYPES.QuarkService) quarkService: QuarkService) {
super(quarkService);
}
};
export const quarkController = {
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);
sendSuccess(res, result);
} catch (error) {
sendError(res, { message: "获取分享信息失败" });
}
},
async getFolderList(req: Request, res: Response): Promise<void> {
try {
const { parentCid } = req.query;
await setCookie(req);
const result = await quark.getFolderList(parentCid as string);
sendSuccess(res, result);
} catch (error) {
sendError(res, { message: (error as Error).message || "获取目录列表失败" });
}
},
async saveFile(req: Request, res: Response): Promise<void> {
try {
await setCookie(req);
const result = await quark.saveSharedFile(req.body);
sendSuccess(res, result);
} catch (error) {
sendError(res, { message: (error as Error).message || "保存文件失败" });
}
},
};

View File

@@ -1,21 +1,23 @@
import { Request, Response } from "express";
import Searcher from "../services/Searcher";
import { sendSuccess, sendError } from "../utils/response";
import { injectable, inject } from "inversify";
import { TYPES } from "../core/types";
import { Searcher } from "../services/Searcher";
import { BaseController } from "./BaseController";
@injectable()
export class ResourceController extends BaseController {
constructor(@inject(TYPES.Searcher) private searcher: Searcher) {
super();
}
export const resourceController = {
async search(req: Request, res: Response): Promise<void> {
try {
const { keyword, channelId = "", lastMessageId = "" } = req.query; // Remove `: string` from here
const result = await Searcher.searchAll(
await this.handleRequest(req, res, async () => {
const { keyword, channelId = "", lastMessageId = "" } = req.query;
return await this.searcher.searchAll(
keyword as string,
channelId as string,
lastMessageId as string
);
sendSuccess(res, result);
} catch (error) {
sendError(res, {
message: (error as Error).message || "搜索资源失败",
});
}
},
};
}

View File

@@ -1,59 +1,28 @@
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";
import { injectable, inject } from "inversify";
import { TYPES } from "../core/types";
import { SettingService } from "../services/SettingService";
import { BaseController } from "./BaseController";
@injectable()
export class SettingController extends BaseController {
constructor(@inject(TYPES.SettingService) private settingService: SettingService) {
super();
}
export const settingController = {
async get(req: Request, res: Response): Promise<void> {
try {
await this.handleRequest(req, res, async () => {
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,
},
const role = Number(req.user?.role);
return await this.settingService.getSettings(userId, role);
});
} 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 {
await this.handleRequest(req, res, async () => {
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: "保存成功",
const role = Number(req.user?.role);
return await this.settingService.saveSettings(userId, role, req.body);
});
}
} catch (error) {
console.log("保存设置失败:" + error);
sendError(res, { message: (error as Error).message || "保存设置失败" });
}
},
};

View File

@@ -0,0 +1,18 @@
import { Request, Response } from "express";
import { injectable, inject } from "inversify";
import { TYPES } from "../core/types";
import { SponsorsService } from "../services/SponsorsService";
import { BaseController } from "./BaseController";
@injectable()
export class SponsorsController extends BaseController {
constructor(@inject(TYPES.SponsorsService) private sponsorsService: SponsorsService) {
super();
}
async get(req: Request, res: Response): Promise<void> {
await this.handleRequest(req, res, async () => {
return await this.sponsorsService.getSponsors();
});
}
}

View File

@@ -1,78 +1,30 @@
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";
import { Request, Response } from "express";
import { injectable, inject } from "inversify";
import { TYPES } from "../core/types";
import { ImageService } from "../services/ImageService";
import { BaseController } from "./BaseController";
export class ImageControll {
private axiosInstance: AxiosInstance | null = null;
private settings: GlobalSetting | null = null;
constructor() {
this.initializeAxiosInstance();
@injectable()
export class ImageController extends BaseController {
constructor(@inject(TYPES.ImageService) private imageService: ImageService) {
super();
}
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,
async getImages(req: Request, res: Response): Promise<void> {
await this.handleRequest(req, res, async () => {
const url = decodeURIComponent((req.query.url as string) || "");
const response = await this.imageService.getImages(url);
// 设置正确的响应头
res.setHeader("Content-Type", response.headers["content-type"]);
res.setHeader("Cache-Control", "no-cache");
// 确保清除任何可能导致304响应的头信息
res.removeHeader("etag");
res.removeHeader("last-modified");
// 直接传输图片数据
response.data.pipe(res);
});
}
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

@@ -1,62 +1,26 @@
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";
import { injectable, inject } from "inversify";
import { TYPES } from "../core/types";
import { UserService } from "../services/UserService";
import { BaseController } from "./BaseController";
@injectable()
export class UserController extends BaseController {
constructor(@inject(TYPES.UserService) private userService: UserService) {
super();
}
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> {
await this.handleRequest(req, res, async () => {
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: "用户注册成功",
return await this.userService.register(username, password, registerCode);
});
} catch (error) {
sendError(res, { message: (error as Error).message || "用户注册失败" });
}
},
async login(req: Request, res: Response): Promise<void> {
await this.handleRequest(req, res, async () => {
const { username, password } = req.body;
const user = await User.findOne({ where: { username } });
if (!user || !(await bcrypt.compare(password, user.password))) {
return sendError(res, { message: "用户名或密码错误" });
return await this.userService.login(username, password);
});
}
}
const token = jwt.sign({ userId: user.userId, role: user.role }, config.jwtSecret, {
expiresIn: "6h",
});
sendSuccess(res, {
data: {
token,
},
});
},
};

View File

@@ -0,0 +1,21 @@
export class ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
code: number;
private constructor(success: boolean, code: number, data?: T, message?: string) {
this.success = success;
this.code = code;
this.data = data;
this.message = message;
}
static success<T>(data?: T, message = "操作成功"): ApiResponse<T> {
return new ApiResponse(true, 0, data, message);
}
static error(message: string, code = 10000): ApiResponse<null> {
return new ApiResponse(false, code, null, message);
}
}

20
backend/src/core/types.ts Normal file
View File

@@ -0,0 +1,20 @@
export const TYPES = {
DatabaseService: Symbol.for("DatabaseService"),
Cloud115Service: Symbol.for("Cloud115Service"),
QuarkService: Symbol.for("QuarkService"),
Searcher: Symbol.for("Searcher"),
DoubanService: Symbol.for("DoubanService"),
ImageService: Symbol.for("ImageService"),
SettingService: Symbol.for("SettingService"),
UserService: Symbol.for("UserService"),
SponsorsService: Symbol.for("SponsorsService"),
Cloud115Controller: Symbol.for("Cloud115Controller"),
QuarkController: Symbol.for("QuarkController"),
ResourceController: Symbol.for("ResourceController"),
DoubanController: Symbol.for("DoubanController"),
ImageController: Symbol.for("ImageController"),
SettingController: Symbol.for("SettingController"),
UserController: Symbol.for("UserController"),
SponsorsController: Symbol.for("SponsorsController"),
};

View File

@@ -0,0 +1,45 @@
import { Container } from "inversify";
import { TYPES } from "./core/types";
// Services
import { DatabaseService } from "./services/DatabaseService";
import { Cloud115Service } from "./services/Cloud115Service";
import { QuarkService } from "./services/QuarkService";
import { Searcher } from "./services/Searcher";
import { DoubanService } from "./services/DoubanService";
import { UserService } from "./services/UserService";
import { ImageService } from "./services/ImageService";
import { SettingService } from "./services/SettingService";
import { SponsorsService } from "./services/SponsorsService";
// Controllers
import { Cloud115Controller } from "./controllers/cloud115";
import { QuarkController } from "./controllers/quark";
import { ResourceController } from "./controllers/resource";
import { DoubanController } from "./controllers/douban";
import { ImageController } from "./controllers/teleImages";
import { SettingController } from "./controllers/setting";
import { UserController } from "./controllers/user";
import { SponsorsController } from "./controllers/sponsors";
const container = new Container();
// Services
container.bind<DatabaseService>(TYPES.DatabaseService).to(DatabaseService).inSingletonScope();
container.bind<Cloud115Service>(TYPES.Cloud115Service).to(Cloud115Service).inSingletonScope();
container.bind<QuarkService>(TYPES.QuarkService).to(QuarkService).inSingletonScope();
container.bind<Searcher>(TYPES.Searcher).to(Searcher).inSingletonScope();
container.bind<ImageService>(TYPES.ImageService).to(ImageService).inSingletonScope();
container.bind<SettingService>(TYPES.SettingService).to(SettingService).inSingletonScope();
container.bind<DoubanService>(TYPES.DoubanService).to(DoubanService).inSingletonScope();
container.bind<UserService>(TYPES.UserService).to(UserService).inSingletonScope();
container.bind<SponsorsService>(TYPES.SponsorsService).to(SponsorsService).inSingletonScope();
// Controllers
container.bind<Cloud115Controller>(TYPES.Cloud115Controller).to(Cloud115Controller);
container.bind<QuarkController>(TYPES.QuarkController).to(QuarkController);
container.bind<ResourceController>(TYPES.ResourceController).to(ResourceController);
container.bind<DoubanController>(TYPES.DoubanController).to(DoubanController);
container.bind<ImageController>(TYPES.ImageController).to(ImageController);
container.bind<SettingController>(TYPES.SettingController).to(SettingController);
container.bind<UserController>(TYPES.UserController).to(UserController);
container.bind<SponsorsController>(TYPES.SponsorsController).to(SponsorsController);
export { container };

View File

@@ -16,7 +16,7 @@ export const authMiddleware = async (
res: Response,
next: NextFunction
): Promise<void | Response> => {
if (req.path === "/user/login" || req.path === "/user/register") {
if (req.path === "/user/login" || req.path === "/user/register" || req.path === "/tele-images/") {
return next();
}

View File

@@ -0,0 +1,15 @@
import { Request, Response, NextFunction } from "express";
export const cors = () => {
return (req: Request, res: Response, next: NextFunction) => {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.header("Access-Control-Allow-Headers", "Content-Type, Authorization, Cookie");
res.header("Access-Control-Allow-Credentials", "true");
if (req.method === "OPTIONS") {
return res.sendStatus(200);
}
next();
};
};

View File

@@ -5,7 +5,6 @@ interface CustomError extends Error {
}
export const errorHandler = (err: CustomError, req: Request, res: Response): void => {
console.error(err);
res.status(err.status || 500).json({
success: false,
error: err.message || "服务器内部错误",

View File

@@ -0,0 +1,14 @@
import { Application } from "express";
import express from "express";
import { authMiddleware } from "./auth";
import { requestLogger } from "./requestLogger";
import { rateLimiter } from "./rateLimiter";
import { cors } from "./cors";
export const setupMiddlewares = (app: Application) => {
app.use(express.json());
app.use(cors());
app.use(requestLogger());
app.use(rateLimiter());
app.use(authMiddleware);
};

View File

@@ -0,0 +1,27 @@
import { Request, Response, NextFunction } from "express";
const requestCounts = new Map<string, { count: number; timestamp: number }>();
const WINDOW_MS = 60 * 1000; // 1分钟窗口
const MAX_REQUESTS = 600; // 每个IP每分钟最多60个请求
export const rateLimiter = () => {
return (req: Request, res: Response, next: NextFunction) => {
const ip = req.ip || req.socket.remoteAddress || "unknown";
const now = Date.now();
const record = requestCounts.get(ip) || { count: 0, timestamp: now };
if (now - record.timestamp > WINDOW_MS) {
record.count = 0;
record.timestamp = now;
}
record.count++;
requestCounts.set(ip, record);
if (record.count > MAX_REQUESTS) {
return res.status(429).json({ message: "请求过于频繁,请稍后再试" });
}
next();
};
};

View File

@@ -0,0 +1,23 @@
import { Request, Response, NextFunction } from "express";
import { logger } from "../utils/logger";
const excludePaths = ["/tele-images/"];
export const requestLogger = () => {
return (req: Request, res: Response, next: NextFunction) => {
const start = Date.now();
res.on("finish", () => {
if (excludePaths.includes(req.path)) {
return;
}
const duration = Date.now() - start;
logger.info({
method: req.method,
path: req.path,
status: res.statusCode,
duration: `${duration}ms`,
});
});
next();
};
};

View File

@@ -1,36 +1,55 @@
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";
import { Router } from "express";
import { container } from "../inversify.config";
import { TYPES } from "../core/types";
import { Cloud115Controller } from "../controllers/cloud115";
import { QuarkController } from "../controllers/quark";
import { ResourceController } from "../controllers/resource";
import { DoubanController } from "../controllers/douban";
import { ImageController } from "../controllers/teleImages";
import { SettingController } from "../controllers/setting";
import { UserController } from "../controllers/user";
import { SponsorsController } from "../controllers/sponsors";
const router = express.Router();
const router = Router();
// 获取控制器实例
const cloud115Controller = container.get<Cloud115Controller>(TYPES.Cloud115Controller);
const quarkController = container.get<QuarkController>(TYPES.QuarkController);
const resourceController = container.get<ResourceController>(TYPES.ResourceController);
const doubanController = container.get<DoubanController>(TYPES.DoubanController);
const imageController = container.get<ImageController>(TYPES.ImageController);
const settingController = container.get<SettingController>(TYPES.SettingController);
const userController = container.get<UserController>(TYPES.UserController);
const sponsorsController = container.get<SponsorsController>(TYPES.SponsorsController);
// 用户相关路由
router.use("/user", userRoutes);
router.post("/user/login", (req, res) => userController.login(req, res));
router.post("/user/register", (req, res) => userController.register(req, res));
router.use("/tele-images", imageControll.getImages);
// 图片相关路由
router.get("/tele-images", (req, res) => imageController.getImages(req, res));
// 设置相关路由
router.use("/setting", settingRoutes);
router.get("/setting/get", (req, res) => settingController.get(req, res));
router.post("/setting/save", (req, res) => settingController.save(req, res));
// 资源搜索
router.get("/search", resourceController.search);
router.get("/search", (req, res) => resourceController.search(req, res));
// 获取赞助者列表
router.get("/sponsors", (req, res) => sponsorsController.get(req, res));
// 115网盘相关
router.get("/cloud115/share-info", cloud115Controller.getShareInfo);
router.get("/cloud115/folders", cloud115Controller.getFolderList);
router.post("/cloud115/save", cloud115Controller.saveFile);
router.get("/cloud115/share-info", (req, res) => cloud115Controller.getShareInfo(req, res));
router.get("/cloud115/folders", (req, res) => cloud115Controller.getFolderList(req, res));
router.post("/cloud115/save", (req, res) => cloud115Controller.saveFile(req, res));
// 夸克网盘相关
router.get("/quark/share-info", quarkController.getShareInfo);
router.get("/quark/folders", quarkController.getFolderList);
router.post("/quark/save", quarkController.saveFile);
router.get("/quark/share-info", (req, res) => quarkController.getShareInfo(req, res));
router.get("/quark/folders", (req, res) => quarkController.getFolderList(req, res));
router.post("/quark/save", (req, res) => quarkController.saveFile(req, res));
// 获取豆瓣热门列表
router.get("/douban/hot", doubanController.getDoubanHotList);
router.get("/douban/hot", (req, res) => doubanController.getDoubanHotList(req, res));
export default router;

View File

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

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

@@ -1,8 +1,11 @@
import { AxiosHeaders, AxiosInstance } from "axios"; // 导入 AxiosHeaders
import { createAxiosInstance } from "../utils/axiosInstance";
import { Logger } from "../utils/logger";
import { config } from "../config/index";
import { ShareInfoResponse } from "../types/cloud115";
import { ShareInfoResponse, FolderListResponse, SaveFileParams } from "../types/cloud";
import { injectable } from "inversify";
import { Request } from "express";
import UserSetting from "../models/UserSetting";
import { ICloudStorageService } from "@/types/services";
import { logger } from "../utils/logger";
interface Cloud115ListItem {
cid: string;
@@ -16,16 +19,12 @@ interface Cloud115FolderItem {
ns: number;
}
interface Cloud115PathItem {
cid: string;
name: string;
}
export class Cloud115Service {
@injectable()
export class Cloud115Service implements ICloudStorageService {
private api: AxiosInstance;
private cookie: string = "";
constructor(cookie?: string) {
constructor() {
this.api = createAxiosInstance(
"https://webapi.115.com",
AxiosHeaders.from({
@@ -45,19 +44,23 @@ export class Cloud115Service {
"Accept-Language": "zh-CN,zh;q=0.9",
})
);
if (cookie) {
this.setCookie(cookie);
} else {
console.log("请注意:115网盘需要提供cookie进行身份验证");
}
this.api.interceptors.request.use((config) => {
config.headers.cookie = cookie || this.cookie;
config.headers.cookie = this.cookie;
return config;
});
}
public setCookie(cookie: string): void {
this.cookie = cookie;
async setCookie(req: Request): Promise<void> {
const userId = req.user?.userId;
const userSetting = await UserSetting.findOne({
where: { userId },
});
if (userSetting && userSetting.dataValues.cloud115Cookie) {
this.cookie = userSetting.dataValues.cloud115Cookie;
} else {
throw new Error("请先设置115网盘cookie");
}
}
async getShareInfo(shareCode: string, receiveCode = ""): Promise<ShareInfoResponse> {
@@ -72,19 +75,21 @@ export class Cloud115Service {
});
if (response.data?.state && response.data.data?.list?.length > 0) {
return {
data: response.data.data.list.map((item: Cloud115ListItem) => ({
data: {
list: response.data.data.list.map((item: Cloud115ListItem) => ({
fileId: item.cid,
fileName: item.n,
fileSize: item.s,
})),
},
};
}
} else {
logger.error("未找到文件信息:", response.data);
throw new Error("未找到文件信息");
}
}
async getFolderList(
parentCid = "0"
): Promise<{ data: { cid: string; name: string; path: Cloud115PathItem[] }[] }> {
async getFolderList(parentCid = "0"): Promise<FolderListResponse> {
const response = await this.api.get("/files", {
params: {
aid: 1,
@@ -115,33 +120,27 @@ export class Cloud115Service {
})),
};
} else {
Logger.error("获取目录列表失败:", response.data.error);
logger.error("获取目录列表失败:", response.data.error);
throw new Error("获取115pan目录列表失败:" + response.data.error);
}
}
async saveSharedFile(params: {
cid: string;
shareCode: string;
receiveCode: string;
fileId: string;
}): Promise<{ message: string; data: unknown }> {
async saveSharedFile(params: SaveFileParams): 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,
cid: params.folderId || "",
share_code: params.shareCode || "",
receive_code: params.receiveCode || "",
file_id: params.fids?.[0] || "",
});
const response = await this.api.post("/share/receive", param.toString());
Logger.info("保存文件:", response.data);
logger.info("保存文件:", response.data);
if (response.data.state) {
return {
message: response.data.error,
data: response.data.data,
};
} else {
Logger.error("保存文件失败:", response.data.error);
logger.error("保存文件失败:", response.data.error);
throw new Error("保存115pan文件失败:" + response.data.error);
}
}

View File

@@ -0,0 +1,62 @@
import { Sequelize, QueryTypes } from "sequelize";
import GlobalSetting from "../models/GlobalSetting";
import { Searcher } from "./Searcher";
import sequelize from "../config/database";
// 全局设置默认值
const DEFAULT_GLOBAL_SETTINGS = {
httpProxyHost: "127.0.0.1",
httpProxyPort: 7890,
isProxyEnabled: false,
CommonUserCode: 9527,
AdminUserCode: 230713,
};
export class DatabaseService {
private sequelize: Sequelize;
constructor() {
this.sequelize = sequelize;
}
async initialize(): Promise<void> {
try {
await this.sequelize.query("PRAGMA foreign_keys = OFF");
await this.cleanupBackupTables();
await this.sequelize.sync({ alter: true });
await this.sequelize.query("PRAGMA foreign_keys = ON");
await this.initializeGlobalSettings();
} catch (error) {
throw new Error(`数据库初始化失败: ${(error as Error).message}`);
}
}
private async initializeGlobalSettings(): Promise<void> {
try {
const settings = await GlobalSetting.findOne();
if (!settings) {
await GlobalSetting.create(DEFAULT_GLOBAL_SETTINGS);
console.log("✅ Global settings initialized with default values.");
}
await Searcher.updateAxiosInstance();
} catch (error) {
console.error("❌ Failed to initialize global settings:", error);
throw error;
}
}
private async cleanupBackupTables(): Promise<void> {
const backupTables = await this.sequelize.query<{ name: string }>(
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%\\_backup%' ESCAPE '\\'",
{ type: QueryTypes.SELECT }
);
for (const table of backupTables) {
if (table?.name) {
await this.sequelize.query(`DROP TABLE IF EXISTS ${table.name}`);
}
}
}
// ... 其他数据库相关方法
}

View File

@@ -10,7 +10,7 @@ interface DoubanSubject {
is_new: boolean;
}
class DoubanService {
export class DoubanService {
private baseUrl: string;
private api: AxiosInstance;
@@ -62,5 +62,3 @@ class DoubanService {
}
}
}
export default DoubanService;

View File

@@ -0,0 +1,68 @@
import { injectable } from "inversify";
import axios, { AxiosInstance } from "axios";
import tunnel from "tunnel";
import GlobalSetting from "../models/GlobalSetting";
import { GlobalSettingAttributes } from "../models/GlobalSetting";
@injectable()
export class ImageService {
private axiosInstance: AxiosInstance | null = null;
constructor() {
// 移除构造函数中的初始化,改为懒加载
}
private async ensureAxiosInstance(): Promise<AxiosInstance> {
if (!this.axiosInstance) {
const settings = await GlobalSetting.findOne();
const globalSetting = settings?.dataValues || ({} as GlobalSettingAttributes);
this.axiosInstance = axios.create({
timeout: 30000,
headers: {
Accept: "image/*, */*",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
},
withCredentials: false,
maxRedirects: 5,
httpsAgent: globalSetting.isProxyEnabled
? tunnel.httpsOverHttp({
proxy: {
host: globalSetting.httpProxyHost,
port: globalSetting.httpProxyPort,
headers: {
"Proxy-Authorization": "",
},
},
})
: undefined,
});
this.axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
throw error;
}
);
}
return this.axiosInstance;
}
async updateAxiosInstance(): Promise<void> {
this.axiosInstance = null;
await this.ensureAxiosInstance();
}
async getImages(url: string): Promise<any> {
const axiosInstance = await this.ensureAxiosInstance();
return await axiosInstance.get(url, {
responseType: "stream",
validateStatus: (status) => status >= 200 && status < 300,
headers: {
Referer: new URL(url).origin,
},
});
}
}

View File

@@ -1,6 +1,16 @@
import { AxiosInstance, AxiosHeaders } from "axios";
import { Logger } from "../utils/logger";
import { logger } from "../utils/logger";
import { createAxiosInstance } from "../utils/axiosInstance";
import { injectable } from "inversify";
import { Request } from "express";
import UserSetting from "../models/UserSetting";
import {
ShareInfoResponse,
FolderListResponse,
QuarkFolderItem,
SaveFileParams,
} from "../types/cloud";
import { ICloudStorageService } from "@/types/services";
interface QuarkShareInfo {
stoken?: string;
@@ -14,17 +24,12 @@ interface QuarkShareInfo {
}[];
}
interface QuarkFolderItem {
fid: string;
file_name: string;
file_type: number;
}
export class QuarkService {
@injectable()
export class QuarkService implements ICloudStorageService {
private api: AxiosInstance;
private cookie: string = "";
constructor(cookie?: string) {
constructor() {
this.api = createAxiosInstance(
"https://drive-h.quark.cn",
AxiosHeaders.from({
@@ -41,22 +46,26 @@ 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;
config.headers.cookie = this.cookie;
return config;
});
}
public setCookie(cookie: string): void {
this.cookie = cookie;
async setCookie(req: Request): Promise<void> {
const userId = req.user?.userId;
const userSetting = await UserSetting.findOne({
where: { userId },
});
if (userSetting && userSetting.dataValues.quarkCookie) {
this.cookie = userSetting.dataValues.quarkCookie;
} else {
throw new Error("请先设置夸克网盘cookie");
}
}
async getShareInfo(pwdId: string, passcode = ""): Promise<{ data: QuarkShareInfo }> {
async getShareInfo(pwdId: string, passcode = ""): Promise<ShareInfoResponse> {
const response = await this.api.post(
`/1/clouddrive/share/sharepage/token?pr=ucpro&fr=pc&uc_param_str=&__dt=994&__t=${Date.now()}`,
{
@@ -76,7 +85,7 @@ export class QuarkService {
throw new Error("获取夸克分享信息失败");
}
async getShareList(pwdId: string, stoken: string): Promise<QuarkShareInfo> {
async getShareList(pwdId: string, stoken: string): Promise<ShareInfoResponse["data"]> {
const response = await this.api.get("/1/clouddrive/share/sharepage/detail", {
params: {
pr: "ucpro",
@@ -117,9 +126,7 @@ export class QuarkService {
}
}
async getFolderList(
parentCid = "0"
): Promise<{ data: { cid: string; name: string; path: [] }[] }> {
async getFolderList(parentCid = "0"): Promise<FolderListResponse> {
const response = await this.api.get("/1/clouddrive/file/sort", {
params: {
pr: "ucpro",
@@ -148,24 +155,25 @@ export class QuarkService {
};
} else {
const message = "获取夸克目录列表失败:" + response.data.error;
Logger.error(message);
logger.error(message);
throw new Error(message);
}
}
async saveSharedFile(params: {
fid_list: string[];
fid_token_list: string[];
to_pdir_fid: string;
pwd_id: string;
stoken: string;
pdir_fid: string;
scene: string;
}): Promise<{ message: string; data: unknown }> {
async saveSharedFile(params: SaveFileParams): Promise<{ message: string; data: unknown }> {
const quarkParams = {
fid_list: params.fids,
fid_token_list: params.fidTokens,
to_pdir_fid: params.folderId,
pwd_id: params.shareCode,
stoken: params.receiveCode,
pdir_fid: "0",
scene: "link",
};
try {
const response = await this.api.post(
`/1/clouddrive/share/sharepage/save?pr=ucpro&fr=pc&uc_param_str=&__dt=208097&__t=${Date.now()}`,
params
quarkParams
);
return {

View File

@@ -4,7 +4,8 @@ import GlobalSetting from "../models/GlobalSetting";
import { GlobalSettingAttributes } from "../models/GlobalSetting";
import * as cheerio from "cheerio";
import { config } from "../config";
import { Logger } from "../utils/logger";
import { logger } from "../utils/logger";
import { injectable } from "inversify";
interface sourceItem {
messageId?: string;
@@ -20,20 +21,23 @@ interface sourceItem {
cloudType?: string;
}
@injectable()
export class Searcher {
private axiosInstance: AxiosInstance | null = null;
private static instance: Searcher;
private api: AxiosInstance | null = null;
constructor() {
this.initializeAxiosInstance();
this.initAxiosInstance();
Searcher.instance = this;
}
private async initializeAxiosInstance(isUpdate = false): Promise<void> {
let settings = null;
private async initAxiosInstance(isUpdate: boolean = false) {
let globalSetting = {} as GlobalSettingAttributes;
if (isUpdate) {
settings = await GlobalSetting.findOne();
const settings = await GlobalSetting.findOne();
globalSetting = settings?.dataValues || ({} as GlobalSettingAttributes);
}
const globalSetting = settings?.dataValues || ({} as GlobalSettingAttributes);
this.axiosInstance = createAxiosInstance(
this.api = createAxiosInstance(
config.telegram.baseUrl,
AxiosHeaders.from({
accept:
@@ -56,8 +60,9 @@ export class Searcher {
: undefined
);
}
public async updateAxiosInstance() {
await this.initializeAxiosInstance(true);
public static async updateAxiosInstance(): Promise<void> {
await Searcher.instance.initAxiosInstance(true);
}
private extractCloudLinks(text: string): { links: string[]; cloudType: string } {
@@ -67,7 +72,7 @@ export class Searcher {
const matches = text.match(pattern);
if (matches) {
links.push(...matches);
cloudType = Object.keys(config.cloudPatterns)[index];
if (!cloudType) cloudType = Object.keys(config.cloudPatterns)[index];
}
});
return {
@@ -77,19 +82,19 @@ export class Searcher {
}
async searchAll(keyword: string, channelId?: string, messageId?: string) {
const allResults = [];
const allResults: any[] = [];
const channelList = channelId
? config.rss.channels.filter((channel) => channel.id === channelId)
: config.rss.channels;
const channelList: any[] = channelId
? config.telegram.channels.filter((channel: any) => channel.id === channelId)
: config.telegram.channels;
for (let i = 0; i < channelList.length; i++) {
const channel = channelList[i];
// 使用Promise.all进行并行请求
const searchPromises = channelList.map(async (channel) => {
try {
const messageIdparams = messageId ? `before=${messageId}` : "";
const url = `/${channel.id}${keyword ? `?q=${encodeURIComponent(keyword)}&${messageIdparams}` : `?${messageIdparams}`}`;
console.log(`Searching in channel ${channel.name} with URL: ${url}`);
const results = await this.searchInWeb(url);
return this.searchInWeb(url).then((results) => {
console.log(`Found ${results.items.length} items in channel ${channel.name}`);
if (results.items.length > 0) {
const channelResults = results.items
@@ -109,10 +114,14 @@ export class Searcher {
id: channel.id,
});
}
});
} catch (error) {
Logger.error(`搜索频道 ${channel.name} 失败:`, error);
}
logger.error(`搜索频道 ${channel.name} 失败:`, error);
}
});
// 等待所有请求完成
await Promise.all(searchPromises);
return {
data: allResults,
@@ -121,10 +130,10 @@ export class Searcher {
async searchInWeb(url: string) {
try {
if (!this.axiosInstance) {
if (!this.api) {
throw new Error("Axios instance is not initialized");
}
const response = await this.axiosInstance.get(url);
const response = await this.api.get(url);
const html = response.data;
const $ = cheerio.load(html);
const items: sourceItem[] = [];
@@ -201,7 +210,7 @@ export class Searcher {
});
return { items: items, channelLogo };
} catch (error) {
Logger.error(`搜索错误: ${url}`, error);
logger.error(`搜索错误: ${url}`, error);
return {
items: [],
channelLogo: "",

View File

@@ -0,0 +1,59 @@
import { injectable, inject } from "inversify";
import { TYPES } from "../core/types";
import UserSetting from "../models/UserSetting";
import GlobalSetting from "../models/GlobalSetting";
import { Searcher } from "./Searcher";
import { ImageService } from "./ImageService";
@injectable()
export class SettingService {
constructor(@inject(TYPES.ImageService) private imageService: ImageService) {}
async getSettings(userId: string | undefined, role: number | undefined) {
if (!userId) {
throw new Error("用户ID无效");
}
let userSettings = await UserSetting.findOne({ where: { userId: userId.toString() } });
if (!userSettings) {
userSettings = await UserSetting.create({
userId: userId.toString(),
cloud115Cookie: "",
quarkCookie: "",
});
}
const globalSetting = await GlobalSetting.findOne();
return {
data: {
userSettings,
globalSetting: role === 1 ? globalSetting : null,
},
};
}
async saveSettings(userId: string | undefined, role: number | undefined, settings: any) {
if (!userId) {
throw new Error("用户ID无效");
}
const { userSettings, globalSetting } = settings;
await UserSetting.update(userSettings, { where: { userId: userId.toString() } });
if (role === 1 && globalSetting) {
await GlobalSetting.update(globalSetting, { where: {} });
}
await this.updateSettings();
return { message: "保存成功" };
}
async updateSettings(/* 参数 */): Promise<void> {
// ... 其他代码 ...
// 修改这一行,使用注入的实例方法而不是静态方法
await this.imageService.updateAxiosInstance();
await Searcher.updateAxiosInstance();
// ... 其他代码 ...
}
}

View File

@@ -0,0 +1,25 @@
import { injectable } from "inversify";
import { createAxiosInstance } from "../utils/axiosInstance";
import { AxiosInstance } from "axios";
import sponsors from "../sponsors/sponsors.json";
@injectable()
export class SponsorsService {
private axiosInstance: AxiosInstance;
constructor() {
this.axiosInstance = createAxiosInstance("http://oss.jiangmuxin.cn/cloudsaver/");
}
async getSponsors() {
try {
const response = await this.axiosInstance.get("sponsors.json");
return {
data: response.data.sponsors,
};
} catch (error) {
return {
data: sponsors.sponsors,
};
}
}
}

View File

@@ -0,0 +1,63 @@
import { injectable } from "inversify";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { config } from "../config";
import User from "../models/User";
import GlobalSetting from "../models/GlobalSetting";
@injectable()
export class UserService {
private isValidInput(input: string): boolean {
// 检查是否包含空格或汉字
const regex = /^[^\s\u4e00-\u9fa5]+$/;
return regex.test(input);
}
async register(username: string, password: string, registerCode: string) {
const globalSetting = await GlobalSetting.findOne();
const registerCodeList = [
globalSetting?.dataValues.CommonUserCode,
globalSetting?.dataValues.AdminUserCode,
];
if (!registerCode || !registerCodeList.includes(Number(registerCode))) {
throw new Error("注册码错误");
}
// 验证输入
if (!this.isValidInput(username) || !this.isValidInput(password)) {
throw new Error("用户名、密码或注册码不能包含空格或汉字");
}
// 检查用户名是否已存在
const existingUser = await User.findOne({ where: { username } });
if (existingUser) {
throw new Error("用户名已存在");
}
const hashedPassword = await bcrypt.hash(password, 10);
const role = registerCodeList.findIndex((x) => x === Number(registerCode));
const user = await User.create({ username, password: hashedPassword, role });
return {
data: user,
message: "用户注册成功",
};
}
async login(username: string, password: string) {
const user = await User.findOne({ where: { username } });
if (!user || !(await bcrypt.compare(password, user.password))) {
throw new Error("用户名或密码错误");
}
const token = jwt.sign({ userId: user.userId, role: user.role }, config.jwtSecret, {
expiresIn: "6h",
});
return {
data: {
token,
},
};
}
}

View File

@@ -0,0 +1,54 @@
{
"sponsors": [
{
"name": "立本狗头",
"avatar": "http://oss.jiangmuxin.cn/cloudsaver/sponsors/thanks1.jpg",
"message": "怒搓楼上狗头! "
},
{
"name": "帝国鼻屎",
"avatar": "http://oss.jiangmuxin.cn/cloudsaver/sponsors/thanks2.jpg",
"message": "芜湖起飞! "
},
{
"name": "雷霆222",
"avatar": "http://oss.jiangmuxin.cn/cloudsaver/sponsors/thanks3.jpg",
"message": "把我弄帅点 "
},
{
"name": "黑田奈奈子",
"avatar": "http://oss.jiangmuxin.cn/cloudsaver/sponsors/thanks4.jpg",
"message": "流年笑掷 未来可期 ",
"link": "https://github.com/htnanako"
},
{
"name": "原野🐇",
"avatar": "http://oss.jiangmuxin.cn/cloudsaver/sponsors/thanks5.jpg"
},
{
"name": "我摆烂!",
"avatar": "http://oss.jiangmuxin.cn/cloudsaver/sponsors/thanks6.jpg",
"message": "人生苦短,及时行乐,卷什么卷,随缘摆烂 "
},
{
"name": "田培",
"avatar": "http://oss.jiangmuxin.cn/cloudsaver/sponsors/thanks7.jpg"
},
{
"name": "River",
"avatar": "http://oss.jiangmuxin.cn/cloudsaver/sponsors/thanks8.jpg"
},
{
"name": "午夜学徒",
"avatar": "http://oss.jiangmuxin.cn/cloudsaver/sponsors/thanks9.jpg"
},
{
"name": "阿潘",
"avatar": "http://oss.jiangmuxin.cn/cloudsaver/sponsors/thanks10.jpg"
},
{
"name": "闹闹黑",
"avatar": "http://oss.jiangmuxin.cn/cloudsaver/sponsors/thanks11.jpg"
}
]
}

View File

@@ -0,0 +1,83 @@
export interface ShareInfoResponse {
data: {
list: ShareInfoItem[];
fileSize?: number;
pwdId?: string;
stoken?: string;
};
}
export interface GetShareInfoParams {
shareCode: string;
receiveCode?: string;
}
export interface ShareInfoItem {
fileId: string;
fileName: string;
fileSize?: number;
fileIdToken?: string;
}
export interface FolderListResponse {
data: {
cid: string;
name: string;
path: { cid: string; name: string }[];
}[];
}
export interface SaveFileParams {
shareCode: string; // 分享code
receiveCode?: string; // 分享文件的密码
folderId?: string; // 文件夹id
fids?: string[]; // 存储文件id
fidTokens?: string[]; // 存储文件token
}
export interface SaveFileResponse {
message: string;
data: unknown;
}
export interface ShareFileInfo {
shareCode: string;
receiveCode?: string;
fileId: string;
cid?: string;
fid_list?: string[];
fid_token_list?: string[];
to_pdir_fid?: string;
pwd_id?: string;
stoken?: string;
pdir_fid?: string;
scene?: string;
[key: string]: any;
}
export interface QuarkShareFileInfo {
fid_list: string[];
fid_token_list: string[];
to_pdir_fid: string;
pwd_id: string;
stoken: string;
pdir_fid: string;
scene: string;
}
export interface QuarkShareInfo {
stoken?: string;
pwdId?: string;
fileSize?: number;
list: {
fid: string;
file_name: string;
file_type: number;
share_fid_token: string;
}[];
}
export interface QuarkFolderItem {
fid: string;
file_name: string;
file_type: number;
}

View File

@@ -0,0 +1,15 @@
export interface Config {
app: {
port: number;
env: string;
};
database: {
type: string;
path: string;
};
jwt: {
secret: string;
expiresIn: string;
};
// ... 其他配置类型
}

View File

@@ -0,0 +1,9 @@
import { Request } from "express";
import { ShareInfoResponse, FolderListResponse, SaveFileParams } from "./cloud";
export interface ICloudStorageService {
setCookie(req: Request): Promise<void>;
getShareInfo(shareCode: string, receiveCode?: string): Promise<ShareInfoResponse>;
getFolderList(parentCid?: string): Promise<FolderListResponse>;
saveSharedFile(params: SaveFileParams): Promise<any>;
}

View File

@@ -8,12 +8,11 @@ interface ProxyConfig {
export function createAxiosInstance(
baseURL: string,
headers: AxiosRequestHeaders,
headers?: AxiosRequestHeaders,
useProxy: boolean = false,
proxyConfig?: ProxyConfig
): AxiosInstance {
let agent;
console.log(proxyConfig);
if (useProxy && proxyConfig) {
agent = tunnel.httpsOverHttp({
proxy: proxyConfig,

View File

@@ -1,5 +1,5 @@
import { Response, NextFunction } from "express";
import { Logger } from "../utils/logger";
import { logger } from "../utils/logger";
interface CustomError {
name?: string;
@@ -13,6 +13,6 @@ export default function handleError(
message: string,
next: NextFunction
) {
Logger.error(message, error);
logger.error(message, error);
next(error || { success: false, message });
}

View File

@@ -1,38 +1,21 @@
type LogLevel = "info" | "success" | "warn" | "error";
import winston from "winston";
import { config } from "../config";
export const Logger = {
info(...args: any[]) {
this.log("info", ...args);
},
const logger = winston.createLogger({
level: config.app.env === "development" ? "debug" : "info",
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
transports: [
new winston.transports.File({ filename: "logs/error.log", level: "error" }),
new winston.transports.File({ filename: "logs/combined.log" }),
],
});
success(...args: any[]) {
this.log("success", ...args);
},
warn(...args: any[]) {
this.log("warn", ...args);
},
error(...args: any[]) {
this.log("error", ...args);
},
log(level: LogLevel, ...args: any[]) {
const timestamp = new Date().toISOString();
const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
switch (level) {
case "success":
console.log(prefix, ...args);
break;
case "warn":
console.warn(prefix, ...args);
break;
case "error":
console.error(prefix, ...args);
break;
default:
console.log(prefix, ...args);
if (config.app.env !== "production") {
logger.add(
new winston.transports.Console({
format: winston.format.simple(),
})
);
}
},
};
export { logger };

View File

@@ -15,7 +15,9 @@
"typeRoots": ["./node_modules/@types", "./src/types"],
"paths": {
"@/*": ["src/*"]
}
},
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]

13
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# 如果配置目录下没有 env 文件,则复制示例文件
if [ ! -f /app/config/env ]; then
cp /app/.env.example /app/config/env
echo "已创建默认配置文件 /app/config/env请根据需要修改配置"
fi
# 创建配置文件软链接
ln -sf /app/config/env /app/.env
# 启动 Nginx 和后端服务
nginx -g 'daemon off;' & npm run start

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

BIN
docs/images/alipay.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 631 KiB

BIN
docs/images/mobile/save.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

BIN
docs/images/pc/detail.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 KiB

BIN
docs/images/pc/douban.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

BIN
docs/images/pc/login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

BIN
docs/images/pc/save.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

BIN
docs/images/pc/save1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

BIN
docs/images/pc/search.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
docs/images/qq.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 824 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 181 KiB

BIN
docs/images/wechat_2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

BIN
docs/images/wechat_pay.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -6,4 +6,5 @@
export {}
declare global {
const ElMessage: typeof import('element-plus/es')['ElMessage']
const showConfirmDialog: typeof import('vant/es')['showConfirmDialog']
}

View File

@@ -8,12 +8,11 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
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']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDialog: typeof import('element-plus/es')['ElDialog']
@@ -37,7 +36,6 @@ declare module 'vue' {
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/Home/FolderSelect.vue')['default']
ResourceCard: typeof import('./src/components/Home/ResourceCard.vue')['default']
ResourceSelect: typeof import('./src/components/Home/ResourceSelect.vue')['default']
@@ -45,6 +43,27 @@ declare module 'vue' {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SearchBar: typeof import('./src/components/SearchBar.vue')['default']
VanBackTop: typeof import('vant/es')['BackTop']
VanButton: typeof import('vant/es')['Button']
VanCell: typeof import('vant/es')['Cell']
VanCellGroup: typeof import('vant/es')['CellGroup']
VanCheckbox: typeof import('vant/es')['Checkbox']
VanCheckboxGroup: typeof import('vant/es')['CheckboxGroup']
VanEmpty: typeof import('vant/es')['Empty']
VanField: typeof import('vant/es')['Field']
VanForm: typeof import('vant/es')['Form']
VanIcon: typeof import('vant/es')['Icon']
VanImage: typeof import('vant/es')['Image']
VanLoading: typeof import('vant/es')['Loading']
VanOverlay: typeof import('vant/es')['Overlay']
VanPopup: typeof import('vant/es')['Popup']
VanSearch: typeof import('vant/es')['Search']
VanSwitch: typeof import('vant/es')['Switch']
VanTab: typeof import('vant/es')['Tab']
VanTabbar: typeof import('vant/es')['Tabbar']
VanTabbarItem: typeof import('vant/es')['TabbarItem']
VanTabs: typeof import('vant/es')['Tabs']
VanTag: typeof import('vant/es')['Tag']
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']

View File

@@ -8,6 +8,30 @@
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"
/>
<meta name="keywords" content="网盘,资源搜索,云存储" />
<!-- SEO关键词 -->
<meta name="description" content="网盘资源搜索工具" />
<!-- 设置Web App描述 -->
<meta name="theme-color" content="#ffffff" />
<!-- 设置主题颜色 -->
<meta property="og:title" content="CloudSaver" />
<!-- 社交媒体分享标题 -->
<meta property="og:description" content="网盘资源搜索工具" />
<!-- 社交媒体分享描述 -->
<meta property="og:url" content="https://github.com/jiangrui1994/CloudSaver" />
<!-- 社交媒体分享链接 -->
<meta name="twitter:card" content="summary" />
<!-- Twitter卡片类型 -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<!-- 开启Web App功能 -->
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<!-- 设置状态栏样式 -->
<meta name="apple-mobile-web-app-title" content="CloudSaver" />
<!-- 设置Web App标题 -->
<link rel="apple-touch-icon" href="/logo-1.png" />
<!-- 设置Web App图标 -->
<link rel="mask-icon" href="/logo.svg" color="transparent" />
<!-- 设置Web App图标遮罩 -->
<meta name="referrer" content="no-referrer" />
<title>CloudSaver</title>
</head>

BIN
frontend/logo-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

3751
frontend/logo.svg Normal file

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 282 KiB

View File

@@ -1,12 +1,12 @@
{
"name": "cloud-disk-web",
"version": "0.1.0",
"version": "0.2.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cloud-disk-web",
"version": "0.1.0",
"version": "0.2.5",
"dependencies": {
"axios": "^1.6.7",
"element-plus": "^2.6.1",

View File

@@ -1,7 +1,7 @@
{
"name": "cloud-saver-web",
"private": true,
"version": "0.1.0",
"version": "0.2.5",
"type": "module",
"scripts": {
"dev": "vite --host",
@@ -12,19 +12,25 @@
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.7",
"element-plus": "^2.6.1",
"gsap": "^3.12.7",
"pinia": "^2.1.7",
"socket.io-client": "^4.8.1",
"typeit": "^8.8.7",
"vant": "^4.9.17",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@types/node": "^20.11.25",
"@vant/auto-import-resolver": "^1.3.0",
"@vitejs/plugin-vue": "^5.0.4",
"postcss-pxtorem": "^6.1.0",
"sass": "^1.83.4",
"typescript": "^5.4.2",
"unplugin-auto-import": "^0.17.5",
"unplugin-auto-import": "^0.17.8",
"unplugin-vue-components": "^0.26.0",
"vite": "^5.1.5",
"vite-plugin-pwa": "^0.21.1",
"vue-tsc": "^2.0.6"
}
}

View File

@@ -0,0 +1,15 @@
module.exports = {
plugins: {
"postcss-pxtorem": {
rootValue({ file }) {
return file.indexOf("vant") !== -1 || file.indexOf("mobile") !== -1 ? 50 : 75;
},
propList: ["*"],
exclude: (file) => {
return !file.includes("mobile") && !file.includes("vant");
},
minPixelValue: 2,
mediaQuery: false,
},
},
};

View File

@@ -7,6 +7,10 @@
<style>
#app {
height: 100vh;
width: 100%;
height: 100%;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
:root {
--theme-color: #3e3e3e;
@@ -33,4 +37,37 @@ body {
background-color: var(--theme-background);
word-wrap: break-word;
}
body {
position: fixed;
width: 100%;
height: 100%;
overflow: hidden;
}
/* 移动端全局样式 */
@media screen and (max-width: 768px) {
#app {
max-width: 100vw;
overflow-x: hidden;
}
/* 统一按钮样式 */
.van-button {
height: 40px;
font-size: var(--font-size-base);
border-radius: var(--border-radius-base);
}
/* 统一输入框样式 */
.van-field {
font-size: var(--font-size-base);
}
/* 统一卡片样式 */
.van-card {
border-radius: var(--border-radius-base);
margin: var(--spacing-base) 0;
}
}
</style>

View File

@@ -1,10 +1,10 @@
import request from "@/utils/request";
import type { ShareInfoResponse, Folder, Save115FileParams } from "@/types";
import type { ShareInfoResponse, Folder, SaveFileParams, GetShareInfoParams } from "@/types";
export const cloud115Api = {
async getShareInfo(shareCode: string, receiveCode = "") {
async getShareInfo(params: GetShareInfoParams) {
const { data } = await request.get<ShareInfoResponse>("/api/cloud115/share-info", {
params: { shareCode, receiveCode },
params,
});
return data as ShareInfoResponse;
},
@@ -16,7 +16,7 @@ export const cloud115Api = {
return res;
},
async saveFile(params: Save115FileParams) {
async saveFile(params: SaveFileParams) {
const res = await request.post("/api/cloud115/save", params);
return res;
},

View File

@@ -1,10 +1,10 @@
import request from "@/utils/request";
import type { ShareInfoResponse, Folder, SaveQuarkFileParams } from "@/types";
import type { ShareInfoResponse, Folder, SaveFileParams, GetShareInfoParams } from "@/types";
export const quarkApi = {
async getShareInfo(pwdId: string, passcode = "") {
async getShareInfo(params: GetShareInfoParams) {
const { data } = await request.get<ShareInfoResponse>("/api/quark/share-info", {
params: { pwdId, passcode },
params,
});
return data as ShareInfoResponse;
},
@@ -16,7 +16,7 @@ export const quarkApi = {
return data;
},
async saveFile(params: SaveQuarkFileParams) {
async saveFile(params: SaveFileParams) {
return await request.post("/api/quark/save", params);
},
};

View File

@@ -4,7 +4,10 @@ export const userApi = {
login: (data: { username: string; password: string }) => {
return request.post<{ token: string }>("/api/user/login", data);
},
register: (data: { username: string; password: string }) => {
register: (data: { username: string; password: string; registerCode: string }) => {
return request.post<{ token: string }>("/api/user/register", data);
},
getSponsors: () => {
return request.get("/api/sponsors?timestamp=" + Date.now());
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -1,34 +1,38 @@
<template>
<div class="aside-menu">
<div class="logo">
<img :src="logo" class="logo-img" />
<div class="logo-text">Cloud Saver</div>
<div class="pc-aside">
<!-- Logo 区域 -->
<div class="pc-aside__logo">
<img :src="logo" alt="Cloud Saver Logo" class="logo__image" />
<h1 class="logo__title">Cloud Saver</h1>
</div>
<!-- 菜单区域 -->
<el-menu
:default-active="currentMenu?.index || '1'"
:default-openeds="currentMenuOpen"
class="el-menu-vertical"
@open="handleOpen"
@close="handleClose"
class="pc-aside__menu"
>
<template v-for="menu in menuList">
<el-sub-menu v-if="menu.children" :key="menu.index" :index="menu.index">
<template v-for="menu in menuList" :key="menu.index">
<!-- 子菜单 -->
<el-sub-menu v-if="menu.children" :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 }}
<span>{{ child.title }}</span>
</el-menu-item>
</el-sub-menu>
<!-- 普通菜单项 -->
<el-menu-item
v-else
:key="menu.router"
:index="menu.index"
:disabled="menu.disabled"
@click="handleMenuClick(menu)"
@@ -38,54 +42,82 @@
</el-menu-item>
</template>
</el-menu>
<!-- GitHub 链接 -->
<div class="pc-aside__footer">
<a :href="PROJECT_GITHUB" target="_blank" rel="noopener noreferrer" class="github-link">
<svg
height="20"
aria-hidden="true"
viewBox="0 0 24 24"
version="1.1"
width="20"
class="github-icon"
>
<path
fill="currentColor"
d="M12.5.75C6.146.75 1 5.896 1 12.25c0 5.089 3.292 9.387 7.863 10.91.575.101.79-.244.79-.546 0-.273-.014-1.178-.014-2.142-2.889.532-3.636-.704-3.866-1.35-.13-.331-.69-1.352-1.18-1.625-.402-.216-.977-.748-.014-.762.906-.014 1.553.834 1.769 1.179 1.035 1.74 2.688 1.25 3.349.948.1-.747.402-1.25.733-1.538-2.559-.287-5.232-1.279-5.232-5.678 0-1.25.445-2.285 1.178-3.09-.115-.288-.517-1.467.115-3.048 0 0 .963-.302 3.163 1.179.92-.259 1.897-.388 2.875-.388.977 0 1.955.13 2.875.388 2.2-1.495 3.162-1.179 3.162-1.179.633 1.581.23 2.76.115 3.048.733.805 1.179 1.825 1.179 3.09 0 4.413-2.688 5.39-5.247 5.678.417.36.776 1.05.776 2.128 0 1.538-.014 2.774-.014 3.162 0 .302.216.662.79.547C20.709 21.637 24 17.324 24 12.25 24 5.896 18.854.75 12.5.75Z"
/>
</svg>
<span>GitHub</span>
<span class="version">v{{ pkg.version }}</span>
</a>
</div>
</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";
<script setup lang="ts">
import { computed } from "vue";
const router = useRouter();
const route = useRoute();
import { useRouter, useRoute } from "vue-router";
import { Search, Film, Setting, Link } from "@element-plus/icons-vue";
import logo from "@/assets/images/logo.png";
import { PROJECT_GITHUB } from "@/constants/project";
import pkg from "../../package.json";
// 类型定义
interface MenuItem {
index: string;
title: string;
icon?: typeof Search | typeof Film | typeof Setting;
icon?: typeof Search | typeof Film | typeof Setting | typeof Link;
router?: string;
children?: MenuItem[];
disabled?: boolean;
}
// 路由相关
const router = useRouter();
const route = useRoute();
// 菜单配置
const menuList: MenuItem[] = [
{
index: "2",
index: "1",
title: "资源搜索",
icon: Search,
router: "/",
router: "/resource",
},
{
index: "1",
index: "2",
title: "豆瓣榜单",
icon: Film,
children: [
{
index: "1-1",
index: "2-1",
title: "热门电影",
router: "/douban?type=movie",
},
{
index: "1-2",
index: "2-2",
title: "热门电视剧",
router: "/douban?type=tv",
},
{
index: "1-3",
index: "2-3",
title: "最新电影",
router: "/douban?type=movie&tag=最新",
},
{
index: "1-4",
index: "2-4",
title: "热门综艺",
router: "/douban?type=tv&tag=综艺",
},
@@ -98,52 +130,179 @@ const menuList: MenuItem[] = [
router: "/setting",
disabled: false,
},
{
index: "4",
title: "鸣谢",
icon: Link,
router: "/thanks",
},
];
// 计算当前激活的菜单
const currentMenu = computed(() => {
return menuList
.reduce((pre: MenuItem[], cur: MenuItem) => {
if (!cur.children) {
pre.push(cur);
const flatMenus = menuList.reduce<MenuItem[]>((acc, menu) => {
if (!menu.children) {
acc.push(menu);
} else {
pre.push(...cur.children);
acc.push(...menu.children);
}
return pre;
}, [])
.find((x) => x.router === decodeURIComponent(route.fullPath));
return acc;
}, []);
return flatMenus.find((menu) => menu.router === decodeURIComponent(route.fullPath));
});
// 计算当前展开的子菜单
const currentMenuOpen = computed(() => {
if (currentMenu.value && currentMenu.value.index.length > 1) {
if (currentMenu.value?.index.includes("-")) {
return [currentMenu.value.index.split("-")[0]];
} else {
return [];
}
return [];
});
const handleOpen = (_key: string, _keyPath: string[]): void => {};
const handleClose = (_key: string, _keyPath: string[]): void => {};
const handleMenuClick = (menu: MenuItem): void => {
// 菜单点击处理
const handleMenuClick = (menu: MenuItem) => {
if (menu.router) {
router.push(menu.router);
}
};
</script>
<style lang="scss" scoped>
.el-menu-vertical {
width: 100%;
height: 100vh;
@import "@/styles/common.scss";
.pc-aside {
height: 100%;
background: var(--theme-card-bg);
border-right: 1px solid rgba(0, 0, 0, 0.1);
// Logo 区域
&__logo {
@include flex-center;
padding: 24px 16px;
gap: 12px;
.logo__image {
width: 32px;
height: 32px;
object-fit: contain;
}
.logo {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 0;
.logo-img {
width: 30px;
height: 30px;
margin-right: 15px;
.logo__title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--theme-text-primary);
@include text-overflow;
}
.logo-text {
}
// 菜单区域
&__menu {
border-right: none;
background: transparent;
:deep(.el-menu-item) {
height: 48px;
line-height: 48px;
color: var(--theme-text-regular);
&.is-active {
color: var(--theme-primary);
background: rgba(0, 102, 204, 0.1);
}
&:hover {
color: var(--theme-primary);
background: rgba(0, 102, 204, 0.05);
}
}
:deep(.el-sub-menu) {
.el-sub-menu__title {
color: var(--theme-text-regular);
&:hover {
color: var(--theme-primary);
background: rgba(0, 102, 204, 0.05);
}
}
}
:deep(.el-icon) {
font-size: 18px;
margin-right: 12px;
color: inherit;
}
}
// GitHub 链接区域
&__footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 16px;
border-top: 1px solid rgba(0, 0, 0, 0.1);
background: var(--theme-card-bg);
.github-link {
@include flex-center;
gap: 8px;
padding: 12px;
color: var(--theme-text-regular);
text-decoration: none;
border-radius: var(--theme-radius);
transition: var(--theme-transition);
.github-icon {
font-size: 20px;
transition: var(--theme-transition);
}
.version {
font-size: 12px;
opacity: 0.7;
margin-left: 4px;
}
&:hover {
color: var(--theme-primary);
background: rgba(0, 102, 204, 0.05);
transform: translateY(-1px);
.github-icon {
transform: scale(1.1);
}
.version {
opacity: 1;
}
}
}
}
}
// 自定义滚动条
.pc-aside__menu {
height: calc(100vh - 80px - 69px); // 减去 logo 高度和 footer 高度
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
&:hover {
background: rgba(0, 0, 0, 0.3);
}
}
&::-webkit-scrollbar-track {
background: transparent;
}
}
</style>

View File

@@ -1,26 +1,46 @@
<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"
<div class="folder-header">
<div class="folder-path">
<el-icon><FolderOpened /></el-icon>
<template v-if="folderPath.length">
<span
v-for="(folder, index) in folderPath"
:key="folder.cid"
class="path-item"
@click="handlePathClick(index)"
>
<template #default="{ node }">
<span class="folder-node">
<el-icon><Folder /></el-icon>
{{ node.label }}
<span class="folder-name">{{ folder.name }}</span>
<el-icon v-if="index < folderPath.length - 1"><ArrowRight /></el-icon>
</span>
</template>
</el-tree>
<span v-else class="root-path" @click="handlePathClick(-1)">根目录</span>
</div>
</div>
<div class="folder-list">
<div v-if="!folders.length" class="empty-folder">
<el-empty description="暂无文件夹" />
</div>
<div
v-for="folder in folders"
:key="folder.cid"
class="folder-item"
:class="{ 'is-selected': folder.cid === selectedFolder?.cid }"
@click="handleFolderClick(folder)"
>
<div class="folder-info">
<el-icon><Folder /></el-icon>
<span class="folder-name">{{ folder.name }}</span>
</div>
<el-icon class="arrow-icon"><ArrowRight /></el-icon>
</div>
</div>
<div v-if="loading" class="loading-overlay">
<el-icon class="loading-icon"><Loading /></el-icon>
<span>加载中...</span>
</div>
</div>
</template>
@@ -28,12 +48,9 @@
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";
import type { Folder as FolderType } from "@/types";
import { Folder, FolderOpened, ArrowRight, Loading } from "@element-plus/icons-vue";
const resourceStore = useResourceStore();
import { ElMessage } from "element-plus";
const props = defineProps({
@@ -43,96 +60,218 @@ const props = defineProps({
},
});
const treeRef = ref<TreeInstance>();
const folders = ref<Folder[]>([]);
const selectedFolder = ref<Folder | null>(null);
const loading = ref(false);
const folders = ref<FolderType[]>([]);
const selectedFolder = ref<FolderType | null>(null);
const folderPath = ref<FolderType[]>([{ name: "根目录", cid: "0" }]);
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 getList = async (cid: string = "0") => {
const api = cloudTypeApiMap[props.cloudType as keyof typeof cloudTypeApiMap];
loading.value = true;
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);
}
}
const res = await api.getFolderList?.(cid);
if (res?.code === 0) {
resolve(res.data.length ? res.data : []);
folders.value = res.data || [];
} else {
throw new Error(res.message);
throw new Error(res?.message);
}
resourceStore.setLoadTree(false);
} catch (error) {
ElMessage.error(error instanceof Error ? `${error.message}` : "获取目录失败");
// 关闭模态框
ElMessage.error(error instanceof Error ? error.message : "获取目录失败");
emit("close");
resourceStore.setLoadTree(false);
resolve([]);
} finally {
loading.value = false;
}
};
const handleNodeClick = (data: Folder) => {
selectedFolder.value = {
...data,
path: data.path ? [...data.path, data] : [data],
const handleFolderClick = async (folder: FolderType) => {
selectedFolder.value = folder;
folderPath.value = [...folderPath.value, folder];
emit("select", folder.cid);
await getList(folder.cid);
};
emit("select", data.cid);
const handlePathClick = async (index: number) => {
if (index < 0) {
// 点击根目录
folderPath.value = [{ name: "根目录", cid: "0" }];
selectedFolder.value = null;
await getList("0");
} else {
// 点击路径中的某个文件夹
const targetFolder = folderPath.value[index];
folderPath.value = folderPath.value.slice(0, index + 1);
selectedFolder.value = targetFolder;
await getList(targetFolder.cid);
emit("select", targetFolder.cid);
}
};
// 初始化加载
getList();
</script>
<style scoped>
<style lang="scss" scoped>
@import "@/styles/common.scss";
.folder-select {
position: relative;
min-height: 300px;
max-height: 500px;
overflow-y: auto;
}
display: flex;
flex-direction: column;
padding: 4px;
.folder-node {
.folder-header {
position: sticky;
top: 0;
z-index: 1;
margin-bottom: 16px;
padding: 12px 16px;
background: var(--el-fill-color-light);
border-radius: var(--theme-radius);
.folder-path {
display: flex;
align-items: center;
gap: 8px;
color: var(--theme-text-regular);
font-size: 14px;
overflow-x: auto;
&::-webkit-scrollbar {
height: 4px;
}
.folder-path {
color: #999;
font-size: 12px;
margin-left: 8px;
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
}
:deep(.el-tree-node__content) {
height: 32px;
.el-icon {
flex-shrink: 0;
font-size: 16px;
color: var(--theme-primary);
}
.folder-select-header {
.path-item {
display: flex;
align-items: center;
justify-content: flex-start;
margin-bottom: 10px;
gap: 8px;
white-space: nowrap;
cursor: pointer;
transition: var(--theme-transition);
&:hover {
color: var(--theme-primary);
.folder-name {
color: var(--theme-primary);
}
}
.folder-name {
color: var(--theme-text-primary);
}
}
.root-path {
color: var(--theme-text-secondary);
cursor: pointer;
transition: var(--theme-transition);
&:hover {
color: var(--theme-primary);
}
}
}
}
}
.folder-list {
flex: 1;
overflow-y: auto;
padding: 4px;
.folder-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-radius: var(--theme-radius);
cursor: pointer;
transition: var(--theme-transition);
&:hover {
background: var(--el-fill-color-light);
}
&.is-selected {
background: var(--el-color-primary-light-9);
color: var(--theme-primary);
.el-icon {
color: var(--theme-primary);
}
}
.folder-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
padding: 5px 10px;
border: 1px solid #e5e6e8;
border-radius: 8px;
.el-icon {
font-size: 16px;
color: var(--theme-text-regular);
}
.folder-name {
color: var(--theme-text-primary);
}
}
.arrow-icon {
font-size: 16px;
color: var(--theme-text-secondary);
}
}
}
.empty-folder {
padding: 32px 0;
}
.loading-overlay {
@include flex-center;
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(4px);
gap: 8px;
font-size: 14px;
color: var(--theme-text-regular);
.loading-icon {
font-size: 20px;
animation: rotating 2s linear infinite;
}
}
@keyframes rotating {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -1,61 +1,175 @@
<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
<div class="resource-card">
<!-- 详情弹窗 -->
<el-dialog
v-model="showDetail"
:title="currentResource?.title"
width="700px"
class="resource-detail-dialog"
destroy-on-close
>
<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">
<div v-if="currentResource" class="detail-content">
<div class="detail-cover">
<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)}`,
]"
class="cover-image"
:src="getProxyImageUrl(currentResource.image as string)"
:fit="currentResource.image ? 'cover' : 'contain'"
/>
<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]"
class="cloud-type"
:type="store.tagColor[currentResource.cloudType as keyof TagColor]"
effect="dark"
round
>
{{ resource.cloudType }}
{{ currentResource.cloudType }}
</el-tag>
<el-button @click="handleSave(resource)">转存</el-button>
</div>
<div class="detail-info">
<h3 class="detail-title">
<el-link :href="currentResource.cloudLinks[0]" target="_blank" :underline="false">
{{ currentResource.title }}
</el-link>
</h3>
<div class="detail-description" v-html="currentResource.content" />
<div v-if="currentResource.tags?.length" class="detail-tags">
<div class="tags-list">
<el-tag
v-for="tag in currentResource.tags"
:key="tag"
class="tag-item"
@click="searchMovieforTag(tag)"
>
{{ tag }}
</el-tag>
</div>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" plain @click="currentResource && handleJump(currentResource)"
>跳转</el-button
>
<el-button
v-if="currentResource?.isSupportSave"
type="primary"
@click="currentResource && handleSave(currentResource)"
>转存</el-button
>
</div>
</template>
</el-dialog>
<div v-for="group in store.resources" :key="group.id" class="resource-group">
<div
:class="{ 'group-header': true, 'is-active': group.displayList }"
@click="group.displayList = !group.displayList"
>
<el-link
class="group-title"
:href="`https://t.me/s/${group.id}`"
target="_blank"
:underline="false"
@click.stop
>
<el-image
:src="getProxyImageUrl(group.channelInfo.channelLogo)"
:fit="group.channelInfo.channelLogo ? 'cover' : 'contain'"
class="channel-logo"
scroll-container="#pc-resources-content"
loading="lazy"
/>
<span>{{ group.channelInfo.name }}</span>
<span class="item-count">({{ group.list.length }})</span>
</el-link>
<el-tooltip effect="dark" :content="group.displayList ? '收起' : '展开'" placement="top">
<el-button class="toggle-btn" type="text">
<el-icon :class="{ 'is-active': group.displayList }">
<ArrowDown />
</el-icon>
</el-button>
</el-tooltip>
</div>
<div v-if="group.displayList" class="group-content">
<div class="card-grid">
<el-card
v-for="resource in group.list"
:key="resource.messageId"
class="resource-card-item"
:body-style="{ padding: '0' }"
>
<div class="card-wrapper">
<div class="card-cover">
<el-image
loading="lazy"
class="cover-image"
:src="getProxyImageUrl(resource.image as string)"
:fit="resource.image ? 'cover' : 'contain'"
:alt="resource.title"
@click="showResourceDetail(resource)"
/>
<el-tag
class="cloud-type"
:type="store.tagColor[resource.cloudType as keyof TagColor]"
effect="dark"
round
size="small"
>
{{ resource.cloudType }}
</el-tag>
</div>
<div class="card-body">
<el-link
class="card-title"
:href="resource.cloudLinks[0]"
target="_blank"
:underline="false"
>
{{ resource.title }}
</el-link>
<div
class="card-content"
@click="showResourceDetail(resource)"
v-html="resource.content"
/>
<div v-if="resource.tags?.length" class="card-tags">
<div class="tags-list">
<el-tag
v-for="tag in resource.tags"
:key="tag"
class="tag-item"
@click="searchMovieforTag(tag)"
>
{{ tag }}
</el-tag>
</div>
</div>
<div class="card-footer">
<el-button type="primary" plain @click="handleJump(resource)">跳转</el-button>
<el-button
v-if="resource.isSupportSave"
type="primary"
@click="handleSave(resource)"
>转存</el-button
>
</div>
</div>
</div>
</el-card>
</div>
<div class="load-more">
<el-button :loading="group.loading" @click="handleLoadMore(group.id)">
<el-icon><Plus /></el-icon>
加载更多
</el-button>
</div>
<div v-show="group.displayList" class="load-more">
<el-button @click="handleLoadMore(group.id)"> 加载更多 </el-button>
</div>
</div>
</div>
@@ -63,19 +177,34 @@
<script setup lang="ts">
import { useResourceStore } from "@/stores/resource";
import { computed } from "vue";
import { ref } from "vue";
import type { ResourceItem, TagColor } from "@/types";
import { ArrowDown, Plus } from "@element-plus/icons-vue";
import { getProxyImageUrl } from "@/utils/image";
const store = useResourceStore();
const location = computed(() => window.location);
const showDetail = ref(false);
const currentResource = ref<ResourceItem | null>(null);
const emit = defineEmits(["save", "loadMore", "searchMovieforTag"]);
const emit = defineEmits(["save", "loadMore", "jump", "searchMovieforTag"]);
const handleSave = (resource: ResourceItem) => {
if (showDetail.value) {
showDetail.value = false;
}
emit("save", resource);
};
const handleJump = (resource: ResourceItem) => {
emit("jump", resource);
};
const showResourceDetail = (resource: ResourceItem) => {
currentResource.value = resource;
showDetail.value = true;
};
const searchMovieforTag = (tag: string) => {
emit("searchMovieforTag", tag);
};
@@ -85,105 +214,405 @@ const handleLoadMore = (channelId: string) => {
};
</script>
<style scoped>
.resource-list {
margin-bottom: 30px;
padding: 20px;
border-radius: 15px;
background-color: var(--theme-other_background);
<style lang="scss" scoped>
@import "@/styles/common.scss";
.resource-card {
position: relative;
height: 100%;
// 资源组
.resource-group {
background: var(--theme-card-bg);
backdrop-filter: var(--theme-blur);
-webkit-backdrop-filter: var(--theme-blur);
margin-bottom: 24px;
border-radius: var(--theme-radius);
border: 1px solid rgba(0, 0, 0, 0.08);
transition: var(--theme-transition);
&:last-child {
margin-bottom: 0;
margin-bottom: 100px;
}
}
.card-item-list {
display: grid;
grid-template-columns: repeat(auto-fill, 220px);
grid-row-gap: 30px;
// 组标题
.group-header {
@include flex-center;
justify-content: space-between;
margin-top: 20px;
/* grid-column-gap: auto-fill; */
/* flex-wrap: wrap; */
}
.card-item-content {
/* height: 520px; */
padding: 12px 20px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
position: sticky;
top: 0;
background: var(--theme-card-bg);
backdrop-filter: var(--theme-blur);
-webkit-backdrop-filter: var(--theme-blur);
z-index: 10;
border-radius: var(--theme-radius);
overflow: hidden;
cursor: pointer;
&.is-active {
border-radius: var(--theme-radius) var(--theme-radius) 0 0;
}
.group-title {
@include flex-center;
gap: 12px;
font-size: 16px;
color: var(--theme-text-primary);
transition: var(--theme-transition);
.channel-logo {
height: 40px;
width: 40px;
width: 32px;
height: 32px;
border-radius: 50%;
overflow: hidden;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
margin-right: 10px;
box-shadow: var(--theme-shadow-sm);
margin-right: 8px;
}
.load-more {
margin-top: 40px;
.item-count {
font-size: 13px;
color: var(--theme-text-secondary);
}
&:hover {
color: var(--theme-primary);
transform: translateY(-1px);
}
}
.toggle-btn {
width: 32px;
height: 32px;
padding: 0;
color: var(--theme-text-regular);
transition: var(--theme-transition);
.el-icon {
font-size: 16px;
transition: transform 0.3s ease;
&.is-active {
transform: rotate(180deg);
}
}
&:hover {
color: var(--theme-primary);
transform: translateY(-1px);
}
}
}
// 组内容
.group-content {
padding: 20px;
}
// 卡片网格
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 24px;
grid-auto-rows: min-content;
}
// 资源卡片
.resource-card-item {
border-radius: var(--theme-radius);
transition: var(--theme-transition);
overflow: hidden;
max-width: 460px;
margin: 0 auto;
width: 100%;
text-align: center;
height: fit-content;
&:hover {
transform: translateY(-2px);
box-shadow: var(--theme-shadow);
}
.card-item {
max-width: 480px;
.card-wrapper {
display: flex;
gap: 20px;
padding: 16px;
height: 100%;
border-radius: 20px;
}
.card-item-image {
border-radius: 20px;
.card-cover {
position: relative;
width: 120px;
height: 180px;
flex-shrink: 0;
.cover-image {
width: 100%;
height: 220px;
height: 100%;
object-fit: cover;
border-radius: var(--theme-radius);
cursor: pointer;
transition: opacity 0.3s ease;
&:hover {
opacity: 0.85;
}
.item-name,
.item-description {
max-width: 100%;
margin: 15px 0;
-webkit-box-orient: vertical;
}
.cloud-type {
position: absolute;
top: 8px;
left: 8px;
z-index: 1;
}
}
.card-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
.card-title {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: all;
font-size: 16px;
line-height: 1.5;
color: var(--theme-text-primary);
word-break: break-word;
height: 3em;
transition: var(--theme-transition);
&:hover {
color: var(--theme-primary);
}
.item-description {
-webkit-line-clamp: 4;
margin-top: 0;
height: 100px;
}
.item-name {
height: 58px;
font-size: 18px;
.card-content {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 14px;
line-height: 1.6;
color: var(--theme-text-regular);
cursor: pointer;
transition: color 0.3s ease;
&:hover {
color: var(--theme-text-primary);
}
}
.card-tags {
margin-top: auto;
max-height: 88px;
overflow: hidden;
.tags-label {
font-size: 13px;
color: var(--theme-text-secondary);
margin-right: 8px;
display: block;
margin-bottom: 8px;
}
.tags-list {
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
height: 58px;
gap: 8px;
max-height: 72px;
overflow: hidden;
.tag-item {
cursor: pointer;
transition: var(--theme-transition);
margin: 0;
height: 24px;
&:hover {
color: var(--theme-primary);
border-color: var(--theme-primary);
transform: translateY(-1px);
}
}
}
}
}
.card-footer {
@include flex-center;
justify-content: flex-end;
margin-top: 8px;
.el-button {
padding: 6px 16px;
font-size: 14px;
height: 32px;
min-width: 80px;
&:hover {
transform: translateY(-1px);
box-shadow: var(--theme-shadow-sm);
}
}
}
}
// 加载更多
.load-more {
@include flex-center;
position: relative;
padding: 32px 0 8px;
margin-top: 16px;
&::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent,
var(--el-border-color-lighter) 20%,
var(--el-border-color-lighter) 80%,
transparent
);
}
.el-button {
min-width: 160px;
height: 40px;
border-radius: 20px;
font-size: 14px;
color: var(--theme-text-regular);
background: var(--theme-card-bg);
border: 1px solid var(--el-border-color-lighter);
transition: var(--theme-transition);
position: relative;
overflow: hidden;
&::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
transform: translateX(-100%);
transition: transform 0.6s ease;
}
&:hover {
color: var(--theme-primary);
border-color: var(--theme-primary);
background: var(--el-color-primary-light-9);
&::after {
transform: translateX(100%);
}
}
&.is-loading {
color: var(--theme-text-secondary);
&::after {
display: none;
}
}
.el-icon {
margin-right: 6px;
font-size: 16px;
}
}
}
// 详情弹窗样式
.resource-detail-dialog {
:deep(.el-dialog__body) {
padding: 20px;
}
.detail-content {
display: flex;
gap: 24px;
}
.detail-cover {
position: relative;
width: 200px;
flex-shrink: 0;
.cover-image {
width: 100%;
height: 300px;
border-radius: var(--theme-radius);
overflow: hidden;
}
.resource_tag {
cursor: pointer;
margin-right: 10px;
margin-bottom: 5px;
.cloud-type {
position: absolute;
top: 8px;
left: 8px;
z-index: 1;
}
.group-header {
height: 50px;
line-height: 50px;
text-align: left;
padding: 0 15px;
font-size: 20px;
}
.detail-info {
flex: 1;
min-width: 0;
.detail-title {
font-size: 18px;
margin: 0 0 16px;
line-height: 1.5;
color: var(--theme-text-primary);
}
.detail-description {
font-size: 14px;
line-height: 1.6;
color: var(--theme-text-regular);
margin-bottom: 20px;
}
.detail-tags {
.tags-label {
font-size: 13px;
color: var(--theme-text-secondary);
margin-right: 8px;
}
.tags-list {
display: flex;
align-items: center;
justify-content: space-between;
/* text-align: center; */
.el-link {
font-size: 22px;
}
.header-icon {
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
.tag-item {
cursor: pointer;
width: 50px;
height: 50px;
transition: var(--theme-transition);
&:hover {
color: var(--theme-primary);
border-color: var(--theme-primary);
transform: translateY(-1px);
}
}
.item-footer {
}
}
}
.dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-end;
padding-top: 16px;
}
}
}
</style>

View File

@@ -1,82 +1,178 @@
<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
<div class="resource-select">
<div class="select-header">
<div class="select-info">
<el-icon><Document /></el-icon>
<span>已选择 {{ selectedCount }} 个文件</span>
<span v-if="totalSize" class="total-size">({{ formattedFileSize(totalSize) }})</span>
</div>
<div class="header-actions">
<el-button type="text" @click="handleSelectAll(!hasSelectedAll)">
{{ hasSelectedAll ? "取消全选" : "全选" }}
</el-button>
</div>
</div>
<div class="file-list">
<div
v-for="file in resourceStore.shareInfo.list"
:key="file.fileId"
class="file-item"
:class="{ 'is-checked': isChecked(file.fileId) }"
@click="toggleSelect(file)"
>
<el-checkbox :model-value="isChecked(file.fileId)" @click.stop>
<div class="file-info">
<el-icon><Document /></el-icon>
<span class="file-name">{{ file.fileName }}</span>
<span v-if="file.fileSize" class="file-size">
{{ formattedFileSize(file.fileSize) }}
</span>
</template>
</el-tree>
</div>
</el-checkbox>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useResourceStore } from "@/stores/resource";
import { formattedFileSize } from "@/utils/index";
import { computed } from "vue";
import type { ShareInfo } from "@/types";
import { Document } from "@element-plus/icons-vue";
const resourceStore = useResourceStore();
const selectedResource = ref<ShareInfo[]>([]);
const defaultProps = {
isLeaf: "leaf",
const selectedCount = computed(
() => resourceStore.resourceSelect.filter((x) => x.isChecked).length
);
const totalSize = computed(() =>
resourceStore.resourceSelect
.filter((x) => x.isChecked)
.reduce((sum, item) => sum + (item.fileSize || 0), 0)
);
const totalFiles = computed(() => resourceStore.shareInfo.list.length);
const hasSelectedAll = computed(() => selectedCount.value === totalFiles.value);
const isChecked = (fileId: string) => {
return resourceStore.resourceSelect.find((x) => x.fileId === fileId)?.isChecked;
};
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);
const toggleSelect = (file: ShareInfo) => {
let resourceSelect = [...resourceStore.resourceSelect];
const item = resourceSelect.find((x) => x.fileId === file.fileId);
if (item) {
item.isChecked = !item.isChecked;
resourceStore.setSelectedResource(resourceSelect);
}
resourceStore.setSelectedResource(selectedResource.value);
};
const handleSelectAll = (checked: boolean) => {
const resourceSelect = resourceStore.shareInfo.list.map((file) => ({
fileId: file.fileId,
fileName: file.fileName,
fileSize: file.fileSize,
isChecked: checked,
}));
resourceStore.setSelectedResource(resourceSelect);
};
</script>
<style scoped>
.folder-select {
min-height: 300px;
max-height: 500px;
overflow-y: auto;
}
<style lang="scss" scoped>
@import "@/styles/responsive.scss";
.folder-node {
.resource-select {
min-height: 200px;
max-height: 500px;
display: flex;
flex-direction: column;
gap: 16px;
.select-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--el-fill-color-light);
border-radius: var(--theme-radius);
.select-info {
display: flex;
align-items: center;
gap: 8px;
color: var(--theme-text-regular);
font-size: 14px;
.el-icon {
font-size: 16px;
}
.folder-path {
color: #999;
font-size: 12px;
margin-left: 8px;
.total-size {
color: var(--theme-text-secondary);
}
}
:deep(.el-tree-node__content) {
height: 32px;
.header-actions {
display: flex;
gap: 16px;
}
.folder-select-header {
.el-button {
font-size: 13px;
padding: 4px 8px;
&:not(:disabled):hover {
color: var(--theme-primary);
}
}
}
.file-list {
flex: 1;
overflow-y: auto;
padding: 4px;
.file-item {
padding: 12px 16px;
border-radius: var(--theme-radius);
cursor: pointer;
transition: var(--theme-transition);
&:hover {
background: var(--el-fill-color-light);
}
&.is-checked {
background: var(--el-color-primary-light-9);
}
.file-info {
display: flex;
align-items: center;
justify-content: flex-start;
margin-bottom: 10px;
gap: 8px;
color: var(--theme-text-primary);
font-size: 14px;
padding: 5px 10px;
border: 1px solid #e5e6e8;
border-radius: 8px;
.el-icon {
font-size: 16px;
color: var(--theme-text-regular);
}
.file-name {
flex: 1;
@include text-ellipsis;
}
.file-size {
color: var(--theme-text-secondary);
font-size: 13px;
}
}
}
}
}
</style>

View File

@@ -5,37 +5,27 @@
:data="store.resources"
style="width: 100%"
row-key="id"
:default-expand-all="true"
:default-expand-all="false"
>
<el-table-column type="expand">
<template #default="props">
<el-table :data="props.row.list" style="width: 100%">
<el-table-column label="图片" width="180">
<el-table-column label="图片" width="80">
<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"
:src="getProxyImageUrl(row.image as string)"
:fit="row.image ? 'cover' : 'contain'"
width="30"
height="60"
/>
<el-icon v-else size="20"><Close /></el-icon>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" width="180">
<el-table-column prop="title" label="标题" width="280">
<template #default="{ row }">
<el-link :href="row.cloudLinks[0]" target="_blank">
<el-link :href="row.cloudLinks[0]" target="_blank" style="font-weight: bold">
{{ row.title }}
</el-link>
</template>
@@ -61,14 +51,6 @@
<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>
@@ -78,7 +60,8 @@
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button @click="handleSave(row)"></el-button>
<el-button type="primary" plain @click="handleJump(row)"></el-button>
<el-button v-if="row.isSupportSave" @click="handleSave(row)">转存</el-button>
</template>
</el-table-column>
</el-table>
@@ -92,7 +75,12 @@
<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 />
<el-image
:src="getProxyImageUrl(row.channelInfo.channelLogo as string)"
class="channel-logo"
:fit="row.channelInfo.channelLogo ? 'cover' : 'contain'"
lazy
/>
<span>{{ row.channelInfo.name }}</span>
<span class="item-count">({{ row.list.length }})</span>
</div>
@@ -104,18 +92,19 @@
<script setup lang="ts">
import { useResourceStore } from "@/stores/resource";
import type { Resource, TagColor } from "@/types";
import { computed } from "vue";
import { getProxyImageUrl } from "@/utils/image";
const store = useResourceStore();
const emit = defineEmits(["save", "loadMore", "searchMovieforTag"]);
const location = computed(() => window.location);
const emit = defineEmits(["save", "loadMore", "searchMovieforTag", "jump"]);
const handleSave = (resource: Resource) => {
emit("save", resource);
};
const handleJump = (resource: Resource) => {
emit("jump", resource);
};
// 添加加载更多处理函数
const handleLoadMore = (channelId: string) => {
emit("loadMore", channelId);
@@ -145,9 +134,8 @@ const searchMovieforTag = (tag: string) => {
}
.table-item-image {
border-radius: 20px;
border-radius: 10px;
width: 100%;
height: 220px;
}
.item-count {
@@ -170,8 +158,8 @@ const searchMovieforTag = (tag: string) => {
margin: 15px 0;
-webkit-box-orient: vertical;
display: -webkit-box;
line-clamp: 4;
-webkit-line-clamp: 4;
line-clamp: 2;
-webkit-line-clamp: 2;
overflow: hidden;
white-space: all;
}
@@ -195,4 +183,10 @@ const searchMovieforTag = (tag: string) => {
justify-content: center;
padding: 16px 0;
}
.resource-table {
position: relative;
height: auto;
overflow: visible;
}
</style>

View File

@@ -1,130 +1,205 @@
<template>
<div class="search-bar">
<div class="search-bar__input">
<input
<div class="pc-search">
<!-- 搜索区域 -->
<div class="pc-search__input">
<el-input
v-model="keyword"
type="text"
placeholder="请输入搜索关键词或输入链接直接解析"
class="input-with-select"
clearable
@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>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
<template #suffix>
<el-icon v-if="keyword" class="search-icon" @click="handleSearch">
<ArrowRight />
</el-icon>
</template>
</el-input>
</div>
<!-- 用户操作区 -->
<div class="pc-search__actions">
<el-tooltip effect="dark" content="退出登录" placement="bottom">
<el-button class="logout-btn" type="text" @click="handleLogout">
<el-icon><SwitchButton /></el-icon>
</el-button>
</el-tooltip>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref,watch } from "vue";
import { ref, computed, watch } from "vue";
import { useResourceStore } from "@/stores/resource";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Search, ArrowRight, SwitchButton } from "@element-plus/icons-vue";
import { STORAGE_KEYS } from "@/constants/storage";
// 路由相关
const route = useRoute();
const router = useRouter();
const resourcStore = useResourceStore();
// 响应式数据
const keyword = ref("");
const routeKeyword = computed(() => route.query.keyword as string);
const logout = () => {
localStorage.removeItem("token");
router.push({ path: "/login" });
// 退出登录
const handleLogout = () => {
localStorage.removeItem(STORAGE_KEYS.TOKEN);
router.push("/login");
ElMessage.success("已退出登录");
};
// 搜索处理
const handleSearch = async () => {
// 如果搜索内容是一个https的链接则尝试解析链接
if (keyword.value.startsWith("http")) {
resourcStore.parsingCloudLink(keyword.value);
const searchText = keyword.value.trim();
if (!searchText) {
ElMessage.warning("请输入搜索内容");
return;
}
if (!keyword.value.trim()) {
// 链接解析处理
if (searchText.startsWith("http")) {
await resourcStore.parsingCloudLink(searchText);
return;
}
await resourcStore.searchResources(keyword.value);
if(route.path !== '/'){
router.push({ path: '/' });
// 关键词搜索
await resourcStore.searchResources(searchText);
if (route.path !== "/resource") {
router.push("/resource");
}
};
watch(() => routeKeyword.value, () => {
if(routeKeyword.value){
keyword.value = routeKeyword.value;
// 监听路由参数变化
watch(
() => routeKeyword.value,
(newKeyword) => {
if (newKeyword) {
keyword.value = newKeyword;
handleSearch();
} else {
keyword.value = ''
keyword.value = resourcStore.keyword;
}
});
}
);
watch(
() => resourcStore.keyword,
(newKeyword) => {
keyword.value = newKeyword;
}
);
</script>
<style scoped lang="scss">
.search-bar {
display: flex;
align-items: center;
<style lang="scss" scoped>
@import "@/styles/common.scss";
.pc-search {
@include flex-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 {
gap: 16px;
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 {
flex: 1;
min-width: 0; // 防止溢出
:deep(.el-input) {
--el-input-height: 44px;
.el-input__wrapper {
@include glass-effect;
padding: 0 16px;
border-radius: var(--theme-radius);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.1),
0 2px 4px rgba(0, 0, 0, 0.05),
0 1px 2px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(0, 0, 0, 0.08);
transition: var(--theme-transition);
background: rgba(255, 255, 255, 0.9);
&:hover {
border-color: var(--theme-primary);
box-shadow:
inset 0 0 0 1px var(--theme-primary),
0 4px 8px rgba(0, 0, 0, 0.1);
}
.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;
&.is-focus {
border-color: var(--theme-primary);
box-shadow:
inset 0 0 0 1px var(--theme-primary),
0 4px 8px rgba(0, 0, 0, 0.1),
0 0 0 3px rgba(0, 102, 204, 0.1);
background: #fff;
}
}
.search-new {
margin-top: 20px;
display: flex;
align-items: center;
justify-content: flex-start;
.el-input__inner {
font-size: 15px;
color: var(--theme-text-primary);
height: 42px;
line-height: 42px;
&::placeholder {
color: var(--theme-text-secondary);
}
}
.el-input__prefix-inner {
.el-icon {
font-size: 18px;
color: var(--theme-text-secondary);
margin-right: 8px;
}
}
.search-icon {
font-size: 18px;
cursor: pointer;
color: var(--theme-primary);
transition: var(--theme-transition);
margin-left: 8px;
&:hover {
transform: scale(1.1);
}
}
}
}
// 操作区域
&__actions {
.logout-btn {
@include glass-effect;
width: 44px;
height: 44px;
padding: 0;
border-radius: var(--theme-radius);
transition: var(--theme-transition);
.el-icon {
font-size: 20px;
color: var(--theme-text-regular);
transition: var(--theme-transition);
}
&:hover {
background: var(--theme-primary);
transform: translateY(-2px);
box-shadow: var(--theme-shadow-sm);
.el-icon {
color: #fff;
}
}
.switch-source {
margin-left: 20px;
}
.switch-source .label {
margin-left: 5px;
}
.display-style {
margin-left: 20px;
}
</style>

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