Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c656eb880 | ||
|
|
89879e8c05 | ||
|
|
7c7ad6ba1f | ||
|
|
d0115c78f5 | ||
|
|
90e708c25f | ||
|
|
eb4aa1bb8d | ||
|
|
1f3a83b84d | ||
|
|
7bcec7e3b4 | ||
|
|
604ba2eec6 | ||
|
|
680108f499 | ||
|
|
301ed5648e | ||
|
|
b755b6b186 | ||
|
|
fa8da9ae25 | ||
|
|
03509eb723 | ||
|
|
4cd71a72e5 | ||
|
|
626313434a | ||
|
|
1a562a612c | ||
|
|
ddfb00298f | ||
|
|
cd7183f206 | ||
|
|
32bf77c3e0 | ||
|
|
9f6b811785 | ||
|
|
6c9034570c | ||
|
|
fe38437a7c | ||
|
|
06b01bf1f9 | ||
|
|
2fffdc30f5 | ||
|
|
5b701e499a | ||
|
|
25ecdb1078 | ||
|
|
1c884eb3b9 | ||
|
|
e4d2a1db52 | ||
|
|
4553b9a7a0 | ||
|
|
c290f5a6d9 | ||
|
|
8668bce863 | ||
|
|
390287876a | ||
|
|
f860cc1f57 | ||
|
|
f5106e782a | ||
|
|
c784b562c4 | ||
|
|
cd4fa8cc2b | ||
|
|
2041a7fa11 | ||
|
|
924903bd58 | ||
|
|
e3bf2d6d6b | ||
|
|
f87ae22924 | ||
|
|
868d99367d | ||
|
|
9864eeb21d | ||
|
|
510fdc48f6 | ||
|
|
fd110590af | ||
|
|
89caf91688 | ||
|
|
16dfa1500d |
28
.env.example
@@ -1,24 +1,8 @@
|
||||
# # 数据库配置
|
||||
# DB_HOST=localhost
|
||||
# DB_USER=your_username
|
||||
# DB_PASSWORD=your_password
|
||||
# jwt密钥 用于生成token加密
|
||||
JWT_SECRET=""
|
||||
|
||||
# # API密钥
|
||||
# API_KEY=your_api_key
|
||||
# 用户注册码
|
||||
REGISTER_CODE='9527'
|
||||
|
||||
# # 其他敏感信息
|
||||
# CLOUD115_TOKEN=your_token
|
||||
|
||||
# 代理信息
|
||||
HTTP_PROXY_HOST=127.0.0.1
|
||||
HTTP_PROXY_PORT=7890
|
||||
|
||||
# 115网盘配置
|
||||
CLOUD115_USER_ID=your_user_id # 用户ID 可以不填
|
||||
CLOUD115_COOKIE=your_cookie
|
||||
|
||||
# 夸克网盘配置
|
||||
QUARK_COOKIE='your_cookie'
|
||||
|
||||
# RSS配置
|
||||
RSS_BASE_URL=https://rsshub.rssforever.com/telegram/channel
|
||||
# 服务端口
|
||||
PORT=8009
|
||||
|
||||
4
.eslintignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
coverage
|
||||
57
.eslintrc.js
Normal file
@@ -0,0 +1,57 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
ignorePatterns: ["node_modules", "dist", "build", "coverage"],
|
||||
env: {
|
||||
node: true,
|
||||
es6: true,
|
||||
},
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["@typescript-eslint"],
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:prettier/recommended",
|
||||
],
|
||||
rules: {
|
||||
"prettier/prettier": "error",
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
||||
"@typescript-eslint/explicit-function-return-type": 0,
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ["frontend/**/*.{js,ts,vue}"],
|
||||
env: {
|
||||
browser: true,
|
||||
},
|
||||
parser: "vue-eslint-parser",
|
||||
parserOptions: {
|
||||
parser: "@typescript-eslint/parser",
|
||||
ecmaVersion: 2020,
|
||||
sourceType: "module",
|
||||
},
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:vue/vue3-recommended",
|
||||
"plugin:prettier/recommended",
|
||||
],
|
||||
plugins: ["@typescript-eslint", "vue"],
|
||||
rules: {
|
||||
"vue/multi-word-component-names": "off",
|
||||
"vue/require-default-prop": "off",
|
||||
"vue/no-v-html": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["backend/**/*.{js,ts}"],
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/explicit-function-return-type": 0,
|
||||
"@typescript-eslint/no-non-null-assertion": "warn",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
44
.github/workflows/docker-build-test.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Build and Push Multi-Arch Docker Image for Test
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
workflow_dispatch: # 添加手动触发
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write # 必须授权以推送镜像
|
||||
env:
|
||||
REPO_NAME: ${{ github.repository }}
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 设置小写镜像名称
|
||||
run: |
|
||||
LOWER_NAME=$(echo "$REPO_NAME" | tr '[:upper:]' '[:lower:]')
|
||||
echo "LOWER_NAME=$LOWER_NAME" >> $GITHUB_ENV
|
||||
|
||||
- name: 登录到 GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: 设置 QEMU 支持多架构
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: 设置 Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: 构建并推送多架构 Docker 镜像
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64 # 指定架构:x86_64 和 ARM64
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ env.LOWER_NAME }}:test
|
||||
44
.github/workflows/docker-image.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Docker Image CI/CD
|
||||
on:
|
||||
push:
|
||||
tags: [ "v*.*.*" ] # 支持标签触发(如 v1.0.0)
|
||||
workflow_dispatch: # 添加手动触发
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write # 必须授权以推送镜像
|
||||
env:
|
||||
REPO_NAME: ${{ github.repository }}
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 设置小写镜像名称
|
||||
run: |
|
||||
LOWER_NAME=$(echo "$REPO_NAME" | tr '[:upper:]' '[:lower:]')
|
||||
echo "LOWER_NAME=$LOWER_NAME" >> $GITHUB_ENV
|
||||
|
||||
- name: 登录到 GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: 设置 QEMU 支持多架构
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: 设置 Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: 构建并推送 Docker 镜像
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64 # 指定架构:x86_64 和 ARM64
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ env.LOWER_NAME }}:latest
|
||||
ghcr.io/${{ env.LOWER_NAME }}:${{ github.sha }}
|
||||
5
.gitignore
vendored
@@ -4,6 +4,11 @@ dist/
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
*.tar
|
||||
|
||||
# 数据库数据
|
||||
*.sqlite
|
||||
|
||||
# 保留模板
|
||||
!.env.example
|
||||
|
||||
|
||||
@@ -24,4 +24,7 @@ node_modules
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# 版本控制
|
||||
.git
|
||||
14
.prettierrc.js
Normal file
@@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
semi: true,
|
||||
trailingComma: "es5",
|
||||
singleQuote: false,
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
useTabs: false,
|
||||
endOfLine: "auto",
|
||||
arrowParens: "always",
|
||||
bracketSpacing: true,
|
||||
embeddedLanguageFormatting: "auto",
|
||||
htmlWhitespaceSensitivity: "css",
|
||||
vueIndentScriptAndStyle: false,
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "lf",
|
||||
"arrowParens": "always",
|
||||
"vueIndentScriptAndStyle": true
|
||||
}
|
||||
45
Dockerfile
Normal file
@@ -0,0 +1,45 @@
|
||||
# 构建前端项目
|
||||
FROM node:18-alpine as frontend-build
|
||||
WORKDIR /app
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm install -g pnpm
|
||||
RUN pnpm install
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# 构建后端项目
|
||||
FROM node:18-alpine as backend-build
|
||||
WORKDIR /app
|
||||
COPY backend/package*.json ./
|
||||
RUN npm install -g pnpm
|
||||
RUN pnpm install
|
||||
COPY backend/ ./
|
||||
RUN rm -f database.sqlite
|
||||
RUN npm run build
|
||||
|
||||
# 生产环境镜像
|
||||
FROM node:18-alpine
|
||||
|
||||
# 安装 Nginx
|
||||
RUN apk add --no-cache nginx
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制前端构建产物到 Nginx
|
||||
COPY --from=frontend-build /app/dist /usr/share/nginx/html
|
||||
|
||||
# 复制 Nginx 配置文件
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# 复制后端构建产物到生产环境镜像
|
||||
COPY --from=backend-build /app /app
|
||||
|
||||
# 安装生产环境依赖
|
||||
RUN npm install --production
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8008
|
||||
|
||||
# 启动 Nginx 和后端服务
|
||||
CMD ["sh", "-c", "nginx -g 'daemon off;' & npm run start && wait"]
|
||||
282
README.md
@@ -1,94 +1,266 @@
|
||||
# CloudSaver
|
||||
|
||||
一个基于 Vue 3 + Express 的网盘资源搜索与转存工具。
|
||||

|
||||

|
||||

