初始提交:网盘管理跨端应用

This commit is contained in:
liRQ 2026-06-18 14:39:13 +08:00
commit ac3bdf0e0c
94 changed files with 18554 additions and 0 deletions

View File

@ -0,0 +1,112 @@
# 知识卡导出索引文件
schema_version: 1
locale: zh-CN
branch: Q0DeR-MaG1C-BrAnCh-Fo2-NoN-GiT-Or-No-C0MmIt
exported_at: "2026-06-12T08:09:05Z"
modules:
"":
dir_name: 网盘管理跨端应用
title: 网盘管理跨端应用
scope:
- vue\.gitignore
- vue\.vscode\extensions.json
- vue\APK_BUILD_OPTIONS.md
- vue\APP_GUIDE.md
- vue\PACKAGING_SUMMARY.md
- vue\README.md
- vue\android\.gitignore
- vue\android\app\.gitignore
- vue\android\app\build.gradle
- vue\android\app\capacitor.build.gradle
- vue\android\app\proguard-rules.pro
- vue\android\app\src\androidTest\java\com\getcapacitor\myapp\ExampleInstrumentedTest.java
- vue\android\app\src\main\AndroidManifest.xml
- vue\android\app\src\main\assets\capacitor.config.json
- vue\android\app\src\main\assets\capacitor.plugins.json
- vue\android\app\src\main\assets\public\assets\index-DrjxTYTT.css
- vue\android\app\src\main\assets\public\assets\index-xOoYtVTp.js
- vue\android\app\src\main\assets\public\cordova.js
- vue\android\app\src\main\assets\public\cordova_plugins.js
- vue\android\app\src\main\assets\public\index.html
- vue\android\app\src\main\assets\public\logo.png
- vue\android\app\src\main\assets\public\vite.svg
- vue\android\app\src\main\java\com\wangpan\app\MainActivity.java
- vue\android\app\src\main\res\drawable-land-hdpi\splash.png
- vue\android\app\src\main\res\drawable-land-mdpi\splash.png
- vue\android\app\src\main\res\drawable-land-xhdpi\splash.png
- vue\android\app\src\main\res\drawable-land-xxhdpi\splash.png
- vue\android\app\src\main\res\drawable-land-xxxhdpi\splash.png
- vue\android\app\src\main\res\drawable-port-hdpi\splash.png
- vue\android\app\src\main\res\drawable-port-mdpi\splash.png
- vue\android\app\src\main\res\drawable-port-xhdpi\splash.png
- vue\android\app\src\main\res\drawable-port-xxhdpi\splash.png
- vue\android\app\src\main\res\drawable-port-xxxhdpi\splash.png
- vue\android\app\src\main\res\drawable-v24\ic_launcher_foreground.xml
- vue\android\app\src\main\res\drawable\ic_launcher_background.xml
- vue\android\app\src\main\res\drawable\splash.png
- vue\android\app\src\main\res\layout\activity_main.xml
- vue\android\app\src\main\res\mipmap-anydpi-v26\ic_launcher.xml
- vue\android\app\src\main\res\mipmap-anydpi-v26\ic_launcher_round.xml
- vue\android\app\src\main\res\mipmap-hdpi\ic_launcher.png
- vue\android\app\src\main\res\mipmap-hdpi\ic_launcher_foreground.png
- vue\android\app\src\main\res\mipmap-hdpi\ic_launcher_round.png
- vue\android\app\src\main\res\mipmap-mdpi\ic_launcher.png
- vue\android\app\src\main\res\mipmap-mdpi\ic_launcher_foreground.png
- vue\android\app\src\main\res\mipmap-mdpi\ic_launcher_round.png
- vue\android\app\src\main\res\mipmap-xhdpi\ic_launcher.png
- vue\android\app\src\main\res\mipmap-xhdpi\ic_launcher_foreground.png
- vue\android\app\src\main\res\mipmap-xhdpi\ic_launcher_round.png
- vue\android\app\src\main\res\mipmap-xxhdpi\ic_launcher.png
- vue\android\app\src\main\res\mipmap-xxhdpi\ic_launcher_foreground.png
- vue\android\app\src\main\res\mipmap-xxhdpi\ic_launcher_round.png
- vue\android\app\src\main\res\mipmap-xxxhdpi\ic_launcher.png
- vue\android\app\src\main\res\mipmap-xxxhdpi\ic_launcher_foreground.png
- vue\android\app\src\main\res\mipmap-xxxhdpi\ic_launcher_round.png
- vue\android\app\src\main\res\values\ic_launcher_background.xml
- vue\android\app\src\main\res\values\strings.xml
- vue\android\app\src\main\res\values\styles.xml
- vue\android\app\src\main\res\xml\config.xml
- vue\android\app\src\main\res\xml\file_paths.xml
- vue\android\app\src\test\java\com\getcapacitor\myapp\ExampleUnitTest.java
- vue\android\build.gradle
- vue\android\capacitor-cordova-android-plugins\build.gradle
- vue\android\capacitor-cordova-android-plugins\cordova.variables.gradle
- vue\android\capacitor-cordova-android-plugins\src\main\AndroidManifest.xml
- vue\android\capacitor-cordova-android-plugins\src\main\java\.gitkeep
- vue\android\capacitor-cordova-android-plugins\src\main\res\.gitkeep
- vue\android\capacitor.settings.gradle
- vue\android\gradle.properties
- vue\android\gradle\wrapper\gradle-wrapper.jar
- vue\android\gradle\wrapper\gradle-wrapper.properties
- vue\android\gradlew
- vue\android\gradlew.bat
- vue\android\settings.gradle
- vue\android\variables.gradle
- vue\build-apk.bat
- vue\capacitor.config.json
- vue\index.html
- vue\package-lock.json
- vue\package.json
- vue\public\logo.png
- vue\public\vite.svg
- vue\src\App.vue
- vue\src\api\index.js
- vue\src\assets\vue.svg
- vue\src\components\DriveManageDialog.vue
- vue\src\components\FolderTree.vue
- vue\src\components\LoginView.vue
- vue\src\components\PosterCard.vue
- vue\src\components\RecordDialog.vue
- vue\src\components\SimpleDialog.vue
- vue\src\components\TreeNode.vue
- vue\src\components\UserManageDialog.vue
- vue\src\main.js
- vue\src\style.css
- vue\src\utils\captcha.js
- vue\src\utils\data.js
- vue\src\utils\storage.js
- vue\vite.config.js
source_files: []
children: []
depends_on: []
related_to: []

View File

@ -0,0 +1,105 @@
schema_version: 1
module_path: ""
title: 网盘管理跨端应用
scope:
- vue\.gitignore
- vue\.vscode\extensions.json
- vue\APK_BUILD_OPTIONS.md
- vue\APP_GUIDE.md
- vue\PACKAGING_SUMMARY.md
- vue\README.md
- vue\android\.gitignore
- vue\android\app\.gitignore
- vue\android\app\build.gradle
- vue\android\app\capacitor.build.gradle
- vue\android\app\proguard-rules.pro
- vue\android\app\src\androidTest\java\com\getcapacitor\myapp\ExampleInstrumentedTest.java
- vue\android\app\src\main\AndroidManifest.xml
- vue\android\app\src\main\assets\capacitor.config.json
- vue\android\app\src\main\assets\capacitor.plugins.json
- vue\android\app\src\main\assets\public\assets\index-DrjxTYTT.css
- vue\android\app\src\main\assets\public\assets\index-xOoYtVTp.js
- vue\android\app\src\main\assets\public\cordova.js
- vue\android\app\src\main\assets\public\cordova_plugins.js
- vue\android\app\src\main\assets\public\index.html
- vue\android\app\src\main\assets\public\logo.png
- vue\android\app\src\main\assets\public\vite.svg
- vue\android\app\src\main\java\com\wangpan\app\MainActivity.java
- vue\android\app\src\main\res\drawable-land-hdpi\splash.png
- vue\android\app\src\main\res\drawable-land-mdpi\splash.png
- vue\android\app\src\main\res\drawable-land-xhdpi\splash.png
- vue\android\app\src\main\res\drawable-land-xxhdpi\splash.png
- vue\android\app\src\main\res\drawable-land-xxxhdpi\splash.png
- vue\android\app\src\main\res\drawable-port-hdpi\splash.png
- vue\android\app\src\main\res\drawable-port-mdpi\splash.png
- vue\android\app\src\main\res\drawable-port-xhdpi\splash.png
- vue\android\app\src\main\res\drawable-port-xxhdpi\splash.png
- vue\android\app\src\main\res\drawable-port-xxxhdpi\splash.png
- vue\android\app\src\main\res\drawable-v24\ic_launcher_foreground.xml
- vue\android\app\src\main\res\drawable\ic_launcher_background.xml
- vue\android\app\src\main\res\drawable\splash.png
- vue\android\app\src\main\res\layout\activity_main.xml
- vue\android\app\src\main\res\mipmap-anydpi-v26\ic_launcher.xml
- vue\android\app\src\main\res\mipmap-anydpi-v26\ic_launcher_round.xml
- vue\android\app\src\main\res\mipmap-hdpi\ic_launcher.png
- vue\android\app\src\main\res\mipmap-hdpi\ic_launcher_foreground.png
- vue\android\app\src\main\res\mipmap-hdpi\ic_launcher_round.png
- vue\android\app\src\main\res\mipmap-mdpi\ic_launcher.png
- vue\android\app\src\main\res\mipmap-mdpi\ic_launcher_foreground.png
- vue\android\app\src\main\res\mipmap-mdpi\ic_launcher_round.png
- vue\android\app\src\main\res\mipmap-xhdpi\ic_launcher.png
- vue\android\app\src\main\res\mipmap-xhdpi\ic_launcher_foreground.png
- vue\android\app\src\main\res\mipmap-xhdpi\ic_launcher_round.png
- vue\android\app\src\main\res\mipmap-xxhdpi\ic_launcher.png
- vue\android\app\src\main\res\mipmap-xxhdpi\ic_launcher_foreground.png
- vue\android\app\src\main\res\mipmap-xxhdpi\ic_launcher_round.png
- vue\android\app\src\main\res\mipmap-xxxhdpi\ic_launcher.png
- vue\android\app\src\main\res\mipmap-xxxhdpi\ic_launcher_foreground.png
- vue\android\app\src\main\res\mipmap-xxxhdpi\ic_launcher_round.png
- vue\android\app\src\main\res\values\ic_launcher_background.xml
- vue\android\app\src\main\res\values\strings.xml
- vue\android\app\src\main\res\values\styles.xml
- vue\android\app\src\main\res\xml\config.xml
- vue\android\app\src\main\res\xml\file_paths.xml
- vue\android\app\src\test\java\com\getcapacitor\myapp\ExampleUnitTest.java
- vue\android\build.gradle
- vue\android\capacitor-cordova-android-plugins\build.gradle
- vue\android\capacitor-cordova-android-plugins\cordova.variables.gradle
- vue\android\capacitor-cordova-android-plugins\src\main\AndroidManifest.xml
- vue\android\capacitor-cordova-android-plugins\src\main\java\.gitkeep
- vue\android\capacitor-cordova-android-plugins\src\main\res\.gitkeep
- vue\android\capacitor.settings.gradle
- vue\android\gradle.properties
- vue\android\gradle\wrapper\gradle-wrapper.jar
- vue\android\gradle\wrapper\gradle-wrapper.properties
- vue\android\gradlew
- vue\android\gradlew.bat
- vue\android\settings.gradle
- vue\android\variables.gradle
- vue\build-apk.bat
- vue\capacitor.config.json
- vue\index.html
- vue\package-lock.json
- vue\package.json
- vue\public\logo.png
- vue\public\vite.svg
- vue\src\App.vue
- vue\src\api\index.js
- vue\src\assets\vue.svg
- vue\src\components\DriveManageDialog.vue
- vue\src\components\FolderTree.vue
- vue\src\components\LoginView.vue
- vue\src\components\PosterCard.vue
- vue\src\components\RecordDialog.vue
- vue\src\components\SimpleDialog.vue
- vue\src\components\TreeNode.vue
- vue\src\components\UserManageDialog.vue
- vue\src\main.js
- vue\src\style.css
- vue\src\utils\captcha.js
- vue\src\utils\data.js
- vue\src\utils\storage.js
- vue\vite.config.js
source_files: []
depends_on: []
related_to: []

View File

@ -0,0 +1 @@
Vue 3.5, Vite 7, Capacitor 8, Android Gradle Plugin

View File

@ -0,0 +1,4 @@
- **前端架构**:采用 Vite + Vue 3 (Composition API) 技术栈,通过 `src/api/index.js` 封装 RESTful 请求并处理 Token 鉴权。
- **跨端桥接**:利用 Capacitor 8 将 Web 应用(`dist` 目录)嵌入原生 Android Shell (`BridgeActivity`),实现从 Web 到 Native 的无缝转换。
- **配置管理**:通过 `capacitor.config.json` 定义应用 ID、启动屏及状态栏样式并通过 `vite.config.js` 代理后端 API 请求。
- **构建流程**遵循“Vite 构建 Web -> Cap sync 同步资源 -> Gradle 编译 APK”的标准化流水线`build-apk.bat` 脚本自动化执行。

View File

@ -0,0 +1 @@
基于 Vue 3 与 Capacitor 构建的网盘资源管理与海报展示跨端应用,支持 Android APK 打包。

View File

@ -0,0 +1 @@
- **APK 打包**:运行根目录下的 `build-apk.bat` 脚本,自动完成 Web 资源构建、Capacitor 同步并唤起 Android Studio 进行最终编译。

View File

@ -0,0 +1,2 @@
- API 模块统一在 `src/api/index.js` 中导出,使用自定义 `request` 包装器处理全局认证头与 401/403 状态分发。
- 组件采用 `<script setup>` 语法糖编写,逻辑复用通过 `src/utils` 下的纯函数(如 `data.js`, `storage.js`)实现。

24
vue/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
vue/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

286
vue/APK_BUILD_OPTIONS.md Normal file
View File

