Files
medical-mall/mall/pages/dashboard/UserTrendChart.uvue

371 lines
7.3 KiB
Plaintext

<template>
<view class="chart-container">
<!-- 图表标题与切换 -->
<view class="chart-header">
<view class="header-left">
<view class="title-icon">
<text class="iconfont">≡</text>
</view>
<text class="chart-title">用户增长趋势</text>
</view>
<view class="chart-controls">
<view
v-for="p in periods"
:key="p.value"
class="seg-btn"
:class="{ active: range === p.value }"
@click="changeRange(p.value)"
>
<text class="seg-btn-text">{{ p.label }}</text>
</view>
</view>
</view>
<!-- 图表内容 -->
<view class="chart-body">
<view class="axis-titles">
<text class="axis-title">新增用户 (人)</text>
</view>
<view class="chart-view-wrapper">
<EChartsView
v-if="!loading && chartOption"
:option="chartOption"
class="echarts-view"
/>
<!-- Loading 状态 -->
<view v-if="loading" class="loading-state">
<text class="loading-text">加载中...</text>
</view>
<!-- 空数据状态 -->
<view v-if="!loading && trendData.length === 0" class="empty-state">
<text class="empty-text">暂无趋势数据</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed, onMounted, watch } from 'vue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import { UserTrend } from '@/types/charts.uts'
/**
* UserTrendChart 用户趋势图
* 展示日粒度用户增长,采用平滑曲线面积图
*/
// --- 状态定义 ---
const loading = ref(false)
const range = ref('7d')
const trendData = ref<UserTrend[]>([])
const periods = [
{ label: '7天', value: '7d' },
{ label: '30天', value: '30d' }
]
// --- ECharts 配置计算属性 ---
const chartOption = computed(() : any => {
if (trendData.value.length === 0) return null
const xData = trendData.value.map(item => item.date)
const yData = trendData.value.map(item => item.count)
return {
tooltip: {
trigger: 'axis',
axisPointer: {
lineStyle: { color: '#1890ff', type: 'dashed' }
}
},
grid: {
left: '3%',
right: '4%',
bottom: '10%',
top: '5%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: xData,
axisLine: { lineStyle: { color: '#f0f0f0' } },
axisLabel: {
color: '#999',
fontSize: 10,
rotate: xData.length > 7 ? 45 : 0
}
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { type: 'dashed', color: '#f5f5f5' } },
axisLabel: { color: '#999', fontSize: 10 }
},
series: [
{
name: '新增用户',
type: 'line',
data: yData,
smooth: true, // 平滑折线
showSymbol: true,
symbol: 'circle',
symbolSize: 6,
itemStyle: {
color: '#1890ff',
borderColor: '#fff',
borderWidth: 2
},
lineStyle: {
width: 3,
color: '#1890ff'
},
// 区域填充效果
areaStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(24, 144, 255, 0.25)' },
{ offset: 1, color: 'rgba(24, 144, 255, 0)' }
]
}
},
emphasis: {
focus: 'series'
}
}
]
}
})
// --- 方法定义 ---
const changeRange = (v: string) => {
range.value = v
}
/**
* 模拟接口请求
* 使用 useRequest 模式的封装
*/
const fetchData = (r: string) => {
loading.value = true
// 模拟业务逻辑
setTimeout(() => {
const mockData: UserTrend[] = []
const count = r === '7d' ? 7 : 30
const now = new Date()
for (let i = count - 1; i >= 0; i--) {
const d = new Date(now.getTime() - i * 24 * 60 * 60 * 1000)
mockData.push({
date: `${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`,
count: Math.floor(Math.random() * 80) + (i % 7 === 0 ? 50 : 20)
} as UserTrend)
}
trendData.value = mockData
loading.value = false
}, 800)
}
// 暴露方法给外部调用
defineExpose({
refresh: () => fetchData(range.value)
})
// --- 生命周期与监听 ---
onMounted(() => {
fetchData(range.value)
})
watch(range, (newVal) => {
fetchData(newVal)
})
</script>
<style scoped lang="scss">
.chart-container {
background: #ffffff;
border-radius: 4px;
border: 1px solid #f0f0f0;
padding: 16px;
}
.chart-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header-left {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.title-icon {
width: 24px;
height: 24px;
background: #e6f7ff;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.chart-title {
font-size: 15px;
font-weight: 500;
color: #333;
}
.chart-controls {
display: flex;
flex-direction: row;
border: 1px solid #d9d9d9;
border-radius: 4px;
overflow: hidden;
}
.seg-btn {
height: 26px;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: center;
border-left: 1px solid #d9d9d9;
background: #fff;
}
.seg-btn:first-child {
border-left: 0;
}
.seg-btn.active {
background: #1890ff;
border-color: #1890ff;
}
.seg-btn-text {
font-size: 12px;
color: #333;
}
.seg-btn.active .seg-btn-text {
color: #fff;
}
.chart-body {
position: relative;
}
.axis-titles {
display: flex;
flex-direction: row;
margin-bottom: 8px;
}
.axis-title {
font-size: 11px;
color: #999;
}
.chart-view-wrapper {
width: 100%;
height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
.echarts-view {
width: 100%;
height: 100%;
}
.loading-state, .empty-state {
display: flex;
align-items: center;
justify-content: center;
}
.loading-text, .empty-text {
color: #999;
font-size: 14px;
}
</style>
// --- Data Fetching ---
const fetchTrendData = () => {
loading.value = true
// 模拟 API 请求: GET /api/user/trend
setTimeout(() => {
const mock: UserTrend[] = []
const now = new Date()
for (let i = 14; i >= 0; i--) {
const d = new Date(now.getTime() - i * 24 * 60 * 60 * 1000)
mock.push({
date: `${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`,
count: Math.floor(Math.random() * 50) + 20 + (i === 5 ? 100 : 0)
})
}
trendData.value = mock
loading.value = false
}, 600)
}
onMounted(() => {
fetchTrendData()
})
// 暴露刷新接口
defineExpose({
refresh: fetchTrendData
})
</script>
<style scoped lang="scss">
.chart-container {
width: 100%;
height: 100%;
}
.chart-wrapper {
width: 100%;
height: 300px;
position: relative;
}
.echarts-view {
width: 100%;
height: 100%;
}
.loading-overlay, .empty-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255,255,255,0.6);
}
.loading-text, .empty-text {
color: #999;
font-size: 13px;
}
</style>