Full Code of jiangrui1994/CloudSaver for AI

main f67a6b274a40 cached
125 files
272.7 KB
79.8k tokens
194 symbols
1 requests
Download .txt
Showing preview only (301K chars total). Download the full file or copy to clipboard to get everything.
Repository: jiangrui1994/CloudSaver
Branch: main
Commit: f67a6b274a40
Files: 125
Total size: 272.7 KB

Directory structure:
gitextract_ls8v8cv6/

├── .eslintignore
├── .eslintrc.js
├── .github/
│   └── workflows/
│       ├── docker-build-test.yml
│       └── docker-image.yml
├── .gitignore
├── .prettierignore
├── .prettierrc.js
├── Dockerfile
├── LICENSE
├── README.md
├── backend/
│   ├── .env.example
│   ├── package.json
│   ├── src/
│   │   ├── app.ts
│   │   ├── config/
│   │   │   ├── database.ts
│   │   │   └── index.ts
│   │   ├── controllers/
│   │   │   ├── BaseCloudController.ts
│   │   │   ├── BaseController.ts
│   │   │   ├── cloud115.ts
│   │   │   ├── douban.ts
│   │   │   ├── quark.ts
│   │   │   ├── resource.ts
│   │   │   ├── setting.ts
│   │   │   ├── sponsors.ts
│   │   │   ├── teleImages.ts
│   │   │   └── user.ts
│   │   ├── core/
│   │   │   ├── ApiResponse.ts
│   │   │   └── types.ts
│   │   ├── inversify.config.ts
│   │   ├── middleware/
│   │   │   ├── auth.ts
│   │   │   ├── cors.ts
│   │   │   ├── errorHandler.ts
│   │   │   ├── index.ts
│   │   │   ├── rateLimiter.ts
│   │   │   ├── requestLogger.ts
│   │   │   └── validateRequest.ts
│   │   ├── models/
│   │   │   ├── GlobalSetting.ts
│   │   │   ├── User.ts
│   │   │   └── UserSetting.ts
│   │   ├── routes/
│   │   │   └── api.ts
│   │   ├── services/
│   │   │   ├── Cloud115Service.ts
│   │   │   ├── DatabaseService.ts
│   │   │   ├── DoubanService.ts
│   │   │   ├── ImageService.ts
│   │   │   ├── QuarkService.ts
│   │   │   ├── Searcher.ts
│   │   │   ├── SettingService.ts
│   │   │   ├── SponsorsService.ts
│   │   │   └── UserService.ts
│   │   ├── sponsors/
│   │   │   └── sponsors.json
│   │   ├── types/
│   │   │   ├── cloud.ts
│   │   │   ├── cloud115.ts
│   │   │   ├── express.ts
│   │   │   ├── index.ts
│   │   │   └── services.ts
│   │   └── utils/
│   │       ├── axiosInstance.ts
│   │       ├── handleError.ts
│   │       ├── index.ts
│   │       ├── logger.ts
│   │       ├── response.ts
│   │       └── responseHandler.ts
│   └── tsconfig.json
├── docker-entrypoint.sh
├── frontend/
│   ├── .env
│   ├── auto-imports.d.ts
│   ├── components.d.ts
│   ├── index.html
│   ├── package.json
│   ├── postcss.config.cjs
│   ├── src/
│   │   ├── App.vue
│   │   ├── api/
│   │   │   ├── cloud115.ts
│   │   │   ├── douban.ts
│   │   │   ├── quark.ts
│   │   │   ├── resource.ts
│   │   │   ├── setting.ts
│   │   │   └── user.ts
│   │   ├── components/
│   │   │   ├── AsideMenu.vue
│   │   │   ├── Home/
│   │   │   │   ├── FolderSelect.vue
│   │   │   │   ├── ResourceCard.vue
│   │   │   │   ├── ResourceSelect.vue
│   │   │   │   └── ResourceTable.vue
│   │   │   ├── SearchBar.vue
│   │   │   └── mobile/
│   │   │       ├── FolderSelect.vue
│   │   │       ├── ResourceCard.vue
│   │   │       └── ResourceSelect.vue
│   │   ├── constants/
│   │   │   ├── project.ts
│   │   │   └── storage.ts
│   │   ├── env.d.ts
│   │   ├── main.ts
│   │   ├── router/
│   │   │   ├── index.ts
│   │   │   ├── mobile-routes.ts
│   │   │   └── pc-routes.ts
│   │   ├── stores/
│   │   │   ├── douban.ts
│   │   │   ├── index.ts
│   │   │   ├── resource.ts
│   │   │   └── userSetting.ts
│   │   ├── styles/
│   │   │   ├── common.scss
│   │   │   ├── global.scss
│   │   │   ├── mobile.scss
│   │   │   └── responsive.scss
│   │   ├── types/
│   │   │   ├── douban.ts
│   │   │   ├── globals.d.ts
│   │   │   ├── index.ts
│   │   │   ├── response.ts
│   │   │   └── user.ts
│   │   ├── utils/
│   │   │   ├── device.ts
│   │   │   ├── image.ts
│   │   │   ├── index.ts
│   │   │   └── request.ts
│   │   └── views/
│   │       ├── Douban.vue
│   │       ├── Home.vue
│   │       ├── ResourceList.vue
│   │       ├── Setting.vue
│   │       ├── Thanks.vue
│   │       ├── mobile/
│   │       │   ├── Douban.vue
│   │       │   ├── Home.vue
│   │       │   ├── Login.vue
│   │       │   ├── ResourceList.vue
│   │       │   └── Setting.vue
│   │       └── pc/
│   │           └── Login.vue
│   ├── tsconfig.json
│   ├── tsconfig.node.json
│   └── vite.config.ts
├── nginx.conf
├── package.json
└── pnpm-workspace.yaml

================================================
FILE CONTENTS
================================================

================================================
FILE: .eslintignore
================================================
node_modules
dist
build
coverage 

================================================
FILE: .eslintrc.js
================================================
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",
      },
    },
  ],
};


================================================
FILE: .github/workflows/docker-build-test.yml
================================================
name: Build and Push Multi-Arch Docker Image for Test
on:
  workflow_dispatch: # 添加手动触发
jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write # 必须授权以推送镜像
    env:
      REPO_NAME: ${{ github.repository }}
      DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
      IMAGE_NAME: cloudsaver
    steps:
      - name: 检出代码
        uses: actions/checkout@v4

      - name: 设置小写镜像名称
        run: |
          LOWER_NAME=$(echo "$REPO_NAME" | tr '[:upper:]' '[:lower:]')
          echo "LOWER_NAME=$LOWER_NAME" >> $GITHUB_ENV

      - name: 登录到 GitHub Container Registry
        uses: docker/login-action@v2
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: 登录到 Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: 设置 QEMU 支持多架构
        uses: docker/setup-qemu-action@v2

      - name: 设置 Docker Buildx
        uses: docker/setup-buildx-action@v2

      - name: 构建并推送多架构 Docker 镜像
        uses: docker/build-push-action@v4
        with:
          context: .
          platforms: linux/amd64,linux/arm64 # 指定架构:x86_64 和 ARM64
          push: true
          tags: |
            ghcr.io/${{ env.LOWER_NAME }}:test
            ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:test


================================================
FILE: .github/workflows/docker-image.yml
================================================
name: Docker Image CI/CD
on:
  push:
    tags: ["v*.*.*"] # 支持标签触发(如 v1.0.0)
  workflow_dispatch: # 添加手动触发
jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write # 必须授权以推送镜像
    env:
      REPO_NAME: ${{ github.repository }}
      DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
      IMAGE_NAME: cloudsaver
    steps:
      - name: 检出代码
        uses: actions/checkout@v4

      - name: 设置小写镜像名称和版本
        run: |
          LOWER_NAME=$(echo "$REPO_NAME" | tr '[:upper:]' '[:lower:]')
          echo "LOWER_NAME=$LOWER_NAME" >> $GITHUB_ENV
          VERSION=${GITHUB_REF#refs/tags/v}
          echo "VERSION=$VERSION" >> $GITHUB_ENV

      - name: 登录到 GitHub Container Registry
        uses: docker/login-action@v2
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: 登录到 Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: 设置 QEMU 支持多架构
        uses: docker/setup-qemu-action@v2

      - name: 设置 Docker Buildx
        uses: docker/setup-buildx-action@v2

      - name: 构建并推送 Docker 镜像
        uses: docker/build-push-action@v4
        with:
          context: .
          platforms: linux/amd64,linux/arm64 # 指定架构:x86_64 和 ARM64
          push: true
          tags: |
            ghcr.io/${{ env.LOWER_NAME }}:latest
            ghcr.io/${{ env.LOWER_NAME }}:${{ env.VERSION }}
            ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:latest
            ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }}


================================================
FILE: .gitignore
================================================
node_modules/
logs/
dist/
.env
.env.local
.env.*.local

*.tar

# 数据库数据
*.sqlite

# 保留模板
!.env.example

!frontend/.env

# 其他敏感文件
config.private.ts
*.pem
*.key

.DS_Store
*.log 

================================================
FILE: .prettierignore
================================================
# 构建产物
dist
build
coverage

# 依赖目录
node_modules

# 日志文件
*.log

# 环境配置
.env*
!.env.example

# 编辑器配置
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

# 系统文件
.DS_Store
Thumbs.db

# 版本控制
.git 

================================================
FILE: .prettierrc.js
================================================
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,
};


================================================
FILE: Dockerfile
================================================
# 构建前端项目
FROM node:18-alpine as frontend-build
WORKDIR /app
COPY frontend/package*.json ./
RUN npm install -g pnpm
RUN pnpm install
COPY frontend/ ./
RUN npm run build

# 构建后端项目
FROM node:18-alpine as backend-build
WORKDIR /app
COPY backend/package*.json ./
RUN npm install -g pnpm
RUN pnpm install
COPY backend/ ./
RUN rm -f database.sqlite
RUN npm run build

# 生产环境镜像
FROM node:18-alpine

# 安装 Nginx
RUN apk add --no-cache nginx

# 设置工作目录
WORKDIR /app

# 创建配置和数据目录
RUN mkdir -p /app/config /app/data

# 复制前端构建产物到 Nginx
COPY --from=frontend-build /app/dist /usr/share/nginx/html

# 复制 Nginx 配置文件
COPY nginx.conf /etc/nginx/nginx.conf

# 复制后端构建产物到生产环境镜像
COPY --from=backend-build /app /app

# 安装生产环境依赖
RUN npm install --production

# 设置数据卷
VOLUME ["/app/config", "/app/data"]

# 暴露端口
EXPOSE 8008

# 启动脚本
COPY docker-entrypoint.sh /app/
RUN chmod +x /app/docker-entrypoint.sh

# 启动服务
ENTRYPOINT ["/app/docker-entrypoint.sh"]


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2024 CloudSaver

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
# CloudSaver

