2
0

24 Коммиты 9f5ac9b918 ... badf210fca

Автор SHA1 Сообщение Дата
  skyline badf210fca feat: 小程序附近站点按距离过滤,超 50 公里站点不再展示 3 дней назад
  skyline e87d1992ac fix: 修复 admin-h5 底部导航图标偏位与切换后高亮残留(Tab 页缓存复用导致) 3 дней назад
  skyline 66d4a8426f feat: 站点切换器改为顶栏可搜索下拉并去掉整页刷新,11 个页面站点筛选联动 3 дней назад
  skyline 9cb50108c8 feat: 设备远程控制支持读取信号值,RSSI 按好/中/差/极差分级着色 3 дней назад
  skyline 21f885968c feat: 用户锁定功能对齐 — 锁定筛选/锁定解锁/锁定记录,退款申请原因改为可选 3 дней назад
  skyline 43aabff49e feat: 订单列表新增新用户订单标记,手机号列加宽并显示品牌红标签 3 дней назад
  skyline 1f64624924 fix: 走势图总金额改为订单实收金额,汇总标签改「总订单实收金额」 3 дней назад
  skyline 86ec164371 fix: 站点切换器默认显示站点名称(修复 loading 态 label 解析回退为ID),入口移至顶栏右侧工具区 3 дней назад
  skyline 1d9e2548cc feat: 站点切换器融入顶栏视觉语言,重做下拉面板与交互反馈 3 дней назад
  skyline 335e6662df fix: 修复 useStationScope 回调前置引用引发的页面 TDZ 崩溃 3 дней назад
  skyline c3a3b5f0c4 feat: admin-web 站点切换入口移至顶栏左侧并统一各页面站点筛选联动 3 дней назад
  skyline 2e2a81095d fix: admin-h5 底部导航切换图标闪烁,改为双层图标交叉过渡 3 дней назад
  skyline 57ec3772a5 fix: 部署脚本前端构建前自动安装依赖 3 дней назад
  skyline 151c0bd0ab fix: 修复列表查询结果为空时 IN () 非法 SQL 报错 3 дней назад
  skyline a4a6510b3f chore: android 工程注册 capacitor-haptics 插件 2 недель назад
  skyline 7b0e415357 fix: admin-h5 App 端底部安全区适配 2 недель назад
  skyline 5700d8234b feat: admin-h5 视觉动效与触感反馈全面升级 2 недель назад
  skyline 790578d9ab docs: README 增加 admin-h5 安卓 App 打包说明 2 недель назад
  skyline 8ef7aee705 feat: 应用图标与启动页采用 Yeswash 品牌样式,附 SVG 源文件与一键生成脚本 2 недель назад
  skyline 47e5af80c7 feat: admin-h5 支持 Capacitor 打包安卓 App,物理返回键处理与相对路径适配 2 недель назад
  skyline 061f29558c style: 订单列表「新用户」标签重新布局,admin-h5 移至卡片头部徽标区,admin-web 改为品牌红并加宽手机号列 2 недель назад
  skyline b33eeb42af feat: 订单列表新增新用户订单标记,注册当天的订单打上「新用户」标签 2 недель назад
  skyline 1234bc881b feat: 月统计定时任务由每月5日调整为每月1日执行 3 недель назад
  skyline f0d46e553a feat: RSSI 信号质量按 好/中/差/极差 分级着色显示 3 недель назад
100 измененных файлов с 3432 добавлено и 211 удалено
  1. 5 0
      .gitignore
  2. 16 0
      README.md
  3. 3 0
      admin-h5/.env.capacitor
  4. 101 0
      admin-h5/android/.gitignore
  5. 2 0
      admin-h5/android/app/.gitignore
  6. 54 0
      admin-h5/android/app/build.gradle
  7. 20 0
      admin-h5/android/app/capacitor.build.gradle
  8. 21 0
      admin-h5/android/app/proguard-rules.pro
  9. 26 0
      admin-h5/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java
  10. 41 0
      admin-h5/android/app/src/main/AndroidManifest.xml
  11. 5 0
      admin-h5/android/app/src/main/java/com/kym/washadmin/MainActivity.java
  12. BIN
      admin-h5/android/app/src/main/res/drawable-land-hdpi/splash.png
  13. BIN
      admin-h5/android/app/src/main/res/drawable-land-mdpi/splash.png
  14. BIN
      admin-h5/android/app/src/main/res/drawable-land-xhdpi/splash.png
  15. BIN
      admin-h5/android/app/src/main/res/drawable-land-xxhdpi/splash.png
  16. BIN
      admin-h5/android/app/src/main/res/drawable-land-xxxhdpi/splash.png
  17. BIN
      admin-h5/android/app/src/main/res/drawable-port-hdpi/splash.png
  18. BIN
      admin-h5/android/app/src/main/res/drawable-port-mdpi/splash.png
  19. BIN
      admin-h5/android/app/src/main/res/drawable-port-xhdpi/splash.png
  20. BIN
      admin-h5/android/app/src/main/res/drawable-port-xxhdpi/splash.png
  21. BIN
      admin-h5/android/app/src/main/res/drawable-port-xxxhdpi/splash.png
  22. 34 0
      admin-h5/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
  23. 170 0
      admin-h5/android/app/src/main/res/drawable/ic_launcher_background.xml
  24. BIN
      admin-h5/android/app/src/main/res/drawable/splash.png
  25. 12 0
      admin-h5/android/app/src/main/res/layout/activity_main.xml
  26. 5 0
      admin-h5/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
  27. 5 0
      admin-h5/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
  28. BIN
      admin-h5/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
  29. BIN
      admin-h5/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
  30. BIN
      admin-h5/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
  31. BIN
      admin-h5/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
  32. BIN
      admin-h5/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
  33. BIN
      admin-h5/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
  34. BIN
      admin-h5/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
  35. BIN
      admin-h5/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
  36. BIN
      admin-h5/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
  37. BIN
      admin-h5/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
  38. BIN
      admin-h5/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
  39. BIN
      admin-h5/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
  40. BIN
      admin-h5/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
  41. BIN
      admin-h5/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
  42. BIN
      admin-h5/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
  43. 4 0
      admin-h5/android/app/src/main/res/values/ic_launcher_background.xml
  44. 7 0
      admin-h5/android/app/src/main/res/values/strings.xml
  45. 22 0
      admin-h5/android/app/src/main/res/values/styles.xml
  46. 5 0
      admin-h5/android/app/src/main/res/xml/file_paths.xml
  47. 18 0
      admin-h5/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java
  48. 36 0
      admin-h5/android/build.gradle
  49. 9 0
      admin-h5/android/capacitor.settings.gradle
  50. 22 0
      admin-h5/android/gradle.properties
  51. BIN
      admin-h5/android/gradle/wrapper/gradle-wrapper.jar
  52. 7 0
      admin-h5/android/gradle/wrapper/gradle-wrapper.properties
  53. 251 0
      admin-h5/android/gradlew
  54. 94 0
      admin-h5/android/gradlew.bat
  55. 5 0
      admin-h5/android/settings.gradle
  56. 16 0
      admin-h5/android/variables.gradle
  57. 18 0
      admin-h5/capacitor.config.ts
  58. 758 10
      admin-h5/package-lock.json
  59. 6 0
      admin-h5/package.json
  60. 171 0
      admin-h5/resources/generate-android-icons.ps1
  61. 10 0
      admin-h5/resources/icon.svg
  62. 60 1
      admin-h5/src/App.vue
  63. 60 23
      admin-h5/src/components/CustomTabBar.vue
  64. 8 2
      admin-h5/src/components/NavBar.vue
  65. 32 0
      admin-h5/src/main.js
  66. 1 2
      admin-h5/src/manifest.json
  67. 22 8
      admin-h5/src/pages/device/detail.vue
  68. 1 0
      admin-h5/src/pages/finance/settlement.vue
  69. 29 19
      admin-h5/src/pages/index/index.vue
  70. 1 0
      admin-h5/src/pages/order/detail.vue
  71. 30 6
      admin-h5/src/pages/order/list.vue
  72. 1 1
      admin-h5/src/pages/setting/device-config.vue
  73. 1 1
      admin-h5/src/pages/setting/rate-config.vue
  74. 1 0
      admin-h5/src/pages/statistics/index.vue
  75. 12 0
      admin-h5/src/uni.scss
  76. 25 0
      admin-h5/src/utils/countup.js
  77. 31 0
      admin-h5/src/utils/haptics.js
  78. 3 1
      admin-h5/vite.config.js
  79. 15 0
      admin-web-new/src/api/custom.ts
  80. 5 0
      admin-web-new/src/api/finance.ts
  81. 53 0
      admin-web-new/src/hooks/useStationScope.ts
  82. 257 0
      admin-web-new/src/layout/components/lay-navbar/StationSwitcher.vue
  83. 3 108
      admin-web-new/src/layout/components/lay-navbar/index.vue
  84. 12 9
      admin-web-new/src/views/admin/dashboard/index.vue
  85. 4 0
      admin-web-new/src/views/admin/finance/settlement.vue
  86. 4 0
      admin-web-new/src/views/admin/finance/split-record.vue
  87. 4 0
      admin-web-new/src/views/admin/finance/withdraw.vue
  88. 6 0
      admin-web-new/src/views/admin/investor/index.vue
  89. 26 1
      admin-web-new/src/views/admin/ordering/index.vue
  90. 4 0
      admin-web-new/src/views/admin/parking-coupon-record/index.vue
  91. 4 0
      admin-web-new/src/views/admin/station/account.vue
  92. 152 1
      admin-web-new/src/views/admin/station/device-remote.vue
  93. 4 0
      admin-web-new/src/views/admin/station/device.vue
  94. 16 0
      admin-web-new/src/views/admin/station/fault/index.vue
  95. 4 0
      admin-web-new/src/views/admin/statistics/daily-stat.vue
  96. 4 0
      admin-web-new/src/views/admin/statistics/month-stat.vue
  97. 273 6
      admin-web-new/src/views/admin/user/index.vue
  98. 6 12
      admin-web/src/components/form/ExtQueryForm.vue
  99. 43 0
      admin-web/src/hooks/useStationScope.ts
  100. 236 0
      admin-web/src/layout/navBars/breadcrumb/stationSwitcher.vue

+ 5 - 0
.gitignore

@@ -43,3 +43,8 @@ admin-h5/dist/
 .impeccable/
 .trae/
 skills-lock.json
+admin-web-new/.claude/settings.local.json
+admin-mp/node_modules/
+admin-app/node_modules/
+admin-app/dist/
+.claude/

+ 16 - 0
README.md

@@ -24,6 +24,22 @@ mvn clean package -DskipTests
 ```
 
 
+### admin-h5 安卓 App 打包(Capacitor)
+
+前置条件:JDK 21、Android SDK(`android/local.properties` 指向 SDK 路径)、Node 20+
+
+- 修改 H5 代码后重新打包
+```
+cd  ./car-wash-java/admin-h5
+npm run build:app        # 以 capacitor 模式构建 H5 到 dist/app
+npx cap sync android     # 同步到安卓工程
+cd android && gradlew.bat assembleDebug
+```
+- APK 输出位置:`admin-h5/android/app/build/outputs/apk/debug/app-debug.apk`
+- App 的 API 地址在 `admin-h5/.env.capacitor` 中配置(`VITE_BASE_API`),公网地址变更时修改后重新打包
+- 应用图标/启动页:源文件为 `admin-h5/resources/icon.svg`(Yeswash 品牌红底白字),修改后执行 `resources/generate-android-icons.ps1` 重新生成全套图标与启动页资源
+
+
 ### docker启动脚本
 ```
 ##jenkins

+ 3 - 0
admin-h5/.env.capacitor

@@ -0,0 +1,3 @@
+# Capacitor App 构建(npm run build:app)专用环境
+VITE_BASE_API = https://cloud.yeswash.cn/admin
+VITE_APP_TITLE = 自助洗车运营管理

+ 101 - 0
admin-h5/android/.gitignore

@@ -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 - 0
admin-h5/android/app/.gitignore

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

+ 54 - 0
admin-h5/android/app/build.gradle

@@ -0,0 +1,54 @@
+apply plugin: 'com.android.application'
+
+android {
+    namespace = "com.kym.washadmin"
+    compileSdk = rootProject.ext.compileSdkVersion
+    defaultConfig {
+        applicationId "com.kym.washadmin"
+        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")
+}

+ 20 - 0
admin-h5/android/app/capacitor.build.gradle

@@ -0,0 +1,20 @@
+// 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-haptics')
+
+}
+
+
+if (hasProperty('postBuildExtras')) {
+  postBuildExtras()
+}

+ 21 - 0
admin-h5/android/app/proguard-rules.pro

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

+ 26 - 0
admin-h5/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java

@@ -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 - 0
admin-h5/android/app/src/main/AndroidManifest.xml

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

+ 5 - 0
admin-h5/android/app/src/main/java/com/kym/washadmin/MainActivity.java

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

BIN
admin-h5/android/app/src/main/res/drawable-land-hdpi/splash.png


BIN
admin-h5/android/app/src/main/res/drawable-land-mdpi/splash.png


BIN
admin-h5/android/app/src/main/res/drawable-land-xhdpi/splash.png


BIN
admin-h5/android/app/src/main/res/drawable-land-xxhdpi/splash.png


BIN
admin-h5/android/app/src/main/res/drawable-land-xxxhdpi/splash.png


BIN
admin-h5/android/app/src/main/res/drawable-port-hdpi/splash.png


BIN
admin-h5/android/app/src/main/res/drawable-port-mdpi/splash.png


BIN
admin-h5/android/app/src/main/res/drawable-port-xhdpi/splash.png


BIN
admin-h5/android/app/src/main/res/drawable-port-xxhdpi/splash.png


BIN
admin-h5/android/app/src/main/res/drawable-port-xxxhdpi/splash.png


+ 34 - 0
admin-h5/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml

@@ -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 - 0
admin-h5/android/app/src/main/res/drawable/ic_launcher_background.xml

@@ -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
admin-h5/android/app/src/main/res/drawable/splash.png


+ 12 - 0
admin-h5/android/app/src/main/res/layout/activity_main.xml

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

+ 5 - 0
admin-h5/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml

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

+ 5 - 0
admin-h5/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml

@@ -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
admin-h5/android/app/src/main/res/mipmap-hdpi/ic_launcher.png


BIN
admin-h5/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png


BIN
admin-h5/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png


BIN
admin-h5/android/app/src/main/res/mipmap-mdpi/ic_launcher.png


BIN
admin-h5/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png


BIN
admin-h5/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png


BIN
admin-h5/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png


BIN
admin-h5/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png


BIN
admin-h5/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png


BIN
admin-h5/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png


BIN
admin-h5/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png


BIN
admin-h5/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png


BIN
admin-h5/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png


BIN
admin-h5/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png


BIN
admin-h5/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png


+ 4 - 0
admin-h5/android/app/src/main/res/values/ic_launcher_background.xml

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

+ 7 - 0
admin-h5/android/app/src/main/res/values/strings.xml

@@ -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.kym.washadmin</string>
+    <string name="custom_url_scheme">com.kym.washadmin</string>
+</resources>

+ 22 - 0
admin-h5/android/app/src/main/res/values/styles.xml

@@ -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 - 0
admin-h5/android/app/src/main/res/xml/file_paths.xml

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

+ 18 - 0
admin-h5/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java

@@ -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);
+    }
+}

+ 36 - 0
admin-h5/android/build.gradle

@@ -0,0 +1,36 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+
+buildscript {
+
+    repositories {
+        // 国内镜像优先,官方源兜底
+        maven { url 'https://maven.aliyun.com/repository/google' }
+        maven { url 'https://maven.aliyun.com/repository/public' }
+        maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
+        google()
+        mavenCentral()
+    }
+    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 {
+        maven { url 'https://maven.aliyun.com/repository/google' }
+        maven { url 'https://maven.aliyun.com/repository/public' }
+        maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
+        google()
+        mavenCentral()
+    }
+}
+
+task clean(type: Delete) {
+    delete rootProject.buildDir
+}

+ 9 - 0
admin-h5/android/capacitor.settings.gradle

@@ -0,0 +1,9 @@
+// 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-haptics'
+project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android')

+ 22 - 0
admin-h5/android/gradle.properties

@@ -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
admin-h5/android/gradle/wrapper/gradle-wrapper.jar


+ 7 - 0
admin-h5/android/gradle/wrapper/gradle-wrapper.properties

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

+ 251 - 0
admin-h5/android/gradlew

@@ -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 - 0
admin-h5/android/gradlew.bat

@@ -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 - 0
admin-h5/android/settings.gradle

@@ -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 - 0
admin-h5/android/variables.gradle

@@ -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'
+}

+ 18 - 0
admin-h5/capacitor.config.ts

@@ -0,0 +1,18 @@
+import type { CapacitorConfig } from '@capacitor/cli';
+
+const config: CapacitorConfig = {
+  appId: 'com.kym.washadmin',
+  appName: '自助洗车运营管理',
+  webDir: 'dist/app',
+  plugins: {
+    // Capacitor 8 内置 SystemBars:状态栏/手势条 inset 处理
+    SystemBars: {
+      // index.html 已含 viewport-fit=cover,提前声明避免首帧布局偏移
+      initialViewportFitValueHint: 'cover',
+      // App 固定浅色主题:状态栏用深色图标(不随系统深色模式翻转)
+      style: 'LIGHT',
+    },
+  },
+};
+
+export default config;

Разница между файлами не показана из-за своего большого размера
+ 758 - 10
admin-h5/package-lock.json


+ 6 - 0
admin-h5/package.json

@@ -7,10 +7,15 @@
     "dev:h5": "uni",
     "build:h5": "uni build --outDir ../car-wash-admin/src/main/resources/static/h5",
     "build:h5:dev": "uni build --mode development --outDir ../car-wash-admin/src/main/resources/static/h5",
+    "build:app": "uni build --mode capacitor --outDir dist/app",
     "dev": "uni",
     "build": "uni build --outDir ../car-wash-admin/src/main/resources/static/h5"
   },
   "dependencies": {
+    "@capacitor/android": "^8.5.2",
+    "@capacitor/app": "^8.1.1",
+    "@capacitor/core": "^8.5.2",
+    "@capacitor/haptics": "^8.0.2",
     "@dcloudio/uni-app": "3.0.0-alpha-5000020260104004",
     "@dcloudio/uni-app-plus": "3.0.0-alpha-5000020260104004",
     "@dcloudio/uni-components": "3.0.0-alpha-5000020260104004",
@@ -30,6 +35,7 @@
     "vue-i18n": "^9.1.9"
   },
   "devDependencies": {
+    "@capacitor/cli": "^8.5.2",
     "@dcloudio/types": "^3.4.8",
     "@dcloudio/uni-automator": "3.0.0-alpha-5000020260104004",
     "@dcloudio/uni-cli-shared": "3.0.0-alpha-5000020260104004",

+ 171 - 0
admin-h5/resources/generate-android-icons.ps1

@@ -0,0 +1,171 @@
+# Generate Android launcher icons & splash from resources/icon.svg
+# Requires: Edge (headless SVG rendering) + .NET System.Drawing (scale/clip)
+# Usage: powershell -ExecutionPolicy Bypass -File generate-android-icons.ps1
+# Re-run this script after modifying icon.svg to refresh all Android assets.
+$ErrorActionPreference = 'Stop'
+Add-Type -AssemblyName System.Drawing
+
+$root    = Split-Path -Parent $MyInvocation.MyCommand.Path   # admin-h5/resources
+$android = Join-Path $root '..\android\app\src\main\res'
+$edge    = 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe'
+$tmp     = Join-Path $env:TEMP 'yeswash_icons'
+New-Item -ItemType Directory -Force $tmp | Out-Null
+
+# Text-only master (same layout as icon.svg, no background)
+# Rendered as BLACK text on TRANSPARENT background; converted to white-on-transparent below
+$textSvg = @'
+<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 512 512">
+  <g transform="translate(54.4,0) skewX(-12)">
+    <text x="256" y="300" text-anchor="middle" font-family="'Arial Black',Arial,sans-serif" font-weight="900" font-size="123" fill="#000000" textLength="428" lengthAdjust="spacingAndGlyphs">Yeswash</text>
+  </g>
+</svg>
+'@
+Set-Content -Path (Join-Path $tmp 'text.svg') -Value $textSvg -Encoding ASCII
+Copy-Item (Join-Path $root 'icon.svg') (Join-Path $tmp 'icon.svg') -Force
+
+function Wait-File($path, $sec = 20) {
+  $deadline = (Get-Date).AddSeconds($sec)
+  while ((Get-Date) -lt $deadline) {
+    if ((Test-Path $path) -and ((Get-Item $path).Length -gt 0)) { return $true }
+    Start-Sleep -Milliseconds 500
+  }
+  return $false
+}
+
+Remove-Item "$tmp\icon.png", "$tmp\text.png" -Force -ErrorAction SilentlyContinue
+& $edge --headless=new --disable-gpu --hide-scrollbars --default-background-color=00000000 --user-data-dir="$tmp\edge1" --window-size=512,512 --screenshot="$tmp\icon.png" "file:///$(($tmp -replace '\\','/'))/icon.svg" 2>$null
+if (-not (Wait-File "$tmp\icon.png")) { throw 'icon master render failed' }
+& $edge --headless=new --disable-gpu --hide-scrollbars --default-background-color=00000000 --user-data-dir="$tmp\edge2" --window-size=1024,1024 --screenshot="$tmp\text.png" "file:///$(($tmp -replace '\\','/'))/text.svg" 2>$null
+if (-not (Wait-File "$tmp\text.png")) { throw 'text master render failed' }
+Start-Sleep -Milliseconds 500
+
+$iconBmp = [System.Drawing.Bitmap]::FromFile("$tmp\icon.png")   # 512 full logo on red
+$textBmp = [System.Drawing.Bitmap]::FromFile("$tmp\text.png")   # 1024 black text on white (text width 856/1024)
+
+# Sanity check: master must contain black text pixels (guards against stale/blank renders)
+$probe = New-Object System.Drawing.Bitmap($textBmp, 128, 128)
+$hasInk = $false
+for ($y = 0; $y -lt 128 -and -not $hasInk; $y++) {
+  for ($x = 0; $x -lt 128; $x++) {
+    if ($probe.GetPixel($x, $y).R -lt 100) { $hasInk = $true; break }
+  }
+}
+$probe.Dispose()
+if (-not $hasInk) { throw 'text master is blank - headless render failed' }
+
+# Convert black-on-white master to white-on-transparent (alpha = 255 - luminance)
+# Pixel-level conversion via inline C# (LockBits) - deterministic, no ColorMatrix quirks
+Add-Type -TypeDefinition @"
+using System;
+using System.Drawing;
+using System.Drawing.Imaging;
+using System.Runtime.InteropServices;
+public static class MaskUtil {
+  public static Bitmap WhiteMask(Bitmap src) {
+    Bitmap outBmp = new Bitmap(src.Width, src.Height, PixelFormat.Format32bppArgb);
+    Rectangle rect = new Rectangle(0, 0, src.Width, src.Height);
+    BitmapData data = src.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
+    BitmapData odata = outBmp.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb);
+    int len = data.Stride * src.Height;
+    byte[] buf = new byte[len];
+    Marshal.Copy(data.Scan0, buf, 0, len);
+    for (int i = 0; i < len; i += 4) {           // BGRA little-endian
+      int lum = (buf[i+2]*299 + buf[i+1]*587 + buf[i]*114) / 1000;
+      int a = buf[i+3] * (255 - lum) / 255;      // source alpha * (1 - luminance)
+      buf[i] = 255; buf[i+1] = 255; buf[i+2] = 255;
+      buf[i+3] = (byte)a;                        // text -> opaque white, any background -> transparent
+    }
+    Marshal.Copy(buf, 0, odata.Scan0, len);
+    src.UnlockBits(data);
+    outBmp.UnlockBits(odata);
+    return outBmp;
+  }
+}
+"@ -ReferencedAssemblies System.Drawing
+$textMask = [MaskUtil]::WhiteMask($textBmp)
+$textBmp.Dispose()
+$textBmp = $textMask   # 1024 white text on transparent (text width 856/1024)
+
+function New-Canvas([int]$w, [int]$h) {
+  $bmp = New-Object System.Drawing.Bitmap($w, $h)
+  $g = [System.Drawing.Graphics]::FromImage($bmp)
+  $g.InterpolationMode = 'HighQualityBicubic'
+  $g.SmoothingMode = 'HighQuality'
+  $g.PixelOffsetMode = 'HighQuality'
+  return @($bmp, $g)
+}
+
+function Save-Png($bmp, $path) {
+  $dir = Split-Path -Parent $path
+  if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Force $dir | Out-Null }
+  $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png)
+}
+
+# 1) Square launcher icons (full logo)
+$launcher = @{ 'mipmap-mdpi'=48; 'mipmap-hdpi'=72; 'mipmap-xhdpi'=96; 'mipmap-xxhdpi'=144; 'mipmap-xxxhdpi'=192 }
+foreach ($k in $launcher.Keys) {
+  $s = $launcher[$k]
+  $bmp, $g = New-Canvas $s $s
+  $g.DrawImage($iconBmp, 0, 0, $s, $s)
+  $g.Dispose()
+  Save-Png $bmp (Join-Path $android "$k\ic_launcher.png")
+  $bmp.Dispose()
+  Write-Output "ic_launcher: $k ${s}x${s}"
+}
+
+# 2) Round launcher icons (ellipse brush clip, anti-aliased edge)
+foreach ($k in $launcher.Keys) {
+  $s = $launcher[$k]
+  $bmp, $g = New-Canvas $s $s
+  $g.Clear([System.Drawing.Color]::Transparent)
+  $mid = New-Object System.Drawing.Bitmap($s, $s)
+  $mg = [System.Drawing.Graphics]::FromImage($mid)
+  $mg.InterpolationMode = 'HighQualityBicubic'
+  $mg.DrawImage($iconBmp, 0, 0, $s, $s)
+  $mg.Dispose()
+  $brush = New-Object System.Drawing.TextureBrush($mid)
+  $brush.WrapMode = 'Clamp'
+  $g.SmoothingMode = 'HighQuality'
+  $g.FillEllipse($brush, 0, 0, $s, $s)
+  $brush.Dispose(); $mid.Dispose(); $g.Dispose()
+  Save-Png $bmp (Join-Path $android "$k\ic_launcher_round.png")
+  $bmp.Dispose()
+  Write-Output "ic_launcher_round: $k ${s}x${s}"
+}
+
+# 3) Adaptive icon foreground (transparent, text scaled into safe zone ~60% of canvas)
+$fg = @{ 'mipmap-mdpi'=108; 'mipmap-hdpi'=162; 'mipmap-xhdpi'=216; 'mipmap-xxhdpi'=324; 'mipmap-xxxhdpi'=432 }
+foreach ($k in $fg.Keys) {
+  $s = $fg[$k]
+  $sf = (0.60 * $s) / 856.0
+  $d = [int][Math]::Round(1024 * $sf)
+  $off = [int][Math]::Round(($s - $d) / 2.0)
+  $bmp, $g = New-Canvas $s $s
+  $g.DrawImage($textBmp, $off, $off, $d, $d)
+  $g.Dispose()
+  Save-Png $bmp (Join-Path $android "$k\ic_launcher_foreground.png")
+  $bmp.Dispose()
+  Write-Output "ic_launcher_foreground: $k ${s}x${s}"
+}
+
+# 4) Splash screens (red background + white text ~58% of short side, keep original sizes)
+$splashDirs = Get-ChildItem $android -Directory | Where-Object { $_.Name -eq 'drawable' -or $_.Name -like 'drawable-land-*' -or $_.Name -like 'drawable-port-*' }
+foreach ($dir in $splashDirs) {
+  $f = Join-Path $dir.FullName 'splash.png'
+  if (-not (Test-Path $f)) { continue }
+  $probe = [System.Drawing.Image]::FromFile($f); $w = $probe.Width; $h = $probe.Height; $probe.Dispose()
+  $bmp, $g = New-Canvas $w $h
+  $g.Clear([System.Drawing.Color]::FromArgb(0xF3, 0x1C, 0x17))
+  $sf = (0.58 * [Math]::Min($w, $h)) / 856.0
+  $d = [int][Math]::Round(1024 * $sf)
+  $offX = [int][Math]::Round(($w - $d) / 2.0)
+  $offY = [int][Math]::Round(($h - $d) / 2.0)
+  $g.DrawImage($textBmp, $offX, $offY, $d, $d)
+  $g.Dispose()
+  Save-Png $bmp $f
+  $bmp.Dispose()
+  Write-Output "splash: $($dir.Name) ${w}x${h}"
+}
+
+$iconBmp.Dispose(); $textBmp.Dispose()
+Write-Output 'DONE'

