arms-app 1.0.77 → 1.0.78

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/view/5.d +422 -0
  3. package/view/2.d +0 -531
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arms-app",
3
- "version": "1.0.77",
3
+ "version": "1.0.78",
4
4
  "description": "一个基于 Express 的 Web 应用1",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/view/5.d ADDED
@@ -0,0 +1,422 @@
1
+ <template>
2
+ <el-dialog
3
+ title="发放"
4
+ :visible.sync="dialogVisible"
5
+ width="980px"
6
+ :close-on-click-modal="false"
7
+ @close="onClose"
8
+ >
9
+ <div class="issue-body">
10
+ <div class="issue-col">
11
+ <div class="issue-col-head">
12
+ <div class="issue-col-title">待选列表</div>
13
+ <div class="issue-col-count">
14
+ {{ issueLeftSelected.length }}/{{ issueLeftTotal }}
15
+ </div>
16
+ </div>
17
+ <el-input
18
+ v-model="issueLeftKeyword"
19
+ size="small"
20
+ placeholder="请输入"
21
+ clearable
22
+ prefix-icon="el-icon-search"
23
+ class="issue-search"
24
+ />
25
+ <el-table
26
+ ref="issueLeftTable"
27
+ :data="issueLeftPageRows"
28
+ border
29
+ stripe
30
+ size="small"
31
+ height="380"
32
+ @selection-change="onIssueLeftSelectionChange"
33
+ >
34
+ <el-table-column type="selection" width="46" />
35
+ <el-table-column label="人员">
36
+ <template slot-scope="scope">
37
+ {{ formatUserLabel(scope.row) }}
38
+ </template>
39
+ </el-table-column>
40
+ </el-table>
41
+ <div class="issue-pagination">
42
+ <el-pagination
43
+ small
44
+ :current-page="issueLeftPage"
45
+ :page-size="issueLeftPageSize"
46
+ layout="total, prev, pager, next"
47
+ :total="issueLeftTotal"
48
+ @current-change="onIssueLeftPageChange"
49
+ />
50
+ </div>
51
+ </div>
52
+
53
+ <div class="issue-transfer">
54
+ <el-button
55
+ size="mini"
56
+ type="primary"
57
+ :disabled="issueLeftSelected.length === 0"
58
+ @click="onIssueMoveToRight"
59
+ >
60
+ <i class="el-icon-arrow-right" />
61
+ </el-button>
62
+ <el-button
63
+ size="mini"
64
+ type="primary"
65
+ :disabled="issueRightSelected.length === 0"
66
+ @click="onIssueMoveToLeft"
67
+ >
68
+ <i class="el-icon-arrow-left" />
69
+ </el-button>
70
+ </div>
71
+
72
+ <div class="issue-col">
73
+ <div class="issue-col-head">
74
+ <div class="issue-col-title">当前佩戴</div>
75
+ <div class="issue-col-count">
76
+ {{ issueSelectedUsers.length }}/{{ limitCount === -1 ? '不限' : limitCount }}
77
+ </div>
78
+ </div>
79
+ <el-input
80
+ v-model="issueRightKeyword"
81
+ size="small"
82
+ placeholder="请输入"
83
+ clearable
84
+ prefix-icon="el-icon-search"
85
+ class="issue-search"
86
+ />
87
+ <el-table
88
+ ref="issueRightTable"
89
+ :data="issueRightPageRows"
90
+ border
91
+ stripe
92
+ size="small"
93
+ height="380"
94
+ @selection-change="onIssueRightSelectionChange"
95
+ >
96
+ <el-table-column type="selection" width="46" />
97
+ <el-table-column label="人员">
98
+ <template slot-scope="scope">
99
+ {{ formatUserLabel(scope.row) }}
100
+ </template>
101
+ </el-table-column>
102
+ </el-table>
103
+ <div class="issue-pagination">
104
+ <el-pagination
105
+ small
106
+ :current-page="issueRightPage"
107
+ :page-size="issueRightPageSize"
108
+ layout="total, prev, pager, next"
109
+ :total="issueRightTotal"
110
+ @current-change="onIssueRightPageChange"
111
+ />
112
+ </div>
113
+ </div>
114
+ </div>
115
+ <span slot="footer" class="dialog-footer">
116
+ <el-button size="small" @click="onCancel">取消</el-button>
117
+ <el-button size="small" type="primary" @click="onConfirm">
118
+ 发放
119
+ </el-button>
120
+ </span>
121
+ </el-dialog>
122
+ </template>
123
+
124
+ <script>
125
+ import { get } from "../utils/request";
126
+
127
+ /**
128
+ * 勋章发放弹窗组件
129
+ * 用于管理勋章的发放对象,支持左右穿梭框选择用户
130
+ */
131
+ export default {
132
+ name: "MedalIssueDialog",
133
+ props: {
134
+ // 控制弹窗显示
135
+ visible: {
136
+ type: Boolean,
137
+ default: false
138
+ },
139
+ limitCount: {
140
+ type: Number,
141
+ default: -1
142
+ },
143
+ // 当前操作的勋章对象
144
+ medal: {
145
+ type: Object,
146
+ default: null
147
+ }
148
+ },
149
+ data() {
150
+ return {
151
+ issueUserPool: [], // 所有可用用户列表
152
+ issueSelectedUsers: [], // 当前勋章已选中的用户列表
153
+ issueLeftKeyword: "", // 左侧(待选)搜索关键字
154
+ issueRightKeyword: "", // 右侧(已选)搜索关键字
155
+ issueLeftPage: 1, // 左侧当前页码
156
+ issueRightPage: 1, // 右侧当前页码
157
+ issueLeftPageSize: 10, // 左侧每页显示数量
158
+ issueRightPageSize: 10, // 右侧每页显示数量
159
+ issueLeftSelected: [], // 左侧表格当前勾选的行(准备移入右侧)
160
+ issueRightSelected: [] // 右侧表格当前勾选的行(准备移出右侧)
161
+ };
162
+ },
163
+ computed: {
164
+ dialogVisible: {
165
+ get() {
166
+ return this.visible;
167
+ },
168
+ set(val) {
169
+ this.$emit("update:visible", val);
170
+ }
171
+ },
172
+ // 左侧待选列表总数
173
+ issueLeftTotal() {
174
+ return this.issueLeftFiltered.length;
175
+ },
176
+ // 右侧已选列表总数
177
+ issueRightTotal() {
178
+ return this.issueRightFiltered.length;
179
+ },
180
+ // 左侧过滤后的列表(排除已选用户,并根据关键字过滤)
181
+ issueLeftFiltered() {
182
+ const selectedSet = new Set((this.issueSelectedUsers || []).map(u => u.userNo));
183
+ const keyword = (this.issueLeftKeyword || "").trim();
184
+ const pool = Array.isArray(this.issueUserPool) ? this.issueUserPool : [];
185
+ // 过滤掉已选中的用户
186
+ const list = pool.filter(u => !selectedSet.has(u.userNo));
187
+ if (!keyword) return list;
188
+ // 根据关键字过滤
189
+ return list.filter(u => this.formatUserLabel(u).includes(keyword));
190
+ },
191
+ // 右侧过滤后的列表(根据关键字过滤)
192
+ issueRightFiltered() {
193
+ const keyword = (this.issueRightKeyword || "").trim();
194
+ const list = Array.isArray(this.issueSelectedUsers) ? this.issueSelectedUsers : [];
195
+ if (!keyword) return list;
196
+ return list.filter(u => this.formatUserLabel(u).includes(keyword));
197
+ },
198
+ // 左侧当前页显示的数据
199
+ issueLeftPageRows() {
200
+ const start = (this.issueLeftPage - 1) * this.issueLeftPageSize;
201
+ return this.issueLeftFiltered.slice(start, start + this.issueLeftPageSize);
202
+ },
203
+ // 右侧当前页显示的数据
204
+ issueRightPageRows() {
205
+ const start = (this.issueRightPage - 1) * this.issueRightPageSize;
206
+ return this.issueRightFiltered.slice(start, start + this.issueRightPageSize);
207
+ }
208
+ },
209
+ watch: {
210
+ // 监听 visible 变化,为 true 时初始化数据
211
+ visible: {
212
+ handler(val) {
213
+ if (val) this.init();
214
+ console.log(this.medal)
215
+ },
216
+ immediate: true
217
+ }
218
+ },
219
+ methods: {
220
+ /**
221
+ * 初始化数据
222
+ * 1. 确保用户池数据已加载
223
+ * 2. 根据服务端返回的已发放用户初始化选中状态
224
+ */
225
+ async init() {
226
+ const serverIssuedUsers = await this.ensureIssueUserPool();
227
+ const row = this.medal;
228
+ if (!row) return;
229
+
230
+ this.issueSelectedUsers = (serverIssuedUsers || []).map(u => ({ ...u }));
231
+
232
+ // 重置搜索和分页状态
233
+ this.issueLeftKeyword = "";
234
+ this.issueRightKeyword = "";
235
+ this.issueLeftPage = 1;
236
+ this.issueRightPage = 1;
237
+ this.issueLeftSelected = [];
238
+ this.issueRightSelected = [];
239
+ // 清空表格选中状态
240
+ this.$nextTick(() => {
241
+ this.$refs.issueLeftTable && this.$refs.issueLeftTable.clearSelection && this.$refs.issueLeftTable.clearSelection();
242
+ this.$refs.issueRightTable && this.$refs.issueRightTable.clearSelection && this.$refs.issueRightTable.clearSelection();
243
+ });
244
+ },
245
+ // 格式化用户显示名称 (姓名/工号)
246
+ formatUserLabel(user) {
247
+ if (!user) return "";
248
+ const name = user.displayName || "";
249
+ const no = user.userNo || "";
250
+ if (name && no) return `${name}/${no}`;
251
+ return name || no || "";
252
+ },
253
+ // 确保加载所有用户列表数据,并返回已发放用户
254
+ async ensureIssueUserPool() {
255
+ try {
256
+ const res = await get('/medal/users');
257
+ if (res) {
258
+ // 兼容新旧接口结构
259
+ if (res.allUsers) {
260
+ this.issueUserPool = res.allUsers;
261
+ return res.issuedUsers || [];
262
+ } else if (Array.isArray(res)) {
263
+ this.issueUserPool = res;
264
+ return [];
265
+ }
266
+ }
267
+ } catch (err) {
268
+ console.error(err);
269
+ this.$message.error('获取用户列表失败');
270
+ }
271
+ return [];
272
+ },
273
+ // 关闭弹窗时的处理
274
+ onClose() {
275
+ this.issueLeftSelected = [];
276
+ this.issueRightSelected = [];
277
+ this.$nextTick(() => {
278
+ this.$refs.issueLeftTable && this.$refs.issueLeftTable.clearSelection && this.$refs.issueLeftTable.clearSelection();
279
+ this.$refs.issueRightTable && this.$refs.issueRightTable.clearSelection && this.$refs.issueRightTable.clearSelection();
280
+ });
281
+ this.$emit('update:visible', false);
282
+ },
283
+ // 点击取消
284
+ onCancel() {
285
+ this.$emit('update:visible', false);
286
+ },
287
+ // 点击确定,校验并发射 confirm 事件
288
+ onConfirm() {
289
+ const medal = this.medal;
290
+ if (!medal) return;
291
+ const total = this.issueSelectedUsers.length;
292
+ // 校验限量勋章的数量限制
293
+ if (this.limitCount !== -1 && total > this.limitCount) {
294
+ this.$message({
295
+ type: "warning",
296
+ message: `超出发放数量上限(上限:${this.limitCount})`
297
+ });
298
+ return;
299
+ }
300
+
301
+ this.$emit('confirm', {
302
+ medal,
303
+ users: this.issueSelectedUsers
304
+ });
305
+ },
306
+ // 左侧表格选中变化
307
+ onIssueLeftSelectionChange(list) {
308
+ this.issueLeftSelected = Array.isArray(list) ? list.slice() : [];
309
+ },
310
+ // 右侧表格选中变化
311
+ onIssueRightSelectionChange(list) {
312
+ this.issueRightSelected = Array.isArray(list) ? list.slice() : [];
313
+ },
314
+ // 将左侧选中用户移动到右侧(添加)
315
+ onIssueMoveToRight() {
316
+ const selected = Array.isArray(this.issueLeftSelected) ? this.issueLeftSelected : [];
317
+ if (selected.length === 0) return;
318
+
319
+ // 预先计算添加后的总数
320
+ const currentCount = this.issueSelectedUsers.length;
321
+ // 过滤掉已经在右侧的用户,计算实际新增数量
322
+ const existingIds = new Set(this.issueSelectedUsers.map(u => u.userNo));
323
+ const newUsers = selected.filter(u => !existingIds.has(u.userNo));
324
+
325
+ if (this.limitCount !== -1 && (currentCount + newUsers.length) > this.limitCount) {
326
+ this.$message({
327
+ type: "warning",
328
+ message: `选择的用户数量超出限制(上限:${this.limitCount},当前已选:${currentCount},尝试添加:${newUsers.length})`
329
+ });
330
+ return;
331
+ }
332
+
333
+ const map = new Map((this.issueSelectedUsers || []).map(u => [u.userNo, u]));
334
+ selected.forEach(u => {
335
+ if (!u || !u.userNo) return;
336
+ if (!map.has(u.userNo)) map.set(u.userNo, { ...u });
337
+ });
338
+ this.issueSelectedUsers = Array.from(map.values());
339
+ this.issueLeftSelected = [];
340
+ this.$nextTick(() => {
341
+ this.$refs.issueLeftTable && this.$refs.issueLeftTable.clearSelection && this.$refs.issueLeftTable.clearSelection();
342
+ });
343
+ // 调整分页如果当前页为空
344
+ const maxPage = Math.max(1, Math.ceil(this.issueLeftTotal / this.issueLeftPageSize));
345
+ if (this.issueLeftPage > maxPage) this.issueLeftPage = maxPage;
346
+ },
347
+ // 将右侧选中用户移动到左侧(移除)
348
+ onIssueMoveToLeft() {
349
+ const selected = Array.isArray(this.issueRightSelected) ? this.issueRightSelected : [];
350
+ if (selected.length === 0) return;
351
+ const removeSet = new Set(selected.map(u => u.userNo));
352
+ this.issueSelectedUsers = (this.issueSelectedUsers || []).filter(u => !removeSet.has(u.userNo));
353
+ this.issueRightSelected = [];
354
+ this.$nextTick(() => {
355
+ this.$refs.issueRightTable && this.$refs.issueRightTable.clearSelection && this.$refs.issueRightTable.clearSelection();
356
+ });
357
+ // 调整分页
358
+ const maxPage = Math.max(1, Math.ceil(this.issueRightTotal / this.issueRightPageSize));
359
+ if (this.issueRightPage > maxPage) this.issueRightPage = maxPage;
360
+ },
361
+ // 左侧分页变化
362
+ onIssueLeftPageChange(page) {
363
+ this.issueLeftPage = page;
364
+ this.issueLeftSelected = [];
365
+ this.$nextTick(() => {
366
+ this.$refs.issueLeftTable && this.$refs.issueLeftTable.clearSelection && this.$refs.issueLeftTable.clearSelection();
367
+ });
368
+ },
369
+ // 右侧分页变化
370
+ onIssueRightPageChange(page) {
371
+ this.issueRightPage = page;
372
+ this.issueRightSelected = [];
373
+ this.$nextTick(() => {
374
+ this.$refs.issueRightTable && this.$refs.issueRightTable.clearSelection && this.$refs.issueRightTable.clearSelection();
375
+ });
376
+ }
377
+ }
378
+ };
379
+ </script>
380
+
381
+ <style scoped>
382
+ .issue-body {
383
+ display: flex;
384
+ align-items: flex-start;
385
+ gap: 12px;
386
+ }
387
+ .issue-col {
388
+ flex: 1;
389
+ min-width: 0;
390
+ }
391
+ .issue-col-head {
392
+ display: flex;
393
+ align-items: center;
394
+ justify-content: space-between;
395
+ margin-bottom: 8px;
396
+ }
397
+ .issue-col-title {
398
+ font-size: 14px;
399
+ color: #303133;
400
+ }
401
+ .issue-col-count {
402
+ font-size: 13px;
403
+ color: #909399;
404
+ }
405
+ .issue-search {
406
+ margin-bottom: 10px;
407
+ }
408
+ .issue-transfer {
409
+ width: 72px;
410
+ flex-shrink: 0;
411
+ display: flex;
412
+ flex-direction: column;
413
+ align-items: center;
414
+ justify-content: center;
415
+ gap: 10px;
416
+ }
417
+ .issue-pagination {
418
+ margin-top: 10px;
419
+ display: flex;
420
+ justify-content: center;
421
+ }
422
+ </style>
package/view/2.d DELETED
@@ -1,531 +0,0 @@
1
- <template>
2
- <div class="page">
3
- <!-- 年份选择工具栏,切换不同年份的趋势数据 -->
4
- <div class="toolbar">
5
- <el-select v-model="selectedYear" size="small" @change="onYearChange('hand')">
6
- <el-option v-for="y in yearOptions" :key="y" :label="y + '年'" :value="y" />
7
- </el-select>
8
- <el-select v-model="selectedDate" size="small" style="margin-left:8px" @change="onDateChange('hand')">
9
- <el-option v-for="d in xAxis" :key="d" :label="d" :value="d" />
10
- </el-select>
11
- <!-- 当前视图最后一条数据展示区 -->
12
- <div class="info">
13
- <span>最后数据:</span>
14
- <strong>{{ lastLabel || '-' }}</strong>
15
- <span>值:</span>
16
- <strong>{{ lastValue === null ? '-' : lastValue }}</strong>
17
- </div>
18
- </div>
19
-
20
- <!-- 折线图容器 -->
21
- <div ref="chartRef" class="chart"></div>
22
- </div>
23
- </template>
24
-
25
- <script>
26
- // 使用 ECharts 绘制折线图
27
- import * as echarts from 'echarts'
28
- // 调用后端接口获取指定年份的每日趋势数据
29
- import { getTrend } from '../api/mock'
30
-
31
- export default {
32
- name: 'LineChartPage',
33
- data() {
34
- return {
35
- /**
36
- * ECharts 图表实例对象
37
- * 用于执行 setOption、resize、dispose 等操作
38
- */
39
- chartInstance: null,
40
-
41
- /**
42
- * 当前选中的年份
43
- * 初始化默认为当前系统年份
44
- */
45
- selectedYear: new Date().getFullYear(),
46
-
47
- /**
48
- * 可选年份列表
49
- * 用于下拉框筛选数据,目前静态配置了 2020-2026 年
50
- */
51
- yearOptions: [2020, 2021, 2022, 2023, 2024, 2025, 2026],
52
-
53
- /**
54
- * 固定视口宽度(单位:天)
55
- * 核心配置项:决定了图表中一次性展示多少天的数据
56
- * 配合 dataZoom 事件逻辑,强制保持视窗大小不变,仅允许左右平移
57
- */
58
- windowSize: 31,
59
-
60
- /**
61
- * 数据总点数
62
- * 即 xAxis/series 数组的长度,用于计算 dataZoom 的百分比与索引映射
63
- * 在 loadData/extendLeft/extendRight 时会更新此值
64
- */
65
- totalPoints: 0,
66
-
67
- /**
68
- * 当前视窗的数据区间状态
69
- * @property {number} startValue - 视窗起始数据索引(包含)
70
- * @property {number} endValue - 视窗结束数据索引(包含)
71
- * 始终满足:endValue = startValue + windowSize - 1
72
- * 用于在 slider(底部滑块)和 inside(鼠标拖拽)两种交互模式间同步状态
73
- */
74
- dataZoomState: { startValue: 0, endValue: 30 },
75
-
76
- /**
77
- * x轴数据缓存(日期字符串数组)
78
- * 格式示例:['2023-01-01', '2023-01-02', ...]
79
- * 会随着左右滑动触发的数据动态加载(拼接)而增长
80
- */
81
- xAxis: [],
82
-
83
- /**
84
- * y轴数据缓存(数值数组)
85
- * 与 xAxis 一一对应
86
- */
87
- series: [],
88
-
89
- /**
90
- * 当前选中的具体日期
91
- * 对应顶部日期下拉框的值,与图表视窗联动
92
- */
93
- selectedDate: '',
94
- isExtendingRight: false,
95
-
96
- /**
97
- * 视窗最右侧(最新)数据的日期标签
98
- * 用于顶部“最后数据”展示区
99
- */
100
- lastLabel: '',
101
-
102
- /**
103
- * 视窗最右侧(最新)数据的数值
104
- * 用于顶部“最后数据”展示区
105
- */
106
- lastValue: null
107
- }
108
- },
109
- watch: {
110
- lastLabel(newVal) {
111
- if (newVal) {
112
- this.selectedYear = parseInt(newVal.slice(0, 4))
113
- this.selectedDate = newVal
114
- }
115
- }
116
- },
117
- mounted() {
118
- // 初始化时拉取默认年份的数据
119
- // 判断当前年份的天数是否已经满足31天。如果不满足需要请求前一年的数据和当前年份数据合并
120
- this.loadData()
121
- window.addEventListener('resize', this.handleResize)
122
- },
123
- beforeDestroy() {
124
- window.removeEventListener('resize', this.handleResize)
125
- if (this.chartInstance) {
126
- this.chartInstance.dispose()
127
- this.chartInstance = null
128
- }
129
- },
130
- methods: {
131
- // 拉取指定年份的趋势数据,并初始化图表(含初次拼接相邻年份)
132
- async loadData(year = this.selectedYear) {
133
- const merged = await this.assembleInitialData(year)
134
- const x = merged.map(i => i.label)
135
- const y = merged.map(i => i.value)
136
- this.xAxis = x
137
- this.series = y
138
- this.initChart(x, y, year)
139
- this.selectedDate = x.length ? x[x.length - 1] : ''
140
- },
141
- // 年份变化时重新加载数据
142
- onYearChange(type) {
143
- if (type === 'hand') {
144
- if (this.chartInstance) {
145
- this.chartInstance.dispose()
146
- this.chartInstance = null
147
- }
148
- this.xAxis = []
149
- this.series = []
150
- this.totalPoints = 0
151
- this.dataZoomState = { startValue: 0, endValue: this.windowSize - 1 }
152
- this.lastLabel = ''
153
- this.lastValue = null
154
- this.loadData(this.selectedYear)
155
- }
156
-
157
- },
158
- /**
159
- * 初始化图表并应用固定 31 天视口窗口
160
- *
161
- * 参数说明:
162
- * @param {string[]} xAxisData - 全年每日的类目轴标签数组,按天排列。
163
- * - 期望格式:YYYY-MM-DD(显示时可能裁剪为 MM-DD 以提升密度)
164
- * - 长度:与 seriesData 等长
165
- * @param {number[]} seriesData - 与 xAxisData 一一对应的数值数据序列。
166
- * - 每个元素代表对应日期的数值
167
- * - 长度:与 xAxisData 等长
168
- * @param {number} year - 当前选择的年份,用于设置图表标题与语义。
169
- *
170
- * 行为与步骤:
171
- * 1) 销毁已有 ECharts 实例(若存在),避免内存泄漏与配置残留
172
- * 2) 初始化新实例,并记录数据总长度 totalPoints
173
- * 3) 设置初始 dataZoomState 区间为 [0, windowSize-1],窗口宽度固定为 31 天
174
- * 4) 构建并设置 option:
175
- * - title/tooltip:基本信息与提示
176
- * - grid:为密集标签与滑块预留空间
177
- * - dataZoom:包含 slider 与 inside,开启 zoomLock,禁止缩放,仅允许平移
178
- * - xAxis/yAxis:类目轴与数值轴,xAxis 的标签密度与格式已优化
179
- * - series:折线图数据(平滑)
180
- * 5) 绑定 dataZoom 事件:根据用户拖动自动维持窗口固定宽度,并做边界约束
181
- * 6) 调用 updateLastViewPoint:更新顶部“最后数据”展示(日期与值)
182
- *
183
- * 前置条件:
184
- * - xAxisData.length === seriesData.length
185
- * - windowSize <= xAxisData.length
186
- *
187
- * 副作用:
188
- * - 更新 this.chartInstance / this.totalPoints / this.dataZoomState
189
- * - 更新 lastLabel / lastValue(顶部展示区)
190
- *
191
- * 返回值:无(void)
192
- */
193
- // 初始化图表:根据所选年份定位初始视口结束位置,并绑定固定窗口 dataZoom 行为
194
- initChart(xAxisData, seriesData, year) {
195
- if (this.chartInstance) {
196
- this.chartInstance.dispose()
197
- }
198
-
199
- this.chartInstance = echarts.init(this.$refs.chartRef)
200
- this.totalPoints = xAxisData.length
201
- let initialEnd = this.computeInitialWindowEnd(xAxisData, year)
202
- const initialStart = Math.max(0, initialEnd - (this.windowSize - 1))
203
- this.dataZoomState = { startValue: initialStart, endValue: initialEnd }
204
-
205
- const option = {
206
- // title: {
207
- // text: year + ' 年每日趋势'
208
- // },
209
- tooltip: {
210
- trigger: 'axis'
211
- },
212
- grid: { left: 60, right: 60, bottom: 80, top: 60, containLabel: true },
213
- // 仅在视口内展示 31 天数据,可通过底部滑动条左右滑动查看全年
214
- // slider:底部可视化拖动条;inside:鼠标滚轮/手势缩放
215
- // 通过 startValue/endValue 控制初始显示区间(0-30 共 31 天)
216
- dataZoom: [
217
- {
218
- type: 'slider', // 底部滑块式数据缩放控件
219
- zoomLock: true, // 锁定缩放,仅允许平移窗口
220
- startValue: this.dataZoomState.startValue, // 初始窗口起始索引
221
- endValue: this.dataZoomState.endValue // 初始窗口结束索引
222
- },
223
- {
224
- type: 'inside', // 内置交互式 dataZoom(滚轮/触摸)
225
- zoomLock: true, // 锁定缩放,仅允许平移
226
- zoomOnMouseWheel: false, // 禁止滚轮缩放
227
- moveOnMouseWheel: true, // 允许滚轮左右平移视口
228
- zoomOnPinch: false, // 禁止触摸捏合缩放
229
- startValue: this.dataZoomState.startValue, // 初始窗口起始索引(与 slider 同步)
230
- endValue: this.dataZoomState.endValue // 初始窗口结束索引(与 slider 同步)
231
- }
232
- ],
233
- xAxis: { // 横轴:类目轴(日期)
234
- type: 'category', // 使用类目轴
235
- data: xAxisData, // 横轴数据(日期列表)
236
- axisLabel: { // 坐标轴标签样式
237
- interval: 0, // 强制显示所有标签
238
- rotate: 45, // 标签旋转 45 度,避免重叠
239
- fontSize: 10, // 标签字体大小
240
- margin: 6, // 标签与轴线间距
241
- showMinLabel: true, // 显示首个标签
242
- showMaxLabel: true, // 显示最后一个标签
243
- formatter: function (value) { // 标签文本格式化
244
- return typeof value === 'string' && value.length >= 10 ? value.slice(5) : value // 长日期仅保留“MM-DD”
245
- }
246
- }
247
- },
248
- yAxis: { // 纵轴:数值轴
249
- type: 'value' // 数值类型
250
- },
251
- series: [
252
- {
253
- name: '值', // 序列名称
254
- type: 'line', // 折线图
255
- smooth: true, // 使用平滑曲线
256
- showSymbol: true,
257
- label: {
258
- show: true,
259
- position: 'top',
260
- formatter: function (params) {
261
- return params.value
262
- }
263
- },
264
- data: seriesData // 序列数据
265
- }
266
- ]
267
- }
268
-
269
- this.chartInstance.setOption(option)
270
-
271
- this.chartInstance.off('dataZoom')
272
- this.chartInstance.on('dataZoom', async (params) => {
273
- // 固定窗口宽度的核心逻辑:
274
- // 1. 将 dataZoom 回调中的百分比或显式索引统一转换为 start/end 索引
275
- // 2. 判断用户是“移动 start”还是“移动 end”,相应地调整另一端保持 windowSize 不变
276
- // 3. 做边界约束,确保 0 <= start <= end <= totalPoints - 1
277
- // 4. 若区间有变化,使用 dispatchAction 同步两个 dataZoom(slider 与 inside),silent 避免死循环
278
- const raw = Array.isArray(params.batch) && params.batch.length ? params.batch[0] : params
279
- const startPercent = typeof raw.start === 'number' ? raw.start : 0
280
- const endPercentDefault = (this.dataZoomState.endValue / (this.totalPoints - 1)) * 100
281
- const endPercent = typeof raw.end === 'number' ? raw.end : endPercentDefault
282
- let start = Number.isFinite(raw.startValue)
283
- ? raw.startValue
284
- : Math.round((startPercent / 100) * (this.totalPoints - 1))
285
- let end = Number.isFinite(raw.endValue)
286
- ? raw.endValue
287
- : Math.round((endPercent / 100) * (this.totalPoints - 1))
288
-
289
- const prev = this.dataZoomState
290
- const movedStart = Math.abs(start - prev.startValue)
291
- const movedEnd = Math.abs(end - prev.endValue)
292
-
293
- if (movedStart >= movedEnd) {
294
- end = start + (this.windowSize - 1)
295
- } else {
296
- start = end - (this.windowSize - 1)
297
- }
298
-
299
- if (end > this.totalPoints - 1) {
300
- end = this.totalPoints - 1
301
- start = end - (this.windowSize - 1)
302
- }
303
- if (start < 0) {
304
- start = 0
305
- end = start + (this.windowSize - 1)
306
- }
307
-
308
- // 触达左边界时尝试向左扩展(不小于 yearOptions[0])
309
- const leftRes = await this.extendLeftIfNeeded(start, end)
310
- start = leftRes.start
311
- end = leftRes.end
312
-
313
- // 触达右边界时尝试向右扩展(不超过“今天”)
314
- const rightRes = await this.extendRightIfNeeded(start, end)
315
- start = rightRes.start
316
- end = rightRes.end
317
-
318
- if (start !== prev.startValue || end !== prev.endValue) {
319
- this.dataZoomState = { startValue: start, endValue: end }
320
- this.chartInstance.dispatchAction({
321
- type: 'dataZoom',
322
- dataZoomIndex: 0,
323
- startValue: start,
324
- endValue: end,
325
- silent: true
326
- })
327
- this.chartInstance.dispatchAction({
328
- type: 'dataZoom',
329
- dataZoomIndex: 1,
330
- startValue: start,
331
- endValue: end,
332
- silent: true
333
- })
334
- }
335
- this.updateLastViewPoint()
336
- })
337
- this.updateLastViewPoint()
338
- },
339
- // 计算 YYYY-MM-DD 的“今天”标签(UTC)
340
- todayLabel() {
341
- const now = new Date()
342
- const y = now.getUTCFullYear()
343
- const m = String(now.getUTCMonth() + 1).padStart(2, '0')
344
- const d = String(now.getUTCDate()).padStart(2, '0')
345
- return `${y}-${m}-${d}`
346
- },
347
- // 初次加载时的合并策略:
348
- // - 选择小于当前年的年份:合并下一年全年,支持右滑
349
- // - 选择当前年:若不足 windowSize,则拼接上一年填满窗口
350
- async assembleInitialData(year) {
351
- const nowYear = new Date().getFullYear()
352
- const list = await getTrend(year)
353
- if (year < nowYear) {
354
- const next = await getTrend(year + 1)
355
- const nextAll = Array.isArray(next) ? next : []
356
- return [...list, ...nextAll]
357
- }
358
- if (year === nowYear && Array.isArray(list) && list.length < this.windowSize) {
359
- const prev = await getTrend(year - 1)
360
- const prevAll = Array.isArray(prev) ? prev : []
361
- return [...prevAll, ...list]
362
- }
363
- return list
364
- },
365
- // 计算初始视口结束索引:若选择非当前年,则落在该年的最后一天或最后一个该年数据点
366
- computeInitialWindowEnd(xAxisData, year) {
367
- let end = Math.max(0, xAxisData.length - 1)
368
- const nowYear = new Date().getFullYear()
369
- if (year < nowYear) {
370
- const target = `${year}-12-31`
371
- let idx = xAxisData.lastIndexOf(target)
372
- if (idx === -1) {
373
- for (let i = xAxisData.length - 1; i >= 0; i--) {
374
- const v = xAxisData[i]
375
- if (typeof v === 'string' && v.slice(0, 4) === String(year)) {
376
- idx = i
377
- break
378
- }
379
- }
380
- }
381
- if (idx !== -1) end = idx
382
- }
383
- return end
384
- },
385
- // 扩展左侧:在起点触达时,将更早年份数据前置拼接,并保持窗口位置不变
386
- async extendLeftIfNeeded(start, end) {
387
- const minYear = Array.isArray(this.yearOptions) && this.yearOptions.length ? this.yearOptions[0] : 1970
388
- if (start > 0 || !Array.isArray(this.xAxis) || this.xAxis.length === 0) return { start, end }
389
- const firstLabel = this.xAxis[0]
390
- const firstYear = this.parseYear(firstLabel)
391
- if (!Number.isFinite(firstYear) || firstYear <= minYear) return { start, end }
392
- const prevData = await getTrend(firstYear - 1)
393
- const prevLen = Array.isArray(prevData) ? prevData.length : 0
394
- if (prevLen <= 0) return { start, end }
395
- const prevX = prevData.map(i => i.label)
396
- const prevY = prevData.map(i => i.value)
397
- this.appendData(prevX, prevY, 'left')
398
- return { start: start + prevLen, end: end + prevLen }
399
- },
400
- // 扩展右侧:在末尾触达时,按需追加下一年或当前年剩余(不超过“今天”)
401
- async extendRightIfNeeded(start, end) {
402
- if (end < this.totalPoints - 1 || !Array.isArray(this.xAxis) || this.xAxis.length === 0) return { start, end }
403
- if (this.isExtendingRight) return { start, end }
404
- this.isExtendingRight = true
405
- const nowYear = new Date().getFullYear()
406
- const lastLabel = this.xAxis[this.xAxis.length - 1]
407
- const lastYear = this.parseYear(lastLabel)
408
- const today = this.todayLabel()
409
- if (!this.canGrowRight(lastYear, lastLabel, nowYear, today)) { this.isExtendingRight = false; return { start, end } }
410
- try {
411
- const targetYear = Number.isFinite(lastYear) && lastYear < nowYear ? lastYear + 1 : nowYear
412
- const nextData = await getTrend(targetYear)
413
- const filtered = this.filterNewer(nextData, lastLabel, today)
414
- if (filtered.length <= 0) return { start, end }
415
- const nextX = filtered.map(i => i.label)
416
- const nextY = filtered.map(i => i.value)
417
- this.appendData(nextX, nextY, 'right')
418
- return { start, end }
419
- } finally {
420
- this.isExtendingRight = false
421
- }
422
- },
423
- // 解析日期字符串中的年份
424
- parseYear(label) {
425
- return typeof label === 'string' ? parseInt(label.slice(0, 4), 10) : NaN
426
- },
427
- // 判断是否还可以向右加载更多数据
428
- // 条件:最后一条数据的年份小于当前年,或者同年但日期小于今天
429
- canGrowRight(lastYear, lastLabel, nowYear, today) {
430
- return (Number.isFinite(lastYear) && lastYear < nowYear) ||
431
- (Number.isFinite(lastYear) && lastYear === nowYear && typeof lastLabel === 'string' && lastLabel < today)
432
- },
433
- // 过滤出新加载数据中,晚于当前视图最后一条且早于等于今天的数据
434
- filterNewer(data, lastLabel, today) {
435
- return Array.isArray(data) ? data.filter(i => i.label > lastLabel && i.label <= today) : []
436
- },
437
- // 将新数据拼接到现有数据序列中(左侧或右侧),并更新图表
438
- appendData(nextX, nextY, direction) {
439
- if (direction === 'left') {
440
- this.xAxis = [...nextX, ...this.xAxis]
441
- this.series = [...nextY, ...this.series]
442
- } else {
443
- this.xAxis = [...this.xAxis, ...nextX]
444
- this.series = [...this.series, ...nextY]
445
- }
446
- this.totalPoints = this.xAxis.length
447
- this.chartInstance.setOption({
448
- xAxis: { data: this.xAxis },
449
- series: [{ data: this.series }]
450
- })
451
- },
452
- // 日期下拉框变更时的处理逻辑:定位视窗到选定日期
453
- onDateChange(type) {
454
- if (!type || type !== 'hand') {
455
- return
456
- }
457
- const idx = this.xAxis.indexOf(this.selectedDate)
458
- if (idx === -1) return
459
- let end = idx
460
- let start = end - (this.windowSize - 1)
461
- if (start < 0) {
462
- start = 0
463
- end = start + (this.windowSize - 1)
464
- }
465
- if (end > this.totalPoints - 1) {
466
- end = this.totalPoints - 1
467
- start = end - (this.windowSize - 1)
468
- }
469
- this.dataZoomState = { startValue: start, endValue: end }
470
- this.chartInstance.dispatchAction({
471
- type: 'dataZoom',
472
- dataZoomIndex: 0,
473
- startValue: start,
474
- endValue: end,
475
- silent: true
476
- })
477
- this.chartInstance.dispatchAction({
478
- type: 'dataZoom',
479
- dataZoomIndex: 1,
480
- startValue: start,
481
- endValue: end,
482
- silent: true
483
- })
484
- this.updateLastViewPoint()
485
- },
486
- // 根据当前窗口区间更新顶部最后一条数据展示
487
- updateLastViewPoint() {
488
- const end = this.dataZoomState.endValue
489
- if (Array.isArray(this.xAxis) && Array.isArray(this.series) && end >= 0 && end < this.series.length) {
490
- this.lastLabel = this.xAxis[end]
491
- this.lastValue = this.series[end]
492
- } else {
493
- this.lastLabel = ''
494
- this.lastValue = null
495
- }
496
- },
497
- // 窗口尺寸变更时自适应
498
- handleResize() {
499
- if (this.chartInstance) {
500
- this.chartInstance.resize()
501
- }
502
- }
503
- }
504
- }
505
- </script>
506
-
507
- <style>
508
- .page {
509
- padding: 24px;
510
- }
511
-
512
- .toolbar {
513
- margin-bottom: 12px;
514
- display: flex;
515
- align-items: center;
516
- }
517
-
518
- .title {
519
- margin-bottom: 16px;
520
- }
521
-
522
- .info {
523
- font-size: 14px;
524
- margin-left: 12px;
525
- }
526
-
527
- .chart {
528
- width: 100%;
529
- height: 400px;
530
- }
531
- </style>