xsh_1997 пре 1 недеља
родитељ
комит
119f9789ec

+ 53 - 8
ruoyi-screen/src/components/ScreenChart.vue

@@ -1,5 +1,5 @@
1 1
 <script setup>
2
-import { nextTick, onMounted, onUnmounted, ref, watch } from "vue"
2
+import { onMounted, onUnmounted, ref, watch } from "vue"
3 3
 import * as echarts from "echarts"
4 4
 import "echarts-wordcloud"
5 5
 
@@ -14,14 +14,31 @@ const props = defineProps({
14 14
 const chartRef = ref(null)
15 15
 let chartInstance = null
16 16
 let resizeObserver = null
17
+let resizeTimer = null
18
+let lastSize = { w: 0, h: 0 }
19
+
20
+const DEFAULT_ANIMATION = {
21
+  animation: true,
22
+  animationDuration: 1000,
23
+  animationEasing: 'cubicOut',
24
+  animationDurationUpdate: 600,
25
+  animationEasingUpdate: 'cubicOut'
26
+}
27
+
28
+/** 合并默认动画;各页/系列可显式 animation: false 覆盖 */
29
+function withAnimation(opt) {
30
+  if (!opt || opt.animation === false) {
31
+    return opt
32
+  }
33
+  return { ...DEFAULT_ANIMATION, ...opt }
34
+}
17 35
 
18 36
 /** 写入配置并强制整表替换,避免从「暂无数据」切到真实图表时残留 title */
19 37
 function applyOption(opt) {
20 38
   if (!chartInstance || !opt) {
21 39
     return
22 40
   }
23
-  chartInstance.setOption(opt, { notMerge: true })
24
-  chartInstance.resize()
41
+  chartInstance.setOption(withAnimation(opt), { notMerge: true })
25 42
 }
26 43
 
27 44
 /** 在挂载点创建实例并塞入配置 */
@@ -32,18 +49,43 @@ function initChart() {
32 49
   if (chartInstance) {
33 50
     chartInstance.dispose()
34 51
   }
52
+  lastSize = { w: 0, h: 0 }
35 53
   chartInstance = echarts.init(chartRef.value)
36 54
   applyOption(props.option)
55
+  scheduleResize()
37 56
 }
38 57
 
39
-/** 外层 div 尺寸变化时重绘,避免缩放过后面糊或留白 */
40
-function resize() {
41
-  chartInstance?.resize()
58
+/** 仅容器尺寸变化时 resize;避免 setOption 后立即 resize 打断入场动画 */
59
+function resizeIfChanged() {
60
+  const el = chartRef.value
61
+  if (!chartInstance || !el) {
62
+    return
63
+  }
64
+  const w = el.clientWidth
65
+  const h = el.clientHeight
66
+  if (w <= 0 || h <= 0) {
67
+    return
68
+  }
69
+  if (w === lastSize.w && h === lastSize.h) {
70
+    return
71
+  }
72
+  lastSize = { w, h }
73
+  chartInstance.resize()
74
+}
75
+
76
+function scheduleResize() {
77
+  if (resizeTimer) {
78
+    clearTimeout(resizeTimer)
79
+  }
80
+  resizeTimer = window.setTimeout(() => {
81
+    resizeTimer = null
82
+    resizeIfChanged()
83
+  }, 80)
42 84
 }
43 85
 
44 86
 onMounted(() => {
45 87
   initChart()
46
-  resizeObserver = new ResizeObserver(() => resize())
88
+  resizeObserver = new ResizeObserver(() => scheduleResize())
47 89
   if (chartRef.value) {
48 90
     resizeObserver.observe(chartRef.value)
49 91
   }
@@ -53,12 +95,15 @@ watch(
53 95
   () => props.option,
54 96
   (opt) => {
55 97
     applyOption(opt)
56
-    nextTick(() => resize())
57 98
   },
58 99
   { deep: true }
59 100
 )
60 101
 
61 102
 onUnmounted(() => {
103
+  if (resizeTimer) {
104
+    clearTimeout(resizeTimer)
105
+    resizeTimer = null
106
+  }
62 107
   resizeObserver?.disconnect()
63 108
   resizeObserver = null
64 109
   chartInstance?.dispose()

+ 441 - 2
ruoyi-screen/src/views/commonProsperity/index.vue

@@ -192,7 +192,15 @@
192 192
             <div class="cp-project-scroll" @scroll="onProjectScroll">
193 193
               <div v-if="projectLoading && !projectRows.length" class="cp-project-empty">加载中…</div>
194 194
               <div v-else-if="!projectRows.length" class="cp-project-empty">暂无共富项目</div>
195
-              <div v-for="row in projectRows" :key="row.id" class="cp-project-row">
195
+              <div
196
+                v-for="row in projectRows"
197
+                :key="row.id"
198
+                class="cp-project-row cp-project-row--clickable"
199
+                role="button"
200
+                tabindex="0"
201
+                @click="openProjectDetail(row)"
202
+                @keyup.enter="openProjectDetail(row)"
203
+              >
196 204
                 <div class="cp-project-row__main">
197 205
                   <div class="cp-project-row__name" :title="row.projectName">
198 206
                     {{ row.projectName || '—' }}
@@ -214,11 +222,118 @@
214 222
         </div>
215 223
       </div>
216 224
     </div>
225
+
226
+    <Teleport to="body">
227
+      <div
228
+        v-if="projectDetailVisible && selectedProject"
229
+        class="cp-detail-mask"
230
+        @click.self="closeProjectDetail"
231
+      >
232
+        <div class="cp-detail-panel" role="dialog" aria-modal="true">
233
+          <button type="button" class="cp-detail-close" aria-label="关闭" @click="closeProjectDetail">
234
+            ×
235
+          </button>
236
+          <div class="cp-detail-title">共富项目详情</div>
237
+          <div class="cp-detail-body">
238
+            <div class="cp-detail-name">{{ selectedProject.projectName || '—' }}</div>
239
+            <div class="cp-detail-grid">
240
+              <div class="cp-detail-item">
241
+                <span class="cp-detail-label">项目类型</span>
242
+                <span class="cp-detail-value">{{ selectedProject.projectTypeName || '—' }}</span>
243
+              </div>
244
+              <div class="cp-detail-item">
245
+                <span class="cp-detail-label">实施周期</span>
246
+                <span class="cp-detail-value">
247
+                  {{ selectedProject.implStartDate || '—' }} ~ {{ selectedProject.implEndDate || '—' }}
248
+                </span>
249
+              </div>
250
+              <div class="cp-detail-item">
251
+                <span class="cp-detail-label">发布时间</span>
252
+                <span class="cp-detail-value">{{ selectedProject.publishTime || '—' }}</span>
253
+              </div>
254
+            </div>
255
+            <div class="cp-detail-block">
256
+              <div class="cp-detail-label">项目简介</div>
257
+              <div class="cp-detail-text">{{ selectedProject.introduction || '—' }}</div>
258
+            </div>
259
+            <div class="cp-detail-block">
260
+              <div class="cp-detail-label">项目内容</div>
261
+              <div class="cp-detail-text">{{ selectedProject.projectContent || '—' }}</div>
262
+            </div>
263
+            <div v-if="selectedProject.coverFileUrl" class="cp-detail-block">
264
+              <div class="cp-detail-label">封面</div>
265
+              <img
266
+                class="cp-detail-cover"
267
+                :src="mediaUrl(selectedProject.coverFileUrl)"
268
+                alt="封面"
269
+              />
270
+            </div>
271
+            <div v-if="selectedProject.operationImages?.length" class="cp-detail-block">
272
+              <div class="cp-detail-label">运营图片</div>
273
+              <div class="cp-detail-carousel">
274
+                <button
275
+                  v-if="opImageCount > 1"
276
+                  type="button"
277
+                  class="cp-detail-carousel__nav cp-detail-carousel__nav--prev"
278
+                  aria-label="上一张"
279
+                  @click="prevOpImage"
280
+                >
281
+                  ‹
282
+                </button>
283
+                <div class="cp-detail-carousel__viewport">
284
+                  <div
285
+                    class="cp-detail-carousel__track"
286
+                    :style="{ transform: `translateX(-${opImageIndex * 100}%)` }"
287
+                  >
288
+                    <div
289
+                      v-for="(img, idx) in selectedProject.operationImages"
290
+                      :key="idx"
291
+                      class="cp-detail-carousel__slide"
292
+                    >
293
+                      <img :src="mediaUrl(img.fileUrl)" alt="运营图片" />
294
+                    </div>
295
+                  </div>
296
+                </div>
297
+                <button
298
+                  v-if="opImageCount > 1"
299
+                  type="button"
300
+                  class="cp-detail-carousel__nav cp-detail-carousel__nav--next"
301
+                  aria-label="下一张"
302
+                  @click="nextOpImage"
303
+                >
304
+                  ›
305
+                </button>
306
+                <div v-if="opImageCount > 1" class="cp-detail-carousel__dots">
307
+                  <button
308
+                    v-for="(_, idx) in selectedProject.operationImages"
309
+                    :key="idx"
310
+                    type="button"
311
+                    class="cp-detail-carousel__dot"
312
+                    :class="{ 'cp-detail-carousel__dot--on': opImageIndex === idx }"
313
+                    :aria-label="`第 ${idx + 1} 张`"
314
+                    @click="goOpImage(idx)"
315
+                  />
316
+                </div>
317
+              </div>
318
+            </div>
319
+            <div v-if="selectedProject.videoFileUrl" class="cp-detail-block">
320
+              <div class="cp-detail-label">运营视频</div>
321
+              <video
322
+                class="cp-detail-video"
323
+                :src="mediaUrl(selectedProject.videoFileUrl)"
324
+                controls
325
+                preload="metadata"
326
+              />
327
+            </div>
328
+          </div>
329
+        </div>
330
+      </div>
331
+    </Teleport>
217 332
   </div>
218 333
 </template>
219 334
 
220 335
 <script setup>
221
-import { computed, onMounted, ref } from 'vue'
336
+import { computed, onMounted, onUnmounted, ref } from 'vue'
222 337
 import ScreenChart from '@/components/ScreenChart.vue'
223 338
 import ScreenScrollNumber from '@/components/ScreenScrollNumber.vue'
224 339
 import { getCommonProsperityDashboard, getCommonProsperityProjects } from '@/api/commonProsperity'
@@ -251,6 +366,13 @@ const projectTotal = ref(0)
251 366
 const projectLoadingMore = ref(false)
252 367
 const projectScrollLoading = ref(false)
253 368
 
369
+const projectDetailVisible = ref(false)
370
+const selectedProject = ref(null)
371
+const opImageIndex = ref(0)
372
+let opImageTimer = null
373
+
374
+const opImageCount = computed(() => selectedProject.value?.operationImages?.length ?? 0)
375
+
254 376
 const summary = computed(() =>
255 377
   achievement.value?.hasData ? achievement.value.summary : null
256 378
 )
@@ -406,6 +528,75 @@ function onProjectScroll(event) {
406 528
   loadMoreProjects()
407 529
 }
408 530
 
531
+function mediaUrl(url) {
532
+  if (!url) {
533
+    return ''
534
+  }
535
+  if (/^https?:\/\//i.test(url)) {
536
+    return url
537
+  }
538
+  const base = import.meta.env.VITE_APP_BASE_API || ''
539
+  return url.startsWith('/') ? `${base}${url}` : `${base}/${url}`
540
+}
541
+
542
+function openProjectDetail(row) {
543
+  if (!row) {
544
+    return
545
+  }
546
+  selectedProject.value = row
547
+  opImageIndex.value = 0
548
+  projectDetailVisible.value = true
549
+  startOpImageAutoplay()
550
+}
551
+
552
+function closeProjectDetail() {
553
+  stopOpImageAutoplay()
554
+  projectDetailVisible.value = false
555
+  selectedProject.value = null
556
+  opImageIndex.value = 0
557
+}
558
+
559
+function stopOpImageAutoplay() {
560
+  if (opImageTimer) {
561
+    clearInterval(opImageTimer)
562
+    opImageTimer = null
563
+  }
564
+}
565
+
566
+function startOpImageAutoplay() {
567
+  stopOpImageAutoplay()
568
+  if (!projectDetailVisible.value || opImageCount.value <= 1) {
569
+    return
570
+  }
571
+  opImageTimer = window.setInterval(() => {
572
+    opImageIndex.value = (opImageIndex.value + 1) % opImageCount.value
573
+  }, 4000)
574
+}
575
+
576
+function prevOpImage() {
577
+  if (opImageCount.value <= 1) {
578
+    return
579
+  }
580
+  opImageIndex.value = (opImageIndex.value - 1 + opImageCount.value) % opImageCount.value
581
+  startOpImageAutoplay()
582
+}
583
+
584
+function nextOpImage() {
585
+  if (opImageCount.value <= 1) {
586
+    return
587
+  }
588
+  opImageIndex.value = (opImageIndex.value + 1) % opImageCount.value
589
+  startOpImageAutoplay()
590
+}
591
+
592
+function goOpImage(idx) {
593
+  if (idx < 0 || idx >= opImageCount.value) {
594
+    return
595
+  }
596
+  opImageIndex.value = idx
597
+  startOpImageAutoplay()
598
+}
599
+
409 600
 function onYearChange() {
410 601
   fetchDashboard()
411 602
 }
@@ -413,6 +604,10 @@ function onYearChange() {
413 604
 onMounted(() => {
414 605
   fetchDashboard()
415 606
 })
607
+
608
+onUnmounted(() => {
609
+  stopOpImageAutoplay()
610
+})
416 611
 </script>
417 612
 
418 613
 <style scoped>
@@ -791,6 +986,19 @@ onMounted(() => {
791 986
   border-bottom: 1px solid rgba(61, 217, 176, 0.15);
792 987
 }
793 988
 
989
+.cp-project-row--clickable {
990
+  cursor: pointer;
991
+  transition: background 0.15s ease;
992
+}
993
+
994
+.cp-project-row--clickable:hover {
995
+  background: rgba(69, 240, 184, 0.08);
996
+}
997
+
998
+.cp-project-row--clickable:active {
999
+  background: rgba(69, 240, 184, 0.14);
1000
+}
1001
+
794 1002
 .cp-project-row__name {
795 1003
   font-size: 12px;
796 1004
   color: #e8eef5;
@@ -832,4 +1040,235 @@ onMounted(() => {
832 1040
   font-size: 10px;
833 1041
   color: #6a9f90;
834 1042
 }
1043
+
1044
+.cp-detail-mask {
1045
+  position: fixed;
1046
+  inset: 0;
1047
+  z-index: 2000;
1048
+  display: flex;
1049
+  align-items: center;
1050
+  justify-content: center;
1051
+  padding: 24px;
1052
+  box-sizing: border-box;
1053
+  background: rgba(2, 18, 15, 0.72);
1054
+  backdrop-filter: blur(2px);
1055
+}
1056
+
1057
+.cp-detail-panel {
1058
+  position: relative;
1059
+  width: min(920px, calc(100vw - 48px));
1060
+  max-height: min(82vh, 720px);
1061
+  display: flex;
1062
+  flex-direction: column;
1063
+  box-sizing: border-box;
1064
+  border: 1px solid rgba(69, 240, 184, 0.45);
1065
+  border-radius: 8px;
1066
+  background:
1067
+    linear-gradient(180deg, rgba(4, 48, 40, 0.96) 0%, rgba(3, 28, 24, 0.98) 100%);
1068
+  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
1069
+}
1070
+
1071
+.cp-detail-close {
1072
+  position: absolute;
1073
+  top: 8px;
1074
+  right: 10px;
1075
+  z-index: 2;
1076
+  width: 28px;
1077
+  height: 28px;
1078
+  padding: 0;
1079
+  border: none;
1080
+  border-radius: 4px;
1081
+  font-size: 22px;
1082
+  line-height: 1;
1083
+  color: #a8d4c8;
1084
+  background: rgba(255, 255, 255, 0.06);
1085
+  cursor: pointer;
1086
+}
1087
+
1088
+.cp-detail-close:hover {
1089
+  color: #e8eef5;
1090
+  background: rgba(69, 240, 184, 0.15);
1091
+}
1092
+
1093
+.cp-detail-title {
1094
+  flex-shrink: 0;
1095
+  padding: 14px 48px 10px 20px;
1096
+  font-size: 16px;
1097
+  font-weight: 600;
1098
+  color: #fff;
1099
+  border-bottom: 1px solid rgba(61, 217, 176, 0.2);
1100
+}
1101
+
1102
+.cp-detail-body {
1103
+  flex: 1;
1104
+  min-height: 0;
1105
+  overflow-y: auto;
1106
+  padding: 14px 28px 22px;
1107
+  scrollbar-width: thin;
1108
+  scrollbar-color: rgba(69, 240, 184, 0.45) rgba(4, 30, 26, 0.35);
1109
+}
1110
+
1111
+.cp-detail-body::-webkit-scrollbar {
1112
+  width: 4px;
1113
+}
1114
+
1115
+.cp-detail-body::-webkit-scrollbar-thumb {
1116
+  background: rgba(69, 240, 184, 0.42);
1117
+  border-radius: 2px;
1118
+}
1119
+
1120
+.cp-detail-name {
1121
+  font-size: 15px;
1122
+  font-weight: 600;
1123
+  color: #ecd27b;
1124
+  line-height: 1.4;
1125
+  margin-bottom: 12px;
1126
+}
1127
+
1128
+.cp-detail-grid {
1129
+  display: grid;
1130
+  grid-template-columns: repeat(3, minmax(0, 1fr));
1131
+  gap: 10px 24px;
1132
+  margin-bottom: 14px;
1133
+}
1134
+
1135
+.cp-detail-item {
1136
+  display: flex;
1137
+  flex-direction: column;
1138
+  gap: 4px;
1139
+  min-width: 0;
1140
+}
1141
+
1142
+.cp-detail-label {
1143
+  font-size: 11px;
1144
+  color: #6a9f90;
1145
+}
1146
+
1147
+.cp-detail-value {
1148
+  font-size: 12px;
1149
+  color: #e8eef5;
1150
+  line-height: 1.45;
1151
+  word-break: break-word;
1152
+}
1153
+
1154
+.cp-detail-block {
1155
+  margin-bottom: 14px;
1156
+}
1157
+
1158
+.cp-detail-block:last-child {
1159
+  margin-bottom: 0;
1160
+}
1161
+
1162
+.cp-detail-block > .cp-detail-label {
1163
+  margin-bottom: 6px;
1164
+}
1165
+
1166
+.cp-detail-text {
1167
+  font-size: 12px;
1168
+  line-height: 1.6;
1169
+  color: #c8ddd6;
1170
+  white-space: pre-wrap;
1171
+  word-break: break-word;
1172
+}
1173
+
1174
+.cp-detail-cover {
1175
+  display: block;
1176
+  width: 320px;
1177
+  max-width: 100%;
1178
+  height: auto;
1179
+  max-height: 240px;
1180
+  object-fit: cover;
1181
+  border-radius: 6px;
1182
+  border: 1px solid rgba(61, 217, 176, 0.25);
1183
+}
1184
+
1185
+.cp-detail-carousel {
1186
+  position: relative;
1187
+  width: 480px;
1188
+  max-width: 100%;
1189
+}
1190
+
1191
+.cp-detail-carousel__viewport {
1192
+  overflow: hidden;
1193
+  border-radius: 6px;
1194
+  border: 1px solid rgba(61, 217, 176, 0.25);
1195
+  background: rgba(4, 30, 26, 0.5);
1196
+}
1197
+
1198
+.cp-detail-carousel__track {
1199
+  display: flex;
1200
+  transition: transform 0.45s ease;
1201
+}
1202
+
1203
+.cp-detail-carousel__slide {
1204
+  flex: 0 0 100%;
1205
+  height: 240px;
1206
+}
1207
+
1208
+.cp-detail-carousel__slide img {
1209
+  display: block;
1210
+  width: 100%;
1211
+  height: 100%;
1212
+  object-fit: cover;
1213
+}
1214
+
1215
+.cp-detail-carousel__nav {
1216
+  position: absolute;
1217
+  top: 50%;
1218
+  z-index: 2;
1219
+  transform: translateY(-50%);
1220
+  width: 28px;
1221
+  height: 48px;
1222
+  padding: 0;
1223
+  border: none;
1224
+  border-radius: 4px;
1225
+  font-size: 28px;
1226
+  line-height: 1;
1227
+  color: #e8eef5;
1228
+  background: rgba(4, 30, 26, 0.65);
1229
+  cursor: pointer;
1230
+}
1231
+
1232
+.cp-detail-carousel__nav:hover {
1233
+  background: rgba(69, 240, 184, 0.25);
1234
+  color: #5ef0c8;
1235
+}
1236
+
1237
+.cp-detail-carousel__nav--prev {
1238
+  left: 6px;
1239
+}
1240
+
1241
+.cp-detail-carousel__nav--next {
1242
+  right: 6px;
1243
+}
1244
+
1245
+.cp-detail-carousel__dots {
1246
+  display: flex;
1247
+  justify-content: center;
1248
+  gap: 8px;
1249
+  margin-top: 10px;
1250
+}
1251
+
1252
+.cp-detail-carousel__dot {
1253
+  width: 8px;
1254
+  height: 8px;
1255
+  padding: 0;
1256
+  border: none;
1257
+  border-radius: 50%;
1258
+  background: rgba(168, 212, 200, 0.35);
1259
+  cursor: pointer;
1260
+}
1261
+
1262
+.cp-detail-carousel__dot--on {
1263
+  background: #5ef0c8;
1264
+  box-shadow: 0 0 6px rgba(94, 240, 200, 0.55);
1265
+}
1266
+
1267
+.cp-detail-video {
1268
+  display: block;
1269
+  width: 100%;
1270
+  max-height: 320px;
1271
+  border-radius: 6px;
1272
+  background: #000;
1273
+}
835 1274
 </style>