![License](https://img.shields.io/badge/license-MIT-blue.svg)
![Vue](https://img.shields.io/badge/vue-3.x-brightgreen.svg)
![TypeScript](https://img.shields.io/badge/typescript-5.x-blue.svg)
[![GitHub Stars](https://img.shields.io/github/stars/jiangrui1994/CloudSaver.svg?style=flat&logo=github)](https://github.com/jiangrui1994/CloudSaver/stargazers)
![Docker](https://img.shields.io/docker/pulls/jiangrui1994/cloudsaver.svg)
<a href="https://hellogithub.com/repository/d13663fb959345e7923ecaccc3387571" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=d13663fb959345e7923ecaccc3387571&claim_uid=xP1MT4mSvN6wn5K&theme=small" alt="Featured|HelloGitHub" /></a>

一个基于 Vue 3 + Express 的网盘资源搜索与转存工具,支持响应式布局,移动端与PC完美适配,可通过 Docker 一键部署。

官方Telegram群组:[https://t.me/+4fWSSbQn5rMxYjM1](https://t.me/+4fWSSbQn5rMxYjM1)

官方QQ交流群([群地址](https://www.yuque.com/xiaoruihenbangde/ggogn3/ga6gaaiy5fsyw62l#lsPla))

版本更新日志:[https://www.yuque.com/xiaoruihenbangde/ggogn3/vxoqxkx4rkcz3g94](https://www.yuque.com/xiaoruihenbangde/ggogn3/vxoqxkx4rkcz3g94)

CloudSaver部署与使用常见问题(包含更多搜索频道):[https://www.yuque.com/xiaoruihenbangde/ggogn3/ga6gaaiy5fsyw62l](https://www.yuque.com/xiaoruihenbangde/ggogn3/ga6gaaiy5fsyw62l)
密码 me16 点个Star呗~

⚠️关于项目更新与需求处理的核心声明:[https://www.yuque.com/xiaoruihenbangde/ggogn3/gt9cgqn2n3vergxx](https://www.yuque.com/xiaoruihenbangde/ggogn3/gt9cgqn2n3vergxx)

---

⚠️**由于某些原因,[新版本](https://www.yuque.com/xiaoruihenbangde/ggogn3/vxoqxkx4rkcz3g94)内容不包含在此开源仓库(停留在V0.2.5版本),如需使用,请使用docker镜像进行部署使用。**

---

**🔒 重要安全提醒|关于本项目私有化部署的强制建议**

为保障您的数据安全与隐私权益,请务必**通过Docker自行私有化部署本项目**。我们**强烈反对**使用任何第三方提供的在线网站或他人部署的服务,原因如下:

⚠️ **高风险预警**

- 本项目涉及**网盘Cookie等敏感凭据**,若使用他人服务:  
  ▶ 您的账号密码、隐私文件可能遭恶意窃取或篡改  
  ▶ 攻击者可利用Cookie直接登录您的网盘实施破坏  
  ▶ 数据泄露、资产损失等后果需完全由使用者自行承担

🚫 **严正声明**

1. 本项目**从未且不会**提供任何形式的在线服务、公开Demo或托管平台
2. **任何声称与本项目相关的在线网站均为未授权第三方搭建**,存在蓄意作恶的高风险
3. 如因使用非自建服务导致损失,本项目开发者**不承担任何法律责任**

❓ **常见问题**  
Q: 是否有在线Demo可直接试用?  
A: **绝无可能!** 任何在线服务都与本项目无关,请立即关闭避免信息泄露

Q: 为何不能使用他人部署好的服务?  
A: Cookie等同于账号密码,交出Cookie=交出家门钥匙,请勿将身家安全托付陌生人

Q: 如何确保100%安全?  
A: 唯一可信方案:通过官方仓库代码+自主服务器部署,全程数据闭环

---

**🛡️ 最后一次严肃提醒**  
您的账号安全只应掌握在自己手中!  
请立即执行私有化部署 ▶ 避免无法挽回的数据灾难

---

## 功能特性

- 🔍 多源资源搜索
  - 支持多个资源订阅源搜索
  - 支持关键词搜索与资源链接解析
  - 支持豆瓣热门榜单展示
- 💾 网盘资源转存
  - 支持**115 网盘,夸克网盘,天翼网盘,123云盘**一键转存
  - 支持转存文件夹展示与选择
- 👥 多用户系统
  - 支持用户注册登录
  - 支持管理员与普通用户权限区分
- 📱 响应式设计
  - 支持 PC 端与移动端自适应布局
  - 针对不同设备优化的交互体验

## 产品展示

<details>
<summary>点击展开截图预览</summary>

### PC 端

<div align="center">
  <img src="./docs/images/pc/login.png" width="400" alt="PC登录页面">
   <img src="./docs/images/pc/douban.png" width="400" alt="PC豆瓣榜单">
  <p>登录页面/榜单</p>
</div>

<div align="center">
  <img src="./docs/images/pc/search.png" width="400" alt="PC资源搜索">
  <img src="./docs/images/pc/detail.png" width="400" alt="PC资源详情">
  <p>资源搜索/资源详情</p>
</div>

<div align="center">
  <img src="./docs/images/pc/save.png" width="400" alt="PC资源转存">
  <img src="./docs/images/pc/save1.png" width="400" alt="PC资源转存">
  <p>资源转存</p>
</div>

### 移动端

<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>

</details>

## 技术栈

### 前端

- 核心框架
  - Vue 3
  - TypeScript
  - Vite
- 状态管理
  - Pinia
- 路由管理
  - Vue Router
- UI 组件库
  - Element Plus (PC)
  - Vant (Mobile)
- 工具库
  - Axios

### 后端

- 运行环境
  - Node.js
  - Express
- 数据存储
  - SQLite3

## 环境要求

- Node.js >= 18.x
- pnpm >= 8.x (推荐)

## 快速开始

### 开发环境

1. 克隆项目

```bash
git clone https://github.com/jiangrui1994/CloudSaver.git
cd CloudSaver
```

2. 安装依赖

```bash
pnpm install
```

3. 配置环境变量

```bash
cp ./backend/.env.example ./backend/.env
```

根据 `.env.example` 文件说明配置必要的环境变量。

4. 启动开发服务器

```bash
pnpm dev
```

### 生产环境部署

1. 构建前端

```bash
pnpm build:frontend
```

2. 构建后端

```bash
cd backend
pnpm build
```

3. 启动服务

```bash
pnpm start
```

### Docker 部署

说明:镜像源有**两个地址**供选择,下面部署命令中使用的是dockerhub托管的地址为例,github托管的地址请自行替换

- dockerhub托管:
  - `jiangrui1994/cloudsaver:latest` 稳定版
  - `jiangrui1994/cloudsaver:test` 测试版 (包含最新功能和bug修复,但可能不如稳定版稳定)
- github托管:
  - `ghcr.io/jiangrui1994/cloudsaver:latest` 稳定版
  - `ghcr.io/jiangrui1994/cloudsaver:test` 测试版 (包含最新功能和bug修复,但可能不如稳定版稳定)

#### 单容器部署

稳定版:

```bash
docker run -d \
  -p 8008:8008 \
  -v /your/local/path/data:/app/data \
  -v /your/local/path/config:/app/config \
  --name cloud-saver \
  jiangrui1994/cloudsaver:latest
```

测试版(包含最新功能和bug修复,但可能不如稳定版稳定):

```bash
docker run -d \
  -p 8008:8008 \
  -v /your/local/path/data:/app/data \
  -v /your/local/path/config:/app/config \
  --name cloud-saver \
  jiangrui1994/cloudsaver:test
```

#### Docker Compose 部署

创建 `docker-compose.yml` 文件:

稳定版:

```yaml
version: "3"
services:
  cloudsaver:
    image: jiangrui1994/cloudsaver:latest
    container_name: cloud-saver
    ports:
      - "8008:8008"
    volumes:
      - /your/local/path/data:/app/data
      - /your/local/path/config:/app/config
    restart: unless-stopped
```

测试版:

```yaml
version: "3"
services:
  cloudsaver:
    image: jiangrui1994/cloudsaver:test
    container_name: cloud-saver
    ports:
      - "8008:8008"
    volumes:
      - /your/local/path/data:/app/data
      - /your/local/path/config:/app/config
    restart: unless-stopped
```

#### /app/config 目录说明

- `env` 文件:包含后端环境变量配置

```bash
# JWT配置
JWT_SECRET=your_jwt_secret_here

# Telegram配置
TELEGRAM_BASE_URL=https://t.me/s

# Telegram频道配置(0.3.0及之后版本无效)
TELE_CHANNELS=[{"id":"xxxx","name":"xxxx资源分享"}]
```

运行:

```bash
docker-compose up -d
```

> **注意**: 测试版(:test标签)包含最新的功能开发和bug修复,但可能存在不稳定因素。建议生产环境使用稳定版(:latest标签)。

## 注意事项

1. 资源搜索需要配置代理环境
2. 默认注册码
   - 管理员:230713
   - 普通用户:9527

## 联系方式

<div align="center">
  <div>
   官方QQ交流群(群地址(https://www.yuque.com/xiaoruihenbangde/ggogn3/ga6gaaiy5fsyw62l#lsPla))
  </div>
</div>

## 支持项目

如果您觉得这个项目对您有帮助,可以考虑给予一点支持,这将帮助我们持续改进项目 ❤️

您可以:

- ⭐ 给项目点个 Star
- 🎉 分享给更多有需要的朋友
- ☕ 请作者喝杯冰阔乐或咖啡
- 💰 **赞赏了一定记得和我联系**

<div align="center">
  <div style="display: inline-block; margin: 0 20px;">
    <img src="./docs/images/wechat_pay.jpg" height="300" alt="微信打赏">
    <img src="./docs/images/alipay.png" height="300" alt="支付宝打赏">
  </div>
</div>

## 特别声明

1. 本项目仅供学习交流使用,请勿用于非法用途
2. 仅支持个人使用,不支持任何形式的商业使用
3. 禁止在项目页面进行任何形式的广告宣传
4. 所有搜索到的资源均来自第三方,本项目不对其真实性、合法性做出任何保证

## 贡献指南

1. Fork 本仓库
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 提交 Pull Request

## 开源协议

本项目基于 MIT 协议开源 - 查看 [LICENSE](LICENSE) 文件了解更多细节

## 鸣谢

- 👨‍💻 感谢所有为这个项目做出贡献的开发者们!
- 👥 感谢所有使用本项目并提供反馈的用户!
- 感谢所有给予支持和鼓励的朋友们!

## 赞助
本项目 CDN 加速及安全防护由 Tencent EdgeOne 赞助

<a href="https://edgeone.ai/?from=github" target="_blank">亚洲最佳CDN、边缘和安全解决方案 - Tencent EdgeOne</a>

<img title="亚洲最佳CDN、边缘和安全解决方案 - Tencent EdgeOne" src="https://edgeone.ai/media/34fe3a45-492d-4ea4-ae5d-ea1087ca7b4b.png" width="300">

## Star History

[![Star History Chart](https://api.star-history.com/svg?repos=jiangrui1994/cloudsaver&type=Date)](https://www.star-history.com/#jiangrui1994/cloudsaver&Date)


================================================
FILE: backend/.env.example
================================================
# JWT配置
JWT_SECRET=your_jwt_secret_here

# Telegram配置
TELEGRAM_BASE_URL=https://t.me/s

# Telegram频道配置
TELE_CHANNELS=[]



================================================
FILE: backend/package.json
================================================
{
  "name": "cloud-saver-server",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "nodemon --exec ts-node src/app.ts",
    "build": "tsc",
    "start": "node dist/app.js"
  },
  "dependencies": {
    "axios": "^1.6.7",
    "bcrypt": "^5.1.1",
    "cheerio": "^1.0.0",
    "cookie-parser": "^1.4.6",
    "cors": "^2.8.5",
    "dotenv": "^16.4.5",
    "express": "^4.18.3",
    "inversify": "^7.1.0",
    "jsonwebtoken": "^9.0.2",
    "rss-parser": "^3.13.0",
    "sequelize": "^6.37.5",
    "socket.io": "^4.8.1",
    "sqlite3": "^5.1.7",
    "tunnel": "^0.0.6",
    "winston": "^3.17.0"
  },
  "devDependencies": {
    "@types/bcrypt": "^5.0.2",
    "@types/cookie-parser": "^1.4.7",
    "@types/cors": "^2.8.17",
    "@types/express": "^4.17.21",
    "@types/jsonwebtoken": "^9.0.7",
    "@types/node": "^20.11.25",
    "@types/tunnel": "^0.0.7",
    "nodemon": "^3.1.0",
    "ts-node": "^10.9.2",
    "typescript": "^5.4.2"
  }
}


================================================
FILE: backend/src/app.ts
================================================
// filepath: /d:/code/CloudDiskDown/backend/src/app.ts
import "./types/express";
import express from "express";
import { container } from "./inversify.config";
import { TYPES } from "./core/types";
import { DatabaseService } from "./services/DatabaseService";
import { setupMiddlewares } from "./middleware";
import routes from "./routes/api";
import { logger } from "./utils/logger";
import { errorHandler } from "./middleware/errorHandler";
class App {
  private app = express();
  private databaseService = container.get<DatabaseService>(TYPES.DatabaseService);

  constructor() {
    this.setupExpress();
  }

  private setupExpress(): void {
    // 设置中间件
    setupMiddlewares(this.app);

    // 设置路由
    this.app.use("/", routes);
    this.app.use(errorHandler);
  }

  public async start(): Promise<void> {
    try {
      // 初始化数据库
      await this.databaseService.initialize();
      logger.info("数据库初始化成功");

      // 启动服务器
      const port = process.env.PORT || 8009;
      this.app.listen(port, () => {
        logger.info(`
🚀 服务器启动成功
🌍 监听端口: ${port}
🔧 运行环境: ${process.env.NODE_ENV || "development"}
        `);
      });
    } catch (error) {
      logger.error("服务器启动失败:", error);
      process.exit(1);
    }
  }
}

// 创建并启动应用
const application = new App();
application.start().catch((error) => {
  logger.error("应用程序启动失败:", error);
  process.exit(1);
});

export default application;


================================================
FILE: backend/src/config/database.ts
================================================
// backend/src/config/database.ts
import { Sequelize } from "sequelize";

const sequelize = new Sequelize({
  dialect: "sqlite",
  storage: "./data/database.sqlite",
});

export default sequelize;


================================================
FILE: backend/src/config/index.ts
================================================
import dotenv from "dotenv";

// 加载.env文件
dotenv.config();

interface Channel {
  id: string;
  name: string;
}

interface CloudPatterns {
  baiduPan: RegExp;
  tianyi: RegExp;
  aliyun: RegExp;
  pan115: RegExp;
  pan123: RegExp;
  quark: RegExp;
  yidong: RegExp;
}

interface Config {
  jwtSecret: string;
  telegram: {
    baseUrl: string;
    channels: Channel[];
  };
  cloudPatterns: CloudPatterns;
  app: {
    port: number;
    env: string;
  };
  database: {
    type: string;
    path: string;
  };
  jwt: {
    secret: string;
    expiresIn: string;
  };
}

// 从环境变量读取频道配置
const getTeleChannels = (): Channel[] => {
  try {
    const channelsStr = process.env.TELE_CHANNELS;
    if (channelsStr) {
      return JSON.parse(channelsStr);
    }
  } catch (error) {
    console.warn("无法解析 TELE_CHANNELS 环境变量,使用默认配置");
  }

  // 默认配置
  return [];
};

export const config: Config = {
  app: {
    port: parseInt(process.env.PORT || "8009"),
    env: process.env.NODE_ENV || "development",
  },
  database: {
    type: "sqlite",
    path: "./data/database.sqlite",
  },
  jwt: {
    secret: process.env.JWT_SECRET || "your-secret-key",
    expiresIn: "6h",
  },
  jwtSecret: process.env.JWT_SECRET || "uV7Y$k92#LkF^q1b!",

  telegram: {
    baseUrl: process.env.TELEGRAM_BASE_URL || "https://t.me/s",
    channels: getTeleChannels(),
  },
  cloudPatterns: {
    baiduPan: /https?:\/\/(?:pan|yun)\.baidu\.com\/[^\s<>"]+/g,
    tianyi: /https?:\/\/cloud\.189\.cn\/[^\s<>"]+/g,
    aliyun: /https?:\/\/\w+\.(?:alipan|aliyundrive)\.com\/[^\s<>"]+/g,
    // pan115有两个域名 115.com 和 anxia.com 和 115cdn.com
    pan115: /https?:\/\/(?:115|anxia|115cdn)\.com\/s\/[^\s<>"]+/g,
    // 修改为匹配所有以123开头的域名
    // eslint-disable-next-line no-useless-escape
    pan123: /https?:\/\/(?:www\.)?123[^\/\s<>"]+\.com\/s\/[^\s<>"]+/g,
    quark: /https?:\/\/pan\.quark\.cn\/[^\s<>"]+/g,
    yidong: /https?:\/\/caiyun\.139\.com\/[^\s<>"]+/g,
  },
};


================================================
FILE: backend/src/controllers/BaseCloudController.ts
================================================
import { Request, Response } from "express";
import { BaseController } from "./BaseController";
import { ICloudStorageService } from "@/types/services";

export abstract class BaseCloudController extends BaseController {
  constructor(protected cloudService: ICloudStorageService) {
    super();
  }

  async getShareInfo(req: Request, res: Response): Promise<void> {
    await this.handleRequest(req, res, async () => {
      const { shareCode, receiveCode } = req.query;
      // await this.cloudService.setCookie(req);
      return await this.cloudService.getShareInfo(shareCode as string, receiveCode as string);
    });
  }

  async getFolderList(req: Request, res: Response): Promise<void> {
    await this.handleRequest(req, res, async () => {
      const { parentCid } = req.query;
      await this.cloudService.setCookie(req);
      return await this.cloudService.getFolderList(parentCid as string);
    });
  }

  async saveFile(req: Request, res: Response): Promise<void> {
    await this.handleRequest(req, res, async () => {
      await this.cloudService.setCookie(req);
      return await this.cloudService.saveSharedFile(req.body);
    });
  }
}


================================================
FILE: backend/src/controllers/BaseController.ts
================================================
import { Request, Response } from "express";
import { ApiResponse } from "../core/ApiResponse";
interface ApiResponseData<T> {
  data?: T;
  message?: string;
}

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


================================================
FILE: backend/src/controllers/cloud115.ts
================================================
import { Cloud115Service } from "../services/Cloud115Service";
import { injectable, inject } from "inversify";
import { TYPES } from "../core/types";
import { BaseCloudController } from "./BaseCloudController";

@injectable()
export class Cloud115Controller extends BaseCloudController {
  constructor(@inject(TYPES.Cloud115Service) cloud115Service: Cloud115Service) {
    super(cloud115Service);
  }
}


================================================
FILE: backend/src/controllers/douban.ts
================================================
import { Request, Response } from "express";
import { injectable, inject } from "inversify";
import { TYPES } from "../core/types";
import { DoubanService } from "../services/DoubanService";
import { BaseController } from "./BaseController";

@injectable()
export class DoubanController extends BaseController {
  constructor(@inject(TYPES.DoubanService) private doubanService: DoubanService) {
    super();
  }

  async getDoubanHotList(req: Request, res: Response): Promise<void> {
    await this.handleRequest(req, res, async () => {
      const { type = "movie", tag = "热门", page_limit = "50", page_start = "0" } = req.query;
      const result = await this.doubanService.getHotList({
        type: type as string,
        tag: tag as string,
        page_limit: page_limit as string,
        page_start: page_start as string,
      });
      return result;
    });
  }
}


================================================
FILE: backend/src/controllers/quark.ts
================================================
import { Request, Response } from "express";
import { injectable, inject } from "inversify";
import { TYPES } from "../core/types";
import { QuarkService } from "../services/QuarkService";
import { BaseCloudController } from "./BaseCloudController";

@injectable()
export class QuarkController extends BaseCloudController {
  constructor(@inject(TYPES.QuarkService) quarkService: QuarkService) {
    super(quarkService);
  }
}


================================================
FILE: backend/src/controllers/resource.ts
================================================
import { Request, Response } from "express";
import { injectable, inject } from "inversify";
import { TYPES } from "../core/types";
import { Searcher } from "../services/Searcher";
import { BaseController } from "./BaseController";

@injectable()
export class ResourceController extends BaseController {
  constructor(@inject(TYPES.Searcher) private searcher: Searcher) {
    super();
  }

  async search(req: Request, res: Response): Promise<void> {
    await this.handleRequest(req, res, async () => {
      const { keyword, channelId = "", lastMessageId = "" } = req.query;
      return await this.searcher.searchAll(
        keyword as string,
        channelId as string,
        lastMessageId as string
      );
    });
  }
}


================================================
FILE: backend/src/controllers/setting.ts
================================================
import { Request, Response } from "express";
import { injectable, inject } from "inversify";
import { TYPES } from "../core/types";
import { SettingService } from "../services/SettingService";
import { BaseController } from "./BaseController";

@injectable()
export class SettingController extends BaseController {
  constructor(@inject(TYPES.SettingService) private settingService: SettingService) {
    super();
  }

  async get(req: Request, res: Response): Promise<void> {
    await this.handleRequest(req, res, async () => {
      const userId = req.user?.userId;
      const role = Number(req.user?.role);
      return await this.settingService.getSettings(userId, role);
    });
  }

  async save(req: Request, res: Response): Promise<void> {
    await this.handleRequest(req, res, async () => {
      const userId = req.user?.userId;
      const role = Number(req.user?.role);
      return await this.settingService.saveSettings(userId, role, req.body);
    });
  }
}


================================================
FILE: backend/src/controllers/sponsors.ts
================================================
import { Request, Response } from "express";
import { injectable, inject } from "inversify";
import { TYPES } from "../core/types";
import { SponsorsService } from "../services/SponsorsService";
import { BaseController } from "./BaseController";

@injectable()
export class SponsorsController extends BaseController {
  constructor(@inject(TYPES.SponsorsService) private sponsorsService: SponsorsService) {
    super();
  }

  async get(req: Request, res: Response): Promise<void> {
    await this.handleRequest(req, res, async () => {
      return await this.sponsorsService.getSponsors();
    });
  }
}


================================================
FILE: backend/src/controllers/teleImages.ts
================================================
import { Request, Response } from "express";
import { injectable, inject } from "inversify";
import { TYPES } from "../core/types";
import { ImageService } from "../services/ImageService";
import { BaseController } from "./BaseController";

@injectable()
export class ImageController extends BaseController {
  constructor(@inject(TYPES.ImageService) private imageService: ImageService) {
    super();
  }

  async getImages(req: Request, res: Response): Promise<void> {
    await this.handleRequest(req, res, async () => {
      const url = decodeURIComponent((req.query.url as string) || "");
      const response = await this.imageService.getImages(url);

      // 设置正确的响应头
      res.setHeader("Content-Type", response.headers["content-type"]);
      res.setHeader("Cache-Control", "no-cache");

      // 确保清除任何可能导致304响应的头信息
      res.removeHeader("etag");
      res.removeHeader("last-modified");

      // 直接传输图片数据
      response.data.pipe(res);
    });
  }
}


================================================
FILE: backend/src/controllers/user.ts
================================================
import { Request, Response } from "express";
import { injectable, inject } from "inversify";
import { TYPES } from "../core/types";
import { UserService } from "../services/UserService";
import { BaseController } from "./BaseController";

@injectable()
export class UserController extends BaseController {
  constructor(@inject(TYPES.UserService) private userService: UserService) {
    super();
  }

  async register(req: Request, res: Response): Promise<void> {
    await this.handleRequest(req, res, async () => {
      const { username, password, registerCode } = req.body;
      return await this.userService.register(username, password, registerCode);
    });
  }

  async login(req: Request, res: Response): Promise<void> {
    await this.handleRequest(req, res, async () => {
      const { username, password } = req.body;
      return await this.userService.login(username, password);
    });
  }
}


================================================
FILE: backend/src/core/ApiResponse.ts
================================================
export class ApiResponse<T> {
  success: boolean;
  data?: T;
  message?: string;
  code: number;

  private constructor(success: boolean, code: number, data?: T, message?: string) {
    this.success = success;
    this.code = code;
    this.data = data;
    this.message = message;
  }

  static success<T>(data?: T, message = "操作成功"): ApiResponse<T> {
    return new ApiResponse(true, 0, data, message);
  }

  static error(message: string, code = 10000): ApiResponse<null> {
    return new ApiResponse(false, code, null, message);
  }
}


================================================
FILE: backend/src/core/types.ts
================================================
export const TYPES = {
  DatabaseService: Symbol.for("DatabaseService"),
  Cloud115Service: Symbol.for("Cloud115Service"),
  QuarkService: Symbol.for("QuarkService"),
  Searcher: Symbol.for("Searcher"),
  DoubanService: Symbol.for("DoubanService"),
  ImageService: Symbol.for("ImageService"),
  SettingService: Symbol.for("SettingService"),
  UserService: Symbol.for("UserService"),
  SponsorsService: Symbol.for("SponsorsService"),

  Cloud115Controller: Symbol.for("Cloud115Controller"),
  QuarkController: Symbol.for("QuarkController"),
  ResourceController: Symbol.for("ResourceController"),
  DoubanController: Symbol.for("DoubanController"),
  ImageController: Symbol.for("ImageController"),
  SettingController: Symbol.for("SettingController"),
  UserController: Symbol.for("UserController"),
  SponsorsController: Symbol.for("SponsorsController"),
};


================================================
FILE: backend/src/inversify.config.ts
================================================
import { Container } from "inversify";
import { TYPES } from "./core/types";

// Services
import { DatabaseService } from "./services/DatabaseService";
import { Cloud115Service } from "./services/Cloud115Service";
import { QuarkService } from "./services/QuarkService";
import { Searcher } from "./services/Searcher";
import { DoubanService } from "./services/DoubanService";
import { UserService } from "./services/UserService";
import { ImageService } from "./services/ImageService";
import { SettingService } from "./services/SettingService";
import { SponsorsService } from "./services/SponsorsService";
// Controllers
import { Cloud115Controller } from "./controllers/cloud115";
import { QuarkController } from "./controllers/quark";
import { ResourceController } from "./controllers/resource";
import { DoubanController } from "./controllers/douban";
import { ImageController } from "./controllers/teleImages";
import { SettingController } from "./controllers/setting";
import { UserController } from "./controllers/user";
import { SponsorsController } from "./controllers/sponsors";
const container = new Container();

// Services
container.bind<DatabaseService>(TYPES.DatabaseService).to(DatabaseService).inSingletonScope();
container.bind<Cloud115Service>(TYPES.Cloud115Service).to(Cloud115Service).inSingletonScope();
container.bind<QuarkService>(TYPES.QuarkService).to(QuarkService).inSingletonScope();
container.bind<Searcher>(TYPES.Searcher).to(Searcher).inSingletonScope();
container.bind<ImageService>(TYPES.ImageService).to(ImageService).inSingletonScope();
container.bind<SettingService>(TYPES.SettingService).to(SettingService).inSingletonScope();
container.bind<DoubanService>(TYPES.DoubanService).to(DoubanService).inSingletonScope();
container.bind<UserService>(TYPES.UserService).to(UserService).inSingletonScope();
container.bind<SponsorsService>(TYPES.SponsorsService).to(SponsorsService).inSingletonScope();
// Controllers
container.bind<Cloud115Controller>(TYPES.Cloud115Controller).to(Cloud115Controller);
container.bind<QuarkController>(TYPES.QuarkController).to(QuarkController);
container.bind<ResourceController>(TYPES.ResourceController).to(ResourceController);
container.bind<DoubanController>(TYPES.DoubanController).to(DoubanController);
container.bind<ImageController>(TYPES.ImageController).to(ImageController);
container.bind<SettingController>(TYPES.SettingController).to(SettingController);
container.bind<UserController>(TYPES.UserController).to(UserController);
container.bind<SponsorsController>(TYPES.SponsorsController).to(SponsorsController);

export { container };


================================================
FILE: backend/src/middleware/auth.ts
================================================
// filepath: /D:/code/CloudDiskDown/backend/src/middleware/auth.ts
import { Request, Response, NextFunction } from "express";
import jwt, { JwtPayload } from "jsonwebtoken";
import User from "../models/User";
import { config } from "../config";

interface AuthenticatedRequest extends Request {
  user?: {
    userId: string;
    role: number;
  };
}

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

  const token = req.headers.authorization?.split(" ")[1];
  if (!token) {
    return res.status(401).json({ message: "未提供 token" });
  }

  try {
    const decoded = jwt.verify(token, config.jwtSecret) as JwtPayload;

    req.user = {
      userId: decoded.userId,
      role: decoded.role,
    };
    const user = await User.findOne({ where: { userId: decoded.userId } });
    if (!user) {
      return res.status(401).json({ message: "无效的 token" });
    }
    next();
  } catch (error) {
    res.status(401).json({ message: "无效的 token" });
  }
};


================================================
FILE: backend/src/middleware/cors.ts
================================================
import { Request, Response, NextFunction } from "express";

export const cors = () => {
  return (req: Request, res: Response, next: NextFunction) => {
    res.header("Access-Control-Allow-Origin", "*");
    res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
    res.header("Access-Control-Allow-Headers", "Content-Type, Authorization, Cookie");
    res.header("Access-Control-Allow-Credentials", "true");

    if (req.method === "OPTIONS") {
      return res.sendStatus(200);
    }
    next();
  };
};


================================================
FILE: backend/src/middleware/errorHandler.ts
================================================
import { Request, Response } from "express";

interface CustomError extends Error {
  status?: number;
}

export const errorHandler = (err: CustomError, req: Request, res: Response): void => {
  res.status(err.status || 500).json({
    success: false,
    error: err.message || "服务器内部错误",
  });
};


================================================
FILE: backend/src/middleware/index.ts
================================================
import { Application } from "express";
import express from "express";
import { authMiddleware } from "./auth";
import { requestLogger } from "./requestLogger";
import { rateLimiter } from "./rateLimiter";
import { cors } from "./cors";

export const setupMiddlewares = (app: Application) => {
  app.use(express.json());
  app.use(cors());
  app.use(requestLogger());
  app.use(rateLimiter());
  app.use(authMiddleware);
};


================================================
FILE: backend/src/middleware/rateLimiter.ts
================================================
import { Request, Response, NextFunction } from "express";

const requestCounts = new Map<string, { count: number; timestamp: number }>();
const WINDOW_MS = 60 * 1000; // 1分钟窗口
const MAX_REQUESTS = 600; // 每个IP每分钟最多60个请求

export const rateLimiter = () => {
  return (req: Request, res: Response, next: NextFunction) => {
    const ip = req.ip || req.socket.remoteAddress || "unknown";
    const now = Date.now();
    const record = requestCounts.get(ip) || { count: 0, timestamp: now };

    if (now - record.timestamp > WINDOW_MS) {
      record.count = 0;
      record.timestamp = now;
    }

    record.count++;
    requestCounts.set(ip, record);

    if (record.count > MAX_REQUESTS) {
      return res.status(429).json({ message: "请求过于频繁,请稍后再试" });
    }

    next();
  };
};


================================================
FILE: backend/src/middleware/requestLogger.ts
================================================
import { Request, Response, NextFunction } from "express";
import { logger } from "../utils/logger";

const excludePaths = ["/tele-images/"];

export const requestLogger = () => {
  return (req: Request, res: Response, next: NextFunction) => {
    const start = Date.now();
    res.on("finish", () => {
      if (excludePaths.includes(req.path)) {
        return;
      }
      const duration = Date.now() - start;
      logger.info({
        method: req.method,
        path: req.path,
        status: res.statusCode,
        duration: `${duration}ms`,
      });
    });
    next();
  };
};


================================================
FILE: backend/src/middleware/validateRequest.ts
================================================
import { Request, Response, NextFunction } from "express";

export const validateRequest = (
  requiredParams: string[]
): ((req: Request, res: Response, next: NextFunction) => Response | void) => {
  return (req: Request, res: Response, next: NextFunction) => {
    const missingParams = requiredParams.filter((param) => !req.query[param] && !req.body[param]);
    if (missingParams.length > 0) {
      return res.status(400).json({
        success: false,
        error: `缺少必要的参数: ${missingParams.join(", ")}`,
      });
    }
    next();
  };
};


================================================
FILE: backend/src/models/GlobalSetting.ts
================================================
import { DataTypes, Model, Optional } from "sequelize";
import sequelize from "../config/database";

export interface GlobalSettingAttributes {
  id: number;
  httpProxyHost: string;
  httpProxyPort: number;
  isProxyEnabled: boolean;
  CommonUserCode: number;
  AdminUserCode: number;
}

interface GlobalSettingCreationAttributes extends Optional<GlobalSettingAttributes, "id"> {}

class GlobalSetting
  extends Model<GlobalSettingAttributes, GlobalSettingCreationAttributes>
  implements GlobalSettingAttributes
{
  public id!: number;
  public httpProxyHost!: string;
  public httpProxyPort!: number;
  public isProxyEnabled!: boolean;
  public CommonUserCode!: number;
  public AdminUserCode!: number;
}

GlobalSetting.init(
  {
    id: {
      type: DataTypes.INTEGER,
      autoIncrement: true,
      primaryKey: true,
    },
    httpProxyHost: {
      type: DataTypes.STRING,
      allowNull: false,
      defaultValue: "127.0.0.1",
    },
    httpProxyPort: {
      type: DataTypes.INTEGER,
      allowNull: false,
      defaultValue: 7890,
    },
    isProxyEnabled: {
      type: DataTypes.BOOLEAN,
      allowNull: false,
      defaultValue: true,
    },
    CommonUserCode: {
      type: DataTypes.INTEGER,
      allowNull: true,
      defaultValue: 9527,
    },
    AdminUserCode: {
      type: DataTypes.INTEGER,
      allowNull: false,
      defaultValue: 230713,
    },
  },
  {
    sequelize,
    modelName: "GlobalSetting",
    tableName: "global_settings",
  }
);

export default GlobalSetting;


================================================
FILE: backend/src/models/User.ts
================================================
import { DataTypes, Model, Optional } from "sequelize";
import sequelize from "../config/database";

interface UserAttributes {
  id: number;
  userId?: number;
  username: string;
  password: string;
  role: number; // 修改为数字类型
}

interface UserCreationAttributes extends Optional<UserAttributes, "id"> {}

class User extends Model<UserAttributes, UserCreationAttributes> implements UserAttributes {
  public id!: number;
  public userId!: number;
  public username!: string;
  public password!: string;
  public role!: number; // 实现数字类型的角色属性
}

User.init(
  {
    id: {
      type: DataTypes.INTEGER,
      autoIncrement: true,
      primaryKey: true,
      allowNull: false, // 显式设置为不可为空
    },
    userId: {
      type: DataTypes.UUID, // 对外暴露的不可预测ID
      defaultValue: DataTypes.UUIDV4,
      unique: true,
      allowNull: false, // 显式设置为不可为空
    },
    username: {
      type: DataTypes.STRING,
      allowNull: false,
      unique: true,
    },
    password: {
      type: DataTypes.STRING,
      allowNull: false,
    },
    role: {
      type: DataTypes.INTEGER, // 修改为数字类型
      allowNull: false,
      defaultValue: 0, // 默认值为普通用户
    },
  },
  {
    sequelize,
    modelName: "User",
    tableName: "users",
  }
);

// 角色映射
// 0: 普通用户
// 1: 管理员

export default User;


================================================
FILE: backend/src/models/UserSetting.ts
================================================
import { DataTypes, Model, Optional } from "sequelize";
import sequelize from "../config/database";
import User from "./User";

interface UserSettingAttributes {
  id: number;
  userId: string;
  cloud115UserId?: string;
  cloud115Cookie: string;
  quarkCookie: string;
}

interface UserSettingCreationAttributes extends Optional<UserSettingAttributes, "id"> {}

class UserSetting
  extends Model<UserSettingAttributes, UserSettingCreationAttributes>
  implements UserSettingAttributes
{
  public id!: number;
  public userId!: string;
  public cloud115UserId?: string;
  public cloud115Cookie!: string;
  public quarkCookie!: string;
}

UserSetting.init(
  {
    id: {
      type: DataTypes.INTEGER,
      autoIncrement: true,
      primaryKey: true,
    },
    userId: {
      type: DataTypes.UUID,
      allowNull: false,
      unique: true,
      references: {
        model: User,
        key: "userId",
      },
      onDelete: "CASCADE",
    },
    cloud115UserId: {
      type: DataTypes.STRING,
      allowNull: true,
    },
    cloud115Cookie: {
      type: DataTypes.STRING,
      allowNull: true,
    },
    quarkCookie: {
      type: DataTypes.STRING,
      allowNull: true,
    },
  },
  {
    sequelize,
    modelName: "UserSetting",
    tableName: "user_settings",
  }
);

User.hasOne(UserSetting, {
  foreignKey: "userId",
  as: "settings",
});
UserSetting.belongsTo(User, {
  foreignKey: "userId",
  as: "user",
});

export default UserSetting;


================================================
FILE: backend/src/routes/api.ts
================================================
import { Router } from "express";
import { container } from "../inversify.config";
import { TYPES } from "../core/types";
import { Cloud115Controller } from "../controllers/cloud115";
import { QuarkController } from "../controllers/quark";
import { ResourceController } from "../controllers/resource";
import { DoubanController } from "../controllers/douban";
import { ImageController } from "../controllers/teleImages";
import { SettingController } from "../controllers/setting";
import { UserController } from "../controllers/user";
import { SponsorsController } from "../controllers/sponsors";

const router = Router();

// 获取控制器实例
const cloud115Controller = container.get<Cloud115Controller>(TYPES.Cloud115Controller);
const quarkController = container.get<QuarkController>(TYPES.QuarkController);
const resourceController = container.get<ResourceController>(TYPES.ResourceController);
const doubanController = container.get<DoubanController>(TYPES.DoubanController);
const imageController = container.get<ImageController>(TYPES.ImageController);
const settingController = container.get<SettingController>(TYPES.SettingController);
const userController = container.get<UserController>(TYPES.UserController);
const sponsorsController = container.get<SponsorsController>(TYPES.SponsorsController);

// 用户相关路由
router.post("/user/login", (req, res) => userController.login(req, res));
router.post("/user/register", (req, res) => userController.register(req, res));

// 图片相关路由
router.get("/tele-images", (req, res) => imageController.getImages(req, res));

// 设置相关路由
router.get("/setting/get", (req, res) => settingController.get(req, res));
router.post("/setting/save", (req, res) => settingController.save(req, res));

// 资源搜索
router.get("/search", (req, res) => resourceController.search(req, res));

// 获取赞助者列表
router.get("/sponsors", (req, res) => sponsorsController.get(req, res));

// 115网盘相关
router.get("/cloud115/share-info", (req, res) => cloud115Controller.getShareInfo(req, res));
router.get("/cloud115/folders", (req, res) => cloud115Controller.getFolderList(req, res));
router.post("/cloud115/save", (req, res) => cloud115Controller.saveFile(req, res));

// 夸克网盘相关
router.get("/quark/share-info", (req, res) => quarkController.getShareInfo(req, res));
router.get("/quark/folders", (req, res) => quarkController.getFolderList(req, res));
router.post("/quark/save", (req, res) => quarkController.saveFile(req, res));

// 获取豆瓣热门列表
router.get("/douban/hot", (req, res) => doubanController.getDoubanHotList(req, res));

export default router;


================================================
FILE: backend/src/services/Cloud115Service.ts
================================================
import { AxiosHeaders, AxiosInstance } from "axios"; // 导入 AxiosHeaders
import { createAxiosInstance } from "../utils/axiosInstance";
import { ShareInfoResponse, FolderListResponse, SaveFileParams } from "../types/cloud";
import { injectable } from "inversify";
import { Request } from "express";
import UserSetting from "../models/UserSetting";
import { ICloudStorageService } from "@/types/services";
import { logger } from "../utils/logger";

interface Cloud115ListItem {
  cid: string;
  n: string;
  s: number;
}

interface Cloud115FolderItem {
  cid: string;
  n: string;
  ns: number;
}

@injectable()
export class Cloud115Service implements ICloudStorageService {
  private api: AxiosInstance;
  private cookie: string = "";

  constructor() {
    this.api = createAxiosInstance(
      "https://webapi.115.com",
      AxiosHeaders.from({
        Host: "webapi.115.com",
        Connection: "keep-alive",
        xweb_xhr: "1",
        Origin: "",
        "Content-Type": "application/x-www-form-urlencoded",
        "User-Agent":
          "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/6.8.0(0x16080000) NetType/WIFI MiniProgramEnv/Mac MacWechat/WMPF MacWechat/3.8.9(0x13080910) XWEB/1227",
        Accept: "*/*",
        "Sec-Fetch-Site": "cross-site",
        "Sec-Fetch-Mode": "cors",
        "Sec-Fetch-Dest": "empty",
        Referer: "https://servicewechat.com/wx2c744c010a61b0fa/94/page-frame.html",
        "Accept-Encoding": "gzip, deflate, br",
        "Accept-Language": "zh-CN,zh;q=0.9",
      })
    );

    this.api.interceptors.request.use((config) => {
      config.headers.cookie = this.cookie;
      return config;
    });
  }

  async setCookie(req: Request): Promise<void> {
    const userId = req.user?.userId;
    const userSetting = await UserSetting.findOne({
      where: { userId },
    });
    if (userSetting && userSetting.dataValues.cloud115Cookie) {
      this.cookie = userSetting.dataValues.cloud115Cookie;
    } else {
      throw new Error("请先设置115网盘cookie");
    }
  }

  async getShareInfo(shareCode: string, receiveCode = ""): Promise<ShareInfoResponse> {
    const response = await this.api.get("/share/snap", {
      params: {
        share_code: shareCode,
        receive_code: receiveCode,
        offset: 0,
        limit: 20,
        cid: "",
      },
    });
    if (response.data?.state && response.data.data?.list?.length > 0) {
      return {
        data: {
          list: response.data.data.list.map((item: Cloud115ListItem) => ({
            fileId: item.cid,
            fileName: item.n,
            fileSize: item.s,
          })),
        },
      };
    } else {
      logger.error("未找到文件信息:", response.data);
      throw new Error("未找到文件信息");
    }
  }

  async getFolderList(parentCid = "0"): Promise<FolderListResponse> {
    const response = await this.api.get("/files", {
      params: {
        aid: 1,
        cid: parentCid,
        o: "user_ptime",
        asc: 1,
        offset: 0,
        show_dir: 1,
        limit: 50,
        type: 0,
        format: "json",
        star: 0,
        suffix: "",
        natsort: 0,
        snap: 0,
        record_open_time: 1,
        fc_mix: 0,
      },
    });
    if (response.data?.state) {
      return {
        data: response.data.data
          .filter((item: Cloud115FolderItem) => item.cid && !!item.ns)
          .map((folder: Cloud115FolderItem) => ({
            cid: folder.cid,
            name: folder.n,
            path: response.data.path,
          })),
      };
    } else {
      logger.error("获取目录列表失败:", response.data.error);
      throw new Error("获取115pan目录列表失败:" + response.data.error);
    }
  }

  async saveSharedFile(params: SaveFileParams): Promise<{ message: string; data: unknown }> {
    const param = new URLSearchParams({
      cid: params.folderId || "",
      share_code: params.shareCode || "",
      receive_code: params.receiveCode || "",
      file_id: params.fids?.[0] || "",
    });
    const response = await this.api.post("/share/receive", param.toString());
    logger.info("保存文件:", response.data);
    if (response.data.state) {
      return {
        message: response.data.error,
        data: response.data.data,
      };
    } else {
      logger.error("保存文件失败:", response.data.error);
      throw new Error("保存115pan文件失败:" + response.data.error);
    }
  }
}


================================================
FILE: backend/src/services/DatabaseService.ts
================================================
import { Sequelize, QueryTypes } from "sequelize";
import GlobalSetting from "../models/GlobalSetting";
import { Searcher } from "./Searcher";
import sequelize from "../config/database";

// 全局设置默认值
const DEFAULT_GLOBAL_SETTINGS = {
  httpProxyHost: "127.0.0.1",
  httpProxyPort: 7890,
  isProxyEnabled: false,
  CommonUserCode: 9527,
  AdminUserCode: 230713,
};

export class DatabaseService {
  private sequelize: Sequelize;

  constructor() {
    this.sequelize = sequelize;
  }

  async initialize(): Promise<void> {
    try {
      await this.sequelize.query("PRAGMA foreign_keys = OFF");
      await this.cleanupBackupTables();
      await this.sequelize.sync({ alter: true });
      await this.sequelize.query("PRAGMA foreign_keys = ON");
      await this.initializeGlobalSettings();
    } catch (error) {
      throw new Error(`数据库初始化失败: ${(error as Error).message}`);
    }
  }

  private async initializeGlobalSettings(): Promise<void> {
    try {
      const settings = await GlobalSetting.findOne();
      if (!settings) {
        await GlobalSetting.create(DEFAULT_GLOBAL_SETTINGS);
        console.log("✅ Global settings initialized with default values.");
      }
      await Searcher.updateAxiosInstance();
    } catch (error) {
      console.error("❌ Failed to initialize global settings:", error);
      throw error;
    }
  }

  private async cleanupBackupTables(): Promise<void> {
    const backupTables = await this.sequelize.query<{ name: string }>(
      "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%\\_backup%' ESCAPE '\\'",
      { type: QueryTypes.SELECT }
    );

    for (const table of backupTables) {
      if (table?.name) {
        await this.sequelize.query(`DROP TABLE IF EXISTS ${table.name}`);
      }
    }
  }

  // ... 其他数据库相关方法
}


================================================
FILE: backend/src/services/DoubanService.ts
================================================
import { AxiosHeaders, AxiosInstance } from "axios";
import { createAxiosInstance } from "../utils/axiosInstance";

interface DoubanSubject {
  id: string;
  title: string;
  rate: string;
  cover: string;
  url: string;
  is_new: boolean;
}

export class DoubanService {
  private baseUrl: string;
  private api: AxiosInstance;

  constructor() {
    this.baseUrl = "https://movie.douban.com/j";
    this.api = createAxiosInstance(
      this.baseUrl,
      AxiosHeaders.from({
        accept: "*/*",
        "accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
        priority: "u=1, i",
        "sec-ch-ua": '"Not A(Brand";v="8", "Chromium";v="132", "Microsoft Edge";v="132"',
        "sec-ch-ua-mobile": "?0",
        "sec-ch-ua-platform": '"Windows"',
        "sec-fetch-dest": "empty",
        "sec-fetch-mode": "cors",
        "sec-fetch-site": "same-origin",
        "x-requested-with": "XMLHttpRequest",
        cookie:
          'll="118282"; bid=StA6AQFsAWQ; _pk_id.100001.4cf6=6448be57b1b5ca7e.1723172321.; _vwo_uuid_v2=DC15B8183560FF1E538FFE1D480723310|c08e2d213ecb5510005f90a6ff332121; __utmv=30149280.6282; _vwo_uuid_v2=DC15B8183560FF1E538FFE1D480723310|c08e2d213ecb5510005f90a6ff332121; __utmz=30149280.1731915179.21.6.utmcsr=search.douban.com|utmccn=(referral)|utmcmd=referral|utmcct=/movie/subject_search; __utmz=223695111.1731915179.21.6.utmcsr=search.douban.com|utmccn=(referral)|utmcmd=referral|utmcct=/movie/subject_search; douban-fav-remind=1; __utmc=30149280; __utmc=223695111; _pk_ref.100001.4cf6=%5B%22%22%2C%22%22%2C1739176523%2C%22https%3A%2F%2Fsearch.douban.com%2Fmovie%2Fsubject_search%3Fsearch_text%3D%E8%84%91%E6%B4%9E%E5%A4%A7%E5%BC%80%26cat%3D1002%22%5D; _pk_ses.100001.4cf6=1; ap_v=0,6.0; __utma=30149280.859303574.1723448979.1739167503.1739176523.42; __utmb=30149280.0.10.1739176523; __utma=223695111.1882744177.1723448979.1739167503.1739176523.42; __utmb=223695111.0.10.1739176523',
        Referer: "https://movie.douban.com/",
        "Referrer-Policy": "unsafe-url",
      })
    );
  }

  async getHotList(params: {
    type: string;
    tag: string;
    page_limit: string;
    page_start: string;
  }): Promise<{ data: DoubanSubject[] }> {
    try {
      const response = await this.api.get("/search_subjects", {
        params: params,
      });
      if (response.data && response.data.subjects) {
        return {
          data: response.data.subjects,
        };
      } else {
        return {
          data: [],
        };
      }
    } catch (error) {
      console.error("Error fetching hot list:", error);
      throw error;
    }
  }
}


================================================
FILE: backend/src/services/ImageService.ts
================================================
import { injectable } from "inversify";
import axios, { AxiosInstance } from "axios";
import tunnel from "tunnel";
import GlobalSetting from "../models/GlobalSetting";
import { GlobalSettingAttributes } from "../models/GlobalSetting";

@injectable()
export class ImageService {
  private axiosInstance: AxiosInstance | null = null;

  constructor() {
    // 移除构造函数中的初始化,改为懒加载
  }

  private async ensureAxiosInstance(): Promise<AxiosInstance> {
    if (!this.axiosInstance) {
      const settings = await GlobalSetting.findOne();
      const globalSetting = settings?.dataValues || ({} as GlobalSettingAttributes);

      this.axiosInstance = axios.create({
        timeout: 30000,
        headers: {
          Accept: "image/*, */*",
          "User-Agent":
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
        },
        withCredentials: false,
        maxRedirects: 5,
        httpsAgent: globalSetting.isProxyEnabled
          ? tunnel.httpsOverHttp({
              proxy: {
                host: globalSetting.httpProxyHost,
                port: globalSetting.httpProxyPort,
                headers: {
                  "Proxy-Authorization": "",
                },
              },
            })
          : undefined,
      });

      this.axiosInstance.interceptors.response.use(
        (response) => response,
        (error) => {
          throw error;
        }
      );
    }
    return this.axiosInstance;
  }

  async updateAxiosInstance(): Promise<void> {
    this.axiosInstance = null;
    await this.ensureAxiosInstance();
  }

  async getImages(url: string): Promise<any> {
    const axiosInstance = await this.ensureAxiosInstance();

    return await axiosInstance.get(url, {
      responseType: "stream",
      validateStatus: (status) => status >= 200 && status < 300,
      headers: {
        Referer: new URL(url).origin,
      },
    });
  }
}


================================================
FILE: backend/src/services/QuarkService.ts
================================================
import { AxiosInstance, AxiosHeaders } from "axios";
import { logger } from "../utils/logger";
import { createAxiosInstance } from "../utils/axiosInstance";
import { injectable } from "inversify";
import { Request } from "express";
import UserSetting from "../models/UserSetting";
import {
  ShareInfoResponse,
  FolderListResponse,
  QuarkFolderItem,
  SaveFileParams,
} from "../types/cloud";
import { ICloudStorageService } from "@/types/services";

interface QuarkShareInfo {
  stoken?: string;
  pwdId?: string;
  fileSize?: number;
  list: {
    fid: string;
    file_name: string;
    file_type: number;
    share_fid_token: string;
  }[];
}

@injectable()
export class QuarkService implements ICloudStorageService {
  private api: AxiosInstance;
  private cookie: string = "";

  constructor() {
    this.api = createAxiosInstance(
      "https://drive-h.quark.cn",
      AxiosHeaders.from({
        cookie: this.cookie,
        accept: "application/json, text/plain, */*",
        "accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
        "content-type": "application/json",
        priority: "u=1, i",
        "sec-ch-ua": '"Microsoft Edge";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
        "sec-ch-ua-mobile": "?0",
        "sec-ch-ua-platform": '"Windows"',
        "sec-fetch-dest": "empty",
        "sec-fetch-mode": "cors",
        "sec-fetch-site": "same-site",
      })
    );

    this.api.interceptors.request.use((config) => {
      config.headers.cookie = this.cookie;
      return config;
    });
  }

  async setCookie(req: Request): Promise<void> {
    const userId = req.user?.userId;
    const userSetting = await UserSetting.findOne({
      where: { userId },
    });
    if (userSetting && userSetting.dataValues.quarkCookie) {
      this.cookie = userSetting.dataValues.quarkCookie;
    } else {
      throw new Error("请先设置夸克网盘cookie");
    }
  }

  async getShareInfo(pwdId: string, passcode = ""): Promise<ShareInfoResponse> {
    const response = await this.api.post(
      `/1/clouddrive/share/sharepage/token?pr=ucpro&fr=pc&uc_param_str=&__dt=994&__t=${Date.now()}`,
      {
        pwd_id: pwdId,
        passcode,
      }
    );
    if (response.data?.status === 200 && response.data.data) {
      const fileInfo = response.data.data;
      if (fileInfo.stoken) {
        const res = await this.getShareList(pwdId, fileInfo.stoken);
        return {
          data: res,
        };
      }
    }
    throw new Error("获取夸克分享信息失败");
  }

  async getShareList(pwdId: string, stoken: string): Promise<ShareInfoResponse["data"]> {
    const response = await this.api.get("/1/clouddrive/share/sharepage/detail", {
      params: {
        pr: "ucpro",
        fr: "pc",
        uc_param_str: "",
        pwd_id: pwdId,
        stoken: stoken,
        pdir_fid: "0",
        force: "0",
        _page: "1",
        _size: "50",
        _fetch_banner: "1",
        _fetch_share: "1",
        _fetch_total: "1",
        _sort: "file_type:asc,updated_at:desc",
        __dt: "1589",
        __t: Date.now(),
      },
    });
    if (response.data?.data) {
      const list = response.data.data.list
        .filter((item: QuarkShareInfo["list"][0]) => item.fid)
        .map((folder: QuarkShareInfo["list"][0]) => ({
          fileId: folder.fid,
          fileName: folder.file_name,
          fileIdToken: folder.share_fid_token,
        }));
      return {
        list,
        pwdId,
        stoken,
        fileSize: response.data.data.share?.size || 0,
      };
    } else {
      return {
        list: [],
      };
    }
  }

  async getFolderList(parentCid = "0"): Promise<FolderListResponse> {
    const response = await this.api.get("/1/clouddrive/file/sort", {
      params: {
        pr: "ucpro",
        fr: "pc",
        uc_param_str: "",
        pdir_fid: parentCid,
        _page: "1",
        _size: "100",
        _fetch_total: "false",
        _fetch_sub_dirs: "1",
        _sort: "",
        __dt: "2093126",
        __t: Date.now(),
      },
    });
    if (response.data?.data && response.data.data.list) {
      const data = response.data.data.list
        .filter((item: QuarkFolderItem) => item.fid && item.file_type === 0)
        .map((folder: QuarkFolderItem) => ({
          cid: folder.fid,
          name: folder.file_name,
          path: [],
        }));
      return {
        data,
      };
    } else {
      const message = "获取夸克目录列表失败:" + response.data.error;
      logger.error(message);
      throw new Error(message);
    }
  }

  async saveSharedFile(params: SaveFileParams): Promise<{ message: string; data: unknown }> {
    const quarkParams = {
      fid_list: params.fids,
      fid_token_list: params.fidTokens,
      to_pdir_fid: params.folderId,
      pwd_id: params.shareCode,
      stoken: params.receiveCode,
      pdir_fid: "0",
      scene: "link",
    };
    try {
      const response = await this.api.post(
        `/1/clouddrive/share/sharepage/save?pr=ucpro&fr=pc&uc_param_str=&__dt=208097&__t=${Date.now()}`,
        quarkParams
      );

      return {
        message: response.data.message,
        data: response.data.data,
      };
    } catch (error) {
      throw new Error(error instanceof Error ? error.message : "未知错误");
    }
  }
}


================================================
FILE: backend/src/services/Searcher.ts
================================================
import { AxiosInstance, AxiosHeaders } from "axios";
import { createAxiosInstance } from "../utils/axiosInstance";
import GlobalSetting from "../models/GlobalSetting";
import { GlobalSettingAttributes } from "../models/GlobalSetting";
import * as cheerio from "cheerio";
import { config } from "../config";
import { logger } from "../utils/logger";
import { injectable } from "inversify";

interface sourceItem {
  messageId?: string;
  title?: string;
  completeTitle?: string;
  link?: string;
  pubDate?: string;
  content?: string;
  description?: string;
  image?: string;
  cloudLinks?: string[];
  tags?: string[];
  cloudType?: string;
}

@injectable()
export class Searcher {
  private static instance: Searcher;
  private api: AxiosInstance | null = null;

  constructor() {
    this.initAxiosInstance();
    Searcher.instance = this;
  }

  private async initAxiosInstance(isUpdate: boolean = false) {
    let globalSetting = {} as GlobalSettingAttributes;
    if (isUpdate) {
      const settings = await GlobalSetting.findOne();
      globalSetting = settings?.dataValues || ({} as GlobalSettingAttributes);
    }
    this.api = createAxiosInstance(
      config.telegram.baseUrl,
      AxiosHeaders.from({
        accept:
          "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
        "accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
        "cache-control": "max-age=0",
        priority: "u=0, i",
        "sec-ch-ua": '"Microsoft Edge";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
        "sec-ch-ua-mobile": "?0",
        "sec-ch-ua-platform": '"macOS"',
        "sec-fetch-dest": "document",
        "sec-fetch-mode": "navigate",
        "sec-fetch-site": "none",
        "sec-fetch-user": "?1",
        "upgrade-insecure-requests": "1",
      }),
      globalSetting?.isProxyEnabled,
      globalSetting?.isProxyEnabled
        ? { host: globalSetting?.httpProxyHost, port: globalSetting?.httpProxyPort }
        : undefined
    );
  }

  public static async updateAxiosInstance(): Promise<void> {
    await Searcher.instance.initAxiosInstance(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);
        if (!cloudType) cloudType = Object.keys(config.cloudPatterns)[index];
      }
    });
    return {
      links: [...new Set(links)],
      cloudType,
    };
  }

  async searchAll(keyword: string, channelId?: string, messageId?: string) {
    const allResults: any[] = [];

    const channelList: any[] = channelId
      ? config.telegram.channels.filter((channel: any) => channel.id === channelId)
      : config.telegram.channels;

    // 使用Promise.all进行并行请求
    const searchPromises = channelList.map(async (channel) => {
      try {
        const messageIdparams = messageId ? `before=${messageId}` : "";
        const url = `/${channel.id}${keyword ? `?q=${encodeURIComponent(keyword)}&${messageIdparams}` : `?${messageIdparams}`}`;
        console.log(`Searching in channel ${channel.name} with URL: ${url}`);
        return this.searchInWeb(url).then((results) => {
          console.log(`Found ${results.items.length} items in channel ${channel.name}`);
          if (results.items.length > 0) {
            const channelResults = results.items
              .filter((item: sourceItem) => item.cloudLinks && item.cloudLinks.length > 0)
              .map((item: sourceItem) => ({
                ...item,
                channel: channel.name,
                channelId: channel.id,
              }));

            allResults.push({
              list: channelResults,
              channelInfo: {
                ...channel,
                channelLogo: results.channelLogo,
              },
              id: channel.id,
            });
          }
        });
      } catch (error) {
        logger.error(`搜索频道 ${channel.name} 失败:`, error);
      }
    });

    // 等待所有请求完成
    await Promise.all(searchPromises);

    return {
      data: allResults,
    };
  }

  async searchInWeb(url: string) {
    try {
      if (!this.api) {
        throw new Error("Axios instance is not initialized");
      }
      const response = await this.api.get(url);
      const html = response.data;
      const $ = cheerio.load(html);
      const items: sourceItem[] = [];
      let channelLogo = "";
      $(".tgme_header_link").each((_, element) => {
        channelLogo = $(element).find("img").attr("src") || "";
      });
      // 遍历每个消息容器
      $(".tgme_widget_message_wrap").each((_, element) => {
        const messageEl = $(element);

        // 通过 data-post 属性来获取消息的链接 去除channelId 获得消息id
        const messageId = messageEl
          .find(".tgme_widget_message")
          .data("post")
          ?.toString()
          .split("/")[1];

        // 提取标题 (第一个<br>标签前的内容)
        const title =
          messageEl
            .find(".js-message_text")
            .html()
            ?.split("<br>")[0]
            .replace(/<[^>]+>/g, "")
            .replace(/\n/g, "") || "";

        // 提取描述 (第一个<a>标签前面的内容,不包含标题)
        const content =
          messageEl
            .find(".js-message_text")
            .html()
            ?.replace(title, "")
            .split("<a")[0]
            .replace(/<br>/g, "")
            .trim() || "";

        // 提取链接 (消息中的链接)
        // const link = messageEl.find('.tgme_widget_message').data('post');

        // 提取发布时间
        const pubDate = messageEl.find("time").attr("datetime");

        // 提取图片
        const image = messageEl
          .find(".tgme_widget_message_photo_wrap")
          .attr("style")
          ?.match(/url\('(.+?)'\)/)?.[1];

        const tags: string[] = [];
        // 提取云盘链接
        const links = messageEl
          .find(".tgme_widget_message_text a")
          .map((_, el) => $(el).attr("href"))
          .get();
        messageEl.find(".tgme_widget_message_text a").each((index, element) => {
          const tagText = $(element).text();
          if (tagText && tagText.startsWith("#")) {
            tags.push(tagText);
          }
        });
        const cloudInfo = this.extractCloudLinks(links.join(" "));
        // 添加到数组第一位
        items.unshift({
          messageId,
          title,
          pubDate,
          content,
          image,
          cloudLinks: cloudInfo.links,
          cloudType: cloudInfo.cloudType,
          tags,
        });
      });
      return { items: items, channelLogo };
    } catch (error) {
      logger.error(`搜索错误: ${url}`, error);
      return {
        items: [],
        channelLogo: "",
      };
    }
  }
}

export default new Searcher();


================================================
FILE: backend/src/services/SettingService.ts
================================================
import { injectable, inject } from "inversify";
import { TYPES } from "../core/types";
import UserSetting from "../models/UserSetting";
import GlobalSetting from "../models/GlobalSetting";
import { Searcher } from "./Searcher";
import { ImageService } from "./ImageService";

@injectable()
export class SettingService {
  constructor(@inject(TYPES.ImageService) private imageService: ImageService) {}

  async getSettings(userId: string | undefined, role: number | undefined) {
    if (!userId) {
      throw new Error("用户ID无效");
    }

    let userSettings = await UserSetting.findOne({ where: { userId: userId.toString() } });
    if (!userSettings) {
      userSettings = await UserSetting.create({
        userId: userId.toString(),
        cloud115Cookie: "",
        quarkCookie: "",
      });
    }

    const globalSetting = await GlobalSetting.findOne();
    return {
      data: {
        userSettings,
        globalSetting: role === 1 ? globalSetting : null,
      },
    };
  }

  async saveSettings(userId: string | undefined, role: number | undefined, settings: any) {
    if (!userId) {
      throw new Error("用户ID无效");
    }

    const { userSettings, globalSetting } = settings;
    await UserSetting.update(userSettings, { where: { userId: userId.toString() } });

    if (role === 1 && globalSetting) {
      await GlobalSetting.update(globalSetting, { where: {} });
    }
    await this.updateSettings();
    return { message: "保存成功" };
  }

  async updateSettings(/* 参数 */): Promise<void> {
    // ... 其他代码 ...

    // 修改这一行,使用注入的实例方法而不是静态方法
    await this.imageService.updateAxiosInstance();
    await Searcher.updateAxiosInstance();

    // ... 其他代码 ...
  }
}


================================================
FILE: backend/src/services/SponsorsService.ts
================================================
import { injectable } from "inversify";
import { createAxiosInstance } from "../utils/axiosInstance";
import { AxiosInstance } from "axios";
import sponsors from "../sponsors/sponsors.json";

@injectable()
export class SponsorsService {
  private axiosInstance: AxiosInstance;

  constructor() {
    this.axiosInstance = createAxiosInstance("http://oss.jiangmuxin.cn/cloudsaver/");
  }
  async getSponsors() {
    try {
      const response = await this.axiosInstance.get("sponsors.json");
      return {
        data: response.data.sponsors,
      };
    } catch (error) {
      return {
        data: sponsors.sponsors,
      };
    }
  }
}


================================================
FILE: backend/src/services/UserService.ts
================================================
import { injectable } from "inversify";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { config } from "../config";
import User from "../models/User";
import GlobalSetting from "../models/GlobalSetting";

@injectable()
export class UserService {
  private isValidInput(input: string): boolean {
    // 检查是否包含空格或汉字
    const regex = /^[^\s\u4e00-\u9fa5]+$/;
    return regex.test(input);
  }

  async register(username: string, password: string, registerCode: string) {
    const globalSetting = await GlobalSetting.findOne();
    const registerCodeList = [
      globalSetting?.dataValues.CommonUserCode,
      globalSetting?.dataValues.AdminUserCode,
    ];
    if (!registerCode || !registerCodeList.includes(Number(registerCode))) {
      throw new Error("注册码错误");
    }

    // 验证输入
    if (!this.isValidInput(username) || !this.isValidInput(password)) {
      throw new Error("用户名、密码或注册码不能包含空格或汉字");
    }

    // 检查用户名是否已存在
    const existingUser = await User.findOne({ where: { username } });
    if (existingUser) {
      throw new Error("用户名已存在");
    }

    const hashedPassword = await bcrypt.hash(password, 10);
    const role = registerCodeList.findIndex((x) => x === Number(registerCode));
    const user = await User.create({ username, password: hashedPassword, role });

    return {
      data: user,
      message: "用户注册成功",
    };
  }

  async login(username: string, password: string) {
    const user = await User.findOne({ where: { username } });
    if (!user || !(await bcrypt.compare(password, user.password))) {
      throw new Error("用户名或密码错误");
    }

    const token = jwt.sign({ userId: user.userId, role: user.role }, config.jwtSecret, {
      expiresIn: "6h",
    });

    return {
      data: {
        token,
      },
    };
  }
}


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


================================================
FILE: backend/src/types/cloud.ts
================================================
export interface ShareInfoResponse {
  data: {
    list: ShareInfoItem[];
    fileSize?: number;
    pwdId?: string;
    stoken?: string;
  };
}

export interface GetShareInfoParams {
  shareCode: string;
  receiveCode?: string;
}

export interface ShareInfoItem {
  fileId: string;
  fileName: string;
  fileSize?: number;
  fileIdToken?: string;
}
export interface FolderListResponse {
  data: {
    cid: string;
    name: string;
    path: { cid: string; name: string }[];
  }[];
}

export interface SaveFileParams {
  shareCode: string; // 分享code
  receiveCode?: string; // 分享文件的密码
  folderId?: string; // 文件夹id
  fids?: string[]; // 存储文件id
  fidTokens?: string[]; // 存储文件token
}

export interface SaveFileResponse {
  message: string;
  data: unknown;
}

export interface ShareFileInfo {
  shareCode: string;
  receiveCode?: string;
  fileId: string;
  cid?: string;
  fid_list?: string[];
  fid_token_list?: string[];
  to_pdir_fid?: string;
  pwd_id?: string;
  stoken?: string;
  pdir_fid?: string;
  scene?: string;
  [key: string]: any;
}

export interface QuarkShareFileInfo {
  fid_list: string[];
  fid_token_list: string[];
  to_pdir_fid: string;
  pwd_id: string;
  stoken: string;
  pdir_fid: string;
  scene: string;
}

export interface QuarkShareInfo {
  stoken?: string;
  pwdId?: string;
  fileSize?: number;
  list: {
    fid: string;
    file_name: string;
    file_type: number;
    share_fid_token: string;
  }[];
}

export interface QuarkFolderItem {
  fid: string;
  file_name: string;
  file_type: number;
}


================================================
FILE: backend/src/types/cloud115.ts
================================================
export interface ShareInfo {
  fileId: string;
  fileName: string;
  fileSize: number;
}

export interface ShareInfoResponse {
  data?: ShareInfo[];
  message?: string;
}


================================================
FILE: backend/src/types/express.ts
================================================
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { Request } from "express";

declare module "express" {
  interface Request {
    user?: {
      userId: string;
      role: number;
    };
  }
}


================================================
FILE: backend/src/types/index.ts
================================================
export interface Config {
  app: {
    port: number;
    env: string;
  };
  database: {
    type: string;
    path: string;
  };
  jwt: {
    secret: string;
    expiresIn: string;
  };
  // ... 其他配置类型
}


================================================
FILE: backend/src/types/services.ts
================================================
import { Request } from "express";
import { ShareInfoResponse, FolderListResponse, SaveFileParams } from "./cloud";

export interface ICloudStorageService {
  setCookie(req: Request): Promise<void>;
  getShareInfo(shareCode: string, receiveCode?: string): Promise<ShareInfoResponse>;
  getFolderList(parentCid?: string): Promise<FolderListResponse>;
  saveSharedFile(params: SaveFileParams): Promise<any>;
}


================================================
FILE: backend/src/utils/axiosInstance.ts
================================================
import axios, { AxiosInstance, AxiosRequestHeaders } from "axios";
import tunnel from "tunnel";

interface ProxyConfig {
  host: string;
  port: number;
}

export function createAxiosInstance(
  baseURL: string,
  headers?: AxiosRequestHeaders,
  useProxy: boolean = false,
  proxyConfig?: ProxyConfig
): AxiosInstance {
  let agent;
  if (useProxy && proxyConfig) {
    agent = tunnel.httpsOverHttp({
      proxy: proxyConfig,
    });
  }

  return axios.create({
    baseURL,
    timeout: 30000,
    headers,
    httpsAgent: useProxy ? agent : undefined,
    withCredentials: true,
  });
}


================================================
FILE: backend/src/utils/handleError.ts
================================================
import { Response, NextFunction } from "express";
import { logger } from "../utils/logger";

interface CustomError {
  name?: string;
  message: string;
  success?: boolean;
}

export default function handleError(
  res: Response,
  error: CustomError | unknown,
  message: string,
  next: NextFunction
) {
  logger.error(message, error);
  next(error || { success: false, message });
}


================================================
FILE: backend/src/utils/index.ts
================================================
import jwt from "jsonwebtoken";
import { Request } from "express";
import { config } from "../config";

interface JwtPayload {
  userId: string;
}

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


================================================
FILE: backend/src/utils/logger.ts
================================================
import winston from "winston";
import { config } from "../config";

const logger = winston.createLogger({
  level: config.app.env === "development" ? "debug" : "info",
  format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
  transports: [
    new winston.transports.File({ filename: "logs/error.log", level: "error" }),
    new winston.transports.File({ filename: "logs/combined.log" }),
  ],
});

if (config.app.env !== "production") {
  logger.add(
    new winston.transports.Console({
      format: winston.format.simple(),
    })
  );
}

export { logger };


================================================
FILE: backend/src/utils/response.ts
================================================
import { Response } from "express";

interface ResponseData {
  code?: number; // 业务状态码
  message?: string;
  data?: any;
}

export const sendSuccess = (res: Response, response: ResponseData, businessCode: number = 0) => {
  response.code = businessCode;
  res.status(200).json(response);
};

export const sendError = (res: Response, response: ResponseData, businessCode: number = 10000) => {
  response.code = businessCode;
  res.status(200).json(response);
};


================================================
FILE: backend/src/utils/responseHandler.ts
================================================
import { Response } from "express";

export const handleResponse = (res: Response, data: any, success: boolean) => {
  res.json({ success, data });
};


================================================
FILE: backend/tsconfig.json
================================================
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "baseUrl": ".",
    "typeRoots": ["./node_modules/@types", "./src/types"],
    "paths": {
      "@/*": ["src/*"]
    },
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}


================================================
FILE: docker-entrypoint.sh
================================================
#!/bin/sh

# 如果配置目录下没有 env 文件,则复制示例文件
if [ ! -f /app/config/env ]; then
    cp /app/.env.example /app/config/env
    echo "已创建默认配置文件 /app/config/env,请根据需要修改配置"
fi

# 创建配置文件软链接
ln -sf /app/config/env /app/.env

# 启动 Nginx 和后端服务
nginx -g 'daemon off;' & npm run start 

================================================
FILE: frontend/.env
================================================
VITE_API_BASE_URL=""
VITE_API_BASE_URL_PROXY="http://127.0.0.1:8009"

================================================
FILE: frontend/auto-imports.d.ts
================================================
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
export {}
declare global {
  const ElMessage: typeof import('element-plus/es')['ElMessage']
  const showConfirmDialog: typeof import('vant/es')['showConfirmDialog']
}


================================================
FILE: frontend/components.d.ts
================================================
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}

declare module 'vue' {
  export interface GlobalComponents {
    AsideMenu: typeof import('./src/components/AsideMenu.vue')['default']
    ElAside: typeof import('element-plus/es')['ElAside']
    ElBacktop: typeof import('element-plus/es')['ElBacktop']
    ElButton: typeof import('element-plus/es')['ElButton']
    ElCard: typeof import('element-plus/es')['ElCard']
    ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
    ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
    ElContainer: typeof import('element-plus/es')['ElContainer']
    ElDialog: typeof import('element-plus/es')['ElDialog']
    ElEmpty: typeof import('element-plus/es')['ElEmpty']
    ElForm: typeof import('element-plus/es')['ElForm']
    ElFormItem: typeof import('element-plus/es')['ElFormItem']
    ElHeader: typeof import('element-plus/es')['ElHeader']
    ElIcon: typeof import('element-plus/es')['ElIcon']
    ElImage: typeof import('element-plus/es')['ElImage']
    ElInput: typeof import('element-plus/es')['ElInput']
    ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
    ElLink: typeof import('element-plus/es')['ElLink']
    ElMain: typeof import('element-plus/es')['ElMain']
    ElMenu: typeof import('element-plus/es')['ElMenu']
    ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
    ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
    ElSwitch: typeof import('element-plus/es')['ElSwitch']
    ElTable: typeof import('element-plus/es')['ElTable']
    ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
    ElTabPane: typeof import('element-plus/es')['ElTabPane']
    ElTabs: typeof import('element-plus/es')['ElTabs']
    ElTag: typeof import('element-plus/es')['ElTag']
    ElTooltip: typeof import('element-plus/es')['ElTooltip']
    FolderSelect: typeof import('./src/components/Home/FolderSelect.vue')['default']
    ResourceCard: typeof import('./src/components/Home/ResourceCard.vue')['default']
    ResourceSelect: typeof import('./src/components/Home/ResourceSelect.vue')['default']
    ResourceTable: typeof import('./src/components/Home/ResourceTable.vue')['default']
    RouterLink: typeof import('vue-router')['RouterLink']
    RouterView: typeof import('vue-router')['RouterView']
    SearchBar: typeof import('./src/components/SearchBar.vue')['default']
    VanBackTop: typeof import('vant/es')['BackTop']
    VanButton: typeof import('vant/es')['Button']
    VanCell: typeof import('vant/es')['Cell']
    VanCellGroup: typeof import('vant/es')['CellGroup']
    VanCheckbox: typeof import('vant/es')['Checkbox']
    VanCheckboxGroup: typeof import('vant/es')['CheckboxGroup']
    VanEmpty: typeof import('vant/es')['Empty']
    VanField: typeof import('vant/es')['Field']
    VanForm: typeof import('vant/es')['Form']
    VanIcon: typeof import('vant/es')['Icon']
    VanImage: typeof import('vant/es')['Image']
    VanLoading: typeof import('vant/es')['Loading']
    VanOverlay: typeof import('vant/es')['Overlay']
    VanPopup: typeof import('vant/es')['Popup']
    VanSearch: typeof import('vant/es')['Search']
    VanSwitch: typeof import('vant/es')['Switch']
    VanTab: typeof import('vant/es')['Tab']
    VanTabbar: typeof import('vant/es')['Tabbar']
    VanTabbarItem: typeof import('vant/es')['TabbarItem']
    VanTabs: typeof import('vant/es')['Tabs']
    VanTag: typeof import('vant/es')['Tag']
  }
  export interface ComponentCustomProperties {
    vLoading: typeof import('element-plus/es')['ElLoadingDirective']
  }
}


================================================
FILE: frontend/index.html
================================================
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="/favicon.ico" />
    <!-- 移动端适配  -->
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"
    />
    <meta name="keywords" content="网盘,资源搜索,云存储" />
    <!-- SEO关键词 -->
    <meta name="description" content="网盘资源搜索工具" />
    <!-- 设置Web App描述 -->
    <meta name="theme-color" content="#ffffff" />
    <!-- 设置主题颜色 -->
    <meta property="og:title" content="CloudSaver" />
    <!-- 社交媒体分享标题 -->
    <meta property="og:description" content="网盘资源搜索工具" />
    <!-- 社交媒体分享描述 -->
    <meta property="og:url" content="https://github.com/jiangrui1994/CloudSaver" />
    <!-- 社交媒体分享链接 -->
    <meta name="twitter:card" content="summary" />
    <!-- Twitter卡片类型 -->
    <meta name="apple-mobile-web-app-capable" content="yes" />
    <!-- 开启Web App功能 -->
    <meta name="apple-mobile-web-app-status-bar-style" content="default" />
    <!-- 设置状态栏样式 -->
    <meta name="apple-mobile-web-app-title" content="CloudSaver" />
    <!-- 设置Web App标题 -->
    <link rel="apple-touch-icon" href="/logo-1.png" />
    <!-- 设置Web App图标 -->
    <link rel="mask-icon" href="/logo.svg" color="transparent" />
    <!-- 设置Web App图标遮罩 -->
    <meta name="referrer" content="no-referrer" />
    <title>CloudSaver</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>


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


================================================
FILE: frontend/postcss.config.cjs
================================================
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,
    },
  },
};


================================================
FILE: frontend/src/App.vue
================================================
<template>
  <el-config-provider>
    <router-view />
  </el-config-provider>
</template>

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

body {
  position: fixed;
  width: 100%;
  height: 100%;
  overflow: hidden;
}

/* 移动端全局样式 */
@media screen and (max-width: 768px) {
  #app {
    max-width: 100vw;
    overflow-x: hidden;
  }

  /* 统一按钮样式 */
  .van-button {
    height: 40px;
    font-size: var(--font-size-base);
    border-radius: var(--border-radius-base);
  }

  /* 统一输入框样式 */
  .van-field {
    font-size: var(--font-size-base);
  }

  /* 统一卡片样式 */
  .van-card {
    border-radius: var(--border-radius-base);
    margin: var(--spacing-base) 0;
  }
}
</style>


================================================
FILE: frontend/src/api/cloud115.ts
================================================
import request from "@/utils/request";
import type { ShareInfoResponse, Folder, SaveFileParams, GetShareInfoParams } from "@/types";

export const cloud115Api = {
  async getShareInfo(params: GetShareInfoParams) {
    const { data } = await request.get<ShareInfoResponse>("/api/cloud115/share-info", {
      params,
    });
    return data as ShareInfoResponse;
  },

  async getFolderList(parentCid = "0") {
    const res = await request.get<Folder[]>("/api/cloud115/folders", {
      params: { parentCid },
    });
    return res;
  },

  async saveFile(params: SaveFileParams) {
    const res = await request.post("/api/cloud115/save", params);
    return res;
  },
};


================================================
FILE: frontend/src/api/douban.ts
================================================
import request from "@/utils/request";
import { HotListItem, HotListParams } from "@/types/douban";

export const doubanApi = {
  async getHotList(params: HotListParams) {
    const { data } = await request.get<HotListItem[]>("/api/douban/hot", {
      params,
    });
    return data;
  },
};


================================================
FILE: frontend/src/api/quark.ts
================================================
import request from "@/utils/request";
import type { ShareInfoResponse, Folder, SaveFileParams, GetShareInfoParams } from "@/types";

export const quarkApi = {
  async getShareInfo(params: GetShareInfoParams) {
    const { data } = await request.get<ShareInfoResponse>("/api/quark/share-info", {
      params,
    });
    return data as ShareInfoResponse;
  },

  async getFolderList(parentCid = "0") {
    const data = await request.get<Folder[]>("/api/quark/folders", {
      params: { parentCid },
    });
    return data;
  },

  async saveFile(params: SaveFileParams) {
    return await request.post("/api/quark/save", params);
  },
};


================================================
FILE: frontend/src/api/resource.ts
================================================
import request from "@/utils/request";
import type { Resource } from "@/types/index";

export const resourceApi = {
  search(keyword: string, channelId?: string, lastMessageId?: string) {
    return request.get<Resource[]>(`/api/search`, {
      params: { keyword, channelId, lastMessageId },
    });
  },
};


================================================
FILE: frontend/src/api/setting.ts
================================================
import request from "@/utils/request";
import type { GlobalSettingAttributes, UserSettingAttributes } from "@/types";

export const settingApi = {
  getSetting: () => {
    return request.get<{
      userSettings: UserSettingAttributes;
      globalSetting: GlobalSettingAttributes;
    }>("/api/setting/get");
  },
  saveSetting: (data: {
    userSettings: UserSettingAttributes;
    globalSetting?: GlobalSettingAttributes | null;
  }) => {
    return request.post("/api/setting/save", data);
  },
};


================================================
FILE: frontend/src/api/user.ts
================================================
import request from "@/utils/request";

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


================================================
FILE: frontend/src/components/AsideMenu.vue
================================================
<template>
  <div class="pc-aside">
    <!-- Logo 区域 -->
    <div class="pc-aside__logo">
      <img :src="logo" alt="Cloud Saver Logo" class="logo__image" />
      <h1 class="logo__title">Cloud Saver</h1>
    </div>

    <!-- 菜单区域 -->
    <el-menu
      :default-active="currentMenu?.index || '1'"
      :default-openeds="currentMenuOpen"
      class="pc-aside__menu"
    >
      <template v-for="menu in menuList" :key="menu.index">
        <!-- 子菜单 -->
        <el-sub-menu v-if="menu.children" :index="menu.index">
          <template #title>
            <el-icon><component :is="menu.icon" /></el-icon>
            <span>{{ menu.title }}</span>
          </template>

          <el-menu-item
            v-for="child in menu.children"
            :key="child.index"
            :index="child.index"
            @click="handleMenuClick(child)"
          >
            <span>{{ child.title }}</span>
          </el-menu-item>
        </el-sub-menu>

        <!-- 普通菜单项 -->
        <el-menu-item
          v-else
          :index="menu.index"
          :disabled="menu.disabled"
          @click="handleMenuClick(menu)"
        >
          <el-icon><component :is="menu.icon" /></el-icon>
          <span>{{ menu.title }}</span>
        </el-menu-item>
      </template>
    </el-menu>

    <!-- GitHub 链接 -->
    <div class="pc-aside__footer">
      <a :href="PROJECT_GITHUB" target="_blank" rel="noopener noreferrer" class="github-link">
        <svg
          height="20"
          aria-hidden="true"
          viewBox="0 0 24 24"
          version="1.1"
          width="20"
          class="github-icon"
        >
          <path
            fill="currentColor"
            d="M12.5.75C6.146.75 1 5.896 1 12.25c0 5.089 3.292 9.387 7.863 10.91.575.101.79-.244.79-.546 0-.273-.014-1.178-.014-2.142-2.889.532-3.636-.704-3.866-1.35-.13-.331-.69-1.352-1.18-1.625-.402-.216-.977-.748-.014-.762.906-.014 1.553.834 1.769 1.179 1.035 1.74 2.688 1.25 3.349.948.1-.747.402-1.25.733-1.538-2.559-.287-5.232-1.279-5.232-5.678 0-1.25.445-2.285 1.178-3.09-.115-.288-.517-1.467.115-3.048 0 0 .963-.302 3.163 1.179.92-.259 1.897-.388 2.875-.388.977 0 1.955.13 2.875.388 2.2-1.495 3.162-1.179 3.162-1.179.633 1.581.23 2.76.115 3.048.733.805 1.179 1.825 1.179 3.09 0 4.413-2.688 5.39-5.247 5.678.417.36.776 1.05.776 2.128 0 1.538-.014 2.774-.014 3.162 0 .302.216.662.79.547C20.709 21.637 24 17.324 24 12.25 24 5.896 18.854.75 12.5.75Z"
          />
        </svg>
        <span>GitHub</span>
        <span class="version">v{{ pkg.version }}</span>
      </a>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed } from "vue";
import { useRouter, useRoute } from "vue-router";
import { Search, Film, Setting, Link } from "@element-plus/icons-vue";
import logo from "@/assets/images/logo.png";
import { PROJECT_GITHUB } from "@/constants/project";
import pkg from "../../package.json";

// 类型定义
interface MenuItem {
  index: string;
  title: string;
  icon?: typeof Search | typeof Film | typeof Setting | typeof Link;
  router?: string;
  children?: MenuItem[];
  disabled?: boolean;
}

// 路由相关
const router = useRouter();
const route = useRoute();

// 菜单配置
const menuList: MenuItem[] = [
  {
    index: "1",
    title: "资源搜索",
    icon: Search,
    router: "/resource",
  },
  {
    index: "2",
    title: "豆瓣榜单",
    icon: Film,
    children: [
      {
        index: "2-1",
        title: "热门电影",
        router: "/douban?type=movie",
      },
      {
        index: "2-2",
        title: "热门电视剧",
        router: "/douban?type=tv",
      },
      {
        index: "2-3",
        title: "最新电影",
        router: "/douban?type=movie&tag=最新",
      },
      {
        index: "2-4",
        title: "热门综艺",
        router: "/douban?type=tv&tag=综艺",
      },
    ],
  },
  {
    index: "3",
    title: "设置",
    icon: Setting,
    router: "/setting",
    disabled: false,
  },
  {
    index: "4",
    title: "鸣谢",
    icon: Link,
    router: "/thanks",
  },
];

// 计算当前激活的菜单
const currentMenu = computed(() => {
  const flatMenus = menuList.reduce<MenuItem[]>((acc, menu) => {
    if (!menu.children) {
      acc.push(menu);
    } else {
      acc.push(...menu.children);
    }
    return acc;
  }, []);

  return flatMenus.find((menu) => menu.router === decodeURIComponent(route.fullPath));
});

// 计算当前展开的子菜单
const currentMenuOpen = computed(() => {
  if (currentMenu.value?.index.includes("-")) {
    return [currentMenu.value.index.split("-")[0]];
  }
  return [];
});

// 菜单点击处理
const handleMenuClick = (menu: MenuItem) => {
  if (menu.router) {
    router.push(menu.router);
  }
};
</script>

<style lang="scss" scoped>
@import "@/styles/common.scss";

.pc-aside {
  height: 100%;
  background: var(--theme-card-bg);
  border-right: 1px solid rgba(0, 0, 0, 0.1);

  // Logo 区域
  &__logo {
    @include flex-center;
    padding: 24px 16px;
    gap: 12px;

    .logo__image {
      width: 32px;
      height: 32px;
      object-fit: contain;
    }

    .logo__title {
      margin: 0;
      font-size: 18px;
      font-weight: 600;
      color: var(--theme-text-primary);
      @include text-overflow;
    }
  }

  // 菜单区域
  &__menu {
    border-right: none;
    background: transparent;

    :deep(.el-menu-item) {
      height: 48px;
      line-height: 48px;
      color: var(--theme-text-regular);

      &.is-active {
        color: var(--theme-primary);
        background: rgba(0, 102, 204, 0.1);
      }

      &:hover {
        color: var(--theme-primary);
        background: rgba(0, 102, 204, 0.05);
      }
    }

    :deep(.el-sub-menu) {
      .el-sub-menu__title {
        color: var(--theme-text-regular);

        &:hover {
          color: var(--theme-primary);
          background: rgba(0, 102, 204, 0.05);
        }
      }
    }

    :deep(.el-icon) {
      font-size: 18px;
      margin-right: 12px;
      color: inherit;
    }
  }

  // GitHub 链接区域
  &__footer {
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    padding: 16px;
    border-top: 1px solid rgba(0, 0, 0, 0.1);
    background: var(--theme-card-bg);

    .github-link {
      @include flex-center;
      gap: 8px;
      padding: 12px;
      color: var(--theme-text-regular);
      text-decoration: none;
      border-radius: var(--theme-radius);
      transition: var(--theme-transition);

      .github-icon {
        font-size: 20px;
        transition: var(--theme-transition);
      }

      .version {
        font-size: 12px;
        opacity: 0.7;
        margin-left: 4px;
      }

      &:hover {
        color: var(--theme-primary);
        background: rgba(0, 102, 204, 0.05);
        transform: translateY(-1px);

        .github-icon {
          transform: scale(1.1);
        }

        .version {
          opacity: 1;
        }
      }
    }
  }
}

// 自定义滚动条
.pc-aside__menu {
  height: calc(100vh - 80px - 69px); // 减去 logo 高度和 footer 高度
  overflow-y: auto;

  &::-webkit-scrollbar {
    width: 6px;
  }

  &::-webkit-scrollbar-thumb {
    background: rgba(0, 0, 0, 0.2);
    border-radius: 3px;

    &:hover {
      background: rgba(0, 0, 0, 0.3);
    }
  }

  &::-webkit-scrollbar-track {
    background: transparent;
  }
}
</style>


================================================
FILE: frontend/src/components/Home/FolderSelect.vue
================================================
<template>
  <div class="folder-select">
    <div class="folder-header">
      <div class="folder-path">
        <el-icon><FolderOpened /></el-icon>
        <template v-if="folderPath.length">
          <span
            v-for="(folder, index) in folderPath"
            :key="folder.cid"
            class="path-item"
            @click="handlePathClick(index)"
          >
            <span class="folder-name">{{ folder.name }}</span>
            <el-icon v-if="index < folderPath.length - 1"><ArrowRight /></el-icon>
          </span>
        </template>
        <span v-else class="root-path" @click="handlePathClick(-1)">根目录</span>
      </div>
    </div>

    <div class="folder-list">
      <div v-if="!folders.length" class="empty-folder">
        <el-empty description="暂无文件夹" />
      </div>
      <div
        v-for="folder in folders"
        :key="folder.cid"
        class="folder-item"
        :class="{ 'is-selected': folder.cid === selectedFolder?.cid }"
        @click="handleFolderClick(folder)"
      >
        <div class="folder-info">
          <el-icon><Folder /></el-icon>
          <span class="folder-name">{{ folder.name }}</span>
        </div>
        <el-icon class="arrow-icon"><ArrowRight /></el-icon>
      </div>
    </div>

    <div v-if="loading" class="loading-overlay">
      <el-icon class="loading-icon"><Loading /></el-icon>
      <span>加载中...</span>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, defineProps } from "vue";
import { cloud115Api } from "@/api/cloud115";
import { quarkApi } from "@/api/quark";
import type { Folder as FolderType } from "@/types";
import { Folder, FolderOpened, ArrowRight, Loading } from "@element-plus/icons-vue";

import { ElMessage } from "element-plus";

const props = defineProps({
  cloudType: {
    type: String,
    required: true,
  },
});

const loading = ref(false);
const folders = ref<FolderType[]>([]);
const selectedFolder = ref<FolderType | null>(null);
const folderPath = ref<FolderType[]>([{ name: "根目录", cid: "0" }]);
const emit = defineEmits<{
  (e: "select", folderId: string): void;
  (e: "close"): void;
}>();

const cloudTypeApiMap = {
  pan115: cloud115Api,
  quark: quarkApi,
};

const getList = async (cid: string = "0") => {
  const api = cloudTypeApiMap[props.cloudType as keyof typeof cloudTypeApiMap];
  loading.value = true;
  try {
    const res = await api.getFolderList?.(cid);
    if (res?.code === 0) {
      folders.value = res.data || [];
    } else {
      throw new Error(res?.message);
    }
  } catch (error) {
    ElMessage.error(error instanceof Error ? error.message : "获取目录失败");
    emit("close");
  } finally {
    loading.value = false;
  }
};

const handleFolderClick = async (folder: FolderType) => {
  selectedFolder.value = folder;
  folderPath.value = [...folderPath.value, folder];
  emit("select", folder.cid);
  await getList(folder.cid);
};

const handlePathClick = async (index: number) => {
  if (index < 0) {
    // 点击根目录
    folderPath.value = [{ name: "根目录", cid: "0" }];
    selectedFolder.value = null;
    await getList("0");
  } else {
    // 点击路径中的某个文件夹
    const targetFolder = folderPath.value[index];
    folderPath.value = folderPath.value.slice(0, index + 1);
    selectedFolder.value = targetFolder;
    await getList(targetFolder.cid);
    emit("select", targetFolder.cid);
  }
};

// 初始化加载
getList();
</script>

<style lang="scss" scoped>
@import "@/styles/common.scss";

.folder-select {
  position: relative;
  min-height: 300px;
  max-height: 500px;
  display: flex;
  flex-direction: column;
  padding: 4px;

  .folder-header {
    position: sticky;
    top: 0;
    z-index: 1;
    margin-bottom: 16px;
    padding: 12px 16px;
    background: var(--el-fill-color-light);
    border-radius: var(--theme-radius);

    .folder-path {
      display: flex;
      align-items: center;
      gap: 8px;
      color: var(--theme-text-regular);
      font-size: 14px;
      overflow-x: auto;

      &::-webkit-scrollbar {
        height: 4px;
      }

      &::-webkit-scrollbar-thumb {
        background: rgba(0, 0, 0, 0.1);
        border-radius: 2px;
      }

      .el-icon {
        flex-shrink: 0;
        font-size: 16px;
        color: var(--theme-primary);
      }

      .path-item {
        display: flex;
        align-items: center;
        gap: 8px;
        white-space: nowrap;
        cursor: pointer;
        transition: var(--theme-transition);

        &:hover {
          color: var(--theme-primary);

          .folder-name {
            color: var(--theme-primary);
          }
        }

        .folder-name {
          color: var(--theme-text-primary);
        }
      }

      .root-path {
        color: var(--theme-text-secondary);
        cursor: pointer;
        transition: var(--theme-transition);

        &:hover {
          color: var(--theme-primary);
        }
      }
    }
  }
}

.folder-list {
  flex: 1;
  overflow-y: auto;
  padding: 4px;

  .folder-item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 12px 16px;
    border-radius: var(--theme-radius);
    cursor: pointer;
    transition: var(--theme-transition);

    &:hover {
      background: var(--el-fill-color-light);
    }

    &.is-selected {
      background: var(--el-color-primary-light-9);
      color: var(--theme-primary);

      .el-icon {
        color: var(--theme-primary);
      }
    }

    .folder-info {
      display: flex;
      align-items: center;
      gap: 8px;
      font-size: 14px;

      .el-icon {
        font-size: 16px;
        color: var(--theme-text-regular);
      }

      .folder-name {
        color: var(--theme-text-primary);
      }
    }

    .arrow-icon {
      font-size: 16px;
      color: var(--theme-text-secondary);
    }
  }
}

.empty-folder {
  padding: 32px 0;
}

.loading-overlay {
  @include flex-center;
  position: absolute;
  inset: 0;
  background: rgba(255, 255, 255, 0.9);
  backdrop-filter: blur(4px);
  gap: 8px;
  font-size: 14px;
  color: var(--theme-text-regular);

  .loading-icon {
    font-size: 20px;
    animation: rotating 2s linear infinite;
  }
}

@keyframes rotating {
  from {
    transform: rotate(0);
  }
  to {
    transform: rotate(360deg);
  }
}
</style>


================================================
FILE: frontend/src/components/Home/ResourceCard.vue
================================================
<template>
  <div class="resource-card">
    <!-- 详情弹窗 -->
    <el-dialog
      v-model="showDetail"
      :title="currentResource?.title"
      width="700px"
      class="resource-detail-dialog"
      destroy-on-close
    >
      <div v-if="currentResource" class="detail-content">
        <div class="detail-cover">
          <el-image
            class="cover-image"
            :src="getProxyImageUrl(currentResource.image as string)"
            :fit="currentResource.image ? 'cover' : 'contain'"
          />
          <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">
            <div class="tags-list">
              <el-tag
                v-for="tag in currentResource.tags"
                :key="tag"
                class="tag-item"
                @click="searchMovieforTag(tag)"
              >
                {{ tag }}
              </el-tag>
            </div>
          </div>
        </div>
      </div>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" plain @click="currentResource && handleJump(currentResource)"
            >跳转</el-button
          >
          <el-button
            v-if="currentResource?.isSupportSave"
            type="primary"
            @click="currentResource && handleSave(currentResource)"
            >转存</el-button
          >
        </div>
      </template>
    </el-dialog>

    <div v-for="group in store.resources" :key="group.id" class="resource-group">
      <div
        :class="{ 'group-header': true, 'is-active': group.displayList }"
        @click="group.displayList = !group.displayList"
      >
        <el-link
          class="group-title"
          :href="`https://t.me/s/${group.id}`"
          target="_blank"
          :underline="false"
          @click.stop
        >
          <el-image
            :src="getProxyImageUrl(group.channelInfo.channelLogo)"
            :fit="group.channelInfo.channelLogo ? 'cover' : 'contain'"
            class="channel-logo"
            scroll-container="#pc-resources-content"
            loading="lazy"
          />
          <span>{{ group.channelInfo.name }}</span>
          <span class="item-count">({{ group.list.length }})</span>
        </el-link>

        <el-tooltip effect="dark" :content="group.displayList ? '收起' : '展开'" placement="top">
          <el-button class="toggle-btn" type="text">
            <el-icon :class="{ 'is-active': group.displayList }">
              <ArrowDown />
            </el-icon>
          </el-button>
        </el-tooltip>
      </div>

      <div v-if="group.displayList" class="group-content">
        <div class="card-grid">
          <el-card
            v-for="resource in group.list"
            :key="resource.messageId"
            class="resource-card-item"
            :body-style="{ padding: '0' }"
          >
            <div class="card-wrapper">
              <div class="card-cover">
                <el-image
                  loading="lazy"
                  class="cover-image"
                  :src="getProxyImageUrl(resource.image as string)"
                  :fit="resource.image ? 'cover' : 'contain'"
                  :alt="resource.title"
                  @click="showResourceDetail(resource)"
                />
                <el-tag
                  class="cloud-type"
                  :type="store.tagColor[resource.cloudType as keyof TagColor]"
                  effect="dark"
                  round
                  size="small"
                >
                  {{ resource.cloudType }}
                </el-tag>
              </div>

              <div class="card-body">
                <el-link
                  class="card-title"
                  :href="resource.cloudLinks[0]"
                  target="_blank"
                  :underline="false"
                >
                  {{ resource.title }}
                </el-link>

                <div
                  class="card-content"
                  @click="showResourceDetail(resource)"
                  v-html="resource.content"
                />

                <div v-if="resource.tags?.length" class="card-tags">
                  <div class="tags-list">
                    <el-tag
                      v-for="tag in resource.tags"
                      :key="tag"
                      class="tag-item"
                      @click="searchMovieforTag(tag)"
                    >
                      {{ tag }}
                    </el-tag>
                  </div>
                </div>

                <div class="card-footer">
                  <el-button type="primary" plain @click="handleJump(resource)">跳转</el-button>
                  <el-button
                    v-if="resource.isSupportSave"
                    type="primary"
                    @click="handleSave(resource)"
                    >转存</el-button
                  >
                </div>
              </div>
            </div>
          </el-card>
        </div>

        <div class="load-more">
          <el-button :loading="group.loading" @click="handleLoadMore(group.id)">
            <el-icon><Plus /></el-icon>
            加载更多
          </el-button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useResourceStore } from "@/stores/resource";
import { ref } from "vue";
import type { ResourceItem, TagColor } from "@/types";
import { ArrowDown, Plus } from "@element-plus/icons-vue";
import { getProxyImageUrl } from "@/utils/image";

const store = useResourceStore();

const showDetail = ref(false);
const currentResource = ref<ResourceItem | null>(null);

const emit = defineEmits(["save", "loadMore", "jump", "searchMovieforTag"]);

const handleSave = (resource: ResourceItem) => {
  if (showDetail.value) {
    showDetail.value = false;
  }
  emit("save", resource);
};

const handleJump = (resource: ResourceItem) => {
  emit("jump", resource);
};

const showResourceDetail = (resource: ResourceItem) => {
  currentResource.value = resource;
  showDetail.value = true;
};

const searchMovieforTag = (tag: string) => {
  emit("searchMovieforTag", tag);
};

const handleLoadMore = (channelId: string) => {
  emit("loadMore", channelId);
};
</script>

<style lang="scss" scoped>
@import "@/styles/common.scss";

.resource-card {
  position: relative;
  height: 100%;

  // 资源组
  .resource-group {
    background: var(--theme-card-bg);
    backdrop-filter: var(--theme-blur);
    -webkit-backdrop-filter: var(--theme-blur);
    margin-bottom: 24px;
    border-radius: var(--theme-radius);
    border: 1px solid rgba(0, 0, 0, 0.08);
    transition: var(--theme-transition);

    &:last-child {
      margin-bottom: 100px;
    }
  }

  // 组标题
  .group-header {
    @include flex-center;
    justify-content: space-between;
    padding: 12px 20px;
    border-bottom: 1px solid rgba(0, 0, 0, 0.06);
    position: sticky;
    top: 0;
    background: var(--theme-card-bg);
    backdrop-filter: var(--theme-blur);
    -webkit-backdrop-filter: var(--theme-blur);
    z-index: 10;
    border-radius: var(--theme-radius);
    overflow: hidden;
    cursor: pointer;

    &.is-active {
      border-radius: var(--theme-radius) var(--theme-radius) 0 0;
    }

    .group-title {
      @include flex-center;
      gap: 12px;
      font-size: 16px;
      color: var(--theme-text-primary);
      transition: var(--theme-transition);

      .channel-logo {
        width: 32px;
        height: 32px;
        border-radius: 50%;
        overflow: hidden;
        box-shadow: var(--theme-shadow-sm);
        margin-right: 8px;
      }

      .item-count {
        font-size: 13px;
        color: var(--theme-text-secondary);
      }

      &:hover {
        color: var(--theme-primary);
        transform: translateY(-1px);
      }
    }

    .toggle-btn {
      width: 32px;
      height: 32px;
      padding: 0;
      color: var(--theme-text-regular);
      transition: var(--theme-transition);

      .el-icon {
        font-size: 16px;
        transition: transform 0.3s ease;

        &.is-active {
          transform: rotate(180deg);
        }
      }

      &:hover {
        color: var(--theme-primary);
        transform: translateY(-1px);
      }
    }
  }

  // 组内容
  .group-content {
    padding: 20px;
  }

  // 卡片网格
  .card-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
    gap: 24px;
    grid-auto-rows: min-content;
  }

  // 资源卡片
  .resource-card-item {
    border-radius: var(--theme-radius);
    transition: var(--theme-transition);
    overflow: hidden;
    max-width: 460px;
    margin: 0 auto;
    width: 100%;
    height: fit-content;

    &:hover {
      transform: translateY(-2px);
      box-shadow: var(--theme-shadow);
    }

    .card-wrapper {
      display: flex;
      gap: 20px;
      padding: 16px;
      height: 100%;
    }

    .card-cover {
      position: relative;
      width: 120px;
      height: 180px;
      flex-shrink: 0;

      .cover-image {
        width: 100%;
        height: 100%;
        object-fit: cover;
        border-radius: var(--theme-radius);
        cursor: pointer;
        transition: opacity 0.3s ease;

        &:hover {
          opacity: 0.85;
        }
      }

      .cloud-type {
        position: absolute;
        top: 8px;
        left: 8px;
        z-index: 1;
      }
    }

    .card-body {
      flex: 1;
      min-width: 0;
      display: flex;
      flex-direction: column;
      gap: 12px;

      .card-title {
        display: -webkit-box;
        -webkit-line-clamp: 2;
        -webkit-box-orient: vertical;
        overflow: hidden;
        font-size: 16px;
        line-height: 1.5;
        color: var(--theme-text-primary);
        word-break: break-word;
        height: 3em;
        transition: var(--theme-transition);

        &:hover {
          color: var(--theme-primary);
        }
      }

      .card-content {
        display: -webkit-box;
        -webkit-line-clamp: 3;
        -webkit-box-orient: vertical;
        overflow: hidden;
        font-size: 14px;
        line-height: 1.6;
        color: var(--theme-text-regular);
        cursor: pointer;
        transition: color 0.3s ease;

        &:hover {
          color: var(--theme-text-primary);
        }
      }

      .card-tags {
        margin-top: auto;
        max-height: 88px;
        overflow: hidden;

        .tags-label {
          font-size: 13px;
          color: var(--theme-text-secondary);
          margin-right: 8px;
          display: block;
          margin-bottom: 8px;
        }

        .tags-list {
          display: flex;
          flex-wrap: wrap;
          gap: 8px;
          max-height: 72px;
          overflow: hidden;

          .tag-item {
            cursor: pointer;
            transition: var(--theme-transition);
            margin: 0;
            height: 24px;

            &:hover {
              color: var(--theme-primary);
              border-color: var(--theme-primary);
              transform: translateY(-1px);
            }
          }
        }
      }
    }

    .card-footer {
      @include flex-center;
      justify-content: flex-end;
      margin-top: 8px;

      .el-button {
        padding: 6px 16px;
        font-size: 14px;
        height: 32px;
        min-width: 80px;

        &:hover {
          transform: translateY(-1px);
          box-shadow: var(--theme-shadow-sm);
        }
      }
    }
  }

  // 加载更多
  .load-more {
    @include flex-center;
    position: relative;
    padding: 32px 0 8px;
    margin-top: 16px;

    &::before {
      content: "";
      position: absolute;
      left: 0;
      right: 0;
      top: 0;
      height: 1px;
      background: linear-gradient(
        90deg,
        transparent,
        var(--el-border-color-lighter) 20%,
        var(--el-border-color-lighter) 80%,
        transparent
      );
    }

    .el-button {
      min-width: 160px;
      height: 40px;
      border-radius: 20px;
      font-size: 14px;
      color: var(--theme-text-regular);
      background: var(--theme-card-bg);
      border: 1px solid var(--el-border-color-lighter);
      transition: var(--theme-transition);
      position: relative;
      overflow: hidden;

      &::after {
        content: "";
        position: absolute;
        inset: 0;
        background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
        transform: translateX(-100%);
        transition: transform 0.6s ease;
      }

      &:hover {
        color: var(--theme-primary);
        border-color: var(--theme-primary);
        background: var(--el-color-primary-light-9);

        &::after {
          transform: translateX(100%);
        }
      }

      &.is-loading {
        color: var(--theme-text-secondary);

        &::after {
          display: none;
        }
      }

      .el-icon {
        margin-right: 6px;
        font-size: 16px;
      }
    }
  }

  // 详情弹窗样式
  .resource-detail-dialog {
    :deep(.el-dialog__body) {
      padding: 20px;
    }

    .detail-content {
      display: flex;
      gap: 24px;
    }

    .detail-cover {
      position: relative;
      width: 200px;
      flex-shrink: 0;

      .cover-image {
        width: 100%;
        height: 300px;
        border-radius: var(--theme-radius);
        overflow: hidden;
      }

      .cloud-type {
        position: absolute;
        top: 8px;
        left: 8px;
        z-index: 1;
      }
    }

    .detail-info {
      flex: 1;
      min-width: 0;

      .detail-title {
        font-size: 18px;
        margin: 0 0 16px;
        line-height: 1.5;
        color: var(--theme-text-primary);
      }

      .detail-description {
        font-size: 14px;
        line-height: 1.6;
        color: var(--theme-text-regular);
        margin-bottom: 20px;
      }

      .detail-tags {
        .tags-label {
          font-size: 13px;
          color: var(--theme-text-secondary);
          margin-right: 8px;
        }

        .tags-list {
          display: flex;
          flex-wrap: wrap;
          gap: 8px;
          margin-top: 8px;

          .tag-item {
            cursor: pointer;
            transition: var(--theme-transition);

            &:hover {
              color: var(--theme-primary);
              border-color: var(--theme-primary);
              transform: translateY(-1px);
            }
          }
        }
      }
    }

    .dialog-footer {
      display: flex;
      justify-content: flex-end;
      padding-top: 16px;
    }
  }
}
</style>


================================================
FILE: frontend/src/components/Home/ResourceSelect.vue
================================================
<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>


================================================
FILE: frontend/src/components/Home/ResourceTable.vue
================================================
<template>
  <el-table
    v-loading="store.loading"
    class="resource-list-table"
    :data="store.resources"
    style="width: 100%"
    row-key="id"
    :default-expand-all="false"
  >
    <el-table-column type="expand">
      <template #default="props">
        <el-table :data="props.row.list" style="width: 100%">
          <el-table-column label="图片" width="80">
            <template #default="{ row }">
              <el-image
                v-if="row.image"
                class="table-item-image"
                :src="getProxyImageUrl(row.image as string)"
                :fit="row.image ? 'cover' : 'contain'"
                width="30"
                height="60"
              />
              <el-icon v-else size="20"><Close /></el-icon>
            </template>
          </el-table-column>
          <el-table-column prop="title" label="标题" width="280">
            <template #default="{ row }">
              <el-link :href="row.cloudLinks[0]" target="_blank" style="font-weight: bold">
                {{ row.title }}
              </el-link>
            </template>
          </el-table-column>
          <el-table-column prop="title" label="描述">
            <template #default="{ row }">
              <div class="item-description" v-html="row.content"></div>
            </template>
          </el-table-column>
          <el-table-column prop="tags" label="标签">
            <template #default="{ row }">
              <div v-if="row.tags.length > 0" class="tags-list">
                <span>标签:</span>
                <el-tag
                  v-for="item in row.tags"
                  :key="item"
                  class="resource_tag"
                  @click="searchMovieforTag(item)"
                >
                  {{ item }}
                </el-tag>
              </div>
              <span v-else>无</span>
            </template>
          </el-table-column>
          <el-table-column label="云盘类型" width="120">
            <template #default="{ row }">
              <el-tag :type="store.tagColor[row.cloudType as keyof TagColor]" effect="dark" round>
                {{ row.cloudType }}
              </el-tag>
            </template>
          </el-table-column>
          <el-table-column label="操作" width="180">
            <template #default="{ row }">
              <el-button type="primary" plain @click="handleJump(row)">跳转</el-button>
              <el-button v-if="row.isSupportSave" @click="handleSave(row)">转存</el-button>
            </template>
          </el-table-column>
        </el-table>
        <div class="load-more">
          <el-button :loading="props.row.loading" @click="handleLoadMore(props.row.id)">
            加载更多
          </el-button>
        </div>
      </template>
    </el-table-column>
    <el-table-column label="来源" prop="channel">
      <template #default="{ row }">
        <div class="group-header">
          <el-image
            :src="getProxyImageUrl(row.channelInfo.channelLogo as string)"
            class="channel-logo"
            :fit="row.channelInfo.channelLogo ? 'cover' : 'contain'"
            lazy
          />
          <span>{{ row.channelInfo.name }}</span>
          <span class="item-count">({{ row.list.length }})</span>
        </div>
      </template>
    </el-table-column>
  </el-table>
</template>

<script setup lang="ts">
import { useResourceStore } from "@/stores/resource";
import type { Resource, TagColor } from "@/types";
import { getProxyImageUrl } from "@/utils/image";

const store = useResourceStore();
const emit = defineEmits(["save", "loadMore", "searchMovieforTag", "jump"]);

const handleSave = (resource: Resource) => {
  emit("save", resource);
};

const handleJump = (resource: Resource) => {
  emit("jump", resource);
};

// 添加加载更多处理函数
const handleLoadMore = (channelId: string) => {
  emit("loadMore", channelId);
};

const searchMovieforTag = (tag: string) => {
  emit("searchMovieforTag", tag);
};
</script>

<style scoped>
.resource-list-table {
  border-radius: 15px;
}

.group-header {
  display: flex;
  align-items: center;
  gap: 8px;
}
.channel-logo {
  width: 20px;
  height: 20px;
  margin-right: 10px;
  border-radius: 50%;
  overflow: hidden;
}

.table-item-image {
  border-radius: 10px;
  width: 100%;
}

.item-count {
  color: #909399;
  font-size: 0.9em;
}
.tags-list {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  flex-wrap: wrap;
}
.resource_tag {
  cursor: pointer;
  margin-right: 10px;
  margin-bottom: 5px;
}
.item-description {
  max-width: 100%;
  margin: 15px 0;
  -webkit-box-orient: vertical;
  display: -webkit-box;
  line-clamp: 2;
  -webkit-line-clamp: 2;
  overflow: hidden;
  white-space: all;
}

:deep(.el-table__expand-column) {
  .cell {
    padding: 0 !important;
  }
}

:deep(.el-table__expanded-cell) {
  padding: 20px !important;
}

:deep(.el-table__expand-icon) {
  height: 23px;
  line-height: 23px;
}
.load-more {
  display: flex;
  justify-content: center;
  padding: 16px 0;
}

.resource-table {
  position: relative;
  height: auto;
  overflow: visible;
}
</style>


================================================
FILE: frontend/src/components/SearchBar.vue
================================================
<template>
  <div class="pc-search">
    <!-- 搜索区域 -->
    <div class="pc-search__input">
      <el-input
        v-model="keyword"
        placeholder="请输入搜索关键词或输入链接直接解析"
        clearable
        @keyup.enter="handleSearch"
      >
        <template #prefix>
          <el-icon><Search /></el-icon>
        </template>
        <template #suffix>
          <el-icon v-if="keyword" class="search-icon" @click="handleSearch">
            <ArrowRight />
          </el-icon>
        </template>
      </el-input>
    </div>

    <!-- 用户操作区 -->
    <div class="pc-search__actions">
      <el-tooltip effect="dark" content="退出登录" placement="bottom">
        <el-button class="logout-btn" type="text" @click="handleLogout">
          <el-icon><SwitchButton /></el-icon>
        </el-button>
      </el-tooltip>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { useResourceStore } from "@/stores/resource";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Search, ArrowRight, SwitchButton } from "@element-plus/icons-vue";
import { STORAGE_KEYS } from "@/constants/storage";

// 路由相关
const route = useRoute();
const router = useRouter();
const resourcStore = useResourceStore();

// 响应式数据
const keyword = ref("");
const routeKeyword = computed(() => route.query.keyword as string);

// 退出登录
const handleLogout = () => {
  localStorage.removeItem(STORAGE_KEYS.TOKEN);
  router.push("/login");
  ElMessage.success("已退出登录");
};

// 搜索处理
const handleSearch = async () => {
  const searchText = keyword.value.trim();
  if (!searchText) {
    ElMessage.warning("请输入搜索内容");
    return;
  }

  // 链接解析处理
  if (searchText.startsWith("http")) {
    await resourcStore.parsingCloudLink(searchText);
    return;
  }

  // 关键词搜索
  await resourcStore.searchResources(searchText);
  if (route.path !== "/resource") {
    router.push("/resource");
  }
};

// 监听路由参数变化
watch(
  () => routeKeyword.value,
  (newKeyword) => {
    if (newKeyword) {
      keyword.value = newKeyword;
      handleSearch();
    } else {
      keyword.value = resourcStore.keyword;
    }
  }
);
watch(
  () => resourcStore.keyword,
  (newKeyword) => {
    keyword.value = newKeyword;
  }
);
</script>

<style lang="scss" scoped>
@import "@/styles/common.scss";

.pc-search {
  @include flex-center;
  justify-content: space-between;
  gap: 16px;
  width: 100%;

  // 搜索输入区域
  &__input {
    flex: 1;
    min-width: 0; // 防止溢出

    :deep(.el-input) {
      --el-input-height: 44px;

      .el-input__wrapper {
        @include glass-effect;
        padding: 0 16px;
        border-radius: var(--theme-radius);
        box-shadow:
          inset 0 0 0 1px rgba(255, 255, 255, 0.1),
          0 2px 4px rgba(0, 0, 0, 0.05),
          0 1px 2px rgba(0, 0, 0, 0.1);
        border: 1px solid rgba(0, 0, 0, 0.08);
        transition: var(--theme-transition);
        background: rgba(255, 255, 255, 0.9);

        &:hover {
          border-color: var(--theme-primary);
          box-shadow:
            inset 0 0 0 1px var(--theme-primary),
            0 4px 8px rgba(0, 0, 0, 0.1);
        }

        &.is-focus {
          border-color: var(--theme-primary);
          box-shadow:
            inset 0 0 0 1px var(--theme-primary),
            0 4px 8px rgba(0, 0, 0, 0.1),
            0 0 0 3px rgba(0, 102, 204, 0.1);
          background: #fff;
        }
      }

      .el-input__inner {
        font-size: 15px;
        color: var(--theme-text-primary);
        height: 42px;
        line-height: 42px;

        &::placeholder {
          color: var(--theme-text-secondary);
        }
      }

      .el-input__prefix-inner {
        .el-icon {
          font-size: 18px;
          color: var(--theme-text-secondary);
          margin-right: 8px;
        }
      }

      .search-icon {
        font-size: 18px;
        cursor: pointer;
        color: var(--theme-primary);
        transition: var(--theme-transition);
        margin-left: 8px;

        &:hover {
          transform: scale(1.1);
        }
      }
    }
  }

  // 操作区域
  &__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>


================================================
FILE: frontend/src/components/mobile/FolderSelect.vue
================================================
<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>


================================================
FILE: frontend/src/components/mobile/ResourceCard.vue
================================================
<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="getProxyImageUrl(item.image as string)"
            :fit="item.image ? 'cover' : 'contain'"
            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="copyUrl(item.cloudLinks[0])">
            {{ item.title }}
          </div>

          <!-- 描述 - 添加展开收起功能 -->
          <div
            class="info__desc"
            :class="{
              'is-expanded': expandedItems[(item.messageId || '') + (item.channelId || '')],
            }"
            @click="toggleExpand((item.messageId || '') + (item.channelId || ''))"
            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 plain @click="handleJump(item)">
                跳转
              </van-button>
              <van-button
                v-if="item.isSupportSave"
                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 { showNotify } from "vant";
import type { ResourceItem } from "@/types";
import { getProxyImageUrl } from "@/utils/image";

// Props 定义
const props = defineProps<{
  currentChannelId: string;
}>();

// 事件定义
const emit = defineEmits<{
  (e: "save", resource: ResourceItem): void;
  (e: "jump", 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 handleJump = (resource: ResourceItem) => {
  emit("jump", resource);
};

const copyUrl = async (url: string) => {
  try {
    await navigator.clipboard.writeText(url);
    showNotify({
      type: "success",
      message: "链接已复制到剪贴板",
      duration: 1500,
    });
  } catch (err) {
    const input = document.createElement("input");
    input.value = url;
    document.body.appendChild(input);
    input.select();
    document.execCommand("copy");
    document.body.removeChild(input);

    showNotify({
      type: "success",
      message: "链接已复制到剪贴板",
      duration: 1500,
    });
  }
};

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 {
      opacity: 0.7;
    }
  }

  &__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>


================================================
FILE: frontend/src/components/mobile/ResourceSelect.vue
================================================
<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>


================================================
FILE: frontend/src/constants/project.ts
================================================
export const PROJECT_NAME = "Cloudsaver";
export const PROJECT_GITHUB = "https://github.com/jiangrui1994/cloudsaver";


================================================
FILE: frontend/src/constants/storage.ts
================================================
export const STORAGE_KEYS = {
  USERNAME: "saved_username",
  PASSWORD: "saved_password",
  TOKEN: "token",
} as const;


================================================
FILE: frontend/src/env.d.ts
================================================
/// <reference types="vite/client" />

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

interface ImportMetaEnv {
  readonly VITE_API_BASE_URL: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}


================================================
FILE: frontend/src/main.ts
================================================
import { createApp } from "vue";
import { createPinia } from "pinia";
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
import * as ElementPlusIconsVue from "@element-plus/icons-vue";
import zhCn from "element-plus/es/locale/lang/zh-cn";
import { isMobileDevice } from "@/utils/index";
import App from "./App.vue";
import { Lazyload } from "vant";
import "vant/es/notify/style";
import "vant/es/dialog/style";
import "@/styles/responsive.scss";
import "@/styles/common.scss";

import router from "./router/index";

const app = createApp(App);

for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component);
}

app.use(createPinia());
app.use(Lazyload);
app.use(router);
app.use(ElementPlus, {
  locale: zhCn,
});

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);


================================================
FILE: frontend/src/router/index.ts
================================================
import { createRouter, createWebHistory } from "vue-router";
import mobileRoutes from "./mobile-routes";
import pcRoutes from "./pc-routes";
import { isMobileDevice } from "@/utils/index";

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [...(isMobileDevice() ? mobileRoutes : pcRoutes)],
});

export default router;


================================================
FILE: frontend/src/router/mobile-routes.ts
================================================
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: "/thanks",
        name: "thanks",
        redirect: "/resource",
      },
    ],
  },
  {
    path: "/login",
    name: "login",
    component: () => import("@/views/mobile/Login.vue"),
  },
];

