Skip to content

横向滑动组件

- 点击查看详情

/src/pages/index/components/videoList.vue

vue
<script setup lang="ts">
import { ref } from 'vue'

// 模拟视频数据
const videoList = ref([
  { src: '', zhanwei: '/static/images/videolist/video1.png' },
  { src: '', zhanwei: '/static/images/videolist/video2.png' },
  { src: '', zhanwei: '/static/images/videolist/video1.png' },
  { src: '', zhanwei: '/static/images/videolist/video1.png' },
])

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const scroll = (e: any) => {
  console.log('滚动中', e)
}
</script>

<template>
  <scroll-view
    class="scroll-view"
    :scroll-x="true"
    @scroll="scroll"
    scroll-left="120"
    :show-scrollbar="false"
  >
    <view
      v-for="(item, index) in videoList"
      :key="index"
      class="scroll-view-item"
    >
      <!-- 有视频源时显示视频 -->
      <video
        v-if="item.src"
        :src="item.src"
        class="video-item"
        :enable-play-gesture="true"
      ></video>
      <!-- 无视频源时显示占位图 -->
      <image
        v-else
        :src="item.zhanwei ? item.zhanwei : ''"
        class="placeholder-image"
        mode="aspectFill"
      ></image>
    </view>
  </scroll-view>
</template>

<style scoped lang="scss">
.scroll-view {
  white-space: nowrap;
  width: 100%;
  padding: 30rpx; // 同时设置了上下左右内边距,相当于滚动元素的固定父盒子

  .scroll-view-item {
    display: inline-block;
    width: 250rpx;
    height: 170rpx;
    border-radius: 16rpx;
    padding-right: 20rpx;
    overflow: hidden;
    position: relative;
    &:last-child {
      padding-right: 60rpx;
    }
  }
}

.video-item,
.placeholder-image {
  width: 100%;
  height: 100%;
  border-radius: 16rpx;
}
</style>

通过精准调试,发现横向滚动容器其子元素集中的padding样式,仅对元素集的其实元素左侧生效;

问题不在于 paddingmargin 的选择,而在于滚动容器的视口边界计算机制

  • 滚动容器的滚动边界是基于内容边界计算的
  • 任何 paddingmargin 都只是在容器外部或内部创建空间
  • 滚动停止位置始终是内容边界对齐视口边界
css
.scroll-view {
  white-space: nowrap;
  width: 100%;
  padding: 30rpx; // 同时设置了上下左右内边距,相当于滚动元素的固定父盒子
  .scroll-view-item {
    // 其他样式
    padding-right: 20rpx;
    &:last-child {
      padding-right: 60rpx;
    }
  }
}
  • 滚动容器只关心内容边界
  • 必须在内容内部扩展空间才能影响滚动边界
  • 外部空间(margin/padding)只影响视觉布局,不影响滚动机制

组件样式专属坑位

点击查看详情

泪的教训,应该自己动手、纯手写布局,这是看起来最笨、最耗时,但最高效,最简洁的布局方式!!

例如项目中我的地块滑动组件,样式布局并不复杂,传统的flex布局即完全可以胜任,但deepseek非常傻逼的建议,导致我仅俩小时耗费在这个上面,否则最多半小时纯手写就可以完成!

全局定义主题状态

实现逻辑

- 点击查看详情

创建 styles/theme.scss

scss
// styles/theme.scss