@ -0,0 +1,286 @@
# APK 打包方案指南
## ⚠️ 当前状态
检测到你的系统**未安装 Android SDK**,因此无法直接在命令行打包 APK。
---
## 🎯 推荐方案3选1
### 方案一:使用 Android Studio最简单⭐⭐⭐
这是**最推荐**的方式Android Studio 会自动处理所有依赖。
#### 步骤:
1. **下载 Android Studio**
- 官网https://developer.android.com/studio
- 国内镜像https://www.androiddevtools.cn/
2. **安装 Android Studio**
- 运行安装程序
- 选择默认选项即可
- 会自动下载 Android SDK 和必要组件
3. **打开项目**
```bash
# 在项目根目录运行
npx cap open android
```
或在 Android Studio 中选择:**File → Open → 选择 `android` 文件夹**
4. **等待同步完成**
- 首次打开需要下载 Gradle约 100MB
- 同步完成后会显示项目结构
5. **构建 APK**
- 菜单:**Build → Build Bundle(s) / APK(s) → Build APK(s)**
- 等待构建完成(约 2-5 分钟)
6. **找到 APK**
- 位置:`android\app\build\outputs\apk\debug\app-debug.apk`
- 可以直接安装到手机
#### 优点:
- ✅ 全自动,无需配置
- ✅ 图形界面,操作简单
- ✅ 支持真机调试
- ✅ 可以查看日志和错误
#### 缺点:
- ⏰ 首次安装需要时间(约 2-3GB
- ⏰ 首次构建需要下载依赖
---
### 方案二:手动安装 Android SDK适合开发者⭐⭐
如果你已经有 Android Studio 或想单独安装 SDK。
#### 步骤:
1. **下载 Command Line Tools**
- 地址https://developer.android.com/studio#command-tools
- 下载 `commandlinetools-win-*.zip`
2. **解压并设置环境变量**
```powershell
# 创建目录
mkdir C:\Android\Sdk
# 解压 commandlinetools 到 C:\Android\Sdk\cmdline-tools
# 设置环境变量
$env:ANDROID_HOME = "C:\Android\Sdk"
$env:PATH += ";C:\Android\Sdk\cmdline-tools\latest\bin"
$env:PATH += ";C:\Android\Sdk\platform-tools"
```
3. **安装必要的组件**
```bash
# 接受许可证
sdkmanager --licenses
# 安装必要组件
sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0"
```
4. **配置 local.properties**
`android/local.properties` 中添加:
```properties
sdk.dir=C\:\\Android\\Sdk
```
5. **打包 APK**
```bash
cd android
.\gradlew assembleDebug
```
#### 优点:
- ✅ 轻量级,不需要完整 IDE
- ✅ 可以命令行操作
- ✅ 适合 CI/CD
#### 缺点:
- ⚙️ 配置较复杂
- ⚙️ 需要手动管理 SDK
---
### 方案三:使用在线打包服务(最快)⭐
如果只是想快速测试,可以使用在线服务。
#### 推荐服务:
1. **VoltBuilder** (付费)
- 网址https://volt.build/
- 上传 Web 资源,自动打包
2. **Monaca** (免费/付费)
- 网址https://monaca.io/
- 云端构建 Cordova/Capacitor 应用
3. **PhoneGap Build** (已停止服务)
- 不推荐使用
#### 步骤(以 VoltBuilder 为例):
1. 注册账号
2. 上传 `dist` 文件夹的内容
3. 配置 APP 信息
4. 点击构建
5. 下载 APK
#### 优点:
- ⚡ 无需安装任何工具
- ⚡ 快速得到 APK
#### 缺点:
- 💰 通常需要付费
- 🔒 代码需要上传到云端
- ⚙️ 自定义程度低
---
## 🚀 快速开始(推荐方案一)
### 一键操作流程:
```bash
# 1. 确保已构建最新代码
npm run build
# 2. 同步到 Android
npx cap sync android
# 3. 打开 Android Studio
npx cap open android
```
然后在 Android Studio 中:
1. 等待 Gradle 同步完成(底部有进度条)
2. 点击菜单:**Build → Build APK(s)**
3. 等待构建完成
4. 点击提示中的 **locate** 按钮找到 APK
5. 将 APK 传输到手机安装
---
## 📱 安装 APK 到手机
### 方法一USB 传输
1. 手机连接电脑
2. 复制 APK 到手机
3. 在手机文件管理器中找到 APK
4. 点击安装(需要允许"未知来源"
### 方法二:二维码扫描
1. 在电脑上启动 HTTP 服务器:
```bash
# 在 APK 所在目录
python -m http.server 8080
```
2. 手机浏览器访问:`http://电脑IP:8080/app-debug.apk`
3. 下载并安装
### 方法三ADB 安装(需要 USB 调试)
```bash
adb install android/app/build/outputs/apk/debug/app-debug.apk
```
---
## 🔧 常见问题
### Q1: Android Studio 打开后一直同步怎么办?
**A**:
- 检查网络连接
- 配置国内镜像(见下方)
- 耐心等待首次同步(可能需要 10-20 分钟)
### Q2: Gradle 下载太慢怎么办?
**A**: 配置国内镜像
编辑 `android/build.gradle`,在 `buildscript``allprojects``repositories` 中添加:
```gradle
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/jcenter' }
maven { url 'https://maven.aliyun.com/nexus/content/groups/public' }
```
编辑 `android/gradle/wrapper/gradle-wrapper.properties`
```properties
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
# 改为国内镜像
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.14.3-all.zip
```
### Q3: 构建失败怎么办?
**A**:
1. 查看错误信息
2. 清理项目:**Build → Clean Project**
3. 重新构建:**Build → Rebuild Project**
4. 检查 Java 版本(需要 JDK 11+
### Q4: APK 安装后白屏怎么办?
**A**:
1. 检查后端服务是否启动
2. 检查 API 地址配置
3. 使用 Chrome 远程调试查看错误
4. 查看 Logcat 日志
---
## 📊 预期结果
### 构建时间:
- 首次构建10-20 分钟(下载依赖)
- 后续构建2-5 分钟
### APK 大小:
- Debug 版本5-8 MB
- Release 版本3-5 MB
### APK 位置:
```
android/app/build/outputs/apk/debug/app-debug.apk
```
---
## 💡 建议
**对于初次打包**
1. ✅ 使用 **方案一Android Studio**
2. ✅ 耐心等待首次同步
3. ✅ 配置国内镜像加速
4. ✅ 构建成功后再考虑优化
**对于经常打包**
1. ✅ 配置 Gradle 缓存
2. ✅ 使用增量构建
3. ✅ 考虑配置 CI/CD
---
## 🎯 下一步
1. **下载安装 Android Studio**
- https://developer.android.com/studio
2. **运行打包命令**
```bash
npm run build
npx cap sync android
npx cap open android
```
3. **在 Android Studio 中构建 APK**
4. **安装到手机测试**
如有问题,请查看错误信息并参考上方的"常见问题"部分。

240
vue/APP_GUIDE.md Normal file
View File

@ -0,0 +1,240 @@
# 网盘管理系统 - APP 打包指南
## 📱 项目说明
这是一个基于 Vue 3 + Capacitor 的跨平台移动应用,支持 Android 和 iOS。
## 🛠️ 技术栈
- **前端框架**: Vue 3 (Composition API)
- **构建工具**: Vite
- **跨平台方案**: Capacitor
- **UI 样式**: 原生 CSS移动端适配
## 📦 安装依赖
```bash
npm install
```
## 🚀 开发模式
### Web 开发
```bash
npm run dev
```
### Android 开发
```bash
# 1. 构建 Web 资源
npm run build
# 2. 同步到 Android
npx cap sync android
# 3. 在 Android Studio 中打开
npx cap open android
```
### iOS 开发(需要 macOS
```bash
# 1. 添加 iOS 平台
npx cap add ios
# 2. 构建并同步
npm run build
npx cap sync ios
# 3. 在 Xcode 中打开
npx cap open ios
```
## 🏗️ 打包发布
### Android APK 打包
#### 方法一:使用 Android Studio推荐
1. 运行 `npx cap open android` 打开 Android Studio
2. 等待 Gradle 同步完成
3. 点击菜单 **Build → Build Bundle(s) / APK(s) → Build APK(s)**
4. APK 文件会生成在 `android/app/build/outputs/apk/debug/` 目录
#### 方法二:命令行打包
```bash
cd android
./gradlew assembleDebug
```
APK 位置:`android/app/build/outputs/apk/debug/app-debug.apk`
### 发布版本(签名 APK
1. 创建密钥库文件:
```bash
keytool -genkey -v -keystore my-release-key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias my-alias
```
2. 在 `android/app/build.gradle` 中配置签名:
```gradle
android {
...
signingConfigs {
release {
storeFile file("../my-release-key.jks")
storePassword "your-store-password"
keyAlias "my-alias"
keyPassword "your-key-password"
}
}
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
```
3. 打包:
```bash
./gradlew assembleRelease
```
## 📂 项目结构
```
wangpan/vue/
├── src/ # 源代码
│ ├── api/ # API 接口
│ ├── components/ # Vue 组件
│ ├── utils/ # 工具函数
│ ├── App.vue # 主组件
│ └── main.js # 入口文件
├── android/ # Android 原生项目
├── ios/ # iOS 原生项目macOS
├── dist/ # 构建输出
├── public/ # 静态资源
├── capacitor.config.json # Capacitor 配置
└── package.json
```
## 🔧 配置说明
### Capacitor 配置 (capacitor.config.json)
```json
{
"appId": "com.wangpan.app", // APP ID
"appName": "网盘管理系统", // APP 名称
"webDir": "dist", // Web 资源目录
"plugins": {
"SplashScreen": { // 启动屏配置
"launchShowDuration": 2000,
"backgroundColor": "#f8f9fc",
"showSpinner": false
},
"StatusBar": { // 状态栏配置
"style": "light",
"backgroundColor": "#6366f1"
}
}
}
```
## 🎨 自定义图标和启动屏
### 更换 APP 图标
1. 准备图标文件PNG 格式):
- 1024x1024 (Google Play 要求)
- 512x512
- 192x192
- 144x144
- 96x96
- 72x72
- 48x48
2. 替换 `android/app/src/main/res/mipmap-*/ic_launcher.png`
3. 或使用在线工具生成https://icon.kitchen/
### 更换启动屏
1. 准备启动屏图片PNG 格式)
2. 替换 `android/app/src/main/res/drawable/splash.png`
3. 或使用 Cordova 插件自动生成
## 🐛 常见问题
### 1. Android Studio 无法识别项目
- 确保已安装 JDK 11+
- 确保已安装 Android SDK
- 运行 `npx cap sync android` 重新同步
### 2. 白屏问题
- 检查 `dist/` 目录是否有正确的构建文件
- 检查 `capacitor.config.json` 中的 `webDir` 配置
- 查看 Chrome DevTools: `chrome://inspect/#devices`
### 3. API 请求失败
- 确保后端服务已启动
- 检查 CORS 配置
- Android 需要配置网络安全策略(允许 HTTP
### 4. 热重载不工作
- Capacitor 不支持热重载
- 修改代码后需要重新构建:`npm run build && npx cap sync`
## 📱 真机测试
### Android 真机调试
1. 开启手机的开发者选项和 USB 调试
2. 连接手机到电脑
3. 在 Android Studio 中点击 Run 按钮
4. 或使用命令:
```bash
npx cap run android
```
### Chrome 远程调试
1. 手机连接电脑
2. 浏览器访问 `chrome://inspect/#devices`
3. 选择你的设备进行调试
## 🔐 权限配置
如果需要访问相机、相册等权限,需要在 `android/app/src/main/AndroidManifest.xml` 中添加:
```xml
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
```
## 📊 性能优化建议
1. **图片优化**:使用 WebP 格式,压缩图片
2. **代码分割**:使用动态导入 `import()`
3. **懒加载**:路由和组件按需加载
4. **缓存策略**:合理使用 localStorage 和 IndexedDB
5. **减少重绘**:避免频繁操作 DOM
## 📝 更新日志
### v1.0.0 (2026-06-08)
- ✅ 完成移动端适配
- ✅ 集成 Capacitor
- ✅ 支持 Android 打包
- ✅ 优化触摸交互
- ✅ 修复验证码拉伸问题
## 🤝 贡献指南
欢迎提交 Issue 和 Pull Request
## 📄 许可证
MIT License

274
vue/PACKAGING_SUMMARY.md Normal file
View File

@ -0,0 +1,274 @@
# 网盘管理系统 - 代码清理和 APP 打包完成报告
## ✅ 代码清理完成
### 已删除的冗余文件:
1. ✅ `src/components/HelloWorld.vue` - Vue 默认模板组件(未使用)
2. ✅ `src/components/SheetTabs.vue` - 旧的标签页组件(已重构)
3. ✅ `src/api/mock.js` - Mock 数据文件(已切换到真实 API
### 已清理的代码引用:
1. ✅ 移除 `App.vue` 中对 `mock.js` 的导入
2. ✅ 修复 `handleReset()` 函数中的 mock 数据引用
3. ✅ 清理所有未使用的 import 语句
### 项目结构优化:
```
wangpan/vue/
├── src/
│ ├── api/
│ │ └── index.js # API 接口(已清理 mock
│ ├── components/
│ │ ├── FolderTree.vue # 文件夹树组件
│ │ ├── LoginView.vue # 登录视图
│ │ ├── PosterCard.vue # 海报卡片
│ │ ├── RecordDialog.vue # 记录对话框
│ │ ├── SimpleDialog.vue # 简单对话框
│ │ └── TreeNode.vue # 树节点组件
│ ├── utils/
│ │ ├── captcha.js # 验证码工具
│ │ ├── data.js # 数据处理工具
│ │ └── storage.js # 存储工具
│ ├── App.vue # 主应用组件
│ ├── main.js # 入口文件
│ └── style.css # 全局样式(含移动端适配)
├── android/ # Android 原生项目 ✨新增
├── dist/ # 构建输出
├── public/ # 静态资源
├── capacitor.config.json # Capacitor 配置 ✨新增
├── APP_GUIDE.md # APP 开发指南 ✨新增
└── package.json
```
**项目状态**:✅ 干净整洁,无冗余文件,无空文件夹
---
## 📱 APP 打包完成
### 已完成的配置:
#### 1. **Capacitor 集成**
- 安装核心包:`@capacitor/core`, `@capacitor/cli`
- 安装平台包:`@capacitor/android`, `@capacitor/ios`
- 安装插件:`@capacitor/app`, `@capacitor/status-bar`, `@capacitor/splash-screen`
#### 2. **Android 平台配置**
- 创建 Android 原生项目
- 配置 APP ID: `com.wangpan.app`
- 配置 APP 名称: "网盘管理系统"
- 配置启动屏和状态栏
- 同步 Web 资源到 Android 项目
#### 3. **移动端适配**
- 响应式断点768px, 640px, 375px
- 触摸优化:禁用点击高亮、文本自适应
- iOS 支持:安全区域、平滑滚动
- 验证码防拉伸修复
- 个人中心菜单优化
#### 4. **配置文件**
**capacitor.config.json**:
```json
{
"appId": "com.wangpan.app",
"appName": "网盘管理系统",
"webDir": "dist",
"plugins": {
"SplashScreen": {
"launchShowDuration": 2000,
"backgroundColor": "#f8f9fc",
"showSpinner": false
},
"StatusBar": {
"style": "light",
"backgroundColor": "#6366f1"
}
},
"server": {
"cleartext": true,
"androidScheme": "http"
}
}
```
---
## 🚀 如何生成 APK
### 方法一:使用 Android Studio推荐
1. **打开 Android Studio**
```bash
npx cap open android
```
2. **等待 Gradle 同步**
- 首次打开需要下载 Gradle 和依赖(可能需要几分钟)
- 如果下载慢,可以配置国内镜像
3. **构建 APK**
- 菜单:**Build → Build Bundle(s) / APK(s) → Build APK(s)**
- 等待构建完成
4. **找到 APK 文件**
- 位置:`android/app/build/outputs/apk/debug/app-debug.apk`
- 可以直接安装到手机测试
### 方法二:命令行打包
```bash
# 进入 android 目录
cd android
# 使用 Gradle 打包(需要网络好)
.\gradlew assembleDebug
# APK 位置
# android\app\build\outputs\apk\debug\app-debug.apk
```
### 方法三:真机调试
1. **连接手机**
- 开启开发者选项和 USB 调试
- 连接手机到电脑
2. **运行 APP**
```bash
npx cap run android
```
3. **Chrome 远程调试**
- 浏览器访问:`chrome://inspect/#devices`
- 选择设备进行调试
---
## 📊 打包结果
### 当前状态:
- ✅ Web 资源已构建:`dist/` 目录
- ✅ Android 项目已创建:`android/` 目录
- ✅ 资源已同步Web 文件已复制到 Android 项目
- ✅ 插件已配置3 个 Capacitor 插件
### APK 大小预估:
- **Debug 版本**:约 5-8 MB
- **Release 版本**:约 3-5 MB经过压缩和优化
### 支持的 Android 版本:
- **最低版本**Android 5.0 (API 21)
- **目标版本**Android 14 (API 34)
---
## 🔧 后续优化建议
### 1. APP 图标和启动屏
```bash
# 准备图标文件
- 1024x1024 PNG (Google Play 要求)
- 替换 android/app/src/main/res/mipmap-*/ic_launcher.png
# 或使用在线工具
https://icon.kitchen/
```
### 2. 性能优化
- 启用代码压缩ProGuard
- 图片转 WebP 格式
- 懒加载非关键资源
- 使用 CDN 加速
### 3. 功能增强
- 添加推送通知
- 离线缓存支持
- 生物识别登录(指纹/面部)
- 深色模式支持
### 4. 发布准备
- 创建签名密钥
- 配置 ProGuard 规则
- 测试不同屏幕尺寸
- 编写隐私政策
---
## 📝 重要文件清单
### 核心配置文件:
- ✅ `capacitor.config.json` - Capacitor 主配置
- ✅ `android/app/build.gradle` - Android 构建配置
- ✅ `android/app/src/main/AndroidManifest.xml` - Android 清单文件
- ✅ `vite.config.js` - Vite 构建配置
### 文档文件:
- ✅ `APP_GUIDE.md` - 完整的 APP 开发指南
- ✅ `README.md` - 项目说明
- ✅ `PACKAGING_SUMMARY.md` - 本文件
### 源代码:
- ✅ `src/App.vue` - 主应用(已清理)
- ✅ `src/style.css` - 样式(含移动端适配)
- ✅ `src/api/index.js` - API 接口(已清理 mock
---
## ⚠️ 注意事项
### 网络问题
- Gradle 下载可能较慢,建议配置国内镜像
- 可以参考 `android/gradle/wrapper/gradle-wrapper.properties` 修改下载地址
### API 配置
- 确保后端服务地址正确
- Android 需要允许 HTTP已在配置中设置
- 生产环境建议使用 HTTPS
### 权限申请
- 如需访问相机、相册等,需要在 `AndroidManifest.xml` 中添加权限
- Capacitor 插件会自动处理部分权限
### 热重载
- Capacitor 不支持热重载
- 修改代码后需要:`npm run build && npx cap sync`
---
## 🎉 总结
### 已完成的工作:
1. ✅ 清理所有冗余代码和文件
2. ✅ 集成 Capacitor 跨平台框架
3. ✅ 配置 Android 平台
4. ✅ 完成移动端 UI 适配
5. ✅ 创建详细的开发文档
### 下一步操作:
1. 📱 使用 Android Studio 打开项目
2. 🔨 构建 Debug APK 进行测试
3. 🐛 在真机上测试功能
4. 🎨 自定义 APP 图标和启动屏
5. 📦 打包 Release 版本发布
### 技术支持:
- Capacitor 官方文档https://capacitorjs.com/docs
- Android 开发文档https://developer.android.com/docs
- 详细指南请查看:`APP_GUIDE.md`
---
**项目状态**:✅ 已准备好打包成 APP
**预计时间**
- 首次构建10-20 分钟(下载依赖)
- 后续构建2-5 分钟
**APK 输出位置**
```
android/app/build/outputs/apk/debug/app-debug.apk
```
祝打包顺利!🚀

5
vue/README.md Normal file
View File

@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

101
vue/android/.gitignore vendored Normal file
View File

@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml

2
vue/android/app/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep

View File

@ -0,0 +1,54 @@
apply plugin: 'com.android.application'
android {
namespace = "com.wangpan.app"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "com.wangpan.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}

View File

@ -0,0 +1,21 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-splash-screen')
implementation project(':capacitor-status-bar')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}

21
vue/android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@ -0,0 +1,5 @@
package com.wangpan.app;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>

View File

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">网盘管理系统</string>
<string name="title_activity_main">网盘管理系统</string>
<string name="package_name">com.wangpan.app</string>
<string name="custom_url_scheme">com.wangpan.app</string>
</resources>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>

View File

@ -0,0 +1,18 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}

35
vue/android/build.gradle Normal file
View File

@ -0,0 +1,35 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/jcenter' }
maven { url 'https://maven.aliyun.com/nexus/content/groups/public' }
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.4'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/jcenter' }
maven { url 'https://maven.aliyun.com/nexus/content/groups/public' }
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

View File

@ -0,0 +1,12 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-splash-screen'
project(':capacitor-splash-screen').projectDir = new File('../node_modules/@capacitor/splash-screen/android')
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')

View File

@ -0,0 +1,22 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.14.3-all.zip
networkTimeout=60000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
vue/android/gradlew vendored Normal file
View File

@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
vue/android/gradlew.bat vendored Normal file
View File

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -0,0 +1,5 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'

View File

@ -0,0 +1,16 @@
ext {
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
androidxAppCompatVersion = '1.7.1'
androidxCoordinatorLayoutVersion = '1.3.0'
androidxCoreVersion = '1.17.0'
androidxFragmentVersion = '1.8.9'
coreSplashScreenVersion = '1.2.0'
androidxWebkitVersion = '1.14.0'
junitVersion = '4.13.2'
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1'
}

52
vue/build-apk.bat Normal file
View File

@ -0,0 +1,52 @@
@echo off
chcp 65001 >nul
echo ========================================
echo 网盘管理系统 - Android APK 打包工具
echo ========================================
echo.
echo [1/4] 检查 Node.js...
node --version >nul 2>&1
if errorlevel 1 (
echo ❌ 未检测到 Node.js请先安装 Node.js
pause
exit /b 1
)
echo ✅ Node.js 已安装
echo.
echo [2/4] 构建 Web 资源...
call npm run build
if errorlevel 1 (
echo ❌ 构建失败
pause
exit /b 1
)
echo ✅ 构建完成
echo.
echo [3/4] 同步到 Android...
call npx cap sync android
if errorlevel 1 (
echo ❌ 同步失败
pause
exit /b 1
)
echo ✅ 同步完成
echo.
echo [4/4] 打开 Android Studio...
echo.
echo 💡 提示:
echo 1. 等待 Android Studio 打开并同步 Gradle
echo 2. 点击菜单: Build → Build APK(s)
echo 3. APK 位置: android\app\build\outputs\apk\debug\app-debug.apk
echo.
call npx cap open android
echo.
echo ========================================
echo 打包流程已完成!
echo 请在 Android Studio 中继续操作
echo ========================================
pause

20
vue/capacitor.config.json Normal file
View File

@ -0,0 +1,20 @@
{
"appId": "com.wangpan.app",
"appName": "网盘管理系统",
"webDir": "dist",
"plugins": {
"SplashScreen": {
"launchShowDuration": 2000,
"backgroundColor": "#f8f9fc",
"showSpinner": false
},
"StatusBar": {
"style": "light",
"backgroundColor": "#6366f1"
}
},
"server": {
"cleartext": true,
"androidScheme": "http"
}
}

17
vue/index.html Normal file
View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#f8f9fc" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="format-detection" content="telephone=no" />
<title>网盘管理系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2434
vue/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
vue/package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "vue",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@capacitor/android": "^8.4.0",
"@capacitor/app": "^8.1.0",
"@capacitor/cli": "^8.4.0",
"@capacitor/core": "^8.4.0",
"@capacitor/ios": "^8.4.0",
"@capacitor/splash-screen": "^8.0.1",
"@capacitor/status-bar": "^8.0.2",
"vue": "^3.5.17"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"vite": "^7.0.4"
}
}

