vue2server 1.0.8 → 1.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/frontEnd/src/api/mock.js +10 -1
- package/frontEnd/src/pages/MeetingListPage.vue +3 -16
- package/frontEnd/src/pages/NameScrollPage.vue +291 -0
- package/frontEnd/src/router/routes.js +11 -0
- package/package.json +1 -1
- package/src/controllers/mock.controller.ts +26 -1
- package/src/routes/modules/mock.route.ts +1 -0
- package/src/services/mock.service.ts +13 -0
package/frontEnd/src/api/mock.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
import { post } from '../utils/request'
|
|
1
|
+
import { post, get } from '../utils/request'
|
|
2
2
|
|
|
3
3
|
export function getTrend(year) {
|
|
4
4
|
return post('/mock/trend', { year })
|
|
5
5
|
}
|
|
6
|
+
|
|
7
|
+
// 获取名字列表(分页)
|
|
8
|
+
// 后端接口:GET /api/mock/names
|
|
9
|
+
// 参数:
|
|
10
|
+
// - size:每页数量(最大200)
|
|
11
|
+
// - page:页码(从1开始)
|
|
12
|
+
export function getNames(params = {}) {
|
|
13
|
+
return get('/mock/names', params)
|
|
14
|
+
}
|
|
@@ -278,7 +278,7 @@ export default {
|
|
|
278
278
|
},
|
|
279
279
|
computed: {},
|
|
280
280
|
methods: {
|
|
281
|
-
async fetchData(
|
|
281
|
+
async fetchData() {
|
|
282
282
|
const params = {};
|
|
283
283
|
const { belOrgNo, meetSubj, meetTypeCd, createDateRange } = this.filters;
|
|
284
284
|
if (belOrgNo) params.belOrgNo = belOrgNo;
|
|
@@ -296,21 +296,8 @@ export default {
|
|
|
296
296
|
}
|
|
297
297
|
try {
|
|
298
298
|
const res = await get("/meeting/list", params);
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
this.totalCount = Number.isFinite(res.total) ? res.total : this.rows.length;
|
|
302
|
-
} else if (Array.isArray(res)) {
|
|
303
|
-
this.rows = res;
|
|
304
|
-
this.totalCount = res.length;
|
|
305
|
-
} else {
|
|
306
|
-
this.rows = [];
|
|
307
|
-
this.totalCount = 0;
|
|
308
|
-
}
|
|
309
|
-
const maxPage = Math.max(1, Math.ceil(this.totalCount / this.pageSize));
|
|
310
|
-
if (allowRetry && this.page > maxPage) {
|
|
311
|
-
this.page = maxPage;
|
|
312
|
-
await this.fetchData(false);
|
|
313
|
-
}
|
|
299
|
+
this.rows = Array.isArray(res.list) ? res.list : [];
|
|
300
|
+
this.totalCount = Number.isFinite(res.total) ? res.total : this.rows.length;
|
|
314
301
|
} catch (e) {
|
|
315
302
|
this.$message.error("获取会议列表失败");
|
|
316
303
|
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
名字无缝滚动页面
|
|
4
|
+
需求:
|
|
5
|
+
- 从后端获取名字列表
|
|
6
|
+
- 自动无缝向下滚动;鼠标移入暂停并允许滚轮上下滚动;移出恢复自动滚动
|
|
7
|
+
-->
|
|
8
|
+
<section class="page name-scroll-page">
|
|
9
|
+
<h1 class="page-title">名字滚动</h1>
|
|
10
|
+
|
|
11
|
+
<!-- 滚动容器:固定高度,溢出隐藏 -->
|
|
12
|
+
<div
|
|
13
|
+
class="scroll-wrapper"
|
|
14
|
+
ref="scrollWrapper"
|
|
15
|
+
@mouseenter="onMouseEnter"
|
|
16
|
+
@mouseleave="onMouseLeave"
|
|
17
|
+
@wheel.prevent="onWheel"
|
|
18
|
+
>
|
|
19
|
+
<!-- 内容容器:放置两份列表以实现无缝循环 -->
|
|
20
|
+
<div class="scroll-content" ref="scrollContent">
|
|
21
|
+
<ul class="name-list">
|
|
22
|
+
<li v-for="(n, idx) in names" :key="'a-' + idx" class="name-item">{{ n }}</li>
|
|
23
|
+
</ul>
|
|
24
|
+
<!-- 第二份同样的列表,用于衔接形成无缝滚动 -->
|
|
25
|
+
<ul class="name-list">
|
|
26
|
+
<li v-for="(n, idx) in names" :key="'b-' + idx" class="name-item">{{ n }}</li>
|
|
27
|
+
</ul>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div class="toolbar">
|
|
32
|
+
<el-button size="small" @click="reload">重新加载</el-button>
|
|
33
|
+
<span class="tip">鼠标移入暂停自动滚动,滚轮可上下滚动;移出恢复</span>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="last-visible">当前最后条显示的数据:<strong>{{ lastVisibleText || '-' }}</strong></div>
|
|
36
|
+
</section>
|
|
37
|
+
</template>
|
|
38
|
+
|
|
39
|
+
<script>
|
|
40
|
+
// 引入后端接口:获取名字列表
|
|
41
|
+
import { getNames } from '../api/mock'
|
|
42
|
+
|
|
43
|
+
export default {
|
|
44
|
+
name: 'NameScrollPage',
|
|
45
|
+
data() {
|
|
46
|
+
return {
|
|
47
|
+
// 名字列表数据(从后端获取)
|
|
48
|
+
names: [],
|
|
49
|
+
// 分页状态
|
|
50
|
+
page: 1,
|
|
51
|
+
pageSize: 50,
|
|
52
|
+
total: 0,
|
|
53
|
+
loadingNext: false,
|
|
54
|
+
// 是否正在自动滚动(true 表示开启)
|
|
55
|
+
autoRunning: false,
|
|
56
|
+
// 自动滚动速度:每帧增加的像素(可根据需要调整)
|
|
57
|
+
speed: 0.4,
|
|
58
|
+
// requestAnimationFrame 的任务 ID,用于取消动画
|
|
59
|
+
rafId: 0,
|
|
60
|
+
// 单份列表的高度(用于实现无缝循环)
|
|
61
|
+
listHeight: 0,
|
|
62
|
+
// 单条目高度(用于计算当前视口内的最后一条)
|
|
63
|
+
itemHeight: 0,
|
|
64
|
+
// 是否处于鼠标悬停状态(悬停时暂停自动滚动)
|
|
65
|
+
isHovering: false,
|
|
66
|
+
// 当前视口内最后一条数据的文案
|
|
67
|
+
lastVisibleText: '',
|
|
68
|
+
// 当前视口内最后一条数据的索引
|
|
69
|
+
lastVisibleIndex: 0
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
mounted() {
|
|
73
|
+
// 页面挂载后初始化:获取数据并启动自动滚动
|
|
74
|
+
this.init()
|
|
75
|
+
},
|
|
76
|
+
beforeDestroy() {
|
|
77
|
+
// 组件销毁前停止自动滚动,避免动画泄漏
|
|
78
|
+
this.stopAuto()
|
|
79
|
+
},
|
|
80
|
+
methods: {
|
|
81
|
+
/**
|
|
82
|
+
* 初始化页面:拉取名字并开始滚动
|
|
83
|
+
*/
|
|
84
|
+
async init() {
|
|
85
|
+
await this.fetchNames()
|
|
86
|
+
this.$nextTick(() => {
|
|
87
|
+
this.measure()
|
|
88
|
+
this.startAuto()
|
|
89
|
+
})
|
|
90
|
+
},
|
|
91
|
+
/**
|
|
92
|
+
* 从后端获取名字列表
|
|
93
|
+
* 参数:size 每页数量(最大200),page 页码(从1开始)
|
|
94
|
+
* 返回:数组形式的名字列表
|
|
95
|
+
*/
|
|
96
|
+
async fetchNames() {
|
|
97
|
+
const res = await getNames({ size: this.pageSize, page: this.page })
|
|
98
|
+
// 后端响应可能是 { list, total, page, pageSize } 或直接数组,做兼容处理
|
|
99
|
+
const list = (res && res.list) || res || []
|
|
100
|
+
this.names = Array.isArray(list) ? list : []
|
|
101
|
+
if (res && typeof res.total === 'number') this.total = res.total
|
|
102
|
+
if (res && typeof res.pageSize === 'number') this.pageSize = res.pageSize
|
|
103
|
+
if (res && typeof res.page === 'number') this.page = res.page
|
|
104
|
+
},
|
|
105
|
+
/**
|
|
106
|
+
* 重新测量单份列表高度
|
|
107
|
+
* 用于判断何时将 scrollTop 回跳,实现无缝循环
|
|
108
|
+
*/
|
|
109
|
+
measure() {
|
|
110
|
+
const content = this.$refs.scrollContent
|
|
111
|
+
if (!content) return
|
|
112
|
+
const lists = content.getElementsByClassName('name-list')
|
|
113
|
+
if (lists && lists[0]) {
|
|
114
|
+
this.listHeight = lists[0].offsetHeight
|
|
115
|
+
}
|
|
116
|
+
const item = content.getElementsByClassName('name-item')[0]
|
|
117
|
+
if (item) {
|
|
118
|
+
this.itemHeight = item.offsetHeight
|
|
119
|
+
}
|
|
120
|
+
this.updateLastVisible()
|
|
121
|
+
},
|
|
122
|
+
/**
|
|
123
|
+
* 启动自动滚动(基于 requestAnimationFrame)
|
|
124
|
+
* 行为:每帧将容器 scrollTop 增加 speed,超出单份高度时回跳
|
|
125
|
+
*/
|
|
126
|
+
startAuto() {
|
|
127
|
+
if (this.autoRunning) return
|
|
128
|
+
this.autoRunning = true
|
|
129
|
+
const step = () => {
|
|
130
|
+
if (!this.autoRunning) return
|
|
131
|
+
const wrapper = this.$refs.scrollWrapper
|
|
132
|
+
if (wrapper) {
|
|
133
|
+
wrapper.scrollTop += this.speed
|
|
134
|
+
// 超过单份列表高度则回跳,形成无缝循环
|
|
135
|
+
if (wrapper.scrollTop >= this.listHeight) {
|
|
136
|
+
wrapper.scrollTop -= this.listHeight
|
|
137
|
+
}
|
|
138
|
+
this.maybeLoadMore()
|
|
139
|
+
}
|
|
140
|
+
this.updateLastVisible()
|
|
141
|
+
this.rafId = window.requestAnimationFrame(step)
|
|
142
|
+
}
|
|
143
|
+
this.rafId = window.requestAnimationFrame(step)
|
|
144
|
+
},
|
|
145
|
+
/**
|
|
146
|
+
* 停止自动滚动:取消动画并置位
|
|
147
|
+
*/
|
|
148
|
+
stopAuto() {
|
|
149
|
+
this.autoRunning = false
|
|
150
|
+
if (this.rafId) {
|
|
151
|
+
window.cancelAnimationFrame(this.rafId)
|
|
152
|
+
this.rafId = 0
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
/**
|
|
156
|
+
* 鼠标移入:暂停自动滚动,进入手动滚动模式
|
|
157
|
+
*/
|
|
158
|
+
onMouseEnter() {
|
|
159
|
+
this.isHovering = true
|
|
160
|
+
this.stopAuto()
|
|
161
|
+
},
|
|
162
|
+
/**
|
|
163
|
+
* 鼠标移出:退出手动模式,恢复自动滚动
|
|
164
|
+
*/
|
|
165
|
+
onMouseLeave() {
|
|
166
|
+
this.isHovering = false
|
|
167
|
+
this.startAuto()
|
|
168
|
+
},
|
|
169
|
+
/**
|
|
170
|
+
* 鼠标滚轮事件:根据滚轮方向上下滚动
|
|
171
|
+
* 同时保持无缝循环的边界处理
|
|
172
|
+
*/
|
|
173
|
+
onWheel(e) {
|
|
174
|
+
const wrapper = this.$refs.scrollWrapper
|
|
175
|
+
if (!wrapper) return
|
|
176
|
+
const delta = e.deltaY || 0
|
|
177
|
+
wrapper.scrollTop += delta
|
|
178
|
+
// 下滚超过单份高度时回跳;上滚小于0时向上回跳
|
|
179
|
+
if (wrapper.scrollTop >= this.listHeight) {
|
|
180
|
+
wrapper.scrollTop -= this.listHeight
|
|
181
|
+
} else if (wrapper.scrollTop < 0) {
|
|
182
|
+
wrapper.scrollTop += this.listHeight
|
|
183
|
+
}
|
|
184
|
+
this.maybeLoadMore()
|
|
185
|
+
this.updateLastVisible()
|
|
186
|
+
},
|
|
187
|
+
/**
|
|
188
|
+
* 重新加载数据:重置滚动位置并恢复自动滚动
|
|
189
|
+
*/
|
|
190
|
+
async reload() {
|
|
191
|
+
await this.fetchNames()
|
|
192
|
+
this.$nextTick(() => {
|
|
193
|
+
const wrapper = this.$refs.scrollWrapper
|
|
194
|
+
if (wrapper) wrapper.scrollTop = 0
|
|
195
|
+
this.measure()
|
|
196
|
+
if (!this.isHovering) this.startAuto()
|
|
197
|
+
})
|
|
198
|
+
},
|
|
199
|
+
hasMore() {
|
|
200
|
+
return this.page * this.pageSize < this.total
|
|
201
|
+
},
|
|
202
|
+
async loadNextPage() {
|
|
203
|
+
if (this.loadingNext || !this.hasMore()) return
|
|
204
|
+
this.loadingNext = true
|
|
205
|
+
const nextPage = this.page + 1
|
|
206
|
+
try {
|
|
207
|
+
const res = await getNames({ size: this.pageSize, page: nextPage })
|
|
208
|
+
const list = (res && res.list) || []
|
|
209
|
+
if (Array.isArray(list) && list.length) {
|
|
210
|
+
this.names = [...this.names, ...list]
|
|
211
|
+
if (res && typeof res.total === 'number') this.total = res.total
|
|
212
|
+
this.page = nextPage
|
|
213
|
+
this.$nextTick(() => {
|
|
214
|
+
this.measure()
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
} finally {
|
|
218
|
+
this.loadingNext = false
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
maybeLoadMore() {
|
|
222
|
+
if (!this.hasMore()) return
|
|
223
|
+
if (this.loadingNext) return
|
|
224
|
+
const thresholdIndex = Math.floor((this.names.length || 0) * 2 / 3)
|
|
225
|
+
if (this.lastVisibleIndex >= thresholdIndex) this.loadNextPage()
|
|
226
|
+
},
|
|
227
|
+
/**
|
|
228
|
+
* 计算并更新当前视口内最后一条可见数据
|
|
229
|
+
*/
|
|
230
|
+
updateLastVisible() {
|
|
231
|
+
const wrapper = this.$refs.scrollWrapper
|
|
232
|
+
if (!wrapper || !Array.isArray(this.names) || this.names.length === 0) {
|
|
233
|
+
this.lastVisibleText = ''
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
const h = this.itemHeight || 1
|
|
237
|
+
const offset = wrapper.scrollTop || 0
|
|
238
|
+
const viewH = wrapper.clientHeight || 0
|
|
239
|
+
const endPos = offset + viewH
|
|
240
|
+
const relative = this.listHeight > 0 ? (endPos % this.listHeight) : endPos
|
|
241
|
+
let idx = Math.floor(relative / h) - 1
|
|
242
|
+
if (idx < 0) idx = 0
|
|
243
|
+
if (idx >= this.names.length) idx = this.names.length - 1
|
|
244
|
+
this.lastVisibleText = this.names[idx] || ''
|
|
245
|
+
this.lastVisibleIndex = idx
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
</script>
|
|
250
|
+
|
|
251
|
+
<style scoped>
|
|
252
|
+
.page.name-scroll-page {
|
|
253
|
+
padding: 16px;
|
|
254
|
+
}
|
|
255
|
+
.page-title {
|
|
256
|
+
font-size: 18px;
|
|
257
|
+
margin-bottom: 12px;
|
|
258
|
+
}
|
|
259
|
+
.scroll-wrapper {
|
|
260
|
+
height: 320px;
|
|
261
|
+
overflow: hidden;
|
|
262
|
+
border: 1px solid #ebeef5;
|
|
263
|
+
border-radius: 4px;
|
|
264
|
+
background: #fff;
|
|
265
|
+
}
|
|
266
|
+
.scroll-content {
|
|
267
|
+
/* 内容容器用于放置两份列表,实现无缝循环 */
|
|
268
|
+
}
|
|
269
|
+
.name-list {
|
|
270
|
+
list-style: none;
|
|
271
|
+
margin: 0;
|
|
272
|
+
padding: 0;
|
|
273
|
+
}
|
|
274
|
+
.name-item {
|
|
275
|
+
padding: 8px 12px;
|
|
276
|
+
border-bottom: 1px dashed #f0f0f0;
|
|
277
|
+
}
|
|
278
|
+
.toolbar {
|
|
279
|
+
margin-top: 12px;
|
|
280
|
+
display: flex;
|
|
281
|
+
align-items: center;
|
|
282
|
+
gap: 12px;
|
|
283
|
+
color: #606266;
|
|
284
|
+
}
|
|
285
|
+
.tip {
|
|
286
|
+
font-size: 12px;
|
|
287
|
+
}
|
|
288
|
+
.last-visible {
|
|
289
|
+
margin-top: 8px;
|
|
290
|
+
}
|
|
291
|
+
</style>
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import LineChartPage from '../pages/LineChartPage.vue'
|
|
7
7
|
import MedalSetting from '../pages/MedalSetting.vue'
|
|
8
8
|
import MeetingListPage from '../pages/MeetingListPage.vue'
|
|
9
|
+
import NameScrollPage from '../pages/NameScrollPage.vue'
|
|
9
10
|
|
|
10
11
|
// routes 将在 router/index 中被 VueRouter 使用
|
|
11
12
|
export const routes = [
|
|
@@ -49,5 +50,15 @@ export const routes = [
|
|
|
49
50
|
icon: 'el-icon-date',
|
|
50
51
|
showInMenu: true
|
|
51
52
|
}
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
path: '/name-scroll',
|
|
56
|
+
name: 'NameScroll',
|
|
57
|
+
component: NameScrollPage,
|
|
58
|
+
meta: {
|
|
59
|
+
title: '名字滚动',
|
|
60
|
+
icon: 'el-icon-user',
|
|
61
|
+
showInMenu: true
|
|
62
|
+
}
|
|
52
63
|
}
|
|
53
64
|
]
|
package/package.json
CHANGED
|
@@ -1,12 +1,37 @@
|
|
|
1
1
|
import { Request, Response } from "express";
|
|
2
2
|
|
|
3
|
-
import { buildMockUser, buildYearTrend } from "../services/mock.service";
|
|
3
|
+
import { buildMockUser, buildYearTrend, buildNameList } from "../services/mock.service";
|
|
4
|
+
|
|
5
|
+
function toInt(value: unknown, fallback: number): number {
|
|
6
|
+
if (typeof value === "number" && Number.isFinite(value)) return Math.floor(value);
|
|
7
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
8
|
+
const parsed = Number.parseInt(value, 10);
|
|
9
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
10
|
+
}
|
|
11
|
+
return fallback;
|
|
12
|
+
}
|
|
13
|
+
|
|
4
14
|
class MockController {
|
|
5
15
|
getMockUser = (_req: Request, res: Response): void => {
|
|
6
16
|
res.json({
|
|
7
17
|
data: buildMockUser()
|
|
8
18
|
});
|
|
9
19
|
};
|
|
20
|
+
getNames = (req: Request, res: Response): void => {
|
|
21
|
+
const pageSize = Math.max(1, Math.min(200, toInt(req.query.size, 10)));
|
|
22
|
+
const page = Math.max(1, toInt(req.query.page, 1));
|
|
23
|
+
const start = (page - 1) * pageSize;
|
|
24
|
+
const list = buildNameList(pageSize, start);
|
|
25
|
+
const total = 100000;
|
|
26
|
+
res.json({
|
|
27
|
+
data: {
|
|
28
|
+
list,
|
|
29
|
+
total,
|
|
30
|
+
page,
|
|
31
|
+
pageSize
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
};
|
|
10
35
|
getTrend = (req: Request, res: Response): void => {
|
|
11
36
|
const rawYear = (req.body as { year?: unknown })?.year;
|
|
12
37
|
const year = typeof rawYear === "string" ? Number.parseInt(rawYear, 10) : typeof rawYear === "number" ? rawYear : NaN;
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
|
|
3
3
|
const FIRST_NAMES = ["Alex", "Sam", "Taylor", "Jordan", "Morgan", "Casey", "Riley", "Avery"];
|
|
4
4
|
const LAST_NAMES = ["Li", "Wang", "Zhang", "Chen", "Liu", "Yang", "Zhao", "Huang"];
|
|
5
|
+
const DISPLAY_NAMES = ["张三", "李四", "王五", "赵六", "孙七", "周八", "吴九", "郑十"];
|
|
5
6
|
|
|
6
7
|
export function buildMockUser(): { id: string; name: string; email: string; age: number; createdAt: string } {
|
|
7
8
|
const firstName = pick(FIRST_NAMES);
|
|
@@ -20,6 +21,18 @@ export function buildMockUser(): { id: string; name: string; email: string; age:
|
|
|
20
21
|
};
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
export function buildNameList(count: number, offset = 0): string[] {
|
|
25
|
+
const list: string[] = [];
|
|
26
|
+
const base = DISPLAY_NAMES;
|
|
27
|
+
const baseLen = base.length || 1;
|
|
28
|
+
for (let i = 0; i < count; i += 1) {
|
|
29
|
+
const index = offset + i;
|
|
30
|
+
const name = base[index % baseLen];
|
|
31
|
+
list.push(`${name}${index + 1}`);
|
|
32
|
+
}
|
|
33
|
+
return list;
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
export function buildYearTrend(year: number): Array<{ label: string; value: number | string }> {
|
|
24
37
|
const result: Array<{ label: string; value: number | string }> = [];
|
|
25
38
|
let current = new Date(Date.UTC(year, 0, 1));
|