Files
guilixu-admin/src/views/shop/dashboard/index.vue
赵忠林 5106fe101c feat(shopDashboard): 为 Dashboard 添加新订单声音提醒功能
- 将 useOrderNotify 改造为实例安全的 Composable,支持多个页面独立调用
- 在 shop/dashboard 和 cms/dashboard 页面添加新订单提醒开关和测试按钮
- 新订单提醒触发时自动刷新 Dashboard 数据,提升数据同步性
- 新订单提醒包括叮声和语音播报,用户交互后自动解锁音频播放
- cms/dashboard 概况卡片标题添加提醒控件,shop/dashboard 欢迎横幅右侧添加提醒栏
- 提取 cms/dashboard loadData 函数实现复用,统一数据加载逻辑
- 优化组件状态管理,防止多个实例间状态冲突,提升稳定性和可维护性
2026-07-14 20:58:40 +08:00

557 lines
18 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">
<h2 class="welcome-title">🏸 {{ userStore.info?.tenantName }}</h2>
<p class="welcome-sub">欢迎回来管理员今日数据已更新</p>
</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>
</div>
<a-space>
<a-tag color="green" style="font-size:13px;padding:4px 12px">{{ siteStore.statusText || '正常' }}</a-tag>
<a-button size="small" @click="refreshStatistics" :loading="loading">
<template #icon><ReloadOutlined /></template>
刷新数据
</a-button>
</a-space>
</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="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 }}</div>
<div class="quick-label">{{ item.label }}</div>
</div>
</div>
</div>
</a-col>
</a-row>
<!-- 今日数据趋势 -->
<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-row :gutter="[16, 16]" style="margin-top: 16px">
<a-col :span="12">
<div class="panel">
<div class="panel-header">
<span class="panel-title">📋 基本信息</span>
</div>
<div style="padding: 16px 18px;">
<a-descriptions :column="1" size="small">
<a-descriptions-item label="系统名称">
{{ siteStore.appName }}
</a-descriptions-item>
<a-descriptions-item label="版本号">
{{ systemInfo.version }}
</a-descriptions-item>
<a-descriptions-item label="运行状态">
{{ siteStore.statusText || '正常' }}
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ siteInfo?.createTime }}
</a-descriptions-item>
<a-descriptions-item label="到期时间">
{{ siteInfo?.expirationTime }}
</a-descriptions-item>
<a-descriptions-item label="系统运行">
{{ runDays }}
</a-descriptions-item>
</a-descriptions>
</div>
</div>
</a-col>
<!-- 快捷操作 -->
<a-col :span="12">
<div class="panel" style="min-height: 353px">
<div class="panel-header">
<span class="panel-title"> 快捷操作</span>
</div>
<div style="padding: 16px 18px;">
<a-space direction="vertical" style="width: 100%">
<a-button
type="primary"
block
@click="navigateTo('/website/field')"
:loading="loading"
>
<UngroupOutlined />
参数配置
</a-button>
<a-button block @click="navigateTo('/shop/shopOrder')">
<CalendarOutlined />
订单管理
</a-button>
<a-button block @click="navigateTo('/system/user')">
<UserOutlined />
用户管理
</a-button>
<a-button block @click="navigateTo('/website/index')">
<ShopOutlined />
站点管理
</a-button>
<a-button block @click="navigateTo('/system/login-record')">
<FileTextOutlined />
登录日志
</a-button>
<a-button block @click="handleClearCache">
<ClearOutlined />
清除缓存
</a-button>
</a-space>
</div>
</div>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref, computed, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { useOrderNotify } from '@/views/shop/shopOrder/useOrderNotify';
import {
ReloadOutlined,
RightOutlined,
UngroupOutlined,
CalendarOutlined,
UserOutlined,
ShopOutlined,
FileTextOutlined,
ClearOutlined,
InfoCircleOutlined
} 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 { pageShopOrder } from '@/api/shop/shopOrder';
import { storeToRefs } from 'pinia';
import { removeSiteInfoCache } from '@/api/cms/cmsWebsite';
// 使用状态管理
const siteStore = useSiteStore();
const statisticsStore = useStatisticsStore();
const userStore = useUserStore();
const router = useRouter();
// 从 store 中获取响应式数据
const { siteInfo, loading: siteLoading } = storeToRefs(siteStore);
const { loading: statisticsLoading } = storeToRefs(statisticsStore);
// 系统信息
const systemInfo = reactive({
name: '小程序商城',
version: '2.0.0',
environment: '生产环境',
database: 'MySQL 8.0',
server: 'Linux CentOS 7.9',
});
// 计算属性
const runDays = computed(() => siteStore.runDays);
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' },
{ icon: '💰', label: '总营业额', value: totalSales.value || 0, desc: '元', color: 'purple', to: '/shop/shopOrder' },
{ icon: '⏱️', label: '运行天数', value: runDays.value || 0, desc: '系统运行', color: 'green', to: '' },
]);
// 待处理事项数据
const pendingShipmentCount = ref(0);
const pendingRefundCount = ref(0);
const couponUsedCount = computed(() => statisticsStore.couponUsedCount);
// 待处理事项使用computed确保响应式更新
const todoItems = computed(() => [
{ label: '待发货订单', value: pendingShipmentCount.value, to: '/shop/shopOrder', tagColor: 'blue', dotColor: 'dot-blue', urgent: pendingShipmentCount.value > 0 },
{ label: '退款申请', value: pendingRefundCount.value, to: '/shop/shopOrder', 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 = [
{ to: '/website/field', icon: '⚙️', label: '参数配置', bg: '#eff6ff' },
{ to: '/shop/shopOrder', icon: '📦', label: '订单管理', bg: '#f0fdf4' },
{ to: '/system/user', icon: '👥', label: '用户管理', bg: '#fff7ed' },
{ to: '/website/index', icon: '🏪', label: '站点管理', bg: '#faf5ff' },
{ to: '/shopGoods', icon: '🏸', label: '商品管理', bg: '#ecfdf5' },
{ to: '/cmsArticle', 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';
};
// 清除缓存
const handleClearCache = () => {
removeSiteInfoCache('SiteInfo:' + localStorage.getItem('TenantId')).then(
(msg) => {
if (msg) {
message.success(msg);
}
}
);
};
// 新订单声音提醒
const { enabled: notifyEnabled, toggle: onNotifyToggle, testNotify: onTestNotify } = useOrderNotify({
onNewOrder: () => loadData()
});
// 刷新统计数据
const refreshStatistics = async () => {
try {
await statisticsStore.fetchStatistics();
message.success('统计数据刷新成功');
} catch (error) {
console.error('刷新统计数据失败:', error);
message.error('刷新统计数据失败');
}
};
// 加载数据
const loadData = async () => {
try {
await Promise.all([
siteStore.fetchSiteInfo(),
statisticsStore.fetchStatistics()
]);
// 获取待处理事项数据
try {
// 获取待发货订单数(使用 statusFilter=1 对应待发货)
const shipmentResult = await pageShopOrder({
statusFilter: 1,
page: 1,
limit: 1
});
pendingShipmentCount.value = shipmentResult?.count || 0;
} catch (e) {
console.warn('获取待发货订单数失败:', e);
pendingShipmentCount.value = 0;
}
try {
// 获取退款/售后订单数(使用 statusFilter=6 对应退款/售后)
const refundResult = await pageShopOrder({
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();
});
onUnmounted(() => {
// 组件卸载时停止自动刷新
statisticsStore.stopAutoRefresh();
});
</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-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: 8px 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-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;
}
.quick-label { font-size: 13px; color: rgba(0,0,0,0.75); font-weight: 500; }
/* 今日数据 */
.today-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
padding: 20px;
}
.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);
}
}
</style>