Files
guilixu-admin/src/views/special/dashboard/index.vue
T
gxwebsoft a284a83b86 feat(dashboard): 新增后台今日数据概况及仪表盘页面
- 新增 shopOrderTotalAll 和 shopOrderOverview 接口,支持含未支付口径数据统计
- 新增 ShopOrderOverview 类型定义,包含今日销售额、订单数、新增用户数和优惠券使用数
- 新增后台专用仪表盘页面,展示用户数、订单数、总营业额和系统运行天数等核心数据
- 仪表盘页面实现新订单提醒功能,支持声音和语音播报
- 提供待付款、待发货订单数和退款申请动态展示,带点击跳转功能
- 新增今日数据概况区域,显示销售额、订单数、新增用户和优惠券使用情况
- 集成订阅和续费功能,支持支付二维码扫码支付及状态轮询
- 基本信息展示系统版本、授权状态以及订阅到期提醒和续费入口
- 快速入口模块方便访问订单管理、商品管理、优惠券管理等功能页
- 实现数据自动刷新机制,包括统计数据和含未支付订单的特殊统计
- 优化了界面样式和交互体验,提升了后台管理的可用性和操作便捷性
2026-08-01 22:10:28 +08:00

1128 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="shop-dashboard">
<!-- 欢迎横幅 -->
<div class="welcome-banner">
<div class="welcome-left flex flex-row gap-2 items-center">
<a-avatar
:src="shopLogoUrl || 'https://oss.wsdns.cn/20240822/0252ad4ed46449cdafe12f8d3d96c2ea.svg'"
:size="64"
shape="square"
class="welcome-logo"
/>
<div>
<h2 class="welcome-title">{{ userStore.info?.tenantName }}</h2>
<p class="welcome-sub">欢迎回来,管理员,今日数据已更新</p>
</div>
</div>
<div class="welcome-right">
<div class="welcome-actions">
<div class="order-notify-bar">
<span class="order-notify-label">新订单提醒</span>
<a-switch v-model:checked="notifyEnabled" @change="onNotifyToggle" size="small" />
<a-tooltip title="开启后每60秒自动检测新订单,检测到后播放叮声并语音播报">
<InfoCircleOutlined class="order-notify-info" />
</a-tooltip>
<!-- <a-button size="small" :disabled="!notifyEnabled" @click="onTestNotify">测试</a-button>-->
<a-button size="small" @click="refreshStatistics" :loading="loading">
<template #icon><ReloadOutlined /></template>
刷新数据
</a-button>
</div>
</div>
</div>
</div>
<!-- 核心数据统计 -->
<a-row :gutter="[16, 16]">
<a-col :xs="12" :sm="12" :md="6" v-for="stat in coreStats" :key="stat.label">
<div class="stat-block" :class="stat.color" @click="navigateTo(stat.to)" :style="{ cursor: stat.to ? 'pointer' : 'default' }">
<div class="stat-block-header">
<span class="stat-block-icon">{{ stat.icon }}</span>
<span class="stat-block-label">{{ stat.label }}</span>
</div>
<div class="stat-block-value">
<template v-if="loading">
<a-skeleton-input :active="true" size="small" style="width:60px" />
</template>
<template v-else>{{ stat.value }}</template>
</div>
<div class="stat-block-desc">{{ stat.desc }}</div>
</div>
</a-col>
</a-row>
<!-- 待办事项 + 快捷操作 -->
<a-row :gutter="[16, 16]">
<!-- 待处理事项 -->
<a-col :xs="24" :md="12">
<div class="panel">
<div class="panel-header">
<span class="panel-title">🔔 待处理事项</span>
</div>
<div class="todo-list">
<div
v-for="todo in todoItems"
:key="todo.label"
class="todo-item"
:class="{ 'todo-item-urgent': todo.urgent }"
@click="navigateTo(todo.to)"
>
<div class="todo-dot" :class="todo.dotColor"></div>
<div class="todo-content">
<span class="todo-label">{{ todo.label }}</span>
<a-tag :color="todo.tagColor" style="margin-left:8px">
<template v-if="loading">...</template>
<template v-else>{{ todo.value }}</template>
</a-tag>
</div>
<RightOutlined class="todo-arrow" />
</div>
<!-- <div v-if="!loading && todoItems.every(t => t.value === 0)" class="todo-empty">-->
<!-- 🎉 暂无待处理事项,一切正常!-->
<!-- </div>-->
</div>
</div>
</a-col>
<!-- 今日数据概况 -->
<a-col :xs="24" :md="12">
<div class="panel">
<div class="panel-header">
<span class="panel-title">📊 今日数据概况</span>
</div>
<div class="today-stats">
<div class="today-stat">
<div class="today-stat-icon">💰</div>
<div class="today-stat-info">
<div class="today-stat-value">{{ formatMoney(todayStats.salesAmount) }}</div>
<div class="today-stat-label">今日销售额(元)</div>
</div>
</div>
<div class="today-stat">
<div class="today-stat-icon">📦</div>
<div class="today-stat-info">
<div class="today-stat-value">{{ todayStats.orderCount || 0 }}</div>
<div class="today-stat-label">今日订单数</div>
</div>
</div>
<div class="today-stat">
<div class="today-stat-icon">👥</div>
<div class="today-stat-info">
<div class="today-stat-value">{{ todayStats.newUsers || 0 }}</div>
<div class="today-stat-label">新增用户</div>
</div>
</div>
<div class="today-stat">
<div class="today-stat-icon">🎁</div>
<div class="today-stat-info">
<div class="today-stat-value">{{ todayStats.couponUsed || 0 }}</div>
<div class="today-stat-label">使用优惠券</div>
</div>
</div>
</div>
</div>
</a-col>
</a-row>
<!-- 今日数据概况 + 基本信息 -->
<a-row :gutter="[16, 16]">
<!-- 基本信息 -->
<a-col :xs="24" :md="12">
<div class="panel">
<div class="panel-header">
<span class="panel-title">📋 基本信息</span>
</div>
<div style="padding: 23px 18px;">
<a-descriptions :column="1" size="small">
<a-descriptions-item label="软件名称">
<template v-if="subscriptionLoading">
<a-skeleton-input :active="true" size="small" style="width: 160px" />
</template>
<template v-else>{{ currentProduct?.productName || '-' }}</template>
</a-descriptions-item>
<a-descriptions-item label="当前版本">
<template v-if="subscriptionLoading">
<a-skeleton-input :active="true" size="small" style="width: 160px" />
</template>
<template v-else>{{ currentProduct?.version || '-' }}</template>
</a-descriptions-item>
<a-descriptions-item label="授权编号">
<span>{{ currentSubscription?.subscriptionNo || '-' }}</span>
</a-descriptions-item>
<a-descriptions-item label="开通时间">
<template v-if="subscriptionLoading">
<a-skeleton-input :active="true" size="small" style="width: 160px" />
</template>
<template v-else>{{ formatDateTime(currentSubscription?.startTime) }}</template>
</a-descriptions-item>
<a-descriptions-item label="到期时间">
<template v-if="subscriptionLoading">
<a-skeleton-input :active="true" size="small" style="width: 140px" />
</template>
<template v-else-if="currentSubscription && currentSubscription.status === 'active' && currentSubscription.expireTime">
<span>{{ formatDateTime(currentSubscription.expireTime) }}</span>
<a-tag v-if="isExpired" :color="isExpired ? 'red' : 'green'" style="margin-left: 8px">
{{ isExpired ? '已过期' : '已激活' }}
</a-tag>
<span v-if="daysToExpire !== null && daysToExpire >= 0 && daysToExpire <= 7" class="expire-warn">
({{ daysToExpire }}天后到期)
</span>
<span v-else-if="daysToExpire !== null && daysToExpire < 0" class="expire-warn">
(已过期{{ Math.abs(daysToExpire) }}天)
</span>
<!-- 续费按钮:即将到期(7天内)或已过期时显示 -->
<a-button v-if="showRenewButton" type="link" size="small" @click="onRenew" style="padding: 0 0 0 8px">
立即续费
</a-button>
</template>
<template v-else-if="currentSubscription && currentSubscription.status === 'pending'">
<span class="text-orange">待支付 ¥{{ Number(currentSubscription.payPrice || 0).toFixed(2) }}</span>
<a-button type="link" size="small" @click="repayPending" style="padding: 0 0 0 8px">去支付</a-button>
</template>
<template v-else>
<span class="text-muted">未订阅</span>
<a-button type="link" size="small" @click="onSubscribe" style="padding: 0 0 0 8px">立即订阅</a-button>
</template>
</a-descriptions-item>
<!-- <a-descriptions-item label="系统运行">-->
<!-- <template v-if="subscriptionLoading">-->
<!-- <a-skeleton-input :active="true" size="small" style="width: 60px" />-->
<!-- </template>-->
<!-- <template v-else>-->
<!-- <a-tooltip title="自您首次开通小程序商城起">-->
<!-- <span>{{ runDays }} 天</span>-->
<!-- </a-tooltip>-->
<!-- </template>-->
<!-- </a-descriptions-item>-->
<!-- <a-descriptions-item label="绑定小程序">-->
<!-- {{ currentSubscription?.miniProgramId || 'wx75cbc65759eb981a' }}-->
<!-- </a-descriptions-item>-->
<!-- <a-descriptions-item label="售后服务">-->
<!-- <a href="https://websoft.top/login" class="text-gray-500">提交售后工单</a>-->
<!-- </a-descriptions-item>-->
</a-descriptions>
</div>
</div>
</a-col>
<!-- 快速入口 -->
<a-col :xs="24" :md="12">
<div class="panel">
<div class="panel-header">
<span class="panel-title">⚡ 快速入口</span>
</div>
<div class="quick-grid">
<div
v-for="item in quickLinks"
:key="item.to"
class="quick-card"
@click="navigateTo(item.to)"
>
<div class="quick-icon" :style="{ background: item.bg }">
{{ item.icon }}
<span v-if="item.badge" class="quick-icon-dot"></span>
</div>
<div class="quick-label">{{ item.label }}</div>
</div>
</div>
</div>
</a-col>
</a-row>
<!-- 订阅/续费 Modal:选择订阅周期(subscribe-新订阅 / renew-续费) -->
<a-modal
v-model:open="subscribeModalVisible"
:title="subscribeMode === 'renew' ? '续费应用' : '订阅应用'"
:confirm-loading="subscribeMode === 'renew' ? renewing : subscribing"
:ok-text="subscribeMode === 'renew' ? '确认续费' : '确认订阅'"
cancel-text="取消"
:mask-closable="false"
@ok="subscribeMode === 'renew' ? confirmRenew() : confirmSubscribe()"
>
<div class="subscribe-modal-body">
<div class="subscribe-product-name">
{{ currentProduct?.productName || '应用订阅' }}
</div>
<a-alert
v-if="isFreeProduct"
type="success"
:message="subscribeMode === 'renew' ? '该应用为免费应用,续费后立即激活' : '该应用为免费应用,订阅后立即激活'"
show-icon
style="margin-bottom: 12px"
/>
<div v-else class="period-options">
<div
class="period-option"
:class="{ active: selectedPeriod === 'month' }"
@click="selectedPeriod = 'month'"
>
<div class="period-option-label">月付</div>
<div class="period-option-price">¥{{ monthPrice.toFixed(2) }}/月</div>
</div>
<div
class="period-option"
:class="{ active: selectedPeriod === 'year' }"
@click="selectedPeriod = 'year'"
>
<div class="period-option-label">年付</div>
<div class="period-option-price">¥{{ yearPrice.toFixed(2) }}/年</div>
<div class="period-option-tag">更划算</div>
</div>
</div>
</div>
</a-modal>
<!-- 支付 Modal:微信扫码支付 -->
<a-modal
v-model:open="payModalVisible"
:footer="null"
:mask-closable="false"
@cancel="onPayModalClose"
>
<div class="pay-modal-body">
<div class="pay-product-name">{{ payProductName }}</div>
<div class="pay-amount">
<span class="pay-amount-label">支付金额</span>
<span class="pay-amount-value">¥{{ payPrice.toFixed(2) }}</span>
</div>
<div class="pay-qrcode-wrap">
<img
v-if="payQrcode"
:src="payQrcode"
alt="支付二维码"
class="pay-qrcode-img"
/>
<a-spin v-else />
</div>
<div class="pay-tip">
<QrcodeOutlined />
请使用微信扫描上方二维码完成支付
</div>
<div class="pay-order-no">订单号:{{ paySubscriptionNo }}</div>
</div>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { useOrderNotify } from '@/views/shop/shopOrder/useOrderNotify';
import {
ReloadOutlined,
RightOutlined,
InfoCircleOutlined,
QrcodeOutlined
} from '@ant-design/icons-vue';
import { message } from 'ant-design-vue/es';
import { useSiteStore } from '@/store/modules/site';
import { useStatisticsStore } from '@/store/modules/statistics';
import { useUserStore } from '@/store/modules/user';
import { useTenantStore } from '@/store/modules/tenant';
import { useAppSubscriptionStore } from '@/store/modules/appSubscription';
import { pageShopOrder, shopOrderTotalAll, shopOrderOverview } from '@/api/shop/shopOrder';
import type { ShopOrderOverview } from '@/api/shop/shopOrder';
import { getShopSettingCategoryValues } from '@/api/shop/shopSetting';
import { getCompressedImageUrl } from '@/utils/image';
import { storeToRefs } from 'pinia';
import { getProductDetail } from '@/api/app/appProduct';
import type { AppProduct } from '@/api/app/appProduct/model';
import type { AppSubscription } from '@/api/app/appSubscription/model';
import dayjs from 'dayjs';
// 使用状态管理
const siteStore = useSiteStore();
const statisticsStore = useStatisticsStore();
const userStore = useUserStore();
const tenantStore = useTenantStore();
const subscriptionStore = useAppSubscriptionStore();
const router = useRouter();
// 从 store 中获取响应式数据
const { loading: siteLoading } = storeToRefs(siteStore);
const { loading: statisticsLoading } = storeToRefs(statisticsStore);
// ============================================================
// 基本信息:数据来源拆分
// - 身份信息(系统名称/版本号/运行状态)来自 app_product (productId=65)
// - 时间线(开通时间/到期时间)来自 app_subscription
// · 开通时间 = subscription.startTime(订阅生效时间)
// · 到期时间 = subscription.expireTime
// - 系统运行天数 = 自租户创建时间起累计(tenantStore.company.createTime)
// ============================================================
// 当前应用产品(按固定 productId=65 查询)
const currentProduct = ref<AppProduct | null>(null);
// 应用发布状态映射(运行状态)
const productStatusText = computed(() => {
const map: Record<string, { text: string; color: string }> = {
published: { text: '已发布', color: 'green' },
pending_review: { text: '审核中', color: 'orange' },
developing: { text: '开发中', color: 'blue' },
rejected: { text: '已驳回', color: 'red' },
deprecated: { text: '已下架', color: 'red' },
};
const status = currentProduct.value?.publishStatus;
if (!status) return { text: '-', color: 'default' };
return map[status] || { text: status, color: 'default' };
});
// 当前应用的有效订阅
const currentSubscription = ref<AppSubscription | null>(null);
// 订阅信息加载中
const subscriptionLoading = ref(false);
// 订阅 Modal(选择周期)
const subscribeModalVisible = ref(false);
const subscribing = ref(false);
const selectedPeriod = ref<'month' | 'year'>('month');
// Modal 模式:subscribe-新订阅 / renew-续费(复用同一 Modal)
const subscribeMode = ref<'subscribe' | 'renew'>('subscribe');
const renewing = ref(false);
// 支付 Modal(小程序码扫码支付)
const payModalVisible = ref(false);
const payQrcode = ref('');
const payPrice = ref(0);
const paySubscriptionNo = ref('');
const payProductName = ref('');
const pollingTimer = ref<ReturnType<typeof setInterval> | null>(null);
const pollCount = ref(0);
const MAX_POLL_COUNT = 120; // 2.5s * 120 = 5分钟
// 微信小程序环境版本:开发用 trial,生产用 release
const envVersion = import.meta.env.DEV ? 'trial' : 'release';
// 是否免费应用
const isFreeProduct = computed(() => {
const p = currentProduct.value;
if (!p) return false;
return p.priceType === 'free' || !p.price || Number(p.price) === 0;
});
// 月付参考价格(product.price 单位为分,除以100转为元)
const monthPrice = computed(() => (Number(currentProduct.value?.price) || 0) / 100);
// 年付参考价格(按10个月计费,对齐后端 subscribe/generatePayQrcode 年付优惠)
const yearPrice = computed(() => monthPrice.value * 10);
// 距到期天数(负数表示已过期),null 表示无到期时间
const daysToExpire = computed(() => {
const expire = currentSubscription.value?.expireTime;
if (!expire) return null;
return dayjs(expire).startOf('day').diff(dayjs().startOf('day'), 'day');
});
// 是否已过期(expireTime 早于今天)
const isExpired = computed(
() => daysToExpire.value !== null && daysToExpire.value < 0
);
// 续费按钮显示条件:即将到期(7天内)或已过期
const showRenewButton = computed(() => {
if (daysToExpire.value === null) return false;
return daysToExpire.value <= 7;
});
// 格式化时间
const formatDateTime = (dt?: string) => {
if (!dt) return '-';
return dayjs(dt).format('YYYY-MM-DD HH:mm:ss');
};
// 加载当前应用的订阅信息
const loadSubscriptionInfo = async () => {
// 固定订阅产品:productId=65(小程序商城)
const FIXED_PRODUCT_ID = 65;
subscriptionLoading.value = true;
try {
// 1. 按固定 productId 查询产品详情
const product = await getProductDetail(FIXED_PRODUCT_ID);
if (!product || !product.productId) {
console.warn(`未找到产品 ${FIXED_PRODUCT_ID}`);
return;
}
currentProduct.value = product;
// 2. 拉取我的订阅列表,过滤出该产品的订阅
const subRes = await subscriptionStore.fetchMySubscriptions(
{ page: 1, limit: 50 },
false
);
const subs = (subRes.list || []).filter(
(s) => s.productId === FIXED_PRODUCT_ID
);
// 优先取 active 中 expireTime 最新;否则取 pending 最新
const active = subs
.filter((s) => s.status === 'active')
.sort(
(a, b) =>
dayjs(b.expireTime).valueOf() - dayjs(a.expireTime).valueOf()
)[0];
const pending = subs
.filter((s) => s.status === 'pending')
.sort(
(a, b) => dayjs(b.createTime).valueOf() - dayjs(a.createTime).valueOf()
)[0];
currentSubscription.value = active || pending || null;
} catch (e) {
console.warn('获取订阅信息失败:', e);
} finally {
subscriptionLoading.value = false;
}
};
// 刷新订阅信息(支付成功/订阅成功后调用)
const refreshSubscription = () => {
loadSubscriptionInfo().catch(() => {});
};
// 打开订阅 Modal
const onSubscribe = () => {
if (!currentProduct.value?.productId) {
message.warning('应用信息加载中,请稍后再试');
return;
}
selectedPeriod.value = 'month';
subscribeMode.value = 'subscribe';
subscribeModalVisible.value = true;
};
// 打开续费 Modal(复用订阅 Modal)
const onRenew = () => {
if (!currentSubscription.value?.id) {
message.warning('订阅信息加载中,请稍后再试');
return;
}
selectedPeriod.value = 'month';
subscribeMode.value = 'renew';
subscribeModalVisible.value = true;
};
// 确认续费(后端 renew-pay 一步完成:创建续费订单 + 生成支付码,无需再调 pay)
const confirmRenew = async () => {
const sub = currentSubscription.value;
if (!sub?.id) return;
renewing.value = true;
try {
// renew-pay 直接返回支付结果(微信支付返回小程序码,余额支付返回 paid)
const result = await subscriptionStore.renew(
sub.id,
selectedPeriod.value,
'wechat',
envVersion
);
// 余额支付:直接成功
if ('paid' in result && result.paid) {
message.success('续费成功');
subscribeModalVisible.value = false;
refreshSubscription();
return;
}
// 微信支付:弹小程序码扫码
if ('miniappQrcode' in result) {
paySubscriptionNo.value = result.subscriptionNo;
payQrcode.value = result.miniappQrcode;
payPrice.value = Number(result.payPrice) || 0;
payProductName.value = currentProduct.value?.productName || '应用续费';
subscribeModalVisible.value = false;
payModalVisible.value = true;
// 开始轮询支付状态
startPolling(paySubscriptionNo.value);
}
} catch (e: any) {
console.error('续费失败:', e);
message.error(e?.message || '续费失败,请重试');
} finally {
renewing.value = false;
}
};
// 确认订阅
const confirmSubscribe = async () => {
const product = currentProduct.value;
if (!product?.productId) return;
subscribing.value = true;
try {
// 1. 创建订阅(subscribe 后端会 setPriceType,避免 generatePayQrcode 的 price_type 报错)
const subResult = await subscriptionStore.subscribe({
productId: product.productId,
subscriptionPeriod: selectedPeriod.value
});
// 免费应用:subscribe 直接激活,status=active
if (subResult.status === 'active') {
message.success('订阅成功');
subscribeModalVisible.value = false;
refreshSubscription();
return;
}
// 2. 付费应用:用 pay 生成支付小程序码(参考 websopy-pc 的 subscribe + pay 模式)
const payResult = await subscriptionStore.pay(
subResult.subscriptionId,
'wechat',
envVersion
);
if ('miniappQrcode' in payResult) {
paySubscriptionNo.value =
payResult.subscriptionNo || subResult.subscriptionNo;
payQrcode.value = payResult.miniappQrcode;
payPrice.value = Number(payResult.payPrice) || 0;
payProductName.value = product.productName || '应用订阅';
subscribeModalVisible.value = false;
payModalVisible.value = true;
// 开始轮询支付状态
startPolling(paySubscriptionNo.value);
}
} catch (e: any) {
console.error('创建订阅失败:', e);
message.error(e?.message || '创建订阅失败,请重试');
} finally {
subscribing.value = false;
}
};
// 重新支付(pending 订阅,复用已有订阅记录生成小程序码)
const repayPending = async () => {
const sub = currentSubscription.value;
if (!sub?.id || !sub.subscriptionNo) return;
try {
const result = await subscriptionStore.pay(sub.id, 'wechat', envVersion);
if ('miniappQrcode' in result) {
paySubscriptionNo.value = sub.subscriptionNo;
payQrcode.value = result.miniappQrcode;
payPrice.value = Number(result.payPrice) || 0;
payProductName.value =
sub.productName || currentProduct.value?.productName || '应用订阅';
payModalVisible.value = true;
startPolling(sub.subscriptionNo);
}
} catch (e: any) {
message.error(e?.message || '生成支付码失败');
}
};
// 开始轮询支付状态(每 2.5s 查一次,最多 5 分钟)
const startPolling = (subscriptionNo: string) => {
stopPolling();
pollCount.value = 0;
pollingTimer.value = setInterval(async () => {
pollCount.value++;
if (pollCount.value > MAX_POLL_COUNT) {
stopPolling();
message.warning('支付状态查询超时,如已支付请刷新页面');
return;
}
try {
const status = await subscriptionStore.checkStatus(subscriptionNo);
if (status.paid) {
stopPolling();
payModalVisible.value = false;
message.success('支付成功');
refreshSubscription();
}
} catch (e) {
console.warn('查询支付状态失败:', e);
}
}, 2500);
};
// 停止轮询
const stopPolling = () => {
if (pollingTimer.value) {
clearInterval(pollingTimer.value);
pollingTimer.value = null;
}
pollCount.value = 0;
};
// 关闭支付 Modal
const onPayModalClose = () => {
stopPolling();
payModalVisible.value = false;
payQrcode.value = '';
paySubscriptionNo.value = '';
};
// 计算属性
const now = ref(Date.now());
let runDaysTimer: ReturnType<typeof setInterval>;
// 含未支付特殊统计的自动刷新定时器
let specialRefreshTimer: ReturnType<typeof setInterval>;
// 系统运行天数:自租户创建时间起累计
// 锚定 tenantStore.company.createTime(租户建档时间),与后台租户创建时间一致。
// 数据来源:loadData 中已调用 tenantStore.fetchTenantInfo() 写入 tenantStore.company。
const runDays = computed(() => {
const createTime = tenantStore.company?.createTime;
if (!createTime) return 0;
return Math.floor((now.value - new Date(createTime).getTime()) / (24 * 60 * 60 * 1000));
});
const userCount = computed(() => statisticsStore.userCount);
const orderCount = computed(() => statisticsStore.orderCount);
// 以下指标使用「含未支付」口径的特殊接口(/total-all、/overview)
const totalAllAmount = ref(0); // 总营业额(含未支付):/shop/shop-order/total-all
const overview = ref<ShopOrderOverview | null>(null); // 今日数据概况(含未支付)
const totalSales = computed(() => totalAllAmount.value || 0);
const todaySales = computed(() => overview.value?.todaySales || 0);
const todayOrders = computed(() => overview.value?.todayOrders || 0);
const todayUsers = computed(() => overview.value?.todayUsers || 0);
// 含未支付特殊统计的加载状态
const specialLoading = ref(false);
// 加载状态
const loading = computed(() => siteLoading.value || statisticsLoading.value || specialLoading.value);
// 核心统计数据(使用computed确保响应式更新)
const coreStats = computed(() => [
{ icon: '🏸', label: '用户总数', value: userCount.value || 0, desc: '注册用户', color: 'blue', to: '/system/user' },
{ icon: '📦', label: '订单总数', value: orderCount.value || 0, desc: '全部订单', color: 'orange', to: '/shop/shopOrder?tab=all' },
{ icon: '💰', label: '总营业额', value: totalSales.value || 0, desc: '元', color: 'purple', to: '/shop/shopOrder?tab=all' },
{ icon: '⏱️', label: '运行天数', value: runDays.value || 0, desc: '系统运行', color: 'green', to: '' },
]);
// 待处理事项数据
const pendingPaymentCount = ref(0);
const pendingShipmentCount = ref(0);
const pendingRefundCount = ref(0);
const couponUsedCount = computed(() => overview.value?.couponUsedCount || 0);
// 商城Logo
const shopLogo = ref('');
const shopLogoUrl = computed(() =>
shopLogo.value ? getCompressedImageUrl(shopLogo.value, { width: 200, quality: 90 }) : ''
);
// 待处理事项(使用computed确保响应式更新)
const todoItems = computed(() => [
{ label: '待付款订单', value: pendingPaymentCount.value, to: '/shop/shopOrder?tab=unpaid', tagColor: 'gold', dotColor: 'dot-gold', urgent: pendingPaymentCount.value > 0 },
{ label: '待发货订单', value: pendingShipmentCount.value, to: '/shop/shopOrder?tab=undelivered', tagColor: 'blue', dotColor: 'dot-blue', urgent: pendingShipmentCount.value > 0 },
{ label: '退款申请', value: pendingRefundCount.value, to: '/shop/shopOrder?tab=refunded', tagColor: 'orange', dotColor: 'dot-orange', urgent: pendingRefundCount.value > 0 },
{ label: '优惠券使用', value: couponUsedCount.value, to: '/market/coupon', tagColor: 'cyan', dotColor: 'dot-cyan', urgent: false },
]);
// 今日统计(使用computed确保响应式更新)
const todayStats = computed(() => ({
salesAmount: todaySales.value || 0,
orderCount: todayOrders.value || 0,
newUsers: todayUsers.value || 0,
couponUsed: couponUsedCount.value || 0,
}));
// 快速入口(订单管理项随新订单状态显示红点)
const quickLinks = computed(() => [
{ to: '/shop/shopOrder?tab=all', icon: '📦', label: '订单管理', bg: '#f0fdf4', badge: hasNewOrder.value },
{ to: '/shop/shopGoods', icon: '🏸', label: '商品管理', bg: '#ecfdf5' },
{ to: '/shop/shopGoodsCategory', icon: '🗂️', label: '商品分类', bg: '#eff6ff' },
{ to: '/market/coupon', icon: '🎁', label: '优惠券', bg: '#fff7ed' },
{ to: '/system/user', icon: '👥', label: '会员管理', bg: '#faf5ff' },
{ to: '/shop/shopSetting', icon: '⚙️', label: '商城设置', bg: '#fefce8' },
]);
// 导航跳转
const navigateTo = (path: string) => {
if (path) {
router.push(path);
}
};
// 金额格式化(保留两位小数)
const formatMoney = (value?: number | string) => {
const num = Number(value);
return Number.isFinite(num) ? num.toFixed(2) : '0.00';
};
// 新订单声音提醒(全局单例:轮询由 layout 启动,此处仅消费状态 + UI)
const { enabled: notifyEnabled, hasNewOrder, toggle: onNotifyToggle, testNotify: onTestNotify } = useOrderNotify({
onNewOrder: () => loadData()
});
// 加载「含未支付」特殊统计(总营业额 / 今日数据概况)
// 对接后端 /shop/shop-order/total-all 与 /shop/shop-order/overview
const loadSpecialStats = async () => {
specialLoading.value = true;
try {
const [totalAll, ov] = await Promise.all([
shopOrderTotalAll(),
shopOrderOverview()
]);
totalAllAmount.value = Number(totalAll) || 0;
overview.value = ov;
} catch (e) {
console.warn('获取含未支付统计数据失败:', e);
} finally {
specialLoading.value = false;
}
};
// 刷新统计数据(强制刷新,跳过缓存)
const refreshStatistics = async () => {
try {
await Promise.all([
statisticsStore.fetchStatistics(true),
loadSpecialStats()
]);
message.success('统计数据刷新成功');
} catch (error) {
console.error('刷新统计数据失败:', error);
message.error('刷新统计数据失败');
}
};
// 加载数据
const loadData = async () => {
// 独立请求租户信息,不受其他请求失败影响
tenantStore.fetchTenantInfo().catch((e) => {
console.warn('获取租户信息失败:', e);
});
// 独立加载订阅信息(到期时间),不受其他请求失败影响
loadSubscriptionInfo().catch((e) => {
console.warn('加载订阅信息失败:', e);
});
// 独立请求商城Logo,不受其他请求失败影响
getShopSettingCategoryValues('basic')
.then((values) => {
shopLogo.value = values?.shopLogo || '';
})
.catch((e) => {
console.warn('获取商城设置失败:', e);
});
try {
await Promise.all([
siteStore.fetchSiteInfo(),
statisticsStore.fetchStatistics()
]);
// 获取待处理事项数据
try {
// 获取待付款订单数(type=0商城订单, statusFilter=0待付款)
const paymentResult = await pageShopOrder({
type: 0,
statusFilter: 0,
page: 1,
limit: 1
});
pendingPaymentCount.value = paymentResult?.count || 0;
} catch (e) {
console.warn('获取待付款订单数失败:', e);
pendingPaymentCount.value = 0;
}
try {
// 获取待发货订单数(type=0商城订单, statusFilter=1待发货)
const shipmentResult = await pageShopOrder({
type: 0,
statusFilter: 1,
page: 1,
limit: 1
});
pendingShipmentCount.value = shipmentResult?.count || 0;
} catch (e) {
console.warn('获取待发货订单数失败:', e);
pendingShipmentCount.value = 0;
}
try {
// 获取退款/售后订单数(type=0商城订单, statusFilter=6退款/售后)
const refundResult = await pageShopOrder({
type: 0,
statusFilter: 6,
page: 1,
limit: 1
});
pendingRefundCount.value = refundResult?.count || 0;
} catch (e) {
console.warn('获取退款/售后订单数失败:', e);
pendingRefundCount.value = 0;
}
// 加载含未支付的特殊统计(总营业额 / 今日数据概况)
await loadSpecialStats();
} catch (error) {
console.error('加载数据失败:', error);
}
};
onMounted(async () => {
await loadData();
// 开始自动刷新统计数据(每5分钟)
statisticsStore.startAutoRefresh();
// 含未支付特殊统计每5分钟自动刷新
specialRefreshTimer = setInterval(() => {
loadSpecialStats().catch(console.error);
}, 5 * 60 * 1000);
// 运行天数每小时检查一次(跨天自动更新)
runDaysTimer = setInterval(() => { now.value = Date.now(); }, 60 * 60 * 1000);
});
onUnmounted(() => {
// 组件卸载时停止自动刷新
statisticsStore.stopAutoRefresh();
clearInterval(specialRefreshTimer);
clearInterval(runDaysTimer);
// 停止支付状态轮询
stopPolling();
});
</script>
<style scoped>
.shop-dashboard {
display: flex;
flex-direction: column;
gap: 20px;
padding: 20px 24px;
}
/* 欢迎横幅 */
.welcome-banner {
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(135deg, #00704A 0%, #1a3931 100%);
border-radius: 14px;
padding: 24px 28px;
color: #fff;
flex-wrap: wrap;
gap: 12px;
}
.welcome-logo {
flex-shrink: 0;
border: 2px solid rgba(255,255,255,0.2);
background: rgba(255,255,255,0.1);
}
.welcome-title { font-size: 20px; font-weight: 700; color: #fff; margin: 0 0 6px; }
.welcome-sub { font-size: 14px; color: rgba(255,255,255,0.7); margin: 0; }
/* 欢迎横幅右侧操作区 */
.welcome-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
/* 新订单提醒栏 */
.order-notify-bar {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255,255,255,0.12);
border-radius: 8px;
padding: 5px 12px;
}
.order-notify-label {
font-size: 13px;
color: rgba(255,255,255,0.85);
white-space: nowrap;
}
.order-notify-info {
color: rgba(255,255,255,0.6);
font-size: 14px;
cursor: help;
}
/* 核心统计块 */
.stat-block {
padding: 18px 20px;
border-radius: 12px;
border: 2px solid transparent;
transition: all 0.2s;
}
.stat-block:hover { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(0,0,0,0.08); }
.stat-block.blue { background: #eff6ff; border-color: #dbeafe; }
.stat-block.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-block.orange { background: #fff7ed; border-color: #fed7aa; }
.stat-block.purple { background: #faf5ff; border-color: #e9d5ff; }
.stat-block-header { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; }
.stat-block-icon { font-size: 18px; }
.stat-block-label { font-size: 13px; color: rgba(0,0,0,0.55); }
.stat-block-value { font-size: 32px; font-weight: 800; color: rgba(0,0,0,0.85); line-height: 1.1; margin-bottom: 4px; }
.stat-block-desc { font-size: 12px; color: rgba(0,0,0,0.4); }
/* Panel */
.panel { background: #fff; border: 1px solid #f0f0f0; border-radius: 12px; overflow: hidden; }
.panel-header { padding: 14px 18px; border-bottom: 1px solid #f5f5f5; }
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0,0,0,0.85); }
/* 待办 */
.todo-list { padding: 2px 0; }
.todo-item {
display: flex; align-items: center; gap: 12px;
padding: 12px 18px; cursor: pointer; transition: background 0.15s;
}
.todo-item:hover { background: #f9fafb; }
.todo-item-urgent .todo-label { font-weight: 600; }
.todo-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.dot-orange { background: #f97316; }
.dot-blue { background: #3b82f6; }
.dot-gold { background: #faad14; }
.dot-cyan { background: #06b6d4; }
.dot-purple { background: #a855f7; }
.todo-content { flex: 1; display: flex; align-items: center; }
.todo-label { font-size: 14px; color: rgba(0,0,0,0.75); }
.todo-arrow { font-size: 11px; color: rgba(0,0,0,0.3); }
.todo-empty { text-align: center; padding: 20px 0; color: rgba(0,0,0,0.4); font-size: 14px; }
/* 快速入口九宫格 */
.quick-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1px;
background: #f5f5f5;
}
.quick-card {
display: flex; flex-direction: column; align-items: center;
gap: 8px; padding: 18px 12px; background: #fff;
cursor: pointer; transition: background 0.15s;
}
.quick-card:hover { background: #f9fafb; }
.quick-icon {
width: 44px; height: 44px; border-radius: 10px;
display: flex; align-items: center; justify-content: center; font-size: 22px;
position: relative;
}
/* 快速入口新订单红点 */
.quick-icon-dot {
position: absolute;
top: -3px;
right: -3px;
width: 10px;
height: 10px;
background: #ff4d4f;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(255, 77, 79, 0.3);
animation: quick-dot-pulse 1.5s ease-in-out infinite;
}
@keyframes quick-dot-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.25); opacity: 0.85; }
}
.quick-label { font-size: 13px; color: rgba(0,0,0,0.75); font-weight: 500; }
/* 今日数据 */
.today-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: 16px;
}
.today-stat {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #fafafa;
border-radius: 8px;
}
.today-stat-icon {
font-size: 28px;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.today-stat-value {
font-size: 20px;
font-weight: 700;
color: #1a3931;
}
.today-stat-label {
font-size: 12px;
color: #666;
margin-top: 2px;
}
@media (max-width: 768px) {
.today-stats {
grid-template-columns: repeat(2, 1fr);
}
}
/* 到期时间状态 */
.text-muted { color: rgba(0,0,0,0.45); }
.text-orange { color: #fa8c16; }
.expire-warn { color: #ff4d4f; font-size: 12px; margin-left: 4px; }
/* 订阅 Modal */
.subscribe-modal-body { padding: 8px 0; }
.subscribe-product-name {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: rgba(0,0,0,0.85);
}
.period-options {
display: flex;
gap: 16px;
}
.period-option {
flex: 1;
border: 2px solid #f0f0f0;
border-radius: 10px;
padding: 16px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.period-option:hover { border-color: #69b1ff; }
.period-option.active {
border-color: #00704A;
background: #f0fdf4;
}
.period-option-label { font-size: 14px; color: rgba(0,0,0,0.65); margin-bottom: 8px; }
.period-option-price { font-size: 18px; font-weight: 700; color: #00704A; }
.period-option-tag {
position: absolute;
top: -8px;
right: 8px;
background: #ff4d4f;
color: #fff;
font-size: 11px;
padding: 1px 8px;
border-radius: 8px;
}
/* 支付 Modal */
.pay-modal-body { text-align: center; padding: 8px 0; }
.pay-product-name {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
color: rgba(0,0,0,0.85);
}
.pay-amount { margin-bottom: 16px; }
.pay-amount-label { font-size: 13px; color: rgba(0,0,0,0.45); margin-right: 8px; }
.pay-amount-value { font-size: 24px; font-weight: 800; color: #ff4d4f; }
.pay-qrcode-wrap {
display: flex;
justify-content: center;
margin-bottom: 12px;
}
.pay-qrcode-img {
width: 220px;
height: 220px;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.pay-tip {
font-size: 13px;
color: rgba(0,0,0,0.55);
margin-bottom: 8px;
}
.pay-order-no {
font-size: 12px;
color: rgba(0,0,0,0.35);
}
</style>