export default routes;


================================================
FILE: frontend/src/router/pc-routes.ts
================================================
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: "/thanks",
        name: "thanks",
        component: () => import("@/views/Thanks.vue"),
      },
    ],
  },
  {
    path: "/login",
    name: "login",
    component: () => import("@/views/pc/Login.vue"),
  },
];

export default routes;


================================================
FILE: frontend/src/stores/douban.ts
================================================
import { defineStore } from "pinia";
import { doubanApi } from "@/api/douban";
import { HotListItem } from "@/types/douban";
import { ElMessage } from "element-plus";

interface StoreType {
  hotList: HotListItem[];
  loading: boolean;
  currentParams: CurrentParams;
}

interface CurrentParams {
  type: string;
  tag?: string;
}

export const useDoubanStore = defineStore("douban", {
  state: (): StoreType => ({
    hotList: [],
    loading: false,
    currentParams: {
      type: "movie",
      tag: "热门",
    },
  }),

  actions: {
    async getHotList() {
      this.loading = true;
      try {
        const params = {
          type: this.currentParams.type,
          tag: this.currentParams.tag || "热门",
          page_limit: "20",
          page_start: "0",
        };
        const result = await doubanApi.getHotList(params);
        if (result && result.length > 0) {
          this.hotList = result;
        } else {
          console.log("获取热门列表失败");
          ElMessage.warning("获取热门列表失败");
        }
      } catch (error) {
        ElMessage.error(error || "获取热门列表失败");
      } finally {
        this.loading = false;
      }
    },
    setCurrentParams(currentParams: CurrentParams) {
      this.currentParams = currentParams;
      this.getHotList();
    },
  },
});


