横向滑动组件
- 点击查看详情
/src/pages/index/components/videoList.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样式,仅对元素集的其实元素左侧生效;
问题不在于 padding 或 margin 的选择,而在于滚动容器的视口边界计算机制:
- 滚动容器的滚动边界是基于内容边界计算的
- 任何
padding或margin都只是在容器外部或内部创建空间 - 但滚动停止位置始终是内容边界对齐视口边界
.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
// 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
简化版:
// 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)
},
},
},
},
)- 原版对比
// 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))
},
},
},
},
)功能对比
| 功能 | 简化版 | 完整版 |
|---|---|---|
| 基础主题切换 | ✅ | ✅ |
| 自动时间计算 | ✅ | ✅ |
| 手动/自动模式 | ❌ | ✅ |
| 智能定时更新 | ❌ | ✅(整点检查) |
| 模式状态持久化 | ❌ | ✅ |
| 推荐主题提示 | ❌ | ✅ |
| 资源自动清理 | ❌ | ✅ |
| 日志输出 | ❌ | ✅ |
| 性能优化 | ⚠️ 每分钟检查 | ✅ 每小时检查 |
使用场景建议
使用简化版的情况:
- 小型项目
- 对性能要求不高
- 不需要手动/自动模式切换
- 开发原型阶段
使用完整版的情况:
- 生产环境项目
- 需要精细控制主题行为
- 重视性能和用户体验
- 需要完整的主题管理功能
推荐选择
你的简化版完全够用,因为:
- 核心功能齐全
- 代码简洁易维护
- 满足大部分业务需求
- 性能影响可以接受(每分钟检查一次)
只有在需要更精细的控制时才需要升级到完整版。简化版是很实用的设计!👍
- 后期优化方向
根据当地日出日落时间,来精准匹配主题的切换
创建自动主题逻辑:
// 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 中引入自动逻辑:
<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 组件为例:
<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"></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"></text>
<text class="info-text">65%</text>
</div>
<div class="air">
<text class="iconfont air-icon"></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:
// ==================== 页面级主题类 ====================
.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定义的全部类名样式,例如:
@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;
}专属坑位
- 点击查看详情
在页面和组件中,直接使用全局定义的样式类,发现不生效!!!
即使是定义了:
: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;
// 其他变量...
}也是没屌用,问题原因:
- 小程序无传统 DOM:没有
:root(即html元素) - 页面隔离:每个页面是独立的 Webview
- 样式作用域:小程序的样式作用域与 Web 不同
使用条件渲染实现tab切换
首先,创建组件:
<!-- 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>页面中使用:
<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 文件中:
$brand-1: #34d399;
$brand-2: #10b981;
$brand-3: #018c49;
$brand-4: #047857;
$brand-5: #065f46;直接定义;
使用时,即可以直接使用定义好的变量:
.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
<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>使用方法:
<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>十进位独立翻滚:
<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>使用组件:
<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>翻滚组件的改进
- 点击查看详情
改进版组件,可以自由定制颜色、样式、动画行高:
<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>现在,可以这样使用:
<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 zrender和pnpm 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 |
CanvasRenderer | Canvas 渲染引擎 | ~300 KB |
运行时工具(zrender/util、绘图指令、颜色工具等) | 内部依赖 | ~150 KB |
| 合计 | — | ≈ 1.3 – 1.6 MB |
推荐的最佳实践方案
✅ 方案 1:创建一个全局 echarts 封装模块(最常用)
步骤:
在项目根目录新建一个文件(例如
/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调用位置:
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 实战技巧
基本配置
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轴的比例
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,
}
}依赖函数: