Compare commits
40 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 |
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
|
||||||
11
.github/workflows/docker-image.yml
vendored
@@ -1,8 +1,8 @@
|
|||||||
name: Docker Image CI/CD
|
name: Docker Image CI/CD
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "main" ]
|
|
||||||
tags: [ "v*.*.*" ] # 支持标签触发(如 v1.0.0)
|
tags: [ "v*.*.*" ] # 支持标签触发(如 v1.0.0)
|
||||||
|
workflow_dispatch: # 添加手动触发
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -27,11 +27,18 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: 设置 QEMU 支持多架构
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
|
- name: 设置 Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
- name: 构建并推送 Docker 镜像
|
- name: 构建并推送 Docker 镜像
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: ${{ github.event_name == 'push' }} # 仅推送代码时上传镜像
|
platforms: linux/amd64,linux/arm64 # 指定架构:x86_64 和 ARM64
|
||||||
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/${{ env.LOWER_NAME }}:latest
|
ghcr.io/${{ env.LOWER_NAME }}:latest
|
||||||
ghcr.io/${{ env.LOWER_NAME }}:${{ github.sha }}
|
ghcr.io/${{ env.LOWER_NAME }}:${{ github.sha }}
|
||||||
|
|||||||
@@ -25,3 +25,6 @@ node_modules
|
|||||||
# 系统文件
|
# 系统文件
|
||||||
.DS_Store
|
.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
|
|
||||||
}
|
|
||||||
315
README.md
@@ -1,129 +1,266 @@
|
|||||||
# CloudSaver
|
# CloudSaver
|
||||||
|
|
||||||
一个基于 Vue 3 + Express 的网盘资源搜索与转存工具。
|

|
||||||
可通过docker 一键部署。
|

|
||||||
|

