overview.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. <template>
  2. <!-- 与订单管理、优惠券管理等页一致的 table-box 容器 + el-card/el-tabs 展示 -->
  3. <div class="table-box data-overview-page">
  4. <!-- 顶部筛选/操作区 -->
  5. <div class="filter-bar">
  6. <div class="filter-left">
  7. <span class="filter-label">日期区间</span>
  8. <el-date-picker
  9. v-model="dateRange"
  10. type="daterange"
  11. range-separator="-"
  12. start-placeholder="开始日期"
  13. end-placeholder="结束日期"
  14. value-format="YYYY-MM-DD"
  15. class="date-range-picker"
  16. />
  17. <span class="filter-label compare-label">对比区间</span>
  18. <el-select v-model="compareRange" placeholder="请选择" class="compare-select" clearable>
  19. <el-option label="较上期" value="lastPeriod" />
  20. <el-option label="较同期" value="samePeriod" />
  21. </el-select>
  22. </div>
  23. <div class="filter-actions">
  24. <el-button @click="handleReset"> 重置 </el-button>
  25. <el-button type="primary" plain @click="handleCompare"> 比较 </el-button>
  26. <el-button type="primary" :loading="loading" @click="handleQuery"> 查询 </el-button>
  27. </div>
  28. </div>
  29. <!-- 数据展示卡片区 -->
  30. <el-card class="data-card" shadow="hover" v-loading="loading">
  31. <el-tabs v-model="activeTab" class="data-tabs">
  32. <el-tab-pane label="流量数据" name="traffic">
  33. <StatCardList :items="trafficStats" />
  34. </el-tab-pane>
  35. <el-tab-pane label="互动数据" name="interaction">
  36. <StatCardList :items="interactionStats" />
  37. </el-tab-pane>
  38. <el-tab-pane label="优惠券" name="coupon">
  39. <StatCardList :items="couponStats" />
  40. </el-tab-pane>
  41. <el-tab-pane label="代金券" name="voucher">
  42. <StatCardList :items="voucherStats" />
  43. </el-tab-pane>
  44. <el-tab-pane label="服务质量" name="service">
  45. <StatCardList :items="serviceStats" />
  46. </el-tab-pane>
  47. <el-tab-pane label="价目表排名" name="ranking">
  48. <div class="ranking-wrap">
  49. <el-table :data="priceListRanking" border stripe>
  50. <el-table-column prop="rank" label="排名" width="80" align="center" />
  51. <el-table-column prop="priceListItemName" label="价目表名称" min-width="120" show-overflow-tooltip />
  52. <el-table-column prop="pageViews" label="浏览量" width="100" align="center" />
  53. <el-table-column prop="visitors" label="访客" width="100" align="center" />
  54. <el-table-column prop="shares" label="分享数" width="100" align="center" />
  55. </el-table>
  56. </div>
  57. </el-tab-pane>
  58. </el-tabs>
  59. </el-card>
  60. </div>
  61. </template>
  62. <script setup lang="ts" name="businessDataOverview">
  63. import { ref, reactive, onMounted } from "vue";
  64. import { useRouter } from "vue-router";
  65. import { ElMessage } from "element-plus";
  66. import { localGet } from "@/utils";
  67. import { getStatistics } from "@/api/modules/businessData";
  68. import type {
  69. TrafficData,
  70. InteractionData,
  71. CouponVoucherData,
  72. ServiceQualityData,
  73. PriceListRankingItem
  74. } from "@/api/modules/businessData";
  75. import StatCardList from "./components/StatCardList.vue";
  76. /** 统计项:key、标题、展示值 */
  77. interface StatItem {
  78. key: string;
  79. title: string;
  80. value: string;
  81. }
  82. const router = useRouter();
  83. const loading = ref(false);
  84. const dateRange = ref<[string, string] | null>(null);
  85. const compareRange = ref("lastPeriod");
  86. const activeTab = ref("traffic");
  87. // 价目表排名(接口返回的 priceListRanking 全量展示,不分页)
  88. const priceListRanking = ref<PriceListRankingItem[]>([]);
  89. // 秒 -> XhXmXs 或 XmXs
  90. function formatDuration(seconds: number | undefined): string {
  91. if (seconds == null || isNaN(Number(seconds))) return "--";
  92. const n = Math.floor(Number(seconds));
  93. if (n < 60) return `${n}s`;
  94. const m = Math.floor(n / 60);
  95. const s = n % 60;
  96. if (m < 60) return `${m}m${s}s`;
  97. const h = Math.floor(m / 60);
  98. const mm = m % 60;
  99. return `${h}h${mm}m${s}s`;
  100. }
  101. // 数字或占比展示
  102. function formatNum(val: number | undefined): string {
  103. if (val == null || (typeof val === "number" && isNaN(val))) return "--";
  104. return String(val);
  105. }
  106. function formatPercent(val: number | undefined): string {
  107. if (val == null || (typeof val === "number" && isNaN(val))) return "--";
  108. return `${val}%`;
  109. }
  110. // 流量数据
  111. const trafficStats = reactive([
  112. { key: "storeSearch", title: "店铺搜索量", value: "--" },
  113. { key: "pageViews", title: "浏览量", value: "--" },
  114. { key: "visitors", title: "访客数", value: "--" },
  115. { key: "newVisitors", title: "新增访客数", value: "--" },
  116. { key: "visitDuration", title: "访问时长", value: "--" },
  117. { key: "avgVisitDuration", title: "平均访问时长", value: "--" }
  118. ]);
  119. function setTraffic(d: TrafficData | undefined) {
  120. if (!d) return;
  121. const map: Record<string, () => string> = {
  122. storeSearch: () => formatNum(d.storeSearchVolume),
  123. pageViews: () => formatNum(d.pageViews),
  124. visitors: () => formatNum(d.visitors),
  125. newVisitors: () => formatNum(d.newVisitors),
  126. visitDuration: () => formatDuration(d.visitDuration),
  127. avgVisitDuration: () => formatDuration(d.avgVisitDuration)
  128. };
  129. trafficStats.forEach(item => {
  130. const fn = map[item.key];
  131. if (fn) item.value = fn();
  132. });
  133. }
  134. // 互动数据
  135. const interactionStats = reactive([
  136. { key: "storeCollectionCount", title: "店铺收藏次数", value: "--" },
  137. { key: "storeShareCount", title: "店铺分享次数", value: "--" },
  138. { key: "storeCheckInCount", title: "店铺打卡次数", value: "--" },
  139. { key: "consultMerchantCount", title: "咨询商家次数", value: "--" },
  140. { key: "friendsCount", title: "好友数量", value: "--" },
  141. { key: "followCount", title: "关注数量", value: "--" },
  142. { key: "fansCount", title: "粉丝数量", value: "--" },
  143. { key: "postsPublishedCount", title: "发布动态数量", value: "--" },
  144. { key: "postLikesCount", title: "动态点赞数量", value: "--" },
  145. { key: "postCommentsCount", title: "动态评论数量", value: "--" },
  146. { key: "postSharesCount", title: "动态转发数量", value: "--" },
  147. { key: "reportedCount", title: "被举报次数", value: "--" },
  148. { key: "blockedCount", title: "被拉黑次数", value: "--" }
  149. ]);
  150. function setInteraction(d: InteractionData | undefined) {
  151. if (!d) return;
  152. interactionStats.forEach(item => {
  153. const v = (d as Record<string, number | undefined>)[item.key];
  154. item.value = formatNum(v);
  155. });
  156. }
  157. // 优惠券
  158. const couponStats = reactive([
  159. { key: "giftToFriendsCount", title: "赠送好友数量", value: "--" },
  160. { key: "giftToFriendsAmount", title: "赠送好友金额合计", value: "--" },
  161. { key: "giftToFriendsUsedCount", title: "赠送好友使用数量", value: "--" },
  162. { key: "giftToFriendsUsedAmount", title: "赠送好友使用金额合计", value: "--" },
  163. { key: "giftToFriendsUsedAmountRatio", title: "赠送好友使用金额占比", value: "--" },
  164. { key: "friendsGiftCount", title: "好友赠送数量", value: "--" },
  165. { key: "friendsGiftAmount", title: "好友赠送金额合计", value: "--" },
  166. { key: "friendsGiftUsedCount", title: "好友赠送使用数量", value: "--" },
  167. { key: "friendsGiftUsedAmount", title: "好友赠送使用金额合计", value: "--" },
  168. { key: "friendsGiftUsedAmountRatio", title: "好友赠送使用金额占比", value: "--" }
  169. ]);
  170. function setCouponVoucher(list: StatItem[], d: CouponVoucherData | undefined) {
  171. if (!d) return;
  172. list.forEach(item => {
  173. const v = (d as Record<string, number | undefined>)[item.key];
  174. item.value = item.key.includes("Ratio") ? formatPercent(v) : formatNum(v);
  175. });
  176. }
  177. // 代金券
  178. const voucherStats = reactive([
  179. { key: "giftToFriendsCount", title: "赠送好友数量", value: "--" },
  180. { key: "giftToFriendsAmount", title: "赠送好友金额合计", value: "--" },
  181. { key: "giftToFriendsUsedCount", title: "赠送好友使用数量", value: "--" },
  182. { key: "giftToFriendsUsedAmount", title: "赠送好友使用金额合计", value: "--" },
  183. { key: "giftToFriendsUsedAmountRatio", title: "赠送好友使用金额占比", value: "--" },
  184. { key: "friendsGiftCount", title: "好友赠送数量", value: "--" },
  185. { key: "friendsGiftAmount", title: "好友赠送金额合计", value: "--" },
  186. { key: "friendsGiftUsedCount", title: "好友赠送使用数量", value: "--" },
  187. { key: "friendsGiftUsedAmount", title: "好友赠送使用金额合计", value: "--" },
  188. { key: "friendsGiftUsedAmountRatio", title: "好友赠送使用金额占比", value: "--" }
  189. ]);
  190. // 服务质量
  191. const serviceStats = reactive([
  192. { key: "storeRating", title: "店铺评分", value: "--" },
  193. { key: "scoreOne", title: "口味评分", value: "--" },
  194. { key: "scoreTwo", title: "环境评分", value: "--" },
  195. { key: "scoreThree", title: "服务评分", value: "--" },
  196. { key: "totalReviews", title: "评价数量", value: "--" },
  197. { key: "positiveReviews", title: "好评数量", value: "--" },
  198. { key: "neutralReviews", title: "中评数量", value: "--" },
  199. { key: "negativeReviews", title: "差评数量", value: "--" },
  200. { key: "negativeReviewRatio", title: "差评占比", value: "--" },
  201. { key: "negativeReviewAppealsCount", title: "差评申诉次数", value: "--" },
  202. { key: "negativeReviewAppealSuccessCount", title: "差评申诉成功次数", value: "--" },
  203. { key: "negativeReviewAppealSuccessRatio", title: "差评申诉成功占比", value: "--" }
  204. ]);
  205. function setService(d: ServiceQualityData | undefined) {
  206. if (!d) return;
  207. serviceStats.forEach(item => {
  208. const v = (d as Record<string, number | undefined>)[item.key];
  209. item.value = item.key.includes("Ratio") ? formatPercent(v) : formatNum(v);
  210. });
  211. }
  212. const toDateString = (d: Date) => d.toISOString().slice(0, 10);
  213. /** 默认日期区间:本周一到当天 */
  214. function initDateRange() {
  215. const end = new Date();
  216. const start = new Date(end);
  217. const day = start.getDay();
  218. const daysToMonday = day === 0 ? 6 : day - 1;
  219. start.setDate(start.getDate() - daysToMonday);
  220. start.setHours(0, 0, 0, 0);
  221. dateRange.value = [toDateString(start), toDateString(end)];
  222. }
  223. async function fetchData() {
  224. const storeId = localGet("createdId");
  225. if (!storeId) {
  226. ElMessage.warning("请先选择门店");
  227. return;
  228. }
  229. if (!dateRange.value || dateRange.value.length !== 2) {
  230. ElMessage.warning("请选择日期区间");
  231. return;
  232. }
  233. const [startTime, endTime] = dateRange.value;
  234. loading.value = true;
  235. try {
  236. const res: any = await getStatistics({ startTime, endTime, storeId });
  237. const data = res?.data ?? res;
  238. setTraffic(data?.trafficData);
  239. setInteraction(data?.interactionData);
  240. setCouponVoucher(couponStats, data?.couponData);
  241. setCouponVoucher(voucherStats, data?.voucherData);
  242. setService(data?.serviceQualityData);
  243. priceListRanking.value = data?.priceListRanking ?? [];
  244. } catch (e) {
  245. ElMessage.error("获取数据失败");
  246. } finally {
  247. loading.value = false;
  248. }
  249. }
  250. function handleQuery() {
  251. fetchData();
  252. }
  253. function handleReset() {
  254. initDateRange();
  255. fetchData();
  256. }
  257. function handleCompare() {
  258. if (!dateRange.value || dateRange.value.length !== 2) {
  259. ElMessage.warning("请先选择日期区间");
  260. return;
  261. }
  262. const [start, end] = dateRange.value;
  263. const startDate = new Date(start);
  264. const endDate = new Date(end);
  265. let compareStart = "";
  266. let compareEnd = "";
  267. if (compareRange.value === "samePeriod") {
  268. const compareStartDate = new Date(startDate);
  269. const compareEndDate = new Date(endDate);
  270. compareStartDate.setFullYear(compareStartDate.getFullYear() - 1);
  271. compareEndDate.setFullYear(compareEndDate.getFullYear() - 1);
  272. compareStart = toDateString(compareStartDate);
  273. compareEnd = toDateString(compareEndDate);
  274. } else if (compareRange.value === "lastPeriod") {
  275. const duration = endDate.getTime() - startDate.getTime();
  276. const msPerDay = 86400000;
  277. compareStart = toDateString(new Date(startDate.getTime() - duration - msPerDay));
  278. compareEnd = toDateString(new Date(startDate.getTime() - msPerDay));
  279. }
  280. router.push({
  281. path: "/businessData/compare",
  282. query: {
  283. start,
  284. end,
  285. compareType: compareRange.value,
  286. compareStart,
  287. compareEnd
  288. }
  289. });
  290. }
  291. onMounted(() => {
  292. initDateRange();
  293. fetchData();
  294. });
  295. </script>
  296. <style lang="scss" scoped>
  297. /* 与订单详情等页一致的 table-box 容器 */
  298. .table-box {
  299. display: flex;
  300. flex-direction: column;
  301. height: auto !important;
  302. min-height: 100%;
  303. }
  304. .data-overview-page {
  305. display: flex;
  306. flex-direction: column;
  307. gap: 16px;
  308. min-height: 100%;
  309. }
  310. .filter-bar {
  311. display: flex;
  312. flex-wrap: wrap;
  313. gap: 12px;
  314. align-items: center;
  315. justify-content: space-between;
  316. padding: 16px;
  317. background: var(--el-bg-color);
  318. border-radius: 8px;
  319. .filter-left {
  320. display: flex;
  321. flex-wrap: wrap;
  322. gap: 12px;
  323. align-items: center;
  324. }
  325. .filter-label {
  326. font-size: 14px;
  327. color: var(--el-text-color-regular);
  328. white-space: nowrap;
  329. &.compare-label {
  330. margin-left: 8px;
  331. }
  332. }
  333. .date-range-picker {
  334. width: 240px;
  335. }
  336. .compare-select {
  337. width: 120px;
  338. }
  339. .filter-actions {
  340. display: flex;
  341. gap: 8px;
  342. }
  343. }
  344. .data-card {
  345. flex: 1;
  346. border-radius: 8px;
  347. :deep(.el-card__body) {
  348. padding: 0;
  349. }
  350. }
  351. .data-tabs {
  352. :deep(.el-tabs__header) {
  353. margin: 0 16px;
  354. border-bottom: 1px solid var(--el-border-color-lighter);
  355. }
  356. :deep(.el-tabs__content) {
  357. padding: 20px 16px;
  358. }
  359. :deep(.el-tabs__item.is-active) {
  360. font-weight: 600;
  361. }
  362. }
  363. .ranking-wrap {
  364. min-height: 120px;
  365. }
  366. @media (width <= 768px) {
  367. .filter-bar .filter-left {
  368. width: 100%;
  369. }
  370. .filter-bar .filter-actions {
  371. justify-content: flex-end;
  372. width: 100%;
  373. }
  374. }
  375. </style>