================================================
FILE: frontend/src/stores/index.ts
================================================
import { defineStore } from "pinia";

interface StoreType {
  scrollTop: boolean;
}

export const useStore = defineStore("global", {
  state: (): StoreType => ({
    scrollTop: true,
  }),

  actions: {
    setScrollTop(top: boolean) {
      this.scrollTop = top;
    },
  },
});


================================================
FILE: frontend/src/stores/resource.ts
================================================
import { defineStore } from "pinia";
import { cloud115Api } from "@/api/cloud115";
import { resourceApi } from "@/api/resource";
import { quarkApi } from "@/api/quark";
import type {
  Resource,
  ShareInfoResponse,
  ShareInfo,
  ResourceItem,
  GetShareInfoParams,
  SaveFileParams,
  ShareFileInfoAndFolder,
} from "@/types";
import { ElMessage } from "element-plus";

interface StorageListObject {
  list: Resource[];
  lastUpdateTime?: string;
}

const lastResource = (
  localStorage.getItem("last_resource_list")
    ? JSON.parse(localStorage.getItem("last_resource_list") as string)
    : { list: [] }
) as StorageListObject;

// 定义云盘驱动配置类型
interface CloudDriveConfig {
  name: string;
  type: string;
  regex: RegExp;
  api: {
    getShareInfo: (params: GetShareInfoParams) => Promise<ShareInfoResponse>;
    saveFile: (params: SaveFileParams) => Promise<{ code: number; message?: string }>;
  };
  parseShareCode: (match: RegExpMatchArray) => GetShareInfoParams;
  getSaveParams: (shareInfoAndFolder: ShareFileInfoAndFolder) => SaveFileParams;
}