|
||||||
|
[](https://github.com/jiangrui1994/CloudSaver/stargazers)
|
||||||
|
|
||||||
## 特别声明
|
一个基于 Vue 3 + Express 的网盘资源搜索与转存工具,支持响应式布局,移动端与PC完美适配,可通过 Docker 一键部署。
|
||||||
|
|
||||||
1. 此项目仅供学习交流使用,请勿用于非法用途。
|
|
||||||
2. 仅支持个人使用,不支持任何形式的 commercial 使用。
|
|
||||||
3. 禁止在项目页面上进行任何形式的广告宣传。
|
|
||||||
4. 所有搜索到的资源均来自第三方,本项目不对其真实性、合法性做出任何保证。
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
1. 此项目的资源搜索需要用到代理环境,请自行搭建。
|
|
||||||
2. 新用户注册,管理员默认注册码:230713;普通用户默认注册码:9527
|
|
||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
- 支持多个资源订阅(电报群)源搜索
|
- 🔍 多源资源搜索
|
||||||
- 支持 115 网盘与夸克网盘**一键**资源转存
|
- 支持多个资源订阅源搜索
|
||||||
- 支持关键词搜索与资源链接解析
|
- 支持关键词搜索与资源链接解析
|
||||||
- 支持转存文件夹展示与选择
|
- 支持豆瓣热门榜单展示
|
||||||
- 支持多用户使用
|
- 💾 网盘资源转存
|
||||||
- 支持豆瓣热门榜单
|
- 支持 115 网盘与夸克网盘一键转存
|
||||||
- 支持热门榜单资源搜索
|
- 支持转存文件夹展示与选择
|
||||||
|
- 👥 多用户系统
|
||||||
|
- 支持用户注册登录
|
||||||
|
- 支持管理员与普通用户权限区分
|
||||||
|
- 📱 响应式设计
|
||||||
|
- 支持 PC 端与移动端自适应布局
|
||||||
|
- 针对不同设备优化的交互体验
|
||||||
|
|
||||||
## 预览
|
## 产品展示
|
||||||
|
|
||||||
### 登录/注册
|
### PC 端
|
||||||
|
|
||||||
<img src="./docs/images/login.png" width="400">
|
<div align="center">
|
||||||
<img src="./docs/images/register.png" width="400">
|
<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/search.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>
|
||||||
<img src="./docs/images/search-movie.png" width="400">
|
</div>
|
||||||
|
|
||||||
### 热门榜单
|
|
||||||
|
|
||||||
<img src="./docs/images/hotmovie.png" width="400">
|
|
||||||
|
|
||||||
|
|
||||||
### 转存
|
### 移动端
|
||||||
|
|
||||||
<img src="./docs/images/save.png" width="400">
|
|
||||||
|
|
||||||
|
|
||||||
### 系统设置
|
|
||||||
|
|
||||||
<img src="./docs/images/setting.png" width="400">
|
|
||||||
|
|
||||||
|
|
||||||
|
<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
|
- Vue 3
|
||||||
- Element Plus
|
- TypeScript
|
||||||
- Pinia
|
- Vite
|
||||||
- Vue Router
|
- 状态管理
|
||||||
- Vite
|
- Pinia
|
||||||
|
- 路由管理
|
||||||
|
- Vue Router
|
||||||
|
- UI 组件库
|
||||||
|
- Element Plus (PC)
|
||||||
|
- Vant (Mobile)
|
||||||
|
- 工具库
|
||||||
|
- Axios
|
||||||
|
|
||||||
### 后端
|
### 后端
|
||||||
|
|
||||||
- Node.js
|
- 运行环境
|
||||||
- Express
|
- Node.js
|
||||||
- TypeScript
|
- Express
|
||||||
- Sqlite3
|
- 数据存储
|
||||||
- RSS Parser
|
- SQLite3
|
||||||
|
|
||||||
## 环境配置
|
## 环境要求
|
||||||
|
|
||||||
### Node.js 版本
|
- Node.js >= 18.x
|
||||||
|
- pnpm >= 8.x (推荐)
|
||||||
|
|
||||||
本项目需要 `Node.js` 版本 `18.x` 或更高版本。请确保在运行项目之前安装了正确的版本。
|
## 快速开始
|
||||||
|
|
||||||
推荐使用pnpm进行依赖包安装,默认支持workspace,可以避免版本冲突。
|
### 开发环境
|
||||||
|
|
||||||
### 后端配置项
|
1. 克隆项目
|
||||||
|
|
||||||
复制环境变量模板文件:
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/jiangrui1994/CloudSaver.git
|
||||||
|
cd CloudSaver
|
||||||
```
|
```
|
||||||
|
|
||||||
|
2. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 配置环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
cp .env.example ./backend/.env
|
cp .env.example ./backend/.env
|
||||||
```
|
```
|
||||||
|
|
||||||
- 根据 `.env.example`文件在 `backend`目录下创建 `.env`文件。
|
根据 `.env.example` 文件说明配置必要的环境变量。
|
||||||
|
|
||||||
## 使用
|
4. 启动开发服务器
|
||||||
|
|
||||||
### 开发环境本地运行
|
|
||||||
|
|
||||||
- 安装依赖:`npm run install`
|
|
||||||
- 启动开发环境:`npm run dev`
|
|
||||||
|
|
||||||
### 打包部署
|
|
||||||
|
|
||||||
- 前端打包:`npm run build:frontend` 或者进入前端目录`frontend`,执行 `npm run build`
|
|
||||||
- 将前端构建产物`dist`目录下的文件复制到服务器上,例如`nginx`的`html`目录下。
|
|
||||||
- 后端服务:先进入后端目录`backend`下`npm run build`构建,然后执行 `npm run start`启动,默认端口为`8009`。
|
|
||||||
- 通过`nginx`配置代理服务,将前端api的请求映射到后端服务。
|
|
||||||
|
|
||||||
## Docker 部署
|
|
||||||
|
|
||||||
- 构建镜像:
|
|
||||||
```bash
|
```bash
|
||||||
# 构建示例
|
pnpm dev
|
||||||
docker build --platform linux/amd64 -t cloud-saver . --no-cache
|
|
||||||
```
|
|
||||||
- 运行容器:
|
|
||||||
```bash
|
|
||||||
docker run -d -p 8008:8008 --name cloud-saver cloud-saver
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 交流沟通
|
### 生产环境部署
|
||||||
|
|
||||||
<img src="./docs/images/20250220115710.jpg" width="400">
|
1. 构建前端
|
||||||
<img src="./docs/images/20241217122628.jpg" width="400">
|
|
||||||
|
|
||||||
## License
|
```bash
|
||||||
|
pnpm build:frontend
|
||||||
|
```
|
||||||
|
|
||||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
2. 构建后端
|
||||||
|
|
||||||
|
```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源加载更多功能~~
|
|
||||||
@@ -38,7 +38,7 @@ app.use((req, res, next) => {
|
|||||||
|
|
||||||
app.use("/", routes);
|
app.use("/", routes);
|
||||||
|
|
||||||
const initializeGlobalSettings = async () => {
|
const initializeGlobalSettings = async (): Promise<void> => {
|
||||||
const settings = await GlobalSetting.findOne();
|
const settings = await GlobalSetting.findOne();
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
await GlobalSetting.create({
|
await GlobalSetting.create({
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response } from "express";
|
||||||
import { Cloud115Service } from "../services/Cloud115Service";
|
import { Cloud115Service } from "../services/Cloud115Service";
|
||||||
import { sendSuccess, sendError } from "../utils/response";
|
import { sendSuccess, sendError } from "../utils/response";
|
||||||
import UserSetting from "../models/UserSetting";
|
import UserSetting from "../models/UserSetting";
|
||||||
|
|
||||||
const cloud115 = new Cloud115Service();
|
const cloud115 = new Cloud115Service();
|
||||||
const setCookie = async (req: Request) => {
|
const setCookie = async (req: Request): Promise<void> => {
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
const userSetting = await UserSetting.findOne({
|
const userSetting = await UserSetting.findOne({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
});
|
});
|
||||||
console.log(userSetting?.dataValues.cloud115Cookie);
|
|
||||||
if (userSetting && userSetting.dataValues.cloud115Cookie) {
|
if (userSetting && userSetting.dataValues.cloud115Cookie) {
|
||||||
cloud115.setCookie(userSetting.dataValues.cloud115Cookie);
|
cloud115.setCookie(userSetting.dataValues.cloud115Cookie);
|
||||||
} else {
|
} else {
|
||||||
@@ -18,7 +17,7 @@ const setCookie = async (req: Request) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const cloud115Controller = {
|
export const cloud115Controller = {
|
||||||
async getShareInfo(req: Request, res: Response, next: NextFunction) {
|
async getShareInfo(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { shareCode, receiveCode } = req.query;
|
const { shareCode, receiveCode } = req.query;
|
||||||
await setCookie(req);
|
await setCookie(req);
|
||||||
@@ -30,7 +29,7 @@ export const cloud115Controller = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getFolderList(req: Request, res: Response, next: NextFunction) {
|
async getFolderList(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { parentCid } = req.query;
|
const { parentCid } = req.query;
|
||||||
await setCookie(req);
|
await setCookie(req);
|
||||||
@@ -41,7 +40,7 @@ export const cloud115Controller = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async saveFile(req: Request, res: Response, next: NextFunction) {
|
async saveFile(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { shareCode, receiveCode, fileId, folderId } = req.body;
|
const { shareCode, receiveCode, fileId, folderId } = req.body;
|
||||||
await setCookie(req);
|
await setCookie(req);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response } from "express";
|
||||||
import DoubanService from "../services/DoubanService";
|
import DoubanService from "../services/DoubanService";
|
||||||
import { sendSuccess, sendError } from "../utils/response";
|
import { sendSuccess, sendError } from "../utils/response";
|
||||||
|
|
||||||
const doubanService = new DoubanService();
|
const doubanService = new DoubanService();
|
||||||
|
|
||||||
export const doubanController = {
|
export const doubanController = {
|
||||||
async getDoubanHotList(req: Request, res: Response, next: NextFunction) {
|
async getDoubanHotList(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { type = "movie", tag = "热门", page_limit = "50", page_start = "0" } = req.query;
|
const { type = "movie", tag = "热门", page_limit = "50", page_start = "0" } = req.query;
|
||||||
const result = await doubanService.getHotList({
|
const result = await doubanService.getHotList({
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response } from "express";
|
||||||
import { QuarkService } from "../services/QuarkService";
|
import { QuarkService } from "../services/QuarkService";
|
||||||
import { sendSuccess, sendError } from "../utils/response";
|
import { sendSuccess, sendError } from "../utils/response";
|
||||||
import UserSetting from "../models/UserSetting";
|
import UserSetting from "../models/UserSetting";
|
||||||
|
|
||||||
const quark = new QuarkService();
|
const quark = new QuarkService();
|
||||||
|
|
||||||
const setCookie = async (req: Request) => {
|
const setCookie = async (req: Request): Promise<void> => {
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
const userSetting = await UserSetting.findOne({
|
const userSetting = await UserSetting.findOne({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@@ -18,7 +18,7 @@ const setCookie = async (req: Request) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const quarkController = {
|
export const quarkController = {
|
||||||
async getShareInfo(req: Request, res: Response, next: NextFunction) {
|
async getShareInfo(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { pwdId, passcode } = req.query;
|
const { pwdId, passcode } = req.query;
|
||||||
await setCookie(req);
|
await setCookie(req);
|
||||||
@@ -29,7 +29,7 @@ export const quarkController = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getFolderList(req: Request, res: Response, next: NextFunction) {
|
async getFolderList(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { parentCid } = req.query;
|
const { parentCid } = req.query;
|
||||||
await setCookie(req);
|
await setCookie(req);
|
||||||
@@ -40,7 +40,7 @@ export const quarkController = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async saveFile(req: Request, res: Response, next: NextFunction) {
|
async saveFile(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await setCookie(req);
|
await setCookie(req);
|
||||||
const result = await quark.saveSharedFile(req.body);
|
const result = await quark.saveSharedFile(req.body);
|
||||||
|
|||||||
@@ -1,22 +1,9 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response } from "express";
|
||||||
import { RSSSearcher } from "../services/RSSSearcher";
|
|
||||||
import Searcher from "../services/Searcher";
|
import Searcher from "../services/Searcher";
|
||||||
import { sendSuccess, sendError } from "../utils/response";
|
import { sendSuccess, sendError } from "../utils/response";
|
||||||
|
|
||||||
export const resourceController = {
|
export const resourceController = {
|
||||||
async rssSearch(req: Request, res: Response, next: NextFunction) {
|
async search(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
|
||||||
const { keyword } = req.query;
|
|
||||||
const searcher = new RSSSearcher();
|
|
||||||
const result = await searcher.searchAll(keyword as string);
|
|
||||||
sendSuccess(res, result);
|
|
||||||
} catch (error) {
|
|
||||||
sendError(res, {
|
|
||||||
message: (error as Error).message || "RSS 搜索失败",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async search(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
const { keyword, channelId = "", lastMessageId = "" } = req.query; // Remove `: string` from here
|
const { keyword, channelId = "", lastMessageId = "" } = req.query; // Remove `: string` from here
|
||||||
const result = await Searcher.searchAll(
|
const result = await Searcher.searchAll(
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response } from "express";
|
||||||
import { sendSuccess, sendError } from "../utils/response";
|
import { sendSuccess, sendError } from "../utils/response";
|
||||||
import Searcher from "../services/Searcher";
|
import Searcher from "../services/Searcher";
|
||||||
import UserSetting from "../models/UserSetting";
|
import UserSetting from "../models/UserSetting";
|
||||||
import GlobalSetting from "../models/GlobalSetting";
|
import GlobalSetting from "../models/GlobalSetting";
|
||||||
|
import { iamgesInstance } from "./teleImages";
|
||||||
|
|
||||||
export const settingController = {
|
export const settingController = {
|
||||||
async get(req: Request, res: Response) {
|
async get(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
const role = req.user?.role;
|
const role = req.user?.role;
|
||||||
@@ -36,7 +37,7 @@ export const settingController = {
|
|||||||
sendError(res, { message: (error as Error).message || "获取设置失败" });
|
sendError(res, { message: (error as Error).message || "获取设置失败" });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async save(req: Request, res: Response) {
|
async save(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
const role = req.user?.role;
|
const role = req.user?.role;
|
||||||
@@ -45,6 +46,7 @@ export const settingController = {
|
|||||||
await UserSetting.update(userSettings, { where: { userId } });
|
await UserSetting.update(userSettings, { where: { userId } });
|
||||||
if (role === 1 && globalSetting) await GlobalSetting.update(globalSetting, { where: {} });
|
if (role === 1 && globalSetting) await GlobalSetting.update(globalSetting, { where: {} });
|
||||||
Searcher.updateAxiosInstance();
|
Searcher.updateAxiosInstance();
|
||||||
|
iamgesInstance.updateProxyConfig();
|
||||||
sendSuccess(res, {
|
sendSuccess(res, {
|
||||||
message: "保存成功",
|
message: "保存成功",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,29 +1,28 @@
|
|||||||
import axios, { AxiosInstance } from "axios";
|
import axios, { AxiosInstance } from "axios";
|
||||||
import { Request, Response } from "express";
|
import e, { Request, Response } from "express";
|
||||||
import tunnel from "tunnel";
|
import tunnel from "tunnel";
|
||||||
import GlobalSetting from "../models/GlobalSetting";
|
import GlobalSetting from "../models/GlobalSetting";
|
||||||
import { GlobalSettingAttributes } from "../models/GlobalSetting";
|
import { GlobalSettingAttributes } from "../models/GlobalSetting";
|
||||||
|
|
||||||
export class ImageControll {
|
export class ImageControll {
|
||||||
private axiosInstance: AxiosInstance | null = null;
|
private axiosInstance: AxiosInstance | null = null;
|
||||||
private isUpdate = false;
|
private settings: GlobalSetting | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.initializeAxiosInstance();
|
this.initializeAxiosInstance();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async initializeAxiosInstance(isUpdate = false) {
|
private async initializeAxiosInstance(): Promise<void> {
|
||||||
let settings = null;
|
try {
|
||||||
if (isUpdate) {
|
this.settings = await GlobalSetting.findOne();
|
||||||
settings = await GlobalSetting.findOne();
|
} catch (error) {
|
||||||
this.isUpdate = isUpdate;
|
console.error("Error fetching global settings:", error);
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const globalSetting = settings?.dataValues || ({} as GlobalSettingAttributes);
|
const globalSetting = this.settings?.dataValues || ({} as GlobalSettingAttributes);
|
||||||
this.axiosInstance = axios.create({
|
this.axiosInstance = axios.create({
|
||||||
timeout: 3000,
|
timeout: 3000,
|
||||||
httpsAgent: tunnel.httpsOverHttp({
|
httpsAgent: globalSetting.isProxyEnabled
|
||||||
|
? tunnel.httpsOverHttp({
|
||||||
proxy: {
|
proxy: {
|
||||||
host: globalSetting.httpProxyHost,
|
host: globalSetting.httpProxyHost,
|
||||||
port: globalSetting.httpProxyPort,
|
port: globalSetting.httpProxyPort,
|
||||||
@@ -31,13 +30,35 @@ export class ImageControll {
|
|||||||
"Proxy-Authorization": "",
|
"Proxy-Authorization": "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
|
: undefined,
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async getImages(req: Request, res: Response, url: string) {
|
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 {
|
try {
|
||||||
if (!this.isUpdate) await this.initializeAxiosInstance(true);
|
|
||||||
const response = await this.axiosInstance?.get(url, { responseType: "stream" });
|
const response = await this.axiosInstance?.get(url, { responseType: "stream" });
|
||||||
res.set("Content-Type", response?.headers["content-type"]);
|
res.set("Content-Type", response?.headers["content-type"]);
|
||||||
response?.data.pipe(res);
|
response?.data.pipe(res);
|
||||||
@@ -47,10 +68,10 @@ export class ImageControll {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const iamgesInstance = new ImageControll();
|
export const iamgesInstance = new ImageControll();
|
||||||
|
|
||||||
export const imageControll = {
|
export const imageControll = {
|
||||||
getImages: async (req: Request, res: Response) => {
|
getImages: async (req: Request, res: Response): Promise<void> => {
|
||||||
const url = req.query.url as string;
|
const url = req.query.url as string;
|
||||||
iamgesInstance.getImages(req, res, url);
|
iamgesInstance.getImages(req, res, url);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const isValidInput = (input: string): boolean => {
|
|||||||
return regex.test(input);
|
return regex.test(input);
|
||||||
};
|
};
|
||||||
export const userController = {
|
export const userController = {
|
||||||
async register(req: Request, res: Response) {
|
async register(req: Request, res: Response): Promise<void> {
|
||||||
const { username, password, registerCode } = req.body;
|
const { username, password, registerCode } = req.body;
|
||||||
const globalSetting = await GlobalSetting.findOne();
|
const globalSetting = await GlobalSetting.findOne();
|
||||||
const registerCodeList = [
|
const registerCodeList = [
|
||||||
@@ -39,12 +39,12 @@ export const userController = {
|
|||||||
data: user,
|
data: user,
|
||||||
message: "用户注册成功",
|
message: "用户注册成功",
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
sendError(res, { message: error.message || "用户注册失败" });
|
sendError(res, { message: (error as Error).message || "用户注册失败" });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async login(req: Request, res: Response) {
|
async login(req: Request, res: Response): Promise<void> {
|
||||||
const { username, password } = req.body;
|
const { username, password } = req.body;
|
||||||
const user = await User.findOne({ where: { username } });
|
const user = await User.findOne({ where: { username } });
|
||||||
if (!user || !(await bcrypt.compare(password, user.password))) {
|
if (!user || !(await bcrypt.compare(password, user.password))) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const authMiddleware = async (
|
|||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
) => {
|
): Promise<void | Response> => {
|
||||||
if (req.path === "/user/login" || req.path === "/user/register") {
|
if (req.path === "/user/login" || req.path === "/user/register") {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
console.error(err);
|
||||||
res.status(err.status || 500).json({
|
res.status(err.status || 500).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
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) => {
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
const missingParams = requiredParams.filter((param) => !req.query[param] && !req.body[param]);
|
const missingParams = requiredParams.filter((param) => !req.query[param] && !req.body[param]);
|
||||||
if (missingParams.length > 0) {
|
if (missingParams.length > 0) {
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ router.use("/setting", settingRoutes);
|
|||||||
|
|
||||||
// 资源搜索
|
// 资源搜索
|
||||||
router.get("/search", resourceController.search);
|
router.get("/search", resourceController.search);
|
||||||
router.get("/rssSearch", resourceController.rssSearch);
|
|
||||||
|
|
||||||
// 115网盘相关
|
// 115网盘相关
|
||||||
router.get("/cloud115/share-info", cloud115Controller.getShareInfo);
|
router.get("/cloud115/share-info", cloud115Controller.getShareInfo);
|
||||||
|
|||||||
@@ -4,6 +4,23 @@ import { Logger } from "../utils/logger";
|
|||||||
import { config } from "../config/index";
|
import { config } from "../config/index";
|
||||||
import { ShareInfoResponse } from "../types/cloud115";
|
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 {
|
export class Cloud115Service {
|
||||||
private api: AxiosInstance;
|
private api: AxiosInstance;
|
||||||
private cookie: string = "";
|
private cookie: string = "";
|
||||||
@@ -39,7 +56,7 @@ export class Cloud115Service {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public setCookie(cookie: string) {
|
public setCookie(cookie: string): void {
|
||||||
this.cookie = cookie;
|
this.cookie = cookie;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,10 +70,9 @@ export class Cloud115Service {
|
|||||||
cid: "",
|
cid: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data?.state && response.data.data?.list?.length > 0) {
|
if (response.data?.state && response.data.data?.list?.length > 0) {
|
||||||
return {
|
return {
|
||||||
data: response.data.data.list.map((item: any) => ({
|
data: response.data.data.list.map((item: Cloud115ListItem) => ({
|
||||||
fileId: item.cid,
|
fileId: item.cid,
|
||||||
fileName: item.n,
|
fileName: item.n,
|
||||||
fileSize: item.s,
|
fileSize: item.s,
|
||||||
@@ -66,13 +82,15 @@ export class Cloud115Service {
|
|||||||
throw new Error("未找到文件信息");
|
throw new Error("未找到文件信息");
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFolderList(parentCid = "0") {
|
async getFolderList(
|
||||||
|
parentCid = "0"
|
||||||
|
): Promise<{ data: { cid: string; name: string; path: Cloud115PathItem[] }[] }> {
|
||||||
const response = await this.api.get("/files", {
|
const response = await this.api.get("/files", {
|
||||||
params: {
|
params: {
|
||||||
aid: 1,
|
aid: 1,
|
||||||
cid: parentCid,
|
cid: parentCid,
|
||||||
o: "user_ptime",
|
o: "user_ptime",
|
||||||
asc: 0,
|
asc: 1,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
show_dir: 1,
|
show_dir: 1,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
@@ -80,15 +98,17 @@ export class Cloud115Service {
|
|||||||
format: "json",
|
format: "json",
|
||||||
star: 0,
|
star: 0,
|
||||||
suffix: "",
|
suffix: "",
|
||||||
natsort: 1,
|
natsort: 0,
|
||||||
|
snap: 0,
|
||||||
|
record_open_time: 1,
|
||||||
|
fc_mix: 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data?.state) {
|
if (response.data?.state) {
|
||||||
return {
|
return {
|
||||||
data: response.data.data
|
data: response.data.data
|
||||||
.filter((item: any) => item.cid && !!item.ns)
|
.filter((item: Cloud115FolderItem) => item.cid && !!item.ns)
|
||||||
.map((folder: any) => ({
|
.map((folder: Cloud115FolderItem) => ({
|
||||||
cid: folder.cid,
|
cid: folder.cid,
|
||||||
name: folder.n,
|
name: folder.n,
|
||||||
path: response.data.path,
|
path: response.data.path,
|
||||||
@@ -105,7 +125,7 @@ export class Cloud115Service {
|
|||||||
shareCode: string;
|
shareCode: string;
|
||||||
receiveCode: string;
|
receiveCode: string;
|
||||||
fileId: string;
|
fileId: string;
|
||||||
}) {
|
}): Promise<{ message: string; data: unknown }> {
|
||||||
const param = new URLSearchParams({
|
const param = new URLSearchParams({
|
||||||
cid: params.cid,
|
cid: params.cid,
|
||||||
user_id: config.cloud115.userId,
|
user_id: config.cloud115.userId,
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { AxiosHeaders, AxiosInstance } from "axios";
|
import { AxiosHeaders, AxiosInstance } from "axios";
|
||||||
import { createAxiosInstance } from "../utils/axiosInstance";
|
import { createAxiosInstance } from "../utils/axiosInstance";
|
||||||
|
|
||||||
|
interface DoubanSubject {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
rate: string;
|
||||||
|
cover: string;
|
||||||
|
url: string;
|
||||||
|
is_new: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
class DoubanService {
|
class DoubanService {
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
private api: AxiosInstance;
|
private api: AxiosInstance;
|
||||||
@@ -28,7 +37,12 @@ class DoubanService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getHotList(params: { type: string; tag: string; page_limit: string; page_start: string }) {
|
async getHotList(params: {
|
||||||
|
type: string;
|
||||||
|
tag: string;
|
||||||
|
page_limit: string;
|
||||||
|
page_start: string;
|
||||||
|
}): Promise<{ data: DoubanSubject[] }> {
|
||||||
try {
|
try {
|
||||||
const response = await this.api.get("/search_subjects", {
|
const response = await this.api.get("/search_subjects", {
|
||||||
params: params,
|
params: params,
|
||||||
|
|||||||
@@ -2,6 +2,24 @@ import { AxiosInstance, AxiosHeaders } from "axios";
|
|||||||
import { Logger } from "../utils/logger";
|
import { Logger } from "../utils/logger";
|
||||||
import { createAxiosInstance } from "../utils/axiosInstance";
|
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 {
|
export class QuarkService {
|
||||||
private api: AxiosInstance;
|
private api: AxiosInstance;
|
||||||
private cookie: string = "";
|
private cookie: string = "";
|
||||||
@@ -34,11 +52,11 @@ export class QuarkService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public setCookie(cookie: string) {
|
public setCookie(cookie: string): void {
|
||||||
this.cookie = cookie;
|
this.cookie = cookie;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getShareInfo(pwdId: string, passcode = "") {
|
async getShareInfo(pwdId: string, passcode = ""): Promise<{ data: QuarkShareInfo }> {
|
||||||
const response = await this.api.post(
|
const response = await this.api.post(
|
||||||
`/1/clouddrive/share/sharepage/token?pr=ucpro&fr=pc&uc_param_str=&__dt=994&__t=${Date.now()}`,
|
`/1/clouddrive/share/sharepage/token?pr=ucpro&fr=pc&uc_param_str=&__dt=994&__t=${Date.now()}`,
|
||||||
{
|
{
|
||||||
@@ -49,7 +67,7 @@ export class QuarkService {
|
|||||||
if (response.data?.status === 200 && response.data.data) {
|
if (response.data?.status === 200 && response.data.data) {
|
||||||
const fileInfo = response.data.data;
|
const fileInfo = response.data.data;
|
||||||
if (fileInfo.stoken) {
|
if (fileInfo.stoken) {
|
||||||
let res = await this.getShareList(pwdId, fileInfo.stoken);
|
const res = await this.getShareList(pwdId, fileInfo.stoken);
|
||||||
return {
|
return {
|
||||||
data: res,
|
data: res,
|
||||||
};
|
};
|
||||||
@@ -58,7 +76,7 @@ export class QuarkService {
|
|||||||
throw new Error("获取夸克分享信息失败");
|
throw new Error("获取夸克分享信息失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
async getShareList(pwdId: string, stoken: string) {
|
async getShareList(pwdId: string, stoken: string): Promise<QuarkShareInfo> {
|
||||||
const response = await this.api.get("/1/clouddrive/share/sharepage/detail", {
|
const response = await this.api.get("/1/clouddrive/share/sharepage/detail", {
|
||||||
params: {
|
params: {
|
||||||
pr: "ucpro",
|
pr: "ucpro",
|
||||||
@@ -80,8 +98,8 @@ export class QuarkService {
|
|||||||
});
|
});
|
||||||
if (response.data?.data) {
|
if (response.data?.data) {
|
||||||
const list = response.data.data.list
|
const list = response.data.data.list
|
||||||
.filter((item: any) => item.fid)
|
.filter((item: QuarkShareInfo["list"][0]) => item.fid)
|
||||||
.map((folder: any) => ({
|
.map((folder: QuarkShareInfo["list"][0]) => ({
|
||||||
fileId: folder.fid,
|
fileId: folder.fid,
|
||||||
fileName: folder.file_name,
|
fileName: folder.file_name,
|
||||||
fileIdToken: folder.share_fid_token,
|
fileIdToken: folder.share_fid_token,
|
||||||
@@ -89,7 +107,8 @@ export class QuarkService {
|
|||||||
return {
|
return {
|
||||||
list,
|
list,
|
||||||
pwdId,
|
pwdId,
|
||||||
stoken: stoken,
|
stoken,
|
||||||
|
fileSize: response.data.data.share?.size || 0,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
@@ -98,7 +117,9 @@ export class QuarkService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFolderList(parentCid = "0") {
|
async getFolderList(
|
||||||
|
parentCid = "0"
|
||||||
|
): Promise<{ data: { cid: string; name: string; path: [] }[] }> {
|
||||||
const response = await this.api.get("/1/clouddrive/file/sort", {
|
const response = await this.api.get("/1/clouddrive/file/sort", {
|
||||||
params: {
|
params: {
|
||||||
pr: "ucpro",
|
pr: "ucpro",
|
||||||
@@ -114,10 +135,10 @@ export class QuarkService {
|
|||||||
__t: Date.now(),
|
__t: Date.now(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (response.data?.data && response.data.data.list.length) {
|
if (response.data?.data && response.data.data.list) {
|
||||||
const data = response.data.data.list
|
const data = response.data.data.list
|
||||||
.filter((item: any) => item.fid && item.file_type === 0)
|
.filter((item: QuarkFolderItem) => item.fid && item.file_type === 0)
|
||||||
.map((folder: any) => ({
|
.map((folder: QuarkFolderItem) => ({
|
||||||
cid: folder.fid,
|
cid: folder.fid,
|
||||||
name: folder.file_name,
|
name: folder.file_name,
|
||||||
path: [],
|
path: [],
|
||||||
@@ -140,7 +161,7 @@ export class QuarkService {
|
|||||||
stoken: string;
|
stoken: string;
|
||||||
pdir_fid: string;
|
pdir_fid: string;
|
||||||
scene: string;
|
scene: string;
|
||||||
}) {
|
}): Promise<{ message: string; data: unknown }> {
|
||||||
try {
|
try {
|
||||||
const response = await this.api.post(
|
const response = await this.api.post(
|
||||||
`/1/clouddrive/share/sharepage/save?pr=ucpro&fr=pc&uc_param_str=&__dt=208097&__t=${Date.now()}`,
|
`/1/clouddrive/share/sharepage/save?pr=ucpro&fr=pc&uc_param_str=&__dt=208097&__t=${Date.now()}`,
|
||||||
|
|||||||
@@ -1,116 +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";
|
|
||||||
import { data } from "cheerio/dist/commonjs/api/attributes";
|
|
||||||
|
|
||||||
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 {
|
|
||||||
data: allResults,
|
|
||||||
message: "搜索成功",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,7 @@ export class Searcher {
|
|||||||
this.initializeAxiosInstance();
|
this.initializeAxiosInstance();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async initializeAxiosInstance(isUpdate = false) {
|
private async initializeAxiosInstance(isUpdate = false): Promise<void> {
|
||||||
let settings = null;
|
let settings = null;
|
||||||
if (isUpdate) {
|
if (isUpdate) {
|
||||||
settings = await GlobalSetting.findOne();
|
settings = await GlobalSetting.findOne();
|
||||||
@@ -77,20 +77,19 @@ export class Searcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async searchAll(keyword: string, channelId?: string, messageId?: string) {
|
async searchAll(keyword: string, channelId?: string, messageId?: string) {
|
||||||
const allResults = [];
|
const allResults: any[] = [];
|
||||||
const totalChannels = config.rss.channels.length;
|
|
||||||
|
|
||||||
const channelList = channelId
|
const channelList: any[] = channelId
|
||||||
? config.rss.channels.filter((channel) => channel.id === channelId)
|
? config.rss.channels.filter((channel: any) => channel.id === channelId)
|
||||||
: config.rss.channels;
|
: config.rss.channels;
|
||||||
|
|
||||||
for (let i = 0; i < channelList.length; i++) {
|
// 使用Promise.all进行并行请求
|
||||||
const channel = channelList[i];
|
const searchPromises = channelList.map(async (channel) => {
|
||||||
try {
|
try {
|
||||||
const messageIdparams = messageId ? `before=${messageId}` : "";
|
const messageIdparams = messageId ? `before=${messageId}` : "";
|
||||||
const url = `/${channel.id}${keyword ? `?q=${encodeURIComponent(keyword)}&${messageIdparams}` : `?${messageIdparams}`}`;
|
const url = `/${channel.id}${keyword ? `?q=${encodeURIComponent(keyword)}&${messageIdparams}` : `?${messageIdparams}`}`;
|
||||||
console.log(`Searching in channel ${channel.name} with URL: ${url}`);
|
console.log(`Searching in channel ${channel.name} with URL: ${url}`);
|
||||||
const results = await this.searchInWeb(url, channel.id);
|
return this.searchInWeb(url).then((results) => {
|
||||||
console.log(`Found ${results.items.length} items in channel ${channel.name}`);
|
console.log(`Found ${results.items.length} items in channel ${channel.name}`);
|
||||||
if (results.items.length > 0) {
|
if (results.items.length > 0) {
|
||||||
const channelResults = results.items
|
const channelResults = results.items
|
||||||
@@ -110,17 +109,21 @@ export class Searcher {
|
|||||||
id: channel.id,
|
id: channel.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(`搜索频道 ${channel.name} 失败:`, error);
|
Logger.error(`搜索频道 ${channel.name} 失败:`, error);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
|
// 等待所有请求完成
|
||||||
|
await Promise.all(searchPromises);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: allResults,
|
data: allResults,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchInWeb(url: string, channelId: string) {
|
async searchInWeb(url: string) {
|
||||||
try {
|
try {
|
||||||
if (!this.axiosInstance) {
|
if (!this.axiosInstance) {
|
||||||
throw new Error("Axios instance is not initialized");
|
throw new Error("Axios instance is not initialized");
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
|
|
||||||
declare module "express" {
|
declare module "express" {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import axios, { AxiosInstance, AxiosRequestHeaders } from "axios";
|
import axios, { AxiosInstance, AxiosRequestHeaders } from "axios";
|
||||||
import tunnel from "tunnel";
|
import tunnel from "tunnel";
|
||||||
import { config } from "../config";
|
|
||||||
import GlobalSetting from "../models/GlobalSetting";
|
|
||||||
|
|
||||||
interface ProxyConfig {
|
interface ProxyConfig {
|
||||||
host: string;
|
host: string;
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { Response, NextFunction } from "express";
|
import { Response, NextFunction } from "express";
|
||||||
import { Logger } from "../utils/logger";
|
import { Logger } from "../utils/logger";
|
||||||
|
|
||||||
|
interface CustomError {
|
||||||
|
name?: string;
|
||||||
|
message: string;
|
||||||
|
success?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export default function handleError(
|
export default function handleError(
|
||||||
res: Response,
|
res: Response,
|
||||||
error: any,
|
error: CustomError | unknown,
|
||||||
message: string,
|
message: string,
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
) {
|
) {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 172 KiB |
BIN
docs/images/alipay.png
Normal file
|
After Width: | Height: | Size: 610 KiB |
|
Before Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
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: 1.2 MiB |
|
Before Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 824 KiB |
|
Before Width: | Height: | Size: 86 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 |
1
frontend/auto-imports.d.ts
vendored
@@ -6,4 +6,5 @@
|
|||||||
export {}
|
export {}
|
||||||
declare global {
|
declare global {
|
||||||
const ElMessage: typeof import('element-plus/es')['ElMessage']
|
const ElMessage: typeof import('element-plus/es')['ElMessage']
|
||||||
|
const showConfirmDialog: typeof import('vant/es')['showConfirmDialog']
|
||||||
}
|
}
|
||||||
|
|||||||
30
frontend/components.d.ts
vendored
@@ -8,13 +8,11 @@ export {}
|
|||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
AsideMenu: typeof import('./src/components/AsideMenu.vue')['default']
|
AsideMenu: typeof import('./src/components/AsideMenu.vue')['default']
|
||||||
DoubanMovie: typeof import('./src/components/Home/DoubanMovie.vue')['default']
|
|
||||||
ElAside: typeof import('element-plus/es')['ElAside']
|
ElAside: typeof import('element-plus/es')['ElAside']
|
||||||
ElBacktop: typeof import('element-plus/es')['ElBacktop']
|
ElBacktop: typeof import('element-plus/es')['ElBacktop']
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElCard: typeof import('element-plus/es')['ElCard']
|
ElCard: typeof import('element-plus/es')['ElCard']
|
||||||
ElCheckbox: (typeof import("element-plus/es"))["ElCheckbox"]
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
ElCol: typeof import('element-plus/es')['ElCol']
|
|
||||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
@@ -30,8 +28,6 @@ declare module 'vue' {
|
|||||||
ElMain: typeof import('element-plus/es')['ElMain']
|
ElMain: typeof import('element-plus/es')['ElMain']
|
||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElMenuItemGroup: typeof import('element-plus/es')['ElMenuItemGroup']
|
|
||||||
ElRow: typeof import('element-plus/es')['ElRow']
|
|
||||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
ElTable: typeof import('element-plus/es')['ElTable']
|
ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
@@ -40,14 +36,34 @@ declare module 'vue' {
|
|||||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||||
ElTag: typeof import('element-plus/es')['ElTag']
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||||
ElTree: typeof import('element-plus/es')['ElTree']
|
|
||||||
FolderSelect: typeof import('./src/components/Home/FolderSelect.vue')['default']
|
FolderSelect: typeof import('./src/components/Home/FolderSelect.vue')['default']
|
||||||
ResourceCard: typeof import('./src/components/Home/ResourceCard.vue')['default']
|
ResourceCard: typeof import('./src/components/Home/ResourceCard.vue')['default']
|
||||||
ResourceList: typeof import('./src/components/Home/ResourceList.vue')['default']
|
ResourceSelect: typeof import('./src/components/Home/ResourceSelect.vue')['default']
|
||||||
ResourceTable: typeof import('./src/components/Home/ResourceTable.vue')['default']
|
ResourceTable: typeof import('./src/components/Home/ResourceTable.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
SearchBar: typeof import('./src/components/SearchBar.vue')['default']
|
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 {
|
export interface ComponentCustomProperties {
|
||||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||||
|
|||||||
@@ -8,6 +8,30 @@
|
|||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"
|
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" />
|
<meta name="referrer" content="no-referrer" />
|
||||||
<title>CloudSaver</title>
|
<title>CloudSaver</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
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",
|
"name": "cloud-saver-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.2.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
@@ -14,17 +14,21 @@
|
|||||||
"element-plus": "^2.6.1",
|
"element-plus": "^2.6.1",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
|
"vant": "^4.9.17",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
"vue-router": "^4.3.0"
|
"vue-router": "^4.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.11.25",
|
"@types/node": "^20.11.25",
|
||||||
|
"@vant/auto-import-resolver": "^1.3.0",
|
||||||
"@vitejs/plugin-vue": "^5.0.4",
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"postcss-pxtorem": "^6.1.0",
|
||||||
"sass": "^1.83.4",
|
"sass": "^1.83.4",
|
||||||
"typescript": "^5.4.2",
|
"typescript": "^5.4.2",
|
||||||
"unplugin-auto-import": "^0.17.5",
|
"unplugin-auto-import": "^0.17.8",
|
||||||
"unplugin-vue-components": "^0.26.0",
|
"unplugin-vue-components": "^0.26.0",
|
||||||
"vite": "^5.1.5",
|
"vite": "^5.1.5",
|
||||||
|
"vite-plugin-pwa": "^0.21.1",
|
||||||
"vue-tsc": "^2.0.6"
|
"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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -5,17 +5,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#app {
|
#app {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
:root {
|
:root {
|
||||||
--theme-color: #3e3e3e;
|
--theme-color: #3e3e3e;
|
||||||
--theme-theme: #133ab3;
|
--theme-theme: #133ab3;
|
||||||
--theme-background: #fafafa;
|
--theme-background: #fafafa;
|
||||||
--theme-other_background: #ffffff;
|
--theme-other_background: #ffffff;
|
||||||
}
|
}
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-family:
|
font-family:
|
||||||
@@ -32,5 +32,31 @@
|
|||||||
color: var(--theme-color);
|
color: var(--theme-color);
|
||||||
background-color: var(--theme-background);
|
background-color: var(--theme-background);
|
||||||
word-wrap: break-word;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export const userApi = {
|
|||||||
login: (data: { username: string; password: string }) => {
|
login: (data: { username: string; password: string }) => {
|
||||||
return request.post<{ token: string }>("/api/user/login", data);
|
return request.post<{ token: string }>("/api/user/login", data);
|
||||||
},
|
},
|
||||||
register: (data: { username: string; password: string }) => {
|
register: (data: { username: string; password: string; registerCode: string }) => {
|
||||||
return request.post<{ token: string }>("/api/user/register", data);
|
return request.post<{ token: string }>("/api/user/register", data);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
BIN
frontend/src/assets/images/mobile-login-bg.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
@@ -1,90 +1,127 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="aside-menu">
|
<div class="pc-aside">
|
||||||
<div class="logo">
|
<!-- Logo 区域 -->
|
||||||
<img :src="logo" class="logo-img" />
|
<div class="pc-aside__logo">
|
||||||
<div class="logo-text">Cloud Saver</div>
|
<img :src="logo" alt="Cloud Saver Logo" class="logo__image" />
|
||||||
|
<h1 class="logo__title">Cloud Saver</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 菜单区域 -->
|
||||||
<el-menu
|
<el-menu
|
||||||
:default-active="currentMenu?.index || '1'"
|
:default-active="currentMenu?.index || '1'"
|
||||||
:default-openeds="currentMenuOpen"
|
:default-openeds="currentMenuOpen"
|
||||||
class="el-menu-vertical"
|
class="pc-aside__menu"
|
||||||
@open="handleOpen"
|
|
||||||
@close="handleClose"
|
|
||||||
>
|
>
|
||||||
<template v-for="menu in menuList">
|
<template v-for="menu in menuList" :key="menu.index">
|
||||||
<el-sub-menu :index="menu.index" v-if="menu.children">
|
<!-- 子菜单 -->
|
||||||
|
<el-sub-menu v-if="menu.children" :index="menu.index">
|
||||||
<template #title>
|
<template #title>
|
||||||
<el-icon><component :is="menu.icon" /></el-icon>
|
<el-icon><component :is="menu.icon" /></el-icon>
|
||||||
<span>{{ menu.title }}</span>
|
<span>{{ menu.title }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-menu-item
|
<el-menu-item
|
||||||
v-for="child in menu.children"
|
v-for="child in menu.children"
|
||||||
:index="child.index"
|
|
||||||
:key="child.index"
|
:key="child.index"
|
||||||
|
:index="child.index"
|
||||||
@click="handleMenuClick(child)"
|
@click="handleMenuClick(child)"
|
||||||
>
|
>
|
||||||
{{ child.title }}
|
<span>{{ child.title }}</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</el-sub-menu>
|
</el-sub-menu>
|
||||||
|
|
||||||
|
<!-- 普通菜单项 -->
|
||||||
<el-menu-item
|
<el-menu-item
|
||||||
v-else
|
v-else
|
||||||
:index="menu.index"
|
:index="menu.index"
|
||||||
@click="handleMenuClick(menu)"
|
|
||||||
:disabled="menu.disabled"
|
:disabled="menu.disabled"
|
||||||
|
@click="handleMenuClick(menu)"
|
||||||
>
|
>
|
||||||
<el-icon><component :is="menu.icon" /></el-icon>
|
<el-icon><component :is="menu.icon" /></el-icon>
|
||||||
<span>{{ menu.title }}</span>
|
<span>{{ menu.title }}</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</template>
|
</template>
|
||||||
</el-menu>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script setup lang="ts">
|
||||||
import { Search, Film, Setting } from "@element-plus/icons-vue";
|
import { computed } from "vue";
|
||||||
import logo from "@/assets/images/logo.png";
|
import { useRouter, useRoute } from "vue-router";
|
||||||
import { useRouter, useRoute } from "vue-router";
|
import { Search, Film, Setting, Link } from "@element-plus/icons-vue";
|
||||||
import { computed } from "vue";
|
import logo from "@/assets/images/logo.png";
|
||||||
const router = useRouter();
|
import pkg from "../../package.json";
|
||||||
const route = useRoute();
|
|
||||||
interface MenuItem {
|
// 类型定义
|
||||||
|
interface MenuItem {
|
||||||
index: string;
|
index: string;
|
||||||
title: string;
|
title: string;
|
||||||
icon?: any;
|
icon?: typeof Search | typeof Film | typeof Setting | typeof Link;
|
||||||
router?: string;
|
router?: string;
|
||||||
children?: MenuItem[];
|
children?: MenuItem[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const menuList: MenuItem[] = [
|
// 路由相关
|
||||||
{
|
const router = useRouter();
|
||||||
index: "2",
|
const route = useRoute();
|
||||||
title: "资源搜索",
|
|
||||||
icon: Search,
|
// 菜单配置
|
||||||
router: "/",
|
const menuList: MenuItem[] = [
|
||||||
},
|
|
||||||
{
|
{
|
||||||
index: "1",
|
index: "1",
|
||||||
|
title: "资源搜索",
|
||||||
|
icon: Search,
|
||||||
|
router: "/resource",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: "2",
|
||||||
title: "豆瓣榜单",
|
title: "豆瓣榜单",
|
||||||
icon: Film,
|
icon: Film,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
index: "1-1",
|
index: "2-1",
|
||||||
title: "热门电影",
|
title: "热门电影",
|
||||||
router: "/douban?type=movie",
|
router: "/douban?type=movie",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
index: "1-2",
|
index: "2-2",
|
||||||
title: "热门电视剧",
|
title: "热门电视剧",
|
||||||
router: "/douban?type=tv",
|
router: "/douban?type=tv",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
index: "1-3",
|
index: "2-3",
|
||||||
title: "最新电影",
|
title: "最新电影",
|
||||||
router: "/douban?type=movie&tag=最新",
|
router: "/douban?type=movie&tag=最新",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
index: "1-4",
|
index: "2-4",
|
||||||
title: "热门综艺",
|
title: "热门综艺",
|
||||||
router: "/douban?type=tv&tag=综艺",
|
router: "/douban?type=tv&tag=综艺",
|
||||||
},
|
},
|
||||||
@@ -97,59 +134,173 @@
|
|||||||
router: "/setting",
|
router: "/setting",
|
||||||
disabled: false,
|
disabled: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const currentMenu = computed(() => {
|
// 计算当前激活的菜单
|
||||||
console.log("route", decodeURIComponent(route.fullPath));
|
const currentMenu = computed(() => {
|
||||||
return menuList
|
const flatMenus = menuList.reduce<MenuItem[]>((acc, menu) => {
|
||||||
.reduce((pre: MenuItem[], cur: MenuItem) => {
|
if (!menu.children) {
|
||||||
if (!cur.children) {
|
acc.push(menu);
|
||||||
pre.push(cur);
|
|
||||||
} else {
|
} else {
|
||||||
pre.push(...cur.children);
|
acc.push(...menu.children);
|
||||||
}
|
}
|
||||||
return pre;
|
return acc;
|
||||||
}, [])
|
}, []);
|
||||||
.find((x) => x.router === decodeURIComponent(route.fullPath));
|
|
||||||
});
|
return flatMenus.find((menu) => menu.router === decodeURIComponent(route.fullPath));
|
||||||
const currentMenuOpen = computed(() => {
|
});
|
||||||
if (currentMenu.value && currentMenu.value.index.length > 1) {
|
|
||||||
console.log([currentMenu.value.index.split("-")[0]]);
|
// 计算当前展开的子菜单
|
||||||
|
const currentMenuOpen = computed(() => {
|
||||||
|
if (currentMenu.value?.index.includes("-")) {
|
||||||
return [currentMenu.value.index.split("-")[0]];
|
return [currentMenu.value.index.split("-")[0]];
|
||||||
} else {
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
});
|
return [];
|
||||||
const handleOpen = (key: string, keyPath: string[]) => {
|
});
|
||||||
console.log(key, keyPath);
|
|
||||||
};
|
// 菜单点击处理
|
||||||
const handleClose = (key: string, keyPath: string[]) => {
|
const handleMenuClick = (menu: MenuItem) => {
|
||||||
console.log(key, keyPath);
|
|
||||||
};
|
|
||||||
const handleMenuClick = (menu: any) => {
|
|
||||||
console.log(menu);
|
|
||||||
if (menu.router) {
|
if (menu.router) {
|
||||||
router.push(menu.router);
|
router.push(menu.router);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.el-menu-vertical {
|
@import "@/styles/common.scss";
|
||||||
width: 100%;
|
|
||||||
height: 100vh;
|
.pc-aside {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--theme-card-bg);
|
||||||
|
border-right: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
// Logo 区域
|
||||||
|
&__logo {
|
||||||
|
@include flex-center;
|
||||||
|
padding: 24px 16px;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.logo__image {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
.logo {
|
|
||||||
display: flex;
|
.logo__title {
|
||||||
align-items: center;
|
margin: 0;
|
||||||
justify-content: center;
|
font-size: 18px;
|
||||||
padding: 10px 0;
|
font-weight: 600;
|
||||||
.logo-img {
|
color: var(--theme-text-primary);
|
||||||
width: 30px;
|
@include text-overflow;
|
||||||
height: 30px;
|
|
||||||
margin-right: 15px;
|
|
||||||
}
|
}
|
||||||
.logo-text {
|
}
|
||||||
|
|
||||||
|
// 菜单区域
|
||||||
|
&__menu {
|
||||||
|
border-right: none;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
:deep(.el-menu-item) {
|
||||||
|
height: 48px;
|
||||||
|
line-height: 48px;
|
||||||
|
color: var(--theme-text-regular);
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
color: var(--theme-primary);
|
||||||
|
background: rgba(0, 102, 204, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--theme-primary);
|
||||||
|
background: rgba(0, 102, 204, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-sub-menu) {
|
||||||
|
.el-sub-menu__title {
|
||||||
|
color: var(--theme-text-regular);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--theme-primary);
|
||||||
|
background: rgba(0, 102, 204, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-icon) {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-right: 12px;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitHub 链接区域
|
||||||
|
&__footer {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
background: var(--theme-card-bg);
|
||||||
|
|
||||||
|
.github-link {
|
||||||
|
@include flex-center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
color: var(--theme-text-regular);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: var(--theme-radius);
|
||||||
|
transition: var(--theme-transition);
|
||||||
|
|
||||||
|
.github-icon {
|
||||||
font-size: 20px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,131 +1,277 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="folder-select">
|
<div class="folder-select">
|
||||||
<div class="folder-select-header">
|
<div class="folder-header">
|
||||||
当前位置:<el-icon style="margin: 0 5px"><Folder /></el-icon
|
<div class="folder-path">
|
||||||
>{{ selectedFolder?.path?.map((x: Folder) => x.name).join("/") }}
|
<el-icon><FolderOpened /></el-icon>
|
||||||
</div>
|
<template v-if="folderPath.length">
|
||||||
<el-tree
|
<span
|
||||||
ref="treeRef"
|
v-for="(folder, index) in folderPath"
|
||||||
:data="folders"
|
:key="folder.cid"
|
||||||
:props="defaultProps"
|
class="path-item"
|
||||||
node-key="cid"
|
@click="handlePathClick(index)"
|
||||||
:load="loadNode"
|
|
||||||
lazy
|
|
||||||
@node-click="handleNodeClick"
|
|
||||||
highlight-current
|
|
||||||
>
|
>
|
||||||
<template #default="{ node }">
|
<span class="folder-name">{{ folder.name }}</span>
|
||||||
<span class="folder-node">
|
<el-icon v-if="index < folderPath.length - 1"><ArrowRight /></el-icon>
|
||||||
<el-icon><Folder /></el-icon>
|
|
||||||
{{ node.label }}
|
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-tree>
|
<span v-else class="root-path" @click="handlePathClick(-1)">根目录</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="folder-list">
|
||||||
|
<div v-if="!folders.length" class="empty-folder">
|
||||||
|
<el-empty description="暂无文件夹" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="folder in folders"
|
||||||
|
:key="folder.cid"
|
||||||
|
class="folder-item"
|
||||||
|
:class="{ 'is-selected': folder.cid === selectedFolder?.cid }"
|
||||||
|
@click="handleFolderClick(folder)"
|
||||||
|
>
|
||||||
|
<div class="folder-info">
|
||||||
|
<el-icon><Folder /></el-icon>
|
||||||
|
<span class="folder-name">{{ folder.name }}</span>
|
||||||
|
</div>
|
||||||
|
<el-icon class="arrow-icon"><ArrowRight /></el-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-overlay">
|
||||||
|
<el-icon class="loading-icon"><Loading /></el-icon>
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, defineProps } from "vue";
|
import { ref, defineProps } from "vue";
|
||||||
import { cloud115Api } from "@/api/cloud115";
|
import { cloud115Api } from "@/api/cloud115";
|
||||||
import { quarkApi } from "@/api/quark";
|
import { quarkApi } from "@/api/quark";
|
||||||
import type { TreeInstance } from "element-plus";
|
import type { Folder as FolderType } from "@/types";
|
||||||
import type { Folder } from "@/types";
|
import { Folder, FolderOpened, ArrowRight, Loading } from "@element-plus/icons-vue";
|
||||||
import { type RequestResult } from "@/types/response";
|
|
||||||
import { ElMessage } from "element-plus";
|
|
||||||
|
|
||||||
const props = defineProps({
|
import { ElMessage } from "element-plus";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
cloudType: {
|
cloudType: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const treeRef = ref<TreeInstance>();
|
const loading = ref(false);
|
||||||
const folders = ref<Folder[]>([]);
|
const folders = ref<FolderType[]>([]);
|
||||||
const selectedFolder = ref<Folder | null>(null);
|
const selectedFolder = ref<FolderType | null>(null);
|
||||||
const emit = defineEmits<{
|
const folderPath = ref<FolderType[]>([{ name: "根目录", cid: "0" }]);
|
||||||
|
const emit = defineEmits<{
|
||||||
(e: "select", folderId: string): void;
|
(e: "select", folderId: string): void;
|
||||||
(e: "close"): void;
|
(e: "close"): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const defaultProps = {
|
const cloudTypeApiMap = {
|
||||||
label: "name",
|
|
||||||
children: "children",
|
|
||||||
isLeaf: "leaf",
|
|
||||||
};
|
|
||||||
|
|
||||||
const cloudTypeApiMap = {
|
|
||||||
pan115: cloud115Api,
|
pan115: cloud115Api,
|
||||||
quark: quarkApi,
|
quark: quarkApi,
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadNode = async (node: any, resolve: (list: Folder[]) => void) => {
|
const getList = async (cid: string = "0") => {
|
||||||
const api = cloudTypeApiMap[props.cloudType as keyof typeof cloudTypeApiMap];
|
const api = cloudTypeApiMap[props.cloudType as keyof typeof cloudTypeApiMap];
|
||||||
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
let res: RequestResult<Folder[]> = { code: 0, data: [] as Folder[], message: "" };
|
const res = await api.getFolderList?.(cid);
|
||||||
if (node.level === 0) {
|
|
||||||
if (api.getFolderList) {
|
|
||||||
// 使用类型保护检查方法是否存在
|
|
||||||
res = await api.getFolderList();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (api.getFolderList) {
|
|
||||||
// 使用类型保护检查方法是否存在
|
|
||||||
res = await api.getFolderList(node.data.cid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (res?.code === 0) {
|
if (res?.code === 0) {
|
||||||
resolve(res.data.length ? res.data : []);
|
folders.value = res.data || [];
|
||||||
} else {
|
} else {
|
||||||
throw new Error(res.message);
|
throw new Error(res?.message);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? `${error.message}` : "获取目录失败");
|
ElMessage.error(error instanceof Error ? error.message : "获取目录失败");
|
||||||
// 关闭模态框
|
|
||||||
emit("close");
|
emit("close");
|
||||||
resolve([]);
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNodeClick = (data: Folder) => {
|
const handleFolderClick = async (folder: FolderType) => {
|
||||||
selectedFolder.value = {
|
selectedFolder.value = folder;
|
||||||
...data,
|
folderPath.value = [...folderPath.value, folder];
|
||||||
path: data.path ? [...data.path, data] : [data],
|
emit("select", folder.cid);
|
||||||
};
|
await getList(folder.cid);
|
||||||
emit("select", data.cid);
|
};
|
||||||
};
|
|
||||||
|
const handlePathClick = async (index: number) => {
|
||||||
|
if (index < 0) {
|
||||||
|
// 点击根目录
|
||||||
|
folderPath.value = [{ name: "根目录", cid: "0" }];
|
||||||
|
selectedFolder.value = null;
|
||||||
|
await getList("0");
|
||||||
|
} else {
|
||||||
|
// 点击路径中的某个文件夹
|
||||||
|
const targetFolder = folderPath.value[index];
|
||||||
|
folderPath.value = folderPath.value.slice(0, index + 1);
|
||||||
|
selectedFolder.value = targetFolder;
|
||||||
|
await getList(targetFolder.cid);
|
||||||
|
emit("select", targetFolder.cid);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化加载
|
||||||
|
getList();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style lang="scss" scoped>
|
||||||
.folder-select {
|
@import "@/styles/common.scss";
|
||||||
|
|
||||||
|
.folder-select {
|
||||||
|
position: relative;
|
||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
max-height: 500px;
|
max-height: 500px;
|
||||||
overflow-y: auto;
|
display: flex;
|
||||||
}
|
flex-direction: column;
|
||||||
|
padding: 4px;
|
||||||
|
|
||||||
.folder-node {
|
.folder-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border-radius: var(--theme-radius);
|
||||||
|
|
||||||
|
.folder-path {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
color: var(--theme-text-regular);
|
||||||
|
font-size: 14px;
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
height: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-path {
|
&::-webkit-scrollbar-thumb {
|
||||||
color: #999;
|
background: rgba(0, 0, 0, 0.1);
|
||||||
font-size: 12px;
|
border-radius: 2px;
|
||||||
margin-left: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-tree-node__content) {
|
.el-icon {
|
||||||
height: 32px;
|
flex-shrink: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--theme-primary);
|
||||||
}
|
}
|
||||||
.folder-select-header {
|
|
||||||
|
.path-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
gap: 8px;
|
||||||
margin-bottom: 10px;
|
white-space: nowrap;
|
||||||
font-size: 14px;
|
cursor: pointer;
|
||||||
padding: 5px 10px;
|
transition: var(--theme-transition);
|
||||||
border: 1px solid #e5e6e8;
|
|
||||||
border-radius: 8px;
|
&: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>
|
</style>
|
||||||
|
|||||||
@@ -1,189 +1,578 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="resource-card-list">
|
<div class="resource-card">
|
||||||
<div v-for="group in store.resources" :key="group.id" class="resource-list">
|
<!-- 详情弹窗 -->
|
||||||
<div class="group-header">
|
<el-dialog
|
||||||
<el-link :href="`https://t.me/s/${group.id}`" target="_blank" :underline="false">
|
v-model="showDetail"
|
||||||
<el-image :src="group.channelInfo.channelLogo" class="channel-logo" fit="cover" lazy />
|
:title="currentResource?.title"
|
||||||
{{ group.channelInfo.name }}</el-link
|
width="700px"
|
||||||
|
class="resource-detail-dialog"
|
||||||
|
destroy-on-close
|
||||||
>
|
>
|
||||||
<el-icon class="header-icon" @click="group.displayList = !group.displayList"
|
<div v-if="currentResource" class="detail-content">
|
||||||
><ArrowDown
|
<div class="detail-cover">
|
||||||
/></el-icon>
|
|
||||||
</div>
|
|
||||||
<div class="card-item-list" v-show="group.displayList">
|
|
||||||
<div v-for="resource in group.list" :key="resource.messageId" class="card-item-content">
|
|
||||||
<el-card class="card-item">
|
|
||||||
<el-image
|
<el-image
|
||||||
class="card-item-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)}`"
|
:src="`/tele-images/?url=${encodeURIComponent(resource.image as string)}`"
|
||||||
fit="cover"
|
fit="cover"
|
||||||
lazy
|
lazy
|
||||||
:alt="resource.title"
|
:alt="resource.title"
|
||||||
hide-on-click-modal
|
@click="showResourceDetail(resource)"
|
||||||
:preview-src-list="[
|
|
||||||
`${location.origin}/tele-images/?url=${encodeURIComponent(resource.image as string)}`,
|
|
||||||
]"
|
|
||||||
/>
|
/>
|
||||||
<el-link :href="resource.cloudLinks[0]" target="_blank" :underline="false"
|
|
||||||
><div class="item-name">{{ resource.title }}</div></el-link
|
|
||||||
>
|
|
||||||
<div class="item-description" v-html="resource.content"></div>
|
|
||||||
<div class="tags-list" v-if="resource.tags && resource.tags.length">
|
|
||||||
<span>标签:</span>
|
|
||||||
<el-tag
|
|
||||||
v-for="item in resource.tags"
|
|
||||||
class="resource_tag"
|
|
||||||
:key="item"
|
|
||||||
@click="searchMovieforTag(item)"
|
|
||||||
>
|
|
||||||
{{ item }}
|
|
||||||
</el-tag>
|
|
||||||
</div>
|
|
||||||
<template #footer>
|
|
||||||
<div class="item-footer">
|
|
||||||
<el-tag
|
<el-tag
|
||||||
|
class="cloud-type"
|
||||||
:type="store.tagColor[resource.cloudType as keyof TagColor]"
|
:type="store.tagColor[resource.cloudType as keyof TagColor]"
|
||||||
effect="dark"
|
effect="dark"
|
||||||
round
|
round
|
||||||
|
size="small"
|
||||||
>
|
>
|
||||||
{{ resource.cloudType }}
|
{{ resource.cloudType }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
<el-button @click="handleSave(resource)">转存</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
|
<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>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="load-more">
|
||||||
|
<el-button :loading="group.loading" @click="handleLoadMore(group.id)">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
加载更多
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="load-more" v-show="group.displayList">
|
|
||||||
<el-button @click="handleLoadMore(group.id)"> 加载更多 </el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useResourceStore } from "@/stores/resource";
|
import { useResourceStore } from "@/stores/resource";
|
||||||
import { computed } from "vue";
|
import { ref } from "vue";
|
||||||
import type { ResourceItem, TagColor } from "@/types";
|
import type { ResourceItem, TagColor } from "@/types";
|
||||||
|
import { ArrowDown, Plus } from "@element-plus/icons-vue";
|
||||||
|
|
||||||
const store = useResourceStore();
|
const store = useResourceStore();
|
||||||
|
const showDetail = ref(false);
|
||||||
|
const currentResource = ref<ResourceItem | null>(null);
|
||||||
|
|
||||||
const location = computed(() => window.location);
|
const emit = defineEmits(["save", "loadMore", "searchMovieforTag"]);
|
||||||
|
|
||||||
const emit = defineEmits(["save", "loadMore", "searchMovieforTag"]);
|
const handleSave = (resource: ResourceItem) => {
|
||||||
|
if (showDetail.value) {
|
||||||
const handleSave = (resource: ResourceItem) => {
|
showDetail.value = false;
|
||||||
|
}
|
||||||
emit("save", resource);
|
emit("save", resource);
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchMovieforTag = (tag: string) => {
|
const showResourceDetail = (resource: ResourceItem) => {
|
||||||
|
currentResource.value = resource;
|
||||||
|
showDetail.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchMovieforTag = (tag: string) => {
|
||||||
emit("searchMovieforTag", tag);
|
emit("searchMovieforTag", tag);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoadMore = (channelId: string) => {
|
const handleLoadMore = (channelId: string) => {
|
||||||
emit("loadMore", channelId);
|
emit("loadMore", channelId);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style lang="scss" scoped>
|
||||||
.resource-list {
|
@import "@/styles/common.scss";
|
||||||
margin-bottom: 30px;
|
|
||||||
padding: 20px;
|
.resource-card {
|
||||||
border-radius: 15px;
|
position: relative;
|
||||||
background-color: var(--theme-other_background);
|
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 {
|
&:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 100px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.card-item-list {
|
|
||||||
display: grid;
|
// 组标题
|
||||||
grid-template-columns: repeat(auto-fill, 220px);
|
.group-header {
|
||||||
grid-row-gap: 30px;
|
@include flex-center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-top: 20px;
|
padding: 12px 20px;
|
||||||
/* grid-column-gap: auto-fill; */
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
/* flex-wrap: wrap; */
|
|
||||||
}
|
.group-title {
|
||||||
.card-item-content {
|
@include flex-center;
|
||||||
/* height: 520px; */
|
gap: 12px;
|
||||||
}
|
font-size: 16px;
|
||||||
|
color: var(--theme-text-primary);
|
||||||
|
transition: var(--theme-transition);
|
||||||
|
|
||||||
.channel-logo {
|
.channel-logo {
|
||||||
height: 40px;
|
width: 32px;
|
||||||
width: 40px;
|
height: 32px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--theme-shadow-sm);
|
||||||
margin-right: 10px;
|
|
||||||
}
|
}
|
||||||
.load-more {
|
|
||||||
margin-top: 40px;
|
.item-count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--theme-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--theme-primary);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--theme-text-regular);
|
||||||
|
transition: var(--theme-transition);
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--theme-primary);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组内容
|
||||||
|
.group-content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 卡片网格
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(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%;
|
width: 100%;
|
||||||
text-align: center;
|
height: fit-content;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--theme-shadow);
|
||||||
}
|
}
|
||||||
.card-item {
|
|
||||||
max-width: 480px;
|
.card-wrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 16px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 20px;
|
|
||||||
}
|
}
|
||||||
.card-item-image {
|
|
||||||
border-radius: 20px;
|
.card-cover {
|
||||||
|
position: relative;
|
||||||
|
width: 120px;
|
||||||
|
height: 180px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.cover-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 220px;
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--theme-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
.item-name,
|
}
|
||||||
.item-description {
|
|
||||||
max-width: 100%;
|
.cloud-type {
|
||||||
margin: 15px 0;
|
position: absolute;
|
||||||
-webkit-box-orient: vertical;
|
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;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: all;
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--theme-text-primary);
|
||||||
|
word-break: break-word;
|
||||||
|
height: 3em;
|
||||||
|
transition: var(--theme-transition);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--theme-primary);
|
||||||
}
|
}
|
||||||
.item-description {
|
|
||||||
-webkit-line-clamp: 4;
|
|
||||||
margin-top: 0;
|
|
||||||
height: 100px;
|
|
||||||
}
|
}
|
||||||
.item-name {
|
|
||||||
height: 58px;
|
.card-content {
|
||||||
font-size: 18px;
|
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 {
|
.tags-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
height: 58px;
|
gap: 8px;
|
||||||
|
max-height: 72px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.tag-item {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--theme-transition);
|
||||||
|
margin: 0;
|
||||||
|
height: 24px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--theme-primary);
|
||||||
|
border-color: var(--theme-primary);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
@include flex-center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
|
||||||
|
.el-button {
|
||||||
|
padding: 6px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
height: 32px;
|
||||||
|
min-width: 80px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--theme-shadow-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载更多
|
||||||
|
.load-more {
|
||||||
|
@include flex-center;
|
||||||
|
position: relative;
|
||||||
|
padding: 32px 0 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
var(--el-border-color-lighter) 20%,
|
||||||
|
var(--el-border-color-lighter) 80%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button {
|
||||||
|
min-width: 160px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--theme-text-regular);
|
||||||
|
background: var(--theme-card-bg);
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
transition: var(--theme-transition);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--theme-primary);
|
||||||
|
border-color: var(--theme-primary);
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-loading {
|
||||||
|
color: var(--theme-text-secondary);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
margin-right: 6px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 详情弹窗样式
|
||||||
|
.resource-detail-dialog {
|
||||||
|
:deep(.el-dialog__body) {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-content {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-cover {
|
||||||
|
position: relative;
|
||||||
|
width: 200px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.cover-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
|
border-radius: var(--theme-radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.resource_tag {
|
|
||||||
cursor: pointer;
|
.cloud-type {
|
||||||
margin-right: 10px;
|
position: absolute;
|
||||||
margin-bottom: 5px;
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
.group-header {
|
}
|
||||||
height: 50px;
|
|
||||||
line-height: 50px;
|
.detail-info {
|
||||||
text-align: left;
|
flex: 1;
|
||||||
padding: 0 15px;
|
min-width: 0;
|
||||||
font-size: 20px;
|
|
||||||
|
.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;
|
display: flex;
|
||||||
align-items: center;
|
flex-wrap: wrap;
|
||||||
justify-content: space-between;
|
gap: 8px;
|
||||||
/* text-align: center; */
|
margin-top: 8px;
|
||||||
.el-link {
|
|
||||||
font-size: 22px;
|
.tag-item {
|
||||||
}
|
|
||||||
.header-icon {
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 50px;
|
transition: var(--theme-transition);
|
||||||
height: 50px;
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--theme-primary);
|
||||||
|
border-color: var(--theme-primary);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.item-footer {
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
justify-content: flex-end;
|
||||||
justify-content: space-between;
|
padding-top: 16px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</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>
|
||||||
@@ -13,8 +13,8 @@
|
|||||||
<el-table-column label="图片" width="180">
|
<el-table-column label="图片" width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-image
|
<el-image
|
||||||
class="table-item-image"
|
|
||||||
v-if="row.image"
|
v-if="row.image"
|
||||||
|
class="table-item-image"
|
||||||
:src="`/tele-images/?url=${encodeURIComponent(row.image as string)}`"
|
:src="`/tele-images/?url=${encodeURIComponent(row.image as string)}`"
|
||||||
hide-on-click-modal
|
hide-on-click-modal
|
||||||
:preview-src-list="[
|
:preview-src-list="[
|
||||||
@@ -42,17 +42,17 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="title" label="描述">
|
<el-table-column prop="title" label="描述">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div v-html="row.content" class="item-description"></div>
|
<div class="item-description" v-html="row.content"></div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="tags" label="标签">
|
<el-table-column prop="tags" label="标签">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="tags-list" v-if="row.tags.length > 0">
|
<div v-if="row.tags.length > 0" class="tags-list">
|
||||||
<span>标签:</span>
|
<span>标签:</span>
|
||||||
<el-tag
|
<el-tag
|
||||||
v-for="item in row.tags"
|
v-for="item in row.tags"
|
||||||
class="resource_tag"
|
|
||||||
:key="item"
|
:key="item"
|
||||||
|
class="resource_tag"
|
||||||
@click="searchMovieforTag(item)"
|
@click="searchMovieforTag(item)"
|
||||||
>
|
>
|
||||||
{{ item }}
|
{{ item }}
|
||||||
@@ -61,14 +61,6 @@
|
|||||||
<span v-else>无</span>
|
<span v-else>无</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<!-- <el-table-column label="地址">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-link :href="row.cloudLinks[0]" target="_blank">
|
|
||||||
{{ row.cloudLinks[0] }}
|
|
||||||
</el-link>
|
|
||||||
</template>
|
|
||||||
</el-table-column> -->
|
|
||||||
<el-table-column label="云盘类型" width="120">
|
<el-table-column label="云盘类型" width="120">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="store.tagColor[row.cloudType as keyof TagColor]" effect="dark" round>
|
<el-tag :type="store.tagColor[row.cloudType as keyof TagColor]" effect="dark" round>
|
||||||
@@ -102,96 +94,103 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useResourceStore } from "@/stores/resource";
|
import { useResourceStore } from "@/stores/resource";
|
||||||
import type { Resource, TagColor } from "@/types";
|
import type { Resource, TagColor } from "@/types";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
|
||||||
const store = useResourceStore();
|
const store = useResourceStore();
|
||||||
|
|
||||||
const emit = defineEmits(["save", "loadMore", "searchMovieforTag"]);
|
const emit = defineEmits(["save", "loadMore", "searchMovieforTag"]);
|
||||||
|
|
||||||
const location = computed(() => window.location);
|
const location = computed(() => window.location);
|
||||||
|
|
||||||
const handleSave = (resource: Resource) => {
|
const handleSave = (resource: Resource) => {
|
||||||
emit("save", resource);
|
emit("save", resource);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 添加加载更多处理函数
|
// 添加加载更多处理函数
|
||||||
const handleLoadMore = (channelId: string) => {
|
const handleLoadMore = (channelId: string) => {
|
||||||
emit("loadMore", channelId);
|
emit("loadMore", channelId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchMovieforTag = (tag: string) => {
|
const searchMovieforTag = (tag: string) => {
|
||||||
emit("searchMovieforTag", tag);
|
emit("searchMovieforTag", tag);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.resource-list-table {
|
.resource-list-table {
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-header {
|
.group-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.channel-logo {
|
.channel-logo {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-item-image {
|
.table-item-image {
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 220px;
|
height: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-count {
|
.item-count {
|
||||||
color: #909399;
|
color: #909399;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
.tags-list {
|
.tags-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.resource_tag {
|
.resource_tag {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
.item-description {
|
.item-description {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
margin: 15px 0;
|
margin: 15px 0;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
|
line-clamp: 4;
|
||||||
-webkit-line-clamp: 4;
|
-webkit-line-clamp: 4;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: all;
|
white-space: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-table__expand-column) {
|
:deep(.el-table__expand-column) {
|
||||||
.cell {
|
.cell {
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-table__expanded-cell) {
|
:deep(.el-table__expanded-cell) {
|
||||||
padding: 20px !important;
|
padding: 20px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-table__expand-icon) {
|
:deep(.el-table__expand-icon) {
|
||||||
height: 23px;
|
height: 23px;
|
||||||
line-height: 23px;
|
line-height: 23px;
|
||||||
}
|
}
|
||||||
.load-more {
|
.load-more {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 16px 0;
|
padding: 16px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resource-table {
|
||||||
|
position: relative;
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,130 +1,199 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="search-bar">
|
<div class="pc-search">
|
||||||
<div class="search-bar__input">
|
<!-- 搜索区域 -->
|
||||||
<input
|
<div class="pc-search__input">
|
||||||
|
<el-input
|
||||||
v-model="keyword"
|
v-model="keyword"
|
||||||
type="text"
|
|
||||||
placeholder="请输入搜索关键词或输入链接直接解析"
|
placeholder="请输入搜索关键词或输入链接直接解析"
|
||||||
class="input-with-select"
|
clearable
|
||||||
@keyup.enter="handleSearch"
|
@keyup.enter="handleSearch"
|
||||||
/>
|
|
||||||
<el-icon class="search_icon" size="28px" @click="handleSearch"><Search /></el-icon class="search_icon">
|
|
||||||
</div>
|
|
||||||
<div class="logout" alt="退出登录" @click="logout">
|
|
||||||
<el-tooltip
|
|
||||||
class="box-item"
|
|
||||||
effect="dark"
|
|
||||||
content="退出登录"
|
|
||||||
placement="bottom"
|
|
||||||
>
|
>
|
||||||
<el-icon><SwitchButton class="logout-icon" /></el-icon>
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
<template #suffix>
|
||||||
|
<el-icon v-if="keyword" class="search-icon" @click="handleSearch">
|
||||||
|
<ArrowRight />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户操作区 -->
|
||||||
|
<div class="pc-search__actions">
|
||||||
|
<el-tooltip effect="dark" content="退出登录" placement="bottom">
|
||||||
|
<el-button class="logout-btn" type="text" @click="handleLogout">
|
||||||
|
<el-icon><SwitchButton /></el-icon>
|
||||||
|
</el-button>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref,watch } from "vue";
|
import { ref, computed, watch } from "vue";
|
||||||
import { useResourceStore } from "@/stores/resource";
|
import { useResourceStore } from "@/stores/resource";
|
||||||
import { useRoute,useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
const route = useRoute();
|
import { ElMessage } from "element-plus";
|
||||||
const router = useRouter();
|
import { Search, ArrowRight, SwitchButton } from "@element-plus/icons-vue";
|
||||||
const resourcStore = useResourceStore();
|
import { STORAGE_KEYS } from "@/constants/storage";
|
||||||
const keyword = ref("");
|
|
||||||
const routeKeyword = computed(() => route.query.keyword as string);
|
|
||||||
const logout = () => {
|
|
||||||
localStorage.removeItem("token");
|
|
||||||
router.push({ path: "/login" });
|
|
||||||
};
|
|
||||||
const handleSearch = async () => {
|
|
||||||
// 如果搜索内容是一个https的链接,则尝试解析链接
|
|
||||||
if (keyword.value.startsWith("http")) {
|
|
||||||
resourcStore.parsingCloudLink(keyword.value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!keyword.value.trim()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await resourcStore.searchResources(keyword.value);
|
|
||||||
if(route.path !== '/'){
|
|
||||||
router.push({ path: '/' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(() => routeKeyword.value, () => {
|
// 路由相关
|
||||||
if(routeKeyword.value){
|
const route = useRoute();
|
||||||
keyword.value = routeKeyword.value;
|
const router = useRouter();
|
||||||
|
const resourcStore = useResourceStore();
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
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();
|
handleSearch();
|
||||||
} else {
|
} else {
|
||||||
keyword.value = ''
|
keyword.value = "";
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.search-bar {
|
@import "@/styles/common.scss";
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
.pc-search {
|
||||||
|
@include flex-center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
.logout{
|
gap: 16px;
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 28px;
|
|
||||||
margin-left: 15px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.search-bar__input {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 15px auto;
|
|
||||||
position: relative;
|
// 搜索输入区域
|
||||||
background-color: var(--theme-other_background);
|
&__input {
|
||||||
box-shadow: 0 4px 10px rgba(225, 225, 225, 0.3);
|
flex: 1;
|
||||||
height: 40px;
|
min-width: 0; // 防止溢出
|
||||||
border-radius: 40px;
|
|
||||||
display: flex;
|
:deep(.el-input) {
|
||||||
align-items: center;
|
--el-input-height: 44px;
|
||||||
|
|
||||||
|
.el-input__wrapper {
|
||||||
|
@include glass-effect;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: var(--theme-radius);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.1),
|
||||||
|
0 2px 4px rgba(0, 0, 0, 0.05),
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
transition: var(--theme-transition);
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--theme-primary);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px var(--theme-primary),
|
||||||
|
0 4px 8px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
.input-with-select {
|
|
||||||
width: 100%;
|
&.is-focus {
|
||||||
height: 100%;
|
border-color: var(--theme-primary);
|
||||||
background: none;
|
box-shadow:
|
||||||
padding-left: 24px;
|
inset 0 0 0 1px var(--theme-primary),
|
||||||
box-sizing: border-box;
|
0 4px 8px rgba(0, 0, 0, 0.1),
|
||||||
border-radius: 40px;
|
0 0 0 3px rgba(0, 102, 204, 0.1);
|
||||||
line-height: 100%;
|
background: #fff;
|
||||||
border: unset;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
.search_icon {
|
|
||||||
width: 64px;
|
|
||||||
height: 64px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.search-bar_tips{
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
margin: 20px auto;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-new {
|
.el-input__inner {
|
||||||
margin-top: 20px;
|
font-size: 15px;
|
||||||
display: flex;
|
color: var(--theme-text-primary);
|
||||||
align-items: center;
|
height: 42px;
|
||||||
justify-content: flex-start;
|
line-height: 42px;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--theme-text-secondary);
|
||||||
}
|
}
|
||||||
.switch-source {
|
|
||||||
margin-left: 20px;
|
|
||||||
}
|
}
|
||||||
.switch-source .label {
|
|
||||||
margin-left: 5px;
|
.el-input__prefix-inner {
|
||||||
|
.el-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--theme-text-secondary);
|
||||||
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
.display-style {
|
|
||||||
margin-left: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--theme-primary);
|
||||||
|
transition: var(--theme-transition);
|
||||||
|
margin-left: 8px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 操作区域
|
||||||
|
&__actions {
|
||||||
|
.logout-btn {
|
||||||
|
@include glass-effect;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: var(--theme-radius);
|
||||||
|
transition: var(--theme-transition);
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--theme-text-regular);
|
||||||
|
transition: var(--theme-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--theme-primary);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--theme-shadow-sm);
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</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" {
|
declare module "*.vue" {
|
||||||
import type { DefineComponent } from "vue";
|
import type { DefineComponent } from "vue";
|
||||||
const component: DefineComponent<{}, {}, any>;
|
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>;
|
||||||
export default component;
|
export default component;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,41 @@ import { createApp } from "vue";
|
|||||||
import { createPinia } from "pinia";
|
import { createPinia } from "pinia";
|
||||||
import ElementPlus from "element-plus";
|
import ElementPlus from "element-plus";
|
||||||
import "element-plus/dist/index.css";
|
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 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";
|
import router from "./router/index";
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|
||||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||||
app.component(key, component)
|
app.component(key, component);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(createPinia());
|
app.use(createPinia());
|
||||||
|
app.use(Lazyload);
|
||||||
app.use(router);
|
app.use(router);
|
||||||
app.use(ElementPlus);
|
app.use(ElementPlus);
|
||||||
|
|
||||||
app.mount("#app");
|
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,41 +1,11 @@
|
|||||||
import { createRouter, createWebHistory } from "vue-router";
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
import type { RouteRecordRaw } from "vue-router";
|
import mobileRoutes from "./mobile-routes";
|
||||||
import Login from "@/views/Login.vue";
|
import pcRoutes from "./pc-routes";
|
||||||
import Home from "@/views/Home.vue";
|
import { isMobileDevice } from "@/utils/index";
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
|
||||||
{
|
|
||||||
path: "/",
|
|
||||||
name: "home",
|
|
||||||
component: Home,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: "",
|
|
||||||
name: "resource",
|
|
||||||
component: () => import("@/views/ResourceList.vue"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/douban",
|
|
||||||
name: "douban",
|
|
||||||
component: () => import("@/views/Douban.vue"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/setting",
|
|
||||||
name: "setting",
|
|
||||||
component: () => import("@/views/Setting.vue"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/login",
|
|
||||||
name: "login",
|
|
||||||
component: Login,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes,
|
routes: [...(isMobileDevice() ? mobileRoutes : pcRoutes)],
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
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;
|
||||||
@@ -5,6 +5,7 @@ import { ElMessage } from "element-plus";
|
|||||||
|
|
||||||
interface StoreType {
|
interface StoreType {
|
||||||
hotList: HotListItem[];
|
hotList: HotListItem[];
|
||||||
|
loading: boolean;
|
||||||
currentParams: CurrentParams;
|
currentParams: CurrentParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ interface CurrentParams {
|
|||||||
export const useDoubanStore = defineStore("douban", {
|
export const useDoubanStore = defineStore("douban", {
|
||||||
state: (): StoreType => ({
|
state: (): StoreType => ({
|
||||||
hotList: [],
|
hotList: [],
|
||||||
|
loading: false,
|
||||||
currentParams: {
|
currentParams: {
|
||||||
type: "movie",
|
type: "movie",
|
||||||
tag: "热门",
|
tag: "热门",
|
||||||
@@ -24,6 +26,7 @@ export const useDoubanStore = defineStore("douban", {
|
|||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
async getHotList() {
|
async getHotList() {
|
||||||
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
const params = {
|
const params = {
|
||||||
type: this.currentParams.type,
|
type: this.currentParams.type,
|
||||||
@@ -40,6 +43,8 @@ export const useDoubanStore = defineStore("douban", {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error || "获取热门列表失败");
|
ElMessage.error(error || "获取热门列表失败");
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setCurrentParams(currentParams: CurrentParams) {
|
setCurrentParams(currentParams: CurrentParams) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
ShareInfoResponse,
|
ShareInfoResponse,
|
||||||
Save115FileParams,
|
Save115FileParams,
|
||||||
SaveQuarkFileParams,
|
SaveQuarkFileParams,
|
||||||
|
ShareInfo,
|
||||||
ResourceItem,
|
ResourceItem,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
@@ -23,20 +24,26 @@ const lastResource = (
|
|||||||
) as StorageListObject;
|
) as StorageListObject;
|
||||||
|
|
||||||
// 定义云盘驱动配置类型
|
// 定义云盘驱动配置类型
|
||||||
interface CloudDriveConfig {
|
interface CloudDriveConfig<
|
||||||
|
T extends Record<string, string>,
|
||||||
|
P extends Save115FileParams | SaveQuarkFileParams,
|
||||||
|
> {
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
regex: RegExp;
|
regex: RegExp;
|
||||||
api: {
|
api: {
|
||||||
getShareInfo: (parsedCode: any) => Promise<ShareInfoResponse>;
|
getShareInfo: (parsedCode: T) => Promise<ShareInfoResponse>;
|
||||||
saveFile: (params: Record<string, any>) => Promise<any>;
|
saveFile: (params: P) => Promise<{ code: number; message?: string }>;
|
||||||
};
|
};
|
||||||
parseShareCode: (match: RegExpMatchArray) => Record<string, string>;
|
parseShareCode: (match: RegExpMatchArray) => T;
|
||||||
getSaveParams: (shareInfo: ShareInfoResponse, folderId: string) => Record<string, any>;
|
getSaveParams: (shareInfo: ShareInfoResponse, folderId: string) => P;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 云盘类型配置
|
// 云盘类型配置
|
||||||
export const CLOUD_DRIVES: CloudDriveConfig[] = [
|
export const CLOUD_DRIVES: [
|
||||||
|
CloudDriveConfig<{ shareCode: string; receiveCode: string }, Save115FileParams>,
|
||||||
|
CloudDriveConfig<{ pwdId: string }, SaveQuarkFileParams>,
|
||||||
|
] = [
|
||||||
{
|
{
|
||||||
name: "115网盘",
|
name: "115网盘",
|
||||||
type: "pan115",
|
type: "pan115",
|
||||||
@@ -44,15 +51,17 @@ export const CLOUD_DRIVES: CloudDriveConfig[] = [
|
|||||||
api: {
|
api: {
|
||||||
getShareInfo: (parsedCode: { shareCode: string; receiveCode: string }) =>
|
getShareInfo: (parsedCode: { shareCode: string; receiveCode: string }) =>
|
||||||
cloud115Api.getShareInfo(parsedCode.shareCode, parsedCode.receiveCode),
|
cloud115Api.getShareInfo(parsedCode.shareCode, parsedCode.receiveCode),
|
||||||
saveFile: async (params) => await cloud115Api.saveFile(params as Save115FileParams),
|
saveFile: async (params: Save115FileParams) => {
|
||||||
|
return await cloud115Api.saveFile(params as Save115FileParams);
|
||||||
},
|
},
|
||||||
parseShareCode: (match) => ({
|
},
|
||||||
|
parseShareCode: (match: RegExpMatchArray) => ({
|
||||||
shareCode: match[1],
|
shareCode: match[1],
|
||||||
receiveCode: match[2] || "",
|
receiveCode: match[2] || "",
|
||||||
}),
|
}),
|
||||||
getSaveParams: (shareInfo, folderId) => ({
|
getSaveParams: (shareInfo: ShareInfoResponse, folderId: string) => ({
|
||||||
shareCode: shareInfo.shareCode,
|
shareCode: shareInfo.shareCode || "",
|
||||||
receiveCode: shareInfo.receiveCode,
|
receiveCode: shareInfo.receiveCode || "",
|
||||||
fileId: shareInfo.list[0].fileId,
|
fileId: shareInfo.list[0].fileId,
|
||||||
folderId,
|
folderId,
|
||||||
}),
|
}),
|
||||||
@@ -63,12 +72,16 @@ export const CLOUD_DRIVES: CloudDriveConfig[] = [
|
|||||||
regex: /pan\.quark\.cn\/s\/([a-zA-Z0-9]+)/,
|
regex: /pan\.quark\.cn\/s\/([a-zA-Z0-9]+)/,
|
||||||
api: {
|
api: {
|
||||||
getShareInfo: (parsedCode: { pwdId: string }) => quarkApi.getShareInfo(parsedCode.pwdId),
|
getShareInfo: (parsedCode: { pwdId: string }) => quarkApi.getShareInfo(parsedCode.pwdId),
|
||||||
saveFile: async (params) => await quarkApi.saveFile(params as SaveQuarkFileParams),
|
saveFile: async (params: SaveQuarkFileParams) => {
|
||||||
|
return await quarkApi.saveFile(params as SaveQuarkFileParams);
|
||||||
},
|
},
|
||||||
parseShareCode: (match) => ({ pwdId: match[1] }),
|
},
|
||||||
getSaveParams: (shareInfo, folderId) => ({
|
parseShareCode: (match: RegExpMatchArray) => ({ pwdId: match[1] }),
|
||||||
fid_list: shareInfo.list.map((item) => item.fileId || ""),
|
getSaveParams: (shareInfo: ShareInfoResponse, folderId: string) => ({
|
||||||
fid_token_list: shareInfo.list.map((item) => item.fileIdToken || ""),
|
fid_list: shareInfo.list.map((item: { fileId?: string }) => item.fileId || ""),
|
||||||
|
fid_token_list: shareInfo.list.map(
|
||||||
|
(item: { fileIdToken?: string }) => item.fileIdToken || ""
|
||||||
|
),
|
||||||
to_pdir_fid: folderId,
|
to_pdir_fid: folderId,
|
||||||
pwd_id: shareInfo.pwdId || "",
|
pwd_id: shareInfo.pwdId || "",
|
||||||
stoken: shareInfo.stoken || "",
|
stoken: shareInfo.stoken || "",
|
||||||
@@ -89,13 +102,18 @@ export const useResourceStore = defineStore("resource", {
|
|||||||
},
|
},
|
||||||
resources: lastResource.list,
|
resources: lastResource.list,
|
||||||
lastUpdateTime: lastResource.lastUpdateTime || "",
|
lastUpdateTime: lastResource.lastUpdateTime || "",
|
||||||
selectedResources: [] as Resource[],
|
shareInfo: {} as ShareInfoResponse,
|
||||||
|
resourceSelect: [] as ShareInfo[],
|
||||||
loading: false,
|
loading: false,
|
||||||
lastKeyWord: "",
|
lastKeyWord: "",
|
||||||
backupPlan: false,
|
backupPlan: false,
|
||||||
|
loadTree: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
|
setLoadTree(loadTree: boolean) {
|
||||||
|
this.loadTree = loadTree;
|
||||||
|
},
|
||||||
// 搜索资源
|
// 搜索资源
|
||||||
async searchResources(keyword?: string, isLoadMore = false, channelId?: string): Promise<void> {
|
async searchResources(keyword?: string, isLoadMore = false, channelId?: string): Promise<void> {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
@@ -141,6 +159,11 @@ export const useResourceStore = defineStore("resource", {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 设置选择资源
|
||||||
|
async setSelectedResource(resourceSelect: ShareInfo[]) {
|
||||||
|
this.resourceSelect = resourceSelect;
|
||||||
|
},
|
||||||
|
|
||||||
// 转存资源
|
// 转存资源
|
||||||
async saveResource(resource: ResourceItem, folderId: string): Promise<void> {
|
async saveResource(resource: ResourceItem, folderId: string): Promise<void> {
|
||||||
const savePromises: Promise<void>[] = [];
|
const savePromises: Promise<void>[] = [];
|
||||||
@@ -156,7 +179,9 @@ export const useResourceStore = defineStore("resource", {
|
|||||||
async saveResourceToDrive(
|
async saveResourceToDrive(
|
||||||
resource: ResourceItem,
|
resource: ResourceItem,
|
||||||
folderId: string,
|
folderId: string,
|
||||||
drive: CloudDriveConfig
|
drive:
|
||||||
|
| CloudDriveConfig<{ shareCode: string; receiveCode: string }, Save115FileParams>
|
||||||
|
| CloudDriveConfig<{ pwdId: string }, SaveQuarkFileParams>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const link = resource.cloudLinks.find((link) => drive.regex.test(link));
|
const link = resource.cloudLinks.find((link) => drive.regex.test(link));
|
||||||
if (!link) return;
|
if (!link) return;
|
||||||
@@ -164,22 +189,12 @@ export const useResourceStore = defineStore("resource", {
|
|||||||
const match = link.match(drive.regex);
|
const match = link.match(drive.regex);
|
||||||
if (!match) throw new Error("链接解析失败");
|
if (!match) throw new Error("链接解析失败");
|
||||||
|
|
||||||
const parsedCode = drive.parseShareCode(match);
|
const shareInfo = {
|
||||||
|
...this.shareInfo,
|
||||||
|
list: this.resourceSelect.filter((x) => x.isChecked),
|
||||||
|
};
|
||||||
|
|
||||||
let shareInfo = await drive.api.getShareInfo(parsedCode);
|
if (this.is115Drive(drive)) {
|
||||||
if (shareInfo) {
|
|
||||||
if (Array.isArray(shareInfo)) {
|
|
||||||
shareInfo = {
|
|
||||||
list: shareInfo,
|
|
||||||
...parsedCode,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
shareInfo = {
|
|
||||||
...shareInfo,
|
|
||||||
...parsedCode,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const params = drive.getSaveParams(shareInfo, folderId);
|
const params = drive.getSaveParams(shareInfo, folderId);
|
||||||
const result = await drive.api.saveFile(params);
|
const result = await drive.api.saveFile(params);
|
||||||
|
|
||||||
@@ -188,6 +203,16 @@ export const useResourceStore = defineStore("resource", {
|
|||||||
} else {
|
} else {
|
||||||
ElMessage.error(result.message);
|
ElMessage.error(result.message);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
const params = drive.getSaveParams(shareInfo, folderId);
|
||||||
|
const result = await drive.api.saveFile(params);
|
||||||
|
|
||||||
|
if (result.code === 0) {
|
||||||
|
ElMessage.success(`${drive.name} 转存成功`);
|
||||||
|
} else {
|
||||||
|
ElMessage.error(result.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 解析云盘链接
|
// 解析云盘链接
|
||||||
@@ -202,7 +227,12 @@ export const useResourceStore = defineStore("resource", {
|
|||||||
if (!match) throw new Error("链接解析失败");
|
if (!match) throw new Error("链接解析失败");
|
||||||
|
|
||||||
const parsedCode = matchedDrive.parseShareCode(match);
|
const parsedCode = matchedDrive.parseShareCode(match);
|
||||||
let shareInfo = await matchedDrive.api.getShareInfo(parsedCode);
|
let shareInfo = this.is115Drive(matchedDrive)
|
||||||
|
? await matchedDrive.api.getShareInfo(
|
||||||
|
parsedCode as { shareCode: string; receiveCode: string }
|
||||||
|
)
|
||||||
|
: await matchedDrive.api.getShareInfo(parsedCode as { pwdId: string });
|
||||||
|
|
||||||
if (Array.isArray(shareInfo)) {
|
if (Array.isArray(shareInfo)) {
|
||||||
shareInfo = {
|
shareInfo = {
|
||||||
list: shareInfo,
|
list: shareInfo,
|
||||||
@@ -241,10 +271,66 @@ export const useResourceStore = defineStore("resource", {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 获取资源列表并选择
|
||||||
|
async getResourceListAndSelect(resource: ResourceItem): Promise<boolean> {
|
||||||
|
this.setSelectedResource([]);
|
||||||
|
const { cloudType } = resource;
|
||||||
|
const drive = CLOUD_DRIVES.find((x) => x.type === cloudType);
|
||||||
|
if (!drive) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const link = resource.cloudLinks.find((link) => drive.regex.test(link));
|
||||||
|
if (!link) return false;
|
||||||
|
|
||||||
|
const match = link.match(drive.regex);
|
||||||
|
if (!match) throw new Error("链接解析失败");
|
||||||
|
|
||||||
|
const parsedCode = drive.parseShareCode(match);
|
||||||
|
let shareInfo = {} as ShareInfoResponse;
|
||||||
|
this.setLoadTree(true);
|
||||||
|
if (this.is115Drive(drive)) {
|
||||||
|
shareInfo = await drive.api.getShareInfo(
|
||||||
|
parsedCode as { shareCode: string; receiveCode: string }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
shareInfo = this.is115Drive(drive)
|
||||||
|
? await drive.api.getShareInfo(parsedCode as { shareCode: string; receiveCode: string })
|
||||||
|
: await drive.api.getShareInfo(parsedCode as { pwdId: string });
|
||||||
|
}
|
||||||
|
this.setLoadTree(false);
|
||||||
|
if (shareInfo) {
|
||||||
|
if (Array.isArray(shareInfo)) {
|
||||||
|
shareInfo = {
|
||||||
|
list: shareInfo,
|
||||||
|
...parsedCode,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
shareInfo = {
|
||||||
|
...shareInfo,
|
||||||
|
...parsedCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.shareInfo = shareInfo;
|
||||||
|
this.setSelectedResource(this.shareInfo.list.map((x) => ({ ...x, isChecked: true })));
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
ElMessage.error("获取资源信息失败,请先检查cookie!");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// 统一错误处理
|
// 统一错误处理
|
||||||
handleError(message: string, error: unknown): void {
|
handleError(message: string, error: unknown): void {
|
||||||
console.error(message, error);
|
console.error(message, error);
|
||||||
ElMessage.error(error instanceof Error ? error.message : message);
|
ElMessage.error(error instanceof Error ? error.message : message);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
is115Drive(
|
||||||
|
drive:
|
||||||
|
| CloudDriveConfig<{ shareCode: string; receiveCode: string }, Save115FileParams>
|
||||||
|
| CloudDriveConfig<{ pwdId: string }, SaveQuarkFileParams>
|
||||||
|
): drive is CloudDriveConfig<{ shareCode: string; receiveCode: string }, Save115FileParams> {
|
||||||
|
return drive.type === "pan115";
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import type { UserSettingStore } from "@/types/user";
|
import type {
|
||||||
|
UserSettingStore,
|
||||||
|
GlobalSettingAttributes,
|
||||||
|
UserSettingAttributes,
|
||||||
|
} from "@/types/user";
|
||||||
import { settingApi } from "@/api/setting";
|
import { settingApi } from "@/api/setting";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
|
|
||||||
@@ -21,19 +25,20 @@ export const useUserSettingStore = defineStore("user", {
|
|||||||
this.userSettings = data.userSettings;
|
this.userSettings = data.userSettings;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async saveSettings() {
|
|
||||||
if (this.userSettings) {
|
async saveSettings(settings: {
|
||||||
const setting: UserSettingStore = {
|
globalSetting?: GlobalSettingAttributes | null;
|
||||||
userSettings: this.userSettings,
|
userSettings: UserSettingAttributes;
|
||||||
};
|
}) {
|
||||||
if (this.globalSetting) setting.globalSetting = this.globalSetting;
|
try {
|
||||||
const res = await settingApi.saveSetting(setting);
|
await settingApi.saveSetting(settings);
|
||||||
if (res) {
|
await this.getSettings();
|
||||||
this.getSettings();
|
} catch (error) {
|
||||||
ElMessage.success("保存成功");
|
console.log(error);
|
||||||
}
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setDisplayStyle(style: "table" | "card") {
|
setDisplayStyle(style: "table" | "card") {
|
||||||
this.displayStyle = style;
|
this.displayStyle = style;
|
||||||
ElMessage.success(`切换成功,当前为${style}模式`);
|
ElMessage.success(`切换成功,当前为${style}模式`);
|
||||||
|
|||||||
62
frontend/src/styles/common.scss
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// 颜色系统
|
||||||
|
:root {
|
||||||
|
// 主题色
|
||||||
|
--theme-primary: #0066cc;
|
||||||
|
--theme-primary-hover: #0256ac;
|
||||||
|
--theme-success: #28cd41;
|
||||||
|
--theme-warning: #ff9f0a;
|
||||||
|
--theme-error: #ff3b30;
|
||||||
|
|
||||||
|
// 中性色
|
||||||
|
--theme-bg: #f5f7fa;
|
||||||
|
--theme-card-bg: rgba(255, 255, 255, 0.8);
|
||||||
|
--theme-text-primary: #000000;
|
||||||
|
--theme-text-regular: #424242;
|
||||||
|
--theme-text-secondary: #6e6e6e;
|
||||||
|
|
||||||
|
// 特效
|
||||||
|
--theme-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
--theme-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||||
|
--theme-shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
|
||||||
|
// 圆角
|
||||||
|
--theme-radius-sm: 8px;
|
||||||
|
--theme-radius: 12px;
|
||||||
|
--theme-radius-lg: 16px;
|
||||||
|
|
||||||
|
// 模糊效果
|
||||||
|
--theme-blur: blur(12px);
|
||||||
|
|
||||||
|
// 动画
|
||||||
|
--theme-transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mixins
|
||||||
|
@mixin glass-effect {
|
||||||
|
background: var(--theme-card-bg);
|
||||||
|
backdrop-filter: var(--theme-blur);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin flex-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin text-overflow {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用动画
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
46
frontend/src/styles/mobile.scss
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/* 移动端通用样式类 */
|
||||||
|
.mobile-page {
|
||||||
|
padding: var(--spacing-base);
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: var(--theme-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-card {
|
||||||
|
background: var(--theme-other_background);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
padding: var(--spacing-base);
|
||||||
|
margin-bottom: var(--spacing-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-title {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: var(--spacing-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-text {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--theme-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-button {
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-form {
|
||||||
|
.van-field {
|
||||||
|
padding: var(--spacing-base);
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__control {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
77
frontend/src/styles/responsive.scss
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
@mixin text-ellipsis {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式布局工具类
|
||||||
|
@mixin mobile {
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin tablet {
|
||||||
|
@media screen and (min-width: 769px) and (max-width: 1024px) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin desktop {
|
||||||
|
@media screen and (min-width: 1025px) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用样式变量
|
||||||
|
:root {
|
||||||
|
// 字体大小 - 整体缩小约25%
|
||||||
|
--font-size-xs: 20px; // 原24px
|
||||||
|
--font-size-sm: 22px; // 原26px
|
||||||
|
--font-size-base: 24px; // 原28px
|
||||||
|
--font-size-lg: 28px; // 原32px
|
||||||
|
--font-size-xl: 32px; // 原36px
|
||||||
|
|
||||||
|
// 间距 - 也相应缩小
|
||||||
|
--spacing-xs: 6px; // 原8px
|
||||||
|
--spacing-sm: 10px; // 原12px
|
||||||
|
--spacing-base: 14px; // 原16px
|
||||||
|
--spacing-lg: 20px; // 原24px
|
||||||
|
--spacing-xl: 28px; // 原32px
|
||||||
|
|
||||||
|
// 圆角 - 适当调整
|
||||||
|
--border-radius-sm: 6px; // 原8px
|
||||||
|
--border-radius-base: 10px; // 原12px
|
||||||
|
--border-radius-lg: 14px; // 原16px
|
||||||
|
--border-radius-xl: 20px; // 原24px
|
||||||
|
|
||||||
|
// 移动端特殊变量
|
||||||
|
@include mobile {
|
||||||
|
--font-size-base: 14px;
|
||||||
|
--spacing-base: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端适配
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
:root {
|
||||||
|
// 间距
|
||||||
|
--spacing-xs: 3px;
|
||||||
|
--spacing-sm: 5px;
|
||||||
|
--spacing-base: 7px;
|
||||||
|
--spacing-lg: 10px;
|
||||||
|
--spacing-xl: 14px;
|
||||||
|
|
||||||
|
// 字体大小
|
||||||
|
--font-size-xs: 10px;
|
||||||
|
--font-size-sm: 11px;
|
||||||
|
--font-size-base: 12px;
|
||||||
|
--font-size-lg: 14px;
|
||||||
|
--font-size-xl: 16px;
|
||||||
|
|
||||||
|
// 圆角
|
||||||
|
--border-radius-sm: 3px;
|
||||||
|
--border-radius-base: 5px;
|
||||||
|
--border-radius-lg: 7px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ export interface ResourceItem {
|
|||||||
export interface Resource {
|
export interface Resource {
|
||||||
list: ResourceItem[];
|
list: ResourceItem[];
|
||||||
displayList?: boolean;
|
displayList?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
channelInfo: {
|
channelInfo: {
|
||||||
channelId: string;
|
channelId: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -26,8 +27,9 @@ export interface Resource {
|
|||||||
export interface ShareInfo {
|
export interface ShareInfo {
|
||||||
fileId: string;
|
fileId: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
fileSize: number;
|
fileSize?: number;
|
||||||
fileIdToken?: string;
|
fileIdToken?: string;
|
||||||
|
isChecked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShareInfoResponse {
|
export interface ShareInfoResponse {
|
||||||
@@ -36,6 +38,7 @@ export interface ShareInfoResponse {
|
|||||||
stoken?: string;
|
stoken?: string;
|
||||||
shareCode?: string;
|
shareCode?: string;
|
||||||
receiveCode?: string;
|
receiveCode?: string;
|
||||||
|
fileSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Folder {
|
export interface Folder {
|
||||||
@@ -51,7 +54,7 @@ export interface SaveFileParams {
|
|||||||
folderId: string;
|
folderId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiResponse<T = any> {
|
export interface ApiResponse<T = unknown> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: T;
|
data?: T;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import { GlobalSettingAttributes, UserSettingAttributes } from "@/types";
|
export interface GlobalSettingAttributes {
|
||||||
export interface UserSettingStore {
|
httpProxyHost: string;
|
||||||
globalSetting?: GlobalSettingAttributes | null;
|
httpProxyPort: string | number;
|
||||||
userSettings: UserSettingAttributes;
|
isProxyEnabled: boolean;
|
||||||
displayStyle?: "table" | "card";
|
AdminUserCode: number;
|
||||||
|
CommonUserCode: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserSettingAttributes {
|
||||||
|
cloud115Cookie: string;
|
||||||
|
quarkCookie: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserSettingStore {
|
||||||
|
globalSetting: GlobalSettingAttributes | null;
|
||||||
|
userSettings: UserSettingAttributes;
|
||||||
|
displayStyle: "table" | "card";
|
||||||
}
|
}
|
||||||
|
|||||||
10
frontend/src/utils/device.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export const isMobile = () => {
|
||||||
|
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isTablet = () => {
|
||||||
|
const userAgent = navigator.userAgent.toLowerCase();
|
||||||
|
return /(ipad|tablet|(android(?!.*mobile))|(windows(?!.*phone)(.*touch))|kindle|playbook|silk|(puffin(?!.*(IP|AP|WP))))/.test(
|
||||||
|
userAgent
|
||||||
|
);
|
||||||
|
};
|
||||||
29
frontend/src/utils/index.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export const formattedFileSize = (size: number): string => {
|
||||||
|
if (size < 1024 * 1024) {
|
||||||
|
return `${(size / 1024).toFixed(2)}KB`;
|
||||||
|
}
|
||||||
|
if (size < 1024 * 1024 * 1024) {
|
||||||
|
return `${(size / 1024 / 1024).toFixed(2)}MB`;
|
||||||
|
}
|
||||||
|
return `${(size / 1024 / 1024 / 1024).toFixed(2)}GB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isMobileDevice() {
|
||||||
|
return (
|
||||||
|
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
|
||||||
|
window.innerWidth <= 768
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function throttle<T extends (...args: any[]) => any>(fn: T, delay: number): T {
|
||||||
|
let lastTime = 0;
|
||||||
|
|
||||||
|
return function (this: any, ...args: Parameters<T>) {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (now - lastTime >= delay) {
|
||||||
|
fn.apply(this, args);
|
||||||
|
lastTime = now;
|
||||||
|
}
|
||||||
|
} as T;
|
||||||
|
}
|
||||||
@@ -1,10 +1,25 @@
|
|||||||
import axios, { AxiosResponse } from "axios";
|
import axios, { AxiosResponse, AxiosRequestConfig } from "axios";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
|
import { isMobileDevice } from "@/utils/index";
|
||||||
|
import { showNotify } from "vant";
|
||||||
import { RequestResult } from "../types/response";
|
import { RequestResult } from "../types/response";
|
||||||
|
import { STORAGE_KEYS } from "@/constants/storage";
|
||||||
|
|
||||||
|
const errorMessage = (message: string) => {
|
||||||
|
if (isMobileDevice()) {
|
||||||
|
console.log(message);
|
||||||
|
showNotify({
|
||||||
|
type: "danger",
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ElMessage.error(message);
|
||||||
|
};
|
||||||
|
|
||||||
const axiosInstance = axios.create({
|
const axiosInstance = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_BASE_URL as string,
|
baseURL: import.meta.env.VITE_API_BASE_URL as string,
|
||||||
timeout: 9000,
|
timeout: 16000,
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -17,11 +32,11 @@ function isLoginAndRedirect(url: string) {
|
|||||||
|
|
||||||
axiosInstance.interceptors.request.use(
|
axiosInstance.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem(STORAGE_KEYS.TOKEN);
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
} else if (!isLoginAndRedirect(config.url || "")) {
|
} else if (!isLoginAndRedirect(config.url || "")) {
|
||||||
ElMessage.error("请先登录");
|
errorMessage("请先登录");
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
@@ -38,24 +53,28 @@ axiosInstance.interceptors.response.use(
|
|||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response.status === 401) {
|
if (error.response.status === 401) {
|
||||||
ElMessage.error("登录过期,请重新登录");
|
errorMessage("登录过期,请重新登录");
|
||||||
localStorage.removeItem("token");
|
localStorage.removeItem(STORAGE_KEYS.TOKEN);
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
return Promise.reject(new Error("登录过期,请重新登录"));
|
return Promise.reject(new Error("登录过期,请重新登录"));
|
||||||
}
|
}
|
||||||
ElMessage.error(error.response.statusText);
|
errorMessage(error.response.statusText);
|
||||||
return Promise.reject(new Error(error.response.statusText));
|
return Promise.reject(new Error(error.response.statusText));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const request = {
|
const request = {
|
||||||
get: <T>(
|
get: <T>(url: string, config?: AxiosRequestConfig): Promise<RequestResult<T>> => {
|
||||||
url: string,
|
|
||||||
config?: Record<string, any>
|
|
||||||
): Promise<RequestResult<T>> => {
|
|
||||||
return axiosInstance.get(url, { ...config });
|
return axiosInstance.get(url, { ...config });
|
||||||
},
|
},
|
||||||
post: axiosInstance.post,
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
post: <T, D = any>(
|
||||||
|
url: string,
|
||||||
|
data: D,
|
||||||
|
config?: AxiosRequestConfig
|
||||||
|
): Promise<RequestResult<T>> => {
|
||||||
|
return axiosInstance.post(url, data, { ...config });
|
||||||
|
},
|
||||||
put: axiosInstance.put,
|
put: axiosInstance.put,
|
||||||
delete: axiosInstance.delete,
|
delete: axiosInstance.delete,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="douban-page">
|
||||||
<div class="movie-wall">
|
<div class="movie-wall">
|
||||||
<div v-for="movie in doubanStore.hotList" :key="movie.id" class="movie-item">
|
<div v-for="movie in doubanStore.hotList" :key="movie.id" class="movie-item">
|
||||||
<div class="movie-poster">
|
<div class="movie-poster">
|
||||||
@@ -27,123 +28,169 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, watch } from "vue";
|
import { computed, watch } from "vue";
|
||||||
import { useRouter, useRoute } from "vue-router";
|
import { useRouter, useRoute } from "vue-router";
|
||||||
import { useDoubanStore } from "@/stores/douban";
|
import { useDoubanStore } from "@/stores/douban";
|
||||||
interface CurrentParams {
|
interface CurrentParams {
|
||||||
type: string;
|
type: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
}
|
}
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const routeParams = computed(
|
const routeParams = computed((): CurrentParams => ({ ...route.query }) as unknown as CurrentParams);
|
||||||
(): CurrentParams => ({ ...route.query }) as unknown as CurrentParams
|
const doubanStore = useDoubanStore();
|
||||||
);
|
if (routeParams.value) {
|
||||||
const doubanStore = useDoubanStore();
|
|
||||||
if (routeParams.value) {
|
|
||||||
doubanStore.setCurrentParams(routeParams.value);
|
doubanStore.setCurrentParams(routeParams.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => routeParams.value,
|
() => routeParams.value,
|
||||||
() => {
|
() => {
|
||||||
console.log(routeParams.value);
|
console.log(routeParams.value);
|
||||||
doubanStore.setCurrentParams(routeParams.value);
|
doubanStore.setCurrentParams(routeParams.value);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const searchMovie = (title: string) => {
|
const searchMovie = (title: string) => {
|
||||||
router.push({ path: "/", query: { keyword: title } });
|
router.push({ path: "/", query: { keyword: title } });
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style lang="scss" scoped>
|
||||||
.movie-wall {
|
@import "@/styles/common.scss";
|
||||||
|
@import "@/styles/responsive.scss";
|
||||||
|
|
||||||
|
.douban-page {
|
||||||
|
height: calc(100vh - 180px);
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.movie-wall {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, 200px);
|
grid-template-columns: repeat(auto-fill, 200px);
|
||||||
grid-row-gap: 15px;
|
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
gap: 20px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.movie-item {
|
.movie-item {
|
||||||
width: 200px; /* 设置固定宽度 */
|
width: 200px;
|
||||||
overflow: hidden; /* 确保内容不会超出卡片 */
|
background: var(--theme-card-bg);
|
||||||
text-align: center;
|
border-radius: var(--theme-radius);
|
||||||
background-color: #f9f9f9; /* 可选:设置背景颜色 */
|
box-shadow: var(--theme-shadow);
|
||||||
box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.12); /* 可选:设置阴影效果 */
|
|
||||||
border-radius: 15px; /* 设置图片圆角 */
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding-bottom: 0px;
|
padding: 12px;
|
||||||
position: relative;
|
transition: var(--theme-transition);
|
||||||
padding: 15px;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.movie-poster-img {
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--theme-shadow-lg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.movie-poster-img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 220px;
|
height: 220px;
|
||||||
object-fit: cover; /* 确保图片使用cover模式 */
|
object-fit: cover;
|
||||||
border-radius: 15px; /* 设置图片圆角 */
|
border-radius: var(--theme-radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.movie-info {
|
|
||||||
/* margin-top: 8px; */
|
.movie-info {
|
||||||
|
padding: 12px 0 4px;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
.movie-title {
|
.movie-title {
|
||||||
|
display: block;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
padding: 10px 0;
|
color: var(--theme-text-primary);
|
||||||
|
transition: var(--theme-transition);
|
||||||
|
@include text-ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
|
line-height: 1.2;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--theme-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.movie-poster {
|
}
|
||||||
|
|
||||||
|
.movie-poster {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 220px;
|
height: 220px;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 15px;
|
border-radius: var(--theme-radius);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.movie-poster-hover {
|
.movie-poster-hover {
|
||||||
opacity: 0; /* 默认情况下隐藏 */
|
opacity: 0;
|
||||||
transition: opacity 0.3s ease; /* 添加过渡效果 */
|
transition: opacity 0.3s ease;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
/* height: 100%; */
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
.movie-poster:hover .movie-poster-hover {
|
.movie-poster:hover .movie-poster-hover {
|
||||||
opacity: 1; /* 鼠标移入时显示 */
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.movie-rate {
|
.movie-rate {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
background-color: rgba(88, 83, 250, 0.8);
|
background: var(--theme-primary);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 0px 8px;
|
padding: 0px 8px;
|
||||||
border-radius: 5px;
|
border-radius: var(--theme-radius-sm);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.movie-search {
|
|
||||||
|
.movie-search {
|
||||||
color: white;
|
color: white;
|
||||||
border-radius: 5px;
|
border-radius: var(--theme-radius);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,93 +1,196 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="home" v-loading="resourcStore.loading" element-loading-background="rgba(0,0,0,0.6)">
|
<div class="pc-home" :class="{ 'is-loading': resourcStore.loading }">
|
||||||
<el-container>
|
<!-- 主布局容器 -->
|
||||||
<el-aside width="200px"><aside-menu /></el-aside>
|
<el-container class="pc-home__container">
|
||||||
<el-container class="home-main">
|
<!-- 侧边栏 -->
|
||||||
<el-header :class="{ 'home-header': true, 'search-bar-active': !store.scrollTop }">
|
<el-aside width="220px" class="pc-home__aside">
|
||||||
|
<aside-menu />
|
||||||
|
</el-aside>
|
||||||
|
|
||||||
|
<!-- 主内容区 -->
|
||||||
|
<el-container class="pc-home__main">
|
||||||
|
<!-- 顶部搜索栏 -->
|
||||||
|
<el-header class="pc-home__header" :class="{ 'is-scrolled': !store.scrollTop }">
|
||||||
<search-bar />
|
<search-bar />
|
||||||
</el-header>
|
</el-header>
|
||||||
<el-main class="home-main-main">
|
|
||||||
<router-view />
|
<!-- 内容区域 -->
|
||||||
|
<el-main class="pc-home__content">
|
||||||
|
<div class="content-wrapper">
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<transition name="fade" mode="out-in">
|
||||||
|
<component :is="Component" />
|
||||||
|
</transition>
|
||||||
|
</router-view>
|
||||||
|
</div>
|
||||||
</el-main>
|
</el-main>
|
||||||
<!-- <el-aside class="home-aside"></el-aside> -->
|
|
||||||
</el-container>
|
</el-container>
|
||||||
</el-container>
|
</el-container>
|
||||||
<el-backtop :bottom="100">
|
|
||||||
<div
|
<!-- 全局加载 -->
|
||||||
style="
|
<div v-if="resourcStore.loading" class="pc-home__loading">
|
||||||
height: 100%;
|
<el-icon class="is-loading"><Loading /></el-icon>
|
||||||
width: 100%;
|
<span class="loading-text">加载中...</span>
|
||||||
background-color: var(--el-bg-color-overlay);
|
|
||||||
box-shadow: var(--el-box-shadow-lighter);
|
|
||||||
text-align: center;
|
|
||||||
line-height: 40px;
|
|
||||||
color: #1989fa;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
UP
|
|
||||||
</div>
|
</div>
|
||||||
</el-backtop>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- <login v-else /> -->
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useResourceStore } from "@/stores/resource";
|
import { onMounted, onUnmounted } from "vue";
|
||||||
import { useStore } from "@/stores/index";
|
import { useResourceStore } from "@/stores/resource";
|
||||||
import { useUserSettingStore } from "@/stores/userSetting";
|
import { useStore } from "@/stores/index";
|
||||||
import { onUnmounted } from "vue";
|
import { useUserSettingStore } from "@/stores/userSetting";
|
||||||
import AsideMenu from "@/components/AsideMenu.vue";
|
import { throttle } from "@/utils/index";
|
||||||
import SearchBar from "@/components/SearchBar.vue";
|
import { Loading } from "@element-plus/icons-vue";
|
||||||
|
import "element-plus/es/components/loading/style/css";
|
||||||
|
import AsideMenu from "@/components/AsideMenu.vue";
|
||||||
|
import SearchBar from "@/components/SearchBar.vue";
|
||||||
|
|
||||||
const resourcStore = useResourceStore();
|
// 状态管理
|
||||||
const store = useStore();
|
const resourcStore = useResourceStore();
|
||||||
const settingStore = useUserSettingStore();
|
const store = useStore();
|
||||||
|
const settingStore = useUserSettingStore();
|
||||||
|
|
||||||
|
// 初始化设置
|
||||||
|
onMounted(() => {
|
||||||
settingStore.getSettings();
|
settingStore.getSettings();
|
||||||
const handleScroll = () => {
|
|
||||||
const scrollTop = window.scrollY;
|
|
||||||
if (scrollTop > 50) {
|
|
||||||
store.scrollTop && store.setScrollTop(false);
|
|
||||||
} else {
|
|
||||||
!store.scrollTop && store.setScrollTop(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("scroll", handleScroll);
|
window.addEventListener("scroll", handleScroll);
|
||||||
onUnmounted(() => {
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
window.removeEventListener("scroll", handleScroll);
|
window.removeEventListener("scroll", handleScroll);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 滚动处理
|
||||||
|
const handleScroll = throttle(() => {
|
||||||
|
const scrollTop = window.scrollY;
|
||||||
|
store.setScrollTop(scrollTop <= 50);
|
||||||
|
}, 100);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.home {
|
@import "@/styles/common.scss";
|
||||||
// padding: 20px;
|
|
||||||
min-width: 1000px;
|
.pc-home {
|
||||||
|
position: relative;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
background: var(--theme-bg);
|
||||||
margin: 0 auto;
|
color: var(--theme-text-primary);
|
||||||
|
|
||||||
|
// 主容器
|
||||||
|
&__container {
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
.home-header {
|
|
||||||
height: auto;
|
// 侧边栏
|
||||||
|
&__aside {
|
||||||
|
background: var(--theme-card-bg);
|
||||||
|
backdrop-filter: var(--theme-blur);
|
||||||
|
border-right: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: var(--theme-transition);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--theme-shadow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主内容区
|
||||||
|
&__main {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顶部搜索栏
|
||||||
|
&__header {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
// padding: 0;
|
height: auto;
|
||||||
background-color: rgba(231, 235, 239, 0.7) !important;
|
padding: 16px;
|
||||||
|
background: var(--theme-card-bg);
|
||||||
|
backdrop-filter: var(--theme-blur);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
transition: var(--theme-transition);
|
||||||
|
|
||||||
|
&.is-scrolled {
|
||||||
|
padding: 12px;
|
||||||
|
box-shadow: var(--theme-shadow-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内容区域
|
||||||
|
&__content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px;
|
||||||
|
height: 0;
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
&__loading {
|
||||||
|
@include flex-center;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
-webkit-backdrop-filter: blur(18px);
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
color: var(--theme-text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-loading {
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--theme-primary);
|
||||||
|
animation: rotating 2s linear infinite;
|
||||||
|
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载动画
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
backdrop-filter: blur(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
-webkit-backdrop-filter: blur(8px);
|
|
||||||
border-radius: 0 0 5px 5px;
|
|
||||||
// background-color: var(--theme-other_background);
|
|
||||||
// box-shadow: 0 4px 10px rgba(225, 225, 225, 0.3);
|
|
||||||
// border-radius: 20px;
|
|
||||||
}
|
}
|
||||||
.home-main {
|
}
|
||||||
width: 1000px;
|
|
||||||
height: 100vh;
|
// 路由过渡动画
|
||||||
overflow: auto;
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotating {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
}
|
}
|
||||||
.home-main-main {
|
to {
|
||||||
padding: 10px 15px;
|
transform: rotate(360deg);
|
||||||
}
|
|
||||||
.home-aside {
|
|
||||||
width: 300px;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,259 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="login-register">
|
|
||||||
<div class="login-bg"></div>
|
|
||||||
<el-card class="card">
|
|
||||||
<!-- 登录与注册切换 -->
|
|
||||||
<el-tabs v-model="activeTab" class="tabs">
|
|
||||||
<el-tab-pane label="登录" name="login"></el-tab-pane>
|
|
||||||
<el-tab-pane label="注册" name="register"> </el-tab-pane>
|
|
||||||
</el-tabs>
|
|
||||||
|
|
||||||
<!-- 登录表单 -->
|
|
||||||
<el-form
|
|
||||||
v-if="activeTab === 'login'"
|
|
||||||
:model="loginForm"
|
|
||||||
:rules="loginRules"
|
|
||||||
ref="loginFormRef"
|
|
||||||
label-position="top"
|
|
||||||
>
|
|
||||||
<el-form-item prop="username" class="form-item">
|
|
||||||
<el-input
|
|
||||||
v-model="loginForm.username"
|
|
||||||
placeholder="用户名"
|
|
||||||
name="username"
|
|
||||||
autocomplete="on"
|
|
||||||
class="form-input"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item prop="password" class="form-item">
|
|
||||||
<el-input
|
|
||||||
v-model="loginForm.password"
|
|
||||||
type="password"
|
|
||||||
placeholder="密码"
|
|
||||||
class="form-input"
|
|
||||||
show-password="true"
|
|
||||||
autocomplete="on"
|
|
||||||
name="password"
|
|
||||||
@keyup.enter="handleLogin"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-button type="primary" class="form-submit" @click="loginFormRefValidate">
|
|
||||||
登录
|
|
||||||
</el-button>
|
|
||||||
</el-form>
|
|
||||||
|
|
||||||
<!-- 注册表单 -->
|
|
||||||
<el-form
|
|
||||||
v-if="activeTab === 'register'"
|
|
||||||
:model="registerForm"
|
|
||||||
:rules="registerRules"
|
|
||||||
ref="registerFormRef"
|
|
||||||
label-position="top"
|
|
||||||
><el-form-item prop="username" class="form-item">
|
|
||||||
<el-input v-model="registerForm.username" placeholder="用户名" class="form-input" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item prop="password" class="form-item">
|
|
||||||
<el-input
|
|
||||||
v-model="registerForm.password"
|
|
||||||
type="password"
|
|
||||||
placeholder="密码"
|
|
||||||
class="form-input"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item prop="password" class="form-item">
|
|
||||||
<el-input
|
|
||||||
v-model="password2"
|
|
||||||
type="password"
|
|
||||||
placeholder="再次输入密码"
|
|
||||||
class="form-input"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item prop="registerCode" class="form-item">
|
|
||||||
<el-input v-model="registerForm.registerCode" placeholder="注册码" class="form-input" />
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-button type="primary" class="form-submit" @click="handleRegister"> 注册 </el-button>
|
|
||||||
</el-form>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup type="ts">
|
|
||||||
import { ref } from "vue";
|
|
||||||
import { userApi } from "@/api/user";
|
|
||||||
import router from "@/router";
|
|
||||||
|
|
||||||
const activeTab = ref("login"); // 默认显示登录表单
|
|
||||||
|
|
||||||
const loginForm = ref({
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
});
|
|
||||||
const registerForm = ref({
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
registerCode: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const password2 = ref("");
|
|
||||||
|
|
||||||
const loginRules = {
|
|
||||||
username: [{ required: true, message: "请输入用户名", trigger: "blur" }],
|
|
||||||
password: [{ required: true, message: "请输入密码", trigger: "blur" }],
|
|
||||||
};
|
|
||||||
|
|
||||||
const registerRules = {
|
|
||||||
username: [{ required: true, message: "请输入用户名", trigger: "blur" }],
|
|
||||||
password: [{ required: true, message: "请输入密码", trigger: "blur" }],
|
|
||||||
registerCode: [{ required: true, message: "请输入注册码", trigger: "blur" }],
|
|
||||||
};
|
|
||||||
|
|
||||||
const loginFormRef = ref(null);
|
|
||||||
const registerFormRef = ref(null);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
try {
|
|
||||||
const res = await userApi.login(loginForm.value);
|
|
||||||
if (res.code === 0) {
|
|
||||||
const { token } = res.data;
|
|
||||||
localStorage.setItem("token", token);
|
|
||||||
// 路由跳转首页
|
|
||||||
router.push("/");
|
|
||||||
} else {
|
|
||||||
ElMessage.error(res.message);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error("登录失败", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const loginFormRefValidate = () => {
|
|
||||||
loginFormRef.value.validate((valid) => {
|
|
||||||
if (valid) {
|
|
||||||
handleLogin();
|
|
||||||
} else {
|
|
||||||
ElMessage.error("登录表单验证失败");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRegister = async () => {
|
|
||||||
registerFormRef.value.validate(async (valid) => {
|
|
||||||
if (valid) {
|
|
||||||
if(password2.value !== registerForm.value.password){
|
|
||||||
return ElMessage.error("两次输入的密码不一致");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await userApi.register(registerForm.value);
|
|
||||||
if (res.code === 0) {
|
|
||||||
ElMessage.success("注册成功");
|
|
||||||
loginForm.value.username = registerForm.value.username
|
|
||||||
loginForm.value.password = registerForm.value.password
|
|
||||||
handleLogin()
|
|
||||||
} else {
|
|
||||||
ElMessage.error(res.message || "注册失败");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(error.message || "注册失败");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error("注册表单验证失败");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.login-register {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.login-bg {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-image: url("../assets/images/login-bg.jpg");
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center center;
|
|
||||||
-webkit-filter: blur(3px);
|
|
||||||
-moz-filter: blur(3px);
|
|
||||||
-o-filter: blur(3px);
|
|
||||||
-ms-filter: blur(3px);
|
|
||||||
filter: blur(3px);
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
|
||||||
width: 480px;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: rgba(255, 255, 255, 0.8);
|
|
||||||
box-shadow: 0px 20px 60px rgba(123, 61, 224, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-button {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-item {
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
.form-input {
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.options {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forgot-password {
|
|
||||||
color: #6366f1;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-submit {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
background-color: #6366f1;
|
|
||||||
width: 100%;
|
|
||||||
height: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.google-login {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agreement {
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-agreement {
|
|
||||||
color: #6366f1;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,41 +1,81 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="resource-list">
|
<div class="pc-resources">
|
||||||
<div :class="{ 'resource-list__header': true }">
|
<!-- 头部工具栏 -->
|
||||||
<div class="header_left">
|
<div class="pc-resources__header">
|
||||||
<div class="refresh_btn" @click="refreshResources">
|
<div class="header__left">
|
||||||
<el-icon class="type_icon" size="20px"><Refresh /></el-icon>最新资源<span
|
<el-tooltip effect="dark" content="点击获取最新资源" placement="bottom">
|
||||||
class="item-count"
|
<el-button class="refresh-btn" type="text" @click="refreshResources">
|
||||||
>(上次刷新时间:{{ resourceStore.lastUpdateTime }})</span
|
<el-icon><Refresh /></el-icon>
|
||||||
|
<span>最新资源</span>
|
||||||
|
<span class="update-time"> (上次刷新时间:{{ resourceStore.lastUpdateTime }}) </span>
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header__right">
|
||||||
|
<el-tooltip
|
||||||
|
effect="dark"
|
||||||
|
:content="userStore.displayStyle === 'card' ? '切换到列表视图' : '切换到卡片视图'"
|
||||||
|
placement="bottom"
|
||||||
>
|
>
|
||||||
|
<el-button
|
||||||
|
type="text"
|
||||||
|
class="view-toggle"
|
||||||
|
@click="setDisplayStyle(userStore.displayStyle === 'card' ? 'table' : 'card')"
|
||||||
|
>
|
||||||
|
<el-icon>
|
||||||
|
<component :is="userStore.displayStyle === 'card' ? 'Menu' : 'Grid'" />
|
||||||
|
</el-icon>
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header_right">
|
|
||||||
<el-icon
|
<!-- 资源列表 -->
|
||||||
class="type_icon"
|
<div ref="contentRef" class="pc-resources__content">
|
||||||
v-if="userStore.displayStyle === 'card'"
|
<component
|
||||||
@click="setDisplayStyle('table')"
|
:is="userStore.displayStyle === 'table' ? ResourceTable : ResourceCard"
|
||||||
><Menu
|
v-if="resourceStore.resources.length > 0"
|
||||||
/></el-icon>
|
@load-more="handleLoadMore"
|
||||||
<el-icon v-else class="type_icon" @click="setDisplayStyle('card')"><Fold /></el-icon>
|
@search-moviefor-tag="searchMovieforTag"
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ResourceTable
|
|
||||||
@loadMore="handleLoadMore"
|
|
||||||
@searchMovieforTag="searchMovieforTag"
|
|
||||||
@save="handleSave"
|
@save="handleSave"
|
||||||
v-if="userStore.displayStyle === 'table'"
|
/>
|
||||||
></ResourceTable>
|
|
||||||
<ResourceCard
|
<!-- 空状态 -->
|
||||||
@loadMore="handleLoadMore"
|
<div v-if="resourceStore.resources.length === 0" class="pc-resources__empty">
|
||||||
@searchMovieforTag="searchMovieforTag"
|
<el-empty :image-size="200">
|
||||||
@save="handleSave"
|
<template #description>
|
||||||
v-else
|
<p class="empty-text">暂无资源</p>
|
||||||
></ResourceCard>
|
<el-tooltip effect="dark" content="点击获取最新资源" placement="top">
|
||||||
<el-empty v-if="resourceStore.resources.length === 0" :image-size="200" />
|
<el-button type="primary" @click="refreshResources">
|
||||||
<el-dialog v-model="folderDialogVisible" title="选择保存目录" v-if="currentResource">
|
<el-icon><Refresh /></el-icon>
|
||||||
|
<span>刷新资源</span>
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-empty>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 返回顶部 -->
|
||||||
|
<el-backtop :bottom="40" :right="40" target=".pc-resources__content">
|
||||||
|
<div class="pc-resources__backtop">
|
||||||
|
<el-icon><ArrowUp /></el-icon>
|
||||||
|
</div>
|
||||||
|
</el-backtop>
|
||||||
|
|
||||||
|
<!-- 保存对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-if="currentResource"
|
||||||
|
v-model="saveDialogVisible"
|
||||||
|
:title="saveDialogMap[saveDialogStep].title"
|
||||||
|
width="580px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
<template #header="{ titleId }">
|
<template #header="{ titleId }">
|
||||||
<div class="my-header">
|
<div class="dialog-header">
|
||||||
<div :id="titleId">
|
<h3 :id="titleId">
|
||||||
|
<div class="title-main">
|
||||||
<el-tag
|
<el-tag
|
||||||
:type="resourceStore.tagColor[currentResource.cloudType as keyof TagColor]"
|
:type="resourceStore.tagColor[currentResource.cloudType as keyof TagColor]"
|
||||||
effect="dark"
|
effect="dark"
|
||||||
@@ -43,144 +83,435 @@
|
|||||||
>
|
>
|
||||||
{{ currentResource.cloudType }}
|
{{ currentResource.cloudType }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
选择保存目录
|
{{ saveDialogMap[saveDialogStep].title }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="title-sub">
|
||||||
|
<span class="resource-title" :title="currentResource.title">
|
||||||
|
{{ currentResource.title }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="resourceStore.shareInfo.fileSize && saveDialogStep === 1"
|
||||||
|
class="file-size"
|
||||||
|
>
|
||||||
|
({{ formattedFileSize(resourceStore.shareInfo.fileSize) }})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<folder-select
|
|
||||||
v-if="folderDialogVisible"
|
<div v-loading="resourceStore.loadTree">
|
||||||
@select="handleFolderSelect"
|
<resource-select
|
||||||
@close="folderDialogVisible = false"
|
v-if="saveDialogVisible && saveDialogStep === 1 && resourceStore.resourceSelect.length"
|
||||||
:cloudType="currentResource.cloudType"
|
:cloud-type="currentResource.cloudType"
|
||||||
|
/>
|
||||||
|
<folder-select
|
||||||
|
v-if="saveDialogVisible && saveDialogStep === 2"
|
||||||
|
:cloud-type="currentResource.cloudType"
|
||||||
|
@select="handleFolderSelect"
|
||||||
|
@close="saveDialogVisible = false"
|
||||||
/>
|
/>
|
||||||
<div class="dialog-footer">
|
|
||||||
<el-button @click="folderDialogVisible = false">取消</el-button>
|
|
||||||
<el-button type="primary" @click="handleSaveBtnClick">保存</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button @click="saveDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleConfirmClick">
|
||||||
|
{{ saveDialogMap[saveDialogStep].buttonText }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="resourceStore.loading" class="pc-resources__loading">
|
||||||
|
<div class="loading-text">加载中...</div>
|
||||||
|
<div class="is-loading">
|
||||||
|
<el-icon><Loading /></el-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { useResourceStore } from "@/stores/resource";
|
import { useResourceStore } from "@/stores/resource";
|
||||||
import { useUserSettingStore } from "@/stores/userSetting";
|
import { useUserSettingStore } from "@/stores/userSetting";
|
||||||
import FolderSelect from "@/components/Home/FolderSelect.vue";
|
import FolderSelect from "@/components/Home/FolderSelect.vue";
|
||||||
import ResourceTable from "@/components/Home/ResourceTable.vue";
|
import ResourceSelect from "@/components/Home/ResourceSelect.vue";
|
||||||
import type { ResourceItem, TagColor } from "@/types";
|
import ResourceTable from "@/components/Home/ResourceTable.vue";
|
||||||
import ResourceCard from "@/components/Home/ResourceCard.vue";
|
import { formattedFileSize } from "@/utils/index";
|
||||||
import { useRouter } from "vue-router";
|
import type { ResourceItem, TagColor } from "@/types";
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const resourceStore = useResourceStore();
|
import ResourceCard from "@/components/Home/ResourceCard.vue";
|
||||||
const userStore = useUserSettingStore();
|
import { useRouter } from "vue-router";
|
||||||
const folderDialogVisible = ref(false);
|
import { ElMessage } from "element-plus";
|
||||||
const currentResource = ref<ResourceItem | null>(null);
|
import { ArrowUp } from "@element-plus/icons-vue";
|
||||||
const currentFolderId = ref<string | null>(null);
|
const router = useRouter();
|
||||||
|
|
||||||
const refreshResources = async () => {
|
const resourceStore = useResourceStore();
|
||||||
|
const userStore = useUserSettingStore();
|
||||||
|
const saveDialogVisible = ref(false);
|
||||||
|
const currentResource = ref<ResourceItem | null>(null);
|
||||||
|
const currentFolderId = ref<string | null>(null);
|
||||||
|
const saveDialogStep = ref<1 | 2>(1);
|
||||||
|
|
||||||
|
const refreshResources = async () => {
|
||||||
resourceStore.searchResources("", false);
|
resourceStore.searchResources("", false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = (resource: ResourceItem) => {
|
const saveDialogMap = {
|
||||||
|
1: {
|
||||||
|
title: "选择资源",
|
||||||
|
buttonText: "下一步",
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
title: "选择保存目录",
|
||||||
|
buttonText: "保存到此目录",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (resource: ResourceItem) => {
|
||||||
currentResource.value = resource;
|
currentResource.value = resource;
|
||||||
folderDialogVisible.value = true;
|
saveDialogVisible.value = true;
|
||||||
};
|
saveDialogStep.value = 1;
|
||||||
|
if (!(await resourceStore.getResourceListAndSelect(currentResource.value))) {
|
||||||
|
saveDialogVisible.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleFolderSelect = async (folderId: string) => {
|
const handleFolderSelect = async (folderId: string) => {
|
||||||
if (!currentResource.value) return;
|
if (!currentResource.value) return;
|
||||||
currentFolderId.value = folderId;
|
currentFolderId.value = folderId;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveBtnClick = async () => {
|
const handleConfirmClick = async () => {
|
||||||
|
if (saveDialogStep.value === 1) {
|
||||||
|
const selectedFiles = resourceStore.resourceSelect.filter((x) => x.isChecked);
|
||||||
|
if (selectedFiles.length === 0) {
|
||||||
|
ElMessage.warning("请选择要保存的资源");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveDialogStep.value = 2;
|
||||||
|
} else {
|
||||||
|
handleSaveBtnClick();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveBtnClick = async () => {
|
||||||
if (!currentResource.value || !currentFolderId.value) return;
|
if (!currentResource.value || !currentFolderId.value) return;
|
||||||
folderDialogVisible.value = false;
|
saveDialogVisible.value = false;
|
||||||
await resourceStore.saveResource(currentResource.value, currentFolderId.value);
|
await resourceStore.saveResource(currentResource.value, currentFolderId.value);
|
||||||
};
|
};
|
||||||
const setDisplayStyle = (style: string) => {
|
const setDisplayStyle = (style: string) => {
|
||||||
console.log(userStore);
|
|
||||||
userStore.setDisplayStyle(style as "card" | "table");
|
userStore.setDisplayStyle(style as "card" | "table");
|
||||||
};
|
};
|
||||||
// 添加加载更多处理函数
|
// 添加加载更多处理函数
|
||||||
const handleLoadMore = (channelId: string) => {
|
const handleLoadMore = (channelId: string) => {
|
||||||
resourceStore.searchResources("", true, channelId);
|
resourceStore.searchResources("", true, channelId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchMovieforTag = (tag: string) => {
|
const searchMovieforTag = (tag: string) => {
|
||||||
console.log("iiii");
|
|
||||||
router.push({ path: "/", query: { keyword: tag } });
|
router.push({ path: "/", query: { keyword: tag } });
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style lang="scss" scoped>
|
||||||
.resource-list {
|
@import "@/styles/common.scss";
|
||||||
/* margin-top: 20px; */
|
@import "@/styles/responsive.scss";
|
||||||
|
|
||||||
|
.pc-resources {
|
||||||
|
// 整体容器
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
width: 100%;
|
||||||
.resource-list__header {
|
|
||||||
height: 48px;
|
// 头部工具栏
|
||||||
background-color: var(--theme-other_background);
|
&__header {
|
||||||
|
@include glass-effect;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 10px;
|
min-height: 48px;
|
||||||
box-sizing: border-box;
|
padding: 0 20px;
|
||||||
border-radius: 15px;
|
margin-bottom: 16px;
|
||||||
padding: 0 15px;
|
border-radius: var(--theme-radius);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
transition: var(--theme-transition);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--theme-primary);
|
||||||
|
box-shadow: var(--theme-shadow-sm);
|
||||||
}
|
}
|
||||||
.header_right {
|
|
||||||
cursor: pointer;
|
.header__left {
|
||||||
}
|
|
||||||
.type_icon {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
size: 48px;
|
|
||||||
}
|
|
||||||
.refresh_btn {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
flex-wrap: wrap;
|
||||||
cursor: pointer;
|
gap: 12px;
|
||||||
.item-count {
|
padding: 8px 0;
|
||||||
color: #909399;
|
|
||||||
font-size: 0.9em;
|
.refresh-btn {
|
||||||
|
@include flex-center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--theme-text-regular);
|
||||||
|
transition: var(--theme-transition);
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--theme-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-time {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--theme-text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--theme-primary);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-footer {
|
.header__right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
|
||||||
|
.view-toggle {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--theme-text-regular);
|
||||||
|
border-radius: var(--theme-radius);
|
||||||
|
transition: var(--theme-transition);
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-header {
|
&:hover {
|
||||||
|
color: var(--theme-primary);
|
||||||
|
background: rgba(0, 102, 204, 0.05);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内容区域
|
||||||
|
&__content {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100vh - 180px);
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
// 资源列表组件样式覆盖
|
||||||
|
:deep(.resource-table),
|
||||||
|
:deep(.resource-card) {
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
// 自定义滚动条
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
&__loading {
|
||||||
|
@include glass-effect;
|
||||||
|
@include flex-center;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
color: var(--theme-text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-loading {
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--theme-primary);
|
||||||
|
animation: rotating 2s linear infinite;
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空状态
|
||||||
|
&__empty {
|
||||||
|
@include flex-center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
color: var(--theme-text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 8px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button {
|
||||||
|
@include flex-center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: var(--theme-transition);
|
||||||
|
background: var(--theme-primary);
|
||||||
|
border-color: var(--theme-primary);
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--theme-shadow-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回顶部按钮
|
||||||
|
&__backtop {
|
||||||
|
@include flex-center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: var(--theme-primary);
|
||||||
|
background: var(--theme-card-bg);
|
||||||
|
border-radius: var(--theme-radius);
|
||||||
|
box-shadow: var(--theme-shadow);
|
||||||
|
transition: var(--theme-transition);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--theme-primary);
|
||||||
|
color: #fff;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对话框样式
|
||||||
|
.dialog-header {
|
||||||
|
h3 {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-primary);
|
||||||
|
|
||||||
|
.title-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-count {
|
.title-sub {
|
||||||
color: #909399;
|
display: flex;
|
||||||
font-size: 0.9em;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--theme-text-secondary);
|
||||||
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-table__expand-column) {
|
.resource-title {
|
||||||
|
max-width: 300px;
|
||||||
|
@include text-ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
color: var(--theme-text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-dialog) {
|
||||||
|
border-radius: var(--theme-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.el-dialog__header {
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-dialog__body {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-dialog__footer {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格扩展列样式
|
||||||
|
:deep(.el-table) {
|
||||||
|
.el-table__expand-column {
|
||||||
.cell {
|
.cell {
|
||||||
padding: 0 !important;
|
padding: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-table__expanded-cell) {
|
.el-table__expanded-cell {
|
||||||
padding: 20px !important;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-table__expand-icon) {
|
.el-table__expand-icon {
|
||||||
height: 23px;
|
height: 23px;
|
||||||
line-height: 23px;
|
line-height: 23px;
|
||||||
}
|
}
|
||||||
.load-more {
|
}
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
// 加载动画
|
||||||
padding: 16px 0;
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
backdrop-filter: blur(0);
|
||||||
}
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,162 +1,458 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="settings">
|
<div class="settings-page">
|
||||||
<el-card class="setting-card" v-if="settingStore.globalSetting">
|
<!-- 项目配置卡片 -->
|
||||||
<h2>网络配置</h2>
|
<el-card v-if="settingStore.globalSetting" class="settings-card network-card">
|
||||||
<div class="section">
|
<template #header>
|
||||||
<div class="form-group">
|
<div class="card-header">
|
||||||
<label for="proxyDomain">代理域名:</label>
|
<el-icon><Connection /></el-icon>
|
||||||
|
<h2>项目配置</h2>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<!-- 代理配置组 -->
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="group-header">
|
||||||
|
<h3>代理设置</h3>
|
||||||
|
<el-switch
|
||||||
|
v-model="localGlobalSetting.isProxyEnabled"
|
||||||
|
active-text="已启用"
|
||||||
|
@change="handleProxyChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-item">
|
||||||
|
<label for="proxyDomain">代理服务器IP</label>
|
||||||
<el-input
|
<el-input
|
||||||
class="form-input"
|
|
||||||
type="text"
|
|
||||||
id="proxyDomain"
|
id="proxyDomain"
|
||||||
|
v-model="localGlobalSetting.httpProxyHost"
|
||||||
placeholder="127.0.0.1"
|
placeholder="127.0.0.1"
|
||||||
v-model="settingStore.globalSetting.httpProxyHost"
|
:disabled="!localGlobalSetting.isProxyEnabled"
|
||||||
/>
|
@input="handleProxyHostChange"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Monitor /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label for="proxyPort">代理端口:</label>
|
<div class="form-item">
|
||||||
|
<label for="proxyPort">代理端口</label>
|
||||||
<el-input
|
<el-input
|
||||||
class="form-input"
|
|
||||||
type="text"
|
|
||||||
id="proxyPort"
|
id="proxyPort"
|
||||||
|
v-model="localGlobalSetting.httpProxyPort"
|
||||||
placeholder="7890"
|
placeholder="7890"
|
||||||
v-model="settingStore.globalSetting.httpProxyPort"
|
:disabled="!localGlobalSetting.isProxyEnabled"
|
||||||
/>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Position /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
</div>
|
||||||
<label for="AdminUserCode">管理员注册码:</label>
|
</div>
|
||||||
|
|
||||||
|
<!-- 注册码配置组 -->
|
||||||
|
<div class="settings-group">
|
||||||
|
<h3>注册码设置</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-item">
|
||||||
|
<label for="AdminUserCode">管理员注册码</label>
|
||||||
<el-input-number
|
<el-input-number
|
||||||
class="form-input"
|
|
||||||
type="text"
|
|
||||||
id="AdminUserCode"
|
id="AdminUserCode"
|
||||||
|
v-model="localGlobalSetting.AdminUserCode"
|
||||||
:controls="false"
|
:controls="false"
|
||||||
:precision="0"
|
:precision="0"
|
||||||
placeholder="设置管理员注册码"
|
placeholder="设置管理员注册码"
|
||||||
v-model="settingStore.globalSetting.AdminUserCode"
|
>
|
||||||
/>
|
<template #prefix>
|
||||||
|
<el-icon><Key /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input-number>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label for="CommonUserCode">普通用户注册码:</label>
|
<div class="form-item">
|
||||||
|
<label for="CommonUserCode">普通用户注册码</label>
|
||||||
<el-input-number
|
<el-input-number
|
||||||
class="form-input"
|
|
||||||
type="text"
|
|
||||||
:precision="0"
|
|
||||||
:controls="false"
|
|
||||||
id="CommonUserCode"
|
id="CommonUserCode"
|
||||||
|
v-model="localGlobalSetting.CommonUserCode"
|
||||||
|
:controls="false"
|
||||||
|
:precision="0"
|
||||||
placeholder="设置普通用户注册码"
|
placeholder="设置普通用户注册码"
|
||||||
v-model="settingStore.globalSetting.CommonUserCode"
|
>
|
||||||
/>
|
<template #prefix>
|
||||||
|
<el-icon><Key /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input-number>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="section">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="isProxyEnabled">启用代理:</label>
|
|
||||||
<el-switch v-model="settingStore.globalSetting.isProxyEnabled" @change="saveSettings" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
<el-card class="setting-card">
|
|
||||||
|
<!-- 用户配置卡片 -->
|
||||||
|
<el-card class="settings-card user-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
<h2>用户配置</h2>
|
<h2>用户配置</h2>
|
||||||
<div class="section">
|
</div>
|
||||||
<div class="form-group">
|
</template>
|
||||||
<label for="cookie115">115网盘Cookie:</label>
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-group">
|
||||||
|
<h3>网盘授权</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-item full-width">
|
||||||
|
<label for="cookie115">115网盘 Cookie</label>
|
||||||
<el-input
|
<el-input
|
||||||
class="form-input"
|
|
||||||
type="text"
|
|
||||||
id="cookie115"
|
id="cookie115"
|
||||||
v-model="settingStore.userSettings.cloud115Cookie"
|
v-model="localUserSettings.cloud115Cookie"
|
||||||
/>
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="请输入115网盘Cookie"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Lock /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
</div>
|
||||||
<label for="cookieQuark">夸克网盘Cookie:</label>
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-item full-width">
|
||||||
|
<label for="cookieQuark">夸克网盘 Cookie</label>
|
||||||
<el-input
|
<el-input
|
||||||
class="form-input"
|
|
||||||
type="text"
|
|
||||||
id="cookieQuark"
|
id="cookieQuark"
|
||||||
v-model="settingStore.userSettings.quarkCookie"
|
v-model="localUserSettings.quarkCookie"
|
||||||
/>
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="请输入夸克网盘Cookie"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Lock /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-setting-tips">
|
</div>
|
||||||
<h3>帮助</h3>
|
|
||||||
<div>
|
<!-- 帮助链接 -->
|
||||||
|
<div class="settings-help">
|
||||||
|
<h3>帮助文档</h3>
|
||||||
|
<div class="help-links">
|
||||||
<el-link
|
<el-link
|
||||||
|
href="https://www.yuque.com/xiaoruihenbangde/ggogn3/ga6gaaiy5fsyw62l?singleDoc=true"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href="https://alist.nn.ci/zh/guide/drivers/115.html#cookie%E8%8E%B7%E5%8F%96%E6%96%B9%E5%BC%8F"
|
type="primary"
|
||||||
>如何获取115网盘cookie?</el-link
|
|
||||||
>
|
>
|
||||||
|
<el-icon><QuestionFilled /></el-icon>
|
||||||
|
CloudSaver部署与使用常见问题
|
||||||
|
</el-link>
|
||||||
|
<el-link
|
||||||
|
href="https://www.yuque.com/xiaoruihenbangde/ggogn3/cl2g0p9h3xrgfa5i"
|
||||||
|
target="_blank"
|
||||||
|
type="primary"
|
||||||
|
>
|
||||||
|
<el-icon><QuestionFilled /></el-icon>
|
||||||
|
CloudSaver功能介绍
|
||||||
|
</el-link>
|
||||||
|
<el-link
|
||||||
|
href="https://alist.nn.ci/zh/guide/drivers/115.html#cookie获取方式"
|
||||||
|
target="_blank"
|
||||||
|
type="primary"
|
||||||
|
>
|
||||||
|
<el-icon><QuestionFilled /></el-icon>
|
||||||
|
如何获取115网盘Cookie?
|
||||||
|
</el-link>
|
||||||
|
<el-link
|
||||||
|
href="https://alist.nn.ci/zh/guide/drivers/quark.html#cookie"
|
||||||
|
target="_blank"
|
||||||
|
type="primary"
|
||||||
|
>
|
||||||
|
<el-icon><QuestionFilled /></el-icon>
|
||||||
|
如何获取夸克网盘Cookie?
|
||||||
|
</el-link>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<el-link target="_blank" href="https://alist.nn.ci/zh/guide/drivers/quark.html#cookie"
|
|
||||||
>如何获取夸克网盘cookie?</el-link
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
<el-button @click="saveSettings">保存设置</el-button>
|
|
||||||
|
<!-- 保存按钮 -->
|
||||||
|
<div class="settings-actions">
|
||||||
|
<el-button type="primary" @click="handleSave"> 保存设置 </el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useUserSettingStore } from "@/stores/userSetting";
|
import { useUserSettingStore } from "@/stores/userSetting";
|
||||||
const settingStore = useUserSettingStore();
|
import { ref, watch } from "vue";
|
||||||
settingStore.getSettings();
|
import { ElMessage } from "element-plus";
|
||||||
|
import type { GlobalSettingAttributes, UserSettingAttributes } from "@/types/user";
|
||||||
|
import {
|
||||||
|
Connection,
|
||||||
|
Monitor,
|
||||||
|
Position,
|
||||||
|
Key,
|
||||||
|
User,
|
||||||
|
Lock,
|
||||||
|
QuestionFilled,
|
||||||
|
} from "@element-plus/icons-vue";
|
||||||
|
|
||||||
const saveSettings = () => {
|
const settingStore = useUserSettingStore();
|
||||||
settingStore.saveSettings();
|
|
||||||
// Add your save logic here
|
// 本地状态
|
||||||
};
|
const localGlobalSetting = ref<GlobalSettingAttributes>({
|
||||||
|
httpProxyHost: "127.0.0.1",
|
||||||
|
httpProxyPort: "7890",
|
||||||
|
isProxyEnabled: false,
|
||||||
|
AdminUserCode: 230713,
|
||||||
|
CommonUserCode: 9527,
|
||||||
|
});
|
||||||
|
|
||||||
|
const localUserSettings = ref<UserSettingAttributes>({
|
||||||
|
cloud115Cookie: "",
|
||||||
|
quarkCookie: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听 store 变化,更新本地状态
|
||||||
|
watch(
|
||||||
|
() => settingStore.globalSetting,
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
localGlobalSetting.value = { ...newVal };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => settingStore.userSettings,
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
localUserSettings.value = { ...newVal };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 初始化获取设置
|
||||||
|
settingStore.getSettings();
|
||||||
|
|
||||||
|
// 处理代理开关变化并立即保存
|
||||||
|
const handleProxyChange = async (val: boolean) => {
|
||||||
|
try {
|
||||||
|
localGlobalSetting.value.isProxyEnabled = val;
|
||||||
|
await settingStore.saveSettings({
|
||||||
|
globalSetting: localGlobalSetting.value,
|
||||||
|
userSettings: localUserSettings.value,
|
||||||
|
});
|
||||||
|
ElMessage.success("设置保存成功");
|
||||||
|
} catch (error) {
|
||||||
|
// 保存失败时恢复开关状态
|
||||||
|
ElMessage.error("设置保存失败");
|
||||||
|
localGlobalSetting.value.isProxyEnabled = !val;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理代理地址,去除协议前缀
|
||||||
|
const handleProxyHostChange = (val: string) => {
|
||||||
|
// 移除 http:// 或 https:// 前缀
|
||||||
|
const cleanHost = val.replace(/^(https?:\/\/)/i, "");
|
||||||
|
// 更新状态
|
||||||
|
localGlobalSetting.value.httpProxyHost = cleanHost;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 其他设置的保存
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
await settingStore.saveSettings({
|
||||||
|
globalSetting: localGlobalSetting.value,
|
||||||
|
userSettings: localUserSettings.value,
|
||||||
|
});
|
||||||
|
ElMessage.success("设置保存成功");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("保存设置失败:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.settings {
|
@import "@/styles/common.scss";
|
||||||
|
|
||||||
|
.settings-page {
|
||||||
|
// max-width: 1000px;
|
||||||
|
margin: 0;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-radius: var(--theme-radius);
|
||||||
|
transition: var(--theme-transition);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--theme-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-card__header) {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
@include flex-center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--theme-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
.setting-card {
|
|
||||||
margin-bottom: 20px;
|
.settings-group {
|
||||||
border-radius: 15px;
|
margin-bottom: 32px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
h3 {
|
||||||
margin-bottom: 20px;
|
margin: 0 0 16px;
|
||||||
display: flex;
|
font-size: 14px;
|
||||||
align-items: center;
|
font-weight: 600;
|
||||||
|
color: var(--theme-text-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header {
|
||||||
|
@include flex-center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
flex-wrap: wrap;
|
margin-bottom: 16px;
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
h3 {
|
||||||
margin-bottom: 10px;
|
margin: 0;
|
||||||
width: 48%;
|
|
||||||
}
|
}
|
||||||
.form-input {
|
}
|
||||||
text-align: left;
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-item {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
&.full-width {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
::v-deep .el-input__inner {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--theme-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
:deep(.el-input),
|
||||||
|
:deep(.el-input-number) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px;
|
|
||||||
box-sizing: border-box;
|
.el-input__wrapper {
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: var(--theme-transition);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 0 0 1px var(--theme-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
&.is-focus {
|
||||||
padding: 10px 20px;
|
box-shadow:
|
||||||
background-color: #007bff;
|
0 0 0 1px var(--theme-primary),
|
||||||
color: white;
|
0 0 0 3px rgba(0, 102, 204, 0.1);
|
||||||
border: none;
|
}
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover {
|
.el-input__prefix-inner {
|
||||||
background-color: #0056b3;
|
.el-icon {
|
||||||
|
margin-right: 8px;
|
||||||
|
color: var(--theme-text-secondary);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-help {
|
||||||
|
padding-top: 24px;
|
||||||
|
margin-top: 24px;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.help-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-link) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 24px;
|
||||||
|
|
||||||
|
.el-button {
|
||||||
|
min-width: 120px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: var(--theme-transition);
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
margin-right: 6px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--theme-shadow-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
217
frontend/src/views/mobile/Douban.vue
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mobile-page douban">
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="doubanStore.loading" class="douban__loading">
|
||||||
|
<van-loading type="spinner" size="24px" vertical>加载中...</van-loading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 电影列表 -->
|
||||||
|
<div v-else class="douban__grid">
|
||||||
|
<div v-for="movie in doubanStore.hotList" :key="movie.id" class="douban__item">
|
||||||
|
<!-- 海报卡片 -->
|
||||||
|
<div class="douban__poster">
|
||||||
|
<van-image
|
||||||
|
class="poster__img"
|
||||||
|
:src="movie.cover"
|
||||||
|
fit="cover"
|
||||||
|
lazy
|
||||||
|
loading="skeleton"
|
||||||
|
:alt="movie.title"
|
||||||
|
@click="previewImage(movie.cover)"
|
||||||
|
/>
|
||||||
|
<!-- 评分标签 -->
|
||||||
|
<van-tag
|
||||||
|
class="poster__rate"
|
||||||
|
type="primary"
|
||||||
|
:style="{ backgroundColor: getRateColor(movie.rate) }"
|
||||||
|
>
|
||||||
|
{{ movie.rate }}
|
||||||
|
</van-tag>
|
||||||
|
<!-- 搜索按钮 -->
|
||||||
|
<div class="poster__action" @click.stop="searchMovie(movie.title)">
|
||||||
|
<van-icon name="search" size="24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 电影信息 -->
|
||||||
|
<div class="douban__info">
|
||||||
|
<van-button class="info__title" type="default" :url="movie.url" text="text" block>
|
||||||
|
{{ movie.title }}
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<van-empty v-if="!doubanStore.loading && !doubanStore.hotList.length" description="暂无数据" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, watch } from "vue";
|
||||||
|
import { useRouter, useRoute } from "vue-router";
|
||||||
|
import { useDoubanStore } from "@/stores/douban";
|
||||||
|
import { showImagePreview } from "vant";
|
||||||
|
|
||||||
|
interface CurrentParams {
|
||||||
|
type: string;
|
||||||
|
tag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 路由相关
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const routeParams = computed((): CurrentParams => ({ ...route.query }) as unknown as CurrentParams);
|
||||||
|
|
||||||
|
// 状态管理
|
||||||
|
const doubanStore = useDoubanStore();
|
||||||
|
|
||||||
|
// 监听路由参数变化
|
||||||
|
watch(
|
||||||
|
() => routeParams.value,
|
||||||
|
(params) => {
|
||||||
|
if (params) {
|
||||||
|
doubanStore.setCurrentParams(params);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 方法定义
|
||||||
|
const searchMovie = (title: string) => {
|
||||||
|
router.push({
|
||||||
|
path: "/resource",
|
||||||
|
query: { keyword: title },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewImage = (url: string) => {
|
||||||
|
showImagePreview({
|
||||||
|
images: [url],
|
||||||
|
closeable: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 根据评分获取颜色
|
||||||
|
const getRateColor = (rate: string | number) => {
|
||||||
|
const numRate = Number(rate);
|
||||||
|
if (numRate >= 8) return "#42b883";
|
||||||
|
if (numRate >= 6) return "#5853fa";
|
||||||
|
return "#f56c6c";
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.douban {
|
||||||
|
// 网格布局 - 修改为两列
|
||||||
|
&__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: var(--spacing-base);
|
||||||
|
padding: var(--spacing-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 电影项
|
||||||
|
&__item {
|
||||||
|
background: var(--theme-other_background);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 海报区域
|
||||||
|
&__poster {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 2/3;
|
||||||
|
background: #f5f5f5;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.poster__img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster__rate {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--spacing-xs);
|
||||||
|
right: var(--spacing-xs);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster__action {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 信息区域
|
||||||
|
&__info {
|
||||||
|
padding: 6px 8px;
|
||||||
|
|
||||||
|
.info__title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--theme-color);
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
color: var(--theme-theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
&__loading {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--theme-background);
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
:deep(.van-loading) {
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 深度修改 Vant 组件样式
|
||||||
|
:deep(.van-image) {
|
||||||
|
display: block;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.van-tag--primary) {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.van-button) {
|
||||||
|
height: auto;
|
||||||
|
padding: var(--spacing-xs) 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
206
frontend/src/views/mobile/Home.vue
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
<template>
|
||||||
|
<div class="home">
|
||||||
|
<!-- 顶部搜索栏 -->
|
||||||
|
<header class="home__header">
|
||||||
|
<div class="header__wrapper">
|
||||||
|
<van-search
|
||||||
|
v-model="searchForm.keyword"
|
||||||
|
class="header__search"
|
||||||
|
shape="round"
|
||||||
|
placeholder="请输入搜索关键词或输入链接直接解析"
|
||||||
|
@search="handleSearch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<van-icon
|
||||||
|
name="https://b.yzcdn.cn/vant/icon-demo-1126.png"
|
||||||
|
class="header__action"
|
||||||
|
@click="handleLogout"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 主要内容区 -->
|
||||||
|
<main class="home__content">
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<transition name="fade" mode="out-in">
|
||||||
|
<component :is="Component" />
|
||||||
|
</transition>
|
||||||
|
</router-view>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- 底部导航栏 -->
|
||||||
|
<van-tabbar class="home__tabbar" route>
|
||||||
|
<van-tabbar-item to="/resource" icon="search">搜索</van-tabbar-item>
|
||||||
|
<van-tabbar-item to="/douban" icon="video">热门</van-tabbar-item>
|
||||||
|
<van-tabbar-item to="/setting" icon="setting-o">设置</van-tabbar-item>
|
||||||
|
</van-tabbar>
|
||||||
|
|
||||||
|
<!-- 全局加载状态 -->
|
||||||
|
<van-overlay :show="resourceStore.loading" class="home__loading" @touchmove.prevent>
|
||||||
|
<van-loading type="spinner" color="#fff" size="24px"> 资源搜索中... </van-loading>
|
||||||
|
</van-overlay>
|
||||||
|
|
||||||
|
<!-- 返回顶部 -->
|
||||||
|
<van-back-top right="30px" bottom="100px" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
import { useRouter, useRoute } from "vue-router";
|
||||||
|
import { showConfirmDialog } from "vant";
|
||||||
|
import { useResourceStore } from "@/stores/resource";
|
||||||
|
import { useUserSettingStore } from "@/stores/userSetting";
|
||||||
|
|
||||||
|
// 接口定义
|
||||||
|
interface SearchForm {
|
||||||
|
keyword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态管理
|
||||||
|
const resourceStore = useResourceStore();
|
||||||
|
const settingStore = useUserSettingStore();
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const searchForm = ref<SearchForm>({
|
||||||
|
keyword: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 路由相关
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
settingStore.getSettings();
|
||||||
|
|
||||||
|
// 监听路由参数
|
||||||
|
watch(
|
||||||
|
() => route.query.keyword as string,
|
||||||
|
(keyword) => {
|
||||||
|
if (keyword) {
|
||||||
|
searchForm.value.keyword = keyword;
|
||||||
|
handleSearch();
|
||||||
|
} else {
|
||||||
|
searchForm.value.keyword = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 方法定义
|
||||||
|
const handleSearch = async () => {
|
||||||
|
const keyword = searchForm.value.keyword.trim();
|
||||||
|
if (!keyword) return;
|
||||||
|
|
||||||
|
if (keyword.startsWith("http")) {
|
||||||
|
await resourceStore.parsingCloudLink(keyword);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.path !== "/resource") {
|
||||||
|
await router.push("/resource");
|
||||||
|
}
|
||||||
|
await resourceStore.searchResources(keyword);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
showConfirmDialog({
|
||||||
|
title: "退出登录",
|
||||||
|
message: "确定要退出登录吗?",
|
||||||
|
}).then(() => {
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
router.push("/login");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.home {
|
||||||
|
// 布局
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--theme-background);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
// 头部搜索
|
||||||
|
&__header {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: var(--theme-other_background);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
box-shadow: 0 1px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
.header__wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__search {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__action {
|
||||||
|
padding: 8px;
|
||||||
|
margin-left: 4px;
|
||||||
|
color: var(--theme-color);
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
color: var(--theme-theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主内容区 - 调整顶部间距
|
||||||
|
&__content {
|
||||||
|
padding-top: 64px; // 搜索框高度(48px) + 上下padding(8px * 2)
|
||||||
|
padding-bottom: 100px; // tabbar高度 + 底部安全区域
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
&__loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过渡动画
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 深度修改 Vant 组件样式
|
||||||
|
:deep(.van-tabbar) {
|
||||||
|
background: var(--theme-other_background);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.van-tabbar-item) {
|
||||||
|
color: var(--theme-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.van-tabbar-item--active) {
|
||||||
|
color: var(--theme-theme);
|
||||||
|
}
|
||||||
|
</style>
|
||||||