|
|
@@ -1,176 +1,913 @@
|
|
|
<template>
|
|
|
- <div class="home">
|
|
|
- <div class="coming-soon-container">
|
|
|
- <div class="coming-soon-content">
|
|
|
- <div class="icon-wrapper">
|
|
|
- <svg class="icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
|
- <path
|
|
|
- d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"
|
|
|
- stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
|
- <path d="M9 12L11 14L15 10" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
|
|
- stroke-linejoin="round" />
|
|
|
- </svg>
|
|
|
+ <div class="app-container home">
|
|
|
+ <!-- 欢迎状态卡片 -->
|
|
|
+ <el-card class="welcome-card" shadow="never">
|
|
|
+ <div class="card-content">
|
|
|
+ <!-- 左侧信息 -->
|
|
|
+ <div class="left-section">
|
|
|
+ <div class="title">早安,管理员</div>
|
|
|
+ <div class="subtitle">数据监控中心 | {{ currentDate }}</div>
|
|
|
</div>
|
|
|
- <h2 class="coming-soon-title">部分功能待开发中</h2>
|
|
|
- <p class="coming-soon-subtitle">请直接进行入驻、新增和下单等流程</p>
|
|
|
|
|
|
- <div class="process-guide">
|
|
|
- <div class="guide-item">
|
|
|
- <div class="guide-info">
|
|
|
- <span class="dot"></span>
|
|
|
- <span>履约入驻流程</span>
|
|
|
+ <!-- 右侧统计 -->
|
|
|
+ <div class="right-section">
|
|
|
+ <div class="stat-item" @click="handleToFulfillerReview">
|
|
|
+ <div class="icon-wrapper warning">
|
|
|
+ <el-icon><BellFilled /></el-icon>
|
|
|
</div>
|
|
|
+ <div class="stat-info">
|
|
|
+ <div class="stat-value">{{ adminCountData.underReviewFulfillerCount }}</div>
|
|
|
+ <div class="stat-label">待审履约者</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <!-- 中间四个卡片并排一列 -->
|
|
|
+ <el-row :gutter="20" class="mini-cards-row">
|
|
|
+ <el-col :span="6">
|
|
|
+ <div class="data-card bg-blue">
|
|
|
+ <div class="card-title">今日交易额</div>
|
|
|
+ <div class="card-value">
|
|
|
+ <span class="currency">¥</span>
|
|
|
+ {{ formatMoney(adminCountData.priceToday) }}
|
|
|
</div>
|
|
|
- <div class="guide-item">
|
|
|
- <div class="guide-info">
|
|
|
- <span class="dot"></span>
|
|
|
- <span>数据新增与修改</span>
|
|
|
+ <div class="card-footer">
|
|
|
+ <span class="footer-text">昨日 ¥{{ formatMoney(adminCountData.priceLastday, false) }}</span>
|
|
|
+ <div class="trend-tag" v-if="adminCountData.priceLastday > 0 || adminCountData.priceToday > 0">
|
|
|
+ {{ getTrendStr(adminCountData.priceToday, adminCountData.priceLastday) }}% ↑
|
|
|
</div>
|
|
|
</div>
|
|
|
- <div class="guide-item">
|
|
|
- <div class="guide-info">
|
|
|
- <span class="dot"></span>
|
|
|
- <span>核心下单业务流</span>
|
|
|
+ <el-icon class="card-bg-icon"><WalletFilled /></el-icon>
|
|
|
+ </div>
|
|
|
+ </el-col>
|
|
|
+ <el-col :span="6">
|
|
|
+ <div class="data-card bg-purple">
|
|
|
+ <div class="card-title">今日订单量</div>
|
|
|
+ <div class="card-value">{{ adminCountData.orderCountToday }}</div>
|
|
|
+ <div class="card-footer">
|
|
|
+ <span class="footer-text">昨日 {{ adminCountData.orderCountLastday }}</span>
|
|
|
+ <div class="trend-tag" v-if="adminCountData.orderCountLastday > 0 || adminCountData.orderCountToday > 0">
|
|
|
+ {{ getTrendStr(adminCountData.orderCountToday, adminCountData.orderCountLastday) }}% ↑
|
|
|
</div>
|
|
|
</div>
|
|
|
+ <el-icon class="card-bg-icon"><List /></el-icon>
|
|
|
</div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
+ </el-col>
|
|
|
+ <el-col :span="6">
|
|
|
+ <div class="data-card bg-orange">
|
|
|
+ <div class="card-title">累计履约者</div>
|
|
|
+ <div class="card-value">{{ adminCountData.fulfillerCount }}</div>
|
|
|
+ <div class="card-footer">
|
|
|
+ <span class="footer-text">待审核 {{ adminCountData.underReviewFulfillerCount }} 人</span>
|
|
|
+ </div>
|
|
|
+ <el-icon class="card-bg-icon"><Bicycle /></el-icon>
|
|
|
+ </div>
|
|
|
+ </el-col>
|
|
|
+ <el-col :span="6">
|
|
|
+ <div class="data-card bg-green">
|
|
|
+ <div class="card-title">合作门店</div>
|
|
|
+ <div class="card-value">{{ adminCountData.storeCount }}</div>
|
|
|
+ <div class="card-footer">
|
|
|
+ <span class="footer-text">本月新增 {{ adminCountData.newStoreCountThisMonth }} 家</span>
|
|
|
+ </div>
|
|
|
+ <el-icon class="card-bg-icon"><OfficeBuilding /></el-icon>
|
|
|
+ </div>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+
|
|
|
+ <!-- 图表展示区域 -->
|
|
|
+ <el-row :gutter="20" class="charts-row">
|
|
|
+ <!-- 交易走势图 (折线图) -->
|
|
|
+ <el-col :span="16">
|
|
|
+ <el-card shadow="never" class="chart-card">
|
|
|
+ <div class="chart-header">
|
|
|
+ <div class="chart-title">
|
|
|
+ <span class="title-icon"></span>
|
|
|
+ 近{{ listType === 0 ? '七' : '三十' }}日交易走势
|
|
|
+ </div>
|
|
|
+ <el-radio-group v-model="listType" size="small" @change="fetchChartData">
|
|
|
+ <el-radio-button :label="0">周</el-radio-button>
|
|
|
+ <el-radio-button :label="1">月</el-radio-button>
|
|
|
+ </el-radio-group>
|
|
|
+ </div>
|
|
|
+ <div class="chart-container" ref="lineChartRef"></div>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+ <!-- 服务占比分布 (饼图) -->
|
|
|
+ <el-col :span="8">
|
|
|
+ <el-card shadow="never" class="chart-card">
|
|
|
+ <div class="chart-header">
|
|
|
+ <div class="chart-title">
|
|
|
+ <span class="title-icon"></span>
|
|
|
+ 服务订单占比
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="chart-container" ref="pieChartRef"></div>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+
|
|
|
+ <!-- 下半部分数据区 -->
|
|
|
+ <el-row :gutter="20" class="charts-row">
|
|
|
+ <!-- 履约者接单排名 -->
|
|
|
+ <el-col :span="12">
|
|
|
+ <el-card shadow="never" class="chart-card rank-card">
|
|
|
+ <div class="chart-header" style="margin-bottom: 16px; border-bottom: 1px solid #f0f2f5; padding-bottom: 16px;">
|
|
|
+ <div class="chart-title">
|
|
|
+ <span class="title-icon"></span>
|
|
|
+ 履约者接单排名 (TOP 5)
|
|
|
+ </div>
|
|
|
+ <!-- 去掉本月标签 -->
|
|
|
+ </div>
|
|
|
+ <div class="rank-list" v-if="fulfillerRankList.length > 0">
|
|
|
+ <div
|
|
|
+ class="rank-item"
|
|
|
+ v-for="(item, index) in fulfillerRankList"
|
|
|
+ :key="item.id || index"
|
|
|
+ >
|
|
|
+ <div class="item-left">
|
|
|
+ <div class="rank-index" :class="'rank-' + (index + 1)">
|
|
|
+ {{ index + 1 }}
|
|
|
+ </div>
|
|
|
+ <!-- 预留动态头像属性 -->
|
|
|
+ <el-avatar class="rank-avatar" :size="40" src="https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png" />
|
|
|
+ <div class="rank-info">
|
|
|
+ <div class="rank-name">{{ item.name }}</div>
|
|
|
+ <div class="rank-site">{{ item.site }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="item-right">
|
|
|
+ <div class="rank-count">{{ item.count }} 单</div>
|
|
|
+ <div class="rank-bar-bg">
|
|
|
+ <div
|
|
|
+ class="rank-bar-fill"
|
|
|
+ :class="'bar-color-' + (index + 1)"
|
|
|
+ :style="{ width: getPercentage(item.count) + '%' }"
|
|
|
+ ></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="rank-list" v-else style="height: 312px; display: flex; align-items: center; justify-content: center;">
|
|
|
+ <el-empty description="暂无履约者排名数据" :image-size="80" />
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+
|
|
|
+ <!-- 门店接单排名 -->
|
|
|
+ <el-col :span="12">
|
|
|
+ <el-card shadow="never" class="chart-card rank-card">
|
|
|
+ <div class="chart-header" style="margin-bottom: 16px; border-bottom: 1px solid #f0f2f5; padding-bottom: 16px;">
|
|
|
+ <div class="chart-title">
|
|
|
+ <span class="title-icon"></span>
|
|
|
+ 门店接单排名 (TOP 5)
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="rank-list" v-if="storeRankList.length > 0">
|
|
|
+ <div
|
|
|
+ class="rank-item"
|
|
|
+ v-for="(item, index) in storeRankList"
|
|
|
+ :key="item.id || index"
|
|
|
+ >
|
|
|
+ <div class="item-left">
|
|
|
+ <div class="rank-index" :class="'rank-' + (index + 1)">
|
|
|
+ {{ index + 1 }}
|
|
|
+ </div>
|
|
|
+ <el-avatar class="rank-avatar store-avatar" shape="square" :size="40" :src="item.logo">
|
|
|
+ <el-icon :size="20"><Shop /></el-icon>
|
|
|
+ </el-avatar>
|
|
|
+ <div class="rank-info">
|
|
|
+ <div class="rank-name">{{ item.name }}</div>
|
|
|
+ <div class="rank-score">
|
|
|
+ <el-icon color="#e6a23c"><StarFilled /></el-icon>
|
|
|
+ <span>4.9</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="item-right store-right">
|
|
|
+ <div class="rank-count">¥ {{ Number(item.count).toLocaleString() }}</div>
|
|
|
+ <div class="rank-bar-bg">
|
|
|
+ <div
|
|
|
+ class="rank-bar-fill"
|
|
|
+ :class="'store-bar-color-' + (index + 1)"
|
|
|
+ :style="{ width: getStorePercentage(item.count) + '%' }"
|
|
|
+ ></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="rank-list" v-else style="height: 312px; display: flex; align-items: center; justify-content: center;">
|
|
|
+ <el-empty description="暂无门店接单排名数据" :image-size="80" />
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script setup name="Index" lang="ts">
|
|
|
-// 首页逻辑组件
|
|
|
+import { ref, onMounted, onUnmounted, markRaw } from 'vue';
|
|
|
+import { useRouter } from 'vue-router';
|
|
|
+import { getAdminCount, listOrder, getFulfillerRank, getStoreRank } from '@/api/system/admin';
|
|
|
+import type { FulfillerRankVO, StoreRankVO } from '@/api/system/admin/types';
|
|
|
+import { listAllService } from '@/api/service/list';
|
|
|
+import { BellFilled, StarFilled, Shop, WalletFilled, List, Bicycle, OfficeBuilding } from '@element-plus/icons-vue';
|
|
|
+import * as echarts from 'echarts';
|
|
|
+
|
|
|
+import dayjs from 'dayjs';
|
|
|
+import 'dayjs/locale/zh-cn';
|
|
|
+dayjs.locale('zh-cn');
|
|
|
+
|
|
|
+const router = useRouter();
|
|
|
+
|
|
|
+const currentDate = ref('');
|
|
|
+const adminCountData = ref<AdminCountVO>({
|
|
|
+ underReviewFulfillerCount: 0,
|
|
|
+ priceToday: 0,
|
|
|
+ priceLastday: 0,
|
|
|
+ orderCountToday: 0,
|
|
|
+ orderCountLastday: 0,
|
|
|
+ fulfillerCount: 0,
|
|
|
+ storeCount: 0,
|
|
|
+ newStoreCountThisMonth: 0,
|
|
|
+ underReviewStoreCount: 5
|
|
|
+});
|
|
|
+
|
|
|
+const formatMoney = (val: number, decimal = true) => {
|
|
|
+ if (!val) return decimal ? '0.00' : '0';
|
|
|
+ const num = val / 100;
|
|
|
+ return num.toLocaleString('en-US', { minimumFractionDigits: decimal ? 2 : 0, maximumFractionDigits: decimal ? 2 : 0 });
|
|
|
+};
|
|
|
+
|
|
|
+const getTrendStr = (current: number, prev: number, checkSign: boolean = true) => {
|
|
|
+ if (prev === 0) return current > 0 ? (checkSign ? '+100' : '100') : '0';
|
|
|
+ const diff = current - prev;
|
|
|
+ const pct = (diff / prev * 100).toFixed(0);
|
|
|
+ return checkSign && Number(pct) >= 0 ? `+${pct}` : `${pct}`;
|
|
|
+};
|
|
|
+
|
|
|
+// 获取相关服务记录数组以便于格式化字典
|
|
|
+const serviceOptions = ref<any[]>([]);
|
|
|
+
|
|
|
+// 数据交互及图表实例
|
|
|
+const listType = ref(0); // 0: 周, 1: 月
|
|
|
+const lineChartRef = ref<HTMLElement | null>(null);
|
|
|
+const pieChartRef = ref<HTMLElement | null>(null);
|
|
|
+let lineChartInstance: echarts.ECharts | null = null;
|
|
|
+let pieChartInstance: echarts.ECharts | null = null;
|
|
|
+
|
|
|
+/** 初始化当天日期文本 */
|
|
|
+const initDate = () => {
|
|
|
+ currentDate.value = dayjs().format('YYYY年M月D日dddd');
|
|
|
+};
|
|
|
+
|
|
|
+/** 请求上方的数量汇总 */
|
|
|
+const fetchAdminCount = async () => {
|
|
|
+ try {
|
|
|
+ const res = await getAdminCount();
|
|
|
+ if (res.data) {
|
|
|
+ adminCountData.value = { ...adminCountData.value, ...res.data };
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取首页统计失败', error);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+/** 履约者接单排名榜数据请求逻辑 */
|
|
|
+const fulfillerRankList = ref<FulfillerRankVO[]>([]);
|
|
|
+
|
|
|
+const fetchFulfillerRank = async () => {
|
|
|
+ try {
|
|
|
+ const res = await getFulfillerRank();
|
|
|
+ fulfillerRankList.value = (res.data || []).slice(0, 5);
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取履约者接单排名失败', error);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const getPercentage = (count: number) => {
|
|
|
+ if (fulfillerRankList.value.length === 0) return 0;
|
|
|
+ // 假设返回的数据是经过倒序排序的,因此取排第一的值作为分母最大值比例依据
|
|
|
+ const maxCount = fulfillerRankList.value[0].count;
|
|
|
+ if (maxCount === 0) return 0;
|
|
|
+ return (count / maxCount) * 100;
|
|
|
+};
|
|
|
+
|
|
|
+/** 门店排名榜数据请求逻辑 */
|
|
|
+const storeRankList = ref<StoreRankVO[]>([]);
|
|
|
+
|
|
|
+const fetchStoreRank = async () => {
|
|
|
+ try {
|
|
|
+ const res = await getStoreRank();
|
|
|
+ storeRankList.value = (res.data || []).slice(0, 5);
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取门店排名失败', error);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const getStorePercentage = (count: number) => {
|
|
|
+ if (storeRankList.value.length === 0) return 0;
|
|
|
+ const maxCount = storeRankList.value[0].count;
|
|
|
+ if (maxCount === 0) return 0;
|
|
|
+ return (count / maxCount) * 100;
|
|
|
+};
|
|
|
+
|
|
|
+/** 请求系统内支持的服务字典 */
|
|
|
+const fetchServiceList = async () => {
|
|
|
+ try {
|
|
|
+ const res = await listAllService();
|
|
|
+ serviceOptions.value = res.data || res || [];
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取服务列表失败', error);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+/** 请求双图表的趋势及占比数据 */
|
|
|
+const fetchChartData = async () => {
|
|
|
+ try {
|
|
|
+ const res = await listOrder({ type: listType.value });
|
|
|
+ const orderData = res.data || [];
|
|
|
+ renderLineChart(orderData);
|
|
|
+ renderPieChart(orderData);
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取图表订单数据失败', error);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * 【左部】折线图绘制:日期为 X 轴;左 Y 轴为交易额,右 Y 轴为单量
|
|
|
+ */
|
|
|
+const renderLineChart = (data: any[]) => {
|
|
|
+ if (!lineChartRef.value) return;
|
|
|
+ if (!lineChartInstance) {
|
|
|
+ lineChartInstance = markRaw(echarts.init(lineChartRef.value));
|
|
|
+ }
|
|
|
+
|
|
|
+ const daysCount = listType.value === 0 ? 7 : 30;
|
|
|
+
|
|
|
+ // 自过去 N 天往前推算,初始化日期结构以防断层
|
|
|
+ const dates: string[] = [];
|
|
|
+ const statMap: Record<string, { count: number, price: number }> = {};
|
|
|
+ for (let i = daysCount - 1; i >= 0; i--) {
|
|
|
+ const dateStr = dayjs().subtract(i, 'day').format('MM-DD');
|
|
|
+ dates.push(dateStr);
|
|
|
+ statMap[dateStr] = { count: 0, price: 0 };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 累加对应日期的账期明细
|
|
|
+ data.forEach(item => {
|
|
|
+ if (!item.createTime) return;
|
|
|
+ const itemDateStr = dayjs(item.createTime).format('MM-DD');
|
|
|
+ if (statMap[itemDateStr] !== undefined) {
|
|
|
+ statMap[itemDateStr].count += 1;
|
|
|
+ // 交易金额以“分”为后端单位,前端转化为“元”
|
|
|
+ statMap[itemDateStr].price += (item.price || 0) / 100;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ const priceData = dates.map(d => Number(statMap[d].price.toFixed(2)));
|
|
|
+ const countData = dates.map(d => statMap[d].count);
|
|
|
+
|
|
|
+ const option: echarts.EChartsOption = {
|
|
|
+ tooltip: { trigger: 'axis' },
|
|
|
+ legend: {
|
|
|
+ data: ['交易额', '订单量'],
|
|
|
+ top: 0,
|
|
|
+ left: 0,
|
|
|
+ icon: 'circle',
|
|
|
+ textStyle: { color: '#86909c' }
|
|
|
+ },
|
|
|
+ grid: {
|
|
|
+ left: '2%',
|
|
|
+ right: '2%',
|
|
|
+ bottom: '10%',
|
|
|
+ top: '15%',
|
|
|
+ containLabel: true
|
|
|
+ },
|
|
|
+ xAxis: {
|
|
|
+ type: 'category',
|
|
|
+ boundaryGap: false,
|
|
|
+ data: dates,
|
|
|
+ axisLine: { lineStyle: { color: '#e5e6eb' } },
|
|
|
+ axisLabel: { color: '#86909c', margin: 16 }
|
|
|
+ },
|
|
|
+ yAxis: [
|
|
|
+ {
|
|
|
+ type: 'value',
|
|
|
+ name: '金额 (元)',
|
|
|
+ nameTextStyle: { color: '#86909c', padding: [0, 20, 0, 0] },
|
|
|
+ axisLabel: { color: '#86909c' },
|
|
|
+ splitLine: { lineStyle: { type: 'dashed', color: '#e5e6eb' } }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: 'value',
|
|
|
+ name: '单量',
|
|
|
+ nameTextStyle: { color: '#86909c', padding: [0, 0, 0, 20] },
|
|
|
+ alignTicks: true,
|
|
|
+ axisLabel: { color: '#86909c' },
|
|
|
+ splitLine: { show: false }
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ name: '交易额',
|
|
|
+ type: 'line',
|
|
|
+ yAxisIndex: 0,
|
|
|
+ smooth: true,
|
|
|
+ showSymbol: false,
|
|
|
+ itemStyle: { color: '#409eff' },
|
|
|
+ areaStyle: {
|
|
|
+ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
|
+ { offset: 0, color: 'rgba(64, 158, 255, 0.4)' },
|
|
|
+ { offset: 1, color: 'rgba(64, 158, 255, 0.05)' }
|
|
|
+ ])
|
|
|
+ },
|
|
|
+ data: priceData
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: '订单量',
|
|
|
+ type: 'line',
|
|
|
+ yAxisIndex: 1,
|
|
|
+ smooth: true,
|
|
|
+ showSymbol: false,
|
|
|
+ itemStyle: { color: '#e6a23c' },
|
|
|
+ areaStyle: {
|
|
|
+ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
|
+ { offset: 0, color: 'rgba(230, 162, 60, 0.3)' },
|
|
|
+ { offset: 1, color: 'rgba(230, 162, 60, 0.05)' }
|
|
|
+ ])
|
|
|
+ },
|
|
|
+ data: countData
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+
|
|
|
+ lineChartInstance.setOption(option);
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * 【右部】环形饼图绘制:依据订单的服务项目,映射显示各类别单量占比情况
|
|
|
+ */
|
|
|
+const renderPieChart = (data: any[]) => {
|
|
|
+ if (!pieChartRef.value) return;
|
|
|
+ if (!pieChartInstance) {
|
|
|
+ pieChartInstance = markRaw(echarts.init(pieChartRef.value));
|
|
|
+ }
|
|
|
+
|
|
|
+ let totalOrdersCount = data.length;
|
|
|
+ const serviceCountMap: Record<number, number> = {};
|
|
|
+
|
|
|
+ data.forEach(item => {
|
|
|
+ serviceCountMap[item.service] = (serviceCountMap[item.service] || 0) + 1;
|
|
|
+ });
|
|
|
+
|
|
|
+ const getServiceNameById = (serviceId: number) => {
|
|
|
+ const matchedService = serviceOptions.value.find(s => s.id === serviceId);
|
|
|
+ return matchedService ? matchedService.name : '其他服务';
|
|
|
+ };
|
|
|
+
|
|
|
+ const pieData = Object.keys(serviceCountMap).map(key => ({
|
|
|
+ name: getServiceNameById(Number(key)),
|
|
|
+ value: serviceCountMap[Number(key)]
|
|
|
+ }));
|
|
|
+
|
|
|
+ const option: echarts.EChartsOption = {
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'item',
|
|
|
+ formatter: '{b}: {c} ({d}%)'
|
|
|
+ },
|
|
|
+ title: {
|
|
|
+ text: 'Total\n100%',
|
|
|
+ left: 'center',
|
|
|
+ top: 'center',
|
|
|
+ textStyle: {
|
|
|
+ fontSize: 15,
|
|
|
+ fontWeight: 'bold',
|
|
|
+ color: '#1d2129',
|
|
|
+ lineHeight: 22
|
|
|
+ }
|
|
|
+ },
|
|
|
+ legend: {
|
|
|
+ orient: 'vertical',
|
|
|
+ right: '2%',
|
|
|
+ top: 'center',
|
|
|
+ icon: 'circle',
|
|
|
+ formatter: (name: string) => {
|
|
|
+ const row = pieData.find(d => d.name === name);
|
|
|
+ const percentRaw = (totalOrdersCount > 0 && row) ? Math.round((row.value / totalOrdersCount) * 100) : 0;
|
|
|
+ // 定长排版处理使格式更加接近图例
|
|
|
+ return `${name} ${percentRaw}%`;
|
|
|
+ },
|
|
|
+ textStyle: { color: '#86909c', fontSize: 13 }
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ type: 'pie',
|
|
|
+ radius: ['55%', '85%'],
|
|
|
+ center: ['35%', '50%'],
|
|
|
+ avoidLabelOverlap: false,
|
|
|
+ label: { show: false },
|
|
|
+ labelLine: { show: false },
|
|
|
+ itemStyle: {
|
|
|
+ borderWidth: 2,
|
|
|
+ borderColor: '#fff'
|
|
|
+ },
|
|
|
+ data: pieData.length > 0 ? pieData : [{ name: '暂无数据', value: 0 }]
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+
|
|
|
+ // 通过调色板高度还原截图中的色系氛围 (洗护、接送、喂遛的色卡映射)
|
|
|
+ option.color = ['#67c23a', '#409eff', '#e6a23c', '#f56c6c', '#909399'];
|
|
|
+
|
|
|
+ pieChartInstance.setOption(option);
|
|
|
+};
|
|
|
+
|
|
|
+/** 如果后续包含路由直接打开相应审核池 */
|
|
|
+const handleToFulfillerReview = () => {
|
|
|
+ // router.push('/fulfiller/pool');
|
|
|
+};
|
|
|
+
|
|
|
+const resizeCharts = () => {
|
|
|
+ lineChartInstance?.resize();
|
|
|
+ pieChartInstance?.resize();
|
|
|
+};
|
|
|
+
|
|
|
+onMounted(async () => {
|
|
|
+ initDate();
|
|
|
+ window.addEventListener('resize', resizeCharts);
|
|
|
+
|
|
|
+ // 按照先后依赖完成视图初始化
|
|
|
+ await fetchServiceList();
|
|
|
+ fetchAdminCount();
|
|
|
+ fetchChartData();
|
|
|
+ fetchFulfillerRank();
|
|
|
+ fetchStoreRank();
|
|
|
+});
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ window.removeEventListener('resize', resizeCharts);
|
|
|
+ if (lineChartInstance) lineChartInstance.dispose();
|
|
|
+ if (pieChartInstance) pieChartInstance.dispose();
|
|
|
+});
|
|
|
</script>
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
.home {
|
|
|
- min-height: 100vh;
|
|
|
+ padding: 20px;
|
|
|
+ background-color: #f5f7f9;
|
|
|
+ min-height: calc(100vh - 84px);
|
|
|
+}
|
|
|
+
|
|
|
+.welcome-card {
|
|
|
+ border-radius: 12px;
|
|
|
+ border: none;
|
|
|
+ background-color: #fff;
|
|
|
+ margin-bottom: 20px;
|
|
|
+ box-shadow: 0 4px 10px rgba(0, 0, 0, 0.02);
|
|
|
+
|
|
|
+ :deep(.el-card__body) {
|
|
|
+ padding: 24px 32px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.card-content {
|
|
|
display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
align-items: center;
|
|
|
- justify-content: center;
|
|
|
- background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
|
|
- font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
|
|
- overflow-x: hidden;
|
|
|
}
|
|
|
|
|
|
-.coming-soon-container {
|
|
|
- width: 100%;
|
|
|
- max-width: 600px;
|
|
|
- padding: 40px;
|
|
|
+.left-section {
|
|
|
+ .title {
|
|
|
+ font-size: 24px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #1d2129;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ letter-spacing: 0.5px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .subtitle {
|
|
|
+ font-size: 14px;
|
|
|
+ color: #86909c;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.right-section {
|
|
|
+ display: flex;
|
|
|
+ gap: 20px;
|
|
|
}
|
|
|
|
|
|
-.coming-soon-content {
|
|
|
- background: rgba(255, 255, 255, 0.95);
|
|
|
- border-radius: 20px;
|
|
|
- box-shadow: 0 20px 60px rgba(0, 0, 0, 0.08);
|
|
|
- backdrop-filter: blur(10px);
|
|
|
- -webkit-backdrop-filter: blur(10px);
|
|
|
- border: 1px solid rgba(255, 255, 255, 0.3);
|
|
|
- padding: 60px 40px;
|
|
|
- text-align: center;
|
|
|
- transition: all 0.3s ease;
|
|
|
+.stat-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 16px;
|
|
|
+ padding: 16px 32px 16px 24px;
|
|
|
+ background-color: #ffffff;
|
|
|
+ border-radius: 12px;
|
|
|
+ border: 1px solid #f0f2f5;
|
|
|
+ box-shadow: 0 4px 10px rgba(0, 0, 0, 0.02);
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
|
|
|
|
&:hover {
|
|
|
- transform: translateY(-5px);
|
|
|
- box-shadow: 0 25px 70px rgba(0, 0, 0, 0.12);
|
|
|
+ box-shadow: 0 8px 20px rgba(0, 0, 0, 0.06);
|
|
|
+ transform: translateY(-2px);
|
|
|
}
|
|
|
-}
|
|
|
|
|
|
-.icon-wrapper {
|
|
|
- margin-bottom: 30px;
|
|
|
- animation: float 3s ease-in-out infinite;
|
|
|
+ .icon-wrapper {
|
|
|
+ width: 48px;
|
|
|
+ height: 48px;
|
|
|
+ border-radius: 12px;
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ align-items: center;
|
|
|
+ font-size: 24px;
|
|
|
|
|
|
- .icon {
|
|
|
- width: 80px;
|
|
|
- height: 80px;
|
|
|
- color: #409eff;
|
|
|
+ &.warning {
|
|
|
+ color: #ff7d00;
|
|
|
+ background-color: #fff2e8;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.primary {
|
|
|
+ color: #409eff;
|
|
|
+ background-color: #e6f1fc;
|
|
|
+ }
|
|
|
}
|
|
|
-}
|
|
|
|
|
|
-.coming-soon-title {
|
|
|
- font-size: 32px;
|
|
|
- font-weight: 600;
|
|
|
- color: #303133;
|
|
|
- margin: 0 0 15px 0;
|
|
|
- letter-spacing: 1px;
|
|
|
+ .stat-info {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ justify-content: center;
|
|
|
+
|
|
|
+ .stat-value {
|
|
|
+ font-size: 24px;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #1d2129;
|
|
|
+ line-height: 1;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ text-align: left;
|
|
|
+ }
|
|
|
+
|
|
|
+ .stat-label {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #86909c;
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
-.coming-soon-subtitle {
|
|
|
- font-size: 18px;
|
|
|
- color: #606266;
|
|
|
- margin: 0 0 40px 0;
|
|
|
- font-weight: 400;
|
|
|
+.mini-cards-row {
|
|
|
+ margin-bottom: 20px;
|
|
|
}
|
|
|
|
|
|
-.process-guide {
|
|
|
+.data-card {
|
|
|
+ position: relative;
|
|
|
+ padding: 24px;
|
|
|
+ border-radius: 12px;
|
|
|
+ color: #fff;
|
|
|
+ overflow: hidden;
|
|
|
+ height: 140px;
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
- gap: 15px;
|
|
|
- max-width: 320px;
|
|
|
- margin: 0 auto;
|
|
|
- text-align: left;
|
|
|
+ justify-content: space-between;
|
|
|
+ box-shadow: 0 4px 10px rgba(0,0,0,0.05);
|
|
|
+ box-sizing: border-box;
|
|
|
+
|
|
|
+ &.bg-blue {
|
|
|
+ background: linear-gradient(135deg, #35c8fe 0%, #20b2aa 100%);
|
|
|
+ }
|
|
|
+ &.bg-purple {
|
|
|
+ background: linear-gradient(135deg, #b392f0 0%, #dda2e0 100%);
|
|
|
+ }
|
|
|
+ &.bg-orange {
|
|
|
+ background: linear-gradient(135deg, #fdb154 0%, #ff986e 100%);
|
|
|
+ }
|
|
|
+ &.bg-green {
|
|
|
+ background: linear-gradient(135deg, #79e0b1 0%, #85e89d 100%);
|
|
|
+ }
|
|
|
+
|
|
|
+ .card-title {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 500;
|
|
|
+ opacity: 0.9;
|
|
|
+ }
|
|
|
+
|
|
|
+ .card-value {
|
|
|
+ font-size: 28px;
|
|
|
+ font-weight: bold;
|
|
|
+ line-height: 1.2;
|
|
|
+ z-index: 1;
|
|
|
+
|
|
|
+ .currency {
|
|
|
+ font-size: 18px;
|
|
|
+ margin-right: 2px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .card-footer {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ font-size: 12px;
|
|
|
+ opacity: 0.9;
|
|
|
+ z-index: 1;
|
|
|
+
|
|
|
+ .footer-text {
|
|
|
+ white-space: nowrap;
|
|
|
+ }
|
|
|
+
|
|
|
+ .trend-tag {
|
|
|
+ background: rgba(255, 255, 255, 0.2);
|
|
|
+ padding: 2px 8px;
|
|
|
+ border-radius: 10px;
|
|
|
+ font-size: 12px;
|
|
|
+ display: inline-block;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .card-bg-icon {
|
|
|
+ position: absolute;
|
|
|
+ right: -10px;
|
|
|
+ bottom: -15px;
|
|
|
+ font-size: 80px;
|
|
|
+ opacity: 0.15;
|
|
|
+ z-index: 0;
|
|
|
+ transform: rotate(-15deg);
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
-.guide-item {
|
|
|
- padding: 12px 20px;
|
|
|
- background: #f8faff;
|
|
|
- border-radius: 10px;
|
|
|
- border: 1px border-color(#e4e7ed);
|
|
|
- transition: all 0.2s ease;
|
|
|
+.charts-row {
|
|
|
+ margin-bottom: 20px;
|
|
|
+}
|
|
|
|
|
|
- &:hover {
|
|
|
- background: #ecf5ff;
|
|
|
- transform: scale(1.02);
|
|
|
+.chart-card {
|
|
|
+ border-radius: 12px;
|
|
|
+ border: none;
|
|
|
+ background-color: #fff;
|
|
|
+ height: 100%;
|
|
|
+ box-shadow: 0 4px 10px rgba(0, 0, 0, 0.02);
|
|
|
+
|
|
|
+ :deep(.el-card__body) {
|
|
|
+ padding: 24px;
|
|
|
}
|
|
|
+}
|
|
|
|
|
|
- .guide-info {
|
|
|
+.chart-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 24px;
|
|
|
+
|
|
|
+ .chart-title {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
- gap: 12px;
|
|
|
- color: #409eff;
|
|
|
font-size: 16px;
|
|
|
- font-weight: 500;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #1d2129;
|
|
|
+
|
|
|
+ .title-icon {
|
|
|
+ width: 4px;
|
|
|
+ height: 14px;
|
|
|
+ background-color: #409eff;
|
|
|
+ border-radius: 2px;
|
|
|
+ margin-right: 8px;
|
|
|
+ }
|
|
|
}
|
|
|
+}
|
|
|
|
|
|
- .dot {
|
|
|
- width: 8px;
|
|
|
- height: 8px;
|
|
|
- background-color: #409eff;
|
|
|
- border-radius: 50%;
|
|
|
- }
|
|
|
+.chart-container {
|
|
|
+ width: 100%;
|
|
|
+ height: 350px;
|
|
|
}
|
|
|
|
|
|
-@keyframes float {
|
|
|
- 0%, 100% {
|
|
|
- transform: translateY(0);
|
|
|
+.rank-card {
|
|
|
+ .chart-header {
|
|
|
+ .header-tag {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #86909c;
|
|
|
+ background-color: #f2f3f5;
|
|
|
+ padding: 3px 10px;
|
|
|
+ border-radius: 4px;
|
|
|
+ }
|
|
|
}
|
|
|
- 50% {
|
|
|
- transform: translateY(-10px);
|
|
|
+
|
|
|
+ .rank-list {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8px;
|
|
|
+ height: 312px;
|
|
|
}
|
|
|
-}
|
|
|
|
|
|
-@media (max-width: 768px) {
|
|
|
- .coming-soon-container {
|
|
|
- padding: 20px;
|
|
|
+ .rank-item {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding: 10px 16px;
|
|
|
+ border-radius: 8px;
|
|
|
+
|
|
|
+ &:nth-child(even) {
|
|
|
+ background-color: #f7f8fa;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- .coming-soon-content {
|
|
|
- padding: 40px 20px;
|
|
|
+ .item-left {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+
|
|
|
+ .store-avatar {
|
|
|
+ background-color: #e8f5e9;
|
|
|
+ color: #67c23a;
|
|
|
+ border-radius: 6px;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- .icon-wrapper .icon {
|
|
|
- width: 60px;
|
|
|
- height: 60px;
|
|
|
+ .rank-index {
|
|
|
+ width: 24px;
|
|
|
+ height: 24px;
|
|
|
+ border-radius: 50%;
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ align-items: center;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #fff;
|
|
|
+ margin-right: 2px;
|
|
|
+
|
|
|
+ &.rank-1 { background-color: #ffc53d; }
|
|
|
+ &.rank-2 { background-color: #c9cdd4; }
|
|
|
+ &.rank-3 { background-color: #d28248; }
|
|
|
+ &.rank-4, &.rank-5 { background-color: #f2f3f5; color: #86909c; }
|
|
|
}
|
|
|
|
|
|
- .coming-soon-title {
|
|
|
- font-size: 24px;
|
|
|
+ .rank-info {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 2px;
|
|
|
+
|
|
|
+ .rank-name {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 500;
|
|
|
+ color: #1d2129;
|
|
|
+ }
|
|
|
+
|
|
|
+ .rank-site {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #86909c;
|
|
|
+ }
|
|
|
+
|
|
|
+ .rank-score {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #e6a23c;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 2px;
|
|
|
+ font-weight: 500;
|
|
|
+ margin-top: 1px;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- .coming-soon-subtitle {
|
|
|
- font-size: 16px;
|
|
|
+ .item-right {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: flex-end;
|
|
|
+ gap: 6px;
|
|
|
+ width: 90px;
|
|
|
+
|
|
|
+ .rank-count {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #1d2129;
|
|
|
+ }
|
|
|
+
|
|
|
+ .rank-bar-bg {
|
|
|
+ width: 100%;
|
|
|
+ height: 4px;
|
|
|
+ background-color: #f2f3f5;
|
|
|
+ border-radius: 2px;
|
|
|
+ overflow: hidden;
|
|
|
+
|
|
|
+ .rank-bar-fill {
|
|
|
+ height: 100%;
|
|
|
+ border-radius: 2px;
|
|
|
+ transition: width 0.5s ease;
|
|
|
+
|
|
|
+ &.bar-color-1 { background-color: #f56c6c; }
|
|
|
+ &.bar-color-2 { background-color: #e6a23c; }
|
|
|
+ &.bar-color-3 { background-color: #409eff; }
|
|
|
+ &.bar-color-4, &.bar-color-5 { background-color: #909399; }
|
|
|
+
|
|
|
+ &.store-bar-color-1 { background-color: #67c23a; }
|
|
|
+ &.store-bar-color-2 { background-color: #409eff; }
|
|
|
+ &.store-bar-color-3, &.store-bar-color-4, &.store-bar-color-5 { background-color: #909399; }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &.store-right {
|
|
|
+ width: 100px;
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
</style>
|
|
|
-
|