初始提交:网盘管理跨端应用
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||