// 云盘类型配置
export const CLOUD_DRIVES: CloudDriveConfig[] = [
  {
    name: "115网盘",
    type: "pan115",
    regex: /(?:115|anxia|115cdn)\.com\/s\/([^?]+)(?:\?password=([^&#]+))?/,
    api: {
      getShareInfo: (params: GetShareInfoParams) => cloud115Api.getShareInfo(params),
      saveFile: async (params: SaveFileParams) => {
        return await cloud115Api.saveFile(params);
      },
    },
    parseShareCode: (match: RegExpMatchArray) => ({
      shareCode: match[1],
      receiveCode: match[2] || "",
    }),
    getSaveParams: (shareInfoAndFolder: ShareFileInfoAndFolder) => ({
      shareCode: shareInfoAndFolder.shareCode || "",
      receiveCode: shareInfoAndFolder.receiveCode || "",
      fileId: shareInfoAndFolder.shareInfo.list[0].fileId,
      folderId: shareInfoAndFolder.folderId,
      fids: shareInfoAndFolder.shareInfo.list.map((item: { fileId?: string }) => item.fileId || ""),
    }),
  },
  {
    name: "夸克网盘",
    type: "quark",
    regex: /pan\.quark\.cn\/s\/([a-zA-Z0-9]+)/,
    api: {
      getShareInfo: (params) => quarkApi.getShareInfo(params),
      saveFile: async (params: SaveFileParams) => {
        return await quarkApi.saveFile(params);
      },
    },
    parseShareCode: (match: RegExpMatchArray) => ({ shareCode: match[1] }),
    getSaveParams: (shareInfoAndFolder: ShareFileInfoAndFolder) => ({
      fids: shareInfoAndFolder.shareInfo.list.map((item: { fileId?: string }) => item.fileId || ""),
      fidTokens: shareInfoAndFolder.shareInfo.list.map(
        (item: { fileIdToken?: string }) => item.fileIdToken || ""
      ),
      folderId: shareInfoAndFolder.folderId,
      shareCode: shareInfoAndFolder.shareInfo.pwdId || "",
      receiveCode: shareInfoAndFolder.shareInfo.stoken || "",
    }),
  },
];

export const useResourceStore = defineStore("resource", {
  state: () => ({
    tagColor: {
      baiduPan: "primary",
      weiyun: "info",
      aliyun: "warning",
      pan115: "danger",
      quark: "success",
    },
    keyword: "",
    resources: lastResource.list,
    lastUpdateTime: lastResource.lastUpdateTime || "",
    shareInfo: {} as ShareInfoResponse,
    resourceSelect: [] as ShareInfo[],
    loading: false,
    backupPlan: false,
    loadTree: false,
  }),

  actions: {
    setLoadTree(loadTree: boolean) {
      this.loadTree = loadTree;
    },
    // 搜索资源
    async searchResources(keyword?: string, isLoadMore = false, channelId?: string): Promise<void> {
      this.loading = true;
      if (!isLoadMore) this.resources = [];
      try {
        let lastMessageId = "";
        if (isLoadMore) {
          const list = this.resources.find((x) => x.id === channelId)?.list || [];
          lastMessageId = list[list.length - 1].messageId || "";
          if (list[list.length - 1].isLastMessage) {
            ElMessage.warning("没有更多了~");
            return;
          }
          if (!lastMessageId) {
            ElMessage.error("当次搜索源不支持加载更多");
            return;
          }
          keyword = this.keyword;
        }
        let { data = [] } = await resourceApi.search(keyword || "", channelId, lastMessageId);
        this.keyword = keyword || "";
        data = data
          .filter((item) => item.list.length > 0)
          .map((x) => ({
            ...x,
            list: x.list.map((item) => ({
              ...item,
              isSupportSave: CLOUD_DRIVES.some((drive) => drive.regex.test(item.cloudLinks[0])),
            })),
          }));
        console.log(data);
        if (isLoadMore) {
          const findedIndex = this.resources.findIndex((item) => item.id === data[0]?.id);
          if (findedIndex !== -1) {
            this.resources[findedIndex].list.push(...data[0].list);
          }
          if (data.length === 0) {
            const list = this.resources.find((item) => item.id === channelId)?.list;
            list && list[list.length - 1] && (list[list.length - 1]!.isLastMessage = true);
            ElMessage.warning("没有更多了~");
          }
        } else {
          this.resources = data.map((item, index) => ({ ...item, displayList: index === 0 }));
          if (!keyword) {
            // 获取当前时间字符串 用于存储到本地
            this.lastUpdateTime = new Date().toLocaleString();
            localStorage.setItem(
              "last_resource_list",
              JSON.stringify({ list: this.resources, lastUpdateTime: this.lastUpdateTime })
            );
          }
          if (this.resources.length === 0) {
            ElMessage.warning("未搜索到相关资源");
          }
        }
      } catch (error) {
        console.log(error);
        this.handleError("搜索失败,请重试", null);
      } finally {
        this.loading = false;
      }
    },

    // 设置选择资源
    async setSelectedResource(resourceSelect: ShareInfo[]) {
      this.resourceSelect = resourceSelect;
    },

    // 转存资源
    async saveResource(resource: ResourceItem, folderId: string): Promise<void> {
      const savePromises: Promise<void>[] = [];
      CLOUD_DRIVES.forEach((drive) => {
        if (resource.cloudLinks.some((link) => drive.regex.test(link))) {
          savePromises.push(this.saveResourceToDrive(resource, folderId, drive));
        }
      });
      await Promise.all(savePromises);
    },

    // 保存资源到网盘
    async saveResourceToDrive(
      resource: ResourceItem,
      folderId: string,
      drive: CloudDriveConfig
    ): Promise<void> {
      const link = resource.cloudLinks.find((link) => drive.regex.test(link));
      if (!link) return;

      const match = link.match(drive.regex);
      if (!match) throw new Error("链接解析失败");
      const parsedCode = drive.parseShareCode(match);

      const shareInfo = {
        ...this.shareInfo,
        list: this.resourceSelect.filter((x) => x.isChecked),
      };
      console.log(shareInfo);

      const params = drive.getSaveParams({
        shareInfo,
        ...parsedCode,
        folderId,
      });
      const result = await drive.api.saveFile(params);

      if (result.code === 0) {
        ElMessage.success(`${drive.name} 转存成功`);
      } else {
        ElMessage.error(result.message);
      }
    },

    // 解析云盘链接
    async parsingCloudLink(url: string): Promise<void> {
      this.loading = true;
      this.resources = [];
      try {
        const matchedDrive = CLOUD_DRIVES.find((drive) => drive.regex.test(url));
        if (!matchedDrive) throw new Error("不支持的网盘链接");

        const match = url.match(matchedDrive.regex);
        if (!match) throw new Error("链接解析失败");

        const parsedCode = matchedDrive.parseShareCode(match);
        const shareInfo = await matchedDrive.api.getShareInfo(parsedCode);
        if (shareInfo?.list?.length) {
          this.resources = [
            {
              id: "",
              channelInfo: {
                name: "自定义搜索",
                channelLogo: "",
                channelId: "",
              },
              displayList: true,
              list: [
                {
                  id: "1",
                  title: shareInfo.list.map((item) => item.fileName).join(", "),
                  cloudLinks: [url],
                  cloudType: matchedDrive.type,
                  channel: matchedDrive.name,
                  pubDate: "",
                  isSupportSave: true,
                },
              ],
            },
          ];
        } else {
          throw new Error("解析失败,请检查链接是否正确");
        }
      } catch (error) {
        this.handleError("解析失败,请重试", error);
      } finally {
        this.loading = false;
      }
    },

    // 获取资源列表并选择
    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);
      this.setLoadTree(true);
      let shareInfo = await drive.api.getShareInfo(parsedCode);
      console.log(shareInfo);
      this.setLoadTree(false);
      if (shareInfo) {
        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 {
      console.error(message, error);
      ElMessage.error(error instanceof Error ? error.message : message);
    },
  },
});


================================================
FILE: frontend/src/stores/userSetting.ts
================================================
import { defineStore } from "pinia";
import type {
  UserSettingStore,
  GlobalSettingAttributes,
  UserSettingAttributes,
} from "@/types/user";
import { settingApi } from "@/api/setting";
import { ElMessage } from "element-plus";

export const useUserSettingStore = defineStore("user", {
  state: (): UserSettingStore => ({
    globalSetting: null,
    userSettings: {
      cloud115Cookie: "",
      quarkCookie: "",
    },
    displayStyle: (localStorage.getItem("display_style") as "table" | "card") || "card",
    imagesSource: (localStorage.getItem("images_source") as "proxy" | "local") || "proxy",
  }),

  actions: {
    async getSettings() {
      const { data } = await settingApi.getSetting();
      if (data) {
        this.globalSetting = data.globalSetting;
        this.userSettings = data.userSettings;
      }
    },

    async saveSettings(settings: {
      globalSetting?: GlobalSettingAttributes | null;
      userSettings: UserSettingAttributes;
    }) {
      try {
        await settingApi.saveSetting(settings);
        await this.getSettings();
      } catch (error) {
        console.log(error);
        throw error;
      }
    },

    setDisplayStyle(style: "table" | "card") {
      this.displayStyle = style;
      localStorage.setItem("display_style", style);
      ElMessage.success(`切换成功,当前为${style === "table" ? "列表" : "卡片"}模式`);
    },

    setImagesSource(source: "proxy" | "local") {
      this.imagesSource = source;
      localStorage.setItem("images_source", source);
      ElMessage.success(`切换成功,图片模式当前为${source === "proxy" ? "代理" : "直连"}模式`);
    },
  },
});


================================================
FILE: frontend/src/styles/common.scss
================================================
// 颜色系统
: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;
}


================================================
FILE: frontend/src/styles/global.scss
================================================
:root {
  --theme-color: #3e3e3e;
  --theme-theme: #133ab3;
  --theme-background: #fafafa;
  --theme-other_background: #ffffff;
}

input {
  border: none;
  outline: none;
}


================================================
FILE: frontend/src/styles/mobile.scss
================================================
/* 移动端通用样式类 */
.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);
        }
    }
}

