|
@@ -1,20 +1,217 @@
|
|
|
<template>
|
|
<template>
|
|
|
<div class="app-container home">
|
|
<div class="app-container home">
|
|
|
<!-- <el-divider /> -->
|
|
<!-- <el-divider /> -->
|
|
|
- <div class="index-style">
|
|
|
|
|
- <div class="typewriter-container">
|
|
|
|
|
- <span v-for="(char, index) in 'welcome!'" :key="index" :style="{ animationDelay: `${index * 0.5}s` }" class="typewriter-char">{{
|
|
|
|
|
- char
|
|
|
|
|
- }}</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <el-row :gutter="12">
|
|
|
|
|
+ <el-col :span="24">
|
|
|
|
|
+ <div class="dashboard-title">数据看板</div>
|
|
|
|
|
+ </el-col>
|
|
|
|
|
+
|
|
|
|
|
+ <el-col :span="24">
|
|
|
|
|
+ <div class="stat-grid">
|
|
|
|
|
+ <el-card shadow="hover" class="stat-card">
|
|
|
|
|
+ <div class="stat-label">商品总数</div>
|
|
|
|
|
+ <div class="stat-value">{{ metrics.productTotal }}</div>
|
|
|
|
|
+ </el-card>
|
|
|
|
|
+ <el-card shadow="hover" class="stat-card">
|
|
|
|
|
+ <div class="stat-label">自营商品</div>
|
|
|
|
|
+ <div class="stat-value">{{ metrics.productSelf }}</div>
|
|
|
|
|
+ </el-card>
|
|
|
|
|
+ <el-card shadow="hover" class="stat-card">
|
|
|
|
|
+ <div class="stat-label">上架商品</div>
|
|
|
|
|
+ <div class="stat-value">{{ metrics.productOnSale }}</div>
|
|
|
|
|
+ </el-card>
|
|
|
|
|
+ <el-card shadow="hover" class="stat-card">
|
|
|
|
|
+ <div class="stat-label">下架商品</div>
|
|
|
|
|
+ <div class="stat-value">{{ metrics.productOffSale }}</div>
|
|
|
|
|
+ </el-card>
|
|
|
|
|
+ <el-card shadow="hover" class="stat-card">
|
|
|
|
|
+ <div class="stat-label">停售商品</div>
|
|
|
|
|
+ <div class="stat-value">{{ metrics.productStopSale }}</div>
|
|
|
|
|
+ </el-card>
|
|
|
|
|
+ <el-card shadow="hover" class="stat-card">
|
|
|
|
|
+ <div class="stat-label">精选商品</div>
|
|
|
|
|
+ <div class="stat-value">{{ metrics.productFeatured }}</div>
|
|
|
|
|
+ </el-card>
|
|
|
|
|
+ <el-card shadow="hover" class="stat-card">
|
|
|
|
|
+ <div class="stat-label">商品总池</div>
|
|
|
|
|
+ <div class="stat-value">{{ metrics.poolTotal }}</div>
|
|
|
|
|
+ </el-card>
|
|
|
|
|
+ <el-card shadow="hover" class="stat-card">
|
|
|
|
|
+ <div class="stat-label">精选商品池</div>
|
|
|
|
|
+ <div class="stat-value">{{ metrics.poolFeatured }}</div>
|
|
|
|
|
+ </el-card>
|
|
|
|
|
+ <el-card shadow="hover" class="stat-card">
|
|
|
|
|
+ <div class="stat-label">方案数量</div>
|
|
|
|
|
+ <div class="stat-value">{{ metrics.programTotal }}</div>
|
|
|
|
|
+ </el-card>
|
|
|
|
|
+ <el-card shadow="hover" class="stat-card">
|
|
|
|
|
+ <div class="stat-label">今日方案新增</div>
|
|
|
|
|
+ <div class="stat-value">{{ metrics.programTodayNew }}</div>
|
|
|
|
|
+ </el-card>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-col>
|
|
|
|
|
+
|
|
|
|
|
+ <el-col :span="12" class="card-box">
|
|
|
|
|
+ <el-card shadow="hover" class="chart-card">
|
|
|
|
|
+ <template #header>
|
|
|
|
|
+ <span>商品状态分布</span>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ <div ref="productStatusChartRef" class="chart" />
|
|
|
|
|
+ </el-card>
|
|
|
|
|
+ </el-col>
|
|
|
|
|
+
|
|
|
|
|
+ <el-col :span="12" class="card-box">
|
|
|
|
|
+ <el-card shadow="hover" class="chart-card">
|
|
|
|
|
+ <template #header>
|
|
|
|
|
+ <span>精选/停售/默认</span>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ <div ref="productTypeChartRef" class="chart" />
|
|
|
|
|
+ </el-card>
|
|
|
|
|
+ </el-col>
|
|
|
|
|
+ </el-row>
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script setup name="Index" lang="ts">
|
|
<script setup name="Index" lang="ts">
|
|
|
-const goTarget = (url: string) => {
|
|
|
|
|
- window.open(url, '__blank');
|
|
|
|
|
|
|
+import * as echarts from 'echarts';
|
|
|
|
|
+import { getProductStatusCount, listBase } from '@/api/product/base';
|
|
|
|
|
+import { listProgram } from '@/api/product/program';
|
|
|
|
|
+import { listPool } from '@/api/product/pool/index';
|
|
|
|
|
+
|
|
|
|
|
+const productStatusChartRef = ref();
|
|
|
|
|
+const productTypeChartRef = ref();
|
|
|
|
|
+let productStatusChartInstance: echarts.ECharts | undefined;
|
|
|
|
|
+let productTypeChartInstance: echarts.ECharts | undefined;
|
|
|
|
|
+
|
|
|
|
|
+const metrics = reactive({
|
|
|
|
|
+ productTotal: 0,
|
|
|
|
|
+ productSelf: 0,
|
|
|
|
|
+ productOnSale: 0,
|
|
|
|
|
+ productOffSale: 0,
|
|
|
|
|
+ productStopSale: 0,
|
|
|
|
|
+ productFeatured: 0,
|
|
|
|
|
+ poolTotal: 0,
|
|
|
|
|
+ poolFeatured: 0,
|
|
|
|
|
+ programTotal: 0,
|
|
|
|
|
+ programTodayNew: 0
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+const safeTotal = (res: any): number => {
|
|
|
|
|
+ const total = res?.total;
|
|
|
|
|
+ return typeof total === 'number' ? total : 0;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const getTodayRange = () => {
|
|
|
|
|
+ const now = new Date();
|
|
|
|
|
+ const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
|
|
|
|
|
+ const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59);
|
|
|
|
|
+ const fmt = (d: Date) => {
|
|
|
|
|
+ const pad = (n: number) => `${n}`.padStart(2, '0');
|
|
|
|
|
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
|
|
|
+ };
|
|
|
|
|
+ return { beginTime: fmt(start), endTime: fmt(end) };
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const renderProductStatusChart = () => {
|
|
|
|
|
+ if (!productStatusChartRef.value) return;
|
|
|
|
|
+ if (!productStatusChartInstance) {
|
|
|
|
|
+ productStatusChartInstance = echarts.init(productStatusChartRef.value, 'macarons');
|
|
|
|
|
+ }
|
|
|
|
|
+ productStatusChartInstance.setOption({
|
|
|
|
|
+ tooltip: { trigger: 'item' },
|
|
|
|
|
+ legend: { bottom: 0 },
|
|
|
|
|
+ series: [
|
|
|
|
|
+ {
|
|
|
|
|
+ name: '商品状态',
|
|
|
|
|
+ type: 'pie',
|
|
|
|
|
+ radius: ['45%', '70%'],
|
|
|
|
|
+ avoidLabelOverlap: true,
|
|
|
|
|
+ itemStyle: { borderRadius: 8, borderColor: '#fff', borderWidth: 2 },
|
|
|
|
|
+ label: { show: false },
|
|
|
|
|
+ emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } },
|
|
|
|
|
+ labelLine: { show: false },
|
|
|
|
|
+ data: [
|
|
|
|
|
+ { value: metrics.productOnSale, name: '上架' },
|
|
|
|
|
+ { value: metrics.productOffSale, name: '下架' }
|
|
|
|
|
+ ]
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+ });
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const renderProductTypeChart = () => {
|
|
|
|
|
+ if (!productTypeChartRef.value) return;
|
|
|
|
|
+ if (!productTypeChartInstance) {
|
|
|
|
|
+ productTypeChartInstance = echarts.init(productTypeChartRef.value, 'macarons');
|
|
|
|
|
+ }
|
|
|
|
|
+ const defaultCount = Math.max(metrics.productTotal - metrics.productFeatured - metrics.productStopSale, 0);
|
|
|
|
|
+ productTypeChartInstance.setOption({
|
|
|
|
|
+ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
|
|
|
|
+ grid: { left: 12, right: 12, top: 20, bottom: 20, containLabel: true },
|
|
|
|
|
+ xAxis: { type: 'category', data: ['默认', '精选', '停售'] },
|
|
|
|
|
+ yAxis: { type: 'value' },
|
|
|
|
|
+ series: [
|
|
|
|
|
+ {
|
|
|
|
|
+ name: '数量',
|
|
|
|
|
+ type: 'bar',
|
|
|
|
|
+ barWidth: 36,
|
|
|
|
|
+ itemStyle: { borderRadius: [8, 8, 0, 0] },
|
|
|
|
|
+ data: [defaultCount, metrics.productFeatured, metrics.productStopSale]
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+ });
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const refreshMetrics = async () => {
|
|
|
|
|
+ const statusRes = await getProductStatusCount();
|
|
|
|
|
+ metrics.productTotal = statusRes.data?.total ?? 0;
|
|
|
|
|
+ metrics.productOnSale = statusRes.data?.onSale ?? 0;
|
|
|
|
|
+ metrics.productOffSale = statusRes.data?.offSale ?? 0;
|
|
|
|
|
+
|
|
|
|
|
+ const [selfRes, featuredRes, stopSaleRes] = await Promise.all([
|
|
|
|
|
+ listBase({ pageNum: 1, pageSize: 1, isSelf: 1 } as any),
|
|
|
|
|
+ listBase({ pageNum: 1, pageSize: 1, productCategory: 2 } as any),
|
|
|
|
|
+ listBase({ pageNum: 1, pageSize: 1, productCategory: 3 } as any)
|
|
|
|
|
+ ]);
|
|
|
|
|
+ metrics.productSelf = safeTotal(selfRes);
|
|
|
|
|
+ metrics.productFeatured = safeTotal(featuredRes);
|
|
|
|
|
+ metrics.productStopSale = safeTotal(stopSaleRes);
|
|
|
|
|
+
|
|
|
|
|
+ const [poolTotalRes, poolFeaturedRes] = await Promise.all([
|
|
|
|
|
+ listPool({ pageNum: 1, pageSize: 1 } as any),
|
|
|
|
|
+ listPool({ pageNum: 1, pageSize: 1, type: 1 } as any)
|
|
|
|
|
+ ]);
|
|
|
|
|
+ metrics.poolTotal = safeTotal(poolTotalRes);
|
|
|
|
|
+ metrics.poolFeatured = safeTotal(poolFeaturedRes);
|
|
|
|
|
+
|
|
|
|
|
+ const programTotalRes = await listProgram({ pageNum: 1, pageSize: 1 } as any);
|
|
|
|
|
+ metrics.programTotal = safeTotal(programTotalRes);
|
|
|
|
|
+
|
|
|
|
|
+ const { beginTime, endTime } = getTodayRange();
|
|
|
|
|
+ const programTodayRes = await listProgram({ pageNum: 1, pageSize: 1, params: { beginTime, endTime } } as any);
|
|
|
|
|
+ metrics.programTodayNew = safeTotal(programTodayRes);
|
|
|
|
|
+
|
|
|
|
|
+ renderProductStatusChart();
|
|
|
|
|
+ renderProductTypeChart();
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const onResize = () => {
|
|
|
|
|
+ productStatusChartInstance?.resize();
|
|
|
|
|
+ productTypeChartInstance?.resize();
|
|
|
};
|
|
};
|
|
|
|
|
+
|
|
|
|
|
+onMounted(async () => {
|
|
|
|
|
+ await refreshMetrics();
|
|
|
|
|
+ window.addEventListener('resize', onResize);
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+onBeforeUnmount(() => {
|
|
|
|
|
+ window.removeEventListener('resize', onResize);
|
|
|
|
|
+ productStatusChartInstance?.dispose();
|
|
|
|
|
+ productTypeChartInstance?.dispose();
|
|
|
|
|
+ productStatusChartInstance = undefined;
|
|
|
|
|
+ productTypeChartInstance = undefined;
|
|
|
|
|
+});
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
<style lang="scss" scoped>
|
|
@@ -35,6 +232,45 @@ const goTarget = (url: string) => {
|
|
|
margin-bottom: 20px;
|
|
margin-bottom: 20px;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ .dashboard-title {
|
|
|
|
|
+ font-size: 18px;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ color: #1f2937;
|
|
|
|
|
+ padding: 4px 2px 10px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .stat-grid {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
|
|
|
+ gap: 12px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .stat-card {
|
|
|
|
|
+ border-radius: 12px;
|
|
|
|
|
+ background: linear-gradient(135deg, #ffffff 0%, #f7fbff 100%);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .stat-label {
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ color: #6b7280;
|
|
|
|
|
+ margin-bottom: 8px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .stat-value {
|
|
|
|
|
+ font-size: 28px;
|
|
|
|
|
+ font-weight: 700;
|
|
|
|
|
+ color: #111827;
|
|
|
|
|
+ letter-spacing: 0.5px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .chart-card {
|
|
|
|
|
+ border-radius: 12px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .chart {
|
|
|
|
|
+ height: 360px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
ul {
|
|
ul {
|
|
|
padding: 0;
|
|
padding: 0;
|
|
|
margin: 0;
|
|
margin: 0;
|
|
@@ -143,5 +379,11 @@ const goTarget = (url: string) => {
|
|
|
transform: scale(1);
|
|
transform: scale(1);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ @media (max-width: 1200px) {
|
|
|
|
|
+ .stat-grid {
|
|
|
|
|
+ grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
</style>
|
|
</style>
|