+ 10 - 0
admin-h5/resources/icon.svg

@@ -0,0 +1,10 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
+  <!-- Yeswash 品牌图标:红底白字(取样自品牌原图 #F31C17) -->
+  <rect width="512" height="512" fill="#F31C17"/>
+  <g transform="translate(54.4,0) skewX(-12)">
+    <text x="256" y="300" text-anchor="middle"
+          font-family="'Arial Black',Arial,sans-serif"
+          font-weight="900" font-size="123"
+          fill="#FFFFFF" textLength="428" lengthAdjust="spacingAndGlyphs">Yeswash</text>
+  </g>
+</svg>

+ 60 - 1
admin-h5/src/App.vue

@@ -33,6 +33,9 @@ page {
   -moz-osx-font-smoothing: grayscale;
   -webkit-user-select: text;
   user-select: text;
+  /* App 体验:去除点击高亮,抑制边缘过度滚动 */
+  -webkit-tap-highlight-color: transparent;
+  overscroll-behavior-y: none;
 }
 
 /* 全局启用文本选择,uni-app H5 模式下默认不可选中 */
@@ -46,6 +49,51 @@ text {
   to { transform: rotate(360deg); }
 }
 
+/* ===== Motion Library — 全局动效关键帧库 ===== */
+@keyframes app-fade-up {
+  from { opacity: 0; transform: translateY(24rpx); }
+  to   { opacity: 1; transform: translateY(0); }
+}
+@keyframes app-fade-in {
+  from { opacity: 0; }
+  to   { opacity: 1; }
+}
+@keyframes app-pop {
+  0%   { transform: scale(0.55); opacity: 0; }
+  60%  { transform: scale(1.12); opacity: 1; }
+  100% { transform: scale(1); }
+}
+@keyframes app-slide-in-right {
+  from { opacity: 0; transform: translateX(48rpx); }
+  to   { opacity: 1; transform: translateX(0); }
+}
+@keyframes app-shimmer {
+  from { background-position: -400rpx 0; }
+  to   { background-position: 400rpx 0; }
+}
+
+/* 页面进入过渡:仅 opacity,避免 transform 生成 containing block 影响 fixed 子元素(TabBar/悬浮按钮) */
+page {
+  animation: app-fade-in $uni-duration-enter $uni-ease-decelerate;
+}
+
+/* 带底部导航的页面不做整页淡入:底部导航是 fixed 定位于页面内,整页淡入会让导航栏在切换 Tab 时一起闪一下 */
+page:has(.tab-bar) {
+  animation: none;
+}
+
+/* 入场工具类:页面内元素可主动挂载 */
+.fade-up { animation: app-fade-up $uni-duration-motion $uni-ease-out-quint backwards; }
+.fade-in { animation: app-fade-in $uni-duration-enter $uni-ease-decelerate backwards; }
+.pop-in  { animation: app-pop $uni-duration-enter $uni-ease-spring backwards; }
+
+/* ===== Skeleton Shimmer (通用骨架屏微光) ===== */
+.skeleton-shimmer {
+  background: linear-gradient(90deg, #ECEFF3 25%, #F7F9FB 37%, #ECEFF3 63%);
+  background-size: 800rpx 100%;
+  animation: app-shimmer 1.4s linear infinite;
+}
+
 /* ===== Loading Spinner (unified CSS, replaces emoji 🔄) ===== */
 .loading-spinner {
   width: 64rpx;
@@ -147,7 +195,17 @@ text {
   box-shadow: $uni-box-shadow-base;
   padding: $uni-spacing-base;
   transition: all $uni-transition-duration-base $uni-transition-timing;
+  /* 卡片交错入场:backwards 填充,动画结束后不锁定 transform,:active 反馈不受影响 */
+  animation: app-fade-up $uni-duration-motion $uni-ease-out-quint backwards;
 }
+.card:nth-child(1) { animation-delay: 0.02s; }
+.card:nth-child(2) { animation-delay: 0.07s; }
+.card:nth-child(3) { animation-delay: 0.12s; }
+.card:nth-child(4) { animation-delay: 0.17s; }
+.card:nth-child(5) { animation-delay: 0.22s; }
+.card:nth-child(6) { animation-delay: 0.27s; }
+.card:nth-child(7) { animation-delay: 0.32s; }
+.card:nth-child(8) { animation-delay: 0.37s; }
 .card:active {
   transform: translateY(-2px);
   box-shadow: $uni-box-shadow-lg;
@@ -166,7 +224,7 @@ text {
   transition: all $uni-transition-duration-base $uni-transition-timing;
 }
 .btn-primary:active {
-  transform: translateY(1px);
+  transform: scale(0.97);
   box-shadow: none;
 }
 .btn-primary:disabled {
@@ -185,6 +243,7 @@ text {
   transition: all $uni-transition-duration-base $uni-transition-timing;
 }
 .btn-secondary:active {
+  transform: scale(0.97);
   background-color: $uni-page-bg-color;
 }
 

+ 60 - 23
admin-h5/src/components/CustomTabBar.vue

@@ -4,14 +4,15 @@
       v-for="(tab, index) in tabs"
       :key="index"
       class="tab-item"
-      @click="switchTab(tab)"
+      @click="switchTab(tab, index)"
     >
       <view class="tab-inner" :class="{ active: current === index }">
-        <AppIcon
-          :name="tab.icon"
-          :size="current === index ? 24 : 22"
-          :color="current === index ? activeColor : inactiveColor"
-        />
+        <view class="tab-icon">
+          <view class="icon-stack">
+            <AppIcon class="icon-layer base" :name="tab.icon" :size="24" :color="inactiveColor" />
+            <AppIcon class="icon-layer active-layer" :name="tab.icon" :size="24" :color="activeColor" />
+          </view>
+        </view>
         <text class="tab-text" :class="{ active: current === index }">{{ tab.text }}</text>
       </view>
     </view>
@@ -19,9 +20,14 @@
 </template>
 
 <script setup>
-import { ref, watch, onMounted, getCurrentInstance } from 'vue'
+import { computed } from 'vue'
+import { hapticMedium } from '../utils/haptics.js'
+
+const props = defineProps({
+  // 由页面传入当前选中项,作为唯一数据源
+  selected: { type: Number, default: 0 }
+})
 
-const instance = getCurrentInstance()
 const activeColor = '#C6171E'
 const inactiveColor = '#8C8C8C'
 
@@ -32,22 +38,14 @@ const tabs = [
   { pagePath: '/pages/finance/index',text: '财务', icon: 'dollar' },
 ]
 
-const current = ref(0)
-
-const getCurrentRoute = () => {
-  const pages = getCurrentPages()
-  if (pages.length > 0) {
-    const route = '/' + pages[pages.length - 1].route
-    const idx = tabs.findIndex(t => t.pagePath === route)
-    if (idx !== -1) current.value = idx
-  }
-}
-
-onMounted(() => getCurrentRoute())
-
-watch(() => instance?.proxy?.$route, () => getCurrentRoute(), { immediate: true })
+// 激活态只派生自 selected,不在组件内维护可变状态:
+// Tab 页在 uni-app H5 下会被缓存复用,若本地改状态(如点击时乐观更新),离开再回来会残留成错误高亮
+const current = computed(() => props.selected)
 
-const switchTab = (tab) => {
+const switchTab = (tab, index) => {
+  // 重复点击当前 Tab 不切换、不触发触感
+  if (current.value === index) return
+  hapticMedium()
   uni.switchTab({ url: tab.pagePath })
 }
 </script>
@@ -73,6 +71,10 @@ const switchTab = (tab) => {
   display: flex;
   align-items: center;
   justify-content: center;
+  transition: transform 0.12s cubic-bezier(0.4, 0, 1, 1);
+}
+.tab-item:active {
+  transform: scale(0.92);
 }
 
 .tab-inner {
@@ -91,6 +93,41 @@ const switchTab = (tab) => {
   background: rgba(198, 23, 30, 0.06);
 }
 
+.tab-icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+/* 双层图标:灰色/红色各渲染一份,src 永不切换,避免 image 重新解码导致的闪烁 */
+/* 盒子尺寸必须与图标(AppIcon size=24 → 24rpx)一致,否则绝对定位的图标会贴在盒子左上角而偏位 */
+.icon-stack {
+  position: relative;
+  width: 24rpx;
+  height: 24rpx;
+}
+
+.icon-layer {
+  position: absolute;
+  left: 0;
+  top: 0;
+  transition: opacity 0.22s ease, transform 0.32s cubic-bezier(0.34, 1.56, 0.64, 1);
+}
+
+.icon-layer.active-layer {
+  opacity: 0;
+  transform: scale(0.55);
+}
+
+.tab-inner.active .icon-layer.base {
+  opacity: 0;
+}
+
+.tab-inner.active .icon-layer.active-layer {
+  opacity: 1;
+  transform: scale(1);
+}
+
 .tab-text {
   font-size: 22rpx;
   font-weight: 600;

+ 8 - 2
admin-h5/src/components/NavBar.vue

@@ -53,7 +53,9 @@ const handleBack = () => {
   align-items: center;
   justify-content: space-between;
   padding: 24rpx 24rpx;
-  padding-top: calc(24rpx + var(--status-bar-height, 0px));
+  /* 安全区:App 内由 Capacitor SystemBars 注入 --safe-area-inset-top;
+     浏览器端回退 env();桌面端为 0 */
+  padding-top: calc(24rpx + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)));
 }
 
 .nav-bar__left,
@@ -79,10 +81,14 @@ const handleBack = () => {
   display: flex;
   align-items: center;
   justify-content: center;
-  transition: background 0.2s;
+  transition: background 0.2s, transform 0.12s cubic-bezier(0.4, 0, 1, 1);
   margin-left: -8rpx;
 }
 
+.nav-back-btn:active {
+  transform: scale(0.88);
+}
+
 .nav-back-btn--hover {
   background: rgba(0, 0, 0, 0.06);
 }

+ 32 - 0
admin-h5/src/main.js

@@ -3,10 +3,42 @@ import App from './App.vue'
 import AppIcon from './components/AppIcon.vue'
 import NavBar from './components/NavBar.vue'
 import * as utils from './utils/index.js'
+import { hapticLight, hapticMedium } from './utils/haptics.js'
 
 // 将工具函数挂载到全局
 globalThis.utils = utils
 
+// 移动端激活 :active 按压态(iOS 需要空 touchstart 监听)
+if (typeof document !== 'undefined') {
+  document.addEventListener('touchstart', () => {}, { passive: true })
+
+  // 全局委托触感:命中按钮类元素时触发轻触感
+  // TabBar 切换(中触感)在 CustomTabBar 内单独处理,不在此列以免重复
+  const HAPTIC_SELECTORS = '.btn-primary, .btn-secondary, .nav-back-btn, .nav-btn, .retry-btn, .empty-refresh-btn'
+  document.addEventListener('click', (e) => {
+    const el = e.target && e.target.closest ? e.target.closest(HAPTIC_SELECTORS) : null
+    if (el) hapticLight()
+  }, { passive: true })
+}
+
+// 安卓物理返回键:首页/登录页退出 App,其余回退页面(仅 Capacitor 原生环境生效)
+if (typeof window !== 'undefined' && window.Capacitor?.isNativePlatform?.()) {
+  import('@capacitor/app').then(({ App }) => {
+    App.addListener('backButton', () => {
+      const pages = getCurrentPages()
+      const current = pages[pages.length - 1]
+      const route = current ? current.route : ''
+      if (pages.length <= 1 || route === 'pages/login/login') {
+        hapticMedium()
+        App.exitApp()
+      } else {
+        hapticLight()
+        uni.navigateBack()
+      }
+    })
+  })
+}
+
 export function createApp() {
   const app = createSSRApp(App)
   app.component('AppIcon', AppIcon)

+ 1 - 2
admin-h5/src/manifest.json

@@ -17,8 +17,7 @@
         },
         "usingComponents" : true,
         "router" : {
-            "mode" : "hash",
-            "base" : "/admin/h5/"
+            "mode" : "hash"
         },
         "title" : "自助洗车运营管理",
         "domain" : "",

+ 22 - 8
admin-h5/src/pages/device/detail.vue

@@ -199,17 +199,27 @@ const formatDuration = (seconds) => {
 }
 
 // ===== 信号值展示 =====
-const rssiDisplay = computed(() => {
-  const rssi = hardwareInfo.value?.network?.rssi
-  return rssi != null ? `${rssi}` : '--'
-})
+// ASU: 0-31,99 表示未知/无信号;好>24,中14~24,差9~14,极差≤9
+const rssiLevelMap = { good: '好', mid: '中', bad: '差', 'very-bad': '极差' }
+
+const getRssiLevel = (rssi) => {
+  if (rssi == null || rssi >= 99) return ''
+  if (rssi > 24) return 'good'
+  if (rssi >= 14) return 'mid'
+  if (rssi >= 9) return 'bad'
+  return 'very-bad'
+}
 
 const rssiClass = computed(() => {
+  const level = getRssiLevel(hardwareInfo.value?.network?.rssi)
+  return level ? `signal-${level}` : ''
+})
+
+const rssiDisplay = computed(() => {
   const rssi = hardwareInfo.value?.network?.rssi
-  if (rssi == null) return ''
-  if (rssi >= 20) return 'signal-good'
-  if (rssi >= 10) return 'signal-mid'
-  return 'signal-bad'
+  if (rssi == null || rssi >= 99) return '--'
+  const label = rssiLevelMap[getRssiLevel(rssi)]
+  return label ? `${rssi} (${label})` : `${rssi}`
 })
 
 const loadHardwareInfo = async () => {
@@ -432,6 +442,10 @@ onMounted(async () => {
 }
 
 .signal-bad {
+  color: #FF9800;
+}
+
+.signal-very-bad {
   color: #F44336;
 }
 

+ 1 - 0
admin-h5/src/pages/finance/settlement.vue

@@ -513,6 +513,7 @@ onMounted(async () => {
   background-color: #FFFFFF;
   border-radius: 32rpx 32rpx 0 0;
   padding: 30rpx;
+  padding-bottom: calc(30rpx + env(safe-area-inset-bottom));
   overflow-y: auto;
 }
 .detail-header {

+ 29 - 19
admin-h5/src/pages/index/index.vue

@@ -49,7 +49,7 @@
 
       <!-- Hero -->
       <view class="hero-section">
-        <text class="hero-value">¥{{ formatAmount(todayRevenue) }}</text>
+        <text class="hero-value">¥{{ formatAmount(displayRevenue) }}</text>
         <text class="hero-label">今日收入</text>
       </view>
 
@@ -75,11 +75,11 @@
       <!-- 主指标:订单 + 洗车人数 -->
       <view class="metrics-primary">
         <view class="metric-card-lg">
-          <text class="metric-lg-value">{{ todayOrders }}</text>
+          <text class="metric-lg-value">{{ Math.round(displayOrders) }}</text>
           <text class="metric-lg-label">今日订单</text>
         </view>
         <view class="metric-card-lg">
-          <text class="metric-lg-value">{{ todayWashUsers }}</text>
+          <text class="metric-lg-value">{{ Math.round(displayWashUsers) }}</text>
           <text class="metric-lg-label">洗车人数</text>
         </view>
       </view>
@@ -87,11 +87,11 @@
       <!-- 主指标:消费总额 + 今日退款 -->
       <view class="metrics-primary">
         <view class="metric-card-lg">
-          <text class="metric-lg-value">¥{{ formatAmount(todayConsumption) }}</text>
+          <text class="metric-lg-value">¥{{ formatAmount(displayConsumption) }}</text>
           <text class="metric-lg-label">消费总额</text>
         </view>
         <view class="metric-card-lg">
-          <text class="metric-lg-value">¥{{ formatAmount(todayRefund) }}</text>
+          <text class="metric-lg-value">¥{{ formatAmount(displayRefund) }}</text>
           <text class="metric-lg-label">今日退款</text>
         </view>
       </view>
@@ -214,6 +214,7 @@ import { getDashboardData, getDeviceStatus } from '../../api/stat.js'
 import { getStationList } from '../../api/station.js'
 import { loadDicts } from '../../utils/dict.js'
 import { storage, showToast, formatAmount } from '../../utils/index.js'
+import { animateNumber } from '../../utils/countup.js'
 
 const userInfo = ref(storage.get('userInfo'))
 const loading = ref(false)
@@ -224,11 +225,12 @@ const stationList = ref([])
 const currentStation = ref(null)
 const selectedStationIndex = ref(0)
 
-const todayRevenue = ref(0)
-const todayOrders = ref(0)
-const todayWashUsers = ref(0)
-const todayConsumption = ref(0)
-const todayRefund = ref(0)
+// 主指标展示值(数字滚动动画)
+const displayRevenue = ref(0)
+const displayOrders = ref(0)
+const displayWashUsers = ref(0)
+const displayConsumption = ref(0)
+const displayRefund = ref(0)
 const avgOrderPrice = ref(0)
 const avgDuration = ref(0)
 const todayMembers = ref(0)
@@ -315,11 +317,11 @@ const loadData = async (stationId) => {
 
     if (dashboardRes && dashboardRes.code === 200) {
       const data = dashboardRes.data
-      todayRevenue.value = data.todayIncome || 0
-      todayOrders.value = data.todayWashOrders || 0
-      todayWashUsers.value = data.todayWashUsers || 0
-      todayConsumption.value = data.todayConsumptionAmount || 0
-      todayRefund.value = data.todayRefundAmount || 0
+      animateNumber(displayRevenue, data.todayIncome || 0)
+      animateNumber(displayOrders, data.todayWashOrders || 0)
+      animateNumber(displayWashUsers, data.todayWashUsers || 0)
+      animateNumber(displayConsumption, data.todayConsumptionAmount || 0)
+      animateNumber(displayRefund, data.todayRefundAmount || 0)
       avgOrderPrice.value = data.avgOrderPrice || 0
       avgDuration.value = Math.round((data.avgOrderDuration || 0) / 60 * 10) / 10
       todayMembers.value = data.todayRegisteredMembers || 0
@@ -651,7 +653,9 @@ onPullDownRefresh(async () => {
 .skeleton-greeting {
   width: 180rpx;
   height: 30rpx;
-  background: #E8E8E8;
+  background: linear-gradient(90deg, #ECEFF3 25%, #F7F9FB 37%, #ECEFF3 63%);
+  background-size: 800rpx 100%;
+  animation: app-shimmer 1.4s linear infinite;
   border-radius: 8rpx;
   margin: 32rpx auto;
 }
@@ -662,7 +666,9 @@ onPullDownRefresh(async () => {
   padding: 0 0 44rpx;
 }
 .skeleton-line {
-  background: #E8E8E8;
+  background: linear-gradient(90deg, #ECEFF3 25%, #F7F9FB 37%, #ECEFF3 63%);
+  background-size: 800rpx 100%;
+  animation: app-shimmer 1.4s linear infinite;
   border-radius: 8rpx;
 }
 .skeleton-line.hero-num {
@@ -693,7 +699,9 @@ onPullDownRefresh(async () => {
 }
 .skeleton-cell {
   height: 120rpx;
-  background: #E8E8E8;
+  background: linear-gradient(90deg, #ECEFF3 25%, #F7F9FB 37%, #ECEFF3 63%);
+  background-size: 800rpx 100%;
+  animation: app-shimmer 1.4s linear infinite;
   border-radius: 24rpx;
 }
 .skeleton-cell.sm {
@@ -708,7 +716,9 @@ onPullDownRefresh(async () => {
 .skeleton-action {
   flex: 1;
   height: 170rpx;
-  background: #E8E8E8;
+  background: linear-gradient(90deg, #ECEFF3 25%, #F7F9FB 37%, #ECEFF3 63%);
+  background-size: 800rpx 100%;
+  animation: app-shimmer 1.4s linear infinite;
   border-radius: 24rpx;
 }
 

+ 1 - 0
admin-h5/src/pages/order/detail.vue

@@ -514,6 +514,7 @@ onMounted(async () => {
   left: 0;
   right: 0;
   padding: 24rpx 30rpx;
+  padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
   background: #FFFFFF;
   border-top: 1rpx solid #F0F0F0;
   box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.06);

+ 30 - 6
admin-h5/src/pages/order/list.vue

@@ -27,12 +27,15 @@
         <view class="order-header">
           <view class="order-header-row">
             <text class="order-id-label">订单号</text>
-            <text
-              class="order-status"
-              :style="getOrderStatusStyle(order.orderStatus)"
-            >
-              {{ getOrderStatusText(order.orderStatus) }}
-            </text>
+            <view class="order-header-tags">
+              <text v-if="order.newUser" class="new-user-tag">新用户</text>
+              <text
+                class="order-status"
+                :style="getOrderStatusStyle(order.orderStatus)"
+              >
+                {{ getOrderStatusText(order.orderStatus) }}
+              </text>
+            </view>
           </view>
           <view class="order-header-row">
             <view class="order-id-wrapper">
@@ -471,6 +474,14 @@ const onPullDownRefresh = () => {
   margin-bottom: 0;
 }
 
+/* 订单头部右侧徽标组 */
+.order-header-tags {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  flex-shrink: 0;
+}
+
 .order-id-label {
   font-size: 24rpx;
   color: #999999;
@@ -557,6 +568,19 @@ const onPullDownRefresh = () => {
   gap: 8rpx;
 }
 
+/* 新用户订单标签 */
+.new-user-tag {
+  font-size: 22rpx;
+  line-height: 1.4;
+  color: #C6171E;
+  background-color: rgba(198, 23, 30, 0.1);
+  border-radius: 200rpx;
+  padding: 6rpx 18rpx;
+  font-weight: 500;
+  white-space: nowrap;
+  flex-shrink: 0;
+}
+
 /* 金额信息 */
 .order-amount-info {
   display: flex;

+ 1 - 1
admin-h5/src/pages/setting/device-config.vue

@@ -158,7 +158,7 @@ const goBack = () => {
   position: fixed;
   left: 50%;
   transform: translateX(-50%);
-  bottom: 60rpx;
+  bottom: calc(60rpx + env(safe-area-inset-bottom));
   width: 110rpx;
   height: 110rpx;
   background: #C6171E;

+ 1 - 1
admin-h5/src/pages/setting/rate-config.vue

@@ -111,7 +111,7 @@ onMounted(() => {
   position: fixed;
   left: 50%;
   transform: translateX(-50%);
-  bottom: 60rpx;
+  bottom: calc(60rpx + env(safe-area-inset-bottom));
   width: 110rpx;
   height: 110rpx;
   background: #C6171E;

+ 1 - 0
admin-h5/src/pages/statistics/index.vue

@@ -663,6 +663,7 @@ onMounted(async () => {
   background-color: #FFFFFF;
   border-radius: 32rpx 32rpx 0 0;
   padding: 30rpx;
+  padding-bottom: calc(30rpx + env(safe-area-inset-bottom));
   overflow-y: auto;
 }
 .detail-header {

+ 12 - 0
admin-h5/src/uni.scss

@@ -74,6 +74,18 @@ $uni-transition-duration-base: 0.25s;
 $uni-transition-duration-slow: 0.35s;
 $uni-transition-timing: cubic-bezier(0.4, 0, 0.2, 1);
 
+/* 8.1 动效缓动体系 — Motion System */
+$uni-ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
+$uni-ease-decelerate: cubic-bezier(0, 0, 0.2, 1);   /* 进入类动画:快出缓停 */
+$uni-ease-accelerate: cubic-bezier(0.4, 0, 1, 1);   /* 退出类动画 */
+$uni-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); /* 弹性回弹 */
+$uni-ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1); /* 大位移入场 */
+
+/* 8.2 动效时长语义 */
+$uni-duration-instant: 0.12s;  /* 按压反馈 */
+$uni-duration-enter: 0.3s;     /* 页面/元素入场 */
+$uni-duration-motion: 0.45s;   /* 大元素位移 */
+
 /* 9. 聚焦环 */
 $uni-focus-ring: 0 0 0 3px rgba(198, 23, 30, 0.2);
 

+ 25 - 0
admin-h5/src/utils/countup.js

@@ -0,0 +1,25 @@
+// 数字滚动动画:用于仪表盘指标展示,缓出曲线滚动到目标值
+export function animateNumber(targetRef, endValue, duration = 900) {
+  const end = Number(endValue) || 0
+  const start = Number(targetRef.value) || 0
+  if (start === end) { targetRef.value = end; return }
+  // 非浏览器环境(无 rAF)直接落值
+  if (typeof requestAnimationFrame === 'undefined' || typeof performance === 'undefined') {
+    targetRef.value = end
+    return
+  }
+  // 令牌防并发:切换站点/下拉刷新时中止上一次未完成的动画
+  const token = (targetRef._countUpToken || 0) + 1
+  targetRef._countUpToken = token
+
+  const startTime = performance.now()
+  const step = (now) => {
+    if (targetRef._countUpToken !== token) return
+    const p = Math.min((now - startTime) / duration, 1)
+    const eased = 1 - Math.pow(1 - p, 4) // easeOutQuart
+    targetRef.value = start + (end - start) * eased
+    if (p < 1) requestAnimationFrame(step)
+    else targetRef.value = end
+  }
+  requestAnimationFrame(step)
+}

+ 31 - 0
admin-h5/src/utils/haptics.js

@@ -0,0 +1,31 @@
+// 触感反馈封装
+// App(Capacitor):Haptics 插件调用原生震动马达,轻/中/重 + 成功/警告/错误语义反馈
+// H5 浏览器:插件 Web 实现自动降级 navigator.vibrate(iOS Safari 不支持则静默忽略)
+import { Haptics, ImpactStyle, NotificationType } from '@capacitor/haptics'
+
+// 统一兜底:任何环境异常都静默,绝不影响业务流程
+const safe = (fn) => {
+  try {
+    Promise.resolve(fn()).catch(() => {})
+  } catch (e) {
+    /* ignore */
+  }
+}
+
+/** 轻触感:普通按钮点击、Tab 切换 */
+export const hapticLight = () => safe(() => Haptics.impact({ style: ImpactStyle.Light }))
+
+/** 中触感:重要操作确认、Tab 切换 */
+export const hapticMedium = () => safe(() => Haptics.impact({ style: ImpactStyle.Medium }))
+
+/** 重触感: destructive 操作(删除、退出) */
+export const hapticHeavy = () => safe(() => Haptics.impact({ style: ImpactStyle.Heavy }))
+
+/** 成功反馈:操作成功、提交完成(三连短震) */
+export const hapticSuccess = () => safe(() => Haptics.notification({ type: NotificationType.Success }))
+
+/** 警告反馈:表单校验不通过等 */
+export const hapticWarning = () => safe(() => Haptics.notification({ type: NotificationType.Warning }))
+
+/** 错误反馈:请求失败、操作出错(长震) */
+export const hapticError = () => safe(() => Haptics.notification({ type: NotificationType.Error }))

+ 3 - 1
admin-h5/vite.config.js

@@ -5,7 +5,9 @@ export default defineConfig(({ command, mode }) => {
   const isBuild = command === 'build'
 
   return {
-    base: isBuild ? '/admin/h5/' : './',
+    // Capacitor 安卓 App 从本地根路径加载资源,必须用相对路径;
+    // Web 端部署在 /admin/h5/ 下,保持绝对路径
+    base: isBuild ? (mode === 'capacitor' ? './' : '/admin/h5/') : './',
     plugins: [uni()],
     build: {
       minify: 'terser'

+ 15 - 0
admin-web-new/src/api/custom.ts

@@ -9,3 +9,18 @@ export const getCustomUserList = (params?: object) => {
 export const getRechargeList = (params?: object) => {
   return http.request<any>("get", "/custom/listRecharge", { params });
 };
+
+/** 锁定用户 */
+export const lockUser = (data: { userId: number; lockRemark?: string; lockNotice: string }) => {
+  return http.request<any>("post", "/custom/lockUser", { data });
+};
+
+/** 解锁用户 */
+export const unlockUser = (userId: number) => {
+  return http.request<any>("post", "/custom/unlockUser", { data: { userId } });
+};
+
+/** 用户锁定记录 */
+export const getUserLockRecord = (userId: number) => {
+  return http.request<any>("get", "/custom/listUserLockRecord", { params: { userId } });
+};

+ 5 - 0
admin-web-new/src/api/finance.ts

@@ -30,6 +30,11 @@ export const triggerSettlement = () => {
   return http.request<any>("post", "/finance/triggerSettlement");
 };
 
+/** 发起退款申请 */
+export const applyRefund = (data: { userId: number; reason?: string }) => {
+  return http.request<any>("post", "/finance/applyRefund", { data });
+};
+
 /** 确认结算 */
 export const confirmSettlement = (id: number) => {
   return http.request<any>("post", "/finance/confirmSettlement", { data: { id } });

+ 53 - 0
admin-web-new/src/hooks/useStationScope.ts

@@ -0,0 +1,53 @@
+import { onMounted, onUnmounted } from "vue";
+import { emitter } from "@/utils/mitt";
+
+//Session 读取(与顶栏站点切换器写入格式一致:sessionStorage + JSON)
+const getCurrentStationId = () => {
+  const value = sessionStorage.getItem("currentStationId");
+  if (!value) return null;
+  try {
+    return JSON.parse(value);
+  } catch {
+    return null;
+  }
+};
+
+/**
+ * 站点范围组合式函数:页面站点筛选与全局"当前站点"联动
+ * 1. 页面初始化时,若站点筛选为空则默认选中全局当前站点(onMounted 中先于页面自身加载执行)
+ * 2. 全局站点切换时(顶栏站点选择器),联动更新页面筛选并刷新列表
+ *
+ * @param formQuery 页面查询条件对象(需包含 stationId 字段)
+ * @param loadData 页面数据加载方法,切换站点时以 refresh=true 重新加载
+ * @param options.follow 是否响应全局站点切换,默认 true
+ */
+export const useStationScope = (
+  formQuery: any,
+  loadData?: (refresh?: boolean) => void,
+  options?: { follow?: boolean }
+) => {
+  const follow = options?.follow !== false;
+
+  const onStationChange = (stationId: any) => {
+    if (!follow || !stationId) return;
+    //站点未变化时不重复加载
+    if (formQuery.stationId === stationId) return;
+    formQuery.stationId = stationId;
+    if (loadData) loadData(true);
+  };
+
+  onMounted(() => {
+    //初始化:站点筛选为空时默认选中全局当前站点
+    if (!formQuery.stationId) {
+      const currentStationId = getCurrentStationId();
+      if (currentStationId) {
+        formQuery.stationId = currentStationId;
+      }
+    }
+    emitter.on("stationChangeRefresh", onStationChange);
+  });
+
+  onUnmounted(() => {
+    emitter.off("stationChangeRefresh", onStationChange);
+  });
+};

+ 257 - 0
admin-web-new/src/layout/components/lay-navbar/StationSwitcher.vue

@@ -0,0 +1,257 @@
+<script setup lang="ts">
+import { reactive, computed, onMounted } from "vue";
+import { emitter } from "@/utils/mitt";
+import { getStationList } from "@/api/station";
+import Location from "~icons/ri/map-pin-line";
+
+defineOptions({ name: "StationSwitcher" });
+
+const Session = {
+  get: (key: string) => {
+    const value = sessionStorage.getItem(key);
+    return value ? JSON.parse(value) : null;
+  },
+  set: (key: string, value: any) => {
+    sessionStorage.setItem(key, JSON.stringify(value));
+  }
+};
+
+const state = reactive({
+  stationList: [] as Array<any>,
+  currentStationId: null as any,
+  options: [] as Array<any>,
+  loading: true,
+  open: false
+});
+
+const placeholder = computed(() => {
+  if (state.loading) return "加载站点中";
+  if (state.stationList.length === 0) return "暂无站点";
+  return "选择站点";
+});
+
+//站点切换:持久化并广播,各页面通过 useStationScope 联动刷新(不整页刷新)
+const handleStationChange = (stationId: string) => {
+  const station = state.stationList.find((k: any) => k.stationId === stationId);
+  if (station) {
+    Session.set("currentStationId", stationId);
+    emitter.emit("stationChangeRefresh", stationId);
+  }
+};
+
+const loadStationList = () => {
+  getStationList({ pageNum: 1, pageSize: 1024 })
+    .then((res: any) => {
+      //必须先结束 loading 再赋值选项:element-plus 在 loading 态下过滤结果为空,
+      //会导致选中项 label 解析失败、回退显示原始站点ID
+      state.loading = false;
+      const { list } = res || {};
+      state.stationList = list || [];
+      state.options = state.stationList.map((k: any) => ({
+        value: k.stationId,
+        label: k.stationName
+      }));
+      if (state.stationList.length > 0) {
+        //优先恢复上次选择的站点,其次默认第一个
+        const savedId = Session.get("currentStationId");
+        const current =
+          state.stationList.find((k: any) => k.stationId === savedId) || state.stationList[0];
+        state.currentStationId = current.stationId;
+        setTimeout(() => {
+          Session.set("currentStationId", current.stationId);
+          emitter.emit("stationChangeRefresh", current.stationId);
+        }, 200);
+      }
+    })
+    .catch(() => {
+      //列表加载失败由请求拦截器提示
+    })
+    .finally(() => {
+      state.loading = false;
+    });
+};
+
+onMounted(() => {
+  loadStationList();
+});
+</script>
+
+<template>
+  <div
+    id="header-station"
+    class="station-switcher"
+    :class="{ 'is-open': state.open, 'is-empty': !state.loading && state.stationList.length === 0 }"
+    title="切换当前查看的站点,页面数据范围会跟随变化"
+  >
+    <el-select-v2
+      v-model="state.currentStationId"
+      :options="state.options"
+      :loading="state.loading"
+      :loading-text="'正在加载站点…'"
+      :no-data-text="'没有匹配的站点'"
+      :disabled="!state.loading && state.stationList.length === 0"
+      :placeholder="placeholder"
+      filterable
+      popper-class="station-select-popper"
+      class="station-select"
+      @change="handleStationChange"
+      @visible-change="state.open = $event"
+    >
+      <template #prefix>
+        <IconifyIconOffline :icon="Location" :size="15" />
+      </template>
+      <template #header>
+        <span>站点范围</span>
+        <span>共 {{ state.stationList.length }} 个站点</span>
+      </template>
+    </el-select-v2>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.station-switcher {
+  display: flex;
+  align-items: center;
+  height: 48px;
+  padding: 0 4px;
+
+  .station-select {
+    width: 180px;
+
+    :deep(.el-select__wrapper) {
+      height: 34px;
+      min-height: 34px;
+      padding: 0 8px;
+      border-radius: 8px;
+      background: transparent;
+      box-shadow: none;
+      transition: background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+
+      &.is-hovering,
+      &.is-focused {
+        background: var(--el-fill-color-light);
+      }
+
+      &.is-disabled {
+        background: transparent;
+      }
+    }
+
+    :deep(.el-select__prefix) {
+      display: flex;
+      align-items: center;
+      margin-right: 6px;
+      color: inherit;
+      transition: color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+    }
+
+    :deep(.el-select__placeholder),
+    :deep(.el-select__input) {
+      color: var(--el-text-color-primary);
+      font-size: 14px;
+      font-weight: 500;
+      transition: color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+    }
+
+    //未选中任何站点时的占位:退一档
+    :deep(.el-select__placeholder.is-transparent) {
+      color: var(--el-text-color-placeholder);
+      font-weight: 400;
+    }
+
+    :deep(.el-select__caret) {
+      color: var(--el-text-color-regular);
+      opacity: 0.6;
+      transform-origin: center;
+      transition:
+        transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
+        opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1),
+        color 0.2s;
+    }
+  }
+
+  //悬停时文字与图标转为品牌色(禁用态除外)
+  &:not(.is-empty) .station-select:hover {
+    :deep(.el-select__wrapper .el-select__placeholder),
+    :deep(.el-select__wrapper .el-select__input),
+    :deep(.el-select__wrapper .el-select__prefix),
+    :deep(.el-select__wrapper .el-select__caret) {
+      color: var(--el-color-primary);
+      opacity: 1;
+    }
+  }
+
+  //展开时箭头翻转
+  &.is-open .station-select {
+    :deep(.el-select__caret) {
+      transform: rotate(180deg);
+      opacity: 1;
+      color: var(--el-color-primary);
+    }
+  }
+
+  //禁用态(无站点可用)
+  &:deep(.el-select__wrapper.is-disabled .el-select__placeholder),
+  &:deep(.el-select__wrapper.is-disabled .el-select__prefix),
+  &:deep(.el-select__wrapper.is-disabled .el-select__caret) {
+    color: var(--el-text-color-disabled);
+    opacity: 1;
+  }
+}
+</style>
+
+<style lang="scss">
+// 下拉面板(teleported,需全局样式)
+.station-select-popper {
+  .el-select-dropdown {
+    padding: 6px;
+    border: none;
+    border-radius: 12px;
+    box-shadow:
+      0 10px 15px -3px rgba(0, 0, 0, 0.08),
+      0 4px 6px -4px rgba(0, 0, 0, 0.06);
+  }
+
+  .el-select-dropdown__header {
+    display: flex;
+    justify-content: space-between;
+    padding: 4px 10px 6px;
+    font-size: 12px;
+    font-weight: 500;
+    color: var(--el-text-color-secondary);
+
+    span:last-child {
+      font-weight: 400;
+      color: var(--el-text-color-placeholder);
+    }
+  }
+
+  .el-select-dropdown__list {
+    padding: 2px;
+  }
+
+  .el-select-dropdown__item {
+    height: 34px;
+    margin: 2px 0;
+    padding: 0 10px;
+    border-radius: 8px;
+    font-size: 14px;
+  }
+
+  .el-select-dropdown__item.is-hovering {
+    background: var(--el-fill-color-light);
+  }
+
+  //当前站点:品牌色 + 加粗标记「你在这里」
+  .el-select-dropdown__item.is-selected {
+    color: var(--el-color-primary);
+    font-weight: 600;
+  }
+
+  .el-select-dropdown__empty {
+    padding: 14px 10px;
+    font-size: 13px;
+    color: var(--el-text-color-placeholder);
+  }
+}
+</style>

+ 3 - 108
admin-web-new/src/layout/components/lay-navbar/index.vue

@@ -11,10 +11,7 @@ import AccountSettingsIcon from "~icons/ri/user-settings-line";
 import LogoutCircleRLine from "~icons/ri/logout-circle-r-line";
 import Setting from "~icons/ri/settings-3-line";
 import Check from "~icons/ep/check";
-import Location from "~icons/ri/map-pin-line";
-import { getStationList } from "@/api/station";
-import { emitter } from "@/utils/mitt";
-import { reactive, computed, onMounted } from "vue";
+import StationSwitcher from "./StationSwitcher.vue";
 
 const {
   layout,
@@ -30,73 +27,6 @@ const {
 } = useNav();
 
 const { t, locale, translationCh, translationEn } = useTranslationLang();
-
-// Session 工具
-const Session = {
-  get: (key: string) => {
-    const value = sessionStorage.getItem(key);
-    return value ? JSON.parse(value) : null;
-  },
-  set: (key: string, value: any) => {
-    sessionStorage.setItem(key, JSON.stringify(value));
-  }
-};
-
-const state = reactive({
-  stationList: [] as any[],
-  currentStationId: null as string | null,
-  currentStationName: '' as string
-});
-
-// 加载站点列表
-const loadStationList = () => {
-  getStationList({ pageNum: 1, pageSize: 1024 }).then((res: any) => {
-    const list = res?.data?.list || res?.list || res?.data || res || [];
-    state.stationList = list;
-    
-    // 获取当前选中的站点
-    const savedStationId = Session.get("currentStationId");
-    if (savedStationId && list.length > 0) {
-      const currentStation = list.find((s: any) => s.stationId === savedStationId);
-      if (currentStation) {
-        state.currentStationId = currentStation.stationId;
-        state.currentStationName = currentStation.stationName;
-        emitter.emit("stationChangeRefresh", currentStation.stationId);
-      } else {
-        // 如果保存的站点不在列表中,选择第一个并持久化
-        state.currentStationId = list[0]?.stationId;
-        state.currentStationName = list[0]?.stationName;
-        Session.set("currentStationId", list[0]?.stationId);
-        emitter.emit("stationChangeRefresh", list[0]?.stationId);
-      }
-    } else if (list.length > 0) {
-      // 默认选择第一个站点并持久化
-      state.currentStationId = list[0]?.stationId;
-      state.currentStationName = list[0]?.stationName;
-      Session.set("currentStationId", list[0]?.stationId);
-      emitter.emit("stationChangeRefresh", list[0]?.stationId);
-    }
-  }).catch(() => {
-    console.warn('站点列表加载失败');
-  });
-};
-
-// 切换站点
-const handleStationChange = (station: any) => {
-  state.currentStationId = station.stationId;
-  state.currentStationName = station.stationName;
-  Session.set("currentStationId", station.stationId);
-  emitter.emit("stationChangeRefresh", station.stationId);
-
-  // 刷新页面以加载新站点数据
-  setTimeout(() => {
-    window.location.reload();
-  }, 300);
-};
-
-onMounted(() => {
-  loadStationList();
-});
 </script>
 
 <template>
@@ -110,26 +40,8 @@ onMounted(() => {
 
     <div v-if="layout === 'vertical'" class="vertical-header-right">
       <!-- 站点切换 -->
-      <el-dropdown id="header-station" trigger="click" @command="handleStationChange">
-        <div class="station-switcher navbar-bg-hover cursor-pointer outline-hidden flex items-center">
-          <IconifyIconOffline :icon="Location" class="station-icon" />
-          <span class="dark:text-white truncate">{{ state.currentStationName || '选择站点' }}</span>
-          <i class="ri-arrow-down-s-line ml-1" />
-        </div>
-        <template #dropdown>
-          <el-dropdown-menu>
-            <el-dropdown-item 
-              v-for="station in state.stationList" 
-              :key="station.stationId" 
-              :command="station"
-              :disabled="state.currentStationId === station.stationId"
-            >
-              {{ station.stationName }}
-            </el-dropdown-item>
-          </el-dropdown-menu>
-        </template>
-      </el-dropdown>
-      
+      <StationSwitcher />
+
       <!-- 菜单搜索 -->
       <LaySearch id="header-search" />
       <!-- 国际化 -->
@@ -205,23 +117,6 @@ onMounted(() => {
 </template>
 
 <style lang="scss" scoped>
-.station-switcher {
-  height: 48px;
-  padding: 0 12px;
-  min-width: 120px;
-  max-width: 200px;
-
-  .station-icon {
-    margin-right: 6px;
-    font-size: 16px;
-    flex-shrink: 0;
-  }
-
-  span {
-    font-size: 14px;
-  }
-}
-
 .navbar {
   width: 100%;
   height: 48px;

+ 12 - 9
admin-web-new/src/views/admin/dashboard/index.vue

@@ -123,7 +123,7 @@ const initLineChart = (dataList: Array<any>) => {
     item.seq = Number(item.statTime.join(""));
   });
 
-  state.homeOneExtra.totalIncome = dataList.reduce((k, v) => k + v.totalAmount, 0);
+  state.homeOneExtra.totalIncome = dataList.reduce((k, v) => k + v.amountReceived, 0);
   state.homeOneExtra.totalWashOrders = dataList.reduce((k, v) => k + v.totalOrders, 0);
 
   dataList.sort((a, b) => a.seq - b.seq);
@@ -141,13 +141,13 @@ const initLineChart = (dataList: Array<any>) => {
       trigger: "axis",
       formatter: (params: any) => {
         const bar = params.find((p: any) => p.seriesName === "洗车量");
-        const line = params.find((p: any) => p.seriesName === "总金额");
+        const line = params.find((p: any) => p.seriesName === "订单实收金额");
         const barVal = bar ? `${bar.value} 次` : "";
         const lineVal = line ? `¥${line.value}` : "";
         return `${params[0].axisValue}<br/>${barVal}<br/>${lineVal}`;
       }
     },
-    legend: { data: ["洗车量", "总金额"], right: 10, top: 5 },
+    legend: { data: ["洗车量", "订单实收金额"], right: 10, top: 5 },
     xAxis: {
       data: dataList.map(k => k.startTime),
       axisLine: { lineStyle: { color: state.charts.color } }
@@ -182,13 +182,13 @@ const initLineChart = (dataList: Array<any>) => {
         }
       },
       {
-        name: "总金额",
+        name: "订单实收金额",
         type: "line",
         symbolSize: 6,
         symbol: "circle",
         smooth: true,
         yAxisIndex: 1,
-        data: dataList.map(k => (k.totalAmount / 100).toFixed(2)),
+        data: dataList.map(k => (k.amountReceived / 100).toFixed(2)),
         lineStyle: { color: "#409EFF" },
         itemStyle: { color: "#409EFF", borderColor: "#409EFF" }
       }
@@ -309,9 +309,12 @@ const loadAll = () => {
 
 onMounted(() => {
   emitter.on("stationChangeRefresh", (stationId) => {
-    state.currentStationId = stationId;
-    state.myCharts = [];
-    nextTick(() => loadAll());
+    //站点未变化时不重复加载
+    if (stationId && stationId !== state.currentStationId) {
+      state.currentStationId = stationId;
+      state.myCharts = [];
+      nextTick(() => loadAll());
+    }
   });
 
   const currentStationId = Session.get("currentStationId");
@@ -388,7 +391,7 @@ watch(
               style="width: 280px"
             />
             <div class="chart-summary">
-              总收益:
+              总订单实收金额:
               <el-tag type="success" effect="plain" size="small">{{ fmtMoney(state.homeOneExtra.totalIncome) }}元</el-tag>
               总订单:
               <el-tag type="danger" effect="plain" size="small">{{ state.homeOneExtra.totalWashOrders }}笔</el-tag>

+ 4 - 0
admin-web-new/src/views/admin/finance/settlement.vue

@@ -3,6 +3,7 @@ import { reactive, onMounted, ref, nextTick } from "vue";
 import { getSettlementList, triggerSettlement, confirmSettlement } from "@/api/finance";
 import { getStationList } from "@/api/station";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { useStationScope } from "@/hooks/useStationScope";
 import { ElMessage, ElMessageBox } from "element-plus";
 import { getDictOptions, formatDict, getDictColor } from "@/utils/dict";
 
@@ -51,6 +52,9 @@ const state = reactive({
   get statusOptions() { return getDictOptions("Settlement.status"); }
 });
 
+//站点筛选与全局当前站点联动(回调懒引用,规避 loadData 声明顺序的 TDZ 错误)
+useStationScope(state.formQuery, (refresh?: boolean) => loadData(refresh));
+
 onMounted(() => {
   loadStationOptions();
   loadData();

+ 4 - 0
admin-web-new/src/views/admin/finance/split-record.vue

@@ -3,6 +3,7 @@ import { reactive, onMounted, ref, nextTick } from "vue";
 import { getSplitRecordList } from "@/api/finance";
 import { getStationList } from "@/api/station";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { useStationScope } from "@/hooks/useStationScope";
 import { ElMessage } from "element-plus";
 import { ExtDLabel, ExtDSelect } from "@/components/ExtForm";
 
@@ -41,6 +42,9 @@ const state = reactive({
   }
 });
 
+//站点筛选与全局当前站点联动(回调懒引用,规避 loadData 声明顺序的 TDZ 错误)
+useStationScope(state.formQuery, (refresh?: boolean) => loadData(refresh));
+
 onMounted(() => {
   loadStationOptions();
   loadData();

+ 4 - 0
admin-web-new/src/views/admin/finance/withdraw.vue

@@ -2,6 +2,7 @@
 import { reactive, onMounted, ref, nextTick } from "vue";
 import { getWithdrawList } from "@/api/finance";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { useStationScope } from "@/hooks/useStationScope";
 import { http } from "@/utils/http";
 import { ElMessage, ElMessageBox } from "element-plus";
 import { getDictOptions, formatDict, getDictColor } from "@/utils/dict";
@@ -46,6 +47,9 @@ const state = reactive({
   get paymentStatusOptions() { return getDictOptions("WithdrawnRecord.paymentStatus"); }
 });
 
+//站点筛选与全局当前站点联动(回调懒引用,规避 loadData 声明顺序的 TDZ 错误)
+useStationScope(state.formQuery, (refresh?: boolean) => loadData(refresh));
+
 onMounted(() => {
   loadData();
   nextTick(() => {

+ 6 - 0
admin-web-new/src/views/admin/investor/index.vue

@@ -2,6 +2,7 @@
 import { reactive, onMounted, ref, nextTick } from "vue";
 import { getInvestorList, removeInvestor } from "@/api/investor";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { useStationScope } from "@/hooks/useStationScope";
 import { ElMessage, ElMessageBox } from "element-plus";
 import InvestorDialog from "./dialog.vue";
 
@@ -18,6 +19,7 @@ interface ColumnItem {
 
 const state = reactive({
   formQuery: {
+    stationId: "",
     stationName: "",
     contactName: ""
   },
@@ -44,6 +46,9 @@ const state = reactive({
   }
 });
 
+//站点筛选与全局当前站点联动(回调懒引用,规避 loadData 声明顺序的 TDZ 错误)
+useStationScope(state.formQuery, (refresh?: boolean) => loadData(refresh));
+
 onMounted(() => {
   loadData();
   nextTick(() => {
@@ -88,6 +93,7 @@ const handleSearch = () => {
 
 const handleReset = () => {
   state.formQuery = {
+    stationId: "",
     stationName: "",
     contactName: ""
   };

+ 26 - 1
admin-web-new/src/views/admin/ordering/index.vue

@@ -36,7 +36,7 @@ const state = reactive({
       { label: "消费站点", prop: "stationName", width: 140 },
       { label: "归属站点", prop: "userStationName", width: 140 },
       { label: "订单号", prop: "orderId", width: 220 },
-      { label: "用户手机号", prop: "mobilePhone", width: 120 },
+      { label: "用户手机号", prop: "mobilePhone", width: 180 },
       { label: "设备编号", prop: "shortId", width: 100 },
       { label: "开始时间", prop: "startTime", width: 160 },
       { label: "结束时间", prop: "endTime", width: 160 },
@@ -262,6 +262,20 @@ const processDetailData = (detail: any[]) => {
             <template v-if="isMoneyField(col.prop)">
               ¥{{ formatMoney(row[col.prop]) }}
             </template>
+            <template v-else-if="col.prop === 'mobilePhone'">
+              <div class="phone-cell">
+                <span>{{ row.mobilePhone }}</span>
+                <el-tag
+                  v-if="row.newUser"
+                  class="new-user-tag"
+                  type="danger"
+                  size="small"
+                  effect="light"
+                >
+                  新用户
+                </el-tag>
+              </div>
+            </template>
             <template v-else-if="col.prop === 'orderStatus'">
               <ExtDLabel type="Order.status" :model-value="row.orderStatus" />
             </template>
@@ -336,6 +350,17 @@ const processDetailData = (detail: any[]) => {
   padding: 10px;
 }
 
+.phone-cell {
+  display: flex;
+  align-items: center;
+  overflow: hidden;
+}
+
+.new-user-tag {
+  margin-left: 6px;
+  flex-shrink: 0;
+}
+
 .no-detail {
   padding: 20px;
   text-align: center;

+ 4 - 0
admin-web-new/src/views/admin/parking-coupon-record/index.vue

@@ -3,6 +3,7 @@ import { reactive, onMounted, ref, nextTick } from "vue";
 import { getParkingCouponRecordPage } from "@/api/parking-coupon";
 import { getStationList } from "@/api/station";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { useStationScope } from "@/hooks/useStationScope";
 
 defineOptions({ name: "AdminParkingCouponRecord" });
 
@@ -36,6 +37,9 @@ const state = reactive({
   }
 });
 
+//站点筛选与全局当前站点联动(回调懒引用,规避 loadData 声明顺序的 TDZ 错误)
+useStationScope(state.formQuery, (refresh?: boolean) => loadData(refresh));
+
 onMounted(() => {
   loadStations();
   loadData();

+ 4 - 0
admin-web-new/src/views/admin/station/account.vue

@@ -2,6 +2,7 @@
 import { reactive, onMounted, ref, nextTick } from "vue";
 import { getStationAccount, applyWithdrawn, getStationList } from "@/api/station";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { useStationScope } from "@/hooks/useStationScope";
 import { ElMessage, ElMessageBox } from "element-plus";
 
 defineOptions({
@@ -36,6 +37,9 @@ const state = reactive({
   stationOptions: [] as Array<{ stationId: string; stationName: string }>
 });
 
+//站点筛选与全局当前站点联动(回调懒引用,规避 loadData 声明顺序的 TDZ 错误)
+useStationScope(state.formQuery, (refresh?: boolean) => loadData(refresh));
+
 onMounted(() => {
   loadStationOptions();
   loadData();

+ 152 - 1
admin-web-new/src/views/admin/station/device-remote.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts">
 import { reactive, computed } from "vue";
 import { ElMessage, ElMessageBox } from "element-plus";
-import { Refresh, Reading, ChatDotSquare, CloseBold, SwitchButton, Loading } from "@element-plus/icons-vue";
+import { Refresh, Reading, ChatDotSquare, CloseBold, SwitchButton, Loading, Odometer } from "@element-plus/icons-vue";
 import { deviceRemoteApi } from "@/api/station";
 
 defineOptions({ name: "DeviceRemoteDialog" });
@@ -16,6 +16,7 @@ const initState = () => ({
   uptimeMs: "",
   hasPa: false,
   orderInfo: null as any,
+  hardwareInfo: null as any,
   showConfigEditor: false,
   configForm: {} as any,
   priceDialog: {
@@ -74,6 +75,33 @@ const temperatureDisplay = computed(() => {
   return state.temperatureChip != null ? `${state.temperatureChip}°C` : "--";
 });
 
+// 信号质量等级(ASU: 0-31,99 表示未知/无信号;好>24,中14~24,差9~14,极差≤9)
+const rssiLevelMap: Record<string, string> = { good: "好", mid: "中", bad: "差", "very-bad": "极差" };
+
+const getRssiLevel = (rssi: number | null | undefined) => {
+  if (rssi == null || rssi >= 99) return "";
+  if (rssi > 24) return "good";
+  if (rssi >= 14) return "mid";
+  if (rssi >= 9) return "bad";
+  return "very-bad";
+};
+
+const rssiLevel = computed(() => {
+  const level = getRssiLevel(state.hardwareInfo?.network?.rssi);
+  return level ? `signal-${level}` : "";
+});
+
+const rssiDisplay = computed(() => {
+  const rssi = state.hardwareInfo?.network?.rssi;
+  if (rssi == null || rssi >= 99) return "--";
+  return `${rssi}`;
+});
+
+const rssiLabel = computed(() => {
+  const level = getRssiLevel(state.hardwareInfo?.network?.rssi);
+  return level ? rssiLevelMap[level] : "";
+});
+
 const api = (url: string, data: any = {}) => {
   return deviceRemoteApi(url, { productKey, deviceName, ...data });
 };
@@ -184,6 +212,19 @@ const confirmPriceEdit = async () => {
   }
 };
 
+const handleReadHardwareInfo = async () => {
+  state.loading = true;
+  try {
+    const res: any = await api("queryHardwareInfo");
+    state.hardwareInfo = res || null;
+    ElMessage.success("信号值读取成功");
+  } catch (e) {
+    ElMessage.error("读取信号值失败");
+  } finally {
+    state.loading = false;
+  }
+};
+
 const handleShowMsgbox = () => {
   state.msgDialog.title = "";
   state.msgDialog.content = "";
@@ -310,6 +351,9 @@ defineExpose({ open });
           <el-button class="action-btn" type="primary" plain @click="handleReadConfig" :loading="state.loading">
             <el-icon><Reading /></el-icon> 读取配置
           </el-button>
+          <el-button class="action-btn" type="info" plain @click="handleReadHardwareInfo" :loading="state.loading">
+            <el-icon><Odometer /></el-icon> 读取信号值
+          </el-button>
           <el-button class="action-btn" type="warning" plain @click="handleShowMsgbox" :loading="state.loading">
             <el-icon><ChatDotSquare /></el-icon> 屏幕消息
           </el-button>
@@ -321,6 +365,48 @@ defineExpose({ open });
           </el-button>
         </div>
 
+        <!-- 信号值信息(点击「读取信号值」后显示) -->
+        <div class="signal-info-card" v-if="state.hardwareInfo">
+          <div class="signal-title">
+            <span>设备信号值</span>
+            <el-button size="small" text type="primary" @click="state.hardwareInfo = null">收起</el-button>
+          </div>
+          <div class="signal-grid">
+            <div class="signal-item">
+              <span class="signal-label">网络类型</span>
+              <span class="signal-value">{{ state.hardwareInfo.network?.type || '--' }}</span>
+            </div>
+            <div class="signal-item">
+              <span class="signal-label">信号质量(RSSI)</span>
+              <span class="signal-value" :class="rssiLevel">{{ rssiDisplay }}<span v-if="rssiLabel" class="signal-level-tag">{{ rssiLabel }}</span></span>
+            </div>
+            <div class="signal-item">
+              <span class="signal-label">误码率(BER)</span>
+              <span class="signal-value">{{ state.hardwareInfo.network?.ber ?? '--' }}</span>
+            </div>
+            <div class="signal-item">
+              <span class="signal-label">IMEI</span>
+              <span class="signal-value">{{ state.hardwareInfo.network?.imei || '--' }}</span>
+            </div>
+            <div class="signal-item">
+              <span class="signal-label">硬件版本</span>
+              <span class="signal-value">{{ state.hardwareInfo.board?.hard_version || '--' }}</span>
+            </div>
+            <div class="signal-item">
+              <span class="signal-label">固件版本</span>
+              <span class="signal-value">{{ state.hardwareInfo.board?.firmware_version || '--' }}</span>
+            </div>
+            <div class="signal-item">
+              <span class="signal-label">编译时间</span>
+              <span class="signal-value">{{ state.hardwareInfo.board?.build_time || '--' }}</span>
+            </div>
+            <div class="signal-item">
+              <span class="signal-label">芯片ID</span>
+              <span class="signal-value chip-id">{{ state.hardwareInfo.board?.chip_id || '--' }}</span>
+            </div>
+          </div>
+        </div>
+
         <!-- 订单信息(仅忙碌时显示) -->
         <div class="order-info-card" v-if="state.deviceState === 'busy' && state.orderInfo">
           <div class="order-title">当前订单</div>
@@ -591,4 +677,69 @@ defineExpose({ open });
     margin-bottom: 12px;
   }
 }
+
+.signal-info-card {
+  margin-top: 8px;
+  padding: 12px 16px;
+  background: #f5f7fa;
+  border-radius: 8px;
+
+  .signal-title {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    font-weight: 600;
+    margin-bottom: 10px;
+    color: #333;
+  }
+
+  .signal-grid {
+    display: grid;
+    grid-template-columns: repeat(2, 1fr);
+    gap: 6px 24px;
+  }
+
+  .signal-item {
+    display: flex;
+    justify-content: space-between;
+    font-size: 13px;
+    padding: 5px 0;
+    border-bottom: 1px dashed #e4e7ed;
+
+    .signal-label {
+      color: #909399;
+      flex-shrink: 0;
+      margin-right: 12px;
+    }
+
+    .signal-value {
+      color: #303133;
+      font-weight: 600;
+      word-break: break-all;
+      text-align: right;
+    }
+
+    .signal-value.chip-id {
+      font-size: 12px;
+      max-width: 220px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+  }
+
+  .signal-good { color: #67c23a; }
+  .signal-mid { color: #e6a23c; }
+  .signal-bad { color: #ff9800; }
+  .signal-very-bad { color: #f56c6c; }
+
+  .signal-level-tag {
+    margin-left: 6px;
+    font-size: 11px;
+    font-weight: 600;
+    padding: 1px 6px;
+    border-radius: 4px;
+    border: 1px solid currentColor;
+  }
+}
 </style>

+ 4 - 0
admin-web-new/src/views/admin/station/device.vue

@@ -3,6 +3,7 @@ import { reactive, onMounted, ref, nextTick, watch } from "vue";
 import { getStationDeviceList, removeDevice, getStationList } from "@/api/station";
 import { stopDevice } from "@/api/device";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { useStationScope } from "@/hooks/useStationScope";
 import { useRoute, useRouter } from "vue-router";
 import { ElMessage, ElMessageBox } from "element-plus";
 import { ExtDLabel, ExtDSelect } from "@/components/ExtForm";
@@ -77,6 +78,9 @@ watch(
   }
 );
 
+//站点筛选与全局当前站点联动(回调懒引用,规避 loadData 声明顺序的 TDZ 错误)
+useStationScope(state.formQuery, (refresh?: boolean) => loadData(refresh));
+
 onMounted(() => {
   loadStationOptions();
   if (state.stationId) {

+ 16 - 0
admin-web-new/src/views/admin/station/fault/index.vue

@@ -3,6 +3,7 @@ import { reactive, onMounted, ref, nextTick, onBeforeUnmount } from "vue";
 import { getSubscriberList, generateQrcode, unsubscribe, getFaultRecords } from "@/api/fault-subscriber";
 import { getStationList } from "@/api/station";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { useStationScope } from "@/hooks/useStationScope";
 import { ElMessage, ElMessageBox } from "element-plus";
 
 defineOptions({ name: "AdminStationFault" });
@@ -45,8 +46,23 @@ const faultTypeLabel = (type: string) => {
   }
 };
 
+//站点筛选与全局当前站点联动:切换站点时同步订阅与故障记录两个页签的筛选
+const reloadFaultPage = (refresh?: boolean) => {
+  state.faultQuery.stationId = state.formQuery.stationId;
+  if (state.activeTab === "faultRecord") {
+    loadFaultRecords(!!refresh);
+  }
+  loadSubscribers();
+};
+useStationScope(state.formQuery, (refresh?: boolean) => reloadFaultPage(refresh));
+
 onMounted(() => {
   loadStations();
+  //默认选中全局当前站点时,自动加载订阅列表
+  if (state.formQuery.stationId) {
+    state.faultQuery.stationId = state.formQuery.stationId;
+    loadSubscribers();
+  }
   nextTick(() => {
     const bodyHeight = document.body.clientHeight;
     state.tableHeight = bodyHeight - 280;

+ 4 - 0
admin-web-new/src/views/admin/statistics/daily-stat.vue

@@ -3,6 +3,7 @@ import { reactive, onMounted, ref, nextTick } from "vue";
 import { getDailyStatList, syncDailyStatBatch } from "@/api/stat";
 import { getStationList } from "@/api/station";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { useStationScope } from "@/hooks/useStationScope";
 import { ElMessage, ElMessageBox } from "element-plus";
 
 defineOptions({ name: "AdminDailyStat" });
@@ -45,6 +46,9 @@ const state = reactive({
   }
 });
 
+//站点筛选与全局当前站点联动(回调懒引用,规避 loadData 声明顺序的 TDZ 错误)
+useStationScope(state.formQuery, (refresh?: boolean) => loadData(refresh));
+
 onMounted(() => {
   const yesterday = new Date();
   yesterday.setDate(yesterday.getDate() - 1);

+ 4 - 0
admin-web-new/src/views/admin/statistics/month-stat.vue

@@ -3,6 +3,7 @@ import { reactive, onMounted, ref, nextTick } from "vue";
 import { getMonthStatList, syncMonthStat } from "@/api/stat";
 import { getStationList } from "@/api/station";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { useStationScope } from "@/hooks/useStationScope";
 import { ElMessage, ElMessageBox } from "element-plus";
 
 defineOptions({ name: "AdminMonthStat" });
@@ -44,6 +45,9 @@ const state = reactive({
   }
 });
 
+//站点筛选与全局当前站点联动(回调懒引用,规避 loadData 声明顺序的 TDZ 错误)
+useStationScope(state.formQuery, (refresh?: boolean) => loadData(refresh));
+
 onMounted(() => {
   const now = new Date();
   const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);

+ 273 - 6
admin-web-new/src/views/admin/user/index.vue

@@ -1,8 +1,12 @@
 <script setup lang="ts">
 import { reactive, onMounted, ref, nextTick } from "vue";
-import { getCustomUserList } from "@/api/custom";
+import { getCustomUserList, lockUser, unlockUser, getUserLockRecord } from "@/api/custom";
+import { applyRefund } from "@/api/finance";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { useStationScope } from "@/hooks/useStationScope";
 import { ExtDLabel } from "@/components/ExtForm";
+import { ElMessageBox } from "element-plus";
+import { message } from "@/utils/message";
 import UserDetail from "./detail.vue";
 
 defineOptions({
@@ -17,7 +21,8 @@ const state = reactive({
   formQuery: {
     mobilePhone: "",
     userId: "",
-    stationId: ""
+    stationId: "",
+    locked: null as number | null
   },
   pageQuery: {
     pageNum: 1,
@@ -37,11 +42,31 @@ const state = reactive({
       { label: "洗车次数", prop: "washTimes", width: 90 },
       { label: "消费总额", prop: "amount", width: 120 },
       { label: "状态", prop: "status", width: 80 },
+      { label: "锁定备注", prop: "lockRemark", width: 150 },
+      { label: "锁定次数", prop: "lockTimes", width: 90 },
+      { label: "锁定时间", prop: "lockTime", width: 170 },
       { label: "注册时间", prop: "registerTime", width: 170 }
     ]
+  },
+  lockDialog: {
+    visible: false,
+    loading: false,
+    userId: null as number | null,
+    mobilePhone: "",
+    lockRemark: "",
+    lockNotice: ""
+  },
+  lockRecordDialog: {
+    visible: false,
+    loading: false,
+    mobilePhone: "",
+    list: [] as Array<any>
   }
 });
 
+//站点筛选与全局当前站点联动(回调懒引用,规避 loadData 声明顺序的 TDZ 错误)
+useStationScope(state.formQuery, (refresh?: boolean) => loadData(refresh));
+
 onMounted(() => {
   loadData();
   nextTick(() => {
@@ -56,7 +81,12 @@ const loadData = (refresh: boolean = false) => {
     state.pageQuery.pageNum = 1;
   }
   state.tableData.loading = true;
-  getCustomUserList({ ...state.formQuery, ...state.pageQuery })
+  const params: any = { ...state.formQuery, ...state.pageQuery };
+  //锁定状态未选时不传参,避免空值被绑定为 Integer 报错
+  if (params.locked === "" || params.locked === null || params.locked === undefined) {
+    delete params.locked;
+  }
+  getCustomUserList(params)
     .then((res: any) => {
       const { list, total } = res || {};
       state.tableData.data = list || [];
@@ -88,7 +118,8 @@ const handleReset = () => {
   state.formQuery = {
     mobilePhone: "",
     userId: "",
-    stationId: ""
+    stationId: "",
+    locked: null
   };
   loadData(true);
 };
@@ -100,6 +131,98 @@ const formatMoney = (value: number) => {
 
 const handleDetail = (row: any) => detailRef.value?.open(row);
 
+const handleUserRefundApply = (row: any) => {
+  ElMessageBox.confirm("确认执行本操作吗?", "退款申请", {
+    confirmButtonText: "确定",
+    cancelButtonText: "取消",
+    type: "warning"
+  }).then(() => {
+    ElMessageBox.prompt("请输入退款申请原因", "退款申请", {
+      confirmButtonText: "提交",
+      cancelButtonText: "取消",
+      inputType: "textarea",
+      draggable: true
+    })
+      .then(({ value }) => {
+        applyRefund({ userId: row.userId, reason: (value || "").trim() })
+          .then(() => {
+            message("退款已发起", { type: "success" });
+            loadData(true);
+          })
+          .catch(() => {
+            // 错误提示由响应拦截器统一处理
+          });
+      })
+      .catch(() => {});
+  }).catch(() => {});
+};
+
+// 打开锁定用户弹窗
+const openLockDialog = (row: any) => {
+  state.lockDialog.userId = row.userId;
+  state.lockDialog.mobilePhone = row.mobilePhone;
+  state.lockDialog.lockRemark = "";
+  state.lockDialog.lockNotice = "";
+  state.lockDialog.visible = true;
+};
+
+// 提交锁定
+const confirmLockUser = () => {
+  state.lockDialog.loading = true;
+  lockUser({
+    userId: state.lockDialog.userId as number,
+    lockRemark: state.lockDialog.lockRemark,
+    lockNotice: state.lockDialog.lockNotice.trim()
+  })
+    .then(() => {
+      message("锁定成功", { type: "success" });
+      state.lockDialog.visible = false;
+      loadData(true);
+    })
+    .catch(() => {
+      // 错误提示由响应拦截器统一处理
+    })
+    .finally(() => {
+      state.lockDialog.loading = false;
+    });
+};
+
+// 打开锁定记录弹窗
+const openLockRecordDialog = (row: any) => {
+  state.lockRecordDialog.mobilePhone = row.mobilePhone;
+  state.lockRecordDialog.list = [];
+  state.lockRecordDialog.visible = true;
+  state.lockRecordDialog.loading = true;
+  getUserLockRecord(row.userId)
+    .then((res: any) => {
+      state.lockRecordDialog.list = res || [];
+    })
+    .catch(() => {
+      // 错误提示由响应拦截器统一处理
+    })
+    .finally(() => {
+      state.lockRecordDialog.loading = false;
+    });
+};
+
+// 解锁用户
+const handleUnlockUser = (row: any) => {
+  ElMessageBox.confirm(
+    `确认解锁用户『${row.mobilePhone}』?解锁后该用户可正常启动设备与退款`,
+    "提示",
+    {
+      confirmButtonText: "确定",
+      cancelButtonText: "取消",
+      type: "warning"
+    }
+  ).then(() => {
+    unlockUser(row.userId).then(() => {
+      message("解锁成功", { type: "success" });
+      loadData(true);
+    });
+  }).catch(() => {});
+};
+
 const isMoneyField = (prop: string) => {
   return ["balance", "rechargeAmount", "amount"].includes(prop);
 };
@@ -133,6 +256,18 @@ const isMoneyField = (prop: string) => {
             @keyup.enter="handleSearch"
           />
         </el-form-item>
+        <el-form-item label="锁定状态">
+          <el-select
+            v-model="state.formQuery.locked"
+            placeholder="请选择锁定状态"
+            clearable
+            style="width: 150px"
+            @change="handleSearch"
+          >
+            <el-option label="已锁定" :value="1" />
+            <el-option label="未锁定" :value="0" />
+          </el-select>
+        </el-form-item>
         <el-form-item>
           <el-button
             type="primary"
@@ -174,16 +309,52 @@ const isMoneyField = (prop: string) => {
               {{ formatMoney(row[col.prop]) }}
             </template>
             <template v-else-if="col.prop === 'status'">
-              <ExtDLabel type="User.status" :model-value="row.status" />
+              <el-tag v-if="row.lockNotice" type="danger" effect="dark" size="small">锁定</el-tag>
+              <ExtDLabel v-else type="User.status" :model-value="row.status" />
+            </template>
+            <template v-else-if="col.prop === 'lockTimes'">
+              <el-button
+                v-if="row.lockTimes > 0"
+                link
+                type="danger"
+                size="small"
+                @click="openLockRecordDialog(row)"
+              >
+                {{ row.lockTimes }}
+              </el-button>
+              <span v-else>0</span>
             </template>
             <template v-else>
               {{ row[col.prop] }}
             </template>
           </template>
         </el-table-column>
-        <el-table-column label="操作" width="100" fixed="right">
+        <el-table-column label="操作" width="200" fixed="right">
           <template #default="{ row }">
             <el-button type="primary" link size="small" @click="handleDetail(row)">详情</el-button>
+            <el-button type="primary" link size="small" @click="handleUserRefundApply(row)">
+              申请退款
+            </el-button>
+            <el-button
+              v-if="!row.lockNotice"
+              v-auth="'user.lock'"
+              type="danger"
+              link
+              size="small"
+              @click="openLockDialog(row)"
+            >
+              锁定
+            </el-button>
+            <el-button
+              v-else
+              v-auth="'user.lock'"
+              type="success"
+              link
+              size="small"
+              @click="handleUnlockUser(row)"
+            >
+              解锁
+            </el-button>
           </template>
         </el-table-column>
       </el-table>
@@ -200,6 +371,97 @@ const isMoneyField = (prop: string) => {
         />
       </div>
     </el-card>
+
+    <!-- 锁定用户弹窗 -->
+    <el-dialog
+      v-model="state.lockDialog.visible"
+      :title="`锁定用户『${state.lockDialog.mobilePhone || ''}』`"
+      width="520px"
+      append-to-body
+      :close-on-click-modal="false"
+    >
+      <el-form label-width="90px" size="default">
+        <el-form-item label="锁定备注">
+          <el-input
+            v-model="state.lockDialog.lockRemark"
+            type="textarea"
+            :rows="2"
+            maxlength="200"
+            show-word-limit
+            placeholder="仅平台人员可见,用于记录锁定原因"
+          />
+        </el-form-item>
+        <el-form-item label="提示内容" required>
+          <el-input
+            v-model="state.lockDialog.lockNotice"
+            type="textarea"
+            :rows="3"
+            maxlength="200"
+            show-word-limit
+            placeholder="用户启动设备时强制弹出展示"
+          />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="state.lockDialog.visible = false">取 消</el-button>
+        <el-button
+          :loading="state.lockDialog.loading"
+          :disabled="!state.lockDialog.lockNotice || !state.lockDialog.lockNotice.trim()"
+          type="danger"
+          @click="confirmLockUser"
+        >
+          确定锁定
+        </el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 锁定记录弹窗 -->
+    <el-dialog
+      v-model="state.lockRecordDialog.visible"
+      :title="`锁定记录『${state.lockRecordDialog.mobilePhone || ''}』`"
+      width="900px"
+      append-to-body
+    >
+      <el-table
+        border
+        stripe
+        :data="state.lockRecordDialog.list"
+        v-loading="state.lockRecordDialog.loading"
+        max-height="420"
+      >
+        <template #empty>
+          <el-empty description="暂无锁定记录" />
+        </template>
+        <el-table-column label="序号" type="index" width="60" align="center" />
+        <el-table-column label="锁定时间" prop="lockTime" width="160" align="center" />
+        <el-table-column label="锁定人" prop="lockOperator" width="110" show-overflow-tooltip />
+        <el-table-column
+          label="锁定备注(仅平台人员可见)"
+          prop="lockRemark"
+          min-width="160"
+          show-overflow-tooltip
+        />
+        <el-table-column label="提示内容" prop="lockNotice" min-width="160" show-overflow-tooltip />
+        <el-table-column label="状态" width="90" align="center">
+          <template #default="{ row }">
+            <el-tag v-if="row.status === 1" type="danger" effect="dark" size="small">锁定中</el-tag>
+            <el-tag v-else type="info" size="small">已解锁</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="解锁信息" width="200">
+          <template #default="{ row }">
+            <div v-if="row.unlockTime">{{ row.unlockTime }}</div>
+            <div v-if="row.unlockOperator" class="unlock-operator">
+              解锁人:{{ row.unlockOperator }}
+            </div>
+          </template>
+        </el-table-column>
+      </el-table>
+      <template #footer>
+        <el-button @click="state.lockRecordDialog.visible = false">关 闭</el-button>
+      </template>
+    </el-dialog>
+
     <UserDetail ref="detailRef" />
   </div>
 </template>
@@ -220,4 +482,9 @@ const isMoneyField = (prop: string) => {
   justify-content: flex-end;
   margin-top: 15px;
 }
+
+.unlock-operator {
+  color: var(--el-text-color-secondary);
+  font-size: 12px;
+}
 </style>

+ 6 - 12
admin-web/src/components/form/ExtQueryForm.vue

@@ -228,19 +228,13 @@ const state = ref({
 });
 
 watch(()=>props.modelValue,(nv,ov)=>{
-  if(nv!=ov){
-    console.log(nv)
-    // nextTick(()=>{
-    //   let val = {...props.modelValue}
-    //   state.value.cols.forEach((col: any) => {
-    //     val[col.prop] = null;
-    //   })
-    //   //console.log(val)
-    //   state.value.form = val;
-    // })
+  if(nv && nv!=ov){
+    //外部变更(如全局站点切换联动)同步进查询表单
+    Object.keys(nv).forEach(key => {
+      if(state.value.form[key] !== nv[key]) state.value.form[key] = nv[key];
+    })
   }
-
-})
+},{deep: true})
 
 onMounted(() => {
   if (props.importConfig && props.importConfig.url) {

+ 43 - 0
admin-web/src/hooks/useStationScope.ts

@@ -0,0 +1,43 @@
+import { onMounted, onUnmounted } from 'vue';
+import { Session } from '/@/utils/storage';
+import mittBus from '/@/utils/mitt';
+
+/**
+ * 站点范围组合式函数:页面站点筛选与全局"当前站点"联动
+ * 1. 页面初始化时,若站点筛选为空则默认选中全局当前站点(onBeforeMount 中手动赋值的优先级更高)
+ * 2. 全局站点切换时(顶栏站点选择器),联动更新页面筛选并刷新列表
+ *
+ * @param formQuery 页面查询条件对象(需包含 stationId 字段)
+ * @param loadData 页面数据加载方法,切换站点时以 refresh=true 重新加载
+ * @param options.follow 是否响应全局站点切换,默认 true
+ */
+export const useStationScope = (
+    formQuery: any,
+    loadData?: (refresh?: boolean) => void,
+    options?: { follow?: boolean }
+) => {
+  const follow = options?.follow !== false;
+
+  const onStationChange = (stationId: any) => {
+    if (!follow || !stationId) return;
+    //站点未变化时不重复加载
+    if (formQuery.stationId === stationId) return;
+    formQuery.stationId = stationId;
+    if (loadData) loadData(true);
+  };
+
+  onMounted(() => {
+    //初始化:站点筛选为空时默认选中全局当前站点
+    if (!formQuery.stationId) {
+      let currentStationId = Session.get('currentStationId');
+      if (currentStationId) {
+        formQuery.stationId = currentStationId;
+      }
+    }
+    mittBus.on('stationChangeRefresh', onStationChange);
+  });
+
+  onUnmounted(() => {
+    mittBus.off('stationChangeRefresh', onStationChange);
+  });
+};

+ 236 - 0
admin-web/src/layout/navBars/breadcrumb/stationSwitcher.vue

@@ -0,0 +1,236 @@
+<template>
+  <div class="layout-navbars-breadcrumb-station"
+       :class="{'is-open': state.open, 'is-empty': !state.loading && state.stationList.length === 0}"
+       title="切换当前查看的站点,页面数据范围会跟随变化">
+    <el-select-v2
+        v-model="state.currentStationId"
+        :options="state.options"
+        :loading="state.loading"
+        :loading-text="'正在加载站点…'"
+        :no-data-text="'没有匹配的站点'"
+        :disabled="!state.loading && state.stationList.length === 0"
+        :placeholder="placeholder"
+        filterable
+        popper-class="station-select-popper"
+        class="station-select"
+        @change="handleStationChange"
+        @visible-change="state.open = $event">
+      <template #prefix>
+        <SvgIcon name="ele-Location" :size="15"/>
+      </template>
+      <template #header>
+        <span>站点范围</span>
+        <span>共 {{ state.stationList.length }} 个站点</span>
+      </template>
+    </el-select-v2>
+  </div>
+</template>
+
+<script setup lang="ts" name="layoutBreadcrumbStationSwitcher">
+import {reactive, computed, onMounted} from 'vue';
+import mittBus from '/@/utils/mitt';
+import {Session} from '/@/utils/storage';
+import {$body} from '/@/utils/request';
+
+const state = reactive({
+  stationList: [] as Array<any>,
+  currentStationId: null as any,
+  options: [] as Array<any>,
+  loading: true,
+  open: false
+});
+
+const placeholder = computed(() => {
+  if (state.loading) return '加载站点中';
+  if (state.stationList.length === 0) return '暂无站点';
+  return '选择站点';
+});
+
+//站点切换
+const handleStationChange = (stationId: string) => {
+  let station = state.stationList.find((k: any) => k.stationId === stationId);
+  if (station) {
+    Session.set("currentStationId", stationId);
+    mittBus.emit('stationChangeRefresh', stationId);
+  }
+}
+
+const loadStationList = () => {
+  $body(`/washStation/list`, {pageNum: 1, pageSize: 1024}).then((res: any) => {
+    //必须先结束 loading 再赋值选项:element-plus 在 loading 态下过滤结果为空,
+    //会导致选中项 label 解析失败、回退显示原始站点ID
+    state.loading = false;
+    let {list} = res;
+    state.stationList = list || [];
+    state.options = state.stationList.map((k: any) => {
+      return {value: k.stationId, label: k.stationName}
+    });
+    if (state.stationList.length > 0) {
+      //优先恢复上次选择的站点,其次默认第一个
+      let savedId = Session.get('currentStationId');
+      let current = state.stationList.find((k: any) => k.stationId === savedId) || state.stationList[0];
+      state.currentStationId = current.stationId;
+      setTimeout(() => {
+        Session.set("currentStationId", current.stationId);
+        mittBus.emit('stationChangeRefresh', current.stationId);
+      }, 200)
+    }
+  }).catch(e => {
+    //console.error(e)
+  }).finally(() => {
+    state.loading = false;
+  })
+}
+
+// 页面加载时
+onMounted(() => {
+  loadStationList();
+});
+</script>
+
+<style scoped lang="scss">
+.layout-navbars-breadcrumb-station {
+  display: flex;
+  align-items: center;
+  height: 50px;
+  padding: 0 8px 0 4px;
+
+  .station-select {
+    width: 180px;
+
+    //去框融入顶栏:静态时无边框无底色,悬停/聚焦时才浮起一层浅底
+    :deep(.el-select__wrapper) {
+      height: 34px;
+      min-height: 34px;
+      padding: 0 8px;
+      border-radius: 8px;
+      background: transparent;
+      box-shadow: none;
+      transition: background-color .2s cubic-bezier(.4, 0, .2, 1);
+
+      &.is-hovering {
+        background: var(--next-color-user-hover);
+      }
+
+      &.is-focused {
+        background: var(--next-color-user-hover);
+      }
+
+      &.is-disabled {
+        background: transparent;
+      }
+    }
+
+    :deep(.el-select__prefix) {
+      display: flex;
+      align-items: center;
+      margin-right: 6px;
+      color: var(--next-bg-topBarColor);
+      transition: color .2s cubic-bezier(.4, 0, .2, 1);
+    }
+
+    :deep(.el-select__placeholder),
+    :deep(.el-select__input) {
+      color: var(--next-bg-topBarColor);
+      font-size: 14px;
+      font-weight: 500;
+      transition: color .2s cubic-bezier(.4, 0, .2, 1);
+    }
+
+    //未选中任何站点时的占位:退一档
+    :deep(.el-select__placeholder.is-transparent) {
+      color: var(--el-text-color-placeholder);
+      font-weight: 400;
+    }
+
+    :deep(.el-select__caret) {
+      color: var(--next-bg-topBarColor);
+      opacity: 0.6;
+      transform-origin: center;
+      transition: transform .2s cubic-bezier(.4, 0, .2, 1), opacity .2s cubic-bezier(.4, 0, .2, 1), color .2s;
+    }
+  }
+
+  //悬停时文字与图标转为品牌色,呼应 Ghost 按钮规则(禁用态除外)
+  &:not(.is-empty) .station-select:hover {
+    :deep(.el-select__wrapper .el-select__placeholder),
+    :deep(.el-select__wrapper .el-select__input),
+    :deep(.el-select__wrapper .el-select__prefix),
+    :deep(.el-select__wrapper .el-select__caret) {
+      color: var(--el-color-primary);
+      opacity: 1;
+    }
+  }
+
+  //展开时箭头翻转
+  &.is-open .station-select {
+    :deep(.el-select__caret) {
+      transform: rotate(180deg);
+      opacity: 1;
+      color: var(--el-color-primary);
+    }
+  }
+
+  //禁用态(无站点可用)
+  &:deep(.el-select__wrapper.is-disabled .el-select__placeholder),
+  &:deep(.el-select__wrapper.is-disabled .el-select__prefix),
+  &:deep(.el-select__wrapper.is-disabled .el-select__caret) {
+    color: var(--el-text-color-disabled);
+    opacity: 1;
+  }
+}
+</style>
+
+<style lang="scss">
+// 下拉面板(teleported,需全局样式)
+.station-select-popper {
+  .el-select-dropdown {
+    padding: 6px;
+    border: none;
+    border-radius: 12px;
+    box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.06);
+  }
+
+  .el-select-dropdown__header {
+    display: flex;
+    justify-content: space-between;
+    padding: 4px 10px 6px;
+    font-size: 12px;
+    font-weight: 500;
+    color: var(--el-text-color-secondary);
+
+    span:last-child {
+      font-weight: 400;
+      color: var(--el-text-color-placeholder);
+    }
+  }
+
+  .el-select-dropdown__list {
+    padding: 2px;
+  }
+
+  .el-select-dropdown__item {
+    height: 34px;
+    margin: 2px 0;
+    padding: 0 10px;
+    border-radius: 8px;
+    font-size: 14px;
+  }
+
+  .el-select-dropdown__item.is-hovering {
+    background: var(--el-fill-color-light);
+  }
+
+  //当前站点:品牌色 + 加粗标记「你在这里」
+  .el-select-dropdown__item.is-selected {
+    color: var(--el-color-primary);
+    font-weight: 600;
+  }
+
+  .el-select-dropdown__empty {
+    padding: 14px 10px;
+    font-size: 13px;
+    color: var(--el-text-color-placeholder);
+  }
+}
+</style>

Некоторые файлы не были показаны из-за большого количества измененных файлов