================================================
FILE: frontend/src/styles/responsive.scss
================================================
@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;
  }
}


================================================
FILE: frontend/src/types/douban.ts
================================================
export interface HotListParams {
  type: string;
  tag?: string;
  page_limit?: string;
  page_start?: string;
}
export interface HotListItem {
  cover: string;
  cover_x: number;
  cover_y: number;
  episodes_info: string;
  id: string;
  is_new: boolean;
  playable: boolean;
  rate: string;
  title: string;
  url: string;
}


================================================
FILE: frontend/src/types/globals.d.ts
================================================
declare global {
  interface Location {
    // 根据你的需求定义 location 的属性和方法
    pathname: string;
    search: string;
    hash: string;
    host: string;
    // 其他属性和方法...
  }
  interface Window {
    location: Location;
  }
}

export {};


================================================
FILE: frontend/src/types/index.ts
================================================
export interface ResourceItem {
  id: string;
  title: string;
  channel: string;
  channelId?: string;
  image?: string;
  cloudLinks: string[];
  tags?: string[];
  content?: string;
  pubDate: string;
  cloudType: string;
  messageId?: string;
  isLastMessage?: boolean;
  isSupportSave?: boolean;
}

export interface Resource {
  list: ResourceItem[];
  displayList?: boolean;
  loading?: boolean;
  channelInfo: {
    channelId: string;
    name: string;
    channelLogo: string;
  };
  id: string;
}

export interface ShareInfo {
  fileId: string;
  fileName: string;
  fileSize?: number;
  fileIdToken?: string;
  isChecked?: boolean;
}

export interface ShareInfoItem {
  fileId: string;
  fileName: string;
  fileSize?: number;
  fileIdToken?: string;
}

export interface ShareInfoResponse {
  list: ShareInfoItem[];
  fileSize?: number;
  pwdId?: string;
  stoken?: string;
}

export interface ShareFileInfoAndFolder {
  shareInfo: ShareInfoResponse;
  folderId: string;
  shareCode: string;
  receiveCode?: string;
}

export interface Folder {
  cid: string;
  name: string;
  path?: Folder[];
}

export interface SaveFileParams {
  shareCode: string; // 分享code
  receiveCode?: string; // 分享文件的密码
  folderId: string; // 文件夹id
  fids: string[]; // 存储文件id
  fidTokens?: string[]; // 存储文件token
}

export interface GetShareInfoParams {
  shareCode: string;
  receiveCode?: string;
}

export interface ApiResponse<T = unknown> {
  success: boolean;
  data?: T;
  error?: string;
}

export interface Save115FileParams {
  shareCode: string;
  receiveCode: string;
  fileId: string;
  folderId: string;
}

export interface SaveQuarkFileParams {
  fid_list: string[];
  fid_token_list: string[];
  to_pdir_fid: string;
  pwd_id: string;
  stoken: string;
  pdir_fid: string;
  scene: string;
}

export interface TagColor {
  baiduPan: string;
  weiyun: string;
  aliyun: string;
  pan115: string;
  quark: string;
}

export interface GlobalSettingAttributes {
  httpProxyHost: string;
  httpProxyPort: number | string;
  isProxyEnabled: boolean;
  AdminUserCode: number;
  CommonUserCode: number;
}
export interface UserSettingAttributes {
  cloud115Cookie: string;
  quarkCookie: string;
}


================================================
FILE: frontend/src/types/response.ts
================================================
export type RequestErrorCode = -1 | 400 | 401 | 402 | 403 | 500 | 501;

export interface RequestSuccess<T> {
  code: 0;
  data: T;
  message: string;
}

export interface RequestError<T> {
  code: RequestErrorCode;
  message: string;
  data?: T;
}

export type RequestResult<T> = RequestSuccess<T> | RequestError<T>;


================================================
FILE: frontend/src/types/user.ts
================================================
export interface GlobalSettingAttributes {
  httpProxyHost: string;
  httpProxyPort: string | number;
  isProxyEnabled: boolean;
  AdminUserCode: number;
  CommonUserCode: number;
}

export interface UserSettingAttributes {
  cloud115Cookie: string;
  quarkCookie: string;
}

export interface UserSettingStore {
  globalSetting: GlobalSettingAttributes | null;
  userSettings: UserSettingAttributes;
  displayStyle: "table" | "card";
  imagesSource: "proxy" | "local";
}


================================================
FILE: frontend/src/utils/device.ts
================================================
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
  );
};


================================================
FILE: frontend/src/utils/image.ts
================================================
import { useUserSettingStore } from "@/stores/userSetting";
import defaultImage from "@/assets/images/default.png";

export const getProxyImageUrl = (originalUrl: string): string => {
  const userStore = useUserSettingStore();
  if (!originalUrl) return defaultImage;
  return userStore.imagesSource === "proxy"
    ? `/tele-images/?url=${encodeURIComponent(originalUrl)}`
    : originalUrl;
};


================================================
FILE: frontend/src/utils/index.ts
================================================
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;
}


================================================
FILE: frontend/src/utils/request.ts
================================================
import axios, { AxiosResponse, AxiosRequestConfig } from "axios";
import { ElMessage } from "element-plus";
import { isMobileDevice } from "@/utils/index";
import { showNotify } from "vant";
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({
  baseURL: import.meta.env.VITE_API_BASE_URL as string,
  timeout: 16000,
  withCredentials: true,
  headers: {
    "Content-Type": "application/json",
  },
});

function isLoginAndRedirect(url: string) {
  return url.includes("/api/user/login") || url.includes("/api/user/register");
}

axiosInstance.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem(STORAGE_KEYS.TOKEN);
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    } else if (!isLoginAndRedirect(config.url || "")) {
      errorMessage("请先登录");
      window.location.href = "/login";
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

axiosInstance.interceptors.response.use(
  (response: AxiosResponse) => {
    const res = response.data;
    return res;
  },
  (error) => {
    if (error.response.status === 401) {
      errorMessage("登录过期,请重新登录");
      localStorage.removeItem(STORAGE_KEYS.TOKEN);
      window.location.href = "/login";
      return Promise.reject(new Error("登录过期,请重新登录"));
    }
    errorMessage(error.response.statusText);
    return Promise.reject(new Error(error.response.statusText));
  }
);

const request = {
  get: <T>(url: string, config?: AxiosRequestConfig): Promise<RequestResult<T>> => {
    return axiosInstance.get(url, { ...config });
  },
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  post: <T, D = any>(
    url: string,
    data: D,
    config?: AxiosRequestConfig
  ): Promise<RequestResult<T>> => {
    return axiosInstance.post(url, data, { ...config });
  },
  put: axiosInstance.put,
  delete: axiosInstance.delete,
};

export default request;


================================================
FILE: frontend/src/views/Douban.vue
================================================
<template>
  <div class="douban-page">
    <div class="movie-wall">
      <div v-for="movie in doubanStore.hotList" :key="movie.id" class="movie-item">
        <div class="movie-poster">
          <el-image
            class="movie-poster-img"
            :src="movie.cover"
            fit="cover"
            lazy
            :alt="movie.title"
            hide-on-click-modal
            :preview-src-list="[movie.cover]"
          />
          <div class="movie-rate">
            {{ movie.rate }}
          </div>
          <div class="movie-poster-hover" @click="searchMovie(movie.title)">
            <div class="movie-search">
              <el-icon class="search_icon" size="28px"><Search /></el-icon>
            </div>
          </div>
        </div>
        <div class="movie-info">
          <el-link :href="movie.url" target="_blank" :underline="false" class="movie-title">{{
            movie.title
          }}</el-link>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, watch } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useDoubanStore } from "@/stores/douban";
interface CurrentParams {
  type: string;
  tag?: string;
}
const router = useRouter();
const route = useRoute();

const routeParams = computed((): CurrentParams => ({ ...route.query }) as unknown as CurrentParams);
const doubanStore = useDoubanStore();
if (routeParams.value) {
  doubanStore.setCurrentParams(routeParams.value);
}

watch(
  () => routeParams.value,
  () => {
    console.log(routeParams.value);
    doubanStore.setCurrentParams(routeParams.value);
  }
);

const searchMovie = (title: string) => {
  router.push({ path: "/", query: { keyword: title } });
};
</script>

<style lang="scss" scoped>
@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;
  grid-template-columns: repeat(auto-fill, 200px);
  justify-content: space-between;
  gap: 20px;
  padding: 4px;
}

.movie-item {
  width: 200px;
  background: var(--theme-card-bg);
  border-radius: var(--theme-radius);
  box-shadow: var(--theme-shadow);
  box-sizing: border-box;
  padding: 12px;
  transition: var(--theme-transition);

  &:hover {
    transform: translateY(-2px);
    box-shadow: var(--theme-shadow-lg);
  }
}

.movie-poster-img {
  width: 100%;
  height: 220px;
  object-fit: cover;
  border-radius: var(--theme-radius);
  overflow: hidden;
}

.movie-info {
  padding: 12px 0 4px;
  text-align: center;
  width: 100%;

  .movie-title {
    display: block;
    font-size: 16px;
    font-weight: bold;
    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 {
  width: 100%;
  height: 220px;
  position: relative;
  overflow: hidden;
  border-radius: var(--theme-radius);
  box-sizing: border-box;
  padding: 0;
  margin: 0;
}

.movie-poster-hover {
  opacity: 0;
  transition: opacity 0.3s ease;
  position: absolute;
  width: 100%;
  height: 100%;
  top: 0;
  left: 0;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  backdrop-filter: blur(2px);
}

.movie-poster:hover .movie-poster-hover {
  opacity: 1;
}

.movie-rate {
  position: absolute;
  top: 10px;
  right: 10px;
  background: var(--theme-primary);
  color: white;
  padding: 0px 8px;
  border-radius: var(--theme-radius-sm);
  font-size: 14px;
}

.movie-search {
  color: white;
  border-radius: var(--theme-radius);
  font-size: 14px;
  cursor: pointer;
  transition: transform 0.2s ease;

  &:hover {
    transform: scale(1.1);
  }
}
</style>


================================================
FILE: frontend/src/views/Home.vue
================================================
<template>
  <div class="pc-home" :class="{ 'is-loading': resourcStore.loading }">
    <!-- 主布局容器 -->
    <el-container class="pc-home__container">
      <!-- 侧边栏 -->
      <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 />
        </el-header>

        <!-- 内容区域 -->
        <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-container>
    </el-container>

    <!-- 全局加载 -->
    <div v-if="resourcStore.loading" class="pc-home__loading">
      <el-icon class="is-loading"><Loading /></el-icon>
      <span class="loading-text">加载中...</span>
    </div>
  </div>
</template>

<script setup lang="ts">
import { onMounted, onUnmounted } from "vue";
import { useResourceStore } from "@/stores/resource";
import { useStore } from "@/stores/index";
import { useUserSettingStore } from "@/stores/userSetting";
import { throttle } from "@/utils/index";
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 settingStore = useUserSettingStore();

// 初始化设置
onMounted(() => {
  settingStore.getSettings();
  window.addEventListener("scroll", handleScroll);
});

onUnmounted(() => {
  window.removeEventListener("scroll", handleScroll);
});

// 滚动处理
const handleScroll = throttle(() => {
  const scrollTop = window.scrollY;
  store.setScrollTop(scrollTop <= 50);
}, 100);
</script>

<style lang="scss" scoped>
@import "@/styles/common.scss";

.pc-home {
  position: relative;
  height: 100vh;
  background: var(--theme-bg);
  color: var(--theme-text-primary);

  // 主容器
  &__container {
    height: 100%;
  }

  // 侧边栏
  &__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;
    top: 0;
    z-index: 10;
    height: auto;
    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);
  }
}

// 路由过渡动画
.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);
  }
  to {
    transform: rotate(360deg);
  }
}
</style>


================================================
FILE: frontend/src/views/ResourceList.vue
================================================
<template>
  <div class="pc-resources">
    <!-- 头部工具栏 -->
    <div class="pc-resources__header">
      <div class="header__left">
        <el-tooltip effect="dark" content="点击获取最新资源" placement="bottom">
          <el-button class="refresh-btn" type="text" @click="refreshResources">
            <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.imagesSource === 'local' ? '图片切换到代理模式' : '图片切换到直连模式'
          "
          placement="bottom"
        >
          <el-button
            type="text"
            class="view-toggle"
            @click="
              userStore.setImagesSource(userStore.imagesSource === 'proxy' ? 'local' : 'proxy')
            "
          >
            <el-icon>
              <component :is="userStore.imagesSource === 'proxy' ? 'Guide' : 'Location'" />
            </el-icon>
          </el-button>
        </el-tooltip>
        <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 id="pc-resources-content" ref="contentRef" class="pc-resources__content">
      <component
        :is="userStore.displayStyle === 'table' ? ResourceTable : ResourceCard"
        v-if="resourceStore.resources.length > 0"
        @load-more="handleLoadMore"
        @jump="handleJump"
        @search-moviefor-tag="searchMovieforTag"
        @save="handleSave"
      />

      <!-- 空状态 -->
      <div v-if="r
Download .txt
gitextract_ls8v8cv6/