BIN
vue/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

3220
vue/public/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.5 MiB

1946
vue/src/App.vue Normal file

File diff suppressed because it is too large Load Diff

689
vue/src/api/index.js Normal file
View File

@ -0,0 +1,689 @@
import { reactive } from 'vue'
import { AUTH_SESSION_STORAGE_KEY, readStorage, removeStorage } from '../utils/storage'
import { createLoginLog } from '../utils/data'
const API_BASE_URL = '/api'
const TOKEN_EXPIRING_SOON_MS = 10 * 60 * 1000
/**
* 全局请求状态响应式
* pendingCount > 0 表示有请求正在进行
*/
export const requestState = reactive({ pendingCount: 0 })
function getAuthSession() {
return readStorage(AUTH_SESSION_STORAGE_KEY, null)
}
function isSessionExpired(session) {
if (!session?.token) return true
if (!session?.expiresAt) return false
return Date.now() >= session.expiresAt
}
function clearExpiredSession() {
removeStorage(AUTH_SESSION_STORAGE_KEY)
}
function dispatchUnauthorized(reason = '登录状态已失效,请重新登录') {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent('wangpan:unauthorized', { detail: { reason } }))
}
function dispatchForbidden(reason = '当前账号无权执行此操作') {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent('wangpan:forbidden', { detail: { reason } }))
}
function dispatchSessionExpiring(reason = '登录状态将在 10 分钟内过期,请注意及时重新登录') {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent('wangpan:session-expiring', { detail: { reason } }))
}
function notifySessionExpiring(session) {
if (!session?.expiresAt || session?.expiringSoonNotified) return
const remainingMs = session.expiresAt - Date.now()
if (remainingMs > 0 && remainingMs <= TOKEN_EXPIRING_SOON_MS) {
const nextSession = {
...session,
expiringSoonNotified: true,
}
localStorage.setItem(AUTH_SESSION_STORAGE_KEY, JSON.stringify(nextSession))
dispatchSessionExpiring()
}
}
function buildAuthHeaders(extraHeaders = {}) {
const session = getAuthSession()
if (isSessionExpired(session)) {
clearExpiredSession()
return extraHeaders
}
const token = session?.token
const tokenType = session?.tokenType || 'Bearer'
if (!token) {
return extraHeaders
}
return {
Authorization: `${tokenType} ${token}`,
...extraHeaders,
}
}
async function request(url, options = {}) {
// 延迟显示150ms内完成的请求不显示加载动画避免闪烁
let showTimer = null
let incremented = false
if (!options.silent) {
showTimer = setTimeout(() => { requestState.pendingCount++; incremented = true }, 150)
}
const session = getAuthSession()
const requiresAuth = options.requiresAuth !== false
if (requiresAuth && isSessionExpired(session)) {
clearTimeout(showTimer)
clearExpiredSession()
dispatchUnauthorized('登录已过期,请重新登录')
const error = new Error('登录已过期,请重新登录')
error.status = 401
error.code = 401
throw error
}
if (requiresAuth) {
notifySessionExpiring(session)
}
try {
console.log(`[API Request] ${options.method || 'GET'} ${url}`)
const isFormData = options.body instanceof FormData
const response = await fetch(url, {
headers: {
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
...buildAuthHeaders(options.headers || {}),
},
...options,
})
const contentType = response.headers.get('content-type') || ''
const data = contentType.includes('application/json') ? await response.json() : null
console.log(`[API Response] ${response.status}`, data)
if (!response.ok) {
if (response.status === 401) {
clearExpiredSession()
dispatchUnauthorized(data?.message || '登录状态已失效,请重新登录')
}
if (response.status === 403) {
dispatchForbidden(data?.message || '当前账号无权执行此操作')
}
const error = new Error(data?.message || `请求失败:${response.status}`)
error.status = response.status
error.code = data?.code ?? response.status
error.payload = data
throw error
}
return data
} catch (err) {
// 网络错误(如 Failed to fetch
if (err.name === 'TypeError' && err.message === 'Failed to fetch') {
console.error('[Network Error] 无法连接到后端服务,请检查:')
console.error('1. 后端服务是否启动在 http://localhost:8888')
console.error('2. 是否有 CORS 跨域问题')
console.error('3. 网络连接是否正常')
const networkError = new Error('无法连接到后端服务,请检查后端是否启动')
networkError.isNetworkError = true
throw networkError
}
// 其他错误直接抛出
throw err
} finally {
clearTimeout(showTimer)
if (incremented) {
requestState.pendingCount--
}
}
}
export function login(payload) {
return request(`${API_BASE_URL}/login`, {
method: 'POST',
requiresAuth: false,
body: JSON.stringify({
username: payload.username,
password: payload.password,
}),
})
}
export function register(payload) {
const body = { username: payload.username, password: payload.password }
if (payload.email) body.email = payload.email
if (payload.phone) body.phone = payload.phone
return request(`${API_BASE_URL}/register`, {
method: 'POST',
requiresAuth: false,
body: JSON.stringify(body),
})
}
export function fetchDashboardData() {
return Promise.resolve({
code: 200,
data: [],
})
}
export function saveRecord(payload) {
return Promise.resolve({
code: 200,
data: payload,
})
}
export function deleteRecord(id) {
return Promise.resolve({
code: 200,
data: { id },
})
}
export function exportData() {
return Promise.resolve({
code: 200,
data: {},
})
}
export function resetData() {
return Promise.resolve({
code: 200,
data: [],
})
}
export function fetchLoginLogs(params = {}) {
const searchParams = new URLSearchParams()
if (params.userId) searchParams.set('userId', String(params.userId))
if (params.username) searchParams.set('username', params.username)
if (params.result) searchParams.set('result', params.result)
if (params.startDate) searchParams.set('startDate', params.startDate)
if (params.endDate) searchParams.set('endDate', params.endDate)
if (params.pageNo) searchParams.set('pageNo', String(params.pageNo))
if (params.pageSize) searchParams.set('pageSize', String(params.pageSize))
const query = searchParams.toString()
return request(`${API_BASE_URL}/login-logs${query ? `?${query}` : ''}`, {
method: 'GET',
}).then((res) => ({
...res,
data: {
...(res?.data || {}),
list: Array.isArray(res?.data?.list)
? res.data.list.map((item) =>
createLoginLog(
item?.userAgent || '未知设备',
item?.loginIp || '127.0.0.1',
{
id: item?.id,
time: item?.loginTime,
hashSeed: `${item?.userId || ''}-${item?.loginUsername || ''}-${item?.loginTime || ''}`,
username: item?.loginUsername,
result: item?.loginResult,
userId: item?.userId,
rawUserAgent: item?.userAgent,
failReason: item?.failReason,
},
),
)
: [],
total: res?.data?.total || 0, // 返回总记录数
},
}))
}
export function fetchTmdbConfig() {
return request(`${API_BASE_URL}/tmdb/key`, {
method: 'GET',
})
}
export function saveTmdbConfig(payload) {
return request(`${API_BASE_URL}/tmdb/key`, {
method: 'POST',
body: JSON.stringify({
tmdbApiKey: payload.tmdbApiKey,
}),
})
}
// ==================== 网盘管理 API ====================
/**
* 创建网盘
* @param {Object} payload - { name, description, logoData }
* @returns {Promise}
*/
export function createDrive(payload) {
return request(`${API_BASE_URL}/drive`, {
method: 'POST',
body: JSON.stringify({
name: payload.name,
description: payload.description || '',
logoData: payload.logoData || '',
}),
})
}
/**
* 查询网盘列表
* @param {Object} params - { pageNo, pageSize }
* @returns {Promise}
*/
export function fetchDriveList(params = {}) {
const searchParams = new URLSearchParams()
if (params.pageNo) searchParams.set('pageNo', String(params.pageNo))
if (params.pageSize) searchParams.set('pageSize', String(params.pageSize))
const query = searchParams.toString()
return request(`${API_BASE_URL}/drive${query ? `?${query}` : ''}`, {
method: 'GET',
})
}
/**
* 删除网盘
* @param {string} driveId - 网盘ID
* @returns {Promise}
*/
export function deleteDrive(driveId) {
return request(`${API_BASE_URL}/drive/${driveId}`, {
method: 'DELETE',
})
}
/**
* 更新网盘
* @param {string} driveId - 网盘ID
* @param {Object} payload - { name, description, logoData }
* @returns {Promise}
*/
export function updateDrive(driveId, payload) {
return request(`${API_BASE_URL}/drive/${driveId}`, {
method: 'PUT',
body: JSON.stringify({
name: payload.name,
description: payload.description,
logoData: payload.logoData,
}),
})
}
// ==================== 文件夹管理 API ====================
/**
* 查询文件夹列表
* @param {string|number} driveId - 网盘ID
* @param {Object} params - { parentId }
* @returns {Promise}
*/
export function fetchFolders(driveId, params = {}) {
const searchParams = new URLSearchParams()
if (params.parentId !== undefined) searchParams.set('parentId', String(params.parentId))
const query = searchParams.toString()
return request(`${API_BASE_URL}/drive/${driveId}/folders${query ? `?${query}` : ''}`, {
method: 'GET',
})
}
/**
* 查询文件夹详情
* @param {string|number} driveId - 网盘ID
* @param {string|number} folderId - 文件夹ID
* @returns {Promise}
*/
export function fetchFolderDetail(driveId, folderId) {
return request(`${API_BASE_URL}/drive/${driveId}/folders/${folderId}`, {
method: 'GET',
})
}
/**
* 创建文件夹
* @param {string|number} driveId - 网盘ID
* @param {Object} payload - { name, parentId, description, sortWeight }
* @returns {Promise}
*/
export function createFolderInDrive(driveId, payload) {
return request(`${API_BASE_URL}/drive/${driveId}/folders`, {
method: 'POST',
body: JSON.stringify({
name: payload.name,
parentId: payload.parentId ?? 0,
description: payload.description || '',
sortWeight: payload.sortWeight ?? 0,
}),
})
}
/**
* 更新文件夹
* @param {string|number} driveId - 网盘ID
* @param {string|number} folderId - 文件夹ID
* @param {Object} payload - { name, description, sortWeight }
* @returns {Promise}
*/
export function updateFolderInDrive(driveId, folderId, payload) {
const body = {}
if (payload.name !== undefined) body.name = payload.name
if (payload.description !== undefined) body.description = payload.description
if (payload.sortWeight !== undefined) body.sortWeight = payload.sortWeight
return request(`${API_BASE_URL}/drive/${driveId}/folders/${folderId}`, {
method: 'PUT',
body: JSON.stringify(body),
})
}
/**
* 删除文件夹
* @param {string|number} driveId - 网盘ID
* @param {string|number} folderId - 文件夹ID
* @returns {Promise}
*/
export function deleteFolderInDrive(driveId, folderId) {
return request(`${API_BASE_URL}/drive/${driveId}/folders/${folderId}`, {
method: 'DELETE',
})
}
// ==================== 电影文件管理 API ====================
/**
* 查询指定文件夹下的电影列表
* @param {string|number} driveId - 网盘ID
* @param {string|number} folderId - 文件夹ID0表示根目录
* @param {Object} params - { pageNo, pageSize }
* @returns {Promise}
*/
export function fetchFolderMovies(driveId, folderId, params = {}) {
const searchParams = new URLSearchParams()
if (params.pageNo) searchParams.set('pageNo', String(params.pageNo))
if (params.pageSize) searchParams.set('pageSize', String(params.pageSize))
const query = searchParams.toString()
return request(`${API_BASE_URL}/drive/${driveId}/folders/${folderId}/movies${query ? `?${query}` : ''}`, {
method: 'GET',
})
}
/**
* 查询电影列表
* @param {string|number} driveId - 网盘ID
* @param {Object} params - { folderId, pageNo, pageSize }
* @returns {Promise}
*/
export function fetchMovieList(driveId, params = {}) {
const searchParams = new URLSearchParams()
if (params.folderId !== undefined) searchParams.set('folderId', String(params.folderId))
if (params.pageNo) searchParams.set('pageNo', String(params.pageNo))
if (params.pageSize) searchParams.set('pageSize', String(params.pageSize))
const query = searchParams.toString()
return request(`${API_BASE_URL}/drive/${driveId}/movies${query ? `?${query}` : ''}`, {
method: 'GET',
})
}
/**
* 查询电影详情
* @param {string|number} driveId - 网盘ID
* @param {string|number} movieId - 电影ID
* @returns {Promise}
*/
export function fetchMovieDetail(driveId, movieId) {
return request(`${API_BASE_URL}/drive/${driveId}/movies/${movieId}`, {
method: 'GET',
})
}
/**
* 创建电影仅登记文件名
* @param {string|number} driveId - 网盘ID
* @param {Object} payload - { title, folderId }
* @returns {Promise}
*/
export function createMovieInDrive(driveId, payload) {
return request(`${API_BASE_URL}/drive/${driveId}/movies`, {
method: 'POST',
body: JSON.stringify({
title: payload.title,
folderId: payload.folderId ?? 0,
}),
})
}
/**
* 更新电影信息
* @param {string|number} driveId - 网盘ID
* @param {string|number} movieId - 电影ID
* @param {Object} payload - 可选字段title, originalName, subtitle, description, rating, tags, category
* @returns {Promise}
*/
export function updateMovieInDrive(driveId, movieId, payload) {
const body = {}
if (payload.title !== undefined) body.title = payload.title
if (payload.originalName !== undefined) body.originalName = payload.originalName
if (payload.subtitle !== undefined) body.subtitle = payload.subtitle
if (payload.description !== undefined) body.description = payload.description
if (payload.rating !== undefined) body.rating = payload.rating
if (payload.tags !== undefined) body.tags = payload.tags
if (payload.category !== undefined) body.category = payload.category
return request(`${API_BASE_URL}/drive/${driveId}/movies/${movieId}`, {
method: 'PUT',
body: JSON.stringify(body),
})
}
/**
* 删除电影
* @param {string|number} driveId - 网盘ID
* @param {string|number} movieId - 电影ID
* @returns {Promise}
*/
export function deleteMovieInDrive(driveId, movieId) {
return request(`${API_BASE_URL}/drive/${driveId}/movies/${movieId}`, {
method: 'DELETE',
})
}
/**
* 查询重复电影
* @returns {Promise}
*/
export function fetchMovieDuplicates() {
return request(`${API_BASE_URL}/drive/movies/duplicates`, {
method: 'GET',
})
}
// ==================== 图标管理 API ====================
/**
* 查询图标列表
* @returns {Promise}
*/
export function fetchIconList() {
return request(`${API_BASE_URL}/icon`, { method: 'GET' })
}
/**
* 查询单个图标详情
* @param {string|number} id - 图标ID
* @returns {Promise}
*/
export function fetchIconDetail(id) {
return request(`${API_BASE_URL}/icon/${id}`, { method: 'GET' })
}
/**
* 创建图标
* @param {Object} payload - { name, url?, internalUrl?, linkType?, description?, sortOrder? }
* @returns {Promise}
*/
export function createIcon(payload) {
return request(`${API_BASE_URL}/icon`, {
method: 'POST',
body: JSON.stringify({
name: payload.name,
url: payload.url || '',
internalUrl: payload.internalUrl || '',
linkType: payload.linkType || 'external',
description: payload.description || '',
sortOrder: payload.sortOrder ?? 0,
}),
})
}
/**
* 更新图标
* @param {string|number} id - 图标ID
* @param {Object} payload - { name?, url?, internalUrl?, linkType?, description?, sortOrder? }
* @returns {Promise}
*/
export function updateIcon(id, payload) {
const body = {}
if (payload.name !== undefined) body.name = payload.name
if (payload.url !== undefined) body.url = payload.url
if (payload.internalUrl !== undefined) body.internalUrl = payload.internalUrl
if (payload.linkType !== undefined) body.linkType = payload.linkType
if (payload.description !== undefined) body.description = payload.description
if (payload.sortOrder !== undefined) body.sortOrder = payload.sortOrder
return request(`${API_BASE_URL}/icon/${id}`, {
method: 'PUT',
body: JSON.stringify(body),
})
}
/**
* 删除图标
* @param {string|number} id - 图标ID
* @returns {Promise}
*/
export function deleteIcon(id) {
return request(`${API_BASE_URL}/icon/${id}`, { method: 'DELETE' })
}
/**
* 上传图标 Logo 图片
* @param {string|number} id - 图标ID
* @param {File} file - 图片文件png/jpg/gif/webp/ico最大 1MB
* @returns {Promise} 返回 { code, data: { id, name, logoUrl, updateTime } }
*/
export function uploadIconLogo(id, file) {
const formData = new FormData()
formData.append('file', file)
return request(`${API_BASE_URL}/icon/${id}/logo`, {
method: 'POST',
body: formData,
})
}
// ==================== 用户管理 API仅管理员 ====================
/**
* 查询用户列表
* @param {Object} params - 查询参数
* @param {string} [params.username] - 用户名模糊搜索
* @param {number} [params.status] - 状态0=禁用, 1=正常, 2=锁定
* @param {number} [params.userType] - 角色0=普通, 1=VIP, 2=管理员
* @param {number} [params.pageNo] - 页码默认1
* @param {number} [params.pageSize] - 每页条数默认20
* @returns {Promise} 返回格式{ code: 200, data: { total, list: [{ id, username, userType, status, ... }], pageNo, pageSize } }
*/
export function fetchUserList(params = {}) {
const searchParams = new URLSearchParams()
if (params.username) searchParams.set('username', params.username)
if (params.status !== undefined && params.status !== '') searchParams.set('status', String(params.status))
if (params.userType !== undefined && params.userType !== '') searchParams.set('userType', String(params.userType))
if (params.pageNo) searchParams.set('pageNo', String(params.pageNo))
if (params.pageSize) searchParams.set('pageSize', String(params.pageSize))
const query = searchParams.toString()
return request(`${API_BASE_URL}/users${query ? `?${query}` : ''}`, {
method: 'GET',
})
}
/**
* 新增用户
* @param {Object} payload - 用户信息
* @param {string} payload.username - 用户名必填
* @param {string} payload.password - 密码必填
* @param {string} [payload.email] - 邮箱可选
* @param {string} [payload.phone] - 手机号可选
* @param {string} [payload.realName] - 真实姓名可选
* @param {number} [payload.status=1] - 状态0=禁用, 1=正常
* @param {number} [payload.userType=0] - 角色0=普通, 1=VIP, 2=管理员
* @returns {Promise}
*/
export function createUser(payload) {
return request(`${API_BASE_URL}/users`, {
method: 'POST',
body: JSON.stringify({
username: payload.username,
password: payload.password,
email: payload.email || '',
phone: payload.phone || '',
realName: payload.realName || '',
status: payload.status !== undefined ? payload.status : 1,
userType: payload.userType !== undefined ? payload.userType : 0,
}),
})
}
/**
* 修改用户信息
* @param {number} userId - 用户ID
* @param {Object} payload - 需要修改的字段部分更新
* @param {string} [payload.email] - 邮箱
* @param {string} [payload.phone] - 手机号
* @param {string} [payload.realName] - 真实姓名
* @param {number} [payload.status] - 状态0=禁用, 1=正常, 2=锁定
* @param {number} [payload.userType] - 角色0=普通, 1=VIP, 2=管理员
* @returns {Promise}
*/
export function updateUser(userId, payload) {
// 构建请求体,只包含需要更新的字段
const body = {}
if (payload.email !== undefined) body.email = payload.email
if (payload.phone !== undefined) body.phone = payload.phone
if (payload.realName !== undefined) body.realName = payload.realName
if (payload.status !== undefined) body.status = payload.status
if (payload.userType !== undefined) body.userType = payload.userType
return request(`${API_BASE_URL}/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(body),
})
}
/**
* 删除用户软删除将is_deleted设为1
* @param {number} userId - 要删除的用户ID
* @returns {Promise}
*/
export function deleteUser(userId) {
return request(`${API_BASE_URL}/users/${userId}`, {
method: 'DELETE',
})
}

1
vue/src/assets/vue.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@ -0,0 +1,210 @@
<template>
<Teleport to="body">
<dialog ref="dialogRef" class="confirm-overlay" @click.self="handleCancel" @close="handleCancel">
<Transition name="confirm-scale-in">
<div v-if="showContent" class="confirm-dialog">
<header>
<span class="confirm-icon">{{ icon }}</span>
{{ title }}
</header>
<div class="confirm-body">
<p v-html="message"></p>
<p v-if="hint" class="confirm-hint">{{ hint }}</p>
</div>
<footer>
<button type="button" class="confirm-btn-cancel" @click="handleCancel">{{ cancelText }}</button>
<button
type="button"
:class="danger ? 'confirm-btn-danger' : 'confirm-btn-primary'"
@click="handleConfirm"
>{{ confirmText }}</button>
</footer>
</div>
</Transition>
</dialog>
</Teleport>
</template>
<script setup>
import { ref, watch } from 'vue'
import { lockScroll, unlockScroll } from '../utils/scrollLock'
const props = defineProps({
open: Boolean,
title: { type: String, default: '确认操作' },
message: { type: String, default: '' },
hint: { type: String, default: '' },
icon: { type: String, default: '⚠️' },
confirmText: { type: String, default: '确认' },
cancelText: { type: String, default: '取消' },
danger: { type: Boolean, default: false },
})
const emit = defineEmits(['confirm', 'cancel'])
const dialogRef = ref(null)
const showContent = ref(false)
watch(
() => props.open,
(val) => {
if (!dialogRef.value) return
if (val && !dialogRef.value.open) {
showContent.value = true
dialogRef.value.showModal()
lockScroll()
} else if (!val && dialogRef.value.open) {
showContent.value = false
dialogRef.value.close()
unlockScroll()
}
},
)
function handleConfirm() {
showContent.value = false
try { dialogRef.value?.close() } catch {}
emit('confirm')
}
function handleCancel() {
showContent.value = false
try { dialogRef.value?.close() } catch {}
unlockScroll()
emit('cancel')
}
</script>
<style scoped>
.confirm-overlay {
position: fixed;
inset: 0;
background: transparent;
display: flex;
align-items: center;
justify-content: center;
z-index: 2147483647;
border: none;
padding: 0;
overflow: hidden;
border-radius: 14px;
}
.confirm-overlay::backdrop {
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(3px);
}
.confirm-dialog {
background: #fff;
border-radius: 14px;
padding: 24px 28px;
min-width: 400px;
max-width: 90vw;
box-shadow: 0 20px 70px rgba(0, 0, 0, 0.25);
border: none;
overflow: visible;
max-height: calc(100vh - 80px);
}
.confirm-dialog header {
display: flex;
align-items: center;
gap: 8px;
font-size: 17px;
font-weight: 700;
color: #303133;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #ebeef5;
}
.confirm-icon {
font-size: 20px;
flex-shrink: 0;
}
.confirm-body {
font-size: 15px;
color: #606266;
line-height: 1.65;
margin-bottom: 8px;
}
.confirm-body :deep(strong) {
color: #f56c6c;
font-weight: 700;
}
.confirm-hint {
font-size: 13px !important;
color: #f56c6c !important;
background: #fef0f0;
padding: 10px 14px;
border-radius: 8px;
border-left: 3px solid #f56c6c;
margin-top: 10px;
}
.confirm-dialog footer {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #ebeef5;
}
.confirm-dialog footer button {
padding: 9px 22px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.25s ease;
border: none;
}
.confirm-btn-cancel {
background: #fff;
color: #606266;
border: 1px solid #dcdfe6 !important;
}
.confirm-btn-cancel:hover {
background: #f5f7fa;
border-color: #c0c4cc !important;
}
.confirm-btn-danger {
background: linear-gradient(135deg, #f56c6c 0%, #e74c3c 100%) !important;
color: #fff !important;
}
.confirm-btn-danger:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(245, 108, 108, 0.35);
}
.confirm-btn-primary {
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%) !important;
color: #fff !important;
}
.confirm-btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(99, 102, 241, 0.35);
}
/* 动画 */
.confirm-scale-in-enter-active {
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.confirm-scale-in-leave-active {
transition: all 0.2s ease;
}
.confirm-scale-in-enter-from {
opacity: 0;
transform: scale(0.85) translateY(15px);
}
.confirm-scale-in-leave-to {
opacity: 0;
transform: scale(0.95) translateY(-8px);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,101 @@
<template>
<dialog ref="dialogRef" class="app-dialog" @close="unlockScroll(); $emit('close')">
<form class="dialog-form" method="dialog" @submit.prevent="handleSave">
<header>
<h2>{{ isEdit ? '编辑文件夹' : '新建文件夹' }}</h2>
<button type="button" aria-label="关闭" @click="closeDialog">×</button>
</header>
<div class="field-grid">
<label class="field-span-2">
<span>文件夹名称 <em>*</em></span>
<input v-model="form.name" type="text" placeholder="请输入文件夹名称" required autofocus />
</label>
<label class="field-span-2">
<span>描述</span>
<input v-model="form.description" type="text" placeholder="请输入文件夹描述(可选)" />
</label>
<label>
<span>排序权重</span>
<input v-model.number="form.sortWeight" type="number" min="0" placeholder="数字越小越靠前,默认 0" />
</label>
</div>
<footer>
<span></span>
<span></span>
<button type="button" class="secondary" @click="closeDialog">取消</button>
<button type="submit" :disabled="!form.name?.trim()">{{ isEdit ? '保存' : '创建' }}</button>
</footer>
</form>
</dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { lockScroll, unlockScroll } from '../utils/scrollLock'
const props = defineProps({
open: Boolean,
folder: {
type: Object,
default: null,
},
})
const emit = defineEmits(['close', 'save'])
const dialogRef = ref(null)
const isEdit = ref(false)
const form = ref({
name: '',
description: '',
sortWeight: 0,
})
watch(
() => props.open,
(value) => {
if (!dialogRef.value) return
if (value && !dialogRef.value.open) {
//
if (props.folder) {
isEdit.value = true
form.value = {
name: props.folder.title || '',
description: props.folder.description || '',
sortWeight: props.folder.sortWeight ?? 0,
}
} else {
isEdit.value = false
form.value = {
name: '',
description: '',
sortWeight: 0,
}
}
dialogRef.value.showModal()
lockScroll()
}
if (!value && dialogRef.value.open) {
dialogRef.value.close()
unlockScroll()
}
},
)
function closeDialog() {
dialogRef.value?.close()
}
function handleSave() {
if (!form.value.name?.trim()) return
emit('save', {
name: form.value.name.trim(),
description: form.value.description?.trim() || '',
sortWeight: Number(form.value.sortWeight) || 0,
})
closeDialog()
}
</script>

View File

@ -0,0 +1,58 @@
<template>
<div class="folder-tree">
<div v-for="node in treeData" :key="node.id" class="tree-node">
<TreeNode
:node="node"
:depth="0"
@add-folder="handleAddFolder"
@add-file="handleAddFile"
@expand="handleExpand"
@edit="handleEdit"
@delete="handleDelete"
/>
</div>
</div>
</template>
<script setup>
import TreeNode from './TreeNode.vue'
defineProps({
treeData: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['add-folder', 'add-file', 'expand', 'edit', 'delete'])
function handleAddFolder(parentId) {
emit('add-folder', parentId)
}
function handleAddFile(parentId) {
emit('add-file', parentId)
}
function handleEdit(node) {
emit('edit', node)
}
function handleExpand(node) {
emit('expand', node)
}
function handleDelete(node) {
emit('delete', node)
}
</script>
<style scoped>
.folder-tree {
padding: 16px;
}
.tree-node {
margin-bottom: 8px;
}
</style>

View File

@ -0,0 +1,780 @@
<template>
<dialog ref="dialogRef" class="app-dialog icon-manage-dialog" @close="unlockScroll(); $emit('close')">
<form class="dialog-form" method="dialog" @submit.prevent>
<header>
<h2>图标管理</h2>
<button type="button" aria-label="关闭" @click="closeDialog">×</button>
</header>
<!-- 列表视图 -->
<div v-if="!showForm" class="icon-list-view">
<div v-if="loading" class="icon-loading">加载中</div>
<div v-else-if="!iconList.length" class="icon-empty">
<p>暂无图标点击下方按钮添加</p>
</div>
<div v-else class="icon-grid">
<div
v-for="icon in iconList"
:key="icon.id"
class="icon-grid-item"
>
<div class="icon-grid-thumb">
<img
:src="icon.logoUrl || getFaviconUrl(icon.url)"
:alt="icon.name"
@error="(e) => { e.target.style.display = 'none'; e.target.nextElementSibling.style.display = 'flex' }"
/>
<span class="icon-grid-fallback" style="display:none">{{ (icon.name || '?')[0] }}</span>
</div>
<span class="icon-grid-name">
{{ icon.name }}
<span v-if="icon.linkType" class="link-type-badge" :class="`link-type-badge--${icon.linkType}`">
{{ icon.linkType === 'external' ? '外网' : icon.linkType === 'internal' ? '内网' : '内外' }}
</span>
</span>
<span class="icon-grid-url" :title="icon.url || icon.internalUrl">{{ icon.url || icon.internalUrl }}</span>
<div class="icon-grid-actions">
<button type="button" class="action-btn" title="编辑" @click="startEdit(icon)"></button>
<button type="button" class="action-btn action-btn--danger" title="删除" @click="confirmDeleteIcon(icon)">🗑</button>
</div>
</div>
</div>
<button type="button" class="icon-add-btn" @click="startCreate">+ 添加图标</button>
</div>
<!-- 表单视图 -->
<div v-else class="icon-form-view">
<div class="field-grid">
<label class="field-span-2">
<span>名称 <em>*</em></span>
<input v-model="form.name" type="text" placeholder="如:百度" required />
</label>
<!-- 链接类型选择 -->
<div class="field-span-2">
<span class="field-label">链接类型</span>
<div class="link-type-group">
<label class="link-type-option" :class="{ 'link-type-option--active': form.linkType === 'external' }">
<input type="radio" v-model="form.linkType" value="external" />🌐 外网
</label>
<label class="link-type-option" :class="{ 'link-type-option--active': form.linkType === 'internal' }">
<input type="radio" v-model="form.linkType" value="internal" />🔒 内网
</label>
<label class="link-type-option" :class="{ 'link-type-option--active': form.linkType === 'both' }">
<input type="radio" v-model="form.linkType" value="both" /> 内外网
</label>
</div>
</div>
<!-- 外网链接 -->
<label v-if="form.linkType === 'external' || form.linkType === 'both'" class="field-span-2">
<span>外网链接 <em v-if="form.linkType === 'external' || form.linkType === 'both'">*</em></span>
<input v-model="form.url" type="url" placeholder="https://www.baidu.com" />
</label>
<!-- 内网链接 -->
<label v-if="form.linkType === 'internal' || form.linkType === 'both'" class="field-span-2">
<span>内网链接 <em v-if="form.linkType === 'internal' || form.linkType === 'both'">*</em></span>
<input v-model="form.internalUrl" type="url" placeholder="http://192.168.x.x 或内网域名" />
</label>
<label class="field-span-2">
<span>描述</span>
<input v-model="form.description" type="text" placeholder="简短描述(可选)" />
</label>
<!-- 自定义图标上传 -->
<div class="field-span-2">
<span class="field-label">图标图片</span>
<div class="icon-upload-area">
<div v-if="form.logoPreview" class="icon-upload-preview">
<img :src="form.logoPreview" :alt="form.name" />
<div class="icon-upload-actions">
<button type="button" class="icon-upload-btn" @click="$refs.iconInputRef.click()">更换</button>
<button type="button" class="icon-upload-btn icon-upload-btn--remove" @click="removeIconImage">移除</button>
</div>
</div>
<div v-else class="icon-upload-placeholder" @click="$refs.iconInputRef.click()">
<span class="icon-upload-plus">+</span>
<span>点击上传图片</span>
</div>
<input ref="iconInputRef" type="file" accept="image/png,image/jpeg,image/gif,image/webp" style="display:none" @change="handleIconChange" />
</div>
<span class="icon-upload-tip">支持 PNG/JPG/GIF/WebP最大 5MB留空则抓取网站 favicon</span>
</div>
<label>
<span>排序权重</span>
<input v-model.number="form.sortOrder" type="number" min="0" placeholder="越小越靠前" />
</label>
</div>
<div v-if="form.url || form.internalUrl || form.logoPreview" class="icon-form-preview">
<span>预览</span>
<div class="icon-grid-thumb">
<img :src="form.logoPreview || getFaviconUrl(form.url || form.internalUrl)" :alt="form.name" @error="(e) => { e.target.style.display = 'none'; e.target.nextElementSibling.style.display = 'flex' }" />
<span class="icon-grid-fallback" style="display:none">{{ (form.name || '?')[0] }}</span>
</div>
<span>{{ form.name || '未命名' }}</span>
</div>
</div>
<footer>
<template v-if="!showForm">
<span></span>
<span></span>
<button type="button" class="secondary" @click="closeDialog">关闭</button>
</template>
<template v-else>
<button type="button" class="danger" v-if="isEditing" @click="confirmDeleteIcon(editingIcon)">删除</button>
<span v-else></span>
<span></span>
<button type="button" class="secondary" @click="showForm = false">取消</button>
<button type="button" @click="handleSubmit" :disabled="submitting || !isFormValid">
{{ submitting ? '保存中…' : (isEditing ? '保存' : '创建') }}
</button>
</template>
</footer>
</form>
<!-- 确认删除弹窗 -->
<Teleport to="body">
<dialog ref="confirmRef" class="confirm-overlay" @click.self="closeConfirm" @close="closeConfirm">
<Transition name="confirm-scale-in">
<div v-if="showConfirm" class="confirm-dialog">
<header><span class="confirm-icon"></span> 确认删除</header>
<div class="confirm-body">
<p>确定要删除图标 <strong>"{{ deleteTarget?.name }}"</strong> </p>
</div>
<footer>
<button type="button" class="confirm-btn-cancel" @click="closeConfirm">取消</button>
<button type="button" class="confirm-btn-danger" @click="doDelete">确认删除</button>
</footer>
</div>
</Transition>
</dialog>
</Teleport>
</dialog>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
import { fetchIconList, createIcon, updateIcon, deleteIcon, uploadIconLogo } from '../api'
import { lockScroll, unlockScroll } from '../utils/scrollLock'
const props = defineProps({ open: Boolean })
const emit = defineEmits(['close', 'success', 'error', 'refresh'])
const dialogRef = ref(null)
const confirmRef = ref(null)
const iconInputRef = ref(null)
const loading = ref(false)
const submitting = ref(false)
const showForm = ref(false)
const isEditing = ref(false)
const editingIcon = ref(null)
const iconList = ref([])
const form = ref({ name: '', url: '', internalUrl: '', linkType: 'external', logoPreview: '', logoFile: null, description: '', sortOrder: 0 })
const showConfirm = ref(false)
const deleteTarget = ref(null)
/** 表单是否有效 */
const isFormValid = computed(() => {
if (!form.value.name?.trim()) return false
const lt = form.value.linkType
if ((lt === 'external' || lt === 'both') && !form.value.url?.trim()) return false
if ((lt === 'internal' || lt === 'both') && !form.value.internalUrl?.trim()) return false
return true
})
function getFaviconUrl(url) {
try {
const u = new URL(url)
return `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=64`
} catch {
return ''
}
}
/** 处理图标图片上传 */
function handleIconChange(event) {
const file = event.target.files[0]
if (!file) return
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
if (!allowedTypes.includes(file.type)) {
emit('error', '仅支持 JPG、PNG、GIF、WebP 格式的图片')
event.target.value = ''
return
}
const maxSize = 5 * 1024 * 1024
if (file.size > maxSize) {
emit('error', '图片大小不能超过 5MB')
event.target.value = ''
return
}
form.value.logoFile = file
const reader = new FileReader()
reader.onload = (e) => {
form.value.logoPreview = e.target.result
}
reader.readAsDataURL(file)
}
/** 移除已上传的图标图片 */
function removeIconImage() {
form.value.logoFile = null
form.value.logoPreview = ''
if (iconInputRef.value) {
iconInputRef.value.value = ''
}
}
watch(
() => props.open,
(val) => {
if (!dialogRef.value) return
if (val && !dialogRef.value.open) {
dialogRef.value.showModal()
lockScroll()
loadIcons()
} else if (!val && dialogRef.value.open) {
dialogRef.value.close()
unlockScroll()
}
},
)
async function loadIcons() {
loading.value = true
try {
const res = await fetchIconList()
if (res?.code === 200 && res?.data) {
const list = Array.isArray(res.data) ? res.data : (res.data.list || res.data.records || [])
iconList.value = list.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
} else {
emit('error', res?.message || '获取图标列表失败')
}
} catch (err) {
emit('error', err?.message || '加载图标列表失败')
} finally {
loading.value = false
}
}
function startCreate() {
isEditing.value = false
editingIcon.value = null
form.value = { name: '', url: '', internalUrl: '', linkType: 'external', logoPreview: '', logoFile: null, description: '', sortOrder: 0 }
showForm.value = true
}
function startEdit(icon) {
isEditing.value = true
editingIcon.value = icon
form.value = {
name: icon.name || '',
url: icon.url || '',
internalUrl: icon.internalUrl || '',
linkType: icon.linkType || 'external',
logoPreview: icon.logoUrl || '', //
logoFile: null,
description: icon.description || '',
sortOrder: icon.sortOrder ?? 0,
}
showForm.value = true
}
async function handleSubmit() {
if (!form.value.name?.trim()) return
//
const lt = form.value.linkType
if ((lt === 'external' || lt === 'both') && !form.value.url?.trim()) return
if ((lt === 'internal' || lt === 'both') && !form.value.internalUrl?.trim()) return
submitting.value = true
try {
const payload = {
name: form.value.name.trim(),
url: form.value.url?.trim() || '',
internalUrl: form.value.internalUrl?.trim() || '',
linkType: form.value.linkType || 'external',
description: form.value.description?.trim() || '',
sortOrder: Number(form.value.sortOrder) || 0,
}
let iconId = null
let result = null
if (isEditing.value) {
iconId = editingIcon.value.id
result = await updateIcon(iconId, payload)
} else {
result = await createIcon(payload)
iconId = result?.data?.id
}
if (result?.code === 200) {
// logo
if (form.value.logoFile && iconId) {
try {
const uploadRes = await uploadIconLogo(iconId, form.value.logoFile)
if (uploadRes?.code !== 200) {
emit('error', uploadRes?.message || '图标图片上传失败,但基本信息已保存')
}
} catch (uploadErr) {
emit('error', `图标图片上传失败:${uploadErr?.message || ''},但基本信息已保存`)
}
}
emit('success', isEditing.value ? `图标“${payload.name}”已更新` : `图标“${payload.name}”创建成功`)
emit('refresh')
showForm.value = false
await loadIcons()
} else {
emit('error', result?.message || (isEditing.value ? '更新失败' : '创建失败'))
}
} catch (err) {
emit('error', `${isEditing.value ? '更新' : '创建'}图标失败:${err?.message || ''}`)
} finally {
submitting.value = false
}
}
function confirmDeleteIcon(icon) {
deleteTarget.value = icon
showConfirm.value = true
try { confirmRef.value?.showModal() } catch {}
}
function closeConfirm() {
showConfirm.value = false
deleteTarget.value = null
try { confirmRef.value?.close() } catch {}
}
async function doDelete() {
if (!deleteTarget.value) return
const target = deleteTarget.value
closeConfirm()
try {
const result = await deleteIcon(target.id)
if (result?.code === 200) {
emit('success', `图标"${target.name}"已删除`)
emit('refresh')
showForm.value = false
await loadIcons()
} else {
emit('error', result?.message || '删除图标失败')
}
} catch (err) {
emit('error', `删除图标失败:${err?.message || ''}`)
}
}
function closeDialog() {
dialogRef.value?.close()
}
</script>
<style scoped>
.icon-manage-dialog .dialog-form {
min-width: 560px;
max-width: 720px;
}
.icon-loading,
.icon-empty {
text-align: center;
padding: 40px 20px;
color: #94a3b8;
font-size: 14px;
}
.icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 16px;
padding: 8px 0 16px;
max-height: 400px;
overflow-y: auto;
}
.icon-grid-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px 8px;
border-radius: 12px;
background: #f8fafc;
transition: all 0.2s;
position: relative;
cursor: default;
}
.icon-grid-item:hover {
background: #f0f4ff;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.1);
}
.icon-grid-thumb {
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.icon-grid-thumb img {
width: 32px;
height: 32px;
object-fit: contain;
}
.icon-grid-fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 700;
color: #6366f1;
}
.icon-grid-name {
font-size: 12px;
font-weight: 600;
color: #1e293b;
text-align: center;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-grid-url {
font-size: 10px;
color: #94a3b8;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-grid-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s;
}
.icon-grid-item:hover .icon-grid-actions {
opacity: 1;
}
.icon-grid-actions .action-btn {
padding: 2px 6px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.icon-grid-actions .action-btn:hover {
background: rgba(99, 102, 241, 0.15);
}
.icon-grid-actions .action-btn--danger:hover {
background: rgba(239, 68, 68, 0.15);
}
.icon-add-btn {
display: block;
width: 100%;
padding: 12px;
border: 2px dashed #c7d2fe;
border-radius: 12px;
background: rgba(99, 102, 241, 0.04);
color: #6366f1;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-top: 12px;
}
.icon-add-btn:hover {
background: rgba(99, 102, 241, 0.1);
border-color: #6366f1;
}
.icon-form-view {
padding: 8px 0;
}
.icon-form-preview {
display: flex;
align-items: center;
gap: 10px;
margin-top: 16px;
padding: 12px;
background: #f8fafc;
border-radius: 10px;
font-size: 13px;
color: #64748b;
}
.icon-form-preview .icon-grid-thumb {
width: 36px;
height: 36px;
border-radius: 8px;
}
.icon-form-preview .icon-grid-thumb img {
width: 24px;
height: 24px;
}
/* 确认弹窗 */
.confirm-overlay {
position: fixed;
inset: 0;
background: transparent;
display: flex;
align-items: center;
justify-content: center;
z-index: 2147483647;
border: none;
padding: 0;
overflow: hidden;
border-radius: 14px;
}
.confirm-overlay::backdrop {
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(3px);
}
.confirm-dialog {
background: #fff;
border-radius: 14px;
padding: 24px 28px;
min-width: 400px;
max-width: 90vw;
box-shadow: 0 20px 70px rgba(0, 0, 0, 0.25);
border: none;
}
.confirm-dialog header {
display: flex;
align-items: center;
gap: 8px;
font-size: 17px;
font-weight: 700;
color: #303133;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #ebeef5;
}
.confirm-icon { font-size: 20px; }
.confirm-body {
font-size: 15px;
color: #606266;
line-height: 1.65;
}
.confirm-body strong { color: #f56c6c; font-weight: 700; }
.confirm-dialog footer {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #ebeef5;
}
.confirm-dialog footer button {
padding: 9px 22px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.25s ease;
border: none;
}
.confirm-btn-cancel {
background: #fff;
color: #606266;
border: 1px solid #dcdfe6 !important;
}
.confirm-btn-cancel:hover { background: #f5f7fa; }
.confirm-btn-danger {
background: linear-gradient(135deg, #f56c6c 0%, #e74c3c 100%) !important;
color: #fff !important;
}
.confirm-btn-danger:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(245, 108, 108, 0.35);
}
.confirm-scale-in-enter-active {
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.confirm-scale-in-leave-active { transition: all 0.2s ease; }
.confirm-scale-in-enter-from {
opacity: 0;
transform: scale(0.85) translateY(15px);
}
.confirm-scale-in-leave-to {
opacity: 0;
transform: scale(0.95) translateY(-8px);
}
/* 链接类型选择组 */
.field-label {
display: block;
font-size: 13px;
font-weight: 600;
color: #475569;
margin-bottom: 8px;
}
.link-type-group {
display: flex;
gap: 8px;
}
.link-type-option {
display: flex;
align-items: center;
gap: 4px;
padding: 7px 14px;
border: 1.5px solid #e2e8f0;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
color: #64748b;
cursor: pointer;
transition: all 0.2s;
background: #fff;
}
.link-type-option input[type="radio"] {
display: none;
}
.link-type-option:hover {
border-color: #a5b4fc;
color: #6366f1;
}
.link-type-option--active {
border-color: #6366f1;
background: rgba(99, 102, 241, 0.06);
color: #6366f1;
font-weight: 600;
}
/* 链接类型标签 */
.link-type-badge {
display: inline-block;
font-size: 10px;
font-weight: 600;
padding: 1px 5px;
border-radius: 4px;
vertical-align: middle;
margin-left: 4px;
}
.link-type-badge--external { background: rgba(59, 130, 246, 0.12); color: #3b82f6; }
.link-type-badge--internal { background: rgba(234, 179, 8, 0.12); color: #ca8a04; }
.link-type-badge--both { background: rgba(139, 92, 246, 0.12); color: #7c3aed; }
/* 图标图片上传区域 */
.icon-upload-area {
margin-top: 4px;
}
.icon-upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
height: 100px;
border: 2px dashed #d1d5db;
border-radius: 12px;
background: rgba(99, 102, 241, 0.02);
cursor: pointer;
transition: all 0.2s;
color: #94a3b8;
font-size: 13px;
}
.icon-upload-placeholder:hover {
border-color: #a5b4fc;
background: rgba(99, 102, 241, 0.06);
color: #6366f1;
}
.icon-upload-plus {
font-size: 28px;
font-weight: 300;
line-height: 1;
}
.icon-upload-preview {
display: flex;
align-items: center;
gap: 12px;
padding: 10px;
background: #f8fafc;
border-radius: 12px;
border: 1.5px solid #e2e8f0;
}
.icon-upload-preview img {
width: 56px;
height: 56px;
object-fit: contain;
border-radius: 8px;
background: #fff;
border: 1px solid #e2e8f0;
}
.icon-upload-actions {
display: flex;
gap: 6px;
}
.icon-upload-btn {
padding: 5px 12px;
border: 1.5px solid #e2e8f0;
border-radius: 8px;
background: #fff;
font-size: 12px;
font-weight: 600;
color: #6366f1;
cursor: pointer;
transition: all 0.15s;
}
.icon-upload-btn:hover {
border-color: #6366f1;
background: rgba(99, 102, 241, 0.06);
}
.icon-upload-btn--remove {
color: #ef4444;
}
.icon-upload-btn--remove:hover {
border-color: #ef4444;
background: rgba(239, 68, 68, 0.06);
}
.icon-upload-tip {
display: block;
margin-top: 6px;
font-size: 11px;
color: #94a3b8;
}
</style>

View File

@ -0,0 +1,297 @@
<template>
<main class="login-shell">
<section class="login-panel" :aria-label="isRegisterMode ? '注册' : '登录'">
<img class="brand-mark" src="/logo.png" alt="网盘管理" aria-hidden="true" />
<h1>网盘管理</h1>
<p>
{{ isRegisterMode ? '创建新账号后即可返回登录,开始管理你的网盘资源。' : 'Welcome home' }}
</p>
<div class="auth-switch">
<button
type="button"
class="switch-chip"
:class="{ active: !isRegisterMode }"
@click="switchMode(false)"
>
登录
</button>
<button
type="button"
class="switch-chip"
:class="{ active: isRegisterMode }"
@click="switchMode(true)"
>
注册
</button>
</div>
<form v-if="!isRegisterMode" class="login-form" @submit.prevent="handleLoginSubmit">
<label>
<span>登录账号</span>
<input
v-model="loginForm.username"
type="text"
autocomplete="username"
placeholder="请输入账号"
/>
</label>
<label>
<span>访问口令</span>
<input
v-model="loginForm.password"
type="password"
autocomplete="current-password"
placeholder="请输入口令"
/>
</label>
<label>
<span>图形验证码</span>
<div class="captcha-row">
<input
v-model="loginForm.captchaInput"
type="text"
autocomplete="off"
placeholder="输入右侧字符"
maxlength="6"
/>
<canvas
ref="canvasRef"
width="136"
height="46"
title="点击刷新验证码"
@click="refreshCaptcha"
/>
</div>
</label>
<button type="submit" :disabled="loading">
{{ loading ? '登录中...' : '登录' }}
</button>
<div v-if="props.notice && !message" class="form-message warning" role="status">{{ props.notice }}</div>
<div class="form-message" role="alert">{{ message }}</div>
</form>
<form v-else class="login-form register-form" @submit.prevent="handleRegisterSubmit">
<label>
<span>注册账号</span>
<input
v-model="registerForm.username"
type="text"
autocomplete="username"
placeholder="请输入账号"
/>
</label>
<label>
<span>邮箱选填</span>
<input
v-model="registerForm.email"
type="email"
autocomplete="email"
placeholder="请输入邮箱"
/>
</label>
<label>
<span>手机号选填</span>
<input
v-model="registerForm.phone"
type="tel"
autocomplete="tel"
placeholder="请输入手机号"
/>
</label>
<label>
<span>登录密码</span>
<input
v-model="registerForm.password"
type="password"
autocomplete="new-password"
placeholder="请输入密码"
/>
</label>
<label>
<span>确认密码</span>
<input
v-model="registerForm.confirmPassword"
type="password"
autocomplete="new-password"
placeholder="请再次输入密码"
/>
</label>
<button type="submit" :disabled="loading">
{{ loading ? '注册中...' : '立即注册' }}
</button>
<div class="form-message success" role="status">{{ registerSuccessMessage }}</div>
<div class="form-message" role="alert">{{ message }}</div>
</form>
</section>
</main>
</template>
<script setup>
import { nextTick, onMounted, reactive, ref } from 'vue'
import { login, register } from '../api'
import { drawCaptcha, generateCaptcha } from '../utils/captcha'
const props = defineProps({
notice: {
type: String,
default: '',
},
})
const emit = defineEmits(['success', 'clear-notice'])
const loading = ref(false)
const message = ref('')
const registerSuccessMessage = ref('')
const isRegisterMode = ref(false)
const canvasRef = ref(null)
const captchaCode = ref('')
const loginForm = reactive({
username: '',
password: '',
captchaInput: '',
})
const registerForm = reactive({
username: '',
password: '',
confirmPassword: '',
email: '',
phone: '',
})
function resetMessage() {
message.value = ''
registerSuccessMessage.value = ''
}
function switchMode(registerMode) {
isRegisterMode.value = registerMode
resetMessage()
// notice
if (props.notice) {
emit('clear-notice')
}
if (!registerMode) {
refreshCaptcha()
}
}
function refreshCaptcha() {
captchaCode.value = generateCaptcha()
nextTick(() => drawCaptcha(canvasRef.value, captchaCode.value))
}
async function handleLoginSubmit() {
resetMessage()
if (!loginForm.username.trim()) {
message.value = '请输入登录账号'
return
}
if (!loginForm.password.trim()) {
message.value = '请输入访问口令'
return
}
if (loginForm.captchaInput.trim().toUpperCase() !== captchaCode.value) {
message.value = '验证码不正确,请重新输入'
loginForm.captchaInput = ''
refreshCaptcha()
return
}
loading.value = true
try {
const result = await login({
username: loginForm.username.trim(),
password: loginForm.password,
})
if (result?.code !== 200 || !result?.data?.token) {
throw new Error(result?.message || '登录失败')
}
emit('success', {
...result.data,
username: result?.data?.username || loginForm.username.trim(),
})
loginForm.captchaInput = ''
} catch (error) {
console.error('登录失败:', error)
message.value = error?.message || '登录失败,请稍后重试'
loginForm.captchaInput = ''
refreshCaptcha()
} finally {
loading.value = false
}
}
async function handleRegisterSubmit() {
resetMessage()
if (!registerForm.username.trim()) {
message.value = '请输入注册账号'
return
}
if (!registerForm.password.trim()) {
message.value = '请输入登录密码'
return
}
if (registerForm.password !== registerForm.confirmPassword) {
message.value = '两次输入的密码不一致'
return
}
loading.value = true
try {
const result = await register({
username: registerForm.username.trim(),
password: registerForm.password,
email: registerForm.email.trim(),
phone: registerForm.phone.trim(),
})
if (result?.code !== 200) {
throw new Error(result?.message || '注册失败')
}
registerSuccessMessage.value = result?.message || '注册成功,请返回登录'
loginForm.username = registerForm.username.trim()
loginForm.password = registerForm.password
registerForm.username = ''
registerForm.password = ''
registerForm.confirmPassword = ''
registerForm.email = ''
registerForm.phone = ''
window.setTimeout(() => {
switchMode(false)
}, 800)
} catch (error) {
console.error('注册失败:', error)
message.value = error?.message || '注册失败,请稍后重试'
} finally {
loading.value = false
}
}
onMounted(() => {
refreshCaptcha()
})
</script>

View File

@ -0,0 +1,54 @@
<template>
<article class="poster-card" :class="{ 'is-readonly': readonly }" @click="handleClick">
<div class="poster-thumb">
<img :src="item.poster" :alt="item.title" loading="lazy" />
<span class="score-badge">{{ item.score }}</span>
</div>
<div class="poster-body">
<div class="poster-meta-row">
<span class="status-pill" :class="statusClass">{{ item.status }}</span>
<span class="poster-time">{{ item.updatedAt }}</span>
</div>
<h3>{{ item.title }}</h3>
<p>{{ item.subtitle }}</p>
<div class="poster-owner">
<span class="poster-sheet">{{ item.sheetName }}</span>
<span>{{ item.accountName }}</span>
<small>{{ item.owner }}</small>
</div>
<div class="tag-list">
<span v-for="tag in item.tags" :key="tag">{{ tag }}</span>
</div>
</div>
</article>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
item: {
type: Object,
required: true,
},
readonly: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['edit'])
const statusClass = computed(() => {
if (props.item.status === '正常') return 'is-normal'
if (props.item.status === '待复核') return 'is-warning'
return 'is-danger'
})
function handleClick() {
if (!props.readonly) {
emit('edit', props.item)
}
}
</script>

View File

@ -0,0 +1,160 @@
<template>
<dialog ref="dialogRef" class="app-dialog" @close="unlockScroll(); $emit('close')">
<form class="dialog-form" method="dialog" @submit.prevent="handleSave">
<header>
<h2>{{ editingRecord?.id ? '编辑电影' : '新增电影' }}</h2>
<button type="button" aria-label="关闭" @click="closeDialog">×</button>
</header>
<div class="field-grid">
<label>
<span>文件名</span>
<input v-model.trim="localRecord.title" type="text" placeholder="请输入电影文件名,如 xxx.mp4" />
</label>
<label>
<span>原始文件名</span>
<input v-model.trim="localRecord.originalName" type="text" placeholder="用于 TMDB 刮削纠错" :disabled="!localRecord.id" />
</label>
<label>
<span>所属网盘</span>
<select v-model="localRecord.sheetId" :disabled="Boolean(localRecord.id)">
<option v-for="sheet in sheets" :key="sheet.id" :value="sheet.id">{{ sheet.name }}</option>
</select>
</label>
<label>
<span>评分</span>
<input v-model.number="localRecord.score" type="number" min="0" max="100" placeholder="0-100" />
</label>
<label>
<span>副标题</span>
<input v-model.trim="localRecord.subtitle" type="text" placeholder="可选" :disabled="!localRecord.id" />
</label>
<label>
<span>分类标签</span>
<input v-model.trim="localRecord.category" type="text" placeholder="如 科幻 / 动作" :disabled="!localRecord.id" />
</label>
<label class="field-span-2">
<span>标签</span>
<input v-model="tagText" type="text" placeholder="多个标签用逗号分隔" :disabled="!localRecord.id" />
</label>
<label class="field-span-2">
<span>描述</span>
<textarea v-model.trim="localRecord.description" rows="4" placeholder="可选" :disabled="!localRecord.id"></textarea>
</label>
</div>
<footer>
<button type="button" class="danger" @click="handleDelete" :disabled="!localRecord.id">删除</button>
<span></span>
<button type="button" class="secondary" @click="closeDialog">取消</button>
<button type="submit">保存</button>
</footer>
</form>
</dialog>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { lockScroll, unlockScroll } from '../utils/scrollLock'
const props = defineProps({
open: Boolean,
editingRecord: {
type: Object,
default: null,
},
sheets: {
type: Array,
default: () => [],
},
activeSheetId: {
type: String,
default: '',
},
})
const emit = defineEmits(['close', 'save', 'delete'])
const dialogRef = ref(null)
const localRecord = ref(createEmptyRecord())
const tagText = computed({
get: () => (localRecord.value.tags || []).join(', '),
set: (value) => {
localRecord.value.tags = value
.split(',')
.map((item) => item.trim())
.filter(Boolean)
},
})
function createEmptyRecord() {
return {
id: null,
title: '',
subtitle: '',
tags: [],
score: 0,
originalName: '',
description: '',
category: '',
sheetId: props.activeSheetId || props.sheets[0]?.id || '',
parentId: null,
}
}
function closeDialog() {
dialogRef.value?.close()
}
function handleSave() {
emit('save', {
...localRecord.value,
title: localRecord.value.title?.trim() || '',
originalName: localRecord.value.originalName?.trim() || '',
subtitle: localRecord.value.subtitle?.trim() || '',
description: localRecord.value.description?.trim() || '',
category: localRecord.value.category?.trim() || '',
})
closeDialog()
}
function handleDelete() {
if (!localRecord.value.id) {
closeDialog()
return
}
emit('delete', localRecord.value)
closeDialog()
}
watch(
() => [props.editingRecord, props.activeSheetId, props.sheets],
() => {
localRecord.value = props.editingRecord
? {
...props.editingRecord,
tags: [...(props.editingRecord.tags || [])],
sheetId: props.editingRecord.sheetId || props.activeSheetId || props.sheets[0]?.id || '',
score: props.editingRecord.score || 0,
}
: createEmptyRecord()
},
{ immediate: true },
)
watch(
() => props.open,
(value) => {
if (!dialogRef.value) return
if (value && !dialogRef.value.open) {
dialogRef.value.showModal()
lockScroll()
}
if (!value && dialogRef.value.open) {
dialogRef.value.close()
unlockScroll()
}
},
)
</script>

View File

@ -0,0 +1,56 @@
<template>
<dialog ref="dialogRef" class="app-dialog" @close="unlockScroll(); $emit('close')">
<form class="dialog-form" method="dialog">
<header>
<h2>{{ title }}</h2>
<button type="button" aria-label="关闭" @click="closeDialog">×</button>
</header>
<div class="simple-dialog-body">
<slot />
</div>
<footer>
<slot name="footer">
<span></span>
<span></span>
<button type="button" class="secondary" @click="closeDialog">关闭</button>
</slot>
</footer>
</form>
</dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { lockScroll, unlockScroll } from '../utils/scrollLock'
const props = defineProps({
open: Boolean,
title: {
type: String,
default: '',
},
})
defineEmits(['close'])
const dialogRef = ref(null)
function closeDialog() {
dialogRef.value?.close()
}
watch(
() => props.open,
(value) => {
if (!dialogRef.value) return
if (value && !dialogRef.value.open) {
dialogRef.value.showModal()
lockScroll()
}
if (!value && dialogRef.value.open) {
dialogRef.value.close()
unlockScroll()
}
},
)
</script>

View File

@ -0,0 +1,299 @@
<template>
<div class="tree-item" :style="{ marginLeft: depth * 24 + 'px' }">
<div
class="tree-item__header"
:class="{ 'is-folder': node.type === 'folder' }"
@click="toggleExpand"
@mouseenter="onHeaderMouseEnter"
@mouseleave="onHeaderMouseLeave"
>
<span v-if="node.type === 'folder'" class="tree-icon">
{{ isExpanded ? '📂' : '📁' }}
</span>
<span v-else class="tree-icon">📄</span>
<span v-if="node.type === 'folder' && node.isLoading" class="tree-loading">加载中...</span>
<!-- 排序权重标签 -->
<span v-if="node.type === 'folder'" class="tree-sort-badge" :title="`排序权重:${node.sortWeight ?? 0}`">
{{ node.sortWeight ?? 0 }}
</span>
<span class="tree-title">{{ node.title }}</span>
<!-- 文件夹描述提示图标 -->
<span
v-if="node.type === 'folder' && node.description"
class="tree-info-badge"
title="有描述"
></span>
<div class="tree-actions" @click.stop>
<button
v-if="node.type === 'folder'"
type="button"
class="action-btn"
title="添加子文件夹"
@click="handleAddFolder"
>
+📁
</button>
<button
v-if="node.type === 'folder'"
type="button"
class="action-btn"
title="添加文件"
@click="handleAddFile"
>
+📄
</button>
<button
type="button"
class="action-btn"
title="编辑"
@click="handleEdit"
>
</button>
<button
type="button"
class="action-btn action-btn--danger"
title="删除"
@click="handleDelete"
>
🗑
</button>
</div>
</div>
<!-- 文件夹描述悬浮提示Teleport body -->
<Teleport to="body">
<Transition name="tooltip-fade">
<div
v-if="showTooltip"
class="app-tooltip"
:style="tooltipStyle"
>
<div class="app-tooltip__header">
<span class="app-tooltip__icon">📁</span>
<span class="app-tooltip__title">{{ node.title }}</span>
</div>
<div class="app-tooltip__body">
<p v-if="node.description" class="app-tooltip__desc">{{ node.description }}</p>
<span v-else class="app-tooltip__empty">暂无描述</span>
<div v-if="node.sortWeight" class="app-tooltip__meta">
排序权重{{ node.sortWeight }}
</div>
</div>
<div class="app-tooltip__arrow"></div>
</div>
</Transition>
</Teleport>
<!-- 递归渲染子节点 -->
<Transition name="expand">
<div v-if="node.type === 'folder' && isExpanded && node.children?.length" class="tree-children">
<TreeNode
v-for="child in node.children"
:key="child.id"
:node="child"
:depth="depth + 1"
@add-folder="$emit('add-folder', $event)"
@add-file="$emit('add-file', $event)"
@expand="$emit('expand', $event)"
@edit="$emit('edit', $event)"
@delete="$emit('delete', $event)"
/>
</div>
</Transition>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
node: {
type: Object,
required: true,
},
depth: {
type: Number,
default: 0,
},
})
const emit = defineEmits(['add-folder', 'add-file', 'expand', 'edit', 'delete'])
const isExpanded = ref(false)
const showTooltip = ref(false)
const tooltipPos = ref({ top: 0, left: 0 })
let tooltipTimer = null
const tooltipStyle = computed(() => ({
position: 'fixed',
top: `${tooltipPos.value.top}px`,
left: `${tooltipPos.value.left}px`,
transform: 'translateX(-50%)',
zIndex: 99999,
}))
function onHeaderMouseEnter(event) {
if (props.node.type !== 'folder') return
const el = event.currentTarget
const rect = el.getBoundingClientRect()
tooltipPos.value = {
top: rect.bottom + 8,
left: rect.left + rect.width / 2,
}
tooltipTimer = setTimeout(() => {
showTooltip.value = true
}, 300)
}
function onHeaderMouseLeave() {
clearTimeout(tooltipTimer)
showTooltip.value = false
}
function toggleExpand() {
if (props.node.type === 'folder') {
const nextExpanded = !isExpanded.value
isExpanded.value = nextExpanded
if (nextExpanded) {
emit('expand', props.node)
}
}
}
function handleAddFolder() {
emit('add-folder', props.node.id)
}
function handleAddFile() {
emit('add-file', props.node.id)
}
function handleEdit() {
emit('edit', props.node)
}
function handleDelete() {
emit('delete', props.node)
}
</script>
<style scoped>
.tree-item {
margin-bottom: 4px;
}
.tree-item__header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
background: rgba(99, 102, 241, 0.05);
cursor: pointer;
transition: all 0.2s;
}
.tree-item__header:hover {
background: rgba(99, 102, 241, 0.1);
}
.tree-item__header.is-folder {
font-weight: 600;
}
.tree-icon {
font-size: 18px;
flex-shrink: 0;
}
.tree-loading {
flex-shrink: 0;
font-size: 12px;
color: #6366f1;
}
.tree-sort-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: rgba(99, 102, 241, 0.12);
color: #6366f1;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
line-height: 1;
letter-spacing: 0.02em;
}
.tree-title {
flex: 1;
font-size: 14px;
color: #1f2937;
}
.tree-info-badge {
font-size: 12px;
flex-shrink: 0;
opacity: 0.6;
transition: opacity 0.2s;
}
.tree-item__header:hover .tree-info-badge {
opacity: 1;
}
.tree-actions {
display: flex;
gap: 4px;
opacity: 1;
transition: opacity 0.2s;
}
.tree-item__header:hover .tree-actions {
opacity: 1;
}
.action-btn {
padding: 4px 8px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.action-btn:hover {
background: rgba(99, 102, 241, 0.15);
}
.action-btn--danger:hover {
background: rgba(239, 68, 68, 0.15);
}
.tree-children {
margin-top: 4px;
}
.expand-enter-active,
.expand-leave-active {
transition: all 0.3s ease;
max-height: 1000px;
overflow: hidden;
}
.expand-enter-from,
.expand-leave-to {
max-height: 0;
opacity: 0;
}
</style>

File diff suppressed because it is too large Load Diff

5
vue/src/main.js Normal file
View File

@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')

2857
vue/src/style.css Normal file

File diff suppressed because it is too large Load Diff

47
vue/src/utils/captcha.js Normal file
View File

@ -0,0 +1,47 @@
export function generateCaptcha(length = 5) {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
return Array.from({ length }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
}
export function drawCaptcha(canvas, code) {
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = '#f8fafc'
ctx.fillRect(0, 0, canvas.width, canvas.height)
for (let i = 0; i < 36; i += 1) {
ctx.fillStyle = `rgba(${60 + Math.random() * 120}, ${80 + Math.random() * 120}, ${90 + Math.random() * 120}, .24)`
ctx.beginPath()
ctx.arc(Math.random() * canvas.width, Math.random() * canvas.height, Math.random() * 2.2 + 0.8, 0, Math.PI * 2)
ctx.fill()
}
for (let i = 0; i < 4; i += 1) {
ctx.strokeStyle = `rgba(15, 118, 110, ${0.2 + Math.random() * 0.35})`
ctx.lineWidth = 1 + Math.random() * 1.5
ctx.beginPath()
ctx.moveTo(Math.random() * canvas.width, Math.random() * canvas.height)
ctx.bezierCurveTo(
Math.random() * canvas.width,
Math.random() * canvas.height,
Math.random() * canvas.width,
Math.random() * canvas.height,
Math.random() * canvas.width,
Math.random() * canvas.height,
)
ctx.stroke()
}
;[...code].forEach((char, index) => {
ctx.save()
ctx.translate(20 + index * 23, 30 + (Math.random() - 0.5) * 8)
ctx.rotate((Math.random() - 0.5) * 0.45)
ctx.fillStyle = index % 2 ? '#0f766e' : '#172033'
ctx.font = `${22 + Math.random() * 5}px Arial`
ctx.fillText(char, 0, 0)
ctx.restore()
})
}

224
vue/src/utils/data.js Normal file
View File

@ -0,0 +1,224 @@
export function normalizeText(value) {
return String(value ?? '').trim()
}
export function filterRows(rows, keyword) {
const target = normalizeText(keyword).toLowerCase()
if (!target) return rows
return rows.filter((item) => {
const merged = [
item.title,
item.subtitle,
item.accountName,
item.owner,
item.sheetName,
...(item.tags || []),
]
.join(' ')
.toLowerCase()
return merged.includes(target)
})
}
export function attachSheetName(sheets = []) {
return sheets.map((sheet) => ({
...sheet,
rows: (sheet.rows || []).map((row) => ({ ...row, sheetName: sheet.name })),
}))
}
export function flattenRows(sheets = []) {
return attachSheetName(sheets).flatMap((sheet) => sheet.rows || [])
}
export function buildDuplicates(rows) {
// 构建完整路径需要包含所有数据(文件和文件夹)
const allItems = rows
// 只检测文件类型,排除文件夹
const fileRows = rows.filter(item => item.type !== 'folder')
const map = new Map()
fileRows.forEach((item) => {
// 使用更智能的标题标准化:去除空格、标点符号,统一小写
const key = normalizeTitleForComparison(item.title)
if (!key) return
if (!map.has(key)) {
map.set(key, [])
}
map.get(key).push(item)
})
return [...map.entries()]
.filter(([, list]) => list.length > 1)
.map(([, list], index) => ({
id: `duplicate-${index + 1}`,
title: list[0].title,
locations: list.map((item, idx) => {
// 构建完整路径:网盘名称 / 父文件夹路径 / 资源库
// 注意:传入 allItems 而不是 fileRows因为需要查找文件夹
const parentPath = buildParentPath(item, allItems)
const location = `${item.sheetName}${parentPath} / ${item.accountName}`
// 如果同一个位置有多个重复文件,添加序号区分
const sameLocationCount = list.filter((other, otherIdx) => {
if (otherIdx === idx) return false
const otherParentPath = buildParentPath(other, allItems)
const otherLocation = `${other.sheetName}${otherParentPath} / ${other.accountName}`
return otherLocation === location
}).length
return sameLocationCount > 0 ? `${location} (副本${sameLocationCount + 1})` : location
}),
}))
}
// 标准化标题用于比较(去除空格、标点符号等)
function normalizeTitleForComparison(title) {
return normalizeText(title)
.toLowerCase()
.replace(/[\s\-_.]+/g, '') // 去除所有空格、连字符、下划线、点号
.replace(/[\(\)\[\]【】《》]/g, '') // 去除各种括号
}
// 构建父文件夹路径
function buildParentPath(item, allItems) {
if (!item.parentId) return ''
const pathParts = []
let currentId = item.parentId
const visited = new Set() // 防止循环引用
// 向上查找所有父文件夹
while (currentId && !visited.has(currentId)) {
visited.add(currentId)
const parent = allItems.find(i => i.id === currentId && i.type === 'folder')
if (!parent) break
pathParts.unshift(parent.title)
currentId = parent.parentId
}
return pathParts.length ? ' / ' + pathParts.join(' / ') : ''
}
export function createLoginLog(device = '未知设备', ip = '127.0.0.1', options = {}) {
const hashSeed = String(options.hashSeed || `${Date.now()}-${Math.random()}`)
const hash = hashSeed
.split('')
.reduce((acc, char) => ((acc << 5) - acc + char.charCodeAt(0)) | 0, 0)
.toString(16)
.replace('-', '')
.slice(0, 8)
return {
id: options.id ? `log-${options.id}` : `log-${Date.now()}`,
time: options.time || new Date().toLocaleString('zh-CN', { hour12: false }),
device,
ip,
hash,
username: normalizeText(options.username) || '未知账号',
result: normalizeText(options.result) || 'success',
userId: options.userId ?? null,
rawUserAgent: normalizeText(options.rawUserAgent) || device,
failReason: normalizeText(options.failReason) || '',
}
}
export function createRecord(record = {}, fallbackSheetName = '') {
const normalizedTags = Array.isArray(record.tags)
? record.tags.map((item) => normalizeText(item)).filter(Boolean)
: normalizeText(record.tags)
.split(/[,]/)
.map((item) => item.trim())
.filter(Boolean)
return {
id: record.id || `record-${Date.now()}`,
title: normalizeText(record.title),
subtitle: normalizeText(record.subtitle),
accountName: normalizeText(record.accountName),
owner: normalizeText(record.owner),
updatedAt: normalizeText(record.updatedAt),
status: normalizeText(record.status) || '正常',
poster: normalizeText(record.poster),
tags: normalizedTags,
score: Number(record.score) || 0,
sheetName: normalizeText(record.sheetName) || fallbackSheetName,
parentId: record.parentId || null, // 父文件夹ID
type: record.type || 'file', // 'folder' 或 'file'
originalName: normalizeText(record.originalName),
description: normalizeText(record.description),
category: normalizeText(record.category),
driveId: record.driveId ?? null,
folderId: record.folderId ?? null,
minioUrl: normalizeText(record.minioUrl),
mimeType: normalizeText(record.mimeType),
extension: normalizeText(record.extension),
fileSize: Number(record.fileSize) || 0,
createdAt: normalizeText(record.createdAt),
}
}
export function mapMovieToRecord(movie = {}, fallbackSheetName = '') {
return createRecord({
id: movie.id,
title: movie.title,
subtitle: movie.subtitle || movie.description || '',
accountName: movie.originalName || movie.minioUrl || '',
owner: movie.category || '',
updatedAt: movie.updateTime || movie.createTime || '',
status: '正常',
poster: movie.posterUrl || '',
tags: movie.tags,
score: Number(movie.rating) || 0,
sheetName: fallbackSheetName,
parentId: movie.folderId || null,
type: 'file',
originalName: movie.originalName,
description: movie.description,
category: movie.category,
driveId: movie.driveId,
folderId: movie.folderId,
minioUrl: movie.minioUrl,
mimeType: movie.mimeType,
extension: movie.extension,
fileSize: movie.fileSize,
createdAt: movie.createTime,
}, fallbackSheetName)
}
// 创建文件夹
export function createFolder(folder = {}, fallbackSheetName = '') {
return {
id: folder.id || `folder-${Date.now()}`,
title: normalizeText(folder.title),
parentId: folder.parentId || null,
sheetName: normalizeText(folder.sheetName) || fallbackSheetName,
type: 'folder',
children: [], // 子项(文件夹或文件)
createdAt: folder.createdAt || new Date().toLocaleString('zh-CN', { hour12: false }),
}
}
// 构建树形结构
export function buildTree(items = []) {
const map = new Map()
const roots = []
// 创建映射
items.forEach((item) => {
map.set(item.id, { ...item, children: [] })
})
// 构建树
items.forEach((item) => {
const node = map.get(item.id)
if (item.parentId && map.has(item.parentId)) {
map.get(item.parentId).children.push(node)
} else {
roots.push(node)
}
})
return roots
}

View File

@ -0,0 +1,25 @@
/**
* 全局滚动锁工具
* 使用引用计数支持多个弹窗同时打开而不冲突
*/
let lockCount = 0
/**
* 锁定背景滚动
*/
export function lockScroll() {
lockCount++
if (lockCount === 1) {
document.body.style.overflow = 'hidden'
}
}
/**
* 解锁背景滚动仅当所有弹窗都关闭后才恢复
*/
export function unlockScroll() {
lockCount = Math.max(0, lockCount - 1)
if (lockCount === 0) {
document.body.style.overflow = ''
}
}

21
vue/src/utils/storage.js Normal file
View File

@ -0,0 +1,21 @@
export const AUTH_STORAGE_KEY = 'wangpan-auth-v1'
export const AUTH_SESSION_STORAGE_KEY = 'wangpan-auth-session-v1'
export const DASHBOARD_STORAGE_KEY = 'wangpan-dashboard-v1'
export const COLUMN_STORAGE_KEY = 'wangpan-columns-v1'
export function readStorage(key, fallback) {
try {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : fallback
} catch {
return fallback
}
}
export function writeStorage(key, value) {
localStorage.setItem(key, JSON.stringify(value))
}
export function removeStorage(key) {
localStorage.removeItem(key)
}

19
vue/vite.config.js Normal file
View File

@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0', // 允许局域网访问
port: 5173,
strictPort: true, // ✅ 强制使用指定端口,如果被占用则报错而不是自动切换
proxy: {
'/api': {
target: 'http://192.168.1.18:8888',
changeOrigin: true,
secure: false,
},
},
},
})