// ==================== 主题 Mixin 定义 ====================
@mixin light-theme {
  // 背景色系统
  --color-bg-page: #f5f7fa;
  --color-bg-card: #ffffff;
  --color-bg-navbar: linear-gradient(135deg, #03a9f0 20%, #0083fd 90%);
  --color-bg-transparent: rgba(255, 255, 255, 0.2);

  // 文字色系统
  --color-text-primary: #333333;
  --color-text-secondary: #666666;
  --color-text-tertiary: #999999;
  --color-text-white: #ffffff;

  // 功能色
  --color-success: #52c41a;
  --color-warning: #faad14;
  --color-error: #ff4d4f;
  --color-info: #1890ff;

  // 边框
  --color-border: #e0e0e0;
  --color-divider: #f0f0f0;
}

@mixin dark-theme {
  --color-bg-page: #1a1a1a;
  --color-bg-card: #2d2d2d;
  --color-bg-navbar: linear-gradient(135deg, #2c3e50 20%, #34495e 90%);
  --color-bg-transparent: rgba(255, 255, 255, 0.1);

  --color-text-primary: #ffffff;
  --color-text-secondary: #cccccc;
  --color-text-tertiary: #999999;
  --color-text-white: #ffffff;

  --color-success: #49aa19;
  --color-warning: #d89614;
  --color-error: #a61d24;
  --color-info: #177ddc;

  --color-border: #444444;
  --color-divider: #333333;
}

@mixin dusk-theme {
  --color-bg-page: #fdf6e3;
  --color-bg-card: #fff9e6;
  --color-bg-navbar: linear-gradient(135deg, #e67e22 20%, #d35400 90%);
  --color-bg-transparent: rgba(255, 255, 255, 0.3);

  --color-text-primary: #8b4513;
  --color-text-secondary: #a0522d;
  --color-text-tertiary: #cd853f;
  --color-text-white: #ffffff;

  --color-success: #389e0d;
  --color-warning: #d46b08;
  --color-error: #a8071a;
  --color-info: #0958d9;

  --color-border: #e6b88a;
  --color-divider: #f5e6d3;
}

// ==================== 页面级主题类 ====================
.theme {
  // 默认主题
  @include light-theme;

  // 主题类名
  &.light-mode {
    @include light-theme;
  }

  &.dark-mode {
    @include dark-theme;
  }

  &.dusk-mode {
    @include dusk-theme;
  }
}

// ==================== 通用样式类 ====================
.text-primary {
  color: var(--color-text-primary) !important;
}

.text-secondary {
  color: var(--color-text-secondary) !important;
}

.text-tertiary {
  color: var(--color-text-tertiary) !important;
}

.text-white {
  color: var(--color-text-white) !important;
}

.text-success {
  color: var(--color-success) !important;
}

.text-warning {
  color: var(--color-warning) !important;
}

.text-error {
  color: var(--color-error) !important;
}

.text-info {
  color: var(--color-info) !important;
}

.bg-page {
  background-color: var(--color-bg-page) !important;
}

.bg-card {
  background-color: var(--color-bg-card) !important;
}

.bg-transparent {
  background-color: var(--color-bg-transparent) !important;
}

.border {
  border: 1rpx solid var(--color-border) !important;
}

.divider {
  background-color: var(--color-divider) !important;
}

创建全局主题状态管理 stores/modules/theme.ts

简化版:

ts
// stores/modules/theme.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'

export type ThemeMode = 'light-mode' | 'dusk-mode' | 'dark-mode'

export const useThemeStore = defineStore(
  'theme',
  () => {
    // 当前主题
    const currentTheme = ref<ThemeMode>('light-mode')

    // 根据时间计算主题
    const calculateThemeByTime = (): ThemeMode => {
      const hour = new Date().getHours()
      if (hour >= 18 || hour < 6) return 'dark-mode'
      if (hour >= 16 && hour < 18) return 'dusk-mode'
      return 'light-mode'
    }

    // 自动更新主题
    const updateThemeByTime = () => {
      currentTheme.value = calculateThemeByTime()
    }

    // 手动设置主题
    const setTheme = (theme: ThemeMode) => {
      currentTheme.value = theme
    }

    // 切换主题
    const toggleTheme = () => {
      const themes: ThemeMode[] = ['light-mode', 'dusk-mode', 'dark-mode']
      const currentIndex = themes.indexOf(currentTheme.value)
      const nextIndex = (currentIndex + 1) % themes.length
      currentTheme.value = themes[nextIndex] ?? 'light-mode'
    }

    return {
      currentTheme,
      updateThemeByTime,
      setTheme,
      toggleTheme,
      calculateThemeByTime,
    }
  },
  {
    persist: {
      key: 'theme-store',
      storage: {
        getItem(key) {
          return uni.getStorageSync(key)
        },
        setItem(key, value) {
          uni.setStorageSync(key, value)
        },
      },
    },
  },
)
- 原版对比
ts
// stores/theme.ts
import { defineStore } from 'pinia'
import { computed, onUnmounted, ref } from 'vue'

export type ThemeMode = 'light-mode' | 'dusk-mode' | 'dark-mode'
export type ThemeSource = 'auto' | 'manual'

export const useThemeStore = defineStore(
  'theme',
  () => {
    // 状态
    const currentTheme = ref<ThemeMode>('light-mode')
    const themeSource = ref<ThemeSource>('auto') // 新增:主题来源
    let autoUpdateTimer: ReturnType<typeof setInterval> | null = null    // 新增:自动更新定时器

    // 计算属性
    const isAutoMode = computed(() => themeSource.value === 'auto')
    const suggestedTheme = computed(() => calculateThemeByTime())

    // 根据时间计算主题
    const calculateThemeByTime = (): ThemeMode => {
      const hour = new Date().getHours()
      if (hour >= 18 || hour < 6) return 'dark-mode'
      if (hour >= 16 && hour < 18) return 'dusk-mode'
      return 'light-mode'
    }

    // 自动更新主题
    const updateThemeByTime = () => {
      const newTheme = calculateThemeByTime()
      if (currentTheme.value !== newTheme) {
        currentTheme.value = newTheme
        console.log(`主题自动切换为: ${newTheme}`)
      }
    }

    // 手动设置主题(切换到手动模式)
    const setTheme = (theme: ThemeMode) => {
      currentTheme.value = theme
      themeSource.value = 'manual'
      stopAutoThemeUpdate() // 切换到手动模式时停止自动更新
      console.log(`主题手动设置为: ${theme}`)
    }

    // 切换到自动模式
    const enableAutoTheme = () => {
      themeSource.value = 'auto'
      updateThemeByTime()
      startAutoThemeUpdate()
      console.log('已启用自动主题切换')
    }

    // 启动自动主题更新(智能定时)
    const startAutoThemeUpdate = () => {
      stopAutoThemeUpdate() // 先停止现有定时器

      // 计算到下一个整点的延迟
      const now = new Date()
      const nextHour = new Date(now)
      nextHour.setHours(now.getHours() + 1, 0, 0, 0)
      const delay = nextHour.getTime() - now.getTime()

      // 设置定时器
      setTimeout(() => {
        updateThemeByTime()
        // 之后每小时更新一次
        autoUpdateTimer = setInterval(updateThemeByTime, 60 * 60 * 1000)
      }, delay)
    }

    // 停止自动主题更新
    const stopAutoThemeUpdate = () => {
      if (autoUpdateTimer) {
        clearInterval(autoUpdateTimer)
        autoUpdateTimer = null
      }
    }

    // 切换主题
    const toggleTheme = () => {
      const themes: ThemeMode[] = ['light-mode', 'dusk-mode', 'dark-mode']
      const currentIndex = themes.indexOf(currentTheme.value)
      const nextIndex = (currentIndex + 1) % themes.length
      setTheme(themes[nextIndex] ?? 'light-mode') // 使用 setTheme 来确保模式切换
    }

    // 初始化主题系统
    const initializeTheme = () => {
      if (themeSource.value === 'auto') {
        startAutoThemeUpdate()
      }
    }

    // 清理资源
    onUnmounted(() => {
      stopAutoThemeUpdate()
    })

    return {
      // 状态
      currentTheme,
      themeSource,
      // 计算属性
      isAutoMode,
      suggestedTheme,
      // 方法
      setTheme,
      enableAutoTheme,
      updateThemeByTime,
      toggleTheme,
      initializeTheme,
      stopAutoThemeUpdate,
      calculateThemeByTime,
    }
  },
  {
    persist: {
      key: 'theme-store',
      storage: {
        getItem(key) {
          const value = uni.getStorageSync(key)
          return value ? JSON.parse(value) : null
        },
        setItem(key, value) {
          uni.setStorageSync(key, JSON.stringify(value))
        },
      },
    },
  },
)

功能对比

功能简化版完整版
基础主题切换
自动时间计算
手动/自动模式
智能定时更新✅(整点检查)
模式状态持久化
推荐主题提示
资源自动清理
日志输出
性能优化⚠️ 每分钟检查✅ 每小时检查

使用场景建议

使用简化版的情况:

  • 小型项目
  • 对性能要求不高
  • 不需要手动/自动模式切换
  • 开发原型阶段

使用完整版的情况:

  • 生产环境项目
  • 需要精细控制主题行为
  • 重视性能和用户体验
  • 需要完整的主题管理功能

推荐选择

你的简化版完全够用,因为:

  1. 核心功能齐全
  2. 代码简洁易维护
  3. 满足大部分业务需求
  4. 性能影响可以接受(每分钟检查一次)

只有在需要更精细的控制时才需要升级到完整版。简化版是很实用的设计!👍

- 后期优化方向

根据当地日出日落时间,来精准匹配主题的切换

创建自动主题逻辑:

ts
// services/themeService.ts
import { useThemeStore } from '@/stores/theme'

let themeTimer: number | null = null

export const startAutoThemeUpdate = () => {
  const themeStore = useThemeStore()
  
  // 立即更新一次
  themeStore.updateThemeByTime()
  
  // 设置定时器,每分钟检查一次
  themeTimer = setInterval(() => {
    themeStore.updateThemeByTime()
  }, 60 * 1000) // 1分钟
  
  return () => {
    if (themeTimer) {
      clearInterval(themeTimer)
      themeTimer = null
    }
  }
}

export const stopAutoThemeUpdate = () => {
  if (themeTimer) {
    clearInterval(themeTimer)
    themeTimer = null
  }
}

app.vue 中引入自动逻辑:

vue
<script setup lang="ts">
import { onHide, onLaunch, onShow } from '@dcloudio/uni-app'

import { startAutoThemeUpdate } from '@/services/themeService'
import { useThemeStore } from '@/stores'

// 引入全局样式
import '@/styles/theme.scss'

onLaunch(() => {
  console.log('App Launch')
  // 启动自动主题更新
  startAutoThemeUpdate() 
})
onShow(() => {
  console.log('App Show')
  // 应用从后台恢复时立即更新主题
  const themeStore = useThemeStore() 
  themeStore.updateThemeByTime() 
})
onHide(() => {
  console.log('App Hide')
})
</script>

使用方法

- 点击查看详情

以 headTop.vue 组件为例:

vue
<script setup lang="ts">
import { useThemeStore, useWeatherStore } from '@/stores'
import { computed } from 'vue'

const weatherStore = useWeatherStore() // 全局天气状态
const themeStore = useThemeStore() // 使用全局主题store

// 删除本地的 themeClass、updateThemeByTime、timer 逻辑
// 这些现在由全局store管理

// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()
console.log(safeAreaInsets)

const greeting = computed(() => {
  const hour = new Date().getHours()

  if (hour >= 5 && hour < 9) {
    return '早上好'
  } else if (hour >= 9 && hour < 12) {
    return '上午好'
  } else if (hour >= 12 && hour < 14) {
    return '中午好'
  } else if (hour >= 14 && hour < 18) {
    return '下午好'
  } else {
    return '晚上好'
  }
})
</script>

<template>
  <view
    :class="['navbar', 'theme', themeStore.currentTheme]"
    :style="{ paddingTop: safeAreaInsets?.top + 'px' }"
  >
    <!-- top文字 -->
    <view class="top">
      <view class="top-left">
        <text class="hello-title">{{ greeting }}!</text>
        <text class="hello-dec">感谢您选择云峰农服</text>
      </view>
      <view class="top-right">.</view>
    </view>

    <!-- navBar-bottom -->
    <view class="navbar-bottom">
      <div class="location">
        <text class="iconfont location-icon">&#xe790;</text>
        <text class="info-text">牡丹区</text>
      </div>
      <div class="tianqi">
        <text class="tianqi-temp info-text">{{ weatherStore.weatherData?.tem }}°</text>
        <text class="tianqi-desc info-text">{{ weatherStore.weatherData?.wea }}</text>
      </div>
      <div class="shidu">
        <text class="iconfont shidu-icon">&#xe682;</text>
        <text class="info-text">65%</text>
      </div>
      <div class="air">
        <text class="iconfont air-icon">&#xe60e;</text>
        <text class="info-text air-text">良好</text>
      </div>
    </view>
  </view>
</template>

<style lang="scss">
/* 自定义导航条 */
.navbar {
  background-size: cover;
  position: relative;
  display: flex;
  flex-direction: column;
  padding-top: 10rpx;

  // 使用 CSS 变量定义背景
  background: var(--color-bg-navbar);

  .top {
    padding: 30rpx 30rpx 10rpx 30rpx;
    display: flex;
    justify-content: space-between;
    align-items: flex-start;

    .top-left {
      display: flex;
      flex-direction: column;
      align-items: flex-start;
      gap: 10rpx;
      .hello-title {
        font-size: 40rpx;
        font-weight: 600;
        color: var(--color-text-white); // 使用 CSS 变量
      }
      .hello-dec {
        font-size: 26rpx;
        font-weight: 400;
        color: var(--color-text-white); // 使用 CSS 变量
        letter-spacing: 1rpx;
      }
    }

    .top-right {
      color: var(--color-info); // 使用 CSS 变量
    }
  }

  .navbar-bottom {
    display: flex;
    align-items: center;
    justify-content: space-between;
    height: 80rpx;
    padding: 10rpx 20rpx 50rpx 20rpx;
    margin-top: 30rpx;
    border-radius: 50rpx 50rpx 0 0;
    background-color: var(--color-bg-transparent); // 使用 CSS 变量

    // 统一所有子元素的样式
    .tianqi,
    .location,
    .shidu,
    .air {
      display: flex;
      align-items: center;
      justify-content: center;
      flex: 1;
      padding: 20rpx;
      gap: 8rpx;
      position: relative;

      // 每个元素都有竖杠
      &::after {
        content: '';
        position: absolute;
        right: 0;
        top: 50%;
        transform: translateY(-50%);
        width: 1rpx;
        height: 30rpx;
        background-color: var(--color-border); // 使用 CSS 变量
      }

      // 隐藏最后一个元素的竖杠
      &:last-child::after {
        display: none;
      }
    }

    .air-text {
      margin-left: 8rpx;
    }

    // 统一图标和文字样式
    .location-icon,
    .shidu-icon,
    .air-icon {
      color: var(--color-text-white); // 使用 CSS 变量
    }

    .location-icon {
      font-size: 32rpx;
    }

    .shidu-icon,
    .air-icon {
      font-size: 36rpx;
    }

    .info-text {
      font-size: 28rpx;
      color: var(--color-text-white); // 使用 CSS 变量
    }
  }
}
</style>

已经证实,只需要在尽可能顶级的标签中,添加全局 theme.scss 中定义的主题类名,既可以实现任意调用其中设定好的样式类名,其中还设置了默认主题为 light-theme:

scss
// ==================== 页面级主题类 ====================
.theme {
  // 默认主题
  @include light-theme;

  // 主题类名
  &.light-mode {
    @include light-theme;
  }

  &.dark-mode {
    @include dark-theme;
  }

  &.dusk-mode {
    @include dusk-theme;
  }
}

既可以实现全局样式的任意使用,其中:.theme 就是需要加入的类名;

而不强制必须引入全局 store 状态管理,这说明这是一个纯 css 实现方式, store 只负责管控主题状态!

一旦上级标签加入了这个类名,这么其子孙级标签,就可以直接使用theme.scss定义的全部类名样式,例如:

scss
@mixin light-theme {
  // 背景色系统
  --color-bg-page: #f5f7fa;
  --color-bg-card: #ffffff;
  --color-bg-navbar: linear-gradient(135deg, #03a9f0 20%, #0083fd 90%);
  --color-bg-transparent: rgba(255, 255, 255, 0.2);

  // 文字色系统
  --color-text-primary: #333333;
  --color-text-secondary: #666666;
  --color-text-tertiary: #999999;
  --color-text-white: #ffffff;

  // 功能色
  --color-success: #52c41a;
  --color-warning: #faad14;
  --color-error: #ff4d4f;
  --color-info: #1890ff;

  // 边框
  --color-border: #e0e0e0;
  --color-divider: #f0f0f0;
}

专属坑位

- 点击查看详情

在页面和组件中,直接使用全局定义的样式类,发现不生效!!!

即使是定义了:

scss
:root {
  // 背景色系统默认值
  --color-bg-page: #f5f7fa;
  --color-bg-card: #ffffff;
  --color-bg-navbar: linear-gradient(135deg, #f00366 20%, #0083fd 90%) !important;
  --color-bg-transparent: rgba(255, 255, 255, 0.2);

  // 文字色系统默认值
  --color-text-primary: #333333;
  --color-text-secondary: #666666;
  --color-text-tertiary: #999999;
  --color-text-white: #ffffff;

  // 其他变量...
}

也是没屌用,问题原因:

  1. 小程序无传统 DOM:没有 :root(即 html 元素)
  2. 页面隔离:每个页面是独立的 Webview
  3. 样式作用域:小程序的样式作用域与 Web 不同

使用条件渲染实现tab切换

首先,创建组件:

vue
<!-- components/tab-container/tabCard.vue -->
<template>
  <view class="tab-container theme">
    <!-- 顶部 Tab 区域 -->
    <view class="tab-header">
      <view
        v-for="(tab, index) in tabList"
        :key="index"
        :class="['tab-item', activeIndex === index ? 'active' : '']"
        @click="switchTab(index)"
      >
        <view class="tab-content-wrapper">
          <text class="tab-text">{{ tab.title }}</text>

          <!-- 提示 icon -->
          <view
            v-if="tab.badge || tab.dot || tab.new"
            :class="['tab-badge', tab.dot ? 'dot' : '', tab.new ? 'new-badge' : '']"
          >
            <text
              v-if="tab.badge"
              class="dot"
            >
              {{ tab.badge }}
            </text>
            <text
              v-else-if="tab.new"
              class="new-badge"
            >
              NEW
            </text>
          </view>
        </view>

        <!-- 指示条 -->
        <view
          v-if="activeIndex === index"
          class="tab-indicator"
        ></view>
      </view>
    </view>

    <!-- 内容区域 -->
    <view class="tab-content">
      <view class="content-wrapper">
        <view
          v-for="index in tabList.length"
          :key="index"
          v-show="activeIndex === index - 1"
          :class="['content-item', getSlideDirection(index - 1)]"
        >
          <slot :name="`tab${index - 1}`" />
        </view>
      </view>
    </view>
  </view>
</template>

<script setup lang="ts">
import { defineEmits, defineProps, ref } from 'vue'

// ✅ 定义 Tab 类型接口
interface TabItem {
  title: string
  badge?: number // 数字角标
  dot?: boolean // 红点提示
  new?: boolean // 新标签
  // 其他自定义图标
  // icon?: string;
  // iconActive?: string;
}

// ✅ Props 定义
const props = defineProps({
  tabList: {
    type: Array as () => TabItem[],
    default: () => [{ title: '标签1' }, { title: '标签2' }, { title: '标签3' }],
  },
  initialTab: {
    type: Number,
    default: 0,
  },
  animationType: {
    type: String as () => 'slide' | 'fade' | 'zoom',
    default: 'slide',
    validator: (val: string) => ['slide', 'fade', 'zoom'].includes(val),
  },
})

// ✅ Emits 定义
const emit = defineEmits(['tab-change'])

// 当前选中索引
const activeIndex = ref(props.initialTab)
// 上一个激活的索引,用于判断滑动方向
const prevIndex = ref(props.initialTab)

// ✅ 切换函数
const switchTab = (index: number) => {
  if (activeIndex.value === index) return // 防止重复点击

  prevIndex.value = activeIndex.value
  activeIndex.value = index
  emit('tab-change', { index, tab: props.tabList[index] })
}

// 获取滑动方向类名
const getSlideDirection = (index: number) => {
  if (activeIndex.value !== index) return ''

  const direction = index > prevIndex.value ? 'slide-left' : 'slide-right'
  return `${props.animationType}-${direction}`
}
</script>

<style lang="scss" scoped>
/* -------------------- 通用容器 -------------------- */
.tab-container {
  width: 100%;
  overflow: hidden;
  border-radius: 20rpx;

  .tab-header {
    display: flex;
    background: #fff;
    border-bottom: 1rpx solid #f0f0f0;

    .tab-item {
      flex: 1;
      display: flex;
      flex-direction: column;
      align-items: center;
      padding: 20rpx 0;
      position: relative;
      transition: transform 0.3s ease;
      will-change: transform;

      &:active {
        opacity: 0.6;
        transform: scale(0.98);
        transition: all 0.1s ease;
      }

      .tab-text {
        font-size: 28rpx;
        color: #666;
      }

      &.active {
        .tab-text {
          color: var(--vp-c-brand-3);
          font-weight: 600;
          transform: translateY(-2rpx);
        }
      }

      .tab-indicator {
        position: absolute;
        bottom: 0;
        width: 60rpx;
        height: 4rpx;
        border-radius: 2rpx;
        background: #007aff;
        animation: indicatorSlide 0.3s ease;
        will-change: transform, opacity;
      }

      @keyframes indicatorSlide {
        0% {
          transform: scaleX(0.8);
          opacity: 0;
        }
        100% {
          transform: scaleX(1);
          opacity: 1;
        }
      }

      .tab-content-wrapper {
        display: flex;
        position: relative;

        /* -------------------- 角标 / 红点 / NEW -------------------- */

        // 1️⃣ 数字角标
        .tab-badge.badge {
          position: absolute;
          top: 10rpx;
          right: 10rpx;
          min-width: 32rpx;
          height: 32rpx;
          border-radius: 16rpx;
          padding: 0 8rpx;
          display: flex;
          align-items: center;
          justify-content: center;
          background: #ff3b30;

          .badge-text {
            color: #fff;
            font-size: 16rpx;
            font-weight: 500;
            line-height: 1;
          }
        }

        // 2️⃣ 红点
        .tab-badge.dot {
          position: absolute;
          top: 2rpx;
          right: -20rpx;
          width: 14rpx;
          height: 14rpx;
          border-radius: 50%;
          background: #ff3b30;
          padding: 0;
        }

        // 3️⃣ NEW 提示
        .tab-badge.new-badge {
          position: absolute;
          top: -8rpx;
          right: -42rpx;
          padding: 0 6rpx;
          height: 20rpx;
          border-radius: 10rpx;
          background: #0bb23d;
          color: #fff;
          font-size: 14rpx;
          line-height: 20rpx;
          display: flex;
          align-items: center;
          justify-content: center;
        }
      }
    }
  }

  /* -------------------- 内容区域 -------------------- */
  .tab-content {
    min-height: 465rpx;
    background: #fff;
    position: relative;
    overflow: hidden;

    .content-wrapper {
      position: relative;
      width: 100%;
      height: 100%;

      .content-item {
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        transition: all 0.3s ease;
        will-change: transform, opacity;

        &.slide-slide-left {
          animation: slideInLeft 0.3s ease forwards;
        }

        &.slide-slide-right {
          animation: slideInRight 0.3s ease forwards;
        }

        &.zoom-slide-left,
        &.zoom-slide-right {
          animation: zoomIn 0.3s ease forwards;
        }
      }
    }
  }

  @keyframes slideInLeft {
    0% {
      transform: translateX(100%);
      opacity: 0;
    }
    100% {
      transform: translateX(0);
      opacity: 1;
    }
  }

  @keyframes slideInRight {
    0% {
      transform: translateX(-100%);
      opacity: 0;
    }
    100% {
      transform: translateX(0);
      opacity: 1;
    }
  }

  @keyframes zoomIn {
    0% {
      transform: scale(0.95);
      opacity: 0;
    }
    100% {
      transform: scale(1);
      opacity: 1;
    }
  }
}
</style>

页面中使用:

html
<tab-card
  :tabList="tabs"
  animation-type="slide"
  @tab-change="onTabChange"
>
  <!-- ✅ 必须用 #插槽 写法 -->
  <template #tab0>
    <!-- 进度条组件 -->
    <ProgressBar
      :total-days="120"
      :current-day="49"
      :stages="cropStages"
      ref="progressBarRef"
    />
  </template>
  <template #tab1>
    <ShouyiCard></ShouyiCard>
  </template>
  <template #tab2>
    <view>其他模块</view>
  </template>
</tab-card>

全局 css 样式预定义

直接在 src/uni.scss 文件中:

scss
$brand-1: #34d399;
$brand-2: #10b981;
$brand-3: #018c49;
$brand-4: #047857;
$brand-5: #065f46;

直接定义;

使用时,即可以直接使用定义好的变量:

scss
.shouyi-buttons {
    margin-top: 40rpx;
    display: flex;
    justify-content: space-between;
    button {
      width: 40%;
      padding: 14rpx 0;
      background-color: $brand-2;
      color: #fff;
      font-size: 28rpx;
      border: none;
      border-radius: 20rpx;
    }
  }

自定义tabbar的自动隐藏

- 点击查看详情

数字翻滚组件的设计与实现

实现了两种翻滚策略:

逐字翻滚:

Details
vue
<template>
  <view class="number-scroll-container">
    <view class="number-display">
      <text class="number-value">{{ displayValue }}</text>
      <text
        v-if="unit"
        class="unit"
      >
        {{ unit }}
      </text>
    </view>
    <text
      v-if="title"
      class="title"
    >
      {{ title }}
    </text>
  </view>
</template>

<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'

const props = defineProps({
  // 目标值
  value: {
    type: Number,
    default: 0,
  },
  // 起始值
  startValue: {
    type: Number,
    default: 0,
  },
  // 动画持续时间(毫秒)
  duration: {
    type: Number,
    default: 2000,
  },
  // 单位
  unit: {
    type: String,
    default: '',
  },
  // 标题
  title: {
    type: String,
    default: '',
  },
  // 是否自动开始动画
  autoStart: {
    type: Boolean,
    default: true,
  },
  // 数字格式化函数
  formatter: {
    type: Function,
    default: null,
  },
  // 缓动函数
  easing: {
    type: Function,
    default: (t: number) => 1 - Math.pow(1 - t, 3), // easeOutCubic
  },
  // 数字精度(小数位数)
  precision: {
    type: Number,
    default: 0,
  },
})

const emit = defineEmits(['animation-start', 'animation-end'])

const displayValue = ref(props.startValue)
const animationFrame = ref<ReturnType<typeof setTimeout> | null>(null)

// 监听value变化,重新开始动画
watch(
  () => props.value,
  () => {
    if (props.autoStart) {
      startAnimation()
    }
  },
)

onMounted(() => {
  if (props.autoStart) {
    startAnimation()
  }
})

// 开始动画
const startAnimation = () => {
  stopAnimation() // 停止之前的动画

  const startTime = Date.now()
  const startValue = displayValue.value
  const endValue = props.value
  const difference = endValue - startValue

  emit('animation-start', { startValue, endValue })

  const animate = () => {
    const currentTime = Date.now()
    const elapsed = currentTime - startTime
    const progress = Math.min(elapsed / props.duration, 1)

    // 使用缓动函数
    const easedProgress = props.easing(progress)
    let currentValue = startValue + difference * easedProgress

    // 处理精度
    if (props.precision > 0) {
      currentValue = Number(currentValue.toFixed(props.precision))
    } else {
      currentValue = Math.floor(currentValue)
    }

    // 应用格式化函数
    if (props.formatter) {
      displayValue.value = props.formatter(currentValue)
    } else {
      displayValue.value = currentValue
    }

    if (progress < 1) {
      animationFrame.value = setTimeout(animate, 16) // 约60fps
    } else {
      // 确保最终值准确
      if (props.formatter) {
        displayValue.value = props.formatter(endValue)
      } else {
        displayValue.value = endValue
      }
      emit('animation-end', { startValue, endValue })
    }
  }

  animate()
}

// 停止动画
const stopAnimation = () => {
  if (animationFrame.value) {
    clearTimeout(animationFrame.value)
    animationFrame.value = null
  }
}

// 重置到起始值
const reset = () => {
  stopAnimation()
  displayValue.value = props.startValue
}

// 暴露方法给父组件
defineExpose({
  startAnimation,
  stopAnimation,
  reset,
})
</script>

<style scoped>
.number-scroll-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 20rpx;
}

.number-display {
  display: flex;
  align-items: baseline;
  gap: 8rpx;
}

.number-value {
  font-size: 48rpx;
  font-weight: bold;
  color: #ff6b35;
  font-variant-numeric: tabular-nums; /* 等宽数字 */
}

.unit {
  font-size: 32rpx;
  color: #666;
}

.title {
  font-size: 28rpx;
  color: #666;
  margin-top: 16rpx;
}
</style>

使用方法:

vue
<template>
  <view class="container">
    <!-- 基础用法 -->
    <NumberScroll
      :value="1150"
      :startValue="980"
      unit="℃"
      title="累计活动积温"
    />

    <!-- 自定义样式和动画 -->
    <NumberScroll
      :value="8888.88"
      :startValue="0"
      :duration="5000"
      :precision="2"
      unit="元"
      title="账户余额"
      class="custom-style"
    />

    <!-- 手动控制动画 -->
    <NumberScroll
      ref="manualScroll"
      :value="100"
      :startValue="0"
      :autoStart="false"
      unit="%"
      title="完成进度"
    />

    <button @click="startManualAnimation">开始动画</button>
  </view>
</template>

<script setup>
import NumberScroll from '@/components/numberPlay.vue'
import { ref } from 'vue'

const manualScroll = ref(null)

const startManualAnimation = () => {
  if (manualScroll.value) {
    manualScroll.value.startAnimation()
  }
}
</script>

<style scoped>
.container {
  padding: 40rpx;
}

.custom-style {
  margin-top: 40rpx;
}

.custom-style :deep(.number-value) {
  color: #1890ff;
  font-size: 56rpx;
}

.custom-style :deep(.title) {
  font-weight: bold;
}
</style>

十进位独立翻滚:

vue
<template>
  <view class="digit-roll-container">
    <view class="digits-wrapper">
      <!-- 整数部分 -->
      <view
        v-for="(digit, index) in integerDigits"
        :key="'int-' + index"
        class="digit-column"
      >
        <view
          class="digit-scroll"
          :style="getDigitScrollStyle(digit, index, 'integer')"
        >
          <text
            v-for="num in 10"
            :key="num"
            class="digit-number"
          >
            {{ num - 1 }}
          </text>
        </view>
      </view>

      <!-- 小数点 -->
      <text
        v-if="hasDecimal"
        class="decimal-point"
      >
        .
      </text>

      <!-- 小数部分 -->
      <view
        v-for="(digit, index) in decimalDigits"
        :key="'dec-' + index"
        class="digit-column"
      >
        <view
          class="digit-scroll"
          :style="getDigitScrollStyle(digit, index, 'decimal')"
        >
          <text
            v-for="num in 10"
            :key="num"
            class="digit-number"
          >
            {{ num - 1 }}
          </text>
        </view>
      </view>

      <!-- 单位 -->
      <text
        v-if="unit"
        class="unit"
      >
        {{ unit }}
      </text>
    </view>

    <!-- 标题 -->
    <text
      v-if="title"
      class="title"
    >
      {{ title }}
    </text>
  </view>
</template>

<script setup lang="ts">
import { computed, onMounted, ref, watch, withDefaults } from 'vue'

/** 每个数字列的类型定义 */
interface DigitItem {
  current: number
  target: number
  position: number
}

type DigitType = 'integer' | 'decimal'

const props = withDefaults(
  defineProps<{
    value: number
    startValue?: number
    duration?: number
    unit?: string
    title?: string
    autoStart?: boolean
    precision?: number
  }>(),
  {
    value: 0,
    startValue: 0,
    duration: 2000,
    autoStart: true,
    precision: 0,
  },
)

const integerDigits = ref<DigitItem[]>([])
const decimalDigits = ref<DigitItem[]>([])

const hasDecimal = computed(() => (props.precision ?? 0) > 0)

/** 初始化数字列 */
const initDigits = (): void => {
  const start = Number(props.startValue ?? 0)
  const end = Number(props.value ?? 0)
  const precision = Math.max(0, Math.floor(props.precision ?? 0))

  // 处理负数(如果需要展示负号需额外处理,这里先取绝对值做位数比对)
  const startInt = Math.floor(Math.abs(start))
  const endInt = Math.floor(Math.abs(end))

  const startIntStr = String(startInt)
  const endIntStr = String(endInt)

  const maxIntLength = Math.max(startIntStr.length, endIntStr.length)
  const startIntPadded = startIntStr.padStart(maxIntLength, '0')
  const endIntPadded = endIntStr.padStart(maxIntLength, '0')

  integerDigits.value = startIntPadded.split('').map((digit, index) => ({
    current: parseInt(digit, 10),
    target: parseInt(endIntPadded[index]!, 10), // ✅ 加上 !
    position: index,
  }))

  // 小数部分
  if (precision > 0) {
    // 用绝对值的残余计算,避免浮点问题:先乘然后取整
    const pow = Math.pow(10, precision)
    const startDecimal = Math.round(Math.abs(start - Math.floor(Math.abs(start))) * pow)
    const endDecimal = Math.round(Math.abs(end - Math.floor(Math.abs(end))) * pow)

    const startDecimalStr = String(startDecimal).padStart(precision, '0')
    const endDecimalStr = String(endDecimal).padStart(precision, '0')

    decimalDigits.value = startDecimalStr.split('').map((digit, index) => ({
      current: parseInt(digit, 10),
      target: parseInt(endDecimalStr[index]!, 10), // ✅ 加上 !
      position: index,
    }))
  } else {
    decimalDigits.value = []
  }
}

/** 数字滚动样式 */
const getDigitScrollStyle = (
  digit: DigitItem,
  index: number,
  type: DigitType,
): Record<string, string> => {
  const duration = props.duration ?? 2000
  const delay = type === 'integer' ? (integerDigits.value.length - index - 1) * 100 : index * 100

  // 使用当前值决定 translate
  const translateY = `-${digit.current * 10}%`

  return {
    transform: `translateY(${translateY})`,
    transition: `transform ${duration}ms ${delay}ms cubic-bezier(0.4, 0, 0.2, 1)`,
  }
}

/** 启动动画:把 current 改为 target(触发 CSS transition) */
const startAnimation = (): void => {
  integerDigits.value.forEach(digit => {
    // 直接赋值会触发响应式更新
    digit.current = digit.target
  })
  decimalDigits.value.forEach(digit => {
    digit.current = digit.target
  })
}

/** 监听 value 变化(立即执行一次以初始化) */
watch(
  () => props.value,
  () => {
    initDigits()
    if (props.autoStart ?? true) {
      // 保留少量延迟以确保 DOM 已渲染样式初态
      setTimeout(startAnimation, 100)
    }
  },
  { immediate: true },
)

onMounted(() => {
  // onMounted 在多数情况下 redundant(watch { immediate } 已处理),但保留以防外部直接调用
  initDigits()
  if (props.autoStart ?? true) {
    setTimeout(startAnimation, 100)
  }
})
</script>

<style scoped>
.digit-roll-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 20rpx;
}

.digits-wrapper {
  display: flex;
  align-items: flex-end;
  gap: 2rpx;
}

.digit-column {
  height: 60rpx;
  overflow: hidden;
  position: relative;
}

.digit-scroll {
  display: flex;
  flex-direction: column;
  transition: transform 2s cubic-bezier(0.4, 0, 0.2, 1);
}

.digit-number {
  height: 60rpx;
  font-size: 48rpx;
  font-weight: bold;
  color: #ff6b35;
  font-variant-numeric: tabular-nums;
  line-height: 60rpx;
  text-align: center;
}

.decimal-point {
  font-size: 48rpx;
  font-weight: bold;
  color: #ff6b35;
  margin: 0 4rpx;
}

.unit {
  font-size: 32rpx;
  color: #666;
  margin-left: 8rpx;
}

.title {
  font-size: 28rpx;
  color: #666;
  margin-top: 16rpx;
}
</style>

使用组件:

vue
<template>
  <view>
    <!-- 直接使用组件 -->
    <FastDigitRoll
      :value="currentValue"
      :startValue="oldValue"
      unit="℃"
      title="实时温度"
    />
  </view>
</template>

<script setup lang="ts">
import FastDigitRoll from '@/components/numberPlay10.vue'
import { onMounted, ref } from 'vue'

const oldValue = 1120

const currentValue = ref(oldValue)

onMounted(() => {
  // 模拟数据更新
  setTimeout(() => {
    currentValue.value = 1250
  }, 1000)
})
</script>

翻滚组件的改进

- 点击查看详情

改进版组件,可以自由定制颜色、样式、动画行高:

vue
<template>
  <view class="digit-roll-container">
    <view class="digits-wrapper">
      <!-- 整数部分 -->
      <view
        v-for="(digit, index) in integerDigits"
        :key="'int-' + index"
        class="digit-column"
        :style="getColumnStyle()"
      >
        <view
          class="digit-scroll"
          :style="getDigitScrollStyle(digit, index, 'integer')"
        >
          <text
            v-for="num in 10"
            :key="num"
            class="digit-number"
            :style="getNumberStyle()"
          >
            {{ num - 1 }}
          </text>
        </view>
      </view>

      <!-- 小数点 -->
      <text
        v-if="hasDecimal"
        class="decimal-point"
        :style="getUnitStyle()"
      >
        .
      </text>

      <!-- 小数部分 -->
      <view
        v-for="(digit, index) in decimalDigits"
        :key="'dec-' + index"
        class="digit-column"
        :style="getColumnStyle()"
      >
        <view
          class="digit-scroll"
          :style="getDigitScrollStyle(digit, index, 'decimal')"
        >
          <text
            v-for="num in 10"
            :key="num"
            class="digit-number"
            :style="getNumberStyle()"
          >
            {{ num - 1 }}
          </text>
        </view>
      </view>

      <!-- 单位 -->
      <text
        v-if="unit"
        class="unit"
        :style="getUnitStyle()"
      >
        {{ unit }}
      </text>
    </view>

    <!-- 标题 -->
    <text
      v-if="title"
      class="title"
      :style="getTitleStyle()"
    >
      {{ title }}
    </text>
  </view>
</template>

<script setup lang="ts">
import { computed, onMounted, ref, watch, withDefaults } from 'vue'

/** 每个数字列的类型定义 */
interface DigitItem {
  current: number
  target: number
  position: number
}

type DigitType = 'integer' | 'decimal'

const props = withDefaults(
  defineProps<{
    value: number
    startValue?: number
    duration?: number
    unit?: string
    title?: string
    autoStart?: boolean
    precision?: number
    // 新增样式相关 props
    fontSize?: number | string
    fontWeight?: string | number
    color?: string
    unitColor?: string
    titleColor?: string
    titleSize?: number | string
    lineHeight?: number | string
    digitHeight?: number | string // 新增:数字列高度
  }>(),
  {
    value: 0,
    startValue: 0,
    duration: 2000,
    autoStart: true,
    precision: 0,
    // 默认样式值
    fontSize: '48rpx',
    fontWeight: 'bold',
    color: '#ff6b35',
    unitColor: '',
    titleColor: '',
    titleSize: '',
    lineHeight: '60rpx',
    digitHeight: '60rpx', // 默认高度
  },
)

const integerDigits = ref<DigitItem[]>([])
const decimalDigits = ref<DigitItem[]>([])

const hasDecimal = computed(() => (props.precision ?? 0) > 0)

/** 数字列样式 */
const getColumnStyle = (): Record<string, string> => {
  return {
    height: typeof props.digitHeight === 'number' ? `${props.digitHeight}rpx` : props.digitHeight,
    overflow: 'hidden',
  }
}

/** 数字样式 */
const getNumberStyle = (): Record<string, string> => {
  const digitHeight =
    typeof props.digitHeight === 'number' ? `${props.digitHeight}rpx` : props.digitHeight
  const lineHeight = props.lineHeight || digitHeight // 如果没有设置lineHeight,使用digitHeight

  const style: Record<string, string> = {
    height: digitHeight,
    'line-height': typeof lineHeight === 'number' ? `${lineHeight}rpx` : lineHeight,
    'font-size': typeof props.fontSize === 'number' ? `${props.fontSize}rpx` : props.fontSize,
    'font-weight': String(props.fontWeight),
    color: props.color,
    'text-align': 'center',
    'font-variant-numeric': 'tabular-nums',
  }
  return style
}

/** 单位和小数点样式 */
const getUnitStyle = (): Record<string, string> => {
  const digitHeight =
    typeof props.digitHeight === 'number' ? `${props.digitHeight}rpx` : props.digitHeight
  const lineHeight = props.lineHeight || digitHeight
  const unitColor = props.unitColor || props.color

  const style: Record<string, string> = {
    height: digitHeight,
    'line-height': typeof lineHeight === 'number' ? `${lineHeight}rpx` : lineHeight,
    'font-size': typeof props.fontSize === 'number' ? `${props.fontSize}rpx` : props.fontSize,
    'font-weight': String(props.fontWeight),
    color: unitColor,
  }
  return style
}

/** 标题样式 */
const getTitleStyle = (): Record<string, string> => {
  const titleColor = props.titleColor || props.color
  const titleSize = props.titleSize || props.fontSize
  const style: Record<string, string> = {
    'font-size': typeof titleSize === 'number' ? `${titleSize}rpx` : titleSize,
    color: titleColor,
  }
  return style
}

/** 初始化数字列 */
const initDigits = (): void => {
  const start = Number(props.startValue ?? 0)
  const end = Number(props.value ?? 0)
  const precision = Math.max(0, Math.floor(props.precision ?? 0))

  // 处理负数(如果需要展示负号需额外处理,这里先取绝对值做位数比对)
  const startInt = Math.floor(Math.abs(start))
  const endInt = Math.floor(Math.abs(end))

  const startIntStr = String(startInt)
  const endIntStr = String(endInt)

  const maxIntLength = Math.max(startIntStr.length, endIntStr.length)
  const startIntPadded = startIntStr.padStart(maxIntLength, '0')
  const endIntPadded = endIntStr.padStart(maxIntLength, '0')

  integerDigits.value = startIntPadded.split('').map((digit, index) => ({
    current: parseInt(digit, 10),
    target: parseInt(endIntPadded[index]!, 10),
    position: index,
  }))

  // 小数部分
  if (precision > 0) {
    const pow = Math.pow(10, precision)
    const startDecimal = Math.round(Math.abs(start - Math.floor(Math.abs(start))) * pow)
    const endDecimal = Math.round(Math.abs(end - Math.floor(Math.abs(end))) * pow)

    const startDecimalStr = String(startDecimal).padStart(precision, '0')
    const endDecimalStr = String(endDecimal).padStart(precision, '0')

    decimalDigits.value = startDecimalStr.split('').map((digit, index) => ({
      current: parseInt(digit, 10),
      target: parseInt(endDecimalStr[index]!, 10),
      position: index,
    }))
  } else {
    decimalDigits.value = []
  }
}

/** 数字滚动样式 */
const getDigitScrollStyle = (
  digit: DigitItem,
  index: number,
  type: DigitType,
): Record<string, string> => {
  const duration = props.duration ?? 2000
  const delay = type === 'integer' ? (integerDigits.value.length - index - 1) * 100 : index * 100

  const translateY = `-${digit.current * 10}%`

  return {
    transform: `translateY(${translateY})`,
    transition: `transform ${duration}ms ${delay}ms cubic-bezier(0.4, 0, 0.2, 1)`,
  }
}

/** 启动动画:把 current 改为 target(触发 CSS transition) */
const startAnimation = (): void => {
  integerDigits.value.forEach(digit => {
    digit.current = digit.target
  })
  decimalDigits.value.forEach(digit => {
    digit.current = digit.target
  })
}

/** 监听 value 变化 */
watch(
  () => props.value,
  () => {
    initDigits()
    if (props.autoStart ?? true) {
      setTimeout(startAnimation, 100)
    }
  },
  { immediate: true },
)

onMounted(() => {
  initDigits()
  if (props.autoStart ?? true) {
    setTimeout(startAnimation, 100)
  }
})
</script>

<style scoped>
.digit-roll-container {
  display: flex;
  flex-direction: column;
  align-items: center;
}

.digits-wrapper {
  display: flex;
  align-items: center;
}

.digit-column {
  overflow: hidden;
}

.digit-scroll {
  display: flex;
  flex-direction: column;
}

.digit-number {
  text-align: center;
  font-variant-numeric: tabular-nums;
}

.unit {
  margin-left: 8rpx;
}

.decimal-point {
  margin: 0 4rpx;
}

.title {
  margin-top: 16rpx;
  text-align: center;
}
</style>

现在,可以这样使用:

html
<template>
  <digit-roll
    :value="1234.56"
    :precision="2"
    unit="元"
    title="总金额"
    :font-size="52"
    :digit-height="80"
    :line-height="80"
    color="#1890ff"
  />
</template>

其中,:precision="2" 代表小数精度,默认为0,也就是说,当传递小数进去时,需要相应的修改该值,否则不显示小数部分!

echarts 表格的使用

Details

使用lime-ui中的echarts

一是版本问题,目前lime-ui仅支持到 echarts5,详见 lime-ui 官网。

二是 两个依赖需要安装,pnpm add zrenderpnpm add tslib

pnpm install echarts zrender tslib

三是按需引入的问题

「按需引入 ECharts 之后,包体积比未使用时多出约 1.4 MB」的情况,正好处于 ECharts 最小可用内核体积的正常范围


📊 我们来拆解一下这 1.4 MB 到底装了什么:

即使你只想画一张简单的折线图(LineChart),ECharts 仍然必须包含:

模块作用约占体积
echarts/core核心调度系统(事件、调度、坐标系、动画、数据管理)~400 KB
GridComponent坐标系网格(折线图、柱状图必需)~200 KB
TooltipComponent提示框~150 KB
LegendComponent图例~100 KB
TitleComponent标题支持~60 KB
CanvasRendererCanvas 渲染引擎~300 KB
运行时工具(zrender/util、绘图指令、颜色工具等)内部依赖~150 KB
合计≈ 1.3 – 1.6 MB

推荐的最佳实践方案

✅ 方案 1:创建一个全局 echarts 封装模块(最常用)

步骤:

  1. 在项目根目录新建一个文件(例如 /utils/echarts.js):

    js
    // utils/echarts.js
    import * as echarts from 'echarts/core'
    import {
      LineChart,
      BarChart,
      PieChart,
      RadarChart,
    } from 'echarts/charts'
    import {
      TitleComponent,
      TooltipComponent,
      GridComponent,
      LegendComponent,
    } from 'echarts/components'
    import { CanvasRenderer } from 'echarts/renderers'
    import { UniversalTransition } from 'echarts/features'
    
    echarts.use([
      TitleComponent,
      TooltipComponent,
      GridComponent,
      LegendComponent,
      LineChart,
      BarChart,
      PieChart,
      RadarChart,
      CanvasRenderer,
      UniversalTransition,
    ])
    
    export default echarts
  2. 调用位置:

    vue
    <template>
      <view style="height: 750rpx">
        <l-echart ref="chartRef"></l-echart>
        <text>test</text>
      </view>
    </template>
    
    <script setup lang="ts">
    import echarts from '@/utils/echarts'
    import { onMounted, ref } from 'vue'
    // 使用 ref 引用图表实例
    const chartRef = ref()
    
    // 图表配置项
    const option = ref({
      tooltip: {
        trigger: 'axis',
        axisPointer: {
          type: 'cross',
          label: {
            backgroundColor: '#6a7985',
          },
        },
      },
      legend: {
        data: ['邮件营销', '联盟广告', '视频广告', '直接访问', '搜索引擎'],
      },
      grid: {
        left: '3%',
        right: '4%',
        bottom: '3%',
        containLabel: true,
      },
      xAxis: [
        {
          type: 'category',
          boundaryGap: false,
          data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
        },
      ],
      yAxis: [
        {
          type: 'value',
        },
      ],
      series: [
        {
          name: '邮件营销',
          type: 'line',
          stack: '总量',
          areaStyle: {},
          emphasis: {
            focus: 'series',
          },
          data: [120, 132, 101, 134, 90, 230, 210],
        },
        {
          name: '联盟广告',
          type: 'line',
          stack: '总量',
          areaStyle: {},
          emphasis: {
            focus: 'series',
          },
          data: [220, 182, 191, 234, 290, 330, 310],
        },
        {
          name: '视频广告',
          type: 'line',
          stack: '总量',
          areaStyle: {},
          emphasis: {
            focus: 'series',
          },
          data: [150, 232, 201, 154, 190, 330, 410],
        },
        {
          name: '直接访问',
          type: 'line',
          stack: '总量',
          areaStyle: {},
          emphasis: {
            focus: 'series',
          },
          data: [320, 332, 301, 334, 390, 330, 320],
        },
        {
          name: '搜索引擎',
          type: 'line',
          stack: '总量',
          label: {
            show: true,
            position: 'top',
          },
          areaStyle: {},
          emphasis: {
            focus: 'series',
          },
          data: [820, 932, 901, 934, 1290, 1330, 1320],
        },
      ],
    })
    
    // 初始化图表
    onMounted(() => {
      if (chartRef.value) {
        chartRef.value.init(echarts, (chart: echarts.ECharts) => {
          chart.setOption(option.value)
        })
      }
    })
    </script>

echarts 专属坑位 - 分包

echarts 实战技巧

基本配置

js
const initTemperatureChart = (): EChartsOption => {
  const xData = getTimeLabels()
  const yData = getCurrentData()

  // 动态积温线配置
  const accumulatedTempSeries: LineSeriesOption = {
    name: accumulatedTemperatureData.value.name,
    type: 'line',
    data: new Array(xData.length).fill(accumulatedTemperatureData.value.value),
    lineStyle: {
      color: accumulatedTemperatureData.value.color || '#59aaf8',
      width: 2,
    },
    symbol: 'none',
    tooltip: {
      trigger: 'axis',
      formatter: (params: unknown) => {
        const paramsArray = (Array.isArray(params) ? params : [params]) as Array<{
          seriesName: string
          value: number
        }>

        const tempLine = paramsArray.find(
          item => item.seriesName === accumulatedTemperatureData.value.name,
        )
        if (tempLine) {
          return `${tempLine.seriesName}: ${tempLine.value}°C`
        }
        return ''
      },
    },
    label: { show: false },
    itemStyle: { opacity: 0 },
  }

  const series: LineSeriesOption[] = [
    // 主温度线
    {
      name: '温度',
      type: 'line',
      smooth: true,
      data: yData,
      symbolSize: 8,
      itemStyle: {
        color: '#0BC677',
        borderColor: '#fff',
        borderWidth: 2,
      },
      lineStyle: { color: '#0BC677', width: 2 },
      areaStyle: {
        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
          { offset: 0, color: 'rgba(10,198,119,0.3)' },
          { offset: 1, color: 'rgba(10,198,119,0.1)' },
        ]),
      },
    },
    // 动态积温线
    accumulatedTempSeries,
  ]

  // 动态高温警戒线 - 根据当前作物显示
  const highTempThreshold = accumulatedTemperatureData.value.highTempThreshold
  if (highTempThreshold && Math.max(...yData) >= highTempThreshold - 5) {
    // 当最高温度接近高温警戒线时显示(阈值-5度)
    series.push({
      name: '高温警戒线',
      type: 'line',
      data: new Array(xData.length).fill(highTempThreshold),
      lineStyle: {
        color: accumulatedTemperatureData.value.highTempColor || '#f44336',
        width: 2,
        type: 'dashed',
      },
      symbol: 'none',
      tooltip: {
        trigger: 'axis',
        formatter: () => {
          return `高温警戒线: ${highTempThreshold}°C`
        },
      },
      label: {
        show: true,
        formatter: `${highTempThreshold}°C`,
        color: accumulatedTemperatureData.value.highTempColor || '#f44336',
        fontWeight: 'bold',
      },
      itemStyle: { opacity: 0 },
    })
  }

  return {
    title: {
      text: '温度变化',
      left: '10',
      subtext: `当前积温:1542`,
      subtextStyle: {
        fontSize: 12,
        color: '#666',
      },
    },
    tooltip: { trigger: 'axis' },
    legend: {
      data: series.map(s => s.name!) as string[],
      top: 16,
      right: 10,
    },
    grid: {
      top: '20%', // 增加顶部空间以容纳副标题
      left: '3%',
      right: '1%',
      bottom: '6%',
      containLabel: true,
    },
    xAxis: [
      {
        type: 'category',
        boundaryGap: false,
        data: xData,
      },
    ],
    yAxis: [
      {
        type: 'value',
        name: '',
        nameTextStyle: {
          color: '#666',
          fontSize: 16,
          fontWeight: 'normal',
          padding: [0, 0, 5, 125],
        },
        interval: 10,
        max: 45,
        axisLine: {
          show: true,
          lineStyle: {
            color: '#e6e6e6',
            width: 1,
          },
        },
        splitLine: {
          show: true,
          lineStyle: {
            type: 'dashed',
            color: '#e0e0e0',
            width: 1,
          },
        },
        axisLabel: {
          color: '#666',
          fontSize: 12,
          fontWeight: 'normal',
        },
      },
    ],
    series,
  }
}

动态计算y轴的比例

js
const initTemperatureChart = (): EChartsOption => {
  const xData = getTimeLabels()
  const yData = getCurrentData()

  // 动态计算 Y 轴配置
  const yAxisConfig = calculateYAxisConfig(yData, 'temperature')

  // 动态积温线配置
  const accumulatedTempSeries: LineSeriesOption = {
    name: accumulatedTemperatureData.value.name,
    type: 'line',
    data: new Array(xData.length).fill(accumulatedTemperatureData.value.value),
    lineStyle: {
      color: accumulatedTemperatureData.value.color || '#59aaf8',
      width: 2,
    },
    symbol: 'none',
    tooltip: {
      trigger: 'axis',
      formatter: (params: unknown) => {
        const paramsArray = (Array.isArray(params) ? params : [params]) as Array<{
          seriesName: string
          value: number
        }>

        const tempLine = paramsArray.find(
          item => item.seriesName === accumulatedTemperatureData.value.name,
        )
        if (tempLine) {
          return `${tempLine.seriesName}: ${tempLine.value}°C`
        }
        return ''
      },
    },
    label: { show: false },
    itemStyle: { opacity: 0 },
  }

  const series: LineSeriesOption[] = [
    // 主温度线
    {
      name: '温度',
      type: 'line',
      smooth: true,
      data: yData,
      symbolSize: 8,
      itemStyle: {
        color: '#0BC677',
        borderColor: '#fff',
        borderWidth: 2,
      },
      lineStyle: { color: '#0BC677', width: 2 },
      areaStyle: {
        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
          { offset: 0, color: 'rgba(10,198,119,0.3)' },
          { offset: 1, color: 'rgba(10,198,119,0.1)' },
        ]),
      },
    },
    // 动态积温线
    accumulatedTempSeries,
  ]

  // 动态高温警戒线 - 根据当前作物显示
  const highTempThreshold = accumulatedTemperatureData.value.highTempThreshold
  if (highTempThreshold && Math.max(...yData) >= highTempThreshold - 5) {
    // 当最高温度接近高温警戒线时显示(阈值-5度)
    series.push({
      name: '高温警戒线',
      type: 'line',
      data: new Array(xData.length).fill(highTempThreshold),
      lineStyle: {
        color: accumulatedTemperatureData.value.highTempColor || '#f44336',
        width: 2,
        type: 'dashed',
      },
      symbol: 'none',
      tooltip: {
        trigger: 'axis',
        formatter: () => {
          return `高温警戒线: ${highTempThreshold}°C`
        },
      },
      label: {
        show: true,
        formatter: `${highTempThreshold}°C`,
        color: accumulatedTemperatureData.value.highTempColor || '#f44336',
        fontWeight: 'bold',
      },
      itemStyle: { opacity: 0 },
    })
  }

  return {
    title: {
      text: '温度变化',
      left: '10',
      subtext: `当前积温:1542`,
      subtextStyle: {
        fontSize: 12,
        color: '#666',
      },
    },
    tooltip: { trigger: 'axis' },
    legend: {
      data: series.map(s => s.name!) as string[],
      top: 16,
      right: 10,
    },
    grid: {
      top: '20%', // 增加顶部空间以容纳副标题
      left: '3%',
      right: '1%',
      bottom: '6%',
      containLabel: true,
    },
    xAxis: [
      {
        type: 'category',
        boundaryGap: false,
        data: xData,
      },
    ],
    yAxis: [
      {
        type: 'value',
        name: '',
        nameTextStyle: {
          color: '#666',
          fontSize: 16,
          fontWeight: 'normal',
          padding: [0, 0, 5, 125],
        },
        min: yAxisConfig.min,
        max: yAxisConfig.max,
        interval: yAxisConfig.interval,
        axisLine: {
          show: true,
          lineStyle: {
            color: '#e6e6e6',
            width: 1,
          },
        },
        splitLine: {
          show: true,
          lineStyle: {
            type: 'dashed',
            color: '#e0e0e0',
            width: 1,
          },
        },
        axisLabel: {
          color: '#666',
          fontSize: 12,
          fontWeight: 'normal',
        },
      },
    ],
    series,
  }
}

依赖函数:

Released under the MIT License.