├── .eslintignore
├── .eslintrc.js
├── .github/
│   └── workflows/
│       ├── docker-build-test.yml
│       └── docker-image.yml
├── .gitignore
├── .prettierignore
├── .prettierrc.js
├── Dockerfile
├── LICENSE
├── README.md
├── backend/
│   ├── .env.example
│   ├── package.json
│   ├── src/
│   │   ├── app.ts
│   │   ├── config/
│   │   │   ├── database.ts
│   │   │   └── index.ts
│   │   ├── controllers/
│   │   │   ├── BaseCloudController.ts
│   │   │   ├── BaseController.ts
│   │   │   ├── cloud115.ts
│   │   │   ├── douban.ts
│   │   │   ├── quark.ts
│   │   │   ├── resource.ts
│   │   │   ├── setting.ts
│   │   │   ├── sponsors.ts
│   │   │   ├── teleImages.ts
│   │   │   └── user.ts
│   │   ├── core/
│   │   │   ├── ApiResponse.ts
│   │   │   └── types.ts
│   │   ├── inversify.config.ts
│   │   ├── middleware/
│   │   │   ├── auth.ts
│   │   │   ├── cors.ts
│   │   │   ├── errorHandler.ts
│   │   │   ├── index.ts
│   │   │   ├── rateLimiter.ts
│   │   │   ├── requestLogger.ts
│   │   │   └── validateRequest.ts
│   │   ├── models/
│   │   │   ├── GlobalSetting.ts
│   │   │   ├── User.ts
│   │   │   └── UserSetting.ts
│   │   ├── routes/
│   │   │   └── api.ts
│   │   ├── services/
│   │   │   ├── Cloud115Service.ts
│   │   │   ├── DatabaseService.ts
│   │   │   ├── DoubanService.ts
│   │   │   ├── ImageService.ts
│   │   │   ├── QuarkService.ts
│   │   │   ├── Searcher.ts
│   │   │   ├── SettingService.ts
│   │   │   ├── SponsorsService.ts
│   │   │   └── UserService.ts
│   │   ├── sponsors/
│   │   │   └── sponsors.json
│   │   ├── types/
│   │   │   ├── cloud.ts
│   │   │   ├── cloud115.ts
│   │   │   ├── express.ts
│   │   │   ├── index.ts
│   │   │   └── services.ts
│   │   └── utils/
│   │       ├── axiosInstance.ts
│   │       ├── handleError.ts
│   │       ├── index.ts
│   │       ├── logger.ts
│   │       ├── response.ts
│   │       └── responseHandler.ts
│   └── tsconfig.json
├── docker-entrypoint.sh
├── frontend/
│   ├── .env
│   ├── auto-imports.d.ts
│   ├── components.d.ts
│   ├── index.html
│   ├── package.json
│   ├── postcss.config.cjs
│   ├── src/
│   │   ├── App.vue
│   │   ├── api/
│   │   │   ├── cloud115.ts
│   │   │   ├── douban.ts
│   │   │   ├── quark.ts
│   │   │   ├── resource.ts
│   │   │   ├── setting.ts
│   │   │   └── user.ts
│   │   ├── components/
│   │   │   ├── AsideMenu.vue
│   │   │   ├── Home/
│   │   │   │   ├── FolderSelect.vue
│   │   │   │   ├── ResourceCard.vue
│   │   │   │   ├── ResourceSelect.vue
│   │   │   │   └── ResourceTable.vue
│   │   │   ├── SearchBar.vue
│   │   │   └── mobile/
│   │   │       ├── FolderSelect.vue
│   │   │       ├── ResourceCard.vue
│   │   │       └── ResourceSelect.vue
│   │   ├── constants/
│   │   │   ├── project.ts
│   │   │   └── storage.ts
│   │   ├── env.d.ts
│   │   ├── main.ts
│   │   ├── router/
│   │   │   ├── index.ts
│   │   │   ├── mobile-routes.ts
│   │   │   └── pc-routes.ts
│   │   ├── stores/
│   │   │   ├── douban.ts
│   │   │   ├── index.ts
│   │   │   ├── resource.ts
│   │   │   └── userSetting.ts
│   │   ├── styles/
│   │   │   ├── common.scss
│   │   │   ├── global.scss
│   │   │   ├── mobile.scss
│   │   │   └── responsive.scss
│   │   ├── types/
│   │   │   ├── douban.ts
│   │   │   ├── globals.d.ts
│   │   │   ├── index.ts
│   │   │   ├── response.ts
│   │   │   └── user.ts
│   │   ├── utils/
│   │   │   ├── device.ts
│   │   │   ├── image.ts
│   │   │   ├── index.ts
│   │   │   └── request.ts
│   │   └── views/
│   │       ├── Douban.vue
│   │       ├── Home.vue
│   │       ├── ResourceList.vue
│   │       ├── Setting.vue
│   │       ├── Thanks.vue
│   │       ├── mobile/
│   │       │   ├── Douban.vue
│   │       │   ├── Home.vue
│   │       │   ├── Login.vue
│   │       │   ├── ResourceList.vue
│   │       │   └── Setting.vue
│   │       └── pc/
│   │           └── Login.vue
│   ├── tsconfig.json
│   ├── tsconfig.node.json
│   └── vite.config.ts
├── nginx.conf
├── package.json
└── pnpm-workspace.yaml
Download .txt
SYMBOL INDEX (194 symbols across 58 files)

