Prechádzať zdrojové kódy

优化 admin-h5 移动端 UI:导航栏、底部 tabBar、设备列表页

- NavBar:渐变背景 + 圆形返回按钮 + 吸顶定位,新增 transparent 模式
- 新增 CustomTabBar:图标+粗体文字,104rpx 高度,选中态背景高亮
- 设备列表页:移除复杂状态筛选,新增站点选择器,默认展示全部设备
- 登录页副标题修正:运营管理小程序→运营管理平台

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline 16 hodín pred
rodič
commit
5296063641

+ 107 - 0
admin-h5/src/components/CustomTabBar.vue

@@ -0,0 +1,107 @@
+<template>
+  <view class="tab-bar">
+    <view
+      v-for="(tab, index) in tabs"
+      :key="index"
+      class="tab-item"
+      @click="switchTab(tab)"
+    >
+      <view class="tab-inner" :class="{ active: current === index }">
+        <AppIcon
+          :name="tab.icon"
+          :size="current === index ? 24 : 22"
+          :color="current === index ? activeColor : inactiveColor"
+        />
+        <text class="tab-text" :class="{ active: current === index }">{{ tab.text }}</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+import { ref, watch, onMounted, getCurrentInstance } from 'vue'
+
+const instance = getCurrentInstance()
+const activeColor = '#C6171E'
+const inactiveColor = '#8C8C8C'
+
+const tabs = [
+  { pagePath: '/pages/index/index',  text: '首页', icon: 'home' },
+  { pagePath: '/pages/order/list',   text: '订单', icon: 'clipboard' },
+  { pagePath: '/pages/device/list',  text: '设备', icon: 'monitor' },
+  { 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 })
+
+const switchTab = (tab) => {
+  uni.switchTab({ url: tab.pagePath })
+}
+</script>
+
+<style scoped>
+.tab-bar {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  display: flex;
+  align-items: stretch;
+  background: #FFFFFF;
+  height: 104rpx;
+  padding: 8rpx 16rpx;
+  padding-bottom: calc(8rpx + env(safe-area-inset-bottom));
+  box-shadow: 0 -2px 20px rgba(0, 0, 0, 0.06);
+  z-index: 999;
+}
+
+.tab-item {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.tab-inner {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 4rpx;
+  padding: 8rpx 24rpx;
+  border-radius: 20rpx;
+  transition: background 0.25s cubic-bezier(0.4, 0, 0.2, 1);
+  min-width: 120rpx;
+}
+
+.tab-inner.active {
+  background: rgba(198, 23, 30, 0.06);
+}
+
+.tab-text {
+  font-size: 22rpx;
+  font-weight: 600;
+  color: #8C8C8C;
+  transition: color 0.25s;
+  line-height: 1;
+}
+
+.tab-text.active {
+  color: #C6171E;
+  font-weight: 700;
+}
+
+</style>

+ 61 - 26
admin-h5/src/components/NavBar.vue

@@ -1,11 +1,15 @@
 <template>
-  <view class="nav-bar">
-    <view class="nav-bar__left" @click="handleBack">
-      <AppIcon v-if="showBack" name="chevron-left" :size="22" color="#FFFFFF" />
-    </view>
-    <text class="nav-bar__title">{{ title }}</text>
-    <view class="nav-bar__right">
-      <slot name="right"></slot>
+  <view class="nav-bar" :class="{ 'nav-bar--transparent': transparent }">
+    <view class="nav-bar__body">
+      <view class="nav-bar__left">
+        <view v-if="showBack" class="nav-back-btn" hover-class="nav-back-btn--hover" @click="handleBack">
+          <AppIcon name="chevron-left" :size="22" color="#FFFFFF" />
+        </view>
+      </view>
+      <text class="nav-bar__title">{{ title }}</text>
+      <view class="nav-bar__right">
+        <slot name="right"></slot>
+      </view>
     </view>
   </view>
 </template>
@@ -13,40 +17,76 @@
 <script setup>
 defineProps({
   title: { type: String, default: '' },
-  showBack: { type: Boolean, default: true }
+  showBack: { type: Boolean, default: true },
+  transparent: { type: Boolean, default: false }
 })
 
 const emit = defineEmits(['back'])
 
 const handleBack = () => {
-  if (emit._events && emit._events.back) {
+  // emit._events check for compatibility
+  const listeners = emit._events || {}
+  if (listeners.back) {
     emit('back')
   } else {
-    uni.navigateBack()
+    uni.navigateBack({ fail: () => uni.switchTab({ url: '/pages/index/index' }) })
   }
 }
 </script>
 
 <style scoped>
 .nav-bar {
+  position: sticky;
+  top: 0;
+  z-index: 100;
+  background: linear-gradient(135deg, #C6171E 0%, #D32F2F 100%);
+  box-shadow: 0 4px 16px rgba(198, 23, 30, 0.2);
+}
+
+.nav-bar--transparent {
+  background: transparent;
+  box-shadow: none;
+}
+
+.nav-bar__body {
   display: flex;
   align-items: center;
   justify-content: space-between;
   height: 88rpx;
-  padding-top: calc(24rpx + var(--status-bar-height));
-  padding-right: 30rpx;
-  padding-bottom: 24rpx;
-  padding-left: 30rpx;
-  background: #C6171E;
-  box-shadow: 0 2px 8px rgba(198, 23, 30, 0.15);
-  position: relative;
-  z-index: 100;
+  padding: 0 24rpx;
+  padding-top: var(--status-bar-height, 0px);
+  box-sizing: content-box;
+}
+
+.nav-bar__left,
+.nav-bar__right {
+  width: 100rpx;
+  display: flex;
+  align-items: center;
+  flex-shrink: 0;
 }
 
 .nav-bar__left {
-  width: 120rpx;
+  justify-content: flex-start;
+}
+
+.nav-bar__right {
+  justify-content: flex-end;
+}
+
+.nav-back-btn {
+  width: 60rpx;
+  height: 60rpx;
+  border-radius: 50%;
   display: flex;
   align-items: center;
+  justify-content: center;
+  transition: background 0.2s;
+  margin-left: -8rpx;
+}
+
+.nav-back-btn--hover {
+  background: rgba(255, 255, 255, 0.18);
 }
 
 .nav-bar__title {
@@ -55,15 +95,10 @@ const handleBack = () => {
   font-size: 34rpx;
   font-weight: 600;
   color: #FFFFFF;
+  letter-spacing: 1rpx;
   white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
-}
-
-.nav-bar__right {
-  width: 120rpx;
-  display: flex;
-  align-items: center;
-  justify-content: flex-end;
+  line-height: 1.4;
 }
 </style>

+ 1 - 0
admin-h5/src/pages.json

@@ -164,6 +164,7 @@
     "backgroundColor": "#F5F7FA"
   },
   "tabBar": {
+    "custom": true,
     "color": "#999999",
     "selectedColor": "#C6171E",
     "backgroundColor": "#ffffff",

+ 118 - 132
admin-h5/src/pages/device/list.vue

@@ -1,32 +1,41 @@
 <template>
   <view class="device-list-container">
+    <!-- 站点选择 -->
+    <picker
+      v-if="stationList.length > 0"
+      mode="selector"
+      :range="stationList"
+      range-key="stationName"
+      :value="selectedStationIndex"
+      @change="onStationChange"
+    >
+      <view class="station-picker">
+        <view class="station-picker-row">
+          <AppIcon name="building" :size="18" color="#C6171E" />
+          <text class="station-picker-name">{{ currentStation?.stationName || '选择站点' }}</text>
+          <AppIcon name="chevron-down" :size="14" color="#999999" />
+        </view>
+        <text class="station-picker-count" v-if="deviceList.length > 0">共 {{ totalDevices }} 台设备</text>
+      </view>
+    </picker>
+
+    <!-- 搜索栏 -->
     <view class="search-bar">
       <view class="search-input-wrap">
         <AppIcon name="search" size="16" color="#999999" />
         <input
           type="text"
-          placeholder="请输入设备名称或编号"
+          placeholder="搜索设备名称或编号"
           v-model="searchKeyword"
           @confirm="handleSearch"
         />
-      </view>
-      <button class="search-btn" @click="handleSearch">搜索</button>
-    </view>
-
-    <view class="filter-bar">
-      <view class="segmented-control">
-        <view
-          v-for="(option, index) in filterOptions"
-          :key="index"
-          class="segment-item"
-          :class="{ active: activeFilter === index }"
-          @click="activeFilter = index; handleFilterChange(index)"
-        >
-          <text>{{ option.label }}</text>
+        <view v-if="searchKeyword" class="search-clear" @click="searchKeyword = ''; handleSearch()">
+          <AppIcon name="x" size="14" color="#B0B0B0" />
         </view>
       </view>
     </view>
 
+    <!-- 设备列表 -->
     <view class="device-list">
       <view
         v-for="device in deviceList"
@@ -68,12 +77,7 @@
         </view>
 
         <view class="device-footer" v-if="isDeviceOnline(getDeviceStatusValue(device))">
-          <button
-            class="stop-btn"
-            @click.stop="handleStopDevice(device)"
-          >
-            停止设备
-          </button>
+          <button class="stop-btn" @click.stop="handleStopDevice(device)">停止设备</button>
         </view>
 
         <view v-if="showConfigDropdown === getDeviceId(device)" class="config-dropdown">
@@ -103,7 +107,7 @@
 
     <view class="load-more" v-if="deviceList.length > 0">
       <text v-if="loadMoreStatus === 'loading'">正在加载...</text>
-      <text v-else-if="loadMoreStatus === 'noMore'">— 没有更多数据了 —</text>
+      <text v-else-if="loadMoreStatus === 'noMore'">— 没有更多了 —</text>
       <text v-else>上拉加载更多</text>
     </view>
 
@@ -118,14 +122,17 @@
       <text class="loading-text">加载中...</text>
     </view>
 
+    <CustomTabBar :selected="2" />
   </view>
 </template>
 
 <script setup>
+import CustomTabBar from '../../components/CustomTabBar.vue'
 import { ref, onMounted, computed } from 'vue'
 import { onReachBottom } from '@dcloudio/uni-app'
 import { getDeviceList, stopDevice, getDeviceConfigList, batchModifyDeviceConfig } from '../../api/device.js'
-import { showToast, fmtDictName, getDictColor } from '../../utils/index.js'
+import { getStationList } from '../../api/station.js'
+import { showToast, storage, fmtDictName, getDictColor } from '../../utils/index.js'
 import dictUtil, { loadDicts } from '../../utils/dict.js'
 
 const deviceList = ref([])
@@ -134,20 +141,46 @@ const page = ref(1)
 const pageSize = ref(10)
 const hasMore = ref(true)
 const loadMoreStatus = ref('more')
-const activeFilter = ref(0)
 const searchKeyword = ref('')
 
+const stationList = ref([])
+const currentStation = ref(null)
+const selectedStationIndex = ref(0)
+const totalDevices = ref(0)
+
 const configList = ref([])
 const showConfigDropdown = ref(null)
 const loadingConfig = ref(false)
 
-const filterOptions = computed(() => dictUtil.getDictFilterOptions('WashDevice.status'))
-
 onMounted(async () => {
-  await loadDicts()
-  loadDeviceList()
+  await Promise.all([loadDicts(), loadStationList()])
 })
 
+const loadStationList = async () => {
+  try {
+    const res = await getStationList({ pageNum: 1, pageSize: 1024 })
+    if (res && res.code === 200 && res.data && res.data.list) {
+      stationList.value = res.data.list
+      if (stationList.value.length > 0) {
+        currentStation.value = stationList.value[0]
+        selectedStationIndex.value = 0
+        storage.set('currentStationId', currentStation.value.stationId)
+        loadDeviceList()
+      }
+    }
+  } catch (e) {
+    console.error('加载站点列表失败:', e)
+  }
+}
+
+const onStationChange = async (e) => {
+  const index = e.detail.value
+  selectedStationIndex.value = index
+  currentStation.value = stationList.value[index]
+  storage.set('currentStationId', currentStation.value.stationId)
+  loadDeviceList()
+}
+
 const loadDeviceList = async (isLoadMore = false) => {
   if (!isLoadMore) {
     page.value = 1
@@ -159,8 +192,8 @@ const loadDeviceList = async (isLoadMore = false) => {
     const params = {
       page: page.value,
       pageSize: pageSize.value,
-      keyword: searchKeyword.value,
-      status: activeFilter.value === 0 ? '' : getStatusValue(activeFilter.value)
+      keyword: searchKeyword.value || '',
+      stationId: currentStation.value?.stationId || ''
     }
 
     const res = await getDeviceList(params)
@@ -170,16 +203,18 @@ const loadDeviceList = async (isLoadMore = false) => {
       const records = data.records || data.list || data
       const totalPages = data.pages || data.totalPages || 1
 
+      totalDevices.value = data.total || (Array.isArray(records) ? records.length : 0)
+
       if (isLoadMore) {
         deviceList.value = [...deviceList.value, ...records]
       } else {
-        deviceList.value = records
+        deviceList.value = Array.isArray(records) ? records : []
       }
 
       hasMore.value = page.value < totalPages
       loadMoreStatus.value = hasMore.value ? 'more' : 'noMore'
 
-      if (isLoadMore) page.value++
+      if (!isLoadMore) page.value++
     }
   } catch (error) {
     showToast('获取设备列表失败')
@@ -191,22 +226,14 @@ const loadDeviceList = async (isLoadMore = false) => {
 const loadMore = () => {
   if (hasMore.value && !loading.value && loadMoreStatus.value !== 'loading') {
     loadMoreStatus.value = 'loading'
+    page.value++
     loadDeviceList(true)
   }
 }
 
-onReachBottom(() => {
-  loadMore()
-})
-
-const handleSearch = () => {
-  loadDeviceList()
-}
+onReachBottom(() => loadMore())
 
-const handleFilterChange = (index) => {
-  activeFilter.value = index
-  loadDeviceList()
-}
+const handleSearch = () => loadDeviceList()
 
 const viewDeviceDetail = (deviceId) => {
   uni.navigateTo({ url: `/pages/device/detail?id=${deviceId}` })
@@ -277,15 +304,9 @@ const selectConfig = async (deviceId, configId, configName) => {
   }
 }
 
-const getDeviceId = (device) => {
-  if (!device) return null
-  return device.id || device.deviceId || device.device_id || device.shortId || null
-}
+const getDeviceId = (device) => device?.id || device?.deviceId || device?.device_id || device?.shortId || null
 
-const getDeviceStatusValue = (device) => {
-  if (!device) return null
-  return device.status ?? device.state ?? device.stateCode ?? device.deviceStatus ?? null
-}
+const getDeviceStatusValue = (device) => device?.status ?? device?.state ?? device?.stateCode ?? device?.deviceStatus ?? null
 
 const getDeviceStatusText = (status) => fmtDictName('WashDevice.status', status)
 
@@ -307,7 +328,7 @@ const getDeviceBusyStatusClass = (device) => {
 
 const getDeviceStatusStyle = (status) => {
   const color = getDictColor('WashDevice.status', status)
-  if (color) return { color: color, backgroundColor: `${color}1A` }
+  if (color) return { color, backgroundColor: `${color}1A` }
   return {}
 }
 
@@ -315,13 +336,6 @@ const isDeviceOnline = (status) => {
   const onlineValue = dictUtil.getDictValue('WashDevice.status', '在线')
   return status == onlineValue
 }
-
-const getStatusValue = (filterIndex) => {
-  if (filterIndex === 0) return ''
-  const options = dictUtil.getDictOptions('WashDevice.status')
-  const idx = filterIndex - 1
-  return options[idx] ? options[idx].value : ''
-}
 </script>
 
 <style scoped>
@@ -330,83 +344,72 @@ const getStatusValue = (filterIndex) => {
   background-color: #F5F7FA;
   min-height: 100vh;
   box-sizing: border-box;
-  padding-bottom: 100rpx;
+  padding-bottom: 120rpx;
 }
 
-/* ===== Search Bar ===== */
-.search-bar {
+/* ===== Station Picker ===== */
+.station-picker {
+  background: #FFFFFF;
+  border-radius: 20rpx;
+  padding: 24rpx;
   display: flex;
-  gap: 16rpx;
+  flex-direction: column;
+  align-items: center;
+  gap: 8rpx;
   margin-bottom: 20rpx;
 }
 
-.search-input-wrap {
-  flex: 1;
+.station-picker:active {
+  background: #F5F7FA;
+}
+
+.station-picker-row {
   display: flex;
   align-items: center;
-  gap: 12rpx;
-  background: #F5F5F5;
-  border-radius: 16rpx;
-  padding: 0 24rpx;
+  justify-content: center;
+  gap: 10rpx;
 }
 
-.search-input-wrap input {
-  flex: 1;
-  height: 72rpx;
-  border: none;
-  outline: none;
+.station-picker-name {
   font-size: 28rpx;
+  font-weight: 600;
   color: #1A1A1A;
-  background: transparent;
-}
-
-.search-btn {
-  height: 72rpx;
-  line-height: 72rpx;
-  padding: 0 36rpx;
-  background: #C6171E;
-  color: #FFFFFF;
-  border: none;
-  border-radius: 16rpx;
-  font-size: 28rpx;
-  font-weight: 500;
-  transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
 }
 
-.search-btn:active {
-  background: #A81212;
-  transform: scale(0.97);
+.station-picker-count {
+  font-size: 22rpx;
+  color: #999999;
 }
 
-/* ===== Filter Bar ===== */
-.filter-bar {
+/* ===== Search Bar ===== */
+.search-bar {
   margin-bottom: 20rpx;
 }
 
-.segmented-control {
+.search-input-wrap {
   display: flex;
+  align-items: center;
+  gap: 12rpx;
   background: #FFFFFF;
   border-radius: 16rpx;
-  padding: 6rpx;
-  gap: 6rpx;
+  padding: 0 24rpx;
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
 }
 
-.segment-item {
+.search-input-wrap input {
   flex: 1;
-  text-align: center;
-  padding: 16rpx 0;
-  font-size: 26rpx;
-  color: #666666;
-  border-radius: 12rpx;
-  font-weight: 500;
-  transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
+  height: 72rpx;
+  border: none;
+  outline: none;
+  font-size: 28rpx;
+  color: #1A1A1A;
+  background: transparent;
 }
 
-.segment-item.active {
-  color: #FFFFFF;
-  font-weight: 600;
-  background: #C6171E;
-  box-shadow: 0 4rpx 12rpx rgba(198, 23, 30, 0.3);
+.search-clear {
+  padding: 6rpx;
+  display: flex;
+  align-items: center;
 }
 
 /* ===== Device List ===== */
@@ -426,7 +429,7 @@ const getStatusValue = (filterIndex) => {
 
 .device-item:active {
   transform: translateY(-2rpx);
-  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
 }
 
 .device-header {
@@ -442,29 +445,12 @@ const getStatusValue = (filterIndex) => {
   min-width: 0;
 }
 
-.device-name-wrap {
-  display: flex;
-  align-items: center;
-  margin-bottom: 10rpx;
-}
-
-.device-name-text {
+.device-name {
   font-size: 30rpx;
   font-weight: 600;
   color: #1A1A1A;
-  flex-shrink: 0;
-}
-
-.pa-badge-list {
-  margin-left: 8rpx;
-  padding: 2rpx 10rpx;
-  background: #C6171E;
-  color: #FFFFFF;
-  border-radius: 6rpx;
-  font-size: 20rpx;
-  font-weight: 700;
-  line-height: 1.4;
-  flex-shrink: 0;
+  display: block;
+  margin-bottom: 10rpx;
 }
 
 .device-meta {
@@ -572,7 +558,7 @@ const getStatusValue = (filterIndex) => {
   color: #C6171E;
 }
 
-/* ===== Config Button (header top-right) ===== */
+/* ===== Config Button ===== */
 .config-btn {
   padding: 8rpx 20rpx;
   background: #C6171E;
@@ -671,7 +657,7 @@ const getStatusValue = (filterIndex) => {
   margin-top: 24rpx;
 }
 
-/* ===== Empty ===== */
+/* ===== Empty State ===== */
 .empty-state {
   display: flex;
   flex-direction: column;

+ 2 - 0
admin-h5/src/pages/finance/index.vue

@@ -154,10 +154,12 @@
       <view class="loading-spinner"></view>
     </view>
 
+    <CustomTabBar :selected="3" />
   </view>
 </template>
 
 <script setup>
+import CustomTabBar from '../../components/CustomTabBar.vue'
 import { ref, onMounted } from 'vue'
 import { getStationAccounts, getSplitRecords } from '../../api/finance.js'
 import { getDashboardData } from '../../api/stat.js'

+ 2 - 0
admin-h5/src/pages/index/index.vue

@@ -143,12 +143,14 @@
       </view>
     </view>
 
+    <CustomTabBar :selected="0" />
   </view>
 </template>
 
 <script setup>
 import { ref, computed, onMounted } from 'vue'
 import { onPullDownRefresh } from '@dcloudio/uni-app'
+import CustomTabBar from '../../components/CustomTabBar.vue'
 import { logout } from '../../api/auth.js'
 import { getDashboardData, getDeviceStatus } from '../../api/stat.js'
 import { getStationList } from '../../api/station.js'

+ 1 - 1
admin-h5/src/pages/login/login.vue

@@ -3,7 +3,7 @@
     <view class="login-form">
       <view class="logo-section">
         <view class="logo-text">自助洗车</view>
-        <view class="logo-subtitle">运营管理小程序</view>
+        <view class="logo-subtitle">运营管理平台</view>
       </view>
       
       <view class="input-group">

+ 2 - 0
admin-h5/src/pages/order/list.vue

@@ -127,10 +127,12 @@
       </view>
     </view>
 
+    <CustomTabBar :selected="1" />
   </view>
 </template>
 
 <script setup>
+import CustomTabBar from '../../components/CustomTabBar.vue'
 import { ref, onMounted, reactive } from 'vue'
 import { onReachBottom } from '@dcloudio/uni-app'
 import { getOrderList, handleRefund as refundApi } from '../../api/order.js'