410 lines
12 KiB
Plaintext
410 lines
12 KiB
Plaintext
<template>
|
|
<view class="admin-marketing-integral-statistic">
|
|
<view class="content-body">
|
|
<!-- 顶部时间选择 -->
|
|
<view class="filter-card border-shadow">
|
|
<view class="filter-item">
|
|
<text class="label-txt">时间选择:</text>
|
|
<AnalyticsDateRangePicker
|
|
:initialStartDate="startDate"
|
|
:initialEndDate="endDate"
|
|
@apply="onApplyRange"
|
|
@clear="onClearRange"
|
|
/>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- 核心指标卡片 -->
|
|
<view class="stats-row">
|
|
<view class="stat-card border-shadow">
|
|
<view class="sc-left bg-blue">
|
|
<text class="sc-icon">💠</text>
|
|
</view>
|
|
<view class="sc-right">
|
|
<text class="sc-val">{{ statsTotal.current.toFixed(2) }}</text>
|
|
<text class="sc-label">当前积分</text>
|
|
</view>
|
|
</view>
|
|
<view class="stat-card border-shadow">
|
|
<view class="sc-left bg-orange">
|
|
<text class="sc-icon">🪙</text>
|
|
</view>
|
|
<view class="sc-right">
|
|
<text class="sc-val">{{ statsTotal.income.toFixed(2) }}</text>
|
|
<text class="sc-label">累计总积分</text>
|
|
</view>
|
|
</view>
|
|
<view class="stat-card border-shadow">
|
|
<view class="sc-left bg-green">
|
|
<text class="sc-icon">💎</text>
|
|
</view>
|
|
<view class="sc-right">
|
|
<text class="sc-val">{{ statsTotal.expend.toFixed(2) }}</text>
|
|
<text class="sc-label">累计消耗积分</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- 积分使用趋势 -->
|
|
<view class="chart-card border-shadow">
|
|
<view class="chart-header">
|
|
<text class="chart-title">积分使用趋势</text>
|
|
<view class="chart-legend">
|
|
<text class="down-ic">📥</text>
|
|
</view>
|
|
</view>
|
|
<view class="chart-body">
|
|
<AnalyticsMultiLineChart :xLabels="dates" :series="trendSeries" :height="350" />
|
|
</view>
|
|
</view>
|
|
|
|
<!-- 底部两个分析卡片 -->
|
|
<view class="bottom-analysis">
|
|
<!-- 积分来源分析 -->
|
|
<view class="analysis-card border-shadow">
|
|
<view class="analysis-header">
|
|
<text class="ah-title">积分来源分析</text>
|
|
<view class="btn-toggle" @click="toggleSourceStyle">
|
|
<text class="toggle-txt">切换样式</text>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="analysis-content">
|
|
<!-- 饼图样式 -->
|
|
<view v-if="sourceStyle === 'pie'" class="pie-layout-new anim-fade">
|
|
<AnalyticsPieChart :items="sourceData" :height="300" />
|
|
</view>
|
|
|
|
<!-- 列表样式 -->
|
|
<view v-else class="list-layout anim-fade">
|
|
<view class="list-head">
|
|
<text class="lh-col" style="width: 50px;">来源</text>
|
|
<text class="lh-col" style="flex: 1; text-align: center;">金额</text>
|
|
<text class="lh-col" style="width: 200px; text-align: right;">占比率</text>
|
|
</view>
|
|
<view class="list-body">
|
|
<view v-for="(item, index) in sourceData" :key="item.label" class="list-row">
|
|
<view class="lr-rank"><text class="rank-txt">{{ index + 1 }}</text></view>
|
|
<text class="lr-label">{{ item.label }}</text>
|
|
<text class="lr-val">{{ item.value }}</text>
|
|
<view class="lr-progress-box">
|
|
<view class="prog-bg">
|
|
<view class="prog-inner" :style="{width: item.percent + '%'}"></view>
|
|
</view>
|
|
<text class="prog-txt">{{ item.percent }}%</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- 积分消耗分析 -->
|
|
<view class="analysis-card border-shadow">
|
|
<view class="analysis-header">
|
|
<text class="ah-title">积分消耗</text>
|
|
<view class="btn-toggle" @click="toggleConsumeStyle">
|
|
<text class="toggle-txt">切换样式</text>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="analysis-content">
|
|
<!-- 饼图样式 -->
|
|
<view v-if="consumeStyle === 'pie'" class="pie-layout-new anim-fade">
|
|
<AnalyticsPieChart :items="consumeData" :height="300" />
|
|
</view>
|
|
|
|
<!-- 列表样式 -->
|
|
<view v-else class="list-layout anim-fade">
|
|
<view class="list-head">
|
|
<text class="lh-col" style="width: 50px;">来源</text>
|
|
<text class="lh-col" style="flex: 1; text-align: center;">金额</text>
|
|
<text class="lh-col" style="width: 200px; text-align: right;">占比率</text>
|
|
</view>
|
|
<view class="list-body">
|
|
<view v-for="(item, index) in consumeData" :key="item.label" class="list-row">
|
|
<view class="lr-rank"><text class="rank-txt">{{ index + 1 }}</text></view>
|
|
<text class="lr-label">{{ item.label }}</text>
|
|
<text class="lr-val">{{ item.value }}</text>
|
|
<view class="lr-progress-box">
|
|
<view class="prog-bg">
|
|
<view class="prog-inner" :style="{width: item.percent + '%'}"></view>
|
|
</view>
|
|
<text class="prog-txt">{{ item.percent }}%</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="uts">
|
|
import { ref, onMounted, reactive } from 'vue'
|
|
import AnalyticsPieChart from '@/components/analytics/AnalyticsPieChart.uvue'
|
|
import AnalyticsMultiLineChart from '@/components/analytics/AnalyticsMultiLineChart.uvue'
|
|
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
|
|
import { fetchIntegralStats, IntegralStats } from '@/services/admin/marketingService.uts'
|
|
|
|
const startDate = ref('')
|
|
const endDate = ref('')
|
|
const isLoading = ref(false)
|
|
|
|
const statsTotal = reactive({
|
|
current: 0,
|
|
income: 0,
|
|
expend: 0
|
|
})
|
|
|
|
const dates = ref<string[]>([])
|
|
const trendSeries = ref<any[]>([])
|
|
|
|
const sourceStyle = ref('pie')
|
|
const consumeStyle = ref('pie')
|
|
|
|
const sourceData = ref<any[]>([])
|
|
const consumeData = ref<any[]>([])
|
|
|
|
onMounted(() => {
|
|
// 默认最近 30 天
|
|
const end = new Date()
|
|
const start = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
|
startDate.value = start.toISOString().substring(0, 10)
|
|
endDate.value = end.toISOString().substring(0, 10)
|
|
|
|
loadData()
|
|
})
|
|
|
|
async function loadData() {
|
|
isLoading.value = true
|
|
try {
|
|
const st = startDate.value ? (startDate.value + ' 00:00:00') : null
|
|
const et = endDate.value ? (endDate.value + ' 23:59:59') : null
|
|
|
|
const res = await fetchIntegralStats(st!, et!)
|
|
if (res != null) {
|
|
// 1. 核心指标
|
|
statsTotal.current = res.totals.current
|
|
statsTotal.income = res.totals.income
|
|
statsTotal.expend = res.totals.expend
|
|
|
|
// 2. 趋势图
|
|
dates.value = res.trend.map(t => t.date_group)
|
|
trendSeries.value = [
|
|
{
|
|
name: '积分积累',
|
|
data: res.trend.map(t => t.income),
|
|
color: '#409eff'
|
|
},
|
|
{
|
|
name: '积分消耗',
|
|
data: res.trend.map(t => t.expend),
|
|
color: '#19be6b'
|
|
}
|
|
]
|
|
|
|
// 3. 来源与消耗分布
|
|
sourceData.value = res.sources.map(s => ({
|
|
label: s.label,
|
|
value: s.value,
|
|
percent: s.percent,
|
|
color: '#409eff' // 这里可以根据类型映射不同颜色
|
|
}))
|
|
|
|
consumeData.value = res.consumes.map(c => ({
|
|
label: c.label,
|
|
value: c.value,
|
|
percent: c.percent,
|
|
color: '#19be6b'
|
|
}))
|
|
}
|
|
} catch (e) {
|
|
uni.showToast({ title: '加载统计失败', icon: 'none' })
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
function onApplyRange(payload : any) {
|
|
startDate.value = payload?.start ?? ''
|
|
endDate.value = payload?.end ?? ''
|
|
loadData()
|
|
}
|
|
|
|
function onClearRange() {
|
|
startDate.value = ''
|
|
endDate.value = ''
|
|
loadData()
|
|
}
|
|
|
|
const toggleSourceStyle = () => {
|
|
sourceStyle.value = sourceStyle.value === 'pie' ? 'list' : 'pie'
|
|
}
|
|
|
|
const toggleConsumeStyle = () => {
|
|
consumeStyle.value = consumeStyle.value === 'pie' ? 'list' : 'pie'
|
|
}
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.admin-marketing-integral-statistic {
|
|
background-color: #f0f2f5;
|
|
min-height: 100vh;
|
|
padding: 24px;
|
|
}
|
|
|
|
.border-shadow {
|
|
background-color: #fff;
|
|
border-radius: 4px;
|
|
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
|
}
|
|
|
|
.content-body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
}
|
|
|
|
/* 时间选择 */
|
|
.filter-card {
|
|
padding: 24px;
|
|
display: flex;
|
|
}
|
|
.filter-item { display: flex; flex-direction: row; align-items: center; gap: 12px; }
|
|
.label-txt { font-size: 14px; color: #606266; }
|
|
.date-picker-mock {
|
|
border: 1px solid #dcdfe6;
|
|
border-radius: 4px;
|
|
padding: 5px 15px;
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.calendar-ic { font-size: 16px; color: #999; }
|
|
.date-range { font-size: 14px; color: #333; }
|
|
|
|
/* 核心卡片 */
|
|
.stats-row {
|
|
display: flex;
|
|
flex-direction: row;
|
|
gap: 20px;
|
|
}
|
|
.stat-card {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: row;
|
|
padding: 24px;
|
|
align-items: center;
|
|
}
|
|
.sc-left {
|
|
width: 64px;
|
|
height: 64px;
|
|
border-radius: 32px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin-right: 20px;
|
|
}
|
|
.sc-icon { font-size: 28px; color: #fff; }
|
|
.bg-blue { background-color: #409eff; }
|
|
.bg-orange { background-color: #ff9900; }
|
|
.bg-green { background-color: #19be6b; }
|
|
|
|
.sc-right { display: flex; flex-direction: column; }
|
|
.sc-val { font-size: 28px; font-weight: bold; color: #333; margin-bottom: 5px; }
|
|
.sc-label { font-size: 14px; color: #999; }
|
|
|
|
/* 趋势图 */
|
|
.chart-card {
|
|
padding: 24px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.chart-header {
|
|
display: flex;
|
|
flex-direction: row;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 24px;
|
|
}
|
|
.chart-title { font-size: 16px; font-weight: bold; color: #333; }
|
|
.chart-legend { display: flex; flex-direction: row; align-items: center; gap: 20px; }
|
|
.down-ic { font-size: 18px; color: #999; cursor: pointer; }
|
|
|
|
.chart-body {
|
|
width: 100%;
|
|
}
|
|
|
|
/* 底部两个分析 */
|
|
.bottom-analysis {
|
|
display: flex;
|
|
flex-direction: row;
|
|
gap: 20px;
|
|
}
|
|
.analysis-card {
|
|
flex: 1;
|
|
padding: 24px;
|
|
}
|
|
.analysis-header {
|
|
display: flex;
|
|
flex-direction: row;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 24px;
|
|
}
|
|
.ah-title { font-size: 16px; font-weight: bold; color: #333; }
|
|
.btn-toggle {
|
|
border: 1px solid #dcdfe6;
|
|
padding: 4px 12px;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
}
|
|
.toggle-txt { font-size: 12px; color: #666; }
|
|
|
|
.analysis-content {
|
|
min-height: 350px;
|
|
}
|
|
|
|
/* 饼图样式布局 */
|
|
.pie-layout-new {
|
|
width: 100%;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
/* 列表样式布局 */
|
|
.list-layout { display: flex; flex-direction: column; }
|
|
.list-head {
|
|
display: flex;
|
|
flex-direction: row;
|
|
background-color: #f8f8f9;
|
|
padding: 12px;
|
|
border-radius: 4px;
|
|
}
|
|
.lh-col { font-size: 14px; font-weight: bold; color: #515a6e; }
|
|
|
|
.list-row {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
padding: 15px 12px;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
}
|
|
.lr-rank { width: 30px; height: 30px; display: flex; align-items: center; }
|
|
.rank-txt { font-size: 14px; color: #999; }
|
|
.lr-label { width: 100px; font-size: 14px; color: #333; }
|
|
.lr-val { flex: 1; font-size: 14px; color: #333; text-align: center; }
|
|
.lr-progress-box { width: 200px; display: flex; flex-direction: row; align-items: center; gap: 10px; justify-content: flex-end; }
|
|
.prog-bg { flex: 1; height: 10px; background-color: #f5f5f5; border-radius: 5px; overflow: hidden; }
|
|
.prog-inner { height: 100%; background-color: #2d8cf0; border-radius: 5px; }
|
|
.prog-txt { font-size: 13px; color: #666; width: 40px; text-align: right; }
|
|
|
|
.anim-fade { animation: fadeIn 0.3s ease-in-out; }
|
|
@keyframes fadeIn { from { opacity: 0; transform: scale(0.98); } to { opacity: 1; transform: scale(1); } }
|
|
</style>
|