xsh_1997 1 месяц назад
Родитель
Сommit
c3f8ce4d6a

+ 6 - 2
ruoyi-screen/src/api/tradeSales.js

@@ -1,10 +1,14 @@
1
 import request from '@/utils/request'
1
 import request from '@/utils/request'
2
 
2
 
3
 /** 看板主数据 GET /bigScreen/tradeSales/dashboard */
3
 /** 看板主数据 GET /bigScreen/tradeSales/dashboard */
4
-export function getTradeSalesDashboard(statYear) {
4
+export function getTradeSalesDashboard(statYear, forceRefresh = false) {
5
+  const params = { statYear }
6
+  if (forceRefresh) {
7
+    params.forceRefresh = true
8
+  }
5
   return request({
9
   return request({
6
     url: '/bigScreen/tradeSales/dashboard',
10
     url: '/bigScreen/tradeSales/dashboard',
7
     method: 'get',
11
     method: 'get',
8
-    params: { statYear }
12
+    params
9
   })
13
   })
10
 }
14
 }

+ 70 - 23
ruoyi-screen/src/views/tradeSales/chartOptions.js

@@ -43,27 +43,39 @@ function formatQuoteDate(quoteDate) {
43
   return quoteDate
43
   return quoteDate
44
 }
44
 }
45
 
45
 
46
-/** 产地行情 — 近 7 日均价面积图 */
46
+/** 产地行情 — 近 7 日最低/最高/均价三曲线 */
47
 export function buildOriginQuoteOption(originQuote) {
47
 export function buildOriginQuoteOption(originQuote) {
48
   if (!originQuote?.hasQuoteData || !originQuote.dailyTrend?.length) {
48
   if (!originQuote?.hasQuoteData || !originQuote.dailyTrend?.length) {
49
     return emptyOption('暂无行情数据')
49
     return emptyOption('暂无行情数据')
50
   }
50
   }
51
   const dates = originQuote.dailyTrend.map((d) => formatQuoteDate(d.quoteDate))
51
   const dates = originQuote.dailyTrend.map((d) => formatQuoteDate(d.quoteDate))
52
+  const minPrices = originQuote.dailyTrend.map((d) => Number(d.minPrice ?? 0))
53
+  const maxPrices = originQuote.dailyTrend.map((d) => Number(d.maxPrice ?? 0))
52
   const avgPrices = originQuote.dailyTrend.map((d) => Number(d.avgPrice ?? 0))
54
   const avgPrices = originQuote.dailyTrend.map((d) => Number(d.avgPrice ?? 0))
53
-  if (!avgPrices.some((v) => v > 0)) {
55
+  if (![...minPrices, ...maxPrices, ...avgPrices].some((v) => v > 0)) {
54
     return emptyOption('暂无行情数据')
56
     return emptyOption('暂无行情数据')
55
   }
57
   }
56
   const unit = originQuote.priceUnitLabel || ''
58
   const unit = originQuote.priceUnitLabel || ''
57
   return {
59
   return {
58
-    color: ['#5ef0c8'],
60
+    color: ['#6eb5ff', '#ecd27b', '#5ef0c8'],
59
     tooltip: {
61
     tooltip: {
60
       trigger: 'axis',
62
       trigger: 'axis',
61
       formatter: (params) => {
63
       formatter: (params) => {
62
-        const p = params[0]
63
-        return `${p.name}<br/>均价 ${p.value}${unit}`
64
+        const lines = [params[0]?.name || '']
65
+        params.forEach((p) => {
66
+          lines.push(`${p.marker}${p.seriesName} ${p.value}${unit}`)
67
+        })
68
+        return lines.join('<br/>')
64
       }
69
       }
65
     },
70
     },
66
-    grid: { ...GRID, top: 8, bottom: 24 },
71
+    legend: {
72
+      bottom: 0,
73
+      left: 'center',
74
+      textStyle: { color: '#a8d4c8', fontSize: 9 },
75
+      itemWidth: 12,
76
+      itemHeight: 6
77
+    },
78
+    grid: { ...GRID, top: 8, bottom: 30 },
67
     xAxis: {
79
     xAxis: {
68
       type: 'category',
80
       type: 'category',
69
       data: dates,
81
       data: dates,
@@ -77,13 +89,29 @@ export function buildOriginQuoteOption(originQuote) {
77
       splitLine: SPLIT_LINE
89
       splitLine: SPLIT_LINE
78
     },
90
     },
79
     series: [
91
     series: [
92
+      {
93
+        name: '最低价',
94
+        type: 'line',
95
+        smooth: true,
96
+        symbol: 'circle',
97
+        symbolSize: 3,
98
+        data: minPrices
99
+      },
100
+      {
101
+        name: '最高价',
102
+        type: 'line',
103
+        smooth: true,
104
+        symbol: 'circle',
105
+        symbolSize: 3,
106
+        data: maxPrices
107
+      },
80
       {
108
       {
81
         name: '均价',
109
         name: '均价',
82
         type: 'line',
110
         type: 'line',
83
         smooth: true,
111
         smooth: true,
84
         symbol: 'circle',
112
         symbol: 'circle',
85
-        symbolSize: 4,
86
-        areaStyle: { color: 'rgba(94, 240, 200, 0.25)' },
113
+        symbolSize: 3,
114
+        areaStyle: { color: 'rgba(94, 240, 200, 0.18)' },
87
         data: avgPrices
115
         data: avgPrices
88
       }
116
       }
89
     ]
117
     ]
@@ -170,9 +198,6 @@ export function buildSalesDestinationBarOption(salesDestination) {
170
   const sorted = [...salesDestination].sort((a, b) => a.salesDestination - b.salesDestination)
198
   const sorted = [...salesDestination].sort((a, b) => a.salesDestination - b.salesDestination)
171
   const names = sorted.map((r) => r.salesDestinationName || '')
199
   const names = sorted.map((r) => r.salesDestinationName || '')
172
   const values = sorted.map((r) => r.tradeHeads ?? 0)
200
   const values = sorted.map((r) => r.tradeHeads ?? 0)
173
-  if (!values.some((v) => v > 0)) {
174
-    return emptyOption('暂无去向数据')
175
-  }
176
   return {
201
   return {
177
     color: DESTINATION_COLOR,
202
     color: DESTINATION_COLOR,
178
     tooltip: {
203
     tooltip: {
@@ -219,13 +244,12 @@ export function buildQualityGradePieOption(qualityGrade) {
219
     return emptyOption('暂无等级数据')
244
     return emptyOption('暂无等级数据')
220
   }
245
   }
221
   const data = qualityGrade.items
246
   const data = qualityGrade.items
222
-    .filter((item) => (item.tradeHeads ?? 0) > 0)
223
     .map((item, i) => ({
247
     .map((item, i) => ({
224
       name: item.gradeName || item.gradeCode,
248
       name: item.gradeName || item.gradeCode,
225
       value: item.tradeHeads ?? 0,
249
       value: item.tradeHeads ?? 0,
226
       itemStyle: { color: GRADE_COLOR[i % GRADE_COLOR.length] }
250
       itemStyle: { color: GRADE_COLOR[i % GRADE_COLOR.length] }
227
     }))
251
     }))
228
-  if (!data.length) {
252
+  if (!data.length || !data.some((item) => item.value > 0)) {
229
     return emptyOption('暂无等级数据')
253
     return emptyOption('暂无等级数据')
230
   }
254
   }
231
   return {
255
   return {
@@ -265,11 +289,22 @@ export function buildQualityGradePieOption(qualityGrade) {
265
 const MALL_PIE_COLOR = ['#5ef0c8', '#ecd27b', '#6eb5ff', '#0f8f72', '#c9a227', '#8b7cf6']
289
 const MALL_PIE_COLOR = ['#5ef0c8', '#ecd27b', '#6eb5ff', '#0f8f72', '#c9a227', '#8b7cf6']
266
 const MALL_BAR_COLOR = ['#5ef0c8', '#6eb5ff', '#ecd27b', '#0f8f72', '#c9a227']
290
 const MALL_BAR_COLOR = ['#5ef0c8', '#6eb5ff', '#ecd27b', '#0f8f72', '#c9a227']
267
 const WORD_COLORS = ['#5ef0c8', '#ecd27b', '#6eb5ff', '#98e9aa', '#0f8f72', '#c9a227']
291
 const WORD_COLORS = ['#5ef0c8', '#ecd27b', '#6eb5ff', '#98e9aa', '#0f8f72', '#c9a227']
292
+const MALL_UNAVAILABLE_TEXT = '商城统计未接入'
293
+
294
+function resolveMallEmptyText(mallStatsAvailable, defaultText = '暂无数据') {
295
+  if (mallStatsAvailable === false) {
296
+    return MALL_UNAVAILABLE_TEXT
297
+  }
298
+  return defaultText
299
+}
268
 
300
 
269
 /** 农资品类销售占比 — 饼图 */
301
 /** 农资品类销售占比 — 饼图 */
270
-export function buildCategorySalesPieOption(categorySales) {
302
+export function buildCategorySalesPieOption(categorySales, mallStatsAvailable = true) {
303
+  if (mallStatsAvailable === false) {
304
+    return emptyOption(MALL_UNAVAILABLE_TEXT)
305
+  }
271
   if (!categorySales?.items?.length || !(categorySales.totalQty > 0)) {
306
   if (!categorySales?.items?.length || !(categorySales.totalQty > 0)) {
272
-    return emptyOption('暂无品类数据')
307
+    return emptyOption(resolveMallEmptyText(mallStatsAvailable, '暂无品类数据'))
273
   }
308
   }
274
   const data = categorySales.items
309
   const data = categorySales.items
275
     .filter((item) => (item.qty ?? 0) > 0)
310
     .filter((item) => (item.qty ?? 0) > 0)
@@ -312,9 +347,12 @@ export function buildCategorySalesPieOption(categorySales) {
312
 }
347
 }
313
 
348
 
314
 /** 商城订单趋势 — 曲线 */
349
 /** 商城订单趋势 — 曲线 */
315
-export function buildMallOrderTrendOption(mallOrderTrend) {
350
+export function buildMallOrderTrendOption(mallOrderTrend, mallStatsAvailable = true) {
351
+  if (mallStatsAvailable === false) {
352
+    return emptyOption(MALL_UNAVAILABLE_TEXT)
353
+  }
316
   if (!mallOrderTrend?.items?.length) {
354
   if (!mallOrderTrend?.items?.length) {
317
-    return emptyOption('暂无订单数据')
355
+    return emptyOption(resolveMallEmptyText(mallStatsAvailable, '暂无订单数据'))
318
   }
356
   }
319
   const counts = monthSeries(mallOrderTrend.items, 'orderCount')
357
   const counts = monthSeries(mallOrderTrend.items, 'orderCount')
320
   if (!counts.some((v) => v > 0)) {
358
   if (!counts.some((v) => v > 0)) {
@@ -352,9 +390,12 @@ export function buildMallOrderTrendOption(mallOrderTrend) {
352
 }
390
 }
353
 
391
 
354
 /** 店铺入驻 — 饼图(按月占比) */
392
 /** 店铺入驻 — 饼图(按月占比) */
355
-export function buildShopEntryPieOption(shopEntry) {
393
+export function buildShopEntryPieOption(shopEntry, mallStatsAvailable = true) {
394
+  if (mallStatsAvailable === false) {
395
+    return emptyOption(MALL_UNAVAILABLE_TEXT)
396
+  }
356
   if (!shopEntry?.items?.length || !(shopEntry.yearTotal > 0)) {
397
   if (!shopEntry?.items?.length || !(shopEntry.yearTotal > 0)) {
357
-    return emptyOption('暂无入驻数据')
398
+    return emptyOption(resolveMallEmptyText(mallStatsAvailable, '暂无入驻数据'))
358
   }
399
   }
359
   const data = shopEntry.items
400
   const data = shopEntry.items
360
     .filter((item) => (item.shopCount ?? 0) > 0)
401
     .filter((item) => (item.shopCount ?? 0) > 0)
@@ -393,9 +434,12 @@ export function buildShopEntryPieOption(shopEntry) {
393
 }
434
 }
394
 
435
 
395
 /** 消费区域排名 Top5 — 柱图(万元) */
436
 /** 消费区域排名 Top5 — 柱图(万元) */
396
-export function buildRegionRankBarOption(regionRank) {
437
+export function buildRegionRankBarOption(regionRank, mallStatsAvailable = true) {
438
+  if (mallStatsAvailable === false) {
439
+    return emptyOption(MALL_UNAVAILABLE_TEXT)
440
+  }
397
   if (!regionRank?.items?.length) {
441
   if (!regionRank?.items?.length) {
398
-    return emptyOption('暂无区域数据')
442
+    return emptyOption(resolveMallEmptyText(mallStatsAvailable, '暂无区域数据'))
399
   }
443
   }
400
   const sorted = [...regionRank.items].sort((a, b) => a.rank - b.rank)
444
   const sorted = [...regionRank.items].sort((a, b) => a.rank - b.rank)
401
   const names = sorted.map((r) => r.city || '')
445
   const names = sorted.map((r) => r.city || '')
@@ -441,10 +485,13 @@ export function buildRegionRankBarOption(regionRank) {
441
 }
485
 }
442
 
486
 
443
 /** 消费者评价词云 */
487
 /** 消费者评价词云 */
444
-export function buildReviewWordCloudOption(reviewWordCloud) {
488
+export function buildReviewWordCloudOption(reviewWordCloud, mallStatsAvailable = true) {
489
+  if (mallStatsAvailable === false) {
490
+    return emptyOption(MALL_UNAVAILABLE_TEXT)
491
+  }
445
   const list = reviewWordCloud?.items || []
492
   const list = reviewWordCloud?.items || []
446
   if (!list.length) {
493
   if (!list.length) {
447
-    return emptyOption('暂无评价数据')
494
+    return emptyOption(resolveMallEmptyText(mallStatsAvailable, '暂无评价数据'))
448
   }
495
   }
449
   const data = list
496
   const data = list
450
     .filter((item) => item.word)
497
     .filter((item) => item.word)

+ 140 - 34
ruoyi-screen/src/views/tradeSales/index.vue

@@ -1,8 +1,10 @@
1
 <template>
1
 <template>
2
   <div class="screen-page ts-page" :class="{ 'is-loading': loading }">
2
   <div class="screen-page ts-page" :class="{ 'is-loading': loading }">
3
-    <div v-if="loadError" class="ts-error">{{ loadError }}</div>
4
-
5
-    <!-- <div class="ts-year-bar">
3
+    <div v-if="loadError" class="ts-error" @click="onRetry">
4
+      {{ loadError }}(点击重试)
5
+    </div>
6
+<!-- 
7
+    <div class="ts-year-bar">
6
       <label class="ts-year-bar__label">统计年份</label>
8
       <label class="ts-year-bar__label">统计年份</label>
7
       <select
9
       <select
8
         v-model="statYear"
10
         v-model="statYear"
@@ -13,9 +15,17 @@
13
         <option v-for="y in availableYears" :key="y" :value="String(y)">{{ y }}年</option>
15
         <option v-for="y in availableYears" :key="y" :value="String(y)">{{ y }}年</option>
14
       </select>
16
       </select>
15
       <span v-if="statDate" class="ts-year-bar__date">统计日 {{ statDate }}</span>
17
       <span v-if="statDate" class="ts-year-bar__date">统计日 {{ statDate }}</span>
18
+      <button
19
+        type="button"
20
+        class="ts-year-bar__refresh"
21
+        :disabled="loading || refreshing"
22
+        @click="onManualRefresh"
23
+      >
24
+        {{ refreshing ? '刷新中…' : '刷新' }}
25
+      </button>
16
     </div> -->
26
     </div> -->
17
 
27
 
18
-    <!-- 左栏:牦牛交易(对接 §3.1) -->
28
+    <!-- 左栏:牦牛交易 -->
19
     <div class="screen-page--home-column">
29
     <div class="screen-page--home-column">
20
       <div class="screen-page--home-column-top">
30
       <div class="screen-page--home-column-top">
21
         <div class="top_title">交易总览 ཚོང་འདོན་ཀྱི་རྒྱུན་བསྡུས།</div>
31
         <div class="top_title">交易总览 ཚོང་འདོན་ཀྱི་རྒྱུན་བསྡུས།</div>
@@ -59,6 +69,12 @@
59
                   <strong>{{ formatPrice(originQuote?.maxPrice7d) }}</strong>
69
                   <strong>{{ formatPrice(originQuote?.maxPrice7d) }}</strong>
60
                 </div>
70
                 </div>
61
               </div>
71
               </div>
72
+              <div class="quote-summary__item">
73
+                <div class="quote-summary__label">最近行情日</div>
74
+                <div class="quote-summary__val quote-summary__val--date">
75
+                  <strong>{{ originQuote?.lastQuoteDate || '—' }}</strong>
76
+                </div>
77
+              </div>
62
             </div>
78
             </div>
63
             <div class="quote-chart">
79
             <div class="quote-chart">
64
               <ScreenChart :option="originQuoteChartOption" />
80
               <ScreenChart :option="originQuoteChartOption" />
@@ -89,10 +105,13 @@
89
       </div>
105
       </div>
90
     </div>
106
     </div>
91
 
107
 
92
-    <!-- 右栏:农资商城(对接 §8~13) -->
93
-    <div class="screen-page--home-column">
108
+    <!-- 右栏:农资商城 -->
109
+    <div class="screen-page--home-column" :class="{ 'ts-mall-unavailable': !mallStatsAvailable }">
94
       <div class="screen-page--home-column-right ts-mall-panel">
110
       <div class="screen-page--home-column-right ts-mall-panel">
95
-        <div class="top_title">农资品类销售 ཞིང་ལས་རྒྱུ་ཆ་རིགས་ཀྱི་ཚོང་འདོན།</div>
111
+        <div class="top_title">
112
+          农资品类销售 ཞིང་ལས་རྒྱུ་ཆ་རིགས་ཀྱི་ཚོང་འདོན།
113
+          <span v-if="categoryStatDate" class="ts-mall-stat-date">({{ categoryStatDate }})</span>
114
+        </div>
96
         <div class="ts-mall-top">
115
         <div class="ts-mall-top">
97
           <div class="ts-mall-top__pie">
116
           <div class="ts-mall-top__pie">
98
             <ScreenChart :option="categorySalesChartOption" />
117
             <ScreenChart :option="categorySalesChartOption" />
@@ -103,7 +122,7 @@
103
               <li v-for="item in hotCategoryItems" :key="item.rank" class="ts-mall-hot-list__row">
122
               <li v-for="item in hotCategoryItems" :key="item.rank" class="ts-mall-hot-list__row">
104
                 <span class="ts-mall-hot-list__rank">{{ item.rank }}</span>
123
                 <span class="ts-mall-hot-list__rank">{{ item.rank }}</span>
105
                 <span class="ts-mall-hot-list__name" :title="item.categoryName">{{ item.categoryName }}</span>
124
                 <span class="ts-mall-hot-list__name" :title="item.categoryName">{{ item.categoryName }}</span>
106
-                <span class="ts-mall-hot-list__qty">{{ formatNum(item.qty) }}</span>
125
+                <span class="ts-mall-hot-list__qty">{{ formatNum(item.qty) }}</span>
107
               </li>
126
               </li>
108
             </ul>
127
             </ul>
109
             <span v-else class="ts-placeholder ts-placeholder--sm">{{ mallEmptyHint }}</span>
128
             <span v-else class="ts-placeholder ts-placeholder--sm">{{ mallEmptyHint }}</span>
@@ -143,7 +162,7 @@
143
 </template>
162
 </template>
144
 
163
 
145
 <script setup>
164
 <script setup>
146
-import { computed, onMounted, ref } from 'vue'
165
+import { computed, onMounted, onUnmounted, ref } from 'vue'
147
 import ScreenChart from '@/components/ScreenChart.vue'
166
 import ScreenChart from '@/components/ScreenChart.vue'
148
 import { getTradeSalesDashboard } from '@/api/tradeSales'
167
 import { getTradeSalesDashboard } from '@/api/tradeSales'
149
 import {
168
 import {
@@ -158,11 +177,15 @@ import {
158
   buildTradeMonthlyOption
177
   buildTradeMonthlyOption
159
 } from './chartOptions'
178
 } from './chartOptions'
160
 
179
 
180
+/** 自动轮播刷新周期(对齐功能需求 §7.3,默认 3 分钟) */
181
+const DASHBOARD_REFRESH_MS = 3 * 60 * 1000
182
+
161
 const loading = ref(false)
183
 const loading = ref(false)
184
+const refreshing = ref(false)
162
 const loadError = ref('')
185
 const loadError = ref('')
163
 const statYear = ref(String(new Date().getFullYear()))
186
 const statYear = ref(String(new Date().getFullYear()))
164
 const statDate = ref('')
187
 const statDate = ref('')
165
-const availableYears = ref([])
188
+const availableYears = ref([new Date().getFullYear()])
166
 
189
 
167
 const tradeOverview = ref(null)
190
 const tradeOverview = ref(null)
168
 const originQuote = ref(null)
191
 const originQuote = ref(null)
@@ -177,15 +200,25 @@ const shopEntry = ref(null)
177
 const regionRank = ref(null)
200
 const regionRank = ref(null)
178
 const reviewWordCloud = ref(null)
201
 const reviewWordCloud = ref(null)
179
 
202
 
203
+let dashboardTimer = null
204
+
205
+const mallAvailable = computed(() => mallStatsAvailable.value)
206
+
180
 const originQuoteChartOption = computed(() => buildOriginQuoteOption(originQuote.value))
207
 const originQuoteChartOption = computed(() => buildOriginQuoteOption(originQuote.value))
181
 const monthlyTrendChartOption = computed(() => buildTradeMonthlyOption(tradeMonthlyTrend.value))
208
 const monthlyTrendChartOption = computed(() => buildTradeMonthlyOption(tradeMonthlyTrend.value))
182
 const destinationChartOption = computed(() => buildSalesDestinationBarOption(salesDestination.value))
209
 const destinationChartOption = computed(() => buildSalesDestinationBarOption(salesDestination.value))
183
 const qualityGradeChartOption = computed(() => buildQualityGradePieOption(qualityGrade.value))
210
 const qualityGradeChartOption = computed(() => buildQualityGradePieOption(qualityGrade.value))
184
-const categorySalesChartOption = computed(() => buildCategorySalesPieOption(categorySales.value))
185
-const mallOrderTrendChartOption = computed(() => buildMallOrderTrendOption(mallOrderTrend.value))
186
-const shopEntryChartOption = computed(() => buildShopEntryPieOption(shopEntry.value))
187
-const regionRankChartOption = computed(() => buildRegionRankBarOption(regionRank.value))
188
-const reviewWordCloudChartOption = computed(() => buildReviewWordCloudOption(reviewWordCloud.value))
211
+const categorySalesChartOption = computed(() =>
212
+  buildCategorySalesPieOption(categorySales.value, mallAvailable.value)
213
+)
214
+const mallOrderTrendChartOption = computed(() =>
215
+  buildMallOrderTrendOption(mallOrderTrend.value, mallAvailable.value)
216
+)
217
+const shopEntryChartOption = computed(() => buildShopEntryPieOption(shopEntry.value, mallAvailable.value))
218
+const regionRankChartOption = computed(() => buildRegionRankBarOption(regionRank.value, mallAvailable.value))
219
+const reviewWordCloudChartOption = computed(() =>
220
+  buildReviewWordCloudOption(reviewWordCloud.value, mallAvailable.value)
221
+)
189
 
222
 
190
 const hotCategoryItems = computed(() => {
223
 const hotCategoryItems = computed(() => {
191
   const items = hotCategoryRank.value?.items || []
224
   const items = hotCategoryRank.value?.items || []
@@ -194,13 +227,17 @@ const hotCategoryItems = computed(() => {
194
 
227
 
195
 const mallEmptyHint = computed(() => (mallStatsAvailable.value ? '暂无热销数据' : '商城统计未接入'))
228
 const mallEmptyHint = computed(() => (mallStatsAvailable.value ? '暂无热销数据' : '商城统计未接入'))
196
 
229
 
230
+const categoryStatDate = computed(
231
+  () => categorySales.value?.statDate || hotCategoryRank.value?.statDate || ''
232
+)
233
+
197
 const tradeOverviewItems = computed(() => {
234
 const tradeOverviewItems = computed(() => {
198
   const o = tradeOverview.value
235
   const o = tradeOverview.value
199
   return [
236
   return [
200
     {
237
     {
201
       key: 'orderCount',
238
       key: 'orderCount',
202
       label: '牦牛交易订单数',
239
       label: '牦牛交易订单数',
203
-      unit: '',
240
+      unit: '',
204
       value: display(o?.orderCount),
241
       value: display(o?.orderCount),
205
       placeholder: false
242
       placeholder: false
206
     },
243
     },
@@ -293,6 +330,21 @@ function formatPrice(val) {
293
   return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
330
   return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
294
 }
331
 }
295
 
332
 
333
+function resetDashboard() {
334
+  tradeOverview.value = null
335
+  originQuote.value = null
336
+  tradeMonthlyTrend.value = []
337
+  salesDestination.value = []
338
+  qualityGrade.value = null
339
+  mallStatsAvailable.value = false
340
+  categorySales.value = null
341
+  hotCategoryRank.value = null
342
+  mallOrderTrend.value = null
343
+  shopEntry.value = null
344
+  regionRank.value = null
345
+  reviewWordCloud.value = null
346
+}
347
+
296
 function applyDashboard(data) {
348
 function applyDashboard(data) {
297
   if (!data) {
349
   if (!data) {
298
     return
350
     return
@@ -309,7 +361,7 @@ function applyDashboard(data) {
309
   tradeMonthlyTrend.value = data.tradeMonthlyTrend || []
361
   tradeMonthlyTrend.value = data.tradeMonthlyTrend || []
310
   salesDestination.value = data.salesDestination || []
362
   salesDestination.value = data.salesDestination || []
311
   qualityGrade.value = data.qualityGrade || null
363
   qualityGrade.value = data.qualityGrade || null
312
-  mallStatsAvailable.value = !!data.mallStatsAvailable
364
+  mallStatsAvailable.value = data.mallStatsAvailable || false
313
   categorySales.value = data.categorySales || null
365
   categorySales.value = data.categorySales || null
314
   hotCategoryRank.value = data.hotCategoryRank || null
366
   hotCategoryRank.value = data.hotCategoryRank || null
315
   mallOrderTrend.value = data.mallOrderTrend || null
367
   mallOrderTrend.value = data.mallOrderTrend || null
@@ -318,16 +370,25 @@ function applyDashboard(data) {
318
   reviewWordCloud.value = data.reviewWordCloud || null
370
   reviewWordCloud.value = data.reviewWordCloud || null
319
 }
371
 }
320
 
372
 
321
-async function fetchDashboard() {
322
-  loading.value = true
373
+async function fetchDashboard(forceRefresh = false) {
374
+  if (forceRefresh) {
375
+    if (refreshing.value) {
376
+      return
377
+    }
378
+    refreshing.value = true
379
+  } else {
380
+    loading.value = true
381
+    resetDashboard()
382
+  }
323
   loadError.value = ''
383
   loadError.value = ''
324
   try {
384
   try {
325
-    const res = await getTradeSalesDashboard(statYear.value)
385
+    const res = await getTradeSalesDashboard(statYear.value, forceRefresh)
326
     applyDashboard(res.data)
386
     applyDashboard(res.data)
327
   } catch (e) {
387
   } catch (e) {
328
     loadError.value = e?.message || '看板数据加载失败'
388
     loadError.value = e?.message || '看板数据加载失败'
329
   } finally {
389
   } finally {
330
     loading.value = false
390
     loading.value = false
391
+    refreshing.value = false
331
   }
392
   }
332
 }
393
 }
333
 
394
 
@@ -335,8 +396,30 @@ function onYearChange() {
335
   fetchDashboard()
396
   fetchDashboard()
336
 }
397
 }
337
 
398
 
399
+function onManualRefresh() {
400
+  fetchDashboard(true)
401
+}
402
+
403
+function onRetry() {
404
+  fetchDashboard()
405
+}
406
+
407
+function startDashboardTimer() {
408
+  dashboardTimer = setInterval(() => {
409
+    fetchDashboard(true)
410
+  }, DASHBOARD_REFRESH_MS)
411
+}
412
+
338
 onMounted(() => {
413
 onMounted(() => {
339
   fetchDashboard()
414
   fetchDashboard()
415
+  startDashboardTimer()
416
+})
417
+
418
+onUnmounted(() => {
419
+  if (dashboardTimer) {
420
+    clearInterval(dashboardTimer)
421
+    dashboardTimer = null
422
+  }
340
 })
423
 })
341
 </script>
424
 </script>
342
 
425
 
@@ -367,6 +450,7 @@ onMounted(() => {
367
   color: #ffb4b4;
450
   color: #ffb4b4;
368
   background: rgba(80, 20, 20, 0.75);
451
   background: rgba(80, 20, 20, 0.75);
369
   border-radius: 4px;
452
   border-radius: 4px;
453
+  cursor: pointer;
370
 }
454
 }
371
 
455
 
372
 .ts-year-bar {
456
 .ts-year-bar {
@@ -401,6 +485,22 @@ onMounted(() => {
401
   color: rgba(168, 212, 200, 0.75);
485
   color: rgba(168, 212, 200, 0.75);
402
 }
486
 }
403
 
487
 
488
+.ts-year-bar__refresh {
489
+  height: 28px;
490
+  padding: 0 10px;
491
+  font-size: 12px;
492
+  color: #e8eef5;
493
+  background: rgba(4, 48, 40, 0.85);
494
+  border: 1px solid var(--screen-line-dim, rgba(61, 217, 176, 0.42));
495
+  border-radius: 4px;
496
+  cursor: pointer;
497
+}
498
+
499
+.ts-year-bar__refresh:disabled {
500
+  opacity: 0.6;
501
+  cursor: not-allowed;
502
+}
503
+
404
 .screen-page--home-column {
504
 .screen-page--home-column {
405
   width: 617px;
505
   width: 617px;
406
   height: 100%;
506
   height: 100%;
@@ -431,6 +531,12 @@ onMounted(() => {
431
   flex-direction: column;
531
   flex-direction: column;
432
 }
532
 }
433
 
533
 
534
+.ts-mall-stat-date {
535
+  font-size: 12px;
536
+  color: rgba(168, 212, 200, 0.8);
537
+  margin-left: 4px;
538
+}
539
+
434
 .ts-mall-top {
540
 .ts-mall-top {
435
   flex: 1;
541
   flex: 1;
436
   min-height: 0;
542
   min-height: 0;
@@ -613,7 +719,6 @@ onMounted(() => {
613
 }
719
 }
614
 
720
 
615
 .content_title {
721
 .content_title {
616
-
617
   font-size: 11px;
722
   font-size: 11px;
618
   color: #a8d4c8;
723
   color: #a8d4c8;
619
   line-height: 1.3;
724
   line-height: 1.3;
@@ -646,13 +751,15 @@ onMounted(() => {
646
 .quote-summary {
751
 .quote-summary {
647
   display: flex;
752
   display: flex;
648
   flex-direction: row;
753
   flex-direction: row;
649
-  gap: 6px;
650
-  height: 42px;
754
+  flex-wrap: wrap;
755
+  gap: 4px;
756
+  min-height: 42px;
651
   flex-shrink: 0;
757
   flex-shrink: 0;
652
 }
758
 }
653
 
759
 
654
 .quote-summary__item {
760
 .quote-summary__item {
655
-  flex: 1;
761
+  flex: 1 1 calc(50% - 4px);
762
+  min-width: 0;
656
   display: flex;
763
   display: flex;
657
   flex-direction: column;
764
   flex-direction: column;
658
   align-items: center;
765
   align-items: center;
@@ -660,28 +767,33 @@ onMounted(() => {
660
   background: rgba(4, 48, 40, 0.45);
767
   background: rgba(4, 48, 40, 0.45);
661
   border-radius: 4px;
768
   border-radius: 4px;
662
   border: 1px solid rgba(61, 217, 176, 0.2);
769
   border: 1px solid rgba(61, 217, 176, 0.2);
770
+  padding: 2px 0;
663
 }
771
 }
664
 
772
 
665
 .quote-summary__label {
773
 .quote-summary__label {
666
-  font-size: 10px;
774
+  font-size: 9px;
667
   color: #a8d4c8;
775
   color: #a8d4c8;
668
 }
776
 }
669
 
777
 
670
 .quote-summary__val {
778
 .quote-summary__val {
671
-  font-size: 11px;
779
+  font-size: 10px;
672
   color: #fff;
780
   color: #fff;
673
 }
781
 }
674
 
782
 
675
 .quote-summary__val strong {
783
 .quote-summary__val strong {
676
-  font-size: 14px;
784
+  font-size: 12px;
677
   background: linear-gradient(to bottom, #98e9aa, #ecd27b);
785
   background: linear-gradient(to bottom, #98e9aa, #ecd27b);
678
   -webkit-background-clip: text;
786
   -webkit-background-clip: text;
679
   background-clip: text;
787
   background-clip: text;
680
   color: transparent;
788
   color: transparent;
681
 }
789
 }
682
 
790
 
683
-.quote-summary__unit {
791
+.quote-summary__val--date strong {
684
   font-size: 10px;
792
   font-size: 10px;
793
+}
794
+
795
+.quote-summary__unit {
796
+  font-size: 9px;
685
   color: #a8d4c8;
797
   color: #a8d4c8;
686
   margin-left: 2px;
798
   margin-left: 2px;
687
 }
799
 }
@@ -690,10 +802,4 @@ onMounted(() => {
690
   flex: 1;
802
   flex: 1;
691
   min-height: 0;
803
   min-height: 0;
692
 }
804
 }
693
-
694
-.flex_content--placeholder {
695
-  display: flex;
696
-  align-items: center;
697
-  justify-content: center;
698
-}
699
 </style>
805
 </style>