|
||||
[](https://github.com/jiangrui1994/CloudSaver/stargazers)
|
||||
|
||||
## 特别声明
|
||||
|
||||
1. 此项目仅供学习交流使用,请勿用于非法用途。
|
||||
2. 仅支持个人使用,不支持任何形式的 commercial 使用。
|
||||
3. 禁止在项目页面上进行任何形式的广告宣传。
|
||||
4. 所有搜索到的资源均来自第三方,本项目不对其真实性、合法性做出任何保证。
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 此项目的资源搜索需要用到代理环境,请自行搭建。
|
||||
2. 默认的代理地址为:`http://127.0.0.1`,端口为:`7890`,如需修改,请自行 `.env`中配置。
|
||||
一个基于 Vue 3 + Express 的网盘资源搜索与转存工具,支持响应式布局,移动端与PC完美适配,可通过 Docker 一键部署。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 支持多个资源订阅(电报群)源搜索
|
||||
- 支持 115 网盘与夸克网盘资源转存
|
||||
- 支持关键词搜索与资源链接解析
|
||||
- 支持转存文件夹展示与选择
|
||||
- 🔍 多源资源搜索
|
||||
- 支持多个资源订阅源搜索
|
||||
- 支持关键词搜索与资源链接解析
|
||||
- 支持豆瓣热门榜单展示
|
||||
- 💾 网盘资源转存
|
||||
- 支持 115 网盘与夸克网盘一键转存
|
||||
- 支持转存文件夹展示与选择
|
||||
- 👥 多用户系统
|
||||
- 支持用户注册登录
|
||||
- 支持管理员与普通用户权限区分
|
||||
- 📱 响应式设计
|
||||
- 支持 PC 端与移动端自适应布局
|
||||
- 针对不同设备优化的交互体验
|
||||
|
||||
## 预览
|
||||
## 产品展示
|
||||
|
||||
### 最新资源搜索
|
||||
### PC 端
|
||||
|
||||
<img src="./docs/images/screenshot-20241216-172442.png" width="400">
|
||||
<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>
|
||||
|
||||
### 转存
|
||||
<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>
|
||||
|
||||
<img src="./docs/images/screenshot-20241216-172609.png" width="400">
|
||||
<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/screenshot-20241216-172710.png" width="400">
|
||||
### 移动端
|
||||
|
||||
### 直接链接解析
|
||||
|
||||
<img src="./docs/images/screenshot-20241216-173136.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>
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 前端
|
||||
|
||||
- Vue 3
|
||||
- TypeScript
|
||||
- Element Plus
|
||||
- Pinia
|
||||
- Vue Router
|
||||
- Vite
|
||||
- 核心框架
|
||||
- Vue 3
|
||||
- TypeScript
|
||||
- Vite
|
||||
- 状态管理
|
||||
- Pinia
|
||||
- 路由管理
|
||||
- Vue Router
|
||||
- UI 组件库
|
||||
- Element Plus (PC)
|
||||
- Vant (Mobile)
|
||||
- 工具库
|
||||
- Axios
|
||||
|
||||
### 后端
|
||||
|
||||
- Node.js
|
||||
- Express
|
||||
- TypeScript
|
||||
- RSS Parser
|
||||
- 运行环境
|
||||
- Node.js
|
||||
- Express
|
||||
- 数据存储
|
||||
- SQLite3
|
||||
|
||||
## 环境配置
|
||||
## 环境要求
|
||||
|
||||
### Node.js 版本
|
||||
- Node.js >= 18.x
|
||||
- pnpm >= 8.x (推荐)
|
||||
|
||||
本项目需要 `Node.js` 版本 `18.x` 或更高版本。请确保在运行项目之前安装了正确的版本。
|
||||
## 快速开始
|
||||
|
||||
### 后端配置项
|
||||
### 开发环境
|
||||
|
||||
复制环境变量模板文件:
|
||||
1. 克隆项目
|
||||
|
||||
```bash
|
||||
git clone https://github.com/jiangrui1994/CloudSaver.git
|
||||
cd CloudSaver
|
||||
```
|
||||
|
||||
2. 安装依赖
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. 配置环境变量
|
||||
|
||||
```bash
|
||||
cp .env.example ./backend/.env
|
||||
```
|
||||
|
||||
- 根据 `.env.example`文件在 `backend`目录下创建 `.env`文件。
|
||||
- `CLOUD115_COOKIE`:115 用户 cookie
|
||||
- `QUARK_COOKIE`:quark cookie
|
||||
根据 `.env.example` 文件说明配置必要的环境变量。
|
||||
|
||||
## 使用
|
||||
4. 启动开发服务器
|
||||
|
||||
### 开发环境本地运行
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
- 安装依赖:`npm run install`
|
||||
- 启动开发环境:`npm run dev`
|
||||
### 生产环境部署
|
||||
|
||||
### 打包部署
|
||||
1. 构建前端
|
||||
|
||||
- 前端打包:`npm run build:frontend` 或者进入前端目录`frontend`,执行 `npm run build`
|
||||
- 将前端构建产物`dist`目录下的文件复制到服务器上,例如`nginx`的`html`目录下。
|
||||
- 后端服务:先进入后端目录`backend`下`npm run build`构建,然后执行 `npm run start`启动,默认端口为`8009`。
|
||||
- 通过`nginx`配置代理服务,将前端api的请求映射到后端服务。
|
||||
```bash
|
||||
pnpm build:frontend
|
||||
```
|
||||
|
||||
## License
|
||||
2. 构建后端
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
```bash
|
||||
cd backend
|
||||
pnpm build
|
||||
```
|
||||
|
||||
3. 启动服务
|
||||
|
||||
```bash
|
||||
pnpm start
|
||||
```
|
||||
|
||||
### Docker 部署
|
||||
|
||||
#### 单容器部署
|
||||
|
||||
稳定版:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 8008:8008 \
|
||||
-v /your/local/path:/app/data \
|
||||
--name cloud-saver \
|
||||
ghcr.io/jiangrui1994/cloudsaver:latest
|
||||
```
|
||||
|
||||
测试版(包含最新功能和bug修复,但可能不如稳定版稳定):
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 8008:8008 \
|
||||
-v /your/local/path:/app/data \
|
||||
--name cloud-saver \
|
||||
ghcr.io/jiangrui1994/cloudsaver:test
|
||||
```
|
||||
|
||||
#### Docker Compose 部署
|
||||
|
||||
创建 `docker-compose.yml` 文件:
|
||||
|
||||
稳定版:
|
||||
|
||||
```yaml
|
||||
version: "3"
|
||||
services:
|
||||
cloudsaver:
|
||||
image: ghcr.io/jiangrui1994/cloudsaver:latest
|
||||
container_name: cloud-saver
|
||||
ports:
|
||||
- "8008:8008"
|
||||
volumes:
|
||||
- /your/local/path:/app/data
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
测试版:
|
||||
|
||||
```yaml
|
||||
version: "3"
|
||||
services:
|
||||
cloudsaver:
|
||||
image: ghcr.io/jiangrui1994/cloudsaver:test
|
||||
container_name: cloud-saver
|
||||
ports:
|
||||
- "8008:8008"
|
||||
volumes:
|
||||
- /your/local/path:/app/data
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
> **注意**: 测试版(:test标签)包含最新的功能开发和bug修复,但可能存在不稳定因素。建议生产环境使用稳定版(:latest标签)。
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 资源搜索需要配置代理环境
|
||||
2. 默认注册码
|
||||
- 管理员:230713
|
||||
- 普通用户:9527
|
||||
|
||||
## 联系方式
|
||||
|
||||
<div align="center">
|
||||
<div>
|
||||
<img src="./docs/images/wechat.jpg" width="200" alt="微信交流群">
|
||||
<p>微信交流群</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) 文件了解更多细节
|
||||
|
||||
|
||||
## 鸣谢
|
||||
|
||||
- 👨💻 感谢所有为这个项目做出贡献的开发者们!
|
||||
- 👥 感谢所有使用本项目并提供反馈的用户!
|
||||
- 感谢所有给予支持和鼓励的朋友们!
|
||||
|
||||
7
TODO.md
@@ -1,7 +0,0 @@
|
||||
# TODO
|
||||
|
||||
- ~~资源列表增加网盘标识~~
|
||||
- ~~增加对夸克网盘转存的支持~~
|
||||
- ~~增加搜索框直接解析链接~~
|
||||
- ~~替换rsshub改为直接从telegram获取资源信息~~
|
||||
- ~~增加资源列表web源加载更多功能~~
|
||||
@@ -9,19 +9,25 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.7",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cheerio": "^1.0.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.18.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"rss-parser": "^3.13.0",
|
||||
"sequelize": "^6.37.5",
|
||||
"socket.io": "^4.8.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tunnel": "^0.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/node": "^20.11.25",
|
||||
"@types/tunnel": "^0.0.7",
|
||||
"nodemon": "^3.1.0",
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
// filepath: /d:/code/CloudDiskDown/backend/src/app.ts
|
||||
import "./types/express";
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import cookieParser from "cookie-parser";
|
||||
import routes from "./routes/api";
|
||||
import { errorHandler } from "./middleware/errorHandler";
|
||||
import sequelize from "./config/database";
|
||||
import { authMiddleware } from "./middleware/auth";
|
||||
import GlobalSetting from "./models/GlobalSetting";
|
||||
import Searcher from "./services/Searcher";
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -17,14 +23,54 @@ app.use(
|
||||
|
||||
app.use(cookieParser());
|
||||
app.use(express.json());
|
||||
|
||||
// 应用 token 验证中间件,排除登录和注册接口
|
||||
app.use((req, res, next) => {
|
||||
if (
|
||||
req.path === "/user/login" ||
|
||||
req.path === "/user/register" ||
|
||||
req.path.includes("tele-images")
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
authMiddleware(req, res, next);
|
||||
});
|
||||
|
||||
app.use("/", routes);
|
||||
|
||||
const initializeGlobalSettings = async (): Promise<void> => {
|
||||
const settings = await GlobalSetting.findOne();
|
||||
if (!settings) {
|
||||
await GlobalSetting.create({
|
||||
httpProxyHost: "127.0.0.1",
|
||||
httpProxyPort: 7890,
|
||||
isProxyEnabled: true,
|
||||
CommonUserCode: 9527,
|
||||
AdminUserCode: 230713,
|
||||
});
|
||||
console.log("Global settings initialized with default values.");
|
||||
}
|
||||
await Searcher.updateAxiosInstance();
|
||||
};
|
||||
|
||||
// 错误处理
|
||||
app.use(errorHandler);
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server is running on port ${PORT}`);
|
||||
});
|
||||
const PORT = process.env.PORT || 8009;
|
||||
|
||||
// 在同步前禁用外键约束,同步后重新启用
|
||||
sequelize
|
||||
.query("PRAGMA foreign_keys = OFF") // 禁用外键
|
||||
.then(() => sequelize.sync({ alter: true }))
|
||||
.then(() => sequelize.query("PRAGMA foreign_keys = ON")) // 重新启用外键
|
||||
.then(() => {
|
||||
app.listen(PORT, async () => {
|
||||
await initializeGlobalSettings();
|
||||
console.log(`Server is running on port ${PORT}`);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Database sync failed:", error);
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
9
backend/src/config/database.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// backend/src/config/database.ts
|
||||
import { Sequelize } from "sequelize";
|
||||
|
||||
const sequelize = new Sequelize({
|
||||
dialect: "sqlite",
|
||||
storage: "./data/database.sqlite",
|
||||
});
|
||||
|
||||
export default sequelize;
|
||||
@@ -30,6 +30,8 @@ interface HttpProxyConfig {
|
||||
port: string;
|
||||
}
|
||||
interface Config {
|
||||
jwtSecret: string;
|
||||
registerCode: string;
|
||||
rss: {
|
||||
baseUrl: string;
|
||||
channels: Channel[];
|
||||
@@ -44,6 +46,7 @@ interface Config {
|
||||
}
|
||||
|
||||
export const config: Config = {
|
||||
jwtSecret: process.env.JWT_SECRET || "uV7Y$k92#LkF^q1b!",
|
||||
rss: {
|
||||
baseUrl: process.env.RSS_BASE_URL || "https://rsshub.rssforever.com/telegram/channel",
|
||||
channels: [
|
||||
@@ -61,14 +64,15 @@ export const config: Config = {
|
||||
},
|
||||
],
|
||||
},
|
||||
registerCode: process.env.REGISTER_CODE || "9527",
|
||||
|
||||
telegram: {
|
||||
baseUrl: process.env.TELEGRAM_BASE_URL || "https://t.me/s",
|
||||
},
|
||||
|
||||
httpProxy: {
|
||||
host: process.env.HTTP_PROXY_HOST || "127.0.0.1",
|
||||
port: process.env.HTTP_PROXY_PORT || "7890",
|
||||
host: process.env.HTTP_PROXY_HOST || "",
|
||||
port: process.env.HTTP_PROXY_PORT || "",
|
||||
},
|
||||
|
||||
cloudPatterns: {
|
||||
@@ -77,7 +81,7 @@ export const config: Config = {
|
||||
weiyun: /https?:\/\/share\.weiyun\.com\/[^\s<>"]+/g,
|
||||
aliyun: /https?:\/\/\w+\.aliyundrive\.com\/[^\s<>"]+/g,
|
||||
// pan115有两个域名 115.com 和 anxia.com
|
||||
pan115: /https?:\/\/(?:115|anxia)\.com\/s\/[^\s<>"]+/g,
|
||||
pan115: /https?:\/\/(?:115|anxia|115cdn)\.com\/s\/[^\s<>"]+/g,
|
||||
quark: /https?:\/\/pan\.quark\.cn\/[^\s<>"]+/g,
|
||||
},
|
||||
|
||||
|
||||
@@ -1,46 +1,60 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { Cloud115Service } from "../services/Cloud115Service";
|
||||
import { config } from "../config";
|
||||
import handleError from "../utils/handleError";
|
||||
import { handleResponse } from "../utils/responseHandler";
|
||||
import { sendSuccess, sendError } from "../utils/response";
|
||||
import UserSetting from "../models/UserSetting";
|
||||
|
||||
const { cookie } = config.cloud115;
|
||||
|
||||
const cloud115 = new Cloud115Service(cookie);
|
||||
const cloud115 = new Cloud115Service();
|
||||
const setCookie = async (req: Request): Promise<void> => {
|
||||
const userId = req.user?.userId;
|
||||
const userSetting = await UserSetting.findOne({
|
||||
where: { userId },
|
||||
});
|
||||
if (userSetting && userSetting.dataValues.cloud115Cookie) {
|
||||
cloud115.setCookie(userSetting.dataValues.cloud115Cookie);
|
||||
} else {
|
||||
throw new Error("请先设置115网盘cookie");
|
||||
}
|
||||
};
|
||||
|
||||
export const cloud115Controller = {
|
||||
async getShareInfo(req: Request, res: Response, next: NextFunction) {
|
||||
async getShareInfo(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { shareCode, receiveCode } = req.query;
|
||||
await setCookie(req);
|
||||
const result = await cloud115.getShareInfo(shareCode as string, receiveCode as string);
|
||||
handleResponse(res, result, true);
|
||||
sendSuccess(res, result);
|
||||
} catch (error) {
|
||||
handleError(res, error, "获取分享信息失败", next);
|
||||
console.log(error);
|
||||
sendError(res, { message: (error as Error).message || "获取分享信息失败" });
|
||||
}
|
||||
},
|
||||
|
||||
async getFolderList(req: Request, res: Response, next: NextFunction) {
|
||||
async getFolderList(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { parentCid } = req.query;
|
||||
await setCookie(req);
|
||||
const result = await cloud115.getFolderList(parentCid as string);
|
||||
handleResponse(res, result, true);
|
||||
sendSuccess(res, result);
|
||||
} catch (error) {
|
||||
handleError(res, error, "获取目录列表失败", next);
|
||||
sendError(res, { message: (error as Error).message || "获取目录列表失败" });
|
||||
}
|
||||
},
|
||||
|
||||
async saveFile(req: Request, res: Response, next: NextFunction) {
|
||||
async saveFile(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { shareCode, receiveCode, fileId, folderId } = req.body;
|
||||
await setCookie(req);
|
||||
const result = await cloud115.saveSharedFile({
|
||||
shareCode,
|
||||
receiveCode,
|
||||
fileId,
|
||||
cid: folderId,
|
||||
});
|
||||
handleResponse(res, result, true);
|
||||
sendSuccess(res, result);
|
||||
} catch (error) {
|
||||
handleError(res, error, "保存文件失败", next);
|
||||
sendError(res, { message: (error as Error).message || "保存文件失败" });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const Cloud115ServiceInstance = cloud115;
|
||||
|
||||
22
backend/src/controllers/douban.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Request, Response } from "express";
|
||||
import DoubanService from "../services/DoubanService";
|
||||
import { sendSuccess, sendError } from "../utils/response";
|
||||
|
||||
const doubanService = new DoubanService();
|
||||
|
||||
export const doubanController = {
|
||||
async getDoubanHotList(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { type = "movie", tag = "热门", page_limit = "50", page_start = "0" } = req.query;
|
||||
const result = await doubanService.getHotList({
|
||||
type: type as string,
|
||||
tag: tag as string,
|
||||
page_limit: page_limit as string,
|
||||
page_start: page_start as string,
|
||||
});
|
||||
sendSuccess(res, result);
|
||||
} catch (error) {
|
||||
sendError(res, { message: "获取热门列表失败" });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,40 +1,52 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { QuarkService } from "../services/QuarkService";
|
||||
import { config } from "../config";
|
||||
import { handleResponse } from "../utils/responseHandler";
|
||||
import handleError from "../utils/handleError";
|
||||
import { sendSuccess, sendError } from "../utils/response";
|
||||
import UserSetting from "../models/UserSetting";
|
||||
|
||||
const { cookie } = config.quark;
|
||||
const quark = new QuarkService();
|
||||
|
||||
const quark = new QuarkService(cookie);
|
||||
const setCookie = async (req: Request): Promise<void> => {
|
||||
const userId = req.user?.userId;
|
||||
const userSetting = await UserSetting.findOne({
|
||||
where: { userId },
|
||||
});
|
||||
if (userSetting && userSetting.dataValues.quarkCookie) {
|
||||
quark.setCookie(userSetting.dataValues.quarkCookie);
|
||||
} else {
|
||||
throw new Error("请先设置夸克网盘cookie");
|
||||
}
|
||||
};
|
||||
|
||||
export const quarkController = {
|
||||
async getShareInfo(req: Request, res: Response, next: NextFunction) {
|
||||
async getShareInfo(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { pwdId, passcode } = req.query;
|
||||
await setCookie(req);
|
||||
const result = await quark.getShareInfo(pwdId as string, passcode as string);
|
||||
handleResponse(res, result, true);
|
||||
sendSuccess(res, result);
|
||||
} catch (error) {
|
||||
handleError(res, error, "获取分享信息失败", next);
|
||||
sendError(res, { message: "获取分享信息失败" });
|
||||
}
|
||||
},
|
||||
|
||||
async getFolderList(req: Request, res: Response, next: NextFunction) {
|
||||
async getFolderList(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { parentCid } = req.query;
|
||||
await setCookie(req);
|
||||
const result = await quark.getFolderList(parentCid as string);
|
||||
handleResponse(res, result, true);
|
||||
sendSuccess(res, result);
|
||||
} catch (error) {
|
||||
handleError(res, error, "获取目录列表失败", next);
|
||||
sendError(res, { message: (error as Error).message || "获取目录列表失败" });
|
||||
}
|
||||
},
|
||||
|
||||
async saveFile(req: Request, res: Response, next: NextFunction) {
|
||||
async saveFile(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
await setCookie(req);
|
||||
const result = await quark.saveSharedFile(req.body);
|
||||
handleResponse(res, result, true);
|
||||
sendSuccess(res, result);
|
||||
} catch (error) {
|
||||
handleError(res, error, "保存文件失败", next);
|
||||
sendError(res, { message: (error as Error).message || "保存文件失败" });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,32 +1,21 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { RSSSearcher } from "../services/RSSSearcher";
|
||||
import { Searcher } from "../services/Searcher";
|
||||
import { handleResponse } from "../utils/responseHandler";
|
||||
import handleError from "../utils/handleError";
|
||||
import { Request, Response } from "express";
|
||||
import Searcher from "../services/Searcher";
|
||||
import { sendSuccess, sendError } from "../utils/response";
|
||||
|
||||
export const resourceController = {
|
||||
async rssSearch(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { keyword } = req.query;
|
||||
const searcher = new RSSSearcher();
|
||||
const result = await searcher.searchAll(keyword as string);
|
||||
handleResponse(res, result, true);
|
||||
} catch (error) {
|
||||
handleError(res, error, "获取资源发生未知错误", next);
|
||||
}
|
||||
},
|
||||
async search(req: Request, res: Response, next: NextFunction) {
|
||||
async search(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { keyword, channelId = "", lastMessageId = "" } = req.query; // Remove `: string` from here
|
||||
const searcher = new Searcher();
|
||||
const result = await searcher.searchAll(
|
||||
const result = await Searcher.searchAll(
|
||||
keyword as string,
|
||||
channelId as string,
|
||||
lastMessageId as string
|
||||
);
|
||||
handleResponse(res, result, true);
|
||||
sendSuccess(res, result);
|
||||
} catch (error) {
|
||||
handleError(res, error, "获取资源发生未知错误", next);
|
||||
sendError(res, {
|
||||
message: (error as Error).message || "搜索资源失败",
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
59
backend/src/controllers/setting.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Request, Response } from "express";
|
||||
import { sendSuccess, sendError } from "../utils/response";
|
||||
import Searcher from "../services/Searcher";
|
||||
import UserSetting from "../models/UserSetting";
|
||||
import GlobalSetting from "../models/GlobalSetting";
|
||||
import { iamgesInstance } from "./teleImages";
|
||||
|
||||
export const settingController = {
|
||||
async get(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
const role = req.user?.role;
|
||||
if (userId !== null) {
|
||||
let userSettings = await UserSetting.findOne({ where: { userId } });
|
||||
if (!userSettings) {
|
||||
userSettings = {
|
||||
userId: userId,
|
||||
cloud115Cookie: "",
|
||||
quarkCookie: "",
|
||||
} as UserSetting;
|
||||
if (userSettings) {
|
||||
await UserSetting.create(userSettings);
|
||||
}
|
||||
}
|
||||
const globalSetting = await GlobalSetting.findOne();
|
||||
sendSuccess(res, {
|
||||
data: {
|
||||
userSettings,
|
||||
globalSetting: role === 1 ? globalSetting : null,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
sendError(res, { message: "用户ID无效" });
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("获取设置失败:" + error);
|
||||
sendError(res, { message: (error as Error).message || "获取设置失败" });
|
||||
}
|
||||
},
|
||||
async save(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
const role = req.user?.role;
|
||||
if (userId !== null) {
|
||||
const { userSettings, globalSetting } = req.body;
|
||||
await UserSetting.update(userSettings, { where: { userId } });
|
||||
if (role === 1 && globalSetting) await GlobalSetting.update(globalSetting, { where: {} });
|
||||
Searcher.updateAxiosInstance();
|
||||
iamgesInstance.updateProxyConfig();
|
||||
sendSuccess(res, {
|
||||
message: "保存成功",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("保存设置失败:" + error);
|
||||
sendError(res, { message: (error as Error).message || "保存设置失败" });
|
||||
}
|
||||
},
|
||||
};
|
||||
78
backend/src/controllers/teleImages.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import e, { Request, Response } from "express";
|
||||
import tunnel from "tunnel";
|
||||
import GlobalSetting from "../models/GlobalSetting";
|
||||
import { GlobalSettingAttributes } from "../models/GlobalSetting";
|
||||
|
||||
export class ImageControll {
|
||||
private axiosInstance: AxiosInstance | null = null;
|
||||
private settings: GlobalSetting | null = null;
|
||||
|
||||
constructor() {
|
||||
this.initializeAxiosInstance();
|
||||
}
|
||||
|
||||
private async initializeAxiosInstance(): Promise<void> {
|
||||
try {
|
||||
this.settings = await GlobalSetting.findOne();
|
||||
} catch (error) {
|
||||
console.error("Error fetching global settings:", error);
|
||||
}
|
||||
const globalSetting = this.settings?.dataValues || ({} as GlobalSettingAttributes);
|
||||
this.axiosInstance = axios.create({
|
||||
timeout: 3000,
|
||||
httpsAgent: globalSetting.isProxyEnabled
|
||||
? tunnel.httpsOverHttp({
|
||||
proxy: {
|
||||
host: globalSetting.httpProxyHost,
|
||||
port: globalSetting.httpProxyPort,
|
||||
headers: {
|
||||
"Proxy-Authorization": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
withCredentials: true,
|
||||
});
|
||||
}
|
||||
public async updateProxyConfig(): Promise<void> {
|
||||
try {
|
||||
this.settings = await GlobalSetting.findOne();
|
||||
const globalSetting = this.settings?.dataValues || ({} as GlobalSettingAttributes);
|
||||
if (this.axiosInstance) {
|
||||
this.axiosInstance.defaults.httpsAgent = globalSetting.isProxyEnabled
|
||||
? tunnel.httpsOverHttp({
|
||||
proxy: {
|
||||
host: globalSetting.httpProxyHost,
|
||||
port: globalSetting.httpProxyPort,
|
||||
headers: {
|
||||
"Proxy-Authorization": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating proxy config:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async getImages(req: Request, res: Response, url: string): Promise<void> {
|
||||
try {
|
||||
const response = await this.axiosInstance?.get(url, { responseType: "stream" });
|
||||
res.set("Content-Type", response?.headers["content-type"]);
|
||||
response?.data.pipe(res);
|
||||
} catch (error) {
|
||||
res.status(500).send("Image fetch error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const iamgesInstance = new ImageControll();
|
||||
|
||||
export const imageControll = {
|
||||
getImages: async (req: Request, res: Response): Promise<void> => {
|
||||
const url = req.query.url as string;
|
||||
iamgesInstance.getImages(req, res, url);
|
||||
},
|
||||
};
|
||||
62
backend/src/controllers/user.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Request, Response } from "express";
|
||||
import bcrypt from "bcrypt";
|
||||
import jwt from "jsonwebtoken";
|
||||
import GlobalSetting from "../models/GlobalSetting";
|
||||
import User from "../models/User";
|
||||
import { config } from "../config";
|
||||
import { sendSuccess, sendError } from "../utils/response";
|
||||
|
||||
const isValidInput = (input: string): boolean => {
|
||||
// 检查是否包含空格或汉字
|
||||
const regex = /^[^\s\u4e00-\u9fa5]+$/;
|
||||
return regex.test(input);
|
||||
};
|
||||
export const userController = {
|
||||
async register(req: Request, res: Response): Promise<void> {
|
||||
const { username, password, registerCode } = req.body;
|
||||
const globalSetting = await GlobalSetting.findOne();
|
||||
const registerCodeList = [
|
||||
globalSetting?.dataValues.CommonUserCode,
|
||||
globalSetting?.dataValues.AdminUserCode,
|
||||
];
|
||||
if (!registerCode || !registerCodeList.includes(Number(registerCode))) {
|
||||
return sendError(res, { message: "注册码错误" });
|
||||
}
|
||||
// 验证输入
|
||||
if (!isValidInput(username) || !isValidInput(password)) {
|
||||
return sendError(res, { message: "用户名、密码或注册码不能包含空格或汉字" });
|
||||
}
|
||||
// 检查用户名是否已存在
|
||||
const existingUser = await User.findOne({ where: { username } });
|
||||
if (existingUser) {
|
||||
return sendError(res, { message: "用户名已存在" });
|
||||
}
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
try {
|
||||
const role = registerCodeList.findIndex((x) => x === Number(registerCode));
|
||||
const user = await User.create({ username, password: hashedPassword, role });
|
||||
sendSuccess(res, {
|
||||
data: user,
|
||||
message: "用户注册成功",
|
||||
});
|
||||
} catch (error) {
|
||||
sendError(res, { message: (error as Error).message || "用户注册失败" });
|
||||
}
|
||||
},
|
||||
|
||||
async login(req: Request, res: Response): Promise<void> {
|
||||
const { username, password } = req.body;
|
||||
const user = await User.findOne({ where: { username } });
|
||||
if (!user || !(await bcrypt.compare(password, user.password))) {
|
||||
return sendError(res, { message: "用户名或密码错误" });
|
||||
}
|
||||
const token = jwt.sign({ userId: user.userId, role: user.role }, config.jwtSecret, {
|
||||
expiresIn: "6h",
|
||||
});
|
||||
sendSuccess(res, {
|
||||
data: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
43
backend/src/middleware/auth.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// filepath: /D:/code/CloudDiskDown/backend/src/middleware/auth.ts
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import jwt, { JwtPayload } from "jsonwebtoken";
|
||||
import User from "../models/User";
|
||||
import { config } from "../config";
|
||||
|
||||
interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
userId: string;
|
||||
role: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const authMiddleware = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void | Response> => {
|
||||
if (req.path === "/user/login" || req.path === "/user/register") {
|
||||
return next();
|
||||
}
|
||||
|
||||
const token = req.headers.authorization?.split(" ")[1];
|
||||
if (!token) {
|
||||
return res.status(401).json({ message: "未提供 token" });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwtSecret) as JwtPayload;
|
||||
|
||||
req.user = {
|
||||
userId: decoded.userId,
|
||||
role: decoded.role,
|
||||
};
|
||||
const user = await User.findOne({ where: { userId: decoded.userId } });
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: "无效的 token" });
|
||||
}
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(401).json({ message: "无效的 token" });
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
|
||||
export const errorHandler = (err: any, req: Request, res: Response, next: NextFunction) => {
|
||||
interface CustomError extends Error {
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export const errorHandler = (err: CustomError, req: Request, res: Response): void => {
|
||||
console.error(err);
|
||||
res.status(err.status || 500).json({
|
||||
success: false,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
|
||||
export const validateRequest = (requiredParams: string[]) => {
|
||||
export const validateRequest = (
|
||||
requiredParams: string[]
|
||||
): ((req: Request, res: Response, next: NextFunction) => Response | void) => {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const missingParams = requiredParams.filter((param) => !req.query[param] && !req.body[param]);
|
||||
if (missingParams.length > 0) {
|
||||
|
||||
67
backend/src/models/GlobalSetting.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { DataTypes, Model, Optional } from "sequelize";
|
||||
import sequelize from "../config/database";
|
||||
|
||||
export interface GlobalSettingAttributes {
|
||||
id: number;
|
||||
httpProxyHost: string;
|
||||
httpProxyPort: number;
|
||||
isProxyEnabled: boolean;
|
||||
CommonUserCode: number;
|
||||
AdminUserCode: number;
|
||||
}
|
||||
|
||||
interface GlobalSettingCreationAttributes extends Optional<GlobalSettingAttributes, "id"> {}
|
||||
|
||||
class GlobalSetting
|
||||
extends Model<GlobalSettingAttributes, GlobalSettingCreationAttributes>
|
||||
implements GlobalSettingAttributes
|
||||
{
|
||||
public id!: number;
|
||||
public httpProxyHost!: string;
|
||||
public httpProxyPort!: number;
|
||||
public isProxyEnabled!: boolean;
|
||||
public CommonUserCode!: number;
|
||||
public AdminUserCode!: number;
|
||||
}
|
||||
|
||||
GlobalSetting.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
},
|
||||
httpProxyHost: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: "127.0.0.1",
|
||||
},
|
||||
httpProxyPort: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 7890,
|
||||
},
|
||||
isProxyEnabled: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
CommonUserCode: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 9527,
|
||||
},
|
||||
AdminUserCode: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 230713,
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
modelName: "GlobalSetting",
|
||||
tableName: "global_settings",
|
||||
}
|
||||
);
|
||||
|
||||
export default GlobalSetting;
|
||||
62
backend/src/models/User.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { DataTypes, Model, Optional } from "sequelize";
|
||||
import sequelize from "../config/database";
|
||||
|
||||
interface UserAttributes {
|
||||
id: number;
|
||||
userId?: number;
|
||||
username: string;
|
||||
password: string;
|
||||
role: number; // 修改为数字类型
|
||||
}
|
||||
|
||||
interface UserCreationAttributes extends Optional<UserAttributes, "id"> {}
|
||||
|
||||
class User extends Model<UserAttributes, UserCreationAttributes> implements UserAttributes {
|
||||
public id!: number;
|
||||
public userId!: number;
|
||||
public username!: string;
|
||||
public password!: string;
|
||||
public role!: number; // 实现数字类型的角色属性
|
||||
}
|
||||
|
||||
User.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
allowNull: false, // 显式设置为不可为空
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.UUID, // 对外暴露的不可预测ID
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
unique: true,
|
||||
allowNull: false, // 显式设置为不可为空
|
||||
},
|
||||
username: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
},
|
||||
password: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
role: {
|
||||
type: DataTypes.INTEGER, // 修改为数字类型
|
||||
allowNull: false,
|
||||
defaultValue: 0, // 默认值为普通用户
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
modelName: "User",
|
||||
tableName: "users",
|
||||
}
|
||||
);
|
||||
|
||||
// 角色映射
|
||||
// 0: 普通用户
|
||||
// 1: 管理员
|
||||
|
||||
export default User;
|
||||
72
backend/src/models/UserSetting.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { DataTypes, Model, Optional } from "sequelize";
|
||||
import sequelize from "../config/database";
|
||||
import User from "./User";
|
||||
|
||||
interface UserSettingAttributes {
|
||||
id: number;
|
||||
userId: string;
|
||||
cloud115UserId?: string;
|
||||
cloud115Cookie: string;
|
||||
quarkCookie: string;
|
||||
}
|
||||
|
||||
interface UserSettingCreationAttributes extends Optional<UserSettingAttributes, "id"> {}
|
||||
|
||||
class UserSetting
|
||||
extends Model<UserSettingAttributes, UserSettingCreationAttributes>
|
||||
implements UserSettingAttributes
|
||||
{
|
||||
public id!: number;
|
||||
public userId!: string;
|
||||
public cloud115UserId?: string;
|
||||
public cloud115Cookie!: string;
|
||||
public quarkCookie!: string;
|
||||
}
|
||||
|
||||
UserSetting.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
references: {
|
||||
model: User,
|
||||
key: "userId",
|
||||
},
|
||||
onDelete: "CASCADE",
|
||||
},
|
||||
cloud115UserId: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
cloud115Cookie: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
quarkCookie: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
modelName: "UserSetting",
|
||||
tableName: "user_settings",
|
||||
}
|
||||
);
|
||||
|
||||
User.hasOne(UserSetting, {
|
||||
foreignKey: "userId",
|
||||
as: "settings",
|
||||
});
|
||||
UserSetting.belongsTo(User, {
|
||||
foreignKey: "userId",
|
||||
as: "user",
|
||||
});
|
||||
|
||||
export default UserSetting;
|
||||
@@ -2,12 +2,23 @@ import express from "express";
|
||||
import { cloud115Controller } from "../controllers/cloud115";
|
||||
import { quarkController } from "../controllers/quark";
|
||||
import { resourceController } from "../controllers/resource";
|
||||
import { doubanController } from "../controllers/douban";
|
||||
import { imageControll } from "../controllers/teleImages";
|
||||
import settingRoutes from "./setting";
|
||||
import userRoutes from "./user";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 用户相关路由
|
||||
router.use("/user", userRoutes);
|
||||
|
||||
router.use("/tele-images", imageControll.getImages);
|
||||
|
||||
// 设置相关路由
|
||||
router.use("/setting", settingRoutes);
|
||||
|
||||
// 资源搜索
|
||||
router.get("/search", resourceController.search);
|
||||
router.get("/rssSearch", resourceController.rssSearch);
|
||||
|
||||
// 115网盘相关
|
||||
router.get("/cloud115/share-info", cloud115Controller.getShareInfo);
|
||||
@@ -19,4 +30,7 @@ router.get("/quark/share-info", quarkController.getShareInfo);
|
||||
router.get("/quark/folders", quarkController.getFolderList);
|
||||
router.post("/quark/save", quarkController.saveFile);
|
||||
|
||||
// 获取豆瓣热门列表
|
||||
router.get("/douban/hot", doubanController.getDoubanHotList);
|
||||
|
||||
export default router;
|
||||
|
||||
9
backend/src/routes/setting.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import express from "express";
|
||||
import { settingController } from "../controllers/setting";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/get", settingController.get);
|
||||
router.post("/save", settingController.save);
|
||||
|
||||
export default router;
|
||||
10
backend/src/routes/user.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// backend/src/routes/user.ts
|
||||
import express from "express";
|
||||
import { userController } from "../controllers/user";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post("/register", userController.register);
|
||||
router.post("/login", userController.login);
|
||||
|
||||
export default router;
|
||||
@@ -4,14 +4,28 @@ import { Logger } from "../utils/logger";
|
||||
import { config } from "../config/index";
|
||||
import { ShareInfoResponse } from "../types/cloud115";
|
||||
|
||||
interface Cloud115ListItem {
|
||||
cid: string;
|
||||
n: string;
|
||||
s: number;
|
||||
}
|
||||
|
||||
interface Cloud115FolderItem {
|
||||
cid: string;
|
||||
n: string;
|
||||
ns: number;
|
||||
}
|
||||
|
||||
interface Cloud115PathItem {
|
||||
cid: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class Cloud115Service {
|
||||
private api: AxiosInstance;
|
||||
private cookie: string = "";
|
||||
|
||||
constructor(cookie: string) {
|
||||
if (!cookie) {
|
||||
throw new Error("115网盘需要提供cookie进行身份验证");
|
||||
}
|
||||
|
||||
constructor(cookie?: string) {
|
||||
this.api = createAxiosInstance(
|
||||
"https://webapi.115.com",
|
||||
AxiosHeaders.from({
|
||||
@@ -29,91 +43,80 @@ export class Cloud115Service {
|
||||
Referer: "https://servicewechat.com/wx2c744c010a61b0fa/94/page-frame.html",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
cookie: cookie,
|
||||
})
|
||||
);
|
||||
if (cookie) {
|
||||
this.setCookie(cookie);
|
||||
} else {
|
||||
console.log("请注意:115网盘需要提供cookie进行身份验证");
|
||||
}
|
||||
this.api.interceptors.request.use((config) => {
|
||||
config.headers.cookie = cookie || this.cookie;
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
public setCookie(cookie: string): void {
|
||||
this.cookie = cookie;
|
||||
}
|
||||
|
||||
async getShareInfo(shareCode: string, receiveCode = ""): Promise<ShareInfoResponse> {
|
||||
try {
|
||||
const response = await this.api.get("/share/snap", {
|
||||
params: {
|
||||
share_code: shareCode,
|
||||
receive_code: receiveCode,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
cid: "",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data?.state && response.data.data?.list?.length > 0) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
list: response.data.data.list.map((item: any) => ({
|
||||
fileId: item.cid,
|
||||
fileName: item.n,
|
||||
fileSize: item.s,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const response = await this.api.get("/share/snap", {
|
||||
params: {
|
||||
share_code: shareCode,
|
||||
receive_code: receiveCode,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
cid: "",
|
||||
},
|
||||
});
|
||||
if (response.data?.state && response.data.data?.list?.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: "未找到文件信息",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "未知错误",
|
||||
data: response.data.data.list.map((item: Cloud115ListItem) => ({
|
||||
fileId: item.cid,
|
||||
fileName: item.n,
|
||||
fileSize: item.s,
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error("未找到文件信息");
|
||||
}
|
||||
|
||||
async getFolderList(parentCid = "0") {
|
||||
try {
|
||||
const response = await this.api.get("/files", {
|
||||
params: {
|
||||
aid: 1,
|
||||
cid: parentCid,
|
||||
o: "user_ptime",
|
||||
asc: 0,
|
||||
offset: 0,
|
||||
show_dir: 1,
|
||||
limit: 50,
|
||||
type: 0,
|
||||
format: "json",
|
||||
star: 0,
|
||||
suffix: "",
|
||||
natsort: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data?.state) {
|
||||
return {
|
||||
success: true,
|
||||
data: response.data.data
|
||||
.filter((item: any) => item.cid)
|
||||
.map((folder: any) => ({
|
||||
cid: folder.cid,
|
||||
name: folder.n,
|
||||
path: response.data.path,
|
||||
})),
|
||||
};
|
||||
} else {
|
||||
Logger.error("获取目录列表失败:", response.data.error);
|
||||
return {
|
||||
success: false,
|
||||
error: "获取115pan目录列表失败:" + response.data.error,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("获取目录列表失败:", error);
|
||||
async getFolderList(
|
||||
parentCid = "0"
|
||||
): Promise<{ data: { cid: string; name: string; path: Cloud115PathItem[] }[] }> {
|
||||
const response = await this.api.get("/files", {
|
||||
params: {
|
||||
aid: 1,
|
||||
cid: parentCid,
|
||||
o: "user_ptime",
|
||||
asc: 1,
|
||||
offset: 0,
|
||||
show_dir: 1,
|
||||
limit: 50,
|
||||
type: 0,
|
||||
format: "json",
|
||||
star: 0,
|
||||
suffix: "",
|
||||
natsort: 0,
|
||||
snap: 0,
|
||||
record_open_time: 1,
|
||||
fc_mix: 0,
|
||||
},
|
||||
});
|
||||
if (response.data?.state) {
|
||||
return {
|
||||
success: false,
|
||||
error: "获取115pan目录列表失败",
|
||||
data: response.data.data
|
||||
.filter((item: Cloud115FolderItem) => item.cid && !!item.ns)
|
||||
.map((folder: Cloud115FolderItem) => ({
|
||||
cid: folder.cid,
|
||||
name: folder.n,
|
||||
path: response.data.path,
|
||||
})),
|
||||
};
|
||||
} else {
|
||||
Logger.error("获取目录列表失败:", response.data.error);
|
||||
throw new Error("获取115pan目录列表失败:" + response.data.error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,27 +125,24 @@ export class Cloud115Service {
|
||||
shareCode: string;
|
||||
receiveCode: string;
|
||||
fileId: string;
|
||||
}) {
|
||||
try {
|
||||
const param = new URLSearchParams({
|
||||
cid: params.cid,
|
||||
user_id: config.cloud115.userId,
|
||||
share_code: params.shareCode,
|
||||
receive_code: params.receiveCode,
|
||||
file_id: params.fileId,
|
||||
});
|
||||
const response = await this.api.post("/share/receive", param.toString());
|
||||
|
||||
}): Promise<{ message: string; data: unknown }> {
|
||||
const param = new URLSearchParams({
|
||||
cid: params.cid,
|
||||
user_id: config.cloud115.userId,
|
||||
share_code: params.shareCode,
|
||||
receive_code: params.receiveCode,
|
||||
file_id: params.fileId,
|
||||
});
|
||||
const response = await this.api.post("/share/receive", param.toString());
|
||||
Logger.info("保存文件:", response.data);
|
||||
if (response.data.state) {
|
||||
return {
|
||||
success: response.data.state,
|
||||
error: response.data.error,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "未知错误",
|
||||
message: response.data.error,
|
||||
data: response.data.data,
|
||||
};
|
||||
} else {
|
||||
Logger.error("保存文件失败:", response.data.error);
|
||||
throw new Error("保存115pan文件失败:" + response.data.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
66
backend/src/services/DoubanService.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { AxiosHeaders, AxiosInstance } from "axios";
|
||||
import { createAxiosInstance } from "../utils/axiosInstance";
|
||||
|
||||
interface DoubanSubject {
|
||||
id: string;
|
||||
title: string;
|
||||
rate: string;
|
||||
cover: string;
|
||||
url: string;
|
||||
is_new: boolean;
|
||||
}
|
||||
|
||||
class DoubanService {
|
||||
private baseUrl: string;
|
||||
private api: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = "https://movie.douban.com/j";
|
||||
this.api = createAxiosInstance(
|
||||
this.baseUrl,
|
||||
AxiosHeaders.from({
|
||||
accept: "*/*",
|
||||
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
|
||||
priority: "u=1, i",
|
||||
"sec-ch-ua": '"Not A(Brand";v="8", "Chromium";v="132", "Microsoft Edge";v="132"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"Windows"',
|
||||
"sec-fetch-dest": "empty",
|
||||
"sec-fetch-mode": "cors",
|
||||
"sec-fetch-site": "same-origin",
|
||||
"x-requested-with": "XMLHttpRequest",
|
||||
cookie:
|
||||
'll="118282"; bid=StA6AQFsAWQ; _pk_id.100001.4cf6=6448be57b1b5ca7e.1723172321.; _vwo_uuid_v2=DC15B8183560FF1E538FFE1D480723310|c08e2d213ecb5510005f90a6ff332121; __utmv=30149280.6282; _vwo_uuid_v2=DC15B8183560FF1E538FFE1D480723310|c08e2d213ecb5510005f90a6ff332121; __utmz=30149280.1731915179.21.6.utmcsr=search.douban.com|utmccn=(referral)|utmcmd=referral|utmcct=/movie/subject_search; __utmz=223695111.1731915179.21.6.utmcsr=search.douban.com|utmccn=(referral)|utmcmd=referral|utmcct=/movie/subject_search; douban-fav-remind=1; __utmc=30149280; __utmc=223695111; _pk_ref.100001.4cf6=%5B%22%22%2C%22%22%2C1739176523%2C%22https%3A%2F%2Fsearch.douban.com%2Fmovie%2Fsubject_search%3Fsearch_text%3D%E8%84%91%E6%B4%9E%E5%A4%A7%E5%BC%80%26cat%3D1002%22%5D; _pk_ses.100001.4cf6=1; ap_v=0,6.0; __utma=30149280.859303574.1723448979.1739167503.1739176523.42; __utmb=30149280.0.10.1739176523; __utma=223695111.1882744177.1723448979.1739167503.1739176523.42; __utmb=223695111.0.10.1739176523',
|
||||
Referer: "https://movie.douban.com/",
|
||||
"Referrer-Policy": "unsafe-url",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async getHotList(params: {
|
||||
type: string;
|
||||
tag: string;
|
||||
page_limit: string;
|
||||
page_start: string;
|
||||
}): Promise<{ data: DoubanSubject[] }> {
|
||||
try {
|
||||
const response = await this.api.get("/search_subjects", {
|
||||
params: params,
|
||||
});
|
||||
if (response.data && response.data.subjects) {
|
||||
return {
|
||||
data: response.data.subjects,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching hot list:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default DoubanService;
|
||||
@@ -2,18 +2,33 @@ import { AxiosInstance, AxiosHeaders } from "axios";
|
||||
import { Logger } from "../utils/logger";
|
||||
import { createAxiosInstance } from "../utils/axiosInstance";
|
||||
|
||||
interface QuarkShareInfo {
|
||||
stoken?: string;
|
||||
pwdId?: string;
|
||||
fileSize?: number;
|
||||
list: {
|
||||
fid: string;
|
||||
file_name: string;
|
||||
file_type: number;
|
||||
share_fid_token: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface QuarkFolderItem {
|
||||
fid: string;
|
||||
file_name: string;
|
||||
file_type: number;
|
||||
}
|
||||
|
||||
export class QuarkService {
|
||||
private api: AxiosInstance;
|
||||
private cookie: string = "";
|
||||
|
||||
constructor(cookie: string) {
|
||||
if (!cookie) {
|
||||
throw new Error("115网盘需要提供cookie进行身份验证");
|
||||
}
|
||||
|
||||
constructor(cookie?: string) {
|
||||
this.api = createAxiosInstance(
|
||||
"https://drive-h.quark.cn",
|
||||
AxiosHeaders.from({
|
||||
cookie: cookie,
|
||||
cookie: this.cookie,
|
||||
accept: "application/json, text/plain, */*",
|
||||
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
|
||||
"content-type": "application/json",
|
||||
@@ -26,125 +41,115 @@ export class QuarkService {
|
||||
"sec-fetch-site": "same-site",
|
||||
})
|
||||
);
|
||||
if (cookie) {
|
||||
this.setCookie(cookie);
|
||||
} else {
|
||||
console.log("请注意:夸克网盘需要提供cookie进行身份验证");
|
||||
}
|
||||
this.api.interceptors.request.use((config) => {
|
||||
config.headers.cookie = cookie || this.cookie;
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
async getShareInfo(pwdId: string, passcode = "") {
|
||||
try {
|
||||
const response = await this.api.post(
|
||||
`/1/clouddrive/share/sharepage/token?pr=ucpro&fr=pc&uc_param_str=&__dt=994&__t=${Date.now()}`,
|
||||
{
|
||||
pwd_id: pwdId,
|
||||
passcode: "",
|
||||
}
|
||||
);
|
||||
if (response.data?.status === 200 && response.data.data) {
|
||||
const fileInfo = response.data.data;
|
||||
if (fileInfo.stoken) {
|
||||
let res = await this.getShareList(pwdId, fileInfo.stoken);
|
||||
return {
|
||||
success: true,
|
||||
data: res,
|
||||
};
|
||||
}
|
||||
public setCookie(cookie: string): void {
|
||||
this.cookie = cookie;
|
||||
}
|
||||
|
||||
async getShareInfo(pwdId: string, passcode = ""): Promise<{ data: QuarkShareInfo }> {
|
||||
const response = await this.api.post(
|
||||
`/1/clouddrive/share/sharepage/token?pr=ucpro&fr=pc&uc_param_str=&__dt=994&__t=${Date.now()}`,
|
||||
{
|
||||
pwd_id: pwdId,
|
||||
passcode,
|
||||
}
|
||||
);
|
||||
if (response.data?.status === 200 && response.data.data) {
|
||||
const fileInfo = response.data.data;
|
||||
if (fileInfo.stoken) {
|
||||
const res = await this.getShareList(pwdId, fileInfo.stoken);
|
||||
return {
|
||||
data: res,
|
||||
};
|
||||
}
|
||||
}
|
||||
throw new Error("获取夸克分享信息失败");
|
||||
}
|
||||
|
||||
async getShareList(pwdId: string, stoken: string): Promise<QuarkShareInfo> {
|
||||
const response = await this.api.get("/1/clouddrive/share/sharepage/detail", {
|
||||
params: {
|
||||
pr: "ucpro",
|
||||
fr: "pc",
|
||||
uc_param_str: "",
|
||||
pwd_id: pwdId,
|
||||
stoken: stoken,
|
||||
pdir_fid: "0",
|
||||
force: "0",
|
||||
_page: "1",
|
||||
_size: "50",
|
||||
_fetch_banner: "1",
|
||||
_fetch_share: "1",
|
||||
_fetch_total: "1",
|
||||
_sort: "file_type:asc,updated_at:desc",
|
||||
__dt: "1589",
|
||||
__t: Date.now(),
|
||||
},
|
||||
});
|
||||
if (response.data?.data) {
|
||||
const list = response.data.data.list
|
||||
.filter((item: QuarkShareInfo["list"][0]) => item.fid)
|
||||
.map((folder: QuarkShareInfo["list"][0]) => ({
|
||||
fileId: folder.fid,
|
||||
fileName: folder.file_name,
|
||||
fileIdToken: folder.share_fid_token,
|
||||
}));
|
||||
return {
|
||||
success: false,
|
||||
error: "未找到文件信息",
|
||||
list,
|
||||
pwdId,
|
||||
stoken,
|
||||
fileSize: response.data.data.share?.size || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "未知错误",
|
||||
list: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getShareList(pwdId: string, stoken: string) {
|
||||
try {
|
||||
const response = await this.api.get("/1/clouddrive/share/sharepage/detail", {
|
||||
params: {
|
||||
pr: "ucpro",
|
||||
fr: "pc",
|
||||
uc_param_str: "",
|
||||
pwd_id: pwdId,
|
||||
stoken: stoken,
|
||||
pdir_fid: "0",
|
||||
force: "0",
|
||||
_page: "1",
|
||||
_size: "50",
|
||||
_fetch_banner: "1",
|
||||
_fetch_share: "1",
|
||||
_fetch_total: "1",
|
||||
_sort: "file_type:asc,updated_at:desc",
|
||||
__dt: "1589",
|
||||
__t: Date.now(),
|
||||
},
|
||||
});
|
||||
if (response.data?.data) {
|
||||
const list = response.data.data.list
|
||||
.filter((item: any) => item.fid)
|
||||
.map((folder: any) => ({
|
||||
fileId: folder.fid,
|
||||
fileName: folder.file_name,
|
||||
fileIdToken: folder.share_fid_token,
|
||||
}));
|
||||
return {
|
||||
list,
|
||||
pwdId,
|
||||
stoken: stoken,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
list: [],
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("获取目录列表失败:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getFolderList(parentCid = "0") {
|
||||
try {
|
||||
const response = await this.api.get("/1/clouddrive/file/sort", {
|
||||
params: {
|
||||
pr: "ucpro",
|
||||
fr: "pc",
|
||||
uc_param_str: "",
|
||||
pdir_fid: parentCid,
|
||||
_page: "1",
|
||||
_size: "100",
|
||||
_fetch_total: "false",
|
||||
_fetch_sub_dirs: "1",
|
||||
_sort: "",
|
||||
__dt: "2093126",
|
||||
__t: Date.now(),
|
||||
},
|
||||
});
|
||||
if (response.data?.data && response.data.data.list.length) {
|
||||
return {
|
||||
success: true,
|
||||
data: response.data.data.list
|
||||
.filter((item: any) => item.fid)
|
||||
.map((folder: any) => ({
|
||||
cid: folder.fid,
|
||||
name: folder.file_name,
|
||||
path: [],
|
||||
})),
|
||||
};
|
||||
} else {
|
||||
Logger.error("获取目录列表失败:", response.data.error);
|
||||
return {
|
||||
success: false,
|
||||
error: "获取夸克目录列表失败:" + response.data.error,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("获取目录列表失败:", error);
|
||||
async getFolderList(
|
||||
parentCid = "0"
|
||||
): Promise<{ data: { cid: string; name: string; path: [] }[] }> {
|
||||
const response = await this.api.get("/1/clouddrive/file/sort", {
|
||||
params: {
|
||||
pr: "ucpro",
|
||||
fr: "pc",
|
||||
uc_param_str: "",
|
||||
pdir_fid: parentCid,
|
||||
_page: "1",
|
||||
_size: "100",
|
||||
_fetch_total: "false",
|
||||
_fetch_sub_dirs: "1",
|
||||
_sort: "",
|
||||
__dt: "2093126",
|
||||
__t: Date.now(),
|
||||
},
|
||||
});
|
||||
if (response.data?.data && response.data.data.list) {
|
||||
const data = response.data.data.list
|
||||
.filter((item: QuarkFolderItem) => item.fid && item.file_type === 0)
|
||||
.map((folder: QuarkFolderItem) => ({
|
||||
cid: folder.fid,
|
||||
name: folder.file_name,
|
||||
path: [],
|
||||
}));
|
||||
return {
|
||||
success: false,
|
||||
error: "获取夸克目录列表失败",
|
||||
data,
|
||||
};
|
||||
} else {
|
||||
const message = "获取夸克目录列表失败:" + response.data.error;
|
||||
Logger.error(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +161,7 @@ export class QuarkService {
|
||||
stoken: string;
|
||||
pdir_fid: string;
|
||||
scene: string;
|
||||
}) {
|
||||
}): Promise<{ message: string; data: unknown }> {
|
||||
try {
|
||||
const response = await this.api.post(
|
||||
`/1/clouddrive/share/sharepage/save?pr=ucpro&fr=pc&uc_param_str=&__dt=208097&__t=${Date.now()}`,
|
||||
@@ -164,15 +169,11 @@ export class QuarkService {
|
||||
);
|
||||
|
||||
return {
|
||||
success: response.data.code === 0,
|
||||
error: response.data.message,
|
||||
message: response.data.message,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "未知错误",
|
||||
};
|
||||
throw new Error(error instanceof Error ? error.message : "未知错误");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import RSSParser from "rss-parser";
|
||||
import { AxiosInstance, AxiosHeaders } from "axios";
|
||||
import { config } from "../config";
|
||||
import { Logger } from "../utils/logger";
|
||||
import { createAxiosInstance } from "../utils/axiosInstance";
|
||||
|
||||
interface RSSItem {
|
||||
title?: string;
|
||||
link?: string;
|
||||
pubDate?: string;
|
||||
content?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
cloudLinks?: string[];
|
||||
}
|
||||
|
||||
export class RSSSearcher {
|
||||
private parser: RSSParser;
|
||||
private axiosInstance: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.parser = new RSSParser({
|
||||
customFields: {
|
||||
item: [
|
||||
["content:encoded", "content"],
|
||||
["description", "description"],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
this.axiosInstance = createAxiosInstance(
|
||||
config.rss.baseUrl,
|
||||
AxiosHeaders.from({
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
Accept: "application/xml,application/xhtml+xml,text/html,application/rss+xml",
|
||||
}),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
private extractCloudLinks(text: string): { links: string[]; cloudType: string } {
|
||||
const links: string[] = [];
|
||||
let cloudType = "";
|
||||
Object.values(config.cloudPatterns).forEach((pattern, index) => {
|
||||
const matches = text.match(pattern);
|
||||
if (matches) {
|
||||
links.push(...matches);
|
||||
cloudType = Object.keys(config.cloudPatterns)[index];
|
||||
}
|
||||
});
|
||||
return {
|
||||
links: [...new Set(links)],
|
||||
cloudType,
|
||||
};
|
||||
}
|
||||
|
||||
async searchAll(keyword: string) {
|
||||
const allResults = [];
|
||||
|
||||
for (let i = 0; i < config.rss.channels.length; i++) {
|
||||
const channel = config.rss.channels[i];
|
||||
try {
|
||||
const rssUrl = `${config.rss.baseUrl}/${
|
||||
channel.id
|
||||
}${keyword ? `/searchQuery=${encodeURIComponent(keyword)}` : ""}`;
|
||||
|
||||
const results = await this.searchInRSSFeed(rssUrl);
|
||||
if (results.items.length > 0) {
|
||||
const channelResults = results.items
|
||||
.filter((item: RSSItem) => item.cloudLinks && item.cloudLinks.length > 0)
|
||||
.map((item: RSSItem) => ({
|
||||
...item,
|
||||
channel: channel.name + "(" + channel.id + ")",
|
||||
}));
|
||||
|
||||
allResults.push(...channelResults);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`搜索频道 ${channel.name} 失败:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return allResults;
|
||||
}
|
||||
|
||||
async searchInRSSFeed(rssUrl: string) {
|
||||
try {
|
||||
const response = await this.axiosInstance.get(rssUrl);
|
||||
const feed = await this.parser.parseString(response.data);
|
||||
|
||||
return {
|
||||
items: feed.items.map((item: RSSItem) => {
|
||||
const linkInfo = this.extractCloudLinks(item.content || item.description || "");
|
||||
return {
|
||||
title: item.title || "",
|
||||
link: item.link || "",
|
||||
pubDate: item.pubDate || "",
|
||||
image: item.image || "",
|
||||
cloudLinks: linkInfo.links,
|
||||
cloudType: linkInfo.cloudType,
|
||||
};
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.error(`RSS源解析错误: ${rssUrl}`, error);
|
||||
return {
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { AxiosInstance, AxiosHeaders } from "axios";
|
||||
import { createAxiosInstance } from "../utils/axiosInstance";
|
||||
import GlobalSetting from "../models/GlobalSetting";
|
||||
import { GlobalSettingAttributes } from "../models/GlobalSetting";
|
||||
import * as cheerio from "cheerio";
|
||||
import { config } from "../config";
|
||||
import { Logger } from "../utils/logger";
|
||||
@@ -7,19 +9,30 @@ import { Logger } from "../utils/logger";
|
||||
interface sourceItem {
|
||||
messageId?: string;
|
||||
title?: string;
|
||||
completeTitle?: string;
|
||||
link?: string;
|
||||
pubDate?: string;
|
||||
content?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
cloudLinks?: string[];
|
||||
tags?: string[];
|
||||
cloudType?: string;
|
||||
}
|
||||
|
||||
export class Searcher {
|
||||
private axiosInstance: AxiosInstance;
|
||||
private axiosInstance: AxiosInstance | null = null;
|
||||
|
||||
constructor() {
|
||||
this.initializeAxiosInstance();
|
||||
}
|
||||
|
||||
private async initializeAxiosInstance(isUpdate = false): Promise<void> {
|
||||
let settings = null;
|
||||
if (isUpdate) {
|
||||
settings = await GlobalSetting.findOne();
|
||||
}
|
||||
const globalSetting = settings?.dataValues || ({} as GlobalSettingAttributes);
|
||||
this.axiosInstance = createAxiosInstance(
|
||||
config.telegram.baseUrl,
|
||||
AxiosHeaders.from({
|
||||
@@ -37,9 +50,15 @@ export class Searcher {
|
||||
"sec-fetch-user": "?1",
|
||||
"upgrade-insecure-requests": "1",
|
||||
}),
|
||||
true
|
||||
globalSetting?.isProxyEnabled,
|
||||
globalSetting?.isProxyEnabled
|
||||
? { host: globalSetting?.httpProxyHost, port: globalSetting?.httpProxyPort }
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
public async updateAxiosInstance() {
|
||||
await this.initializeAxiosInstance(true);
|
||||
}
|
||||
|
||||
private extractCloudLinks(text: string): { links: string[]; cloudType: string } {
|
||||
const links: string[] = [];
|
||||
@@ -58,46 +77,65 @@ export class Searcher {
|
||||
}
|
||||
|
||||
async searchAll(keyword: string, channelId?: string, messageId?: string) {
|
||||
const allResults = [];
|
||||
const totalChannels = config.rss.channels.length;
|
||||
const allResults: any[] = [];
|
||||
|
||||
const channelList = channelId
|
||||
? config.rss.channels.filter((channel) => channel.id === channelId)
|
||||
const channelList: any[] = channelId
|
||||
? config.rss.channels.filter((channel: any) => channel.id === channelId)
|
||||
: config.rss.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, channel.id);
|
||||
console.log(`Found ${results.items.length} items in channel ${channel.name}`);
|
||||
if (results.items.length > 0) {
|
||||
const channelResults = results.items
|
||||
.filter((item: sourceItem) => item.cloudLinks && item.cloudLinks.length > 0)
|
||||
.map((item: sourceItem) => ({
|
||||
...item,
|
||||
channel: channel.name,
|
||||
channelId: channel.id,
|
||||
}));
|
||||
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
|
||||
.filter((item: sourceItem) => item.cloudLinks && item.cloudLinks.length > 0)
|
||||
.map((item: sourceItem) => ({
|
||||
...item,
|
||||
channel: channel.name,
|
||||
channelId: channel.id,
|
||||
}));
|
||||
|
||||
allResults.push(...channelResults);
|
||||
}
|
||||
allResults.push({
|
||||
list: channelResults,
|
||||
channelInfo: {
|
||||
...channel,
|
||||
channelLogo: results.channelLogo,
|
||||
},
|
||||
id: channel.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error(`搜索频道 ${channel.name} 失败:`, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return allResults;
|
||||
// 等待所有请求完成
|
||||
await Promise.all(searchPromises);
|
||||
|
||||
return {
|
||||
data: allResults,
|
||||
};
|
||||
}
|
||||
|
||||
async searchInWeb(url: string, channelId: string) {
|
||||
async searchInWeb(url: string) {
|
||||
try {
|
||||
if (!this.axiosInstance) {
|
||||
throw new Error("Axios instance is not initialized");
|
||||
}
|
||||
const response = await this.axiosInstance.get(url);
|
||||
const html = response.data;
|
||||
const $ = cheerio.load(html);
|
||||
const items: sourceItem[] = [];
|
||||
let channelLogo = "";
|
||||
$(".tgme_header_link").each((_, element) => {
|
||||
channelLogo = $(element).find("img").attr("src") || "";
|
||||
});
|
||||
// 遍历每个消息容器
|
||||
$(".tgme_widget_message_wrap").each((_, element) => {
|
||||
const messageEl = $(element);
|
||||
@@ -109,8 +147,24 @@ export class Searcher {
|
||||
?.toString()
|
||||
.split("/")[1];
|
||||
|
||||
// 提取标题 (消息截取100长度)
|
||||
const title = messageEl.find(".js-message_text").text().trim().substring(0, 50) + "...";
|
||||
// 提取标题 (第一个<br>标签前的内容)
|
||||
const title =
|
||||
messageEl
|
||||
.find(".js-message_text")
|
||||
.html()
|
||||
?.split("<br>")[0]
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/\n/g, "") || "";
|
||||
|
||||
// 提取描述 (第一个<a>标签前面的内容,不包含标题)
|
||||
const content =
|
||||
messageEl
|
||||
.find(".js-message_text")
|
||||
.html()
|
||||
?.replace(title, "")
|
||||
.split("<a")[0]
|
||||
.replace(/<br>/g, "")
|
||||
.trim() || "";
|
||||
|
||||
// 提取链接 (消息中的链接)
|
||||
// const link = messageEl.find('.tgme_widget_message').data('post');
|
||||
@@ -118,23 +172,24 @@ export class Searcher {
|
||||
// 提取发布时间
|
||||
const pubDate = messageEl.find("time").attr("datetime");
|
||||
|
||||
// 提取内容 (完整消息文本)
|
||||
const content = messageEl.find(".js-message_text").text();
|
||||
|
||||
// 提取描述 (消息文本中"描述:"后的内容)
|
||||
const description = content.split("描述:")[1]?.split("\n")[0]?.trim();
|
||||
|
||||
// 提取图片
|
||||
const image = messageEl
|
||||
.find(".tgme_widget_message_photo_wrap")
|
||||
.attr("style")
|
||||
?.match(/url\('(.+?)'\)/)?.[1];
|
||||
|
||||
const tags: string[] = [];
|
||||
// 提取云盘链接
|
||||
const links = messageEl
|
||||
.find(".tgme_widget_message_text a")
|
||||
.map((_, el) => $(el).attr("href"))
|
||||
.get();
|
||||
messageEl.find(".tgme_widget_message_text a").each((index, element) => {
|
||||
const tagText = $(element).text();
|
||||
if (tagText && tagText.startsWith("#")) {
|
||||
tags.push(tagText);
|
||||
}
|
||||
});
|
||||
const cloudInfo = this.extractCloudLinks(links.join(" "));
|
||||
// 添加到数组第一位
|
||||
items.unshift({
|
||||
@@ -142,18 +197,21 @@ export class Searcher {
|
||||
title,
|
||||
pubDate,
|
||||
content,
|
||||
description,
|
||||
image,
|
||||
cloudLinks: cloudInfo.links,
|
||||
cloudType: cloudInfo.cloudType,
|
||||
tags,
|
||||
});
|
||||
});
|
||||
return { items };
|
||||
return { items: items, channelLogo };
|
||||
} catch (error) {
|
||||
Logger.error(`RSS源解析错误: ${url}`, error);
|
||||
Logger.error(`搜索错误: ${url}`, error);
|
||||
return {
|
||||
items: [],
|
||||
channelLogo: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new Searcher();
|
||||
|
||||
@@ -5,9 +5,6 @@ export interface ShareInfo {
|
||||
}
|
||||
|
||||
export interface ShareInfoResponse {
|
||||
success: boolean;
|
||||
data?: {
|
||||
list: ShareInfo[];
|
||||
};
|
||||
error?: string;
|
||||
data?: ShareInfo[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
11
backend/src/types/express.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { Request } from "express";
|
||||
|
||||
declare module "express" {
|
||||
interface Request {
|
||||
user?: {
|
||||
userId: string;
|
||||
role: number;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,22 @@
|
||||
import axios, { AxiosInstance, AxiosRequestHeaders } from "axios";
|
||||
import tunnel from "tunnel";
|
||||
import { config } from "../config";
|
||||
|
||||
interface ProxyConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export function createAxiosInstance(
|
||||
baseURL: string,
|
||||
headers: AxiosRequestHeaders,
|
||||
useProxy: boolean = false
|
||||
useProxy: boolean = false,
|
||||
proxyConfig?: ProxyConfig
|
||||
): AxiosInstance {
|
||||
let agent;
|
||||
|
||||
if (useProxy) {
|
||||
console.log(proxyConfig);
|
||||
if (useProxy && proxyConfig) {
|
||||
agent = tunnel.httpsOverHttp({
|
||||
proxy: {
|
||||
host: config.httpProxy.host,
|
||||
port: Number(config.httpProxy.port),
|
||||
},
|
||||
proxy: proxyConfig,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { Response, NextFunction } from "express";
|
||||
import { Logger } from "../utils/logger";
|
||||
|
||||
interface CustomError {
|
||||
name?: string;
|
||||
message: string;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
export default function handleError(
|
||||
res: Response,
|
||||
error: any,
|
||||
error: CustomError | unknown,
|
||||
message: string,
|
||||
next: NextFunction
|
||||
) {
|
||||
|
||||
21
backend/src/utils/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import { Request } from "express";
|
||||
import { config } from "../config";
|
||||
|
||||
interface JwtPayload {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export function getUserIdFromToken(req: Request): string | null {
|
||||
try {
|
||||
const token = req.headers.authorization?.split(" ")[1];
|
||||
if (!token) {
|
||||
throw new Error("Token not found");
|
||||
}
|
||||
const decoded = jwt.verify(token, config.jwtSecret) as JwtPayload;
|
||||
return decoded.userId;
|
||||
} catch (error) {
|
||||
console.error("Invalid token:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
17
backend/src/utils/response.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Response } from "express";
|
||||
|
||||
interface ResponseData {
|
||||
code?: number; // 业务状态码
|
||||
message?: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export const sendSuccess = (res: Response, response: ResponseData, businessCode: number = 0) => {
|
||||
response.code = businessCode;
|
||||
res.status(200).json(response);
|
||||
};
|
||||
|
||||
export const sendError = (res: Response, response: ResponseData, businessCode: number = 10000) => {
|
||||
response.code = businessCode;
|
||||
res.status(200).json(response);
|
||||
};
|
||||
@@ -12,6 +12,7 @@
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"baseUrl": ".",
|
||||
"typeRoots": ["./node_modules/@types", "./src/types"],
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
|
||||
BIN
docs/images/alipay.png
Normal file
|
After Width: | Height: | Size: 610 KiB |
BIN
docs/images/mobile/douban.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
docs/images/mobile/login.png
Normal file
|
After Width: | Height: | Size: 631 KiB |
BIN
docs/images/mobile/save.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
docs/images/mobile/save1.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
docs/images/mobile/search.png
Normal file
|
After Width: | Height: | Size: 470 KiB |
BIN
docs/images/pc/detail.png
Normal file
|
After Width: | Height: | Size: 674 KiB |
BIN
docs/images/pc/douban.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
docs/images/pc/login.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
docs/images/pc/save.png
Normal file
|
After Width: | Height: | Size: 314 KiB |
BIN
docs/images/pc/save1.png
Normal file
|
After Width: | Height: | Size: 311 KiB |
BIN
docs/images/pc/search.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 35 KiB |
BIN
docs/images/wechat.jpg
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
docs/images/wechat_pay.jpg
Normal file
|
After Width: | Height: | Size: 121 KiB |
3
frontend/auto-imports.d.ts
vendored
@@ -5,5 +5,6 @@
|
||||
// Generated by unplugin-auto-import
|
||||
export {}
|
||||
declare global {
|
||||
|
||||
const ElMessage: typeof import('element-plus/es')['ElMessage']
|
||||
const showConfirmDialog: typeof import('vant/es')['showConfirmDialog']
|
||||
}
|
||||
|
||||
46
frontend/components.d.ts
vendored
@@ -7,25 +7,63 @@ export {}
|
||||
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
AsideMenu: typeof import('./src/components/AsideMenu.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']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElImage: typeof import('element-plus/es')['ElImage']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElLink: typeof import('element-plus/es')['ElLink']
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTree: typeof import('element-plus/es')['ElTree']
|
||||
FolderSelect: typeof import('./src/components/FolderSelect.vue')['default']
|
||||
ResourceList: typeof import('./src/components/ResourceList.vue')['default']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
FolderSelect: typeof import('./src/components/Home/FolderSelect.vue')['default']
|
||||
ResourceCard: typeof import('./src/components/Home/ResourceCard.vue')['default']
|
||||
ResourceSelect: typeof import('./src/components/Home/ResourceSelect.vue')['default']
|
||||
ResourceTable: typeof import('./src/components/Home/ResourceTable.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SearchBar: typeof import('./src/components/SearchBar.vue')['default']
|
||||
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']
|
||||
|
||||
BIN
frontend/favicon.ico
Normal file
|
After Width: | Height: | Size: 17 KiB |
@@ -3,7 +3,36 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- 移动端适配 -->
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<meta name="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>
|
||||
<body>
|
||||
|
||||
BIN
frontend/logo-1.png
Normal file
|
After Width: | Height: | Size: 248 KiB |
3751
frontend/logo.svg
Normal file
|
After Width: | Height: | Size: 282 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cloud-saver-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
@@ -14,16 +14,21 @@
|
||||
"element-plus": "^2.6.1",
|
||||
"pinia": "^2.1.7",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
15
frontend/postcss.config.cjs
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -8,4 +8,55 @@
|
||||
#app {
|
||||
height: 100vh;
|
||||
}
|
||||
:root {
|
||||
--theme-color: #3e3e3e;
|
||||
--theme-theme: #133ab3;
|
||||
--theme-background: #fafafa;
|
||||
--theme-other_background: #ffffff;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-family:
|
||||
v-sans,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
Segoe UI,
|
||||
sans-serif,
|
||||
"Apple Color Emoji",
|
||||
"Segoe UI Emoji",
|
||||
Segoe UI Symbol;
|
||||
line-height: 1.6;
|
||||
color: var(--theme-color);
|
||||
background-color: var(--theme-background);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* 移动端全局样式 */
|
||||
@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>
|
||||
|
||||
@@ -2,22 +2,22 @@ import request from "@/utils/request";
|
||||
import type { ShareInfoResponse, Folder, Save115FileParams } from "@/types";
|
||||
|
||||
export const cloud115Api = {
|
||||
async getShareInfo(shareCode: string, receiveCode = ""): Promise<ShareInfoResponse> {
|
||||
const { data } = await request.get("/api/cloud115/share-info", {
|
||||
async getShareInfo(shareCode: string, receiveCode = "") {
|
||||
const { data } = await request.get<ShareInfoResponse>("/api/cloud115/share-info", {
|
||||
params: { shareCode, receiveCode },
|
||||
});
|
||||
return data;
|
||||
return data as ShareInfoResponse;
|
||||
},
|
||||
|
||||
async getFolderList(parentCid = "0"): Promise<{ data: Folder[] }> {
|
||||
const { data } = await request.get("/api/cloud115/folders", {
|
||||
async getFolderList(parentCid = "0") {
|
||||
const res = await request.get<Folder[]>("/api/cloud115/folders", {
|
||||
params: { parentCid },
|
||||
});
|
||||
return data;
|
||||
return res;
|
||||
},
|
||||
|
||||
async saveFile(params: Save115FileParams) {
|
||||
const { data } = await request.post("/api/cloud115/save", params);
|
||||
return data;
|
||||
const res = await request.post("/api/cloud115/save", params);
|
||||
return res;
|
||||
},
|
||||
};
|
||||
|
||||
11
frontend/src/api/douban.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import request from "@/utils/request";
|
||||
import { HotListItem, HotListParams } from "@/types/douban";
|
||||
|
||||
export const doubanApi = {
|
||||
async getHotList(params: HotListParams) {
|
||||
const { data } = await request.get<HotListItem[]>("/api/douban/hot", {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -2,22 +2,21 @@ import request from "@/utils/request";
|
||||
import type { ShareInfoResponse, Folder, SaveQuarkFileParams } from "@/types";
|
||||
|
||||
export const quarkApi = {
|
||||
async getShareInfo(pwdId: string, passcode = ""): Promise<ShareInfoResponse> {
|
||||
const { data } = await request.get("/api/quark/share-info", {
|
||||
async getShareInfo(pwdId: string, passcode = "") {
|
||||
const { data } = await request.get<ShareInfoResponse>("/api/quark/share-info", {
|
||||
params: { pwdId, passcode },
|
||||
});
|
||||
return data;
|
||||
return data as ShareInfoResponse;
|
||||
},
|
||||
|
||||
async getFolderList(parentCid = "0"): Promise<{ data: Folder[] }> {
|
||||
const { data } = await request.get("/api/quark/folders", {
|
||||
async getFolderList(parentCid = "0") {
|
||||
const data = await request.get<Folder[]>("/api/quark/folders", {
|
||||
params: { parentCid },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async saveFile(params: SaveQuarkFileParams) {
|
||||
const { data } = await request.post("/api/quark/save", params);
|
||||
return data;
|
||||
return await request.post("/api/quark/save", params);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,8 +2,8 @@ import request from "@/utils/request";
|
||||
import type { Resource } from "@/types/index";
|
||||
|
||||
export const resourceApi = {
|
||||
search(keyword: string, backupPlan: boolean, channelId?: string, lastMessageId?: string) {
|
||||
return request.get<Resource[]>(`/api/${backupPlan ? "rssSearch" : "search"}`, {
|
||||
search(keyword: string, channelId?: string, lastMessageId?: string) {
|
||||
return request.get<Resource[]>(`/api/search`, {
|
||||
params: { keyword, channelId, lastMessageId },
|
||||
});
|
||||
},
|
||||
|
||||
17
frontend/src/api/setting.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import request from "@/utils/request";
|
||||
import type { GlobalSettingAttributes, UserSettingAttributes } from "@/types";
|
||||
|
||||
export const settingApi = {
|
||||
getSetting: () => {
|
||||
return request.get<{
|
||||
userSettings: UserSettingAttributes;
|
||||
globalSetting: GlobalSettingAttributes;
|
||||
}>("/api/setting/get");
|
||||
},
|
||||
saveSetting: (data: {
|
||||
userSettings: UserSettingAttributes;
|
||||
globalSetting?: GlobalSettingAttributes | null;
|
||||
}) => {
|
||||
return request.post("/api/setting/save", data);
|
||||
},
|
||||
};
|
||||
10
frontend/src/api/user.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
export const userApi = {
|
||||
login: (data: { username: string; password: string }) => {
|
||||
return request.post<{ token: string }>("/api/user/login", data);
|
||||
},
|
||||
register: (data: { username: string; password: string; registerCode: string }) => {
|
||||
return request.post<{ token: string }>("/api/user/register", data);
|
||||
},
|
||||
};
|
||||
BIN
frontend/src/assets/images/login-bg.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
frontend/src/assets/images/logo-1.png
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
frontend/src/assets/images/logo.png
Normal file
|
After Width: | Height: | Size: 276 KiB |
BIN
frontend/src/assets/images/mobile-login-bg.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
306
frontend/src/components/AsideMenu.vue
Normal file
@@ -0,0 +1,306 @@
|
||||
<template>
|
||||
<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="pc-aside__menu"
|
||||
>
|
||||
<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)"
|
||||
>
|
||||
<span>{{ child.title }}</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 普通菜单项 -->
|
||||
<el-menu-item
|
||||
v-else
|
||||
:index="menu.index"
|
||||
:disabled="menu.disabled"
|
||||
@click="handleMenuClick(menu)"
|
||||
>
|
||||
<el-icon><component :is="menu.icon" /></el-icon>
|
||||
<span>{{ menu.title }}</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-menu>
|
||||
|
||||
<!-- GitHub 链接 -->
|
||||
<div class="pc-aside__footer">
|
||||
<a
|
||||
href="https://github.com/jiangrui1994/CloudSaver"
|
||||
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 setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { Search, Film, Setting, Link } from "@element-plus/icons-vue";
|
||||
import logo from "@/assets/images/logo.png";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
// 类型定义
|
||||
interface MenuItem {
|
||||
index: string;
|
||||
title: string;
|
||||
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: "1",
|
||||
title: "资源搜索",
|
||||
icon: Search,
|
||||
router: "/resource",
|
||||
},
|
||||
{
|
||||
index: "2",
|
||||
title: "豆瓣榜单",
|
||||
icon: Film,
|
||||
children: [
|
||||
{
|
||||
index: "2-1",
|
||||
title: "热门电影",
|
||||
router: "/douban?type=movie",
|
||||
},
|
||||
{
|
||||
index: "2-2",
|
||||
title: "热门电视剧",
|
||||
router: "/douban?type=tv",
|
||||
},
|
||||
{
|
||||
index: "2-3",
|
||||
title: "最新电影",
|
||||
router: "/douban?type=movie&tag=最新",
|
||||
},
|
||||
{
|
||||
index: "2-4",
|
||||
title: "热门综艺",
|
||||
router: "/douban?type=tv&tag=综艺",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
index: "3",
|
||||
title: "设置",
|
||||
icon: Setting,
|
||||
router: "/setting",
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
// 计算当前激活的菜单
|
||||
const currentMenu = computed(() => {
|
||||
const flatMenus = menuList.reduce<MenuItem[]>((acc, menu) => {
|
||||
if (!menu.children) {
|
||||
acc.push(menu);
|
||||
} else {
|
||||
acc.push(...menu.children);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return flatMenus.find((menu) => menu.router === decodeURIComponent(route.fullPath));
|
||||
});
|
||||
|
||||
// 计算当前展开的子菜单
|
||||
const currentMenuOpen = computed(() => {
|
||||
if (currentMenu.value?.index.includes("-")) {
|
||||
return [currentMenu.value.index.split("-")[0]];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// 菜单点击处理
|
||||
const handleMenuClick = (menu: MenuItem) => {
|
||||
if (menu.router) {
|
||||
router.push(menu.router);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@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__title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-text-primary);
|
||||
@include text-overflow;
|
||||
}
|
||||
}
|
||||
|
||||
// 菜单区域
|
||||
&__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>
|
||||
@@ -1,134 +0,0 @@
|
||||
<template>
|
||||
<div class="folder-select">
|
||||
<div class="folder-select-header">
|
||||
当前位置:<el-icon style="margin: 0 5px"><Folder /></el-icon
|
||||
>{{ selectedFolder?.path?.map((x: Folder) => x.name).join("/") }}
|
||||
</div>
|
||||
<el-tree
|
||||
ref="treeRef"
|
||||
:data="folders"
|
||||
:props="defaultProps"
|
||||
node-key="cid"
|
||||
:load="loadNode"
|
||||
lazy
|
||||
@node-click="handleNodeClick"
|
||||
highlight-current
|
||||
>
|
||||
<template #default="{ node }">
|
||||
<span class="folder-node">
|
||||
<el-icon><Folder /></el-icon>
|
||||
{{ node.label }}
|
||||
</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, defineProps } from "vue";
|
||||
import { cloud115Api } from "@/api/cloud115";
|
||||
import { quarkApi } from "@/api/quark";
|
||||
import type { TreeInstance } from "element-plus";
|
||||
import type { Folder } from "@/types";
|
||||
import { ElMessage } from "element-plus";
|
||||
|
||||
const props = defineProps({
|
||||
cloudType: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const treeRef = ref<TreeInstance>();
|
||||
const folders = ref<Folder[]>([]);
|
||||
const selectedFolder = ref<Folder | null>(null);
|
||||
const emit = defineEmits<{
|
||||
(e: "select", folderId: string): void;
|
||||
(e: "close"): void;
|
||||
}>();
|
||||
|
||||
const defaultProps = {
|
||||
label: "name",
|
||||
children: "children",
|
||||
isLeaf: "leaf",
|
||||
};
|
||||
|
||||
const cloudTypeApiMap = {
|
||||
pan115: cloud115Api,
|
||||
quark: quarkApi,
|
||||
};
|
||||
|
||||
const loadNode = async (node: any, resolve: (data: Folder[]) => void) => {
|
||||
const api = cloudTypeApiMap[props.cloudType as keyof typeof cloudTypeApiMap];
|
||||
try {
|
||||
let res: {
|
||||
data: Folder[];
|
||||
error?: string;
|
||||
} = { data: [] };
|
||||
if (node.level === 0) {
|
||||
if (api.getFolderList) {
|
||||
// 使用类型保护检查方法是否存在
|
||||
res = await api.getFolderList();
|
||||
}
|
||||
} else {
|
||||
if (api.getFolderList) {
|
||||
// 使用类型保护检查方法是否存在
|
||||
res = await api.getFolderList(node.data.cid);
|
||||
}
|
||||
}
|
||||
if (res.data?.length > 0) {
|
||||
resolve(res.data);
|
||||
} else {
|
||||
resolve([]);
|
||||
throw new Error(res.error);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? `${error.message}` : "获取目录失败");
|
||||
// 关闭模态框
|
||||
emit("close");
|
||||
resolve([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNodeClick = (data: Folder) => {
|
||||
selectedFolder.value = {
|
||||
...data,
|
||||
path: data.path ? [...data.path, data] : [data],
|
||||
};
|
||||
emit("select", data.cid);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.folder-select {
|
||||
min-height: 300px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.folder-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.folder-path {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-tree-node__content) {
|
||||
height: 32px;
|
||||
}
|
||||
.folder-select-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid #e5e6e8;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
277
frontend/src/components/Home/FolderSelect.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div class="folder-select">
|
||||
<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)"
|
||||
>
|
||||
<span class="folder-name">{{ folder.name }}</span>
|
||||
<el-icon v-if="index < folderPath.length - 1"><ArrowRight /></el-icon>
|
||||
</span>
|
||||
</template>
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, defineProps } from "vue";
|
||||
import { cloud115Api } from "@/api/cloud115";
|
||||
import { quarkApi } from "@/api/quark";
|
||||
import type { Folder as FolderType } from "@/types";
|
||||
import { Folder, FolderOpened, ArrowRight, Loading } from "@element-plus/icons-vue";
|
||||
|
||||
import { ElMessage } from "element-plus";
|
||||
|
||||
const props = defineProps({
|
||||
cloudType: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
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 cloudTypeApiMap = {
|
||||
pan115: cloud115Api,
|
||||
quark: quarkApi,
|
||||
};
|
||||
|
||||
const getList = async (cid: string = "0") => {
|
||||
const api = cloudTypeApiMap[props.cloudType as keyof typeof cloudTypeApiMap];
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await api.getFolderList?.(cid);
|
||||
if (res?.code === 0) {
|
||||
folders.value = res.data || [];
|
||||
} else {
|
||||
throw new Error(res?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "获取目录失败");
|
||||
emit("close");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFolderClick = async (folder: FolderType) => {
|
||||
selectedFolder.value = folder;
|
||||
folderPath.value = [...folderPath.value, folder];
|
||||
emit("select", folder.cid);
|
||||
await getList(folder.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 lang="scss" scoped>
|
||||
@import "@/styles/common.scss";
|
||||
|
||||
.folder-select {
|
||||
position: relative;
|
||||
min-height: 300px;
|
||||
max-height: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px;
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
color: var(--theme-primary);
|
||||
}
|
||||
|
||||
.path-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
|
||||
.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>
|
||||
578
frontend/src/components/Home/ResourceCard.vue
Normal file
@@ -0,0 +1,578 @@
|
||||
<template>
|
||||
<div class="resource-card">
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showDetail"
|
||||
:title="currentResource?.title"
|
||||
width="700px"
|
||||
class="resource-detail-dialog"
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-if="currentResource" class="detail-content">
|
||||
<div class="detail-cover">
|
||||
<el-image
|
||||
class="cover-image"
|
||||
:src="`/tele-images/?url=${encodeURIComponent(currentResource.image as string)}`"
|
||||
fit="cover"
|
||||
/>
|
||||
<el-tag
|
||||
class="cloud-type"
|
||||
:type="store.tagColor[currentResource.cloudType as keyof TagColor]"
|
||||
effect="dark"
|
||||
round
|
||||
>
|
||||
{{ currentResource.cloudType }}
|
||||
</el-tag>
|
||||
</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">
|
||||
<span class="tags-label">标签:</span>
|
||||
<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" @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">
|
||||
<el-link
|
||||
class="group-title"
|
||||
:href="`https://t.me/s/${group.id}`"
|
||||
target="_blank"
|
||||
:underline="false"
|
||||
>
|
||||
<el-image :src="group.channelInfo.channelLogo" class="channel-logo" fit="cover" 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" @click="group.displayList = !group.displayList">
|
||||
<el-icon :class="{ 'is-active': group.displayList }">
|
||||
<ArrowDown />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<div v-show="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
|
||||
class="cover-image"
|
||||
:src="`/tele-images/?url=${encodeURIComponent(resource.image as string)}`"
|
||||
fit="cover"
|
||||
lazy
|
||||
: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">
|
||||
<span class="tags-label">标签:</span>
|
||||
<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" @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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useResourceStore } from "@/stores/resource";
|
||||
import { ref } from "vue";
|
||||
import type { ResourceItem, TagColor } from "@/types";
|
||||
import { ArrowDown, Plus } from "@element-plus/icons-vue";
|
||||
|
||||
const store = useResourceStore();
|
||||
const showDetail = ref(false);
|
||||
const currentResource = ref<ResourceItem | null>(null);
|
||||
|
||||
const emit = defineEmits(["save", "loadMore", "searchMovieforTag"]);
|
||||
|
||||
const handleSave = (resource: ResourceItem) => {
|
||||
if (showDetail.value) {
|
||||
showDetail.value = false;
|
||||
}
|
||||
emit("save", resource);
|
||||
};
|
||||
|
||||
const showResourceDetail = (resource: ResourceItem) => {
|
||||
currentResource.value = resource;
|
||||
showDetail.value = true;
|
||||
};
|
||||
|
||||
const searchMovieforTag = (tag: string) => {
|
||||
emit("searchMovieforTag", tag);
|
||||
};
|
||||
|
||||
const handleLoadMore = (channelId: string) => {
|
||||
emit("loadMore", channelId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<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: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
// 组标题
|
||||
.group-header {
|
||||
@include flex-center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
|
||||
.group-title {
|
||||
@include flex-center;
|
||||
gap: 12px;
|
||||
font-size: 16px;
|
||||
color: var(--theme-text-primary);
|
||||
transition: var(--theme-transition);
|
||||
|
||||
.channel-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--theme-shadow-sm);
|
||||
}
|
||||
|
||||
.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(400px, 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%;
|
||||
height: fit-content;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--theme-shadow);
|
||||
}
|
||||
|
||||
.card-wrapper {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card-cover {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 180px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: var(--theme-radius);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
flex-wrap: wrap;
|
||||
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;
|
||||
}
|
||||
|
||||
.cloud-type {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
|
||||
.tag-item {
|
||||
cursor: pointer;
|
||||
transition: var(--theme-transition);
|
||||
|
||||
&:hover {
|
||||
color: var(--theme-primary);
|
||||
border-color: var(--theme-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
178
frontend/src/components/Home/ResourceSelect.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<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>
|
||||
</div>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 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 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);
|
||||
}
|
||||
};
|
||||
|
||||
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 lang="scss" scoped>
|
||||
@import "@/styles/responsive.scss";
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.total-size {
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.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;
|
||||
gap: 8px;
|
||||
color: var(--theme-text-primary);
|
||||
font-size: 14px;
|
||||
|
||||
.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>
|
||||
196
frontend/src/components/Home/ResourceTable.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<el-table
|
||||
v-loading="store.loading"
|
||||
class="resource-list-table"
|
||||
:data="store.resources"
|
||||
style="width: 100%"
|
||||
row-key="id"
|
||||
:default-expand-all="true"
|
||||
>
|
||||
<el-table-column type="expand">
|
||||
<template #default="props">
|
||||
<el-table :data="props.row.list" style="width: 100%">
|
||||
<el-table-column label="图片" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.image"
|
||||
class="table-item-image"
|
||||
:src="`/tele-images/?url=${encodeURIComponent(row.image as string)}`"
|
||||
hide-on-click-modal
|
||||
:preview-src-list="[
|
||||
`${location.origin}/tele-images/?url=${encodeURIComponent(row.image as string)}`,
|
||||
]"
|
||||
:zoom-rate="1.2"
|
||||
:max-scale="7"
|
||||
:min-scale="0.2"
|
||||
:initial-index="4"
|
||||
preview-teleported
|
||||
:z-index="999"
|
||||
fit="cover"
|
||||
width="60"
|
||||
height="90"
|
||||
/>
|
||||
<el-icon v-else size="20"><Close /></el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="标题" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-link :href="row.cloudLinks[0]" target="_blank">
|
||||
{{ row.title }}
|
||||
</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="描述">
|
||||
<template #default="{ row }">
|
||||
<div class="item-description" v-html="row.content"></div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="tags" label="标签">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.tags.length > 0" class="tags-list">
|
||||
<span>标签:</span>
|
||||
<el-tag
|
||||
v-for="item in row.tags"
|
||||
:key="item"
|
||||
class="resource_tag"
|
||||
@click="searchMovieforTag(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<span v-else>无</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="云盘类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="store.tagColor[row.cloudType as keyof TagColor]" effect="dark" round>
|
||||
{{ row.cloudType }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button @click="handleSave(row)">转存</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="load-more">
|
||||
<el-button :loading="props.row.loading" @click="handleLoadMore(props.row.id)">
|
||||
加载更多
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" prop="channel">
|
||||
<template #default="{ row }">
|
||||
<div class="group-header">
|
||||
<el-image :src="row.channelInfo.channelLogo" class="channel-logo" fit="cover" lazy />
|
||||
<span>{{ row.channelInfo.name }}</span>
|
||||
<span class="item-count">({{ row.list.length }})</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useResourceStore } from "@/stores/resource";
|
||||
import type { Resource, TagColor } from "@/types";
|
||||
import { computed } from "vue";
|
||||
|
||||
const store = useResourceStore();
|
||||
|
||||
const emit = defineEmits(["save", "loadMore", "searchMovieforTag"]);
|
||||
|
||||
const location = computed(() => window.location);
|
||||
|
||||
const handleSave = (resource: Resource) => {
|
||||
emit("save", resource);
|
||||
};
|
||||
|
||||
// 添加加载更多处理函数
|
||||
const handleLoadMore = (channelId: string) => {
|
||||
emit("loadMore", channelId);
|
||||
};
|
||||
|
||||
const searchMovieforTag = (tag: string) => {
|
||||
emit("searchMovieforTag", tag);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.resource-list-table {
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.channel-logo {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 10px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-item-image {
|
||||
border-radius: 20px;
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.item-count {
|
||||
color: #909399;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.tags-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.resource_tag {
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.item-description {
|
||||
max-width: 100%;
|
||||
margin: 15px 0;
|
||||
-webkit-box-orient: vertical;
|
||||
display: -webkit-box;
|
||||
line-clamp: 4;
|
||||
-webkit-line-clamp: 4;
|
||||
overflow: hidden;
|
||||
white-space: all;
|
||||
}
|
||||
|
||||
:deep(.el-table__expand-column) {
|
||||
.cell {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-table__expanded-cell) {
|
||||
padding: 20px !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__expand-icon) {
|
||||
height: 23px;
|
||||
line-height: 23px;
|
||||
}
|
||||
.load-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.resource-table {
|
||||
position: relative;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
@@ -1,221 +0,0 @@
|
||||
<template>
|
||||
<div class="resource-list">
|
||||
<el-table
|
||||
v-loading="store.loading"
|
||||
:data="groupedResources"
|
||||
style="width: 100%"
|
||||
row-key="id"
|
||||
:default-expand-all="true"
|
||||
>
|
||||
<el-table-column type="expand">
|
||||
<template #default="props">
|
||||
<el-table :data="props.row.items" style="width: 100%">
|
||||
<el-table-column label="图片" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.image"
|
||||
:src="row.image"
|
||||
:preview-src-list="[row.image]"
|
||||
:zoom-rate="1.2"
|
||||
:max-scale="7"
|
||||
:min-scale="0.2"
|
||||
:initial-index="4"
|
||||
preview-teleported
|
||||
:z-index="999"
|
||||
fit="cover"
|
||||
width="60"
|
||||
height="90"
|
||||
/>
|
||||
<el-icon v-else size="20"><Close /></el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="标题" width="180" />
|
||||
<el-table-column label="地址">
|
||||
<template #default="{ row }">
|
||||
<el-link :href="row.cloudLinks[0]" target="_blank">
|
||||
{{ row.cloudLinks[0] }}
|
||||
</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="云盘类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="tagColor[row.cloudType as keyof typeof tagColor]"
|
||||
effect="dark"
|
||||
round
|
||||
>
|
||||
{{ row.cloudType }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button @click="handleSave(row)">转存</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="props.row.hasMore" class="load-more">
|
||||
<el-button :loading="props.row.loading" @click="handleLoadMore(props.row.channel)">
|
||||
加载更多
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" prop="channel">
|
||||
<template #default="{ row }">
|
||||
<div class="group-header">
|
||||
<span>{{ row.channel }}</span>
|
||||
<span class="item-count">({{ row.items.length }})</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="folderDialogVisible" title="选择保存目录" v-if="currentResource">
|
||||
<template #header="{ titleId }">
|
||||
<div class="my-header">
|
||||
<div :id="titleId">
|
||||
<el-tag
|
||||
:type="tagColor[currentResource.cloudType as keyof typeof tagColor]"
|
||||
effect="dark"
|
||||
round
|
||||
>
|
||||
{{ currentResource.cloudType }}
|
||||
</el-tag>
|
||||
选择保存目录
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<folder-select
|
||||
v-if="folderDialogVisible"
|
||||
@select="handleFolderSelect"
|
||||
@close="folderDialogVisible = false"
|
||||
:cloudType="currentResource.cloudType"
|
||||
/>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="folderDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSaveBtnClick">保存</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { useResourceStore } from "@/stores/resource";
|
||||
import FolderSelect from "./FolderSelect.vue";
|
||||
import type { Resource } from "@/types";
|
||||
|
||||
const tagColor = {
|
||||
baiduPan: "primary",
|
||||
weiyun: "info",
|
||||
aliyun: "warning",
|
||||
pan115: "danger",
|
||||
quark: "success",
|
||||
};
|
||||
|
||||
const store = useResourceStore();
|
||||
const folderDialogVisible = ref(false);
|
||||
const currentResource = ref<Resource | null>(null);
|
||||
const currentFolderId = ref<string | null>(null);
|
||||
|
||||
// 按来源分组的数据
|
||||
const groupedResources = computed(() => {
|
||||
const groups = store.resources.reduce(
|
||||
(acc, curr) => {
|
||||
const channel = curr.channel;
|
||||
const channelId = curr.channelId;
|
||||
if (!acc[channel]) {
|
||||
acc[channel] = {
|
||||
channel,
|
||||
items: [],
|
||||
hasMore: true,
|
||||
loading: false, // 添加 loading 状态
|
||||
id: channelId || "", // 用于row-key
|
||||
};
|
||||
}
|
||||
acc[channel].items.push(curr);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<
|
||||
string,
|
||||
{ channel: string; items: Resource[]; id: string; hasMore: boolean; loading: boolean }
|
||||
>
|
||||
);
|
||||
|
||||
return Object.values(groups);
|
||||
});
|
||||
|
||||
const handleSave = (resource: Resource) => {
|
||||
currentResource.value = resource;
|
||||
folderDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleFolderSelect = async (folderId: string) => {
|
||||
if (!currentResource.value) return;
|
||||
currentFolderId.value = folderId;
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (!currentResource.value || !currentFolderId.value) return;
|
||||
folderDialogVisible.value = false;
|
||||
await store.saveResource(currentResource.value, currentFolderId.value);
|
||||
};
|
||||
|
||||
// 添加加载更多处理函数
|
||||
const handleLoadMore = async (channel: string) => {
|
||||
const group = groupedResources.value.find((g) => g.channel === channel);
|
||||
if (!group || group.loading) return;
|
||||
|
||||
group.loading = true;
|
||||
try {
|
||||
const lastMessageId = group.items[group.items.length - 1].messageId;
|
||||
store.searchResources("", false, true, group.id, lastMessageId);
|
||||
} finally {
|
||||
group.loading = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.resource-list {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.item-count {
|
||||
color: #909399;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
:deep(.el-table__expand-column) {
|
||||
.cell {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-table__expanded-cell) {
|
||||
padding: 20px !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__expand-icon) {
|
||||
height: 23px;
|
||||
line-height: 23px;
|
||||
}
|
||||
.load-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,82 +1,199 @@
|
||||
<template>
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="请输入搜索关键词与输入链接直接解析"
|
||||
class="input-with-select"
|
||||
@keyup.enter="handleSearch"
|
||||
style="margin-bottom: 8px"
|
||||
>
|
||||
<template #append>
|
||||
<el-button type="success" @click="handleSearch">{{ searchBtnText }}</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-alert
|
||||
title="可直接输入链接进行资源解析,也可进行资源搜索!"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
<div class="search-new">
|
||||
<el-button type="primary" @click="handleSearchNew">最新资源</el-button>
|
||||
<div class="switch-source">
|
||||
<el-switch v-model="backupPlan" /><span class="label">使用rsshub(较慢)</span>
|
||||
</div>
|
||||
<div class="pc-search">
|
||||
<!-- 搜索区域 -->
|
||||
<div class="pc-search__input">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="请输入搜索关键词或输入链接直接解析"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<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 { effect, ref } from "vue";
|
||||
import { useResourceStore } from "@/stores/resource";
|
||||
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 keyword = ref("");
|
||||
const backupPlan = ref(false);
|
||||
const store = useResourceStore();
|
||||
const searchBtnText = ref("搜索");
|
||||
// 路由相关
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const resourcStore = useResourceStore();
|
||||
|
||||
effect(() => {
|
||||
// 监听搜索关键词的变化,如果存在,则自动触发搜索
|
||||
if (keyword.value && keyword.value.startsWith("http")) {
|
||||
searchBtnText.value = "解析";
|
||||
// 响应式数据
|
||||
const keyword = ref("");
|
||||
const routeKeyword = computed(() => route.query.keyword as string);
|
||||
|
||||
// 退出登录
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem(STORAGE_KEYS.TOKEN);
|
||||
router.push("/login");
|
||||
ElMessage.success("已退出登录");
|
||||
};
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = async () => {
|
||||
const searchText = keyword.value.trim();
|
||||
if (!searchText) {
|
||||
ElMessage.warning("请输入搜索内容");
|
||||
return;
|
||||
}
|
||||
|
||||
// 链接解析处理
|
||||
if (searchText.startsWith("http")) {
|
||||
await resourcStore.parsingCloudLink(searchText);
|
||||
return;
|
||||
}
|
||||
|
||||
// 关键词搜索
|
||||
await resourcStore.searchResources(searchText);
|
||||
if (route.path !== "/resource") {
|
||||
router.push("/resource");
|
||||
}
|
||||
};
|
||||
|
||||
// 监听路由参数变化
|
||||
watch(
|
||||
() => routeKeyword.value,
|
||||
(newKeyword) => {
|
||||
if (newKeyword) {
|
||||
keyword.value = newKeyword;
|
||||
handleSearch();
|
||||
} else {
|
||||
searchBtnText.value = "搜索";
|
||||
keyword.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
const handleSearch = async () => {
|
||||
// 如果搜索内容是一个https的链接,则尝试解析链接
|
||||
if (keyword.value.startsWith("http")) {
|
||||
store.parsingCloudLink(keyword.value);
|
||||
return;
|
||||
}
|
||||
if (!keyword.value.trim()) {
|
||||
return;
|
||||
}
|
||||
await store.searchResources(keyword.value, backupPlan.value);
|
||||
};
|
||||
|
||||
const handleSearchNew = async () => {
|
||||
keyword.value = "";
|
||||
await store.searchResources("", backupPlan.value);
|
||||
};
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-bar {
|
||||
padding: 20px;
|
||||
<style lang="scss" scoped>
|
||||
@import "@/styles/common.scss";
|
||||
|
||||
.pc-search {
|
||||
@include flex-center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
|
||||
// 搜索输入区域
|
||||
&__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);
|
||||
}
|
||||
|
||||
&.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;
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-new {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.switch-source {
|
||||
margin-left: 20px;
|
||||
}
|
||||
.switch-source .label {
|
||||
margin-left: 5px;
|
||||
// 操作区域
|
||||
&__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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
261
frontend/src/components/mobile/FolderSelect.vue
Normal file
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<div class="folder-select">
|
||||
<!-- 面包屑导航 -->
|
||||
<div class="folder-select__nav">
|
||||
<van-cell :border="false" class="nav-cell">
|
||||
<template #title>
|
||||
<div class="nav-breadcrumb">
|
||||
<van-icon name="wap-home-o" class="home-icon" @click="handleHomeClick" />
|
||||
<template v-for="(path, index) in currentFolderPath" :key="path.cid">
|
||||
<van-icon v-if="index !== 0" name="arrow" />
|
||||
<span
|
||||
class="path-item"
|
||||
:class="{ 'is-active': index === currentFolderPath.length - 1 }"
|
||||
@click="handleFolderClick(path, index)"
|
||||
>
|
||||
{{ path.name }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</van-cell>
|
||||
</div>
|
||||
|
||||
<!-- 文件夹列表 -->
|
||||
<div class="folder-select__list">
|
||||
<div v-if="resourceStore.loadTree" class="folder-select__loading">
|
||||
<van-loading type="spinner" vertical>加载中...</van-loading>
|
||||
</div>
|
||||
<van-empty v-if="!resourceStore.loadTree && !folders.length" description="暂无文件夹" />
|
||||
<van-cell-group v-if="!resourceStore.loadTree && folders.length" :border="false">
|
||||
<van-cell
|
||||
v-for="folder in folders"
|
||||
:key="folder.cid"
|
||||
:border="false"
|
||||
clickable
|
||||
@click="getList(folder)"
|
||||
>
|
||||
<template #icon>
|
||||
<van-icon name="folder-o" class="folder-icon" />
|
||||
</template>
|
||||
<template #title>
|
||||
<span class="folder-name">{{ folder.name }}</span>
|
||||
</template>
|
||||
<template #right-icon>
|
||||
<van-icon name="arrow" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, defineProps, onBeforeUnmount } from "vue";
|
||||
import { cloud115Api } from "@/api/cloud115";
|
||||
import { quarkApi } from "@/api/quark";
|
||||
import type { Folder } from "@/types";
|
||||
import { type RequestResult } from "@/types/response";
|
||||
import { useResourceStore } from "@/stores/resource";
|
||||
import { showNotify } from "vant";
|
||||
|
||||
const props = defineProps({
|
||||
cloudType: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const resourceStore = useResourceStore();
|
||||
const folders = ref<Folder[]>([]);
|
||||
const currentFolderPath = ref<Folder[]>([]);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "select", currentFolderPath: Folder[] | null): void;
|
||||
(e: "close"): void;
|
||||
}>();
|
||||
|
||||
const cloudTypeApiMap = {
|
||||
pan115: cloud115Api,
|
||||
quark: quarkApi,
|
||||
};
|
||||
|
||||
// 返回根目录
|
||||
const handleHomeClick = () => {
|
||||
currentFolderPath.value = [];
|
||||
getList();
|
||||
};
|
||||
|
||||
const handleFolderClick = (folder: Folder, index: number) => {
|
||||
currentFolderPath.value = currentFolderPath.value.slice(0, index + 1);
|
||||
getList(folder);
|
||||
};
|
||||
|
||||
const getList = async (data?: Folder) => {
|
||||
const api = cloudTypeApiMap[props.cloudType as keyof typeof cloudTypeApiMap];
|
||||
try {
|
||||
resourceStore.setLoadTree(true);
|
||||
const res: RequestResult<Folder[]> = await api.getFolderList?.(data?.cid || "0");
|
||||
|
||||
if (res?.code === 0) {
|
||||
folders.value = res.data || [];
|
||||
if (!data) {
|
||||
currentFolderPath.value = [
|
||||
{
|
||||
name: "根目录",
|
||||
cid: "0",
|
||||
},
|
||||
];
|
||||
} else if (!currentFolderPath.value.find((p) => p.cid === data.cid)) {
|
||||
currentFolderPath.value.push(data);
|
||||
}
|
||||
emit("select", currentFolderPath.value);
|
||||
} else {
|
||||
throw new Error(res.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showNotify({
|
||||
type: "danger",
|
||||
message: error instanceof Error ? error.message : "获取目录失败",
|
||||
});
|
||||
currentFolderPath.value = [];
|
||||
folders.value = [];
|
||||
emit("select", null);
|
||||
emit("close");
|
||||
} finally {
|
||||
resourceStore.setLoadTree(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化加载
|
||||
getList();
|
||||
|
||||
// 组件销毁前重置状态
|
||||
onBeforeUnmount(() => {
|
||||
currentFolderPath.value = [];
|
||||
folders.value = [];
|
||||
emit("select", null);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.folder-select {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
background: var(--theme-other_background);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__nav {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 0.5px solid #f5f5f5;
|
||||
background: var(--theme-other_background);
|
||||
|
||||
.nav-cell {
|
||||
padding: 12px 16px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.nav-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.home-icon {
|
||||
font-size: 16px;
|
||||
color: var(--theme-theme);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.path-item {
|
||||
color: #666;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
|
||||
&.is-active {
|
||||
color: var(--theme-theme);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
position: relative;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.van-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.van-cell-group {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__loading {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
z-index: 0;
|
||||
|
||||
.van-loading {
|
||||
padding: 16px 24px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
font-size: 20px;
|
||||
color: var(--theme-theme);
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
font-size: 15px;
|
||||
color: var(--theme-color);
|
||||
}
|
||||
}
|
||||
|
||||
// 深度修改 Vant 组件样式
|
||||
:deep(.van-cell) {
|
||||
padding: 12px 16px;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.van-empty) {
|
||||
padding: 32px 0;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
261
frontend/src/components/mobile/ResourceCard.vue
Normal file
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<div class="resource-card">
|
||||
<div v-for="item in dataList" :key="item.id" class="resource-card__item">
|
||||
<!-- 内容区域 -->
|
||||
<div class="item__content">
|
||||
<!-- 左侧图片 -->
|
||||
<div class="content__image">
|
||||
<van-image
|
||||
:src="`/tele-images/?url=${encodeURIComponent(item.image as string)}`"
|
||||
fit="cover"
|
||||
lazy-load
|
||||
/>
|
||||
<!-- 来源标签移到图片左上角 -->
|
||||
<van-tag class="image__tag" :color="getTagColor(item.cloudType)" round>
|
||||
{{ item.cloudType }}
|
||||
</van-tag>
|
||||
</div>
|
||||
|
||||
<!-- 右侧信息 -->
|
||||
<div class="content__info">
|
||||
<!-- 标题 -->
|
||||
<div class="info__title" @click="openUrl(item.cloudLinks[0])">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
|
||||
<!-- 描述 - 添加展开收起功能 -->
|
||||
<div
|
||||
class="info__desc"
|
||||
:class="{ 'is-expanded': expandedItems[item.id] }"
|
||||
@click="toggleExpand(item.id)"
|
||||
v-html="item.content"
|
||||
/>
|
||||
|
||||
<!-- 底部区域:标签 -->
|
||||
<div class="info__footer">
|
||||
<div v-if="item.tags?.length" class="info__tags">
|
||||
<van-tag
|
||||
v-for="tag in item.tags"
|
||||
:key="tag"
|
||||
type="primary"
|
||||
plain
|
||||
round
|
||||
@click.stop="searchMovieforTag(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
</van-tag>
|
||||
</div>
|
||||
|
||||
<!-- 转存按钮 -->
|
||||
<div class="info__action">
|
||||
<van-button type="primary" size="mini" round @click="handleSave(item)">
|
||||
转存
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useResourceStore } from "@/stores/resource";
|
||||
import type { ResourceItem } from "@/types";
|
||||
|
||||
// Props 定义
|
||||
const props = defineProps<{
|
||||
currentChannelId: string;
|
||||
}>();
|
||||
|
||||
// 事件定义
|
||||
const emit = defineEmits<{
|
||||
(e: "save", resource: ResourceItem): void;
|
||||
(e: "searchMovieforTag", tag: string): void;
|
||||
}>();
|
||||
|
||||
// 状态管理
|
||||
const store = useResourceStore();
|
||||
|
||||
// 计算属性
|
||||
const dataList = computed(() => {
|
||||
const channel = store.resources.find((item) => item.id === props.currentChannelId);
|
||||
return channel?.list || [];
|
||||
});
|
||||
|
||||
// 标签颜色映射
|
||||
const getTagColor = (type?: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
pan115: "#07c160",
|
||||
quark: "#1989fa",
|
||||
};
|
||||
return colorMap[type || ""] || "#ff976a";
|
||||
};
|
||||
|
||||
// 方法定义
|
||||
const handleSave = (resource: ResourceItem) => {
|
||||
emit("save", resource);
|
||||
};
|
||||
|
||||
const openUrl = (url: string) => {
|
||||
window.open(url);
|
||||
};
|
||||
|
||||
const searchMovieforTag = (tag: string) => {
|
||||
emit("searchMovieforTag", tag);
|
||||
};
|
||||
|
||||
// 展开状态管理
|
||||
const expandedItems = ref<Record<string, boolean>>({});
|
||||
|
||||
// 切换展开状态
|
||||
const toggleExpand = (id: string) => {
|
||||
expandedItems.value[id] = !expandedItems.value[id];
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 文本省略混入 - 移到最前面
|
||||
@mixin text-ellipsis($lines) {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: $lines;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.resource-card {
|
||||
padding: 5px 10px;
|
||||
|
||||
&__item {
|
||||
margin-bottom: 12px;
|
||||
background: var(--theme-other_background);
|
||||
border-radius: var(--border-radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
&__content {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
&__image {
|
||||
position: relative; // 为标签定位
|
||||
flex-shrink: 0;
|
||||
width: 100px;
|
||||
height: 140px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--van-gray-2);
|
||||
|
||||
:deep(.van-image) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.image__tag {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
font-size: 10px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
&__title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
color: var(--theme-color);
|
||||
@include text-ellipsis(2);
|
||||
|
||||
&:active {
|
||||
color: var(--theme-theme);
|
||||
}
|
||||
}
|
||||
|
||||
&__desc {
|
||||
position: relative;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--van-gray-7);
|
||||
@include text-ellipsis(3);
|
||||
margin: 4px 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.is-expanded {
|
||||
-webkit-line-clamp: 8;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "展开";
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 0 4px;
|
||||
font-size: 12px;
|
||||
color: var(--theme-theme);
|
||||
background: var(--theme-other_background);
|
||||
}
|
||||
|
||||
&.is-expanded::after {
|
||||
content: "收起";
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
:deep(.van-tag) {
|
||||
font-size: 11px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&__action {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 4px 0;
|
||||
|
||||
.van-button {
|
||||
font-size: 13px;
|
||||
height: 32px;
|
||||
padding: 0 20px;
|
||||
|
||||
:deep(.van-button__text) {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
184
frontend/src/components/mobile/ResourceSelect.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div class="resource-select">
|
||||
<van-checkbox-group v-model="selectedResourceIds">
|
||||
<van-cell-group :border="false">
|
||||
<van-cell
|
||||
v-for="item in resourceStore.shareInfo.list"
|
||||
:key="item.fileId"
|
||||
class="resource-item"
|
||||
:border="false"
|
||||
center
|
||||
@click="handleItemClick(item.fileId)"
|
||||
>
|
||||
<template #title>
|
||||
<div class="resource-item__content">
|
||||
<van-icon name="folder-o" class="content__icon" />
|
||||
<div class="content__info">
|
||||
<span class="info__name">{{ item.fileName }}</span>
|
||||
<span v-if="item.fileSize" class="info__size">
|
||||
{{ formattedFileSize(item.fileSize) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #right-icon>
|
||||
<van-checkbox
|
||||
:name="item.fileId"
|
||||
class="resource-item__checkbox"
|
||||
@click.stop="handleItemClick(item.fileId)"
|
||||
/>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
</van-checkbox-group>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<van-empty v-if="!resourceStore.shareInfo.list?.length" description="暂无可选资源" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { useResourceStore } from "@/stores/resource";
|
||||
import { formattedFileSize } from "@/utils/index";
|
||||
|
||||
const resourceStore = useResourceStore();
|
||||
const selectedResourceIds = ref<string[]>([]);
|
||||
|
||||
// 初始化选中状态
|
||||
selectedResourceIds.value = resourceStore.resourceSelect
|
||||
.filter((x) => x.isChecked)
|
||||
.map((x) => x.fileId);
|
||||
|
||||
// 监听选中状态变化
|
||||
watch(selectedResourceIds, (newIds) => {
|
||||
const newResourceSelect = [...resourceStore.resourceSelect];
|
||||
newResourceSelect.forEach((x) => {
|
||||
x.isChecked = newIds.includes(x.fileId);
|
||||
});
|
||||
resourceStore.setSelectedResource(newResourceSelect);
|
||||
});
|
||||
|
||||
// 添加点击处理函数
|
||||
const handleItemClick = (fileId: string) => {
|
||||
const index = selectedResourceIds.value.indexOf(fileId);
|
||||
if (index === -1) {
|
||||
selectedResourceIds.value.push(fileId);
|
||||
} else {
|
||||
selectedResourceIds.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 工具类
|
||||
@mixin text-ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-select {
|
||||
height: 100%;
|
||||
background: var(--theme-other_background);
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
|
||||
.resource-item {
|
||||
position: relative;
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
margin-right: 40px;
|
||||
|
||||
.content__icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 20px;
|
||||
color: var(--theme-theme);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.content__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.info__name {
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
color: var(--van-text-color);
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info__size {
|
||||
font-size: 13px;
|
||||
color: var(--van-gray-6);
|
||||
@include text-ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__checkbox {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
|
||||
:deep(.van-checkbox__icon) {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
|
||||
.van-icon {
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--van-active-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 深度修改 Vant 组件样式
|
||||
:deep(.van-cell) {
|
||||
align-items: flex-start;
|
||||
padding: 0 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 60px;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.van-cell__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.van-checkbox__icon--checked) {
|
||||
.van-icon {
|
||||
background-color: var(--theme-theme);
|
||||
border-color: var(--theme-theme);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.van-empty) {
|
||||
padding: 32px 0;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
5
frontend/src/constants/storage.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const STORAGE_KEYS = {
|
||||
USERNAME: "saved_username",
|
||||
PASSWORD: "saved_password",
|
||||
TOKEN: "token",
|
||||
} as const;
|
||||
2
frontend/src/env.d.ts
vendored
@@ -2,7 +2,7 @@
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>;
|
||||
export default component;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,18 +2,41 @@ import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import ElementPlus from "element-plus";
|
||||
import "element-plus/dist/index.css";
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import * as ElementPlusIconsVue from "@element-plus/icons-vue";
|
||||
import { isMobileDevice } from "@/utils/index";
|
||||
import App from "./App.vue";
|
||||
import { Lazyload } from "vant";
|
||||
import "vant/es/notify/style";
|
||||
import "vant/es/dialog/style";
|
||||
import "@/styles/responsive.scss";
|
||||
import "@/styles/common.scss";
|
||||
|
||||
import router from "./router/index";
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
app.component(key, component);
|
||||
}
|
||||
|
||||
app.use(createPinia());
|
||||
app.use(Lazyload);
|
||||
app.use(router);
|
||||
app.use(ElementPlus);
|
||||
|
||||
app.mount("#app");
|
||||
|
||||
const setRootFontSize = () => {
|
||||
const isMobile = isMobileDevice();
|
||||
if (!isMobile) {
|
||||
return;
|
||||
} // PC端不干预
|
||||
const clientWidth = document.documentElement.clientWidth;
|
||||
const baseSize = clientWidth / 7.5; // 按750px设计稿
|
||||
document.documentElement.style.fontSize = baseSize + "px";
|
||||
};
|
||||
|
||||
// 初始化执行
|
||||
setRootFontSize();
|
||||
// 监听窗口变化
|
||||
window.addEventListener("resize", setRootFontSize);
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
import HomeView from "@/views/HomeView.vue";
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/",
|
||||
name: "home",
|
||||
component: HomeView,
|
||||
},
|
||||
];
|
||||
import mobileRoutes from "./mobile-routes";
|
||||
import pcRoutes from "./pc-routes";
|
||||
import { isMobileDevice } from "@/utils/index";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
routes: [...(isMobileDevice() ? mobileRoutes : pcRoutes)],
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
33
frontend/src/router/mobile-routes.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/",
|
||||
name: "home",
|
||||
component: () => import("@/views/mobile/Home.vue"),
|
||||
redirect: "/resource",
|
||||
children: [
|
||||
{
|
||||
path: "/resource",
|
||||
name: "resource",
|
||||
component: () => import("@/views/mobile/ResourceList.vue"),
|
||||
},
|
||||
{
|
||||
path: "/douban",
|
||||
name: "douban",
|
||||
component: () => import("@/views/mobile/Douban.vue"),
|
||||
},
|
||||
{
|
||||
path: "/setting",
|
||||
name: "setting",
|
||||
component: () => import("@/views/mobile/Setting.vue"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
name: "login",
|
||||
component: () => import("@/views/mobile/Login.vue"),
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
||||
33
frontend/src/router/pc-routes.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/",
|
||||
name: "home",
|
||||
component: () => import("@/views/Home.vue"),
|
||||
redirect: "/resource",
|
||||
children: [
|
||||
{
|
||||
path: "/resource",
|
||||
name: "resource",
|
||||
component: () => import("@/views/ResourceList.vue"),
|
||||
},
|
||||
{
|
||||
path: "/douban",
|
||||
name: "douban",
|
||||
component: () => import("@/views/Douban.vue"),
|
||||
},
|
||||
{
|
||||
path: "/setting",
|
||||
name: "setting",
|
||||
component: () => import("@/views/Setting.vue"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
name: "login",
|
||||
component: () => import("@/views/pc/Login.vue"),
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
||||
55
frontend/src/stores/douban.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { doubanApi } from "@/api/douban";
|
||||
import { HotListItem } from "@/types/douban";
|
||||
import { ElMessage } from "element-plus";
|
||||
|
||||
interface StoreType {
|
||||
hotList: HotListItem[];
|
||||
loading: boolean;
|
||||
currentParams: CurrentParams;
|
||||
}
|
||||
|
||||
interface CurrentParams {
|
||||
type: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
export const useDoubanStore = defineStore("douban", {
|
||||
state: (): StoreType => ({
|
||||
hotList: [],
|
||||
loading: false,
|
||||
currentParams: {
|
||||
type: "movie",
|
||||
tag: "热门",
|
||||
},
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async getHotList() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const params = {
|
||||
type: this.currentParams.type,
|
||||
tag: this.currentParams.tag || "热门",
|
||||
page_limit: "20",
|
||||
page_start: "0",
|
||||
};
|
||||
const result = await doubanApi.getHotList(params);
|
||||
if (result && result.length > 0) {
|
||||
this.hotList = result;
|
||||
} else {
|
||||
console.log("获取热门列表失败");
|
||||
ElMessage.warning("获取热门列表失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error || "获取热门列表失败");
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
setCurrentParams(currentParams: CurrentParams) {
|
||||
this.currentParams = currentParams;
|
||||
this.getHotList();
|
||||
},
|
||||
},
|
||||
});
|
||||
17
frontend/src/stores/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
interface StoreType {
|
||||
scrollTop: boolean;
|
||||
}
|
||||
|
||||
export const useStore = defineStore("global", {
|
||||
state: (): StoreType => ({
|
||||
scrollTop: true,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
setScrollTop(top: boolean) {
|
||||
this.scrollTop = top;
|
||||
},
|
||||
},
|
||||
});
|
||||