Files
guilixu-admin/src/views/shop/dashboard/index.vue
赵忠林 e87e60fd6a feat(shopOrder): 添加订单商品明细展开功能并优化订单状态显示
- 在订单列表中增加商品明细的展开/收起功能,默认显示2条商品信息
- 支持点击“展开全部 N 件”切换显示全部或折叠商品明细
- 优化订单状态栏支付状态、发货状态、开票状态及操作按钮中的分隔线统一风格
- 将shop/dashboard运行天数改为基于租户创建时间,而非订阅生效时间,保证准确性
- 调整订单列表表格列宽及显示顺序,改善界面布局和信息展示
- 注释和代码格式微调,删除无用的组件显示及注释代码,提升代码整洁度
- 修复了部分变量定义和模板标签排版,完善代码可维护性
2026-07-29 00:44:54 +08:00

1089 lines
38 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 } 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>;
// 系统运行天数:自租户创建时间起累计
// 锚定 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);
const totalSales = computed(() => statisticsStore.totalSales);
const todaySales = computed(() => statisticsStore.todaySales);
const todayOrders = computed(() => statisticsStore.todayOrders);
const todayUsers = computed(() => statisticsStore.todayUsers);
// 加载状态
const loading = computed(() => siteLoading.value || statisticsLoading.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(() => statisticsStore.safeCouponUsedCount);
// 商城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()
});
// 刷新统计数据(强制刷新,跳过缓存)
const refreshStatistics = async () => {
try {
await statisticsStore.fetchStatistics(true);
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;
}
} catch (error) {
console.error('加载数据失败:', error);
}
};
onMounted(async () => {
await loadData();
// 开始自动刷新统计数据每5分钟
statisticsStore.startAutoRefresh();
// 运行天数每小时检查一次(跨天自动更新)
runDaysTimer = setInterval(() => { now.value = Date.now(); }, 60 * 60 * 1000);
});
onUnmounted(() => {
// 组件卸载时停止自动刷新
statisticsStore.stopAutoRefresh();
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>