FILE: backend/src/app.ts
  class App (line 11) | class App {
    method constructor (line 15) | constructor() {
    method setupExpress (line 19) | private setupExpress(): void {
    method start (line 28) | public async start(): Promise<void> {

FILE: backend/src/config/index.ts
  type Channel (line 6) | interface Channel {
  type CloudPatterns (line 11) | interface CloudPatterns {
  type Config (line 21) | interface Config {

FILE: backend/src/controllers/BaseCloudController.ts
  method constructor (line 6) | constructor(protected cloudService: ICloudStorageService) {
  method getShareInfo (line 10) | async getShareInfo(req: Request, res: Response): Promise<void> {
  method getFolderList (line 18) | async getFolderList(req: Request, res: Response): Promise<void> {
  method saveFile (line 26) | async saveFile(req: Request, res: Response): Promise<void> {

FILE: backend/src/controllers/BaseController.ts
  type ApiResponseData (line 3) | interface ApiResponseData<T> {
  method handleRequest (line 9) | protected async handleRequest<T>(

FILE: backend/src/controllers/cloud115.ts
  class Cloud115Controller (line 7) | class Cloud115Controller extends BaseCloudController {
    method constructor (line 8) | constructor(@inject(TYPES.Cloud115Service) cloud115Service: Cloud115Se...

FILE: backend/src/controllers/douban.ts
  class DoubanController (line 8) | class DoubanController extends BaseController {
    method constructor (line 9) | constructor(@inject(TYPES.DoubanService) private doubanService: Douban...
    method getDoubanHotList (line 13) | async getDoubanHotList(req: Request, res: Response): Promise<void> {

FILE: backend/src/controllers/quark.ts
  class QuarkController (line 8) | class QuarkController extends BaseCloudController {
    method constructor (line 9) | constructor(@inject(TYPES.QuarkService) quarkService: QuarkService) {

FILE: backend/src/controllers/resource.ts
  class ResourceController (line 8) | class ResourceController extends BaseController {
    method constructor (line 9) | constructor(@inject(TYPES.Searcher) private searcher: Searcher) {
    method search (line 13) | async search(req: Request, res: Response): Promise<void> {

FILE: backend/src/controllers/setting.ts
  class SettingController (line 8) | class SettingController extends BaseController {
    method constructor (line 9) | constructor(@inject(TYPES.SettingService) private settingService: Sett...
    method get (line 13) | async get(req: Request, res: Response): Promise<void> {
    method save (line 21) | async save(req: Request, res: Response): Promise<void> {

FILE: backend/src/controllers/sponsors.ts
  class SponsorsController (line 8) | class SponsorsController extends BaseController {
    method constructor (line 9) | constructor(@inject(TYPES.SponsorsService) private sponsorsService: Sp...
    method get (line 13) | async get(req: Request, res: Response): Promise<void> {

FILE: backend/src/controllers/teleImages.ts
  class ImageController (line 8) | class ImageController extends BaseController {
    method constructor (line 9) | constructor(@inject(TYPES.ImageService) private imageService: ImageSer...
    method getImages (line 13) | async getImages(req: Request, res: Response): Promise<void> {

FILE: backend/src/controllers/user.ts
  class UserController (line 8) | class UserController extends BaseController {
    method constructor (line 9) | constructor(@inject(TYPES.UserService) private userService: UserServic...
    method register (line 13) | async register(req: Request, res: Response): Promise<void> {
    method login (line 20) | async login(req: Request, res: Response): Promise<void> {

FILE: backend/src/core/ApiResponse.ts
  class ApiResponse (line 1) | class ApiResponse<T> {
    method constructor (line 7) | private constructor(success: boolean, code: number, data?: T, message?...
    method success (line 14) | static success<T>(data?: T, message = "操作成功"): ApiResponse<T> {
    method error (line 18) | static error(message: string, code = 10000): ApiResponse<null> {

FILE: backend/src/core/types.ts
  constant TYPES (line 1) | const TYPES = {

FILE: backend/src/middleware/auth.ts
  type AuthenticatedRequest (line 7) | interface AuthenticatedRequest extends Request {

FILE: backend/src/middleware/errorHandler.ts
  type CustomError (line 3) | interface CustomError extends Error {

FILE: backend/src/middleware/rateLimiter.ts
  constant WINDOW_MS (line 4) | const WINDOW_MS = 60 * 1000;
  constant MAX_REQUESTS (line 5) | const MAX_REQUESTS = 600;

FILE: backend/src/models/GlobalSetting.ts
  type GlobalSettingAttributes (line 4) | interface GlobalSettingAttributes {
  type GlobalSettingCreationAttributes (line 13) | interface GlobalSettingCreationAttributes extends Optional<GlobalSetting...
  class GlobalSetting (line 15) | class GlobalSetting

FILE: backend/src/models/User.ts
  type UserAttributes (line 4) | interface UserAttributes {
  type UserCreationAttributes (line 12) | interface UserCreationAttributes extends Optional<UserAttributes, "id"> {}
  class User (line 14) | class User extends Model<UserAttributes, UserCreationAttributes> impleme...

FILE: backend/src/models/UserSetting.ts
  type UserSettingAttributes (line 5) | interface UserSettingAttributes {
  type UserSettingCreationAttributes (line 13) | interface UserSettingCreationAttributes extends Optional<UserSettingAttr...
  class UserSetting (line 15) | class UserSetting

FILE: backend/src/services/Cloud115Service.ts
  type Cloud115ListItem (line 10) | interface Cloud115ListItem {
  type Cloud115FolderItem (line 16) | interface Cloud115FolderItem {
  class Cloud115Service (line 23) | class Cloud115Service implements ICloudStorageService {
    method constructor (line 27) | constructor() {
    method setCookie (line 54) | async setCookie(req: Request): Promise<void> {
    method getShareInfo (line 66) | async getShareInfo(shareCode: string, receiveCode = ""): Promise<Share...
    method getFolderList (line 92) | async getFolderList(parentCid = "0"): Promise<FolderListResponse> {
    method saveSharedFile (line 128) | async saveSharedFile(params: SaveFileParams): Promise<{ message: strin...

FILE: backend/src/services/DatabaseService.ts
  constant DEFAULT_GLOBAL_SETTINGS (line 7) | const DEFAULT_GLOBAL_SETTINGS = {
  class DatabaseService (line 15) | class DatabaseService {
    method constructor (line 18) | constructor() {
    method initialize (line 22) | async initialize(): Promise<void> {
    method initializeGlobalSettings (line 34) | private async initializeGlobalSettings(): Promise<void> {
    method cleanupBackupTables (line 48) | private async cleanupBackupTables(): Promise<void> {

FILE: backend/src/services/DoubanService.ts
  type DoubanSubject (line 4) | interface DoubanSubject {
  class DoubanService (line 13) | class DoubanService {
    method constructor (line 17) | constructor() {
    method getHotList (line 40) | async getHotList(params: {

FILE: backend/src/services/ImageService.ts
  class ImageService (line 8) | class ImageService {
    method constructor (line 11) | constructor() {
    method ensureAxiosInstance (line 15) | private async ensureAxiosInstance(): Promise<AxiosInstance> {
    method updateAxiosInstance (line 52) | async updateAxiosInstance(): Promise<void> {
    method getImages (line 57) | async getImages(url: string): Promise<any> {

FILE: backend/src/services/QuarkService.ts
  type QuarkShareInfo (line 15) | interface QuarkShareInfo {
  class QuarkService (line 28) | class QuarkService implements ICloudStorageService {
    method constructor (line 32) | constructor() {
    method setCookie (line 56) | async setCookie(req: Request): Promise<void> {
    method getShareInfo (line 68) | async getShareInfo(pwdId: string, passcode = ""): Promise<ShareInfoRes...
    method getShareList (line 88) | async getShareList(pwdId: string, stoken: string): Promise<ShareInfoRe...
    method getFolderList (line 129) | async getFolderList(parentCid = "0"): Promise<FolderListResponse> {
    method saveSharedFile (line 163) | async saveSharedFile(params: SaveFileParams): Promise<{ message: strin...

FILE: backend/src/services/Searcher.ts
  type sourceItem (line 10) | interface sourceItem {
  class Searcher (line 25) | class Searcher {
    method constructor (line 29) | constructor() {
    method initAxiosInstance (line 34) | private async initAxiosInstance(isUpdate: boolean = false) {
    method updateAxiosInstance (line 64) | public static async updateAxiosInstance(): Promise<void> {
    method extractCloudLinks (line 68) | private extractCloudLinks(text: string): { links: string[]; cloudType:...
    method searchAll (line 84) | async searchAll(keyword: string, channelId?: string, messageId?: strin...
    method searchInWeb (line 131) | async searchInWeb(url: string) {

FILE: backend/src/services/SettingService.ts
  class SettingService (line 9) | class SettingService {
    method constructor (line 10) | constructor(@inject(TYPES.ImageService) private imageService: ImageSer...
    method getSettings (line 12) | async getSettings(userId: string | undefined, role: number | undefined) {
    method saveSettings (line 35) | async saveSettings(userId: string | undefined, role: number | undefine...
    method updateSettings (line 50) | async updateSettings(/* 参数 */): Promise<void> {

FILE: backend/src/services/SponsorsService.ts
  class SponsorsService (line 7) | class SponsorsService {
    method constructor (line 10) | constructor() {
    method getSponsors (line 13) | async getSponsors() {

FILE: backend/src/services/UserService.ts
  class UserService (line 9) | class UserService {
    method isValidInput (line 10) | private isValidInput(input: string): boolean {
    method register (line 16) | async register(username: string, password: string, registerCode: strin...
    method login (line 47) | async login(username: string, password: string) {

FILE: backend/src/types/cloud.ts
  type ShareInfoResponse (line 1) | interface ShareInfoResponse {
  type GetShareInfoParams (line 10) | interface GetShareInfoParams {
  type ShareInfoItem (line 15) | interface ShareInfoItem {
  type FolderListResponse (line 21) | interface FolderListResponse {
  type SaveFileParams (line 29) | interface SaveFileParams {
  type SaveFileResponse (line 37) | interface SaveFileResponse {
  type ShareFileInfo (line 42) | interface ShareFileInfo {
  type QuarkShareFileInfo (line 57) | interface QuarkShareFileInfo {
  type QuarkShareInfo (line 67) | interface QuarkShareInfo {
  type QuarkFolderItem (line 79) | interface QuarkFolderItem {

FILE: backend/src/types/cloud115.ts
  type ShareInfo (line 1) | interface ShareInfo {
  type ShareInfoResponse (line 7) | interface ShareInfoResponse {

FILE: backend/src/types/express.ts
  type Request (line 5) | interface Request {

FILE: backend/src/types/index.ts
  type Config (line 1) | interface Config {

FILE: backend/src/types/services.ts
  type ICloudStorageService (line 4) | interface ICloudStorageService {

FILE: backend/src/utils/axiosInstance.ts
  type ProxyConfig (line 4) | interface ProxyConfig {
  function createAxiosInstance (line 9) | function createAxiosInstance(

FILE: backend/src/utils/handleError.ts
  type CustomError (line 4) | interface CustomError {
  function handleError (line 10) | function handleError(

FILE: backend/src/utils/index.ts
  type JwtPayload (line 5) | interface JwtPayload {
  function getUserIdFromToken (line 9) | function getUserIdFromToken(req: Request): string | null {

FILE: backend/src/utils/response.ts
  type ResponseData (line 3) | interface ResponseData {

FILE: frontend/components.d.ts
  type GlobalComponents (line 9) | interface GlobalComponents {
  type ComponentCustomProperties (line 68) | interface ComponentCustomProperties {

FILE: frontend/postcss.config.cjs
  method rootValue (line 4) | rootValue({ file }) {

FILE: frontend/src/api/cloud115.ts
  method getShareInfo (line 5) | async getShareInfo(params: GetShareInfoParams) {
  method getFolderList (line 12) | async getFolderList(parentCid = "0") {
  method saveFile (line 19) | async saveFile(params: SaveFileParams) {

FILE: frontend/src/api/douban.ts
  method getHotList (line 5) | async getHotList(params: HotListParams) {

FILE: frontend/src/api/quark.ts
  method getShareInfo (line 5) | async getShareInfo(params: GetShareInfoParams) {
  method getFolderList (line 12) | async getFolderList(parentCid = "0") {
  method saveFile (line 19) | async saveFile(params: SaveFileParams) {

FILE: frontend/src/api/resource.ts
  method search (line 5) | search(keyword: string, channelId?: string, lastMessageId?: string) {

FILE: frontend/src/constants/project.ts
  constant PROJECT_NAME (line 1) | const PROJECT_NAME = "Cloudsaver";
  constant PROJECT_GITHUB (line 2) | const PROJECT_GITHUB = "https://github.com/jiangrui1994/cloudsaver";

FILE: frontend/src/constants/storage.ts
  constant STORAGE_KEYS (line 1) | const STORAGE_KEYS = {

FILE: frontend/src/env.d.ts
  type ImportMetaEnv (line 9) | interface ImportMetaEnv {
  type ImportMeta (line 13) | interface ImportMeta {

FILE: frontend/src/stores/douban.ts
  type StoreType (line 6) | interface StoreType {
  type CurrentParams (line 12) | interface CurrentParams {
  method getHotList (line 28) | async getHotList() {
  method setCurrentParams (line 50) | setCurrentParams(currentParams: CurrentParams) {

FILE: frontend/src/stores/index.ts
  type StoreType (line 3) | interface StoreType {
  method setScrollTop (line 13) | setScrollTop(top: boolean) {

FILE: frontend/src/stores/resource.ts
  type StorageListObject (line 16) | interface StorageListObject {
  type CloudDriveConfig (line 28) | interface CloudDriveConfig {
  constant CLOUD_DRIVES (line 41) | const CLOUD_DRIVES: CloudDriveConfig[] = [
  method setLoadTree (line 107) | setLoadTree(loadTree: boolean) {
  method searchResources (line 111) | async searchResources(keyword?: string, isLoadMore = false, channelId?: ...
  method setSelectedResource (line 174) | async setSelectedResource(resourceSelect: ShareInfo[]) {
  method saveResource (line 179) | async saveResource(resource: ResourceItem, folderId: string): Promise<vo...
  method saveResourceToDrive (line 190) | async saveResourceToDrive(
  method parsingCloudLink (line 223) | async parsingCloudLink(url: string): Promise<void> {
  method getResourceListAndSelect (line 269) | async getResourceListAndSelect(resource: ResourceItem): Promise<boolean> {
  method handleError (line 302) | handleError(message: string, error: unknown): void {

FILE: frontend/src/stores/userSetting.ts
  method getSettings (line 22) | async getSettings() {
  method saveSettings (line 30) | async saveSettings(settings: {
  method setDisplayStyle (line 43) | setDisplayStyle(style: "table" | "card") {
  method setImagesSource (line 49) | setImagesSource(source: "proxy" | "local") {

FILE: frontend/src/types/douban.ts
  type HotListParams (line 1) | interface HotListParams {
  type HotListItem (line 7) | interface HotListItem {

FILE: frontend/src/types/globals.d.ts
  type Location (line 2) | interface Location {
  type Window (line 10) | interface Window {

FILE: frontend/src/types/index.ts
  type ResourceItem (line 1) | interface ResourceItem {
  type Resource (line 17) | interface Resource {
  type ShareInfo (line 29) | interface ShareInfo {
  type ShareInfoItem (line 37) | interface ShareInfoItem {
  type ShareInfoResponse (line 44) | interface ShareInfoResponse {
  type ShareFileInfoAndFolder (line 51) | interface ShareFileInfoAndFolder {
  type Folder (line 58) | interface Folder {
  type SaveFileParams (line 64) | interface SaveFileParams {
  type GetShareInfoParams (line 72) | interface GetShareInfoParams {
  type ApiResponse (line 77) | interface ApiResponse<T = unknown> {
  type Save115FileParams (line 83) | interface Save115FileParams {
  type SaveQuarkFileParams (line 90) | interface SaveQuarkFileParams {
  type TagColor (line 100) | interface TagColor {
  type GlobalSettingAttributes (line 108) | interface GlobalSettingAttributes {
  type UserSettingAttributes (line 115) | interface UserSettingAttributes {

FILE: frontend/src/types/response.ts
  type RequestErrorCode (line 1) | type RequestErrorCode = -1 | 400 | 401 | 402 | 403 | 500 | 501;
  type RequestSuccess (line 3) | interface RequestSuccess<T> {
  type RequestError (line 9) | interface RequestError<T> {
  type RequestResult (line 15) | type RequestResult<T> = RequestSuccess<T> | RequestError<T>;

FILE: frontend/src/types/user.ts
  type GlobalSettingAttributes (line 1) | interface GlobalSettingAttributes {
  type UserSettingAttributes (line 9) | interface UserSettingAttributes {
  type UserSettingStore (line 14) | interface UserSettingStore {

FILE: frontend/src/utils/index.ts
  function isMobileDevice (line 11) | function isMobileDevice() {
  function throttle (line 18) | function throttle<T extends (...args: any[]) => any>(fn: T, delay: numbe...

FILE: frontend/src/utils/request.ts
  function isLoginAndRedirect (line 29) | function isLoginAndRedirect(url: string) {
Condensed preview — 125 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (303K chars).
[
  {
    "path": ".eslintignore",
    "chars": 33,
    "preview": "node_modules\ndist\nbuild\ncoverage "
  },
  {
    "path": ".eslintrc.js",
    "chars": 1492,
    "preview": "module.exports = {\n  root: true,\n  ignorePatterns: [\"node_modules\", \"dist\", \"build\", \"coverage\"],\n  env: {\n    node: tru"
  },
  {
    "path": ".github/workflows/docker-build-test.yml",
    "chars": 1462,
    "preview": "name: Build and Push Multi-Arch Docker Image for Test\non:\n  workflow_dispatch: # 添加手动触发\njobs:\n  build-and-push:\n    runs"
  },
  {
    "path": ".github/workflows/docker-image.yml",
    "chars": 1726,
    "preview": "name: Docker Image CI/CD\non:\n  push:\n    tags: [\"v*.*.*\"] # 支持标签触发(如 v1.0.0)\n  workflow_dispatch: # 添加手动触发\njobs:\n  build"
  },
  {
    "path": ".gitignore",
    "chars": 175,
    "preview": "node_modules/\nlogs/\ndist/\n.env\n.env.local\n.env.*.local\n\n*.tar\n\n# 数据库数据\n*.sqlite\n\n# 保留模板\n!.env.example\n\n!frontend/.env\n\n#"
  },
  {
    "path": ".prettierignore",
    "chars": 190,
    "preview": "# 构建产物\ndist\nbuild\ncoverage\n\n# 依赖目录\nnode_modules\n\n# 日志文件\n*.log\n\n# 环境配置\n.env*\n!.env.example\n\n# 编辑器配置\n.idea\n.vscode\n*.suo\n*"
  },
  {
    "path": ".prettierrc.js",
    "chars": 312,
    "preview": "module.exports = {\n  semi: true,\n  trailingComma: \"es5\",\n  singleQuote: false,\n  printWidth: 100,\n  tabWidth: 2,\n  useTa"
  },
  {
    "path": "Dockerfile",
    "chars": 924,
    "preview": "# 构建前端项目\nFROM node:18-alpine as frontend-build\nWORKDIR /app\nCOPY frontend/package*.json ./\nRUN npm install -g pnpm\nRUN p"
  },
  {
    "path": "LICENSE",
    "chars": 1067,
    "preview": "MIT License\n\nCopyright (c) 2024 CloudSaver\n\nPermission is hereby granted, free of charge, to any person obtaining a copy"
  },
  {
    "path": "README.md",
    "chars": 7282,
    "preview": "# CloudSaver\n\n![License](https://img.shields.io/badge/license-MIT-blue.svg)\n![Vue](https://img.shields.io/badge/vue-3.x-"
  },
  {
    "path": "backend/.env.example",
    "chars": 121,
    "preview": "# JWT配置\nJWT_SECRET=your_jwt_secret_here\n\n# Telegram配置\nTELEGRAM_BASE_URL=https://t.me/s\n\n# Telegram频道配置\nTELE_CHANNELS=[]\n"
  },
  {
    "path": "backend/package.json",
    "chars": 951,
    "preview": "{\n  \"name\": \"cloud-saver-server\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"dev\": \"nodemon --exec ts-"
  },
  {
    "path": "backend/src/app.ts",
    "chars": 1399,
    "preview": "// filepath: /d:/code/CloudDiskDown/backend/src/app.ts\nimport \"./types/express\";\nimport express from \"express\";\nimport {"
  },
  {
    "path": "backend/src/config/database.ts",
    "chars": 197,
    "preview": "// backend/src/config/database.ts\nimport { Sequelize } from \"sequelize\";\n\nconst sequelize = new Sequelize({\n  dialect: \""
  },
  {
    "path": "backend/src/config/index.ts",
    "chars": 1930,
    "preview": "import dotenv from \"dotenv\";\n\n// 加载.env文件\ndotenv.config();\n\ninterface Channel {\n  id: string;\n  name: string;\n}\n\ninterfa"
  },
  {
    "path": "backend/src/controllers/BaseCloudController.ts",
    "chars": 1161,
    "preview": "import { Request, Response } from \"express\";\nimport { BaseController } from \"./BaseController\";\nimport { ICloudStorageSe"
  },
  {
    "path": "backend/src/controllers/BaseController.ts",
    "chars": 671,
    "preview": "import { Request, Response } from \"express\";\nimport { ApiResponse } from \"../core/ApiResponse\";\ninterface ApiResponseDat"
  },
  {
    "path": "backend/src/controllers/cloud115.ts",
    "chars": 403,
    "preview": "import { Cloud115Service } from \"../services/Cloud115Service\";\nimport { injectable, inject } from \"inversify\";\nimport { "
  },
  {
    "path": "backend/src/controllers/douban.ts",
    "chars": 876,
    "preview": "import { Request, Response } from \"express\";\nimport { injectable, inject } from \"inversify\";\nimport { TYPES } from \"../c"
  },
  {
    "path": "backend/src/controllers/quark.ts",
    "chars": 427,
    "preview": "import { Request, Response } from \"express\";\nimport { injectable, inject } from \"inversify\";\nimport { TYPES } from \"../c"
  },
  {
    "path": "backend/src/controllers/resource.ts",
    "chars": 732,
    "preview": "import { Request, Response } from \"express\";\nimport { injectable, inject } from \"inversify\";\nimport { TYPES } from \"../c"
  },
  {
    "path": "backend/src/controllers/setting.ts",
    "chars": 976,
    "preview": "import { Request, Response } from \"express\";\nimport { injectable, inject } from \"inversify\";\nimport { TYPES } from \"../c"
  },
  {
    "path": "backend/src/controllers/sponsors.ts",
    "chars": 605,
    "preview": "import { Request, Response } from \"express\";\nimport { injectable, inject } from \"inversify\";\nimport { TYPES } from \"../c"
  },
  {
    "path": "backend/src/controllers/teleImages.ts",
    "chars": 965,
    "preview": "import { Request, Response } from \"express\";\nimport { injectable, inject } from \"inversify\";\nimport { TYPES } from \"../c"
  },
  {
    "path": "backend/src/controllers/user.ts",
    "chars": 908,
    "preview": "import { Request, Response } from \"express\";\nimport { injectable, inject } from \"inversify\";\nimport { TYPES } from \"../c"
  },
  {
    "path": "backend/src/core/ApiResponse.ts",
    "chars": 540,
    "preview": "export class ApiResponse<T> {\n  success: boolean;\n  data?: T;\n  message?: string;\n  code: number;\n\n  private constructor"
  },
  {
    "path": "backend/src/core/types.ts",
    "chars": 859,
    "preview": "export const TYPES = {\n  DatabaseService: Symbol.for(\"DatabaseService\"),\n  Cloud115Service: Symbol.for(\"Cloud115Service\""
  },
  {
    "path": "backend/src/inversify.config.ts",
    "chars": 2612,
    "preview": "import { Container } from \"inversify\";\nimport { TYPES } from \"./core/types\";\n\n// Services\nimport { DatabaseService } fro"
  },
  {
    "path": "backend/src/middleware/auth.ts",
    "chars": 1164,
    "preview": "// filepath: /D:/code/CloudDiskDown/backend/src/middleware/auth.ts\nimport { Request, Response, NextFunction } from \"expr"
  },
  {
    "path": "backend/src/middleware/cors.ts",
    "chars": 531,
    "preview": "import { Request, Response, NextFunction } from \"express\";\n\nexport const cors = () => {\n  return (req: Request, res: Res"
  },
  {
    "path": "backend/src/middleware/errorHandler.ts",
    "chars": 298,
    "preview": "import { Request, Response } from \"express\";\n\ninterface CustomError extends Error {\n  status?: number;\n}\n\nexport const e"
  },
  {
    "path": "backend/src/middleware/index.ts",
    "chars": 423,
    "preview": "import { Application } from \"express\";\nimport express from \"express\";\nimport { authMiddleware } from \"./auth\";\nimport { "
  },
  {
    "path": "backend/src/middleware/rateLimiter.ts",
    "chars": 781,
    "preview": "import { Request, Response, NextFunction } from \"express\";\n\nconst requestCounts = new Map<string, { count: number; times"
  },
  {
    "path": "backend/src/middleware/requestLogger.ts",
    "chars": 592,
    "preview": "import { Request, Response, NextFunction } from \"express\";\nimport { logger } from \"../utils/logger\";\n\nconst excludePaths"
  },
  {
    "path": "backend/src/middleware/validateRequest.ts",
    "chars": 549,
    "preview": "import { Request, Response, NextFunction } from \"express\";\n\nexport const validateRequest = (\n  requiredParams: string[]\n"
  },
  {
    "path": "backend/src/models/GlobalSetting.ts",
    "chars": 1514,
    "preview": "import { DataTypes, Model, Optional } from \"sequelize\";\nimport sequelize from \"../config/database\";\n\nexport interface Gl"
  },
  {
    "path": "backend/src/models/User.ts",
    "chars": 1280,
    "preview": "import { DataTypes, Model, Optional } from \"sequelize\";\nimport sequelize from \"../config/database\";\n\ninterface UserAttri"
  },
  {
    "path": "backend/src/models/UserSetting.ts",
    "chars": 1463,
    "preview": "import { DataTypes, Model, Optional } from \"sequelize\";\nimport sequelize from \"../config/database\";\nimport User from \"./"
  },
  {
    "path": "backend/src/routes/api.ts",
    "chars": 2551,
    "preview": "import { Router } from \"express\";\nimport { container } from \"../inversify.config\";\nimport { TYPES } from \"../core/types\""
  },
  {
    "path": "backend/src/services/Cloud115Service.ts",
    "chars": 4417,
    "preview": "import { AxiosHeaders, AxiosInstance } from \"axios\"; // 导入 AxiosHeaders\nimport { createAxiosInstance } from \"../utils/ax"
  },
  {
    "path": "backend/src/services/DatabaseService.ts",
    "chars": 1792,
    "preview": "import { Sequelize, QueryTypes } from \"sequelize\";\nimport GlobalSetting from \"../models/GlobalSetting\";\nimport { Searche"
  },
  {
    "path": "backend/src/services/DoubanService.ts",
    "chars": 2610,
    "preview": "import { AxiosHeaders, AxiosInstance } from \"axios\";\nimport { createAxiosInstance } from \"../utils/axiosInstance\";\n\ninte"
  },
  {
    "path": "backend/src/services/ImageService.ts",
    "chars": 1957,
    "preview": "import { injectable } from \"inversify\";\nimport axios, { AxiosInstance } from \"axios\";\nimport tunnel from \"tunnel\";\nimpor"
  },
  {
    "path": "backend/src/services/QuarkService.ts",
    "chars": 5260,
    "preview": "import { AxiosInstance, AxiosHeaders } from \"axios\";\nimport { logger } from \"../utils/logger\";\nimport { createAxiosInsta"
  },
  {
    "path": "backend/src/services/Searcher.ts",
    "chars": 6886,
    "preview": "import { AxiosInstance, AxiosHeaders } from \"axios\";\nimport { createAxiosInstance } from \"../utils/axiosInstance\";\nimpor"
  },
  {
    "path": "backend/src/services/SettingService.ts",
    "chars": 1684,
    "preview": "import { injectable, inject } from \"inversify\";\nimport { TYPES } from \"../core/types\";\nimport UserSetting from \"../model"
  },
  {
    "path": "backend/src/services/SponsorsService.ts",
    "chars": 643,
    "preview": "import { injectable } from \"inversify\";\nimport { createAxiosInstance } from \"../utils/axiosInstance\";\nimport { AxiosInst"
  },
  {
    "path": "backend/src/services/UserService.ts",
    "chars": 1786,
    "preview": "import { injectable } from \"inversify\";\nimport bcrypt from \"bcrypt\";\nimport jwt from \"jsonwebtoken\";\nimport { config } f"
  },
  {
    "path": "backend/src/sponsors/sponsors.json",
    "chars": 1432,
    "preview": "{\n  \"sponsors\": [\n    {\n      \"name\": \"立本狗头\",\n      \"avatar\": \"http://oss.jiangmuxin.cn/cloudsaver/sponsors/thanks1.jpg\""
  },
  {
    "path": "backend/src/types/cloud.ts",
    "chars": 1535,
    "preview": "export interface ShareInfoResponse {\n  data: {\n    list: ShareInfoItem[];\n    fileSize?: number;\n    pwdId?: string;\n   "
  },
  {
    "path": "backend/src/types/cloud115.ts",
    "chars": 171,
    "preview": "export interface ShareInfo {\n  fileId: string;\n  fileName: string;\n  fileSize: number;\n}\n\nexport interface ShareInfoResp"
  },
  {
    "path": "backend/src/types/express.ts",
    "chars": 215,
    "preview": "// eslint-disable-next-line @typescript-eslint/no-unused-vars\nimport { Request } from \"express\";\n\ndeclare module \"expres"
  },
  {
    "path": "backend/src/types/index.ts",
    "chars": 205,
    "preview": "export interface Config {\n  app: {\n    port: number;\n    env: string;\n  };\n  database: {\n    type: string;\n    path: str"
  },
  {
    "path": "backend/src/types/services.ts",
    "chars": 408,
    "preview": "import { Request } from \"express\";\nimport { ShareInfoResponse, FolderListResponse, SaveFileParams } from \"./cloud\";\n\nexp"
  },
  {
    "path": "backend/src/utils/axiosInstance.ts",
    "chars": 592,
    "preview": "import axios, { AxiosInstance, AxiosRequestHeaders } from \"axios\";\nimport tunnel from \"tunnel\";\n\ninterface ProxyConfig {"
  },
  {
    "path": "backend/src/utils/handleError.ts",
    "chars": 387,
    "preview": "import { Response, NextFunction } from \"express\";\nimport { logger } from \"../utils/logger\";\n\ninterface CustomError {\n  n"
  },
  {
    "path": "backend/src/utils/index.ts",
    "chars": 533,
    "preview": "import jwt from \"jsonwebtoken\";\nimport { Request } from \"express\";\nimport { config } from \"../config\";\n\ninterface JwtPay"
  },
  {
    "path": "backend/src/utils/logger.ts",
    "chars": 591,
    "preview": "import winston from \"winston\";\nimport { config } from \"../config\";\n\nconst logger = winston.createLogger({\n  level: confi"
  },
  {
    "path": "backend/src/utils/response.ts",
    "chars": 462,
    "preview": "import { Response } from \"express\";\n\ninterface ResponseData {\n  code?: number; // 业务状态码\n  message?: string;\n  data?: any"
  },
  {
    "path": "backend/src/utils/responseHandler.ts",
    "chars": 151,
    "preview": "import { Response } from \"express\";\n\nexport const handleResponse = (res: Response, data: any, success: boolean) => {\n  r"
  },
  {
    "path": "backend/tsconfig.json",
    "chars": 596,
    "preview": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"module\": \"commonjs\",\n    \"lib\": [\"ES2020\"],\n    \"outDir\": \"./dist\""
  },
  {
    "path": "docker-entrypoint.sh",
    "chars": 266,
    "preview": "#!/bin/sh\n\n# 如果配置目录下没有 env 文件,则复制示例文件\nif [ ! -f /app/config/env ]; then\n    cp /app/.env.example /app/config/env\n    ech"
  },
  {
    "path": "frontend/.env",
    "chars": 68,
    "preview": "VITE_API_BASE_URL=\"\"\nVITE_API_BASE_URL_PROXY=\"http://127.0.0.1:8009\""
  },
  {
    "path": "frontend/auto-imports.d.ts",
    "chars": 300,
    "preview": "/* eslint-disable */\n/* prettier-ignore */\n// @ts-nocheck\n// noinspection JSUnusedGlobalSymbols\n// Generated by unplugin"
  },
  {
    "path": "frontend/components.d.ts",
    "chars": 3682,
    "preview": "/* eslint-disable */\n/* prettier-ignore */\n// @ts-nocheck\n// Generated by unplugin-vue-components\n// Read more: https://"
  },
  {
    "path": "frontend/index.html",
    "chars": 1491,
    "preview": "<!doctype html>\n<html lang=\"zh-CN\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" href=\"/favicon.ico\" />\n  "
  },
  {
    "path": "frontend/package.json",
    "chars": 887,
    "preview": "{\n  \"name\": \"cloud-saver-web\",\n  \"private\": true,\n  \"version\": \"0.2.5\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"v"
  },
  {
    "path": "frontend/postcss.config.cjs",
    "chars": 371,
    "preview": "module.exports = {\n  plugins: {\n    \"postcss-pxtorem\": {\n      rootValue({ file }) {\n        return file.indexOf(\"vant\")"
  },
  {
    "path": "frontend/src/App.vue",
    "chars": 1223,
    "preview": "<template>\n  <el-config-provider>\n    <router-view />\n  </el-config-provider>\n</template>\n\n<style>\n#app {\n  height: 100v"
  },
  {
    "path": "frontend/src/api/cloud115.ts",
    "chars": 672,
    "preview": "import request from \"@/utils/request\";\nimport type { ShareInfoResponse, Folder, SaveFileParams, GetShareInfoParams } fro"
  },
  {
    "path": "frontend/src/api/douban.ts",
    "chars": 294,
    "preview": "import request from \"@/utils/request\";\nimport { HotListItem, HotListParams } from \"@/types/douban\";\n\nexport const douban"
  },
  {
    "path": "frontend/src/api/quark.ts",
    "chars": 641,
    "preview": "import request from \"@/utils/request\";\nimport type { ShareInfoResponse, Folder, SaveFileParams, GetShareInfoParams } fro"
  },
  {
    "path": "frontend/src/api/resource.ts",
    "chars": 309,
    "preview": "import request from \"@/utils/request\";\nimport type { Resource } from \"@/types/index\";\n\nexport const resourceApi = {\n  se"
  },
  {
    "path": "frontend/src/api/setting.ts",
    "chars": 503,
    "preview": "import request from \"@/utils/request\";\nimport type { GlobalSettingAttributes, UserSettingAttributes } from \"@/types\";\n\ne"
  },
  {
    "path": "frontend/src/api/user.ts",
    "chars": 459,
    "preview": "import request from \"@/utils/request\";\n\nexport const userApi = {\n  login: (data: { username: string; password: string })"
  },
  {
    "path": "frontend/src/components/AsideMenu.vue",
    "chars": 7166,
    "preview": "<template>\n  <div class=\"pc-aside\">\n    <!-- Logo 区域 -->\n    <div class=\"pc-aside__logo\">\n      <img :src=\"logo\" alt=\"Cl"
  },
  {
    "path": "frontend/src/components/Home/FolderSelect.vue",
    "chars": 6251,
    "preview": "<template>\n  <div class=\"folder-select\">\n    <div class=\"folder-header\">\n      <div class=\"folder-path\">\n        <el-ico"
  },
  {
    "path": "frontend/src/components/Home/ResourceCard.vue",
    "chars": 15094,
    "preview": "<template>\n  <div class=\"resource-card\">\n    <!-- 详情弹窗 -->\n    <el-dialog\n      v-model=\"showDetail\"\n      :title=\"curre"
  },
  {
    "path": "frontend/src/components/Home/ResourceSelect.vue",
    "chars": 4354,
    "preview": "<template>\n  <div class=\"resource-select\">\n    <div class=\"select-header\">\n      <div class=\"select-info\">\n        <el-i"
  },
  {
    "path": "frontend/src/components/Home/ResourceTable.vue",
    "chars": 5073,
    "preview": "<template>\n  <el-table\n    v-loading=\"store.loading\"\n    class=\"resource-list-table\"\n    :data=\"store.resources\"\n    sty"
  },
  {
    "path": "frontend/src/components/SearchBar.vue",
    "chars": 4669,
    "preview": "<template>\n  <div class=\"pc-search\">\n    <!-- 搜索区域 -->\n    <div class=\"pc-search__input\">\n      <el-input\n        v-mode"
  },
  {
    "path": "frontend/src/components/mobile/FolderSelect.vue",
    "chars": 5864,
    "preview": "<template>\n  <div class=\"folder-select\">\n    <!-- 面包屑导航 -->\n    <div class=\"folder-select__nav\">\n      <van-cell :border"
  },
  {
    "path": "frontend/src/components/mobile/ResourceCard.vue",
    "chars": 6295,
    "preview": "<template>\n  <div class=\"resource-card\">\n    <div v-for=\"item in dataList\" :key=\"item.id\" class=\"resource-card__item\">\n "
  },
  {
    "path": "frontend/src/components/mobile/ResourceSelect.vue",
    "chars": 4197,
    "preview": "<template>\n  <div class=\"resource-select\">\n    <van-checkbox-group v-model=\"selectedResourceIds\">\n      <van-cell-group "
  },
  {
    "path": "frontend/src/constants/project.ts",
    "chars": 118,
    "preview": "export const PROJECT_NAME = \"Cloudsaver\";\nexport const PROJECT_GITHUB = \"https://github.com/jiangrui1994/cloudsaver\";\n"
  },
  {
    "path": "frontend/src/constants/storage.ts",
    "chars": 120,
    "preview": "export const STORAGE_KEYS = {\n  USERNAME: \"saved_username\",\n  PASSWORD: \"saved_password\",\n  TOKEN: \"token\",\n} as const;\n"
  },
  {
    "path": "frontend/src/env.d.ts",
    "chars": 355,
    "preview": "/// <reference types=\"vite/client\" />\n\ndeclare module \"*.vue\" {\n  import type { DefineComponent } from \"vue\";\n  const co"
  },
  {
    "path": "frontend/src/main.ts",
    "chars": 1182,
    "preview": "import { createApp } from \"vue\";\nimport { createPinia } from \"pinia\";\nimport ElementPlus from \"element-plus\";\nimport \"el"
  },
  {
    "path": "frontend/src/router/index.ts",
    "chars": 364,
    "preview": "import { createRouter, createWebHistory } from \"vue-router\";\nimport mobileRoutes from \"./mobile-routes\";\nimport pcRoutes"
  },
  {
    "path": "frontend/src/router/mobile-routes.ts",
    "chars": 859,
    "preview": "import type { RouteRecordRaw } from \"vue-router\";\nconst routes: RouteRecordRaw[] = [\n  {\n    path: \"/\",\n    name: \"home\""
  },
  {
    "path": "frontend/src/router/pc-routes.ts",
    "chars": 851,
    "preview": "import type { RouteRecordRaw } from \"vue-router\";\nconst routes: RouteRecordRaw[] = [\n  {\n    path: \"/\",\n    name: \"home\""
  },
  {
    "path": "frontend/src/stores/douban.ts",
    "chars": 1288,
    "preview": "import { defineStore } from \"pinia\";\nimport { doubanApi } from \"@/api/douban\";\nimport { HotListItem } from \"@/types/doub"
  },
  {
    "path": "frontend/src/stores/index.ts",
    "chars": 280,
    "preview": "import { defineStore } from \"pinia\";\n\ninterface StoreType {\n  scrollTop: boolean;\n}\n\nexport const useStore = defineStore"
  },
  {
    "path": "frontend/src/stores/resource.ts",
    "chars": 9795,
    "preview": "import { defineStore } from \"pinia\";\nimport { cloud115Api } from \"@/api/cloud115\";\nimport { resourceApi } from \"@/api/re"
  },
  {
    "path": "frontend/src/stores/userSetting.ts",
    "chars": 1604,
    "preview": "import { defineStore } from \"pinia\";\nimport type {\n  UserSettingStore,\n  GlobalSettingAttributes,\n  UserSettingAttribute"
  },
  {
    "path": "frontend/src/styles/common.scss",
    "chars": 1187,
    "preview": "// 颜色系统\n:root {\n  // 主题色\n  --theme-primary: #0066cc;\n  --theme-primary-hover: #0256ac;\n  --theme-success: #28cd41;\n  --t"
  },
  {
    "path": "frontend/src/styles/global.scss",
    "chars": 174,
    "preview": ":root {\n  --theme-color: #3e3e3e;\n  --theme-theme: #133ab3;\n  --theme-background: #fafafa;\n  --theme-other_background: #"
  },
  {
    "path": "frontend/src/styles/mobile.scss",
    "chars": 894,
    "preview": "/* 移动端通用样式类 */\n.mobile-page {\n    padding: var(--spacing-base);\n    min-height: 100vh;\n    background-color: var(--theme"
  },
  {
    "path": "frontend/src/styles/responsive.scss",
    "chars": 1443,
    "preview": "@mixin text-ellipsis {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n// 响应式布局工具类\n@mixin mobil"
  },
  {
    "path": "frontend/src/types/douban.ts",
    "chars": 328,
    "preview": "export interface HotListParams {\n  type: string;\n  tag?: string;\n  page_limit?: string;\n  page_start?: string;\n}\nexport "
  },
  {
    "path": "frontend/src/types/globals.d.ts",
    "chars": 235,
    "preview": "declare global {\n  interface Location {\n    // 根据你的需求定义 location 的属性和方法\n    pathname: string;\n    search: string;\n    ha"
  },
  {
    "path": "frontend/src/types/index.ts",
    "chars": 2194,
    "preview": "export interface ResourceItem {\n  id: string;\n  title: string;\n  channel: string;\n  channelId?: string;\n  image?: string"
  },
  {
    "path": "frontend/src/types/response.ts",
    "chars": 316,
    "preview": "export type RequestErrorCode = -1 | 400 | 401 | 402 | 403 | 500 | 501;\n\nexport interface RequestSuccess<T> {\n  code: 0;\n"
  },
  {
    "path": "frontend/src/types/user.ts",
    "chars": 471,
    "preview": "export interface GlobalSettingAttributes {\n  httpProxyHost: string;\n  httpProxyPort: string | number;\n  isProxyEnabled: "
  },
  {
    "path": "frontend/src/utils/device.ts",
    "chars": 375,
    "preview": "export const isMobile = () => {\n  return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator"
  },
  {
    "path": "frontend/src/utils/image.ts",
    "chars": 395,
    "preview": "import { useUserSettingStore } from \"@/stores/userSetting\";\nimport defaultImage from \"@/assets/images/default.png\";\n\nexp"
  },
  {
    "path": "frontend/src/utils/index.ts",
    "chars": 764,
    "preview": "export const formattedFileSize = (size: number): string => {\n  if (size < 1024 * 1024) {\n    return `${(size / 1024).toF"
  },
  {
    "path": "frontend/src/utils/request.ts",
    "chars": 2200,
    "preview": "import axios, { AxiosResponse, AxiosRequestConfig } from \"axios\";\nimport { ElMessage } from \"element-plus\";\nimport { isM"
  },
  {
    "path": "frontend/src/views/Douban.vue",
    "chars": 4103,
    "preview": "<template>\n  <div class=\"douban-page\">\n    <div class=\"movie-wall\">\n      <div v-for=\"movie in doubanStore.hotList\" :key"
  },
  {
    "path": "frontend/src/views/Home.vue",
    "chars": 4213,
    "preview": "<template>\n  <div class=\"pc-home\" :class=\"{ 'is-loading': resourcStore.loading }\">\n    <!-- 主布局容器 -->\n    <el-container "
  },
  {
    "path": "frontend/src/views/ResourceList.vue",
    "chars": 13608,
    "preview": "<template>\n  <div class=\"pc-resources\">\n    <!-- 头部工具栏 -->\n    <div class=\"pc-resources__header\">\n      <div class=\"head"
  },
  {
    "path": "frontend/src/views/Setting.vue",
    "chars": 10636,
    "preview": "<template>\n  <div class=\"settings-page\">\n    <!-- 项目配置卡片 -->\n    <el-card v-if=\"settingStore.globalSetting\" class=\"setti"
  },
  {
    "path": "frontend/src/views/Thanks.vue",
    "chars": 18224,
    "preview": "<template>\n  <div ref=\"containerRef\" class=\"thanks-container\">\n    <div ref=\"titleRef\" class=\"title\">感谢Ta们对项目的赞赏</div>\n\n"
  },
  {
    "path": "frontend/src/views/mobile/Douban.vue",
    "chars": 4594,
    "preview": "<template>\n  <div class=\"mobile-page douban\">\n    <!-- 加载状态 -->\n    <div v-if=\"doubanStore.loading\" class=\"douban__loadi"
  },
  {
    "path": "frontend/src/views/mobile/Home.vue",
    "chars": 4389,
    "preview": "<template>\n  <div class=\"home\">\n    <!-- 顶部搜索栏 -->\n    <header class=\"home__header\">\n      <div class=\"header__wrapper\">"
  },
  {
    "path": "frontend/src/views/mobile/Login.vue",
    "chars": 11065,
    "preview": "<template>\n  <div class=\"login\">\n    <!-- 背景区域 -->\n    <div class=\"login__background\" aria-hidden=\"true\" />\n\n    <!-- 主要"
  },
  {
    "path": "frontend/src/views/mobile/ResourceList.vue",
    "chars": 11851,
    "preview": "<template>\n  <div ref=\"listRef\" class=\"resource-list\">\n    <!-- 头部刷新区 -->\n    <van-cell-group :border=\"false\" class=\"res"
  },
  {
    "path": "frontend/src/views/mobile/Setting.vue",
    "chars": 7020,
    "preview": "<template>\n  <div class=\"setting\">\n    <!-- 全局设置 -->\n    <div v-if=\"settingStore.globalSetting\" class=\"setting__section\""
  },
  {
    "path": "frontend/src/views/pc/Login.vue",
    "chars": 8895,
    "preview": "<template>\n  <div class=\"login-page\">\n    <div class=\"login-bg\"></div>\n    <div class=\"login-card\">\n      <div class=\"ca"
  },
  {
    "path": "frontend/tsconfig.json",
    "chars": 738,
    "preview": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"module\": \"ESNext\",\n    \"lib\":"
  },
  {
    "path": "frontend/tsconfig.node.json",
    "chars": 213,
    "preview": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"skipLibCheck\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\""
  },
  {
    "path": "frontend/vite.config.ts",
    "chars": 2664,
    "preview": "import { defineConfig } from \"vite\";\nimport vue from \"@vitejs/plugin-vue\";\nimport { fileURLToPath, URL } from \"node:url\""
  },
  {
    "path": "nginx.conf",
    "chars": 901,
    "preview": "# nginx.conf\nuser root;  # 定义 Nginx 进程的运行用户\nworker_processes 1;  # 设置 Nginx 进程数\n\nevents {\n    worker_connections 1024;  "
  },
  {
    "path": "package.json",
    "chars": 1730,
    "preview": "{\n  \"name\": \"cloud-saver\",\n  \"version\": \"0.2.5\",\n  \"private\": true,\n  \"workspaces\": [\n    \"frontend\",\n    \"backend\"\n  ],"
  },
  {
    "path": "pnpm-workspace.yaml",
    "chars": 38,
    "preview": "packages:\n  - 'frontend'\n  - 'backend'"
  }
]

About this extraction

This page contains the full source code of the jiangrui1994/CloudSaver GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 125 files (272.7 KB), approximately 79.8k tokens, and a symbol index with 194 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!