初始提交:网盘管理跨端应用
112
.qoder/repowiki/knowledge/zh/_index.yaml
Normal 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: []
|
||||||
105
.qoder/repowiki/knowledge/zh/网盘管理跨端应用/_module.yaml
Normal 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: []
|
||||||
1
.qoder/repowiki/knowledge/zh/网盘管理跨端应用/技术栈.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
Vue 3.5, Vite 7, Capacitor 8, Android Gradle Plugin
|
||||||
4
.qoder/repowiki/knowledge/zh/网盘管理跨端应用/架构设计.md
Normal 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` 脚本自动化执行。
|
||||||
1
.qoder/repowiki/knowledge/zh/网盘管理跨端应用/概述.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
基于 Vue 3 与 Capacitor 构建的网盘资源管理与海报展示跨端应用,支持 Android APK 打包。
|
||||||
1
.qoder/repowiki/knowledge/zh/网盘管理跨端应用/特殊配置与命令.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
- **APK 打包**:运行根目录下的 `build-apk.bat` 脚本,自动完成 Web 资源构建、Capacitor 同步并唤起 Android Studio 进行最终编译。
|
||||||
2
.qoder/repowiki/knowledge/zh/网盘管理跨端应用/编码规范.md
Normal 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
@ -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
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
286
vue/APK_BUILD_OPTIONS.md
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -0,0 +1,2 @@
|
|||||||
|
/build/*
|
||||||
|
!/build/.npmkeep
|
||||||
54
vue/android/app/build.gradle
Normal 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")
|
||||||
|
}
|
||||||
21
vue/android/app/capacitor.build.gradle
Normal 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
@ -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
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
41
vue/android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package com.wangpan.app;
|
||||||
|
|
||||||
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
|
public class MainActivity extends BridgeActivity {}
|
||||||
BIN
vue/android/app/src/main/res/drawable-land-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
vue/android/app/src/main/res/drawable-land-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
vue/android/app/src/main/res/drawable-land-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
vue/android/app/src/main/res/drawable-land-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
vue/android/app/src/main/res/drawable-land-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
vue/android/app/src/main/res/drawable-port-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
vue/android/app/src/main/res/drawable-port-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
vue/android/app/src/main/res/drawable-port-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
vue/android/app/src/main/res/drawable-port-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
vue/android/app/src/main/res/drawable-port-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
@ -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>
|
||||||
170
vue/android/app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||||
BIN
vue/android/app/src/main/res/drawable/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
12
vue/android/app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
BIN
vue/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
BIN
vue/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
vue/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
BIN
vue/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
vue/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
BIN
vue/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
vue/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
BIN
vue/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
vue/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#FFFFFF</color>
|
||||||
|
</resources>
|
||||||
7
vue/android/app/src/main/res/values/strings.xml
Normal 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>
|
||||||
22
vue/android/app/src/main/res/values/styles.xml
Normal 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>
|
||||||
5
vue/android/app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||||
@ -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
@ -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
|
||||||
|
}
|
||||||
12
vue/android/capacitor.settings.gradle
Normal 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')
|
||||||
22
vue/android/gradle.properties
Normal 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
|
||||||
BIN
vue/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
vue/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
@ -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
@ -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
|
||||||
5
vue/android/settings.gradle
Normal 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'
|
||||||
16
vue/android/variables.gradle
Normal 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
@ -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
@ -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
@ -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
25
vue/package.json
Normal 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
|
After Width: | Height: | Size: 120 KiB |
3220
vue/public/vite.svg
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
1946
vue/src/App.vue
Normal file
689
vue/src/api/index.js
Normal 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 - 文件夹ID,0表示根目录
|
||||||
|
* @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
@ -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 |
210
vue/src/components/ConfirmDialog.vue
Normal 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>
|
||||||
1544
vue/src/components/DriveManageDialog.vue
Normal file
101
vue/src/components/FolderDialog.vue
Normal 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>
|
||||||
58
vue/src/components/FolderTree.vue
Normal 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>
|
||||||
780
vue/src/components/IconManageDialog.vue
Normal 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>
|
||||||
297
vue/src/components/LoginView.vue
Normal 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>
|
||||||
54
vue/src/components/PosterCard.vue
Normal 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>
|
||||||
160
vue/src/components/RecordDialog.vue
Normal 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>
|
||||||
56
vue/src/components/SimpleDialog.vue
Normal 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>
|
||||||
299
vue/src/components/TreeNode.vue
Normal 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>
|
||||||
1340
vue/src/components/UserManageDialog.vue
Normal file
5
vue/src/main.js
Normal 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
47
vue/src/utils/captcha.js
Normal 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
@ -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
|
||||||
|
}
|
||||||
25
vue/src/utils/scrollLock.js
Normal 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
@ -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
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||