Explorar el Código

feat: admin-h5 视觉动效与触感反馈全面升级

接入 @capacitor/haptics 触感反馈(按钮轻震/Tab 中震/返回键反馈,H5 降级 navigator.vibrate);新增全局动效库:页面淡入过渡、卡片交错入场、按压缩放反馈、骨架屏微光;首页收入与主指标数字滚动动画;TabBar 选中图标弹性放大;uni.scss 新增 Motion System 缓动与时长令牌;去除点击高亮并抑制过度滚动
skyline hace 2 semanas
padre
commit
5700d8234b

+ 10 - 0
admin-h5/package-lock.json

@@ -12,6 +12,7 @@
         "@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",
@@ -2332,6 +2333,15 @@
         "tslib": "^2.1.0"
       }
     },
+    "node_modules/@capacitor/haptics": {
+      "version": "8.0.2",
+      "resolved": "https://registry.npmmirror.com/@capacitor/haptics/-/haptics-8.0.2.tgz",
+      "integrity": "sha512-c2hZzRR5Fk1tbTvhG1jhh2XBAf3EhnIerMIb2sl7Mt41Gxx1fhBJFDa0/BI1IbY4loVepyyuqNC9820/GZuoWQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@capacitor/core": ">=8.0.0"
+      }
+    },
     "node_modules/@dcloudio/types": {
       "version": "3.4.28",
       "resolved": "https://registry.npmmirror.com/@dcloudio/types/-/types-3.4.28.tgz",

+ 1 - 0
admin-h5/package.json

@@ -15,6 +15,7 @@
     "@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",

+ 55 - 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,46 @@ 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;
+}
+
+/* 入场工具类:页面内元素可主动挂载 */
+.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 +190,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 +219,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 +238,7 @@ text {
   transition: all $uni-transition-duration-base $uni-transition-timing;
 }
 .btn-secondary:active {
+  transform: scale(0.97);
   background-color: $uni-page-bg-color;
 }
 

+ 27 - 7
admin-h5/src/components/CustomTabBar.vue

@@ -4,14 +4,16 @@
       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" :class="{ active: current === index }">
+          <AppIcon
+            :name="tab.icon"
+            :size="current === index ? 24 : 22"
+            :color="current === index ? activeColor : inactiveColor"
+          />
+        </view>
         <text class="tab-text" :class="{ active: current === index }">{{ tab.text }}</text>
       </view>
     </view>
@@ -20,6 +22,7 @@
 
 <script setup>
 import { ref, watch, onMounted, getCurrentInstance } from 'vue'
+import { hapticMedium } from '../utils/haptics.js'
 
 const instance = getCurrentInstance()
 const activeColor = '#C6171E'
@@ -47,7 +50,10 @@ onMounted(() => getCurrentRoute())
 
 watch(() => instance?.proxy?.$route, () => getCurrentRoute(), { immediate: true })
 
-const switchTab = (tab) => {
+const switchTab = (tab, index) => {
+  // 重复点击当前 Tab 不切换、不触发触感
+  if (current.value === index) return
+  hapticMedium()
   uni.switchTab({ url: tab.pagePath })
 }
 </script>
@@ -73,6 +79,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 +101,16 @@ const switchTab = (tab) => {
   background: rgba(198, 23, 30, 0.06);
 }
 
+.tab-icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+/* 选中图标弹性放大入场 */
+.tab-icon.active {
+  animation: app-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 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);
 }

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

@@ -3,10 +3,24 @@ 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 }) => {
@@ -15,8 +29,10 @@ if (typeof window !== 'undefined' && window.Capacitor?.isNativePlatform?.()) {
       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()
       }
     })

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

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