n20-common-lib 3.1.4 → 3.1.6

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.
@@ -2,65 +2,17 @@
2
2
  <Dialog class="n20-pivot-dialog" width="95%" max-dialog :visible.sync="visibleC" title="数据透视分析中心">
3
3
  <div class="n20-pivot-container">
4
4
  <!-- 左侧报表清单 -->
5
- <div class="pivot-sidebar-left" :class="{ collapsed: isLeftSidebarCollapsed }">
6
- <div class="sidebar-header">
7
- <span class="sidebar-title">我的报表清单</span>
8
- <div class="add-report-btn" @click="handleAddReport" title="新建">
9
- <i class="v3-icon-plus"></i>
10
- </div>
11
- </div>
12
-
13
- <div class="report-list">
14
- <div
15
- v-for="(report, index) in reportList"
16
- :key="report.id"
17
- class="report-item"
18
- :class="{ active: currentReportIndex === index, editing: editingReportId === report.id }"
19
- @click="handleSelectReport(index)"
20
- @mouseenter="hoveredReportId = report.id"
21
- @mouseleave="hoveredReportId = null"
22
- >
23
- <i :class="getChartTypeIcon(report.chartType)" class="report-icon"></i>
24
- <span v-if="editingReportId !== report.id" class="report-name">{{ report.name }}</span>
25
- <input
26
- v-else
27
- v-model="editingReportName"
28
- class="report-name-input"
29
- type="text"
30
- @click.stop
31
- @blur="handleSaveRename"
32
- @keyup.enter="handleSaveRename"
33
- @keyup.esc="handleCancelRename"
34
- />
35
- <div
36
- v-if="editingReportId !== report.id"
37
- class="report-more"
38
- :class="{ visible: hoveredReportId === report.id || activeMenuReportId === report.id }"
39
- @click.stop="handleToggleMenu(report.id)"
40
- >
41
- <i class="v3-icon-more"></i>
42
- <div v-if="activeMenuReportId === report.id" class="report-menu">
43
- <div class="report-menu-item" @click.stop="handleStartRename(report)">
44
- <i class="v3-icon-edit"></i>
45
- <span>重命名</span>
46
- </div>
47
- <div class="report-menu-item delete" @click.stop="handleDeleteReport(report)">
48
- <i class="v3-icon-delete"></i>
49
- <span>删除</span>
50
- </div>
51
- </div>
52
- </div>
53
- </div>
54
- </div>
55
-
56
- <div
57
- class="scroll-bar-indicator"
58
- :class="{ collapsed: isLeftSidebarCollapsed }"
59
- @click="handleScrollBarIndicator"
60
- >
61
- <i :class="isLeftSidebarCollapsed ? 'v3-icon-right' : 'v3-icon-left'"></i>
62
- </div>
63
- </div>
5
+ <ReportSidebar
6
+ ref="reportSidebar"
7
+ :report-list="reportList"
8
+ :current-index="currentReportIndex"
9
+ :is-collapsed="isLeftSidebarCollapsed"
10
+ @select="handleSelectReport"
11
+ @add="handleAddReport"
12
+ @toggle-collapse="handleToggleLeftSidebar"
13
+ @rename="handleRenameReport"
14
+ @delete="handleDeleteReport"
15
+ />
64
16
 
65
17
  <!-- 主内容区域 -->
66
18
  <div
@@ -68,336 +20,137 @@
68
20
  :class="{ 'left-collapsed': isLeftSidebarCollapsed, 'right-collapsed': isRightSidebarCollapsed }"
69
21
  >
70
22
  <!-- 内容头部工具栏 -->
71
- <div class="main-toolbar">
72
- <div class="toolbar-left">
73
- <div class="toolbar-left-top">
74
- <h2 class="report-title">{{ currentReport.name }}</h2>
75
- <div class="add-to-home-btn" @click="handleAddToHome">
76
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
77
- <path d="M7 1V13M1 7H13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
78
- </svg>
79
- <span>添加到首页</span>
80
- </div>
81
- </div>
82
- <div class="report-stats">
83
- <span class="stat-item">计算耗时:{{ calculateTime }} ms</span>
84
- <span class="stat-divider"></span>
85
- <span class="stat-item">数据条数:{{ dataCount }}</span>
86
- </div>
87
- </div>
88
-
89
- <div class="toolbar-right">
90
- <ViewToggle
91
- v-model="viewMode"
92
- :options="viewModeOptions"
93
- :disabled="isCrossMode"
94
- @change="handleViewModeChange"
95
- />
96
-
97
- <div class="action-buttons">
98
- <div class="action-btn" @click="handleRefresh" title="刷新">
99
- <i class="n20-icon-shuaxin"></i>
100
- </div>
101
- <div class="action-btn" @click="handleMore" title="更多">
102
- <i class="v3-icon-more"></i>
103
- </div>
104
- </div>
105
- </div>
106
- </div>
23
+ <MainToolbar
24
+ ref="mainToolbar"
25
+ :report-name="currentReport.name"
26
+ :calculate-time="calculateTime"
27
+ :data-count="rawDataCount"
28
+ :view-mode="viewMode"
29
+ :view-mode-options="viewModeOptions"
30
+ :is-cross-mode="isCrossMode"
31
+ :is-right-sidebar-collapsed="isRightSidebarCollapsed"
32
+ @view-mode-change="handleViewModeChange"
33
+ @refresh="handleRefresh"
34
+ @config="handleConfig"
35
+ @export="handleExport"
36
+ @add-to-home="handleAddToHome"
37
+ @delete-report="handleDeleteCurrentReport"
38
+ />
107
39
 
108
40
  <!-- 内容区域 -->
109
41
  <div class="main-content">
110
42
  <!-- 图表视图 -->
111
- <div v-if="viewMode === 'chart'" class="chart-view">
112
- <div v-if="hasData" ref="chartContainer" class="chart-container"></div>
113
- <div v-else class="empty-state">
114
- <div class="empty-icon">
115
- <svg width="100" height="100" viewBox="0 0 100 100" fill="none">
116
- <circle cx="50" cy="50" r="45" stroke="#E5E6EB" stroke-width="2" />
117
- <path
118
- d="M30 60V45L40 55M45 50H55M55 50H65M55 50V40"
119
- stroke="#E5E6EB"
120
- stroke-width="2"
121
- stroke-linecap="round"
122
- stroke-linejoin="round"
123
- />
124
- <circle cx="55" cy="30" r="8" stroke="#E5E6EB" stroke-width="2" />
125
- <path d="M55 38V42M55 48V50" stroke="#E5E6EB" stroke-width="2" stroke-linecap="round" />
126
- </svg>
127
- </div>
128
- <p class="empty-text">暂无数据</p>
129
- </div>
130
- </div>
43
+ <ChartView
44
+ v-if="viewMode === 'chart'"
45
+ ref="chartView"
46
+ :has-data="hasData"
47
+ :data-count="dataCount"
48
+ :dimension-label="getFirstDimensionLabel"
49
+ :unit-label="getChartUnitLabel"
50
+ :chart-option="chartOption"
51
+ />
131
52
 
132
53
  <!-- 表格视图 -->
133
- <div v-else class="table-view">
134
- <div v-if="hasData" class="table-container">
135
- <table class="pivot-table">
136
- <thead>
137
- <tr>
138
- <th v-for="col in tableColumns" :key="col.key">{{ col.title }}</th>
139
- </tr>
140
- </thead>
141
- <tbody>
142
- <tr v-for="(row, index) in tableData" :key="index">
143
- <td v-for="col in tableColumns" :key="col.key">
144
- {{ row[col.key] }}
145
- </td>
146
- </tr>
147
- </tbody>
148
- </table>
149
- </div>
150
- <div v-else class="empty-state">
151
- <div class="empty-icon">
152
- <svg width="100" height="100" viewBox="0 0 100 100" fill="none">
153
- <rect x="15" y="20" width="70" height="60" rx="4" stroke="#E5E6EB" stroke-width="2" />
154
- <path d="M15 35H85" stroke="#E5E6EB" stroke-width="2" />
155
- <path d="M40 35V80" stroke="#E5E6EB" stroke-width="2" />
156
- </svg>
157
- </div>
158
- <p class="empty-text">暂无数据</p>
159
- </div>
160
- </div>
54
+ <TableView
55
+ v-else
56
+ ref="tableView"
57
+ :has-data="hasData"
58
+ :data-count="dataCount"
59
+ :dimension-label="getFirstDimensionLabel"
60
+ :is-cross-mode="isCrossMode"
61
+ :row-dimension-label="getRowDimensionLabel"
62
+ :column-dimension-label="getColumnDimensionLabel"
63
+ :table-columns="tableColumns"
64
+ :cross-table-columns="crossTableColumns"
65
+ :table-data="paginatedTableData"
66
+ :total-count="dataCount"
67
+ :current-page="currentPage"
68
+ :page-size="pageSize"
69
+ :page-size-options="pageSizeOptions"
70
+ :total-pages="totalPages"
71
+ @prev-page="handlePrevPage"
72
+ @next-page="handleNextPage"
73
+ @jump-page="handleJumpToPage"
74
+ @page-size-change="handlePageSizeChange"
75
+ />
161
76
  </div>
162
77
  </div>
163
78
 
164
79
  <!-- 右侧维度配置 -->
165
- <div class="pivot-sidebar-right" :class="{ collapsed: isRightSidebarCollapsed }">
166
- <div class="sidebar-header">
167
- <template v-if="!isRightSidebarCollapsed">
168
- <i class="v3-icon-move-right sidebar-header-collapse" @click="handleToggleRightSidebar"></i>
169
- <span class="sidebar-title">维度配置</span>
170
- </template>
171
- </div>
172
-
173
- <div class="config-form">
174
- <!-- 报表名称 -->
175
- <div class="form-item">
176
- <label class="form-label">报表标题</label>
177
-
178
- <div class="input-wrapper">
179
- <input class="form-input" type="text" v-model="currentReport.name" placeholder="请输入报表名称" />
180
- </div>
181
- </div>
182
- <!-- 统计类型 -->
183
- <div class="form-item">
184
- <label class="form-label">统计类型</label>
185
-
186
- <div class="radio-group">
187
- <ViewToggle
188
- v-model="currentReport.statType"
189
- :itemWidth="120"
190
- :options="totalModeOptions"
191
- @change="handleStatTypeChange"
192
- />
193
- </div>
194
- </div>
195
-
196
- <!-- 展示形式 -->
197
- <div v-if="!isCrossMode" class="form-item">
198
- <label class="form-label">展示形式</label>
199
- <div class="select-wrapper" @click="showChartTypeDropdown = !showChartTypeDropdown">
200
- <div class="select-value">
201
- <i :class="getChartTypeIcon(currentReport.chartType)" class="report-icon"></i>
202
- <span>{{ getChartTypeLabel(currentReport.chartType) }}</span>
203
- </div>
204
- <svg class="select-arrow" width="10" height="10" viewBox="0 0 10 10" fill="none">
205
- <path
206
- d="M2 3L5 7L8 3"
207
- stroke="currentColor"
208
- stroke-width="1.5"
209
- stroke-linecap="round"
210
- stroke-linejoin="round"
211
- />
212
- </svg>
213
- <div v-if="showChartTypeDropdown" class="dropdown-menu">
214
- <div
215
- v-for="type in chartTypes"
216
- :key="type.value"
217
- class="dropdown-item"
218
- :class="{ active: currentReport.chartType === type.value }"
219
- @click.stop="handleChartTypeChange(type.value)"
220
- >
221
- {{ type.label }}
222
- </div>
223
- </div>
224
- </div>
225
- </div>
226
-
227
- <div class="form-divider"></div>
228
-
229
- <!-- 分组维度 -->
230
- <div class="form-item">
231
- <label class="form-label">
232
- 分组维度
233
- <svg class="label-tip" width="12" height="12" viewBox="0 0 12 12" fill="none">
234
- <circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1" />
235
- <path d="M6 5V9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
236
- <circle cx="6" cy="3.5" r="0.5" fill="currentColor" />
237
- </svg>
238
- </label>
239
- <div class="select-wrapper" @click="showDimensionDropdown = !showDimensionDropdown">
240
- <div class="select-value">
241
- <span v-if="currentReport.dimensions.length === 0" class="placeholder">请选择</span>
242
- <span v-else>{{ currentReport.dimensions.join(', ') }}</span>
243
- </div>
244
- <svg class="select-arrow" width="10" height="10" viewBox="0 0 10 10" fill="none">
245
- <path
246
- d="M2 3L5 7L8 3"
247
- stroke="currentColor"
248
- stroke-width="1.5"
249
- stroke-linecap="round"
250
- stroke-linejoin="round"
251
- />
252
- </svg>
253
- <div v-if="showDimensionDropdown" class="dropdown-menu dimension-dropdown">
254
- <div
255
- v-for="dim in availableDimensions"
256
- :key="dim.key"
257
- class="dropdown-item checkbox-item"
258
- @click.stop="handleDimensionToggle(dim.key)"
259
- >
260
- <div class="checkbox" :class="{ checked: currentReport.dimensions.includes(dim.key) }">
261
- <svg
262
- v-if="currentReport.dimensions.includes(dim.key)"
263
- width="10"
264
- height="10"
265
- viewBox="0 0 10 10"
266
- fill="none"
267
- >
268
- <path
269
- d="M2 5L4 7L8 3"
270
- stroke="currentColor"
271
- stroke-width="1.5"
272
- stroke-linecap="round"
273
- stroke-linejoin="round"
274
- />
275
- </svg>
276
- </div>
277
- <span>{{ dim.label }}</span>
278
- </div>
279
- </div>
280
- </div>
281
- </div>
282
-
283
- <div class="form-divider"></div>
284
-
285
- <!-- 计算指标 -->
286
- <div class="form-item">
287
- <div class="form-label-row">
288
- <label class="form-label">
289
- 计算指标
290
- <svg class="label-tip" width="12" height="12" viewBox="0 0 12 12" fill="none">
291
- <circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1" />
292
- <path d="M6 5V9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
293
- <circle cx="6" cy="3.5" r="0.5" fill="currentColor" />
294
- </svg>
295
- </label>
296
- <div class="aggregate-dropdown" @click="showAggregateDropdown = !showAggregateDropdown">
297
- <span>{{ getAggregateLabel(currentReport.aggregateType) }}</span>
298
- <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
299
- <path
300
- d="M2 3L5 7L8 3"
301
- stroke="currentColor"
302
- stroke-width="1.5"
303
- stroke-linecap="round"
304
- stroke-linejoin="round"
305
- />
306
- </svg>
307
- <div v-if="showAggregateDropdown" class="dropdown-menu aggregate-dropdown-menu">
308
- <div
309
- v-for="agg in aggregateTypes"
310
- :key="agg.value"
311
- class="dropdown-item"
312
- :class="{ active: currentReport.aggregateType === agg.value }"
313
- @click.stop="handleAggregateChange(agg.value)"
314
- >
315
- {{ agg.label }}
316
- </div>
317
- </div>
318
- </div>
319
- </div>
320
-
321
- <div class="select-wrapper" @click="showMetricDropdown = !showMetricDropdown">
322
- <div class="select-value">
323
- <span v-if="currentReport.metrics.length === 0" class="placeholder">请选择</span>
324
- <span v-else>{{ currentReport.metrics.join(', ') }}</span>
325
- </div>
326
- <div class="select-tag" v-if="currentReport.metrics.length > 0">
327
- {{ getAggregateLabel(currentReport.aggregateType) }}
328
- </div>
329
- <svg class="select-arrow" width="10" height="10" viewBox="0 0 10 10" fill="none">
330
- <path
331
- d="M2 3L5 7L8 3"
332
- stroke="currentColor"
333
- stroke-width="1.5"
334
- stroke-linecap="round"
335
- stroke-linejoin="round"
336
- />
337
- </svg>
338
- <div v-if="showMetricDropdown" class="dropdown-menu">
339
- <div
340
- v-for="metric in availableMetrics"
341
- :key="metric.key"
342
- class="dropdown-item checkbox-item"
343
- @click.stop="handleMetricToggle(metric.key)"
344
- >
345
- <div class="checkbox" :class="{ checked: currentReport.metrics.includes(metric.key) }">
346
- <svg
347
- v-if="currentReport.metrics.includes(metric.key)"
348
- width="10"
349
- height="10"
350
- viewBox="0 0 10 10"
351
- fill="none"
352
- >
353
- <path
354
- d="M2 5L4 7L8 3"
355
- stroke="currentColor"
356
- stroke-width="1.5"
357
- stroke-linecap="round"
358
- stroke-linejoin="round"
359
- />
360
- </svg>
361
- </div>
362
- <span>{{ metric.label }}</span>
363
- </div>
364
- </div>
365
- </div>
366
-
367
- <div class="add-field-btn" @click="handleAddMetric">
368
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
369
- <path d="M7 1V13M1 7H13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
370
- </svg>
371
- <span>添加字段</span>
372
- </div>
373
- </div>
374
- </div>
375
- </div>
80
+ <ConfigSidebar
81
+ ref="configSidebar"
82
+ :is-collapsed="isRightSidebarCollapsed"
83
+ :report-name="currentReport.name"
84
+ :stat-type="currentReport.statType"
85
+ :stat-type-options="totalModeOptions"
86
+ :chart-type="currentReport.chartType"
87
+ :chart-types="chartTypes"
88
+ :is-cross-mode="isCrossMode"
89
+ :view-mode="viewMode"
90
+ :selected-dimension="currentReport.selectedDimension"
91
+ :row-dimension="currentReport.rowDimension"
92
+ :column-dimension="currentReport.columnDimension"
93
+ :metrics="validMetrics"
94
+ :available-dimensions="availableDimensions"
95
+ :available-metrics="availableMetrics"
96
+ :available-metrics-for-add="availableMetricsForAdd"
97
+ :global-aggregate-type="getGlobalAggregateType()"
98
+ :aggregate-types="aggregateTypes"
99
+ @toggle-collapse="handleToggleRightSidebar"
100
+ @name-change="handleNameChange"
101
+ @stat-type-change="handleStatTypeChange"
102
+ @chart-type-change="handleChartTypeChange"
103
+ @dimension-select="handleDimensionSelect"
104
+ @row-dimension-change="handleRowDimensionChange"
105
+ @column-dimension-change="handleColumnDimensionChange"
106
+ @metric-aggregate-change="handleMetricAggregateChange"
107
+ @global-aggregate-change="handleGlobalAggregateChange"
108
+ @add-metric="handleAddSpecificMetric"
109
+ @remove-metric="handleRemoveMetric"
110
+ />
376
111
  </div>
377
112
  </Dialog>
378
113
  </template>
379
114
 
380
115
  <script>
381
- import Dialog from '../Dialog/index.vue'
382
- import ViewToggle from '../ViewToggle/index.vue'
383
116
  import XEUtils from 'xe-utils'
384
- import importG from '../../utils/importGlobal.js'
117
+ import Dialog from '../Dialog/index.vue'
118
+ import ReportSidebar from './ReportSidebar.vue'
119
+ import MainToolbar from './MainToolbar.vue'
120
+ import ChartView from './ChartView.vue'
121
+ import TableView from './TableView.vue'
122
+ import ConfigSidebar from './ConfigSidebar.vue'
385
123
 
386
124
  const STORAGE_KEY = 'pivot_report_configs'
125
+ const DEBOUNCE_DELAY = 300
126
+
127
+ // 防抖函数
128
+ function debounce(func, wait) {
129
+ let timeout
130
+ return function (...args) {
131
+ clearTimeout(timeout)
132
+ timeout = setTimeout(() => func.apply(this, args), wait)
133
+ }
134
+ }
387
135
 
388
136
  export default {
389
137
  name: 'Pivot',
390
138
  components: {
391
139
  Dialog,
392
- ViewToggle
140
+ ReportSidebar,
141
+ MainToolbar,
142
+ ChartView,
143
+ TableView,
144
+ ConfigSidebar
393
145
  },
394
146
  props: {
147
+ // 对话框可见性
395
148
  visible: {
396
149
  type: Boolean,
397
150
  default: false
398
151
  },
399
152
  // 数据源
400
- dataSource: {
153
+ data: {
401
154
  type: Array,
402
155
  default: () => []
403
156
  },
@@ -406,46 +159,51 @@ export default {
406
159
  type: Array,
407
160
  default: () => []
408
161
  },
409
- // 组件宽度
410
- width: {
411
- type: [Number, String],
412
- default: 1360
162
+ // 行维度字段
163
+ rowFields: {
164
+ type: Array,
165
+ default: () => []
166
+ },
167
+ // 列维度字段
168
+ colFields: {
169
+ type: Array,
170
+ default: () => []
171
+ },
172
+ // 计算指标字段
173
+ valueFields: {
174
+ type: Array,
175
+ default: () => []
413
176
  },
414
- // 组件高度
415
- height: {
416
- type: [Number, String],
417
- default: 820
177
+ // 图表类型
178
+ chartType: {
179
+ type: String,
180
+ default: 'bar',
181
+ validator: (val) => ['bar', 'line', 'pie', 'area'].includes(val)
418
182
  }
419
183
  },
420
184
 
421
185
  data() {
422
186
  return {
187
+ // 错误状态
188
+ error: null,
189
+ // 加载状态
190
+ isProcessing: false,
423
191
  // 报表列表
424
192
  reportList: [],
425
193
  // 当前选中的报表索引
426
194
  currentReportIndex: 0,
427
- // 视图模式: chart / table
428
- viewMode: 'chart',
429
195
  // 计算耗时
430
196
  calculateTime: 0,
431
197
  // 数据条数
432
198
  dataCount: 0,
433
- // 图表实例
434
- chartInstance: null,
435
- // 下拉框显示状态
436
- showCustomAggregateDropdown: false,
437
199
  // 左侧边栏是否折叠
438
200
  isLeftSidebarCollapsed: false,
439
201
  // 右侧边栏是否折叠
440
202
  isRightSidebarCollapsed: false,
441
- // 报表项悬停ID
442
- hoveredReportId: null,
443
- // 当前显示菜单的报表ID
444
- activeMenuReportId: null,
445
- // 正在编辑的报表ID
446
- editingReportId: null,
447
- // 编辑中的报表名称
448
- editingReportName: '',
203
+ // 分页状态
204
+ currentPage: 1,
205
+ pageSize: 10,
206
+ pageSizeOptions: [10, 20, 50, 100],
449
207
  // 视图模式选项
450
208
  viewModeOptions: [
451
209
  { label: '图表视图', value: 'chart' },
@@ -474,6 +232,16 @@ export default {
474
232
  },
475
233
 
476
234
  computed: {
235
+ // 数据源兼容性处理
236
+ actualData() {
237
+ return this.data || []
238
+ },
239
+
240
+ // 原始数据条数(用于工具栏显示)
241
+ rawDataCount() {
242
+ return this.actualData.length
243
+ },
244
+
477
245
  visibleC: {
478
246
  get() {
479
247
  return this.visible
@@ -483,6 +251,7 @@ export default {
483
251
  return v
484
252
  }
485
253
  },
254
+
486
255
  // 当前报表配置
487
256
  currentReport() {
488
257
  if (this.reportList.length === 0) {
@@ -496,143 +265,390 @@ export default {
496
265
  return this.currentReport.statType === 'cross'
497
266
  },
498
267
 
268
+ // 视图模式:从报表配置中读取,交叉统计强制为 table
269
+ viewMode() {
270
+ // 交叉统计模式强制使用表格视图
271
+ if (this.isCrossMode) {
272
+ return 'table'
273
+ }
274
+ // 从报表配置中读取,默认为 chart
275
+ return this.currentReport.viewMode || 'chart'
276
+ },
277
+
499
278
  // 是否有数据
500
279
  hasData() {
501
280
  return this.processedData.length > 0
502
281
  },
503
282
 
504
- // 可用维度
283
+ // 当前报表有效的指标列表
284
+ validMetrics() {
285
+ return (this.currentReport.metrics || []).filter((m) => m && m.prop)
286
+ },
287
+
288
+ // 可用维度(根据数据源值类型判断,过滤掉 checkbox 和无 prop 的列)
505
289
  availableDimensions() {
506
- return this.columns.filter((col) => col.type !== 'number' && col.type !== 'date')
290
+ return this.columns.filter((col) => {
291
+ // 过滤掉 checkbox 类型和没有 prop 的列
292
+ if (col.type === 'checkbox' || !col.prop) return false
293
+ // 数值类型不作为维度
294
+ return this.getColumnType(col.prop) !== 'number'
295
+ })
507
296
  },
508
297
 
509
- // 可用指标
298
+ // 可用指标(根据数据源值类型判断,过滤掉 checkbox 和无 prop 的列)
510
299
  availableMetrics() {
511
- return this.columns.filter((col) => col.type === 'number')
300
+ return this.columns.filter((col) => {
301
+ // 过滤掉 checkbox 类型和没有 prop 的列
302
+ if (col.type === 'checkbox' || !col.prop) return false
303
+ // 仅数值类型作为指标
304
+ return this.getColumnType(col.prop) === 'number'
305
+ })
306
+ },
307
+
308
+ // 可添加的指标
309
+ availableMetricsForAdd() {
310
+ const selectedProps = (this.currentReport.metrics || []).filter((m) => m && m.prop).map((m) => m.prop)
311
+ return this.availableMetrics.filter((m) => !selectedProps.includes(m.prop))
512
312
  },
513
313
 
514
314
  // 处理后的数据
515
315
  processedData() {
516
316
  const startTime = performance.now()
517
- const data = this.dataSource
518
- const result = []
317
+ this.error = null
318
+ this.isProcessing = true
519
319
 
520
- if (!data || data.length === 0) {
521
- this.calculateTime = 0
522
- this.dataCount = 0
523
- return []
524
- }
320
+ try {
321
+ const data = this.actualData
322
+ const result = []
323
+
324
+ if (!data || data.length === 0) {
325
+ this.calculateTime = 0
326
+ this.dataCount = 0
327
+ this.isProcessing = false
328
+ return []
329
+ }
525
330
 
526
- const { dimensions, metrics, aggregateType, chartType } = this.currentReport
331
+ // 交叉统计模式
332
+ if (this.isCrossMode) {
333
+ const { rowDimension, columnDimension, metrics: rawMetrics } = this.currentReport
334
+ const metrics = (rawMetrics || []).filter((m) => m && m.prop)
527
335
 
528
- if (dimensions.length === 0 || metrics.length === 0) {
529
- this.calculateTime = Math.round(performance.now() - startTime)
530
- this.dataCount = data.length
531
- return []
532
- }
336
+ if (!rowDimension || !columnDimension || metrics.length === 0) {
337
+ this.calculateTime = Math.round(performance.now() - startTime)
338
+ this.dataCount = data.length
339
+ this.isProcessing = false
340
+ return []
341
+ }
533
342
 
534
- // 按维度分组
535
- const groupedData = XEUtils.groupBy(data, dimensions)
536
-
537
- // 计算聚合值
538
- for (const key in groupedData) {
539
- const group = groupedData[key]
540
- const row = {}
541
-
542
- // 设置维度值
543
- if (dimensions.length === 1) {
544
- row[dimensions[0]] = key
545
- } else {
546
- const keyParts = key.split(',')
547
- dimensions.forEach((dim, i) => {
548
- row[dim] = keyParts[i]
549
- })
550
- }
343
+ // 使用枚举显示值进行分组
344
+ const groupedByRow = XEUtils.groupBy(data, (item) => this.getEnumDisplayValue(item, rowDimension))
345
+
346
+ for (const rowKey in groupedByRow) {
347
+ const rowGroup = groupedByRow[rowKey]
348
+ const row = {}
349
+
350
+ row[rowDimension] = rowKey
351
+ row._rowKey = rowKey
352
+
353
+ const groupedByColumn = XEUtils.groupBy(rowGroup, (item) => this.getEnumDisplayValue(item, columnDimension))
354
+
355
+ for (const colKey in groupedByColumn) {
356
+ const colGroup = groupedByColumn[colKey]
357
+
358
+ metrics.forEach((metric) => {
359
+ const metricProp = metric.prop
360
+ const values = colGroup.map((item) => parseFloat(item[metricProp]) || 0)
361
+ const colMetricKey = `${colKey}_${metricProp}`
362
+
363
+ switch (metric.aggregateType) {
364
+ case 'sum':
365
+ row[colMetricKey] = XEUtils.sum(values)
366
+ break
367
+ case 'count':
368
+ row[colMetricKey] = values.length
369
+ break
370
+ case 'avg':
371
+ row[colMetricKey] = XEUtils.mean(values)
372
+ break
373
+ case 'max':
374
+ row[colMetricKey] = XEUtils.max(values)
375
+ break
376
+ case 'min':
377
+ row[colMetricKey] = XEUtils.min(values)
378
+ break
379
+ default:
380
+ row[colMetricKey] = XEUtils.sum(values)
381
+ }
382
+ })
383
+ }
384
+
385
+ row._groupCount = rowGroup.length
386
+ row._groupKey = rowKey
551
387
 
552
- // 计算指标值
553
- metrics.forEach((metric) => {
554
- const values = group.map((item) => parseFloat(item[metric]) || 0)
555
- switch (aggregateType) {
556
- case 'sum':
557
- row[metric] = XEUtils.sum(values)
558
- break
559
- case 'count':
560
- row[metric] = values.length
561
- break
562
- case 'avg':
563
- row[metric] = XEUtils.mean(values)
564
- break
565
- case 'max':
566
- row[metric] = XEUtils.max(values)
567
- break
568
- case 'min':
569
- row[metric] = XEUtils.min(values)
570
- break
571
- default:
572
- row[metric] = XEUtils.sum(values)
388
+ result.push(row)
573
389
  }
390
+
391
+ this.calculateTime = Math.round(performance.now() - startTime)
392
+ this.dataCount = result.length
393
+ this.isProcessing = false
394
+
395
+ return result
396
+ }
397
+
398
+ // 分组统计模式
399
+ const { selectedDimension, metrics: rawMetrics } = this.currentReport
400
+ const metrics = (rawMetrics || []).filter((m) => m && m.prop)
401
+
402
+ if (!selectedDimension || metrics.length === 0) {
403
+ this.calculateTime = Math.round(performance.now() - startTime)
404
+ this.dataCount = data.length
405
+ this.isProcessing = false
406
+ return []
407
+ }
408
+
409
+ // 使用枚举显示值进行分组(单维度)
410
+ const groupedData = XEUtils.groupBy(data, (item) => {
411
+ return this.getEnumDisplayValue(item, selectedDimension)
574
412
  })
575
413
 
576
- result.push(row)
577
- }
414
+ for (const key in groupedData) {
415
+ const group = groupedData[key]
416
+ const row = {}
417
+
418
+ row[selectedDimension] = key
419
+
420
+ metrics.forEach((metric) => {
421
+ const metricProp = metric.prop
422
+ const values = group.map((item) => parseFloat(item[metricProp]) || 0)
423
+ switch (metric.aggregateType) {
424
+ case 'sum':
425
+ row[metricProp] = XEUtils.sum(values)
426
+ break
427
+ case 'count':
428
+ row[metricProp] = values.length
429
+ break
430
+ case 'avg':
431
+ row[metricProp] = XEUtils.mean(values)
432
+ break
433
+ case 'max':
434
+ row[metricProp] = XEUtils.max(values)
435
+ break
436
+ case 'min':
437
+ row[metricProp] = XEUtils.min(values)
438
+ break
439
+ default:
440
+ row[metricProp] = XEUtils.sum(values)
441
+ }
442
+ })
578
443
 
579
- this.calculateTime = Math.round(performance.now() - startTime)
580
- this.dataCount = result.length
444
+ row._groupCount = group.length
445
+ row._groupKey = key
446
+
447
+ result.push(row)
448
+ }
581
449
 
582
- return result
450
+ this.calculateTime = Math.round(performance.now() - startTime)
451
+ this.dataCount = result.length
452
+ this.isProcessing = false
453
+
454
+ return result
455
+ } catch (err) {
456
+ console.error('数据透视计算失败:', err)
457
+ this.error = {
458
+ code: 'AGGREGATION_ERROR',
459
+ message: err.message || '数据计算过程中发生错误',
460
+ stack: err.stack
461
+ }
462
+ this.calculateTime = 0
463
+ this.dataCount = 0
464
+ this.isProcessing = false
465
+ return []
466
+ }
583
467
  },
584
468
 
585
469
  // 表格列定义
586
470
  tableColumns() {
471
+ if (this.isCrossMode) {
472
+ return this.crossTableColumns
473
+ }
587
474
  const cols = []
588
- const { dimensions, metrics } = this.currentReport
475
+ const { selectedDimension, metrics: rawMetrics } = this.currentReport
476
+ const metrics = (rawMetrics || []).filter((m) => m && m.prop)
589
477
 
590
- dimensions.forEach((dim) => {
591
- const colDef = this.columns.find((c) => c.key === dim)
478
+ // 维度列
479
+ if (selectedDimension) {
480
+ const colDef = this.columns.find((c) => c.prop === selectedDimension)
592
481
  cols.push({
593
- key: dim,
594
- title: colDef ? colDef.label : dim
482
+ key: selectedDimension,
483
+ title: colDef ? colDef.label : selectedDimension,
484
+ type: 'dimension',
485
+ width: '37.5%'
595
486
  })
596
- })
487
+ }
597
488
 
489
+ // 指标列
490
+ const totalMetricWidth = 62.5
491
+ const metricWidth = metrics.length > 0 ? totalMetricWidth / metrics.length + '%' : null
598
492
  metrics.forEach((metric) => {
599
- const colDef = this.columns.find((c) => c.key === metric)
493
+ const colDef = this.columns.find((c) => c.prop === metric.prop)
600
494
  cols.push({
601
- key: metric,
602
- title: colDef ? colDef.label : metric
495
+ key: metric.prop,
496
+ title: colDef ? colDef.label : metric.prop,
497
+ type: 'metric',
498
+ width: metricWidth,
499
+ aggregateType: metric.aggregateType
603
500
  })
604
501
  })
605
502
 
606
503
  return cols
607
504
  },
608
505
 
609
- // 表格数据
610
- tableData() {
611
- return this.processedData
506
+ // 交叉表列定义(支持多级表头)
507
+ crossTableColumns() {
508
+ const { rowDimension, columnDimension, metrics: rawMetrics } = this.currentReport
509
+ const metrics = (rawMetrics || []).filter((m) => m && m.prop)
510
+ if (!rowDimension || !columnDimension || metrics.length === 0) return []
511
+
512
+ const cols = []
513
+
514
+ // 行维度列(第一列)
515
+ const rowDimDef = this.columns.find((c) => c.prop === rowDimension)
516
+ cols.push({
517
+ key: rowDimension,
518
+ title: rowDimDef ? rowDimDef.label : rowDimension,
519
+ type: 'dimension',
520
+ isPrimary: true
521
+ })
522
+
523
+ // 获取列维度的唯一值,使用 getEnumDisplayValue 处理枚举和对象类型
524
+ const uniqueColumnValues = [
525
+ ...new Set(
526
+ this.actualData.map((item) => this.getEnumDisplayValue(item, columnDimension))
527
+ )
528
+ ]
529
+
530
+ // 如果有多个指标,使用多级表头结构
531
+ if (metrics.length > 1) {
532
+ // 每个列维度值作为一个父列
533
+ uniqueColumnValues.forEach((colValue) => {
534
+ const safeColValue = String(colValue)
535
+ const children = metrics.map((metric) => {
536
+ const metricDef = this.columns.find((c) => c.prop === metric.prop)
537
+ const colMetricKey = `${safeColValue}_${metric.prop}`
538
+ return {
539
+ key: colMetricKey,
540
+ title: metricDef ? metricDef.label : metric.prop,
541
+ type: 'metric',
542
+ aggregateType: metric.aggregateType,
543
+ columnValue: colValue,
544
+ metricKey: metric.prop
545
+ }
546
+ })
547
+ cols.push({
548
+ key: `group_${safeColValue}`,
549
+ title: colValue,
550
+ type: 'group',
551
+ children
552
+ })
553
+ })
554
+ } else {
555
+ // 单个指标时,使用原来的扁平结构
556
+ uniqueColumnValues.forEach((colValue) => {
557
+ metrics.forEach((metric) => {
558
+ const metricDef = this.columns.find((c) => c.prop === metric.prop)
559
+ const safeColValue = String(colValue)
560
+ const colMetricKey = `${safeColValue}_${metric.prop}`
561
+ cols.push({
562
+ key: colMetricKey,
563
+ title: `${colValue} - ${metricDef ? metricDef.label : metric.prop}`,
564
+ type: 'metric',
565
+ aggregateType: metric.aggregateType,
566
+ columnValue: colValue,
567
+ metricKey: metric.prop
568
+ })
569
+ })
570
+ })
571
+ }
572
+
573
+ return cols
574
+ },
575
+
576
+ // 分页后的表格数据
577
+ paginatedTableData() {
578
+ const allData = this.processedData
579
+ if (!allData || allData.length === 0) return []
580
+
581
+ const start = (this.currentPage - 1) * this.pageSize
582
+ const end = start + this.pageSize
583
+ return allData.slice(start, end)
584
+ },
585
+
586
+ // 总页数
587
+ totalPages() {
588
+ const total = this.dataCount
589
+ if (!total || total === 0) return 1
590
+ return Math.ceil(total / this.pageSize)
591
+ },
592
+
593
+ // 图表单位标签
594
+ getChartUnitLabel() {
595
+ const metrics = (this.currentReport.metrics || []).filter((m) => m && m.prop)
596
+ if (metrics.length === 0) return ''
597
+ const firstMetric = this.columns.find((c) => c.prop === metrics[0].prop)
598
+ return firstMetric ? `${firstMetric.label}(人民币)` : ''
599
+ },
600
+
601
+ // 获取第一个维度标签
602
+ getFirstDimensionLabel() {
603
+ const dim = this.currentReport.selectedDimension
604
+ if (!dim) return '未选择'
605
+ const dimDef = this.columns.find((c) => c.prop === dim)
606
+ return dimDef ? dimDef.label : dim
607
+ },
608
+
609
+ // 获取行维度标签(交叉统计用)
610
+ getRowDimensionLabel() {
611
+ const dim = this.currentReport.rowDimension
612
+ if (!dim) return '未选择'
613
+ const dimDef = this.columns.find((c) => c.prop === dim)
614
+ return dimDef ? dimDef.label : dim
615
+ },
616
+
617
+ // 获取列维度标签(交叉统计用)
618
+ getColumnDimensionLabel() {
619
+ const dim = this.currentReport.columnDimension
620
+ if (!dim) return '未选择'
621
+ const dimDef = this.columns.find((c) => c.prop === dim)
622
+ return dimDef ? dimDef.label : dim
612
623
  },
613
624
 
614
625
  // 图表配置
615
626
  chartOption() {
616
627
  if (!this.hasData) return {}
617
628
 
618
- const { chartType, dimensions, metrics, aggregateType } = this.currentReport
629
+ const { chartType, selectedDimension, metrics: rawMetrics } = this.currentReport
630
+ const metrics = (rawMetrics || []).filter((m) => m && m.prop)
619
631
  const data = this.processedData
620
632
 
621
- // 获取标签和数值
622
- const labels = data.map((row) => row[dimensions[0]] || '')
623
- const labelCol = this.columns.find((c) => c.key === dimensions[0])
624
- const labelName = labelCol ? labelCol.label : dimensions[0]
633
+ const labels = data.map((row) => row[selectedDimension] || '')
634
+
635
+ const gridConfig = {
636
+ left: '3%',
637
+ right: '4%',
638
+ bottom: '15%',
639
+ top: '10%',
640
+ containLabel: true
641
+ }
625
642
 
626
643
  if (chartType === 'pie') {
627
- // 饼图配置
628
- const metric = metrics[0] || ''
629
- const metricCol = this.columns.find((c) => c.key === metric)
630
- const metricName = metricCol ? metricCol.label : metric
644
+ const metricProp = metrics[0]?.prop || ''
645
+ const metricCol = this.columns.find((c) => c.prop === metricProp)
646
+ const metricName = metricCol ? metricCol.label : metricProp
631
647
 
632
648
  return {
633
649
  tooltip: {
634
650
  trigger: 'item',
635
- formatter: `{b}: {c} ({d}%)`
651
+ formatter: '{b}: {c} ({d}%)'
636
652
  },
637
653
  legend: {
638
654
  orient: 'horizontal',
@@ -662,25 +678,24 @@ export default {
662
678
  }
663
679
  },
664
680
  data: data.map((row) => ({
665
- name: row[dimensions[0]],
666
- value: row[metric]
681
+ name: row[selectedDimension],
682
+ value: row[metricProp]
667
683
  }))
668
684
  }
669
685
  ],
670
- color: ['#007AFF', '#34C759', '#FF9500', '#FF3B30', '#5856D6', '#AF52DE', '#00C7BE', '#FF2D55']
686
+ color: ['#165DFF', '#14C9C9', '#F7BA1E', '#F53F3F', '#86909C', '#00B42A', '#FF7D00', '#F5319D']
671
687
  }
672
688
  }
673
689
 
674
- // 柱状图/折线图配置
675
- const series = metrics.map((metric, index) => {
676
- const metricCol = this.columns.find((c) => c.key === metric)
677
- const metricName = metricCol ? metricCol.label : metric
690
+ const series = metrics.map((metric) => {
691
+ const metricCol = this.columns.find((c) => c.prop === metric.prop)
692
+ const metricName = metricCol ? metricCol.label : metric.prop
678
693
 
679
694
  return {
680
695
  name: metricName,
681
696
  type: chartType === 'line' ? 'line' : 'bar',
682
- data: data.map((row) => row[metric]),
683
- barMaxWidth: 32,
697
+ data: data.map((row) => row[metric.prop]),
698
+ barMaxWidth: 24,
684
699
  smooth: true
685
700
  }
686
701
  })
@@ -688,40 +703,197 @@ export default {
688
703
  return {
689
704
  tooltip: {
690
705
  trigger: 'axis',
691
- axisPointer: {
692
- type: 'shadow'
706
+ backgroundColor: '#fff',
707
+ borderColor: '#e5e6eb',
708
+ borderWidth: 1,
709
+ padding: [12, 12],
710
+ textStyle: {
711
+ color: '#1d2129'
712
+ },
713
+ extraCssText: 'box-shadow: 0px 8px 10px rgba(0,0,0,0.1); border-radius: 4px;',
714
+ formatter: (params) => {
715
+ const title = params[0].axisValue
716
+ const dataIndex = params[0].dataIndex
717
+ const rowData = this.processedData[dataIndex] || {}
718
+ const groupCount = rowData._groupCount || 0
719
+ let html = `<div style="font-size:14px;font-weight:500;margin-bottom:8px;color:#1d2129;">${title}</div>`
720
+ params.forEach((p) => {
721
+ const color = p.color
722
+ const value =
723
+ typeof p.value === 'number' ? p.value.toLocaleString('en-US', { minimumFractionDigits: 2 }) : p.value
724
+ html += `<div style="display:flex;align-items:center;justify-content:space-between;gap:24px;margin-top:4px;">`
725
+ html += `<div style="display:flex;align-items:center;gap:4px;">`
726
+ html += `<span style="display:inline-block;width:8px;height:3px;background:${color};"></span>`
727
+ html += `<span style="font-size:12px;color:#4e5969;font-family:'PingFang SC',sans-serif;">${p.seriesName}</span>`
728
+ html += `</div>`
729
+ html += `<div style="display:flex;align-items:center;gap:8px;">`
730
+ html += `<span style="font-size:12px;color:#1d2129;font-weight:600;font-family:'Open Sans',sans-serif;">${value}</span>`
731
+ html += `<span style="font-size:12px;color:#4e5969;font-family:'PingFang SC',sans-serif;">${groupCount} 笔</span>`
732
+ html += `</div>`
733
+ html += `</div>`
734
+ })
735
+ return html
693
736
  }
694
737
  },
695
738
  legend: {
696
739
  data: series.map((s) => s.name),
697
- bottom: 10,
698
- left: 'center'
699
- },
700
- grid: {
701
- left: '3%',
702
- right: '4%',
703
- bottom: '15%',
704
- top: '10%',
705
- containLabel: true
740
+ top: 0,
741
+ right: 0,
742
+ itemWidth: 8,
743
+ itemHeight: 3,
744
+ textStyle: {
745
+ fontSize: 12,
746
+ color: '#4e5969',
747
+ fontFamily: "'PingFang SC', sans-serif"
748
+ }
706
749
  },
750
+ grid: gridConfig,
707
751
  xAxis: {
708
752
  type: 'category',
709
753
  data: labels,
710
754
  axisLabel: {
711
755
  interval: 0,
712
- rotate: labels.length > 6 ? 30 : 0
756
+ rotate: labels.length > 6 ? 30 : 0,
757
+ color: '#86909c',
758
+ fontSize: 12
759
+ },
760
+ axisLine: {
761
+ lineStyle: {
762
+ color: '#e5e6eb'
763
+ }
764
+ },
765
+ axisTick: {
766
+ alignWithLabel: true,
767
+ lineStyle: {
768
+ color: '#e5e6eb'
769
+ }
713
770
  }
714
771
  },
715
772
  yAxis: {
716
- type: 'value'
773
+ type: 'value',
774
+ axisLabel: {
775
+ color: '#86909c',
776
+ fontSize: 12,
777
+ formatter: (value) => {
778
+ if (value >= 1000000) {
779
+ return (value / 1000000).toFixed(0) + ',000,000.00'
780
+ }
781
+ return value.toLocaleString('en-US', { minimumFractionDigits: 2 })
782
+ }
783
+ },
784
+ splitLine: {
785
+ lineStyle: {
786
+ color: '#e5e6eb',
787
+ type: [4, 4]
788
+ }
789
+ },
790
+ axisLine: {
791
+ show: false
792
+ },
793
+ axisTick: {
794
+ show: false
795
+ }
717
796
  },
718
797
  series,
719
- color: ['#007AFF', '#34C759', '#FF9500', '#FF3B30', '#5856D6']
798
+ color: ['#165DFF', '#14C9C9', '#F7BA1E', '#F53F3F', '#86909C']
720
799
  }
721
800
  }
722
801
  },
723
802
 
724
803
  watch: {
804
+ // 弹窗打开时重新加载数据
805
+ visible(newVal) {
806
+ if (newVal) {
807
+ // 弹窗打开时重新加载持久化数据
808
+ this.loadFromStorage()
809
+ // 如果没有报表则新建
810
+ if (this.reportList.length === 0) {
811
+ this.addNewReport()
812
+ }
813
+ // 初始化图表
814
+ if (this.viewMode === 'chart') {
815
+ this.$nextTick(() => {
816
+ this.initChart()
817
+ })
818
+ }
819
+ } else {
820
+ // 弹窗关闭时保存数据
821
+ this.saveToStorage()
822
+ }
823
+ },
824
+
825
+ actualData: {
826
+ handler(newData) {
827
+ if (newData && newData.length > 0) {
828
+ this.currentPage = 1
829
+ this.error = null
830
+ if (this.viewMode === 'chart') {
831
+ this.$nextTick(() => {
832
+ this.initChart()
833
+ })
834
+ }
835
+ }
836
+ },
837
+ deep: true
838
+ },
839
+
840
+ columns: {
841
+ handler(newColumns) {
842
+ if (newColumns && newColumns.length > 0) {
843
+ // 仅在首次初始化或没有配置时调用
844
+ if (!this.currentReport.selectedDimension && !this.currentReport.rowDimension) {
845
+ this.initDefaultConfig()
846
+ }
847
+ }
848
+ },
849
+ immediate: true
850
+ },
851
+
852
+ rowFields: {
853
+ handler(fields) {
854
+ if (fields && fields.length > 0 && this.currentReport) {
855
+ this.currentReport.selectedDimension = fields[0]
856
+ }
857
+ },
858
+ immediate: true
859
+ },
860
+
861
+ colFields: {
862
+ handler(fields) {
863
+ if (fields && fields.length > 0 && this.currentReport) {
864
+ if (fields.length >= 1) {
865
+ this.currentReport.rowDimension = fields[0]
866
+ }
867
+ if (fields.length >= 2) {
868
+ this.currentReport.columnDimension = fields[1]
869
+ }
870
+ }
871
+ },
872
+ immediate: true
873
+ },
874
+
875
+ valueFields: {
876
+ handler(fields) {
877
+ if (fields && fields.length > 0 && this.currentReport) {
878
+ this.currentReport.metrics = fields.map((prop) => ({
879
+ prop,
880
+ aggregateType: 'sum'
881
+ }))
882
+ }
883
+ },
884
+ immediate: true
885
+ },
886
+
887
+ chartType: {
888
+ handler(type) {
889
+ if (type && this.currentReport) {
890
+ this.currentReport.chartType = type
891
+ }
892
+ },
893
+ immediate: true
894
+ },
895
+
896
+ // 视图模式变化时初始化图表
725
897
  viewMode(newVal) {
726
898
  if (newVal === 'chart') {
727
899
  this.$nextTick(() => {
@@ -729,11 +901,14 @@ export default {
729
901
  })
730
902
  }
731
903
  },
904
+
732
905
  chartOption: {
733
906
  deep: true,
734
907
  handler() {
735
- if (this.viewMode === 'chart' && this.chartInstance) {
736
- this.chartInstance.setOption(this.chartOption, true)
908
+ if (this.viewMode === 'chart') {
909
+ this.$nextTick(() => {
910
+ this.initChart()
911
+ })
737
912
  }
738
913
  }
739
914
  },
@@ -743,6 +918,9 @@ export default {
743
918
  this.initChart()
744
919
  })
745
920
  }
921
+ },
922
+ 'currentReport.statType'() {
923
+ this.currentPage = 1
746
924
  }
747
925
  },
748
926
 
@@ -752,43 +930,186 @@ export default {
752
930
  this.addNewReport()
753
931
  }
754
932
  document.addEventListener('click', this.handleOutsideClick)
933
+
934
+ this.handleResize = debounce(() => {
935
+ if (this.$refs.chartView) {
936
+ this.$refs.chartView.resizeChart()
937
+ }
938
+ }, DEBOUNCE_DELAY)
939
+ window.addEventListener('resize', this.handleResize)
755
940
  },
756
941
 
757
942
  beforeDestroy() {
758
943
  document.removeEventListener('click', this.handleOutsideClick)
944
+ window.removeEventListener('resize', this.handleResize)
759
945
  this.saveToStorage()
760
- if (this.chartInstance) {
761
- this.chartInstance.dispose()
762
- this.chartInstance = null
763
- }
764
946
  },
765
947
 
766
948
  methods: {
949
+ // 获取列的数据类型(遍历数据找到第一个有效值判断)
950
+ getColumnType(prop) {
951
+ if (!this.actualData || this.actualData.length === 0) return 'unknown'
952
+ // 遍历数据找到第一个有效值
953
+ for (const row of this.actualData) {
954
+ const value = row[prop]
955
+ if (value !== null && value !== undefined && value !== '') {
956
+ if (typeof value === 'number') return 'number'
957
+ return 'string'
958
+ }
959
+ }
960
+ return 'unknown'
961
+ },
962
+
963
+ // 获取字段的枚举选项(filters 或 options)
964
+ getEnumOptions(prop) {
965
+ const col = this.columns.find((c) => c.prop === prop)
966
+ if (!col) return null
967
+ // 优先使用 filters,其次 options
968
+ if (col.filters && col.filters.length > 0) return col.filters
969
+ if (col.options && col.options.length > 0) return col.options
970
+ return null
971
+ },
972
+
973
+ // 获取枚举字段的显示值(用于分组)
974
+ getEnumDisplayValue(row, prop) {
975
+ const enumOptions = this.getEnumOptions(prop)
976
+ const rawValue = row[prop]
977
+
978
+ // 如果没有枚举配置,直接返回原始值
979
+ if (!enumOptions) {
980
+ // 处理对象类型的值(如 txType.name)
981
+ if (rawValue && typeof rawValue === 'object') {
982
+ return rawValue.name || rawValue.label || JSON.stringify(rawValue)
983
+ }
984
+ return rawValue
985
+ }
986
+
987
+ // 根据 rawValue 查找对应的枚举标签
988
+ // rawValue 可能是对象 { code, name } 或直接是值
989
+ const codeValue = rawValue && typeof rawValue === 'object' ? rawValue.code || rawValue.value : rawValue
990
+
991
+ // 在枚举选项中查找匹配项
992
+ const matchedOption = enumOptions.find((opt) => {
993
+ const optValue = opt.value !== undefined ? opt.value : opt.code
994
+ return optValue === codeValue || optValue === rawValue
995
+ })
996
+
997
+ if (matchedOption) {
998
+ return matchedOption.label || matchedOption.name || matchedOption.text || codeValue
999
+ }
1000
+
1001
+ // 未匹配到,返回原始值的显示形式
1002
+ if (rawValue && typeof rawValue === 'object') {
1003
+ return rawValue.name || rawValue.label || JSON.stringify(rawValue)
1004
+ }
1005
+ return rawValue
1006
+ },
1007
+
1008
+ // 初始化默认配置
1009
+ initDefaultConfig() {
1010
+ if (!this.currentReport) return
1011
+
1012
+ const allColumns = this.availableDimensions
1013
+ const numericCols = this.availableMetrics
1014
+
1015
+ // 分组统计模式:初始化分组维度
1016
+ if (
1017
+ this.currentReport.statType === 'group' &&
1018
+ !this.currentReport.selectedDimension &&
1019
+ allColumns.length > 0
1020
+ ) {
1021
+ this.currentReport.selectedDimension = allColumns[0].prop
1022
+ }
1023
+
1024
+ // 交叉统计模式:初始化行列维度
1025
+ if (this.currentReport.statType === 'cross') {
1026
+ if (!this.currentReport.rowDimension && allColumns.length > 0) {
1027
+ this.currentReport.rowDimension = allColumns[0].prop
1028
+ }
1029
+ if (!this.currentReport.columnDimension && allColumns.length > 1) {
1030
+ this.currentReport.columnDimension = allColumns[1].prop
1031
+ }
1032
+ }
1033
+
1034
+ // 初始化指标(仅当没有任何指标时)
1035
+ if (numericCols.length > 0 && this.currentReport.metrics.length === 0) {
1036
+ this.currentReport.metrics = [
1037
+ {
1038
+ prop: numericCols[0].prop,
1039
+ aggregateType: 'sum'
1040
+ }
1041
+ ]
1042
+ }
1043
+ },
1044
+
767
1045
  // 获取默认报表配置
768
1046
  getDefaultReport() {
769
1047
  return {
770
1048
  id: this.generateId(),
771
1049
  name: '报表清单',
772
1050
  statType: 'group',
773
- chartType: 'bar',
774
- dimensions: [],
775
- metrics: [] // 每项格式: { key: '字段名', aggregateType: 'sum' }
1051
+ chartType: this.chartType || 'bar',
1052
+ selectedDimension: null,
1053
+ rowDimension: null,
1054
+ columnDimension: null,
1055
+ metrics: [],
1056
+ // 添加视图模式持久化
1057
+ viewMode: 'chart'
776
1058
  }
777
1059
  },
778
1060
 
779
- // 迁移旧格式报表数据(metrics 从 string[] 迁移为 { key, aggregateType }[])
1061
+ // 迁移旧格式报表数据
780
1062
  migrateReportFormat(report) {
781
- if (report.metrics && report.metrics.length > 0 && typeof report.metrics[0] === 'string') {
782
- const aggregateType = report.aggregateType || 'sum'
783
- report.metrics = report.metrics.map((key) => ({ key, aggregateType }))
1063
+ if (!report) return this.getDefaultReport()
1064
+
1065
+ // 确保 statType 存在,默认为 'group'
1066
+ if (!report.statType) {
1067
+ report.statType = 'group'
1068
+ }
1069
+
1070
+ if (!report.metrics) {
1071
+ report.metrics = []
1072
+ }
1073
+ report.metrics = report.metrics
1074
+ .filter((m) => m !== null && m !== undefined)
1075
+ .map((m) => {
1076
+ if (typeof m === 'string') {
1077
+ return { prop: m, aggregateType: report.aggregateType || 'sum' }
1078
+ }
1079
+ if (m && m.key && !m.prop) {
1080
+ return { prop: m.key, aggregateType: m.aggregateType || 'sum' }
1081
+ }
1082
+ return m
1083
+ })
1084
+ if (!report.rowDimension) {
1085
+ report.rowDimension = null
1086
+ }
1087
+ if (!report.columnDimension) {
1088
+ report.columnDimension = null
1089
+ }
1090
+ // 兼容旧的 dimensions 数组,迁移为 selectedDimension
1091
+ if (report.dimensions && report.dimensions.length > 0 && !report.selectedDimension) {
1092
+ report.selectedDimension = report.dimensions[0]
1093
+ }
1094
+ if (!report.selectedDimension) {
1095
+ report.selectedDimension = null
1096
+ }
1097
+ // 确保图表类型存在
1098
+ if (!report.chartType) {
1099
+ report.chartType = 'bar'
1100
+ }
1101
+ // 确保 viewMode 存在,交叉统计默认为 table,分组统计默认为 chart
1102
+ if (!report.viewMode) {
1103
+ report.viewMode = report.statType === 'cross' ? 'table' : 'chart'
784
1104
  }
785
1105
  delete report.aggregateType
1106
+ delete report.dimensions
786
1107
  return report
787
1108
  },
788
1109
 
789
1110
  // 生成唯一ID
790
1111
  generateId() {
791
- return 'report_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9)
1112
+ return 'report_' + Date.now() + '_' + Math.random().toString(36).substring(2, 11)
792
1113
  },
793
1114
 
794
1115
  // 添加新报表
@@ -800,141 +1121,68 @@ export default {
800
1121
  this.saveToStorage()
801
1122
  },
802
1123
 
803
- // 选择报表
1124
+ // ========== ReportSidebar 事件处理 ==========
804
1125
  handleSelectReport(index) {
805
1126
  this.currentReportIndex = index
806
1127
  },
807
1128
 
808
- // 添加报表
809
1129
  handleAddReport() {
810
1130
  this.addNewReport()
811
1131
  },
812
1132
 
813
- // 统计类型变更
814
- handleStatTypeChange(type) {
815
- this.currentReport.statType = type
816
- // 交叉统计模式下强制切换到表格视图
817
- if (type === 'cross') {
818
- this.viewMode = 'table'
819
- }
820
- this.saveToStorage()
821
- },
822
-
823
- // 图表类型变更
824
- handleChartTypeChange(type) {
825
- this.currentReport.chartType = type
826
- this.showChartTypeDropdown = false
827
- this.saveToStorage()
1133
+ handleToggleLeftSidebar() {
1134
+ this.isLeftSidebarCollapsed = !this.isLeftSidebarCollapsed
1135
+ this.$nextTick(() => {
1136
+ if (this.viewMode === 'chart' && this.$refs.chartView) {
1137
+ this.$refs.chartView.resizeChart()
1138
+ }
1139
+ })
828
1140
  },
829
1141
 
830
- // 维度选择变更
831
- handleDimensionToggle(key) {
832
- const index = this.currentReport.dimensions.indexOf(key)
833
- if (index > -1) {
834
- this.currentReport.dimensions.splice(index, 1)
835
- } else {
836
- this.currentReport.dimensions.push(key)
1142
+ handleRenameReport({ id, name }) {
1143
+ const report = this.reportList.find((r) => r.id === id)
1144
+ if (report) {
1145
+ report.name = name
1146
+ this.saveToStorage()
837
1147
  }
838
- this.saveToStorage()
839
1148
  },
840
1149
 
841
- // 指标选择变更
842
- handleMetricToggle(key) {
843
- const index = this.currentReport.metrics.indexOf(key)
844
- if (index > -1) {
845
- this.currentReport.metrics.splice(index, 1)
846
- } else {
847
- this.currentReport.metrics.push(key)
848
- }
849
- this.saveToStorage()
1150
+ handleDeleteReport(report) {
1151
+ this.$confirm(`确定要删除报表"${report.name}"吗?`, '提示', {
1152
+ confirmButtonText: '确定',
1153
+ cancelButtonText: '取消',
1154
+ type: 'warning'
1155
+ })
1156
+ .then(() => {
1157
+ const index = this.reportList.findIndex((r) => r.id === report.id)
1158
+ if (index > -1) {
1159
+ this.reportList.splice(index, 1)
1160
+ if (this.currentReportIndex >= index && this.currentReportIndex > 0) {
1161
+ this.currentReportIndex--
1162
+ }
1163
+ if (this.reportList.length === 0) {
1164
+ this.addNewReport()
1165
+ }
1166
+ this.saveToStorage()
1167
+ this.$message.success('删除成功')
1168
+ }
1169
+ })
1170
+ .catch(() => {})
850
1171
  },
851
1172
 
852
- // 聚合类型变更
853
- handleAggregateChange(type) {
854
- this.currentReport.aggregateType = type
855
- this.showAggregateDropdown = false
1173
+ // ========== MainToolbar 事件处理 ==========
1174
+ handleViewModeChange(val) {
1175
+ // 将视图模式保存到报表配置中
1176
+ this.currentReport.viewMode = val
856
1177
  this.saveToStorage()
1178
+ // 切换到图表视图时初始化图表
1179
+ if (val === 'chart') {
1180
+ this.$nextTick(() => {
1181
+ this.initChart()
1182
+ })
1183
+ }
857
1184
  },
858
1185
 
859
- // 添加指标字段
860
- handleAddMetric() {
861
- // 默认选中第一个可用指标
862
- if (this.availableMetrics.length > 0 && !this.currentReport.metrics.includes(this.availableMetrics[0].key)) {
863
- this.currentReport.metrics.push(this.availableMetrics[0].key)
864
- this.saveToStorage()
865
- }
866
- },
867
-
868
- // 配置变更
869
- handleConfigChange() {
870
- this.saveToStorage()
871
- },
872
-
873
- // 获取图表类型标签
874
- getChartTypeLabel(type) {
875
- const chart = this.chartTypes.find((t) => t.value === type)
876
- return chart ? chart.label : '柱状图'
877
- },
878
-
879
- // 获取图表类型图标
880
- getChartTypeIcon(type) {
881
- const iconMap = {
882
- bar: 'v3-icon-chart-histogram',
883
- line: 'v3-icon-chart-line',
884
- pie: 'v3-icon-chart-pie',
885
- horizontalBar: 'v3-icon-histogram'
886
- }
887
- return iconMap[type] || 'v3-icon-chart-histogram'
888
- },
889
-
890
- // 获取聚合类型标签
891
- getAggregateLabel(type) {
892
- const agg = this.aggregateTypes.find((t) => t.value === type)
893
- return agg ? agg.label : '求和'
894
- },
895
-
896
- // 初始化图表
897
- async initChart() {
898
- if (!this.$refs.chartContainer) return
899
- if (!this.hasData) return
900
-
901
- const echarts = await importG('echarts', () => import(/*webpackChunkName "echarts"*/ 'echarts'))
902
-
903
- if (!this.chartInstance) {
904
- this.chartInstance = echarts.init(this.$refs.chartContainer)
905
- }
906
-
907
- if (this.chartOption) {
908
- this.chartInstance.setOption(this.chartOption, true)
909
- }
910
- },
911
-
912
- // 从本地存储加载
913
- loadFromStorage() {
914
- try {
915
- const saved = localStorage.getItem(STORAGE_KEY)
916
- if (saved) {
917
- const configs = JSON.parse(saved)
918
- if (Array.isArray(configs) && configs.length > 0) {
919
- // 迁移旧格式数据
920
- this.reportList = configs.map((report) => this.migrateReportFormat(report))
921
- }
922
- }
923
- } catch (e) {
924
- console.warn('Failed to load pivot configs from storage:', e)
925
- }
926
- },
927
-
928
- // 保存到本地存储
929
- saveToStorage() {
930
- try {
931
- localStorage.setItem(STORAGE_KEY, JSON.stringify(this.reportList))
932
- } catch (e) {
933
- console.warn('Failed to save pivot configs to storage:', e)
934
- }
935
- },
936
-
937
- // 刷新数据
938
1186
  handleRefresh() {
939
1187
  if (this.viewMode === 'chart') {
940
1188
  this.$nextTick(() => {
@@ -944,7 +1192,10 @@ export default {
944
1192
  this.$emit('refresh')
945
1193
  },
946
1194
 
947
- // 导出数据
1195
+ handleConfig() {
1196
+ this.handleToggleRightSidebar()
1197
+ },
1198
+
948
1199
  handleExport() {
949
1200
  const data = this.processedData
950
1201
  if (data.length === 0) {
@@ -952,7 +1203,6 @@ export default {
952
1203
  return
953
1204
  }
954
1205
 
955
- // 生成CSV
956
1206
  const headers = this.tableColumns.map((col) => col.title).join(',')
957
1207
  const rows = data.map((row) => {
958
1208
  return this.tableColumns
@@ -973,891 +1223,205 @@ export default {
973
1223
  this.$emit('export', data)
974
1224
  },
975
1225
 
976
- // 添加到首页
977
1226
  handleAddToHome() {
978
1227
  this.$emit('add-to-home', this.currentReport)
979
1228
  this.$message.success('已添加到首页')
980
1229
  },
981
1230
 
982
- // 新建报表
983
- handleNewReport() {
984
- this.addNewReport()
1231
+ handleDeleteCurrentReport() {
1232
+ this.handleDeleteReport(this.currentReport)
985
1233
  },
986
1234
 
987
- // 关闭
988
- handleClose() {
989
- this.$emit('close')
1235
+ // ========== TableView 事件处理 ==========
1236
+ handlePrevPage() {
1237
+ if (this.currentPage > 1) {
1238
+ this.currentPage--
1239
+ }
990
1240
  },
991
1241
 
992
- // 点击外部关闭下拉框
993
- handleOutsideClick(e) {
994
- if (
995
- !e.target.closest('.select-wrapper') &&
996
- !e.target.closest('.aggregate-dropdown') &&
997
- !e.target.closest('.report-more')
998
- ) {
999
- this.showChartTypeDropdown = false
1000
- this.showDimensionDropdown = false
1001
- this.showMetricDropdown = false
1002
- this.showAggregateDropdown = false
1003
- this.activeMenuReportId = null
1242
+ handleNextPage() {
1243
+ if (this.currentPage < this.totalPages) {
1244
+ this.currentPage++
1004
1245
  }
1005
1246
  },
1006
1247
 
1007
- // 处理左侧边栏折叠/展开
1008
- handleScrollBarIndicator() {
1009
- this.isLeftSidebarCollapsed = !this.isLeftSidebarCollapsed
1010
- this.$nextTick(() => {
1011
- if (this.viewMode === 'chart' && this.chartInstance) {
1012
- this.chartInstance.resize()
1013
- }
1014
- })
1248
+ handleJumpToPage(page) {
1249
+ this.currentPage = page
1250
+ },
1251
+
1252
+ handlePageSizeChange(size) {
1253
+ this.pageSize = size
1254
+ this.currentPage = 1
1015
1255
  },
1016
1256
 
1017
- // 处理右侧边栏折叠/展开
1257
+ // ========== ConfigSidebar 事件处理 ==========
1018
1258
  handleToggleRightSidebar() {
1019
1259
  this.isRightSidebarCollapsed = !this.isRightSidebarCollapsed
1020
1260
  this.$nextTick(() => {
1021
- if (this.viewMode === 'chart' && this.chartInstance) {
1022
- this.chartInstance.resize()
1261
+ if (this.viewMode === 'chart' && this.$refs.chartView) {
1262
+ this.$refs.chartView.resizeChart()
1023
1263
  }
1024
1264
  })
1025
1265
  },
1026
1266
 
1027
- // 切换报表菜单显示
1028
- handleToggleMenu(reportId) {
1029
- this.activeMenuReportId = this.activeMenuReportId === reportId ? null : reportId
1030
- },
1031
-
1032
- // 开始重命名
1033
- handleStartRename(report) {
1034
- this.editingReportId = report.id
1035
- this.editingReportName = report.name
1036
- this.activeMenuReportId = null
1037
- this.$nextTick(() => {
1038
- const input = this.$el.querySelector('.report-name-input')
1039
- if (input) {
1040
- input.focus()
1041
- input.select()
1042
- }
1043
- })
1267
+ handleNameChange(value) {
1268
+ this.currentReport.name = value
1269
+ this.saveToStorage()
1044
1270
  },
1045
1271
 
1046
- // 保存重命名
1047
- handleSaveRename() {
1048
- if (this.editingReportId && this.editingReportName.trim()) {
1049
- const report = this.reportList.find((r) => r.id === this.editingReportId)
1050
- if (report) {
1051
- report.name = this.editingReportName.trim()
1052
- this.saveToStorage()
1272
+ handleStatTypeChange(type) {
1273
+ this.currentReport.statType = type
1274
+ if (type === 'cross') {
1275
+ // 切换到交叉统计时清除分组维度,并强制使用表格视图
1276
+ this.currentReport.selectedDimension = null
1277
+ this.currentReport.viewMode = 'table'
1278
+ } else {
1279
+ // 切换到分组统计时清除行列维度
1280
+ this.currentReport.rowDimension = null
1281
+ this.currentReport.columnDimension = null
1282
+ // 如果之前是表格视图,可以保持;否则默认图表视图
1283
+ if (!this.currentReport.viewMode || this.currentReport.viewMode === 'table') {
1284
+ this.currentReport.viewMode = 'chart'
1053
1285
  }
1054
1286
  }
1055
- this.editingReportId = null
1056
- this.editingReportName = ''
1287
+ this.saveToStorage()
1057
1288
  },
1058
1289
 
1059
- // 取消重命名
1060
- handleCancelRename() {
1061
- this.editingReportId = null
1062
- this.editingReportName = ''
1290
+ handleChartTypeChange(type) {
1291
+ this.currentReport.chartType = type
1292
+ this.saveToStorage()
1063
1293
  },
1064
1294
 
1065
- // 删除报表
1066
- handleDeleteReport(report) {
1067
- this.activeMenuReportId = null
1068
- this.$confirm(`确定要删除报表"${report.name}"吗?`, '提示', {
1069
- confirmButtonText: '确定',
1070
- cancelButtonText: '取消',
1071
- type: 'warning'
1072
- })
1073
- .then(() => {
1074
- const index = this.reportList.findIndex((r) => r.id === report.id)
1075
- if (index > -1) {
1076
- this.reportList.splice(index, 1)
1077
- if (this.currentReportIndex >= index && this.currentReportIndex > 0) {
1078
- this.currentReportIndex--
1079
- }
1080
- if (this.reportList.length === 0) {
1081
- this.addNewReport()
1082
- }
1083
- this.saveToStorage()
1084
- this.$message.success('删除成功')
1085
- }
1086
- })
1087
- .catch(() => {})
1295
+ handleDimensionSelect(dimKey) {
1296
+ this.currentReport.selectedDimension = dimKey
1297
+ this.saveToStorage()
1088
1298
  },
1089
1299
 
1090
- // 处理视图模式切换
1091
- handleViewModeChange(val) {
1092
- if (val === 'chart') {
1093
- this.$nextTick(() => {
1094
- this.initChart()
1095
- })
1300
+ handleRowDimensionChange(dimKey) {
1301
+ if (this.currentReport.columnDimension === dimKey) {
1302
+ this.currentReport.columnDimension = null
1096
1303
  }
1097
- }
1098
- }
1099
- }
1100
- </script>
1101
-
1102
- <style lang="scss">
1103
- .n20-pivot-dialog {
1104
- .el-dialog {
1105
- box-sizing: border-box;
1106
- margin-top: 3vh !important;
1107
- max-height: 94vh;
1108
- display: flex;
1109
- flex-direction: column;
1110
- .el-dialog__body {
1111
- padding: 0;
1112
- flex: 1;
1113
- min-height: 0;
1114
- overflow: hidden;
1115
- }
1116
- }
1117
- }
1118
- .n20-pivot-container {
1119
- display: flex;
1120
- background: #fff;
1121
- height: 100%;
1122
- border-radius: 8px;
1123
- overflow: hidden;
1124
- font-family: 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1125
- position: relative;
1126
- }
1127
-
1128
- /* 左侧边栏 */
1129
- .pivot-sidebar-left {
1130
- width: 280px;
1131
- background: #f2f3f5;
1132
- border-right: 1px solid #e5e6eb;
1133
- display: flex;
1134
- flex-direction: column;
1135
- position: relative;
1136
- transition: width 0.3s ease;
1137
- }
1138
-
1139
- .pivot-sidebar-left.collapsed {
1140
- width: 8px;
1141
- }
1142
-
1143
- .pivot-sidebar-left.collapsed .sidebar-header,
1144
- .pivot-sidebar-left.collapsed .report-list {
1145
- opacity: 0;
1146
- pointer-events: none;
1147
- }
1148
-
1149
- .pivot-sidebar-left .sidebar-header {
1150
- height: 48px;
1151
- padding: 0 16px;
1152
- display: flex;
1153
- align-items: center;
1154
- justify-content: space-between;
1155
- border-bottom: 1px solid #e5e6eb;
1156
- background: #f2f3f5;
1157
- }
1158
-
1159
- .pivot-sidebar-left .sidebar-title {
1160
- color: var(--text-2, #4e5969);
1161
- /* 13/CN-Regular */
1162
- font-family: 'PingFang SC';
1163
- font-size: 13px;
1164
- font-style: normal;
1165
- font-weight: 400;
1166
- line-height: 22px; /* 169.231% */
1167
- }
1168
-
1169
- .add-report-btn {
1170
- padding: 9px;
1171
- display: flex;
1172
- align-items: center;
1173
- justify-content: center;
1174
- border-radius: 4px;
1175
- border: 1px solid var(--border-2, #e5e6eb);
1176
- background: var(--bg-white, #fff);
1177
- cursor: pointer;
1178
- color: var(--text-primary, #4e5969);
1179
- transition: all 0.2s;
1180
- i {
1181
- font-size: 14px;
1182
- line-height: 14px;
1183
- }
1184
- }
1185
-
1186
- .add-report-btn:hover {
1187
- background: #fff;
1188
- color: #007aff;
1189
- }
1190
-
1191
- .report-list {
1192
- flex: 1;
1193
- overflow-y: auto;
1194
- padding: 8px;
1195
- }
1196
-
1197
- .report-item {
1198
- display: flex;
1199
- align-items: center;
1200
- padding: 8px;
1201
- border-radius: 4px;
1202
- cursor: pointer;
1203
- transition: all 0.2s;
1204
- }
1205
-
1206
- .report-item:hover {
1207
- background: rgba(0, 0, 0, 0.04);
1208
- }
1209
-
1210
- .report-item.active {
1211
- border-radius: 4px;
1212
- background: var(--bg-white, #fff);
1213
- }
1214
-
1215
- .report-item.editing {
1216
- background: var(--bg-white, #fff);
1217
- }
1218
-
1219
- .report-name-input {
1220
- flex: 1;
1221
- height: 24px;
1222
- padding: 0 8px;
1223
- border: 1px solid #007aff;
1224
- border-radius: 2px;
1225
- font-size: 14px;
1226
- color: #1d2129;
1227
- outline: none;
1228
- }
1229
-
1230
- .report-more {
1231
- display: none;
1232
- align-items: center;
1233
- justify-content: center;
1234
- width: 24px;
1235
- height: 24px;
1236
- margin-left: 4px;
1237
- border-radius: 4px;
1238
- cursor: pointer;
1239
- color: #86909c;
1240
- position: relative;
1241
- }
1242
-
1243
- .report-more.visible {
1244
- display: flex;
1245
- }
1246
-
1247
- .report-more:hover {
1248
- background: rgba(0, 0, 0, 0.04);
1249
- color: #4e5969;
1250
- }
1251
-
1252
- .report-menu {
1253
- position: absolute;
1254
- top: 100%;
1255
- right: 0;
1256
- margin-top: 4px;
1257
- background: #fff;
1258
- border: 1px solid #e5e6eb;
1259
- border-radius: 4px;
1260
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
1261
- z-index: 100;
1262
- min-width: 100px;
1263
- padding: 4px 0;
1264
- }
1265
-
1266
- .report-menu-item {
1267
- display: flex;
1268
- align-items: center;
1269
- gap: 8px;
1270
- padding: 8px 12px;
1271
- font-size: 14px;
1272
- color: #1d2129;
1273
- cursor: pointer;
1274
- transition: background 0.2s;
1275
- white-space: nowrap;
1276
- }
1277
-
1278
- .report-menu-item:hover {
1279
- background: #f7f8fa;
1280
- }
1281
-
1282
- .report-menu-item.delete {
1283
- color: #f53f3f;
1284
- }
1285
-
1286
- .report-menu-item.delete:hover {
1287
- background: #fff2f0;
1288
- }
1289
-
1290
- .report-icon {
1291
- width: 16px;
1292
- height: 16px;
1293
- margin-right: 8px;
1294
- color: #4e5969;
1295
- }
1296
-
1297
- .report-item.active .report-icon {
1298
- color: #007aff;
1299
- }
1300
-
1301
- .report-name {
1302
- font-size: 14px;
1303
- color: #1d2129;
1304
- flex: 1;
1305
- overflow: hidden;
1306
- text-overflow: ellipsis;
1307
- white-space: nowrap;
1308
- }
1309
-
1310
- .scroll-bar-indicator {
1311
- position: absolute;
1312
- right: -8px;
1313
- top: 50%;
1314
- transform: translateY(-50%);
1315
- width: 16px;
1316
- height: 80px;
1317
- display: flex;
1318
- align-items: center;
1319
- justify-content: center;
1320
- border-radius: 8px;
1321
- border: 1px solid var(--border-2, #e5e6eb);
1322
- background: var(--bg-white, #fff);
1323
- color: #4e5969;
1324
- cursor: pointer;
1325
- // transition: right 0.3s ease, color 0.2s;
1326
- // z-index: 10;
1327
- }
1328
-
1329
- .scroll-bar-indicator:hover {
1330
- color: #007aff;
1331
- }
1332
-
1333
- .scroll-bar-indicator.collapsed {
1334
- right: -8px;
1335
- }
1336
-
1337
- /* 主内容区域 */
1338
- .pivot-main {
1339
- flex: 1;
1340
- display: flex;
1341
- flex-direction: column;
1342
- position: relative;
1343
- padding: 16px;
1344
- transition: margin-left 0.3s ease, margin-right 0.3s ease;
1345
- }
1346
-
1347
- .pivot-main.left-collapsed {
1348
- margin-left: 8px;
1349
- }
1350
-
1351
- .pivot-main.right-collapsed {
1352
- margin-right: 8px;
1353
- }
1354
-
1355
- .main-toolbar {
1356
- display: flex;
1357
- align-items: center;
1358
- justify-content: space-between;
1359
- padding: 6px 16px;
1360
- border-bottom: 1px solid #e5e6eb;
1361
- }
1362
-
1363
- .toolbar-left {
1364
- display: flex;
1365
- flex-direction: column;
1366
- justify-content: center;
1367
- gap: 4px;
1368
- }
1369
-
1370
- .toolbar-left-top {
1371
- display: flex;
1372
- align-items: center;
1373
- gap: 8px;
1374
- }
1375
-
1376
- .report-title {
1377
- color: var(--text-1, #1d2129);
1378
- /* 18/CN-Medium */
1379
- font-family: 'PingFang SC';
1380
- font-size: 18px;
1381
- font-style: normal;
1382
- font-weight: 500;
1383
- line-height: 26px; /* 144.444% */
1384
- }
1385
-
1386
- .add-to-home-btn {
1387
- display: flex;
1388
- align-items: center;
1389
- gap: 4px;
1390
- padding: 4px 8px;
1391
- border-radius: 2px;
1392
- cursor: pointer;
1393
- color: #007aff;
1394
- font-size: 14px;
1395
- transition: all 0.2s;
1396
- }
1397
-
1398
- .add-to-home-btn:hover {
1399
- background: rgba(0, 122, 255, 0.08);
1400
- }
1401
-
1402
- .report-stats {
1403
- display: flex;
1404
- align-items: center;
1405
- gap: 8px;
1406
- }
1407
-
1408
- .stat-item {
1409
- font-size: 12px;
1410
- color: #4e5969;
1411
- }
1412
-
1413
- .stat-divider {
1414
- width: 0;
1415
- height: 12px;
1416
- border-left: 1px solid #e5e6eb;
1417
- }
1418
-
1419
- .toolbar-right {
1420
- display: flex;
1421
- align-items: center;
1422
- gap: 8px;
1423
- }
1424
-
1425
- .action-buttons {
1426
- display: flex;
1427
- gap: 8px;
1428
- }
1429
-
1430
- .action-btn {
1431
- width: 32px;
1432
- height: 32px;
1433
- display: flex;
1434
- align-items: center;
1435
- justify-content: center;
1436
- background: #fff;
1437
- border: 1px solid #e5e6eb;
1438
- border-radius: 4px;
1439
- cursor: pointer;
1440
- color: #4e5969;
1441
- transition: all 0.2s;
1442
- }
1443
-
1444
- .action-btn:hover {
1445
- color: #007aff;
1446
- border-color: #007aff;
1447
- }
1448
-
1449
- /* 主内容 */
1450
- .main-content {
1451
- flex: 1;
1452
- padding: 16px;
1453
- overflow: hidden;
1454
- border: 1px solid #e5e6eb;
1455
- border-radius: 4px;
1456
- }
1457
-
1458
- .chart-view,
1459
- .table-view {
1460
- width: 100%;
1461
- height: 100%;
1462
- position: relative;
1463
- }
1464
-
1465
- .chart-container {
1466
- width: 100%;
1467
- height: 100%;
1468
- }
1469
-
1470
- .table-container {
1471
- width: 100%;
1472
- height: 100%;
1473
- overflow: auto;
1474
- }
1475
-
1476
- .pivot-table {
1477
- width: 100%;
1478
- border-collapse: collapse;
1479
- font-size: 14px;
1480
- }
1481
-
1482
- .pivot-table th,
1483
- .pivot-table td {
1484
- padding: 12px 16px;
1485
- text-align: left;
1486
- border: 1px solid #e5e6eb;
1487
- }
1488
-
1489
- .pivot-table th {
1490
- background: #f2f3f5;
1491
- font-weight: 500;
1492
- color: #1d2129;
1493
- position: sticky;
1494
- top: 0;
1495
- z-index: 1;
1496
- }
1497
-
1498
- .pivot-table tbody tr:hover {
1499
- background: #f7f8fa;
1500
- }
1501
-
1502
- .empty-state {
1503
- width: 100%;
1504
- height: 100%;
1505
- display: flex;
1506
- flex-direction: column;
1507
- align-items: center;
1508
- justify-content: center;
1509
- }
1510
-
1511
- .empty-icon {
1512
- opacity: 0.5;
1513
- }
1514
-
1515
- .empty-text {
1516
- margin-top: 16px;
1517
- font-size: 12px;
1518
- color: #4e5969;
1519
- }
1520
-
1521
- /* 新建按钮 */
1522
- .new-report-btn {
1523
- position: absolute;
1524
- top: 56px;
1525
- right: 16px;
1526
- display: flex;
1527
- flex-direction: column;
1528
- align-items: center;
1529
- cursor: pointer;
1530
- }
1531
-
1532
- .new-report-btn > span {
1533
- background: #1d2129;
1534
- color: #fff;
1535
- padding: 8px 16px;
1536
- border-radius: 4px;
1537
- font-size: 14px;
1538
- }
1539
-
1540
- .btn-arrow {
1541
- color: #1d2129;
1542
- }
1543
-
1544
- /* 右侧边栏 */
1545
- .pivot-sidebar-right {
1546
- width: 280px;
1547
- background: #fff;
1548
- border-left: 1px solid #e5e6eb;
1549
- display: flex;
1550
- flex-direction: column;
1551
- transition: width 0.3s ease;
1552
- }
1553
-
1554
- .pivot-sidebar-right.collapsed {
1555
- width: 8px;
1556
- }
1557
-
1558
- .pivot-sidebar-right.collapsed .sidebar-header,
1559
- .pivot-sidebar-right.collapsed .config-form {
1560
- opacity: 0;
1561
- pointer-events: none;
1562
- }
1563
-
1564
- .pivot-sidebar-right .sidebar-header {
1565
- height: 48px;
1566
- padding: 0 16px;
1567
- display: flex;
1568
- align-items: center;
1569
- gap: 8px;
1570
- border-bottom: 1px solid #e5e6eb;
1571
- white-space: nowrap;
1572
- }
1573
-
1574
- .pivot-sidebar-right .sidebar-title {
1575
- color: var(--text-1, #1d2129);
1576
-
1577
- /* 14/CN-Medium */
1578
- font-family: 'PingFang SC';
1579
- font-size: 14px;
1580
- font-style: normal;
1581
- font-weight: 500;
1582
- line-height: 22px; /* 157.143% */
1583
- }
1584
-
1585
- .pivot-sidebar-right .sidebar-header-collapse {
1586
- font-size: 14px;
1587
- color: #86909c;
1588
- cursor: pointer;
1589
- transition: color 0.2s;
1590
- &:hover {
1591
- color: #1d2129;
1592
- }
1593
- }
1594
-
1595
- .config-form {
1596
- flex: 1;
1597
- padding: 0 16px;
1598
- overflow-y: auto;
1599
-
1600
- .form-item:first-child {
1601
- margin-top: 16px;
1602
- }
1603
- }
1604
-
1605
- .form-item {
1606
- margin-bottom: 16px;
1607
- }
1608
-
1609
- .form-divider {
1610
- height: 1px;
1611
- background-color: #e5e6eb;
1612
- margin: 16px 0;
1613
- }
1614
-
1615
- .form-label {
1616
- display: flex;
1617
- align-items: center;
1618
- gap: 4px;
1619
- font-size: 14px;
1620
- color: #4e5969;
1621
- margin-bottom: 8px;
1622
- }
1623
-
1624
- .label-tip {
1625
- color: #86909c;
1626
- }
1627
-
1628
- .form-label-row {
1629
- display: flex;
1630
- align-items: center;
1631
- justify-content: space-between;
1632
- margin-bottom: 8px;
1633
- }
1634
-
1635
- .form-label-row .form-label {
1636
- margin-bottom: 0;
1637
- }
1304
+ this.currentReport.rowDimension = dimKey
1305
+ this.saveToStorage()
1306
+ },
1638
1307
 
1639
- .input-wrapper {
1640
- width: 100%;
1641
- }
1308
+ handleColumnDimensionChange(dimKey) {
1309
+ if (this.currentReport.rowDimension === dimKey) {
1310
+ this.currentReport.rowDimension = null
1311
+ }
1312
+ this.currentReport.columnDimension = dimKey
1313
+ this.saveToStorage()
1314
+ },
1642
1315
 
1643
- .form-input {
1644
- width: 224px;
1645
- height: 32px;
1646
- padding: 0 12px;
1647
- border: 1px solid #e5e6eb;
1648
- border-radius: 4px;
1649
- font-size: 14px;
1650
- color: #1d2129;
1651
- outline: none;
1652
- transition: border-color 0.2s, box-shadow 0.2s;
1653
- }
1316
+ handleMetricAggregateChange({ prop, aggregateType }) {
1317
+ const index = this.currentReport.metrics.findIndex((m) => m && m.prop === prop)
1318
+ if (index > -1) {
1319
+ this.$set(this.currentReport.metrics, index, {
1320
+ ...this.currentReport.metrics[index],
1321
+ aggregateType
1322
+ })
1323
+ this.saveToStorage()
1324
+ }
1325
+ },
1654
1326
 
1655
- .form-input:focus {
1656
- border-color: #007aff;
1657
- box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.1);
1658
- }
1327
+ handleGlobalAggregateChange(type) {
1328
+ this.currentReport.metrics.forEach((metric, index) => {
1329
+ if (metric && metric.prop) {
1330
+ this.$set(this.currentReport.metrics, index, { ...metric, aggregateType: type })
1331
+ }
1332
+ })
1333
+ this.saveToStorage()
1334
+ },
1659
1335
 
1660
- .form-input::placeholder {
1661
- color: #86909c;
1662
- }
1336
+ getGlobalAggregateType() {
1337
+ const firstValid = (this.currentReport.metrics || []).find((m) => m && m.prop)
1338
+ return firstValid ? firstValid.aggregateType : 'sum'
1339
+ },
1663
1340
 
1664
- /* 单选组 */
1665
- .radio-group {
1666
- display: flex;
1667
- background: #f2f3f5;
1668
- border-radius: 4px;
1669
- padding: 3px;
1670
- }
1341
+ handleAddSpecificMetric(metricProp) {
1342
+ this.currentReport.metrics.push({
1343
+ prop: metricProp,
1344
+ aggregateType: this.getGlobalAggregateType()
1345
+ })
1346
+ this.saveToStorage()
1347
+ },
1671
1348
 
1672
- .radio-item {
1673
- flex: 1;
1674
- height: 26px;
1675
- display: flex;
1676
- align-items: center;
1677
- justify-content: center;
1678
- font-size: 14px;
1679
- color: #4e5969;
1680
- cursor: pointer;
1681
- border-radius: 2px;
1682
- transition: all 0.2s;
1683
- }
1349
+ handleRemoveMetric(metricProp) {
1350
+ const index = this.currentReport.metrics.findIndex((m) => m && m.prop === metricProp)
1351
+ if (index > -1) {
1352
+ this.currentReport.metrics.splice(index, 1)
1353
+ this.saveToStorage()
1354
+ }
1355
+ },
1684
1356
 
1685
- .radio-item.active {
1686
- background: #fff;
1687
- color: #007aff;
1688
- font-weight: 500;
1689
- box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.08);
1690
- }
1357
+ // ========== 其他方法 ==========
1358
+ initChart() {
1359
+ if (this.$refs.chartView) {
1360
+ this.$refs.chartView.initChart()
1361
+ }
1362
+ },
1691
1363
 
1692
- /* 选择框 */
1693
- .select-wrapper {
1694
- position: relative;
1695
- display: flex;
1696
- align-items: center;
1697
- gap: 4px;
1698
- padding: 4px 12px;
1699
- background: #fff;
1700
- border: 1px solid #e5e6eb;
1701
- border-radius: 4px;
1702
- cursor: pointer;
1703
- transition: border-color 0.2s;
1704
- }
1364
+ loadFromStorage() {
1365
+ try {
1366
+ const saved = localStorage.getItem(STORAGE_KEY)
1367
+ if (saved) {
1368
+ const configs = JSON.parse(saved)
1369
+ if (Array.isArray(configs) && configs.length > 0) {
1370
+ // 迁移格式并加载报表列表
1371
+ this.reportList = configs.map((report) => this.migrateReportFormat(report))
1372
+ // 确保 currentReportIndex 有效
1373
+ if (this.currentReportIndex >= this.reportList.length) {
1374
+ this.currentReportIndex = 0
1375
+ }
1376
+ }
1377
+ }
1378
+ } catch (e) {
1379
+ console.warn('Failed to load pivot configs from storage:', e)
1380
+ // 加载失败时清空报表列表
1381
+ this.reportList = []
1382
+ }
1383
+ },
1705
1384
 
1706
- .select-wrapper:hover {
1707
- border-color: #007aff;
1708
- }
1385
+ saveToStorage() {
1386
+ try {
1387
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(this.reportList))
1388
+ } catch (e) {
1389
+ console.warn('Failed to save pivot configs to storage:', e)
1390
+ }
1391
+ },
1709
1392
 
1710
- .select-value {
1711
- flex: 1;
1712
- display: flex;
1713
- align-items: center;
1714
- gap: 8px;
1715
- font-size: 14px;
1716
- color: #1d2129;
1717
- .report-icon {
1718
- width: 16px;
1719
- height: 16px;
1720
- font-size: 16px;
1721
- line-height: 16px;
1722
- display: inline-flex;
1723
- align-items: center;
1724
- justify-content: center;
1725
- margin-right: 0;
1726
- color: #007aff;
1393
+ handleOutsideClick(e) {
1394
+ const dropdownSelectors = [
1395
+ '.select-wrapper',
1396
+ '.aggregate-dropdown',
1397
+ '.add-field-btn',
1398
+ '.metric-add-dropdown',
1399
+ '.metric-select-tag',
1400
+ '.report-more',
1401
+ '.more-btn',
1402
+ '.pagination-size-select'
1403
+ ]
1404
+ const isInsideDropdown = dropdownSelectors.some((sel) => e.target.closest(sel))
1405
+ if (!isInsideDropdown) {
1406
+ // 关闭所有子组件的下拉菜单
1407
+ if (this.$refs.reportSidebar) {
1408
+ this.$refs.reportSidebar.closeMenu()
1409
+ }
1410
+ if (this.$refs.mainToolbar) {
1411
+ this.$refs.mainToolbar.closeMenu()
1412
+ }
1413
+ if (this.$refs.tableView) {
1414
+ this.$refs.tableView.closeDropdown()
1415
+ }
1416
+ if (this.$refs.configSidebar) {
1417
+ this.$refs.configSidebar.closeAllDropdowns()
1418
+ }
1419
+ }
1420
+ }
1727
1421
  }
1728
1422
  }
1423
+ </script>
1729
1424
 
1730
- .select-value .placeholder {
1731
- color: #86909c;
1732
- }
1733
-
1734
- .select-arrow {
1735
- color: #4e5969;
1736
- transition: transform 0.2s;
1737
- }
1738
-
1739
- .select-tag {
1740
- padding: 2px 8px;
1741
- background: rgba(78, 89, 105, 0.08);
1742
- border-radius: 2px;
1743
- font-size: 12px;
1744
- color: #4e5969;
1745
- margin-right: 8px;
1746
- }
1747
-
1748
- /* 下拉菜单 */
1749
- .dropdown-menu {
1750
- position: absolute;
1751
- top: 100%;
1752
- left: 0;
1753
- right: 0;
1754
- margin-top: 4px;
1755
- background: #fff;
1756
- border: 1px solid #e5e6eb;
1757
- border-radius: 4px;
1758
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
1759
- z-index: 100;
1760
- max-height: 200px;
1761
- overflow-y: auto;
1762
- }
1763
-
1764
- .dropdown-item {
1765
- padding: 8px 12px;
1766
- font-size: 14px;
1767
- color: #1d2129;
1768
- cursor: pointer;
1769
- transition: background 0.2s;
1770
- }
1771
-
1772
- .dropdown-item:hover {
1773
- background: #f7f8fa;
1774
- }
1775
-
1776
- .dropdown-item.active {
1777
- color: #007aff;
1778
- }
1779
-
1780
- .checkbox-item {
1781
- display: flex;
1782
- align-items: center;
1783
- gap: 8px;
1784
- }
1785
-
1786
- .checkbox {
1787
- width: 16px;
1788
- height: 16px;
1789
- border: 1px solid #e5e6eb;
1790
- border-radius: 2px;
1791
- display: flex;
1792
- align-items: center;
1793
- justify-content: center;
1794
- transition: all 0.2s;
1795
- }
1796
-
1797
- .checkbox.checked {
1798
- background: #007aff;
1799
- border-color: #007aff;
1800
- color: #fff;
1801
- }
1802
-
1803
- /* 聚合类型下拉 */
1804
- .aggregate-dropdown {
1805
- position: relative;
1806
- display: flex;
1807
- align-items: center;
1808
- gap: 4px;
1809
- padding: 4px 8px;
1810
- border-radius: 2px;
1811
- cursor: pointer;
1812
- color: #007aff;
1813
- font-size: 12px;
1814
- transition: background 0.2s;
1815
- }
1816
-
1817
- .aggregate-dropdown:hover {
1818
- background: rgba(0, 122, 255, 0.08);
1819
- }
1820
-
1821
- .aggregate-dropdown-menu {
1822
- right: 0;
1823
- left: auto;
1824
- min-width: 80px;
1825
- }
1826
-
1827
- /* 添加字段按钮 */
1828
- .add-field-btn {
1829
- display: flex;
1830
- align-items: center;
1831
- gap: 4px;
1832
- margin-top: 8px;
1833
- padding: 4px 8px;
1834
- font-size: 14px;
1835
- color: #007aff;
1836
- cursor: pointer;
1837
- border-radius: 2px;
1838
- transition: background 0.2s;
1839
- }
1840
-
1841
- .add-field-btn:hover {
1842
- background: rgba(0, 122, 255, 0.08);
1843
- }
1844
-
1845
- /* 滚动条样式 */
1846
- ::-webkit-scrollbar {
1847
- width: 6px;
1848
- height: 6px;
1849
- }
1850
-
1851
- ::-webkit-scrollbar-thumb {
1852
- background: #c9cdd4;
1853
- border-radius: 3px;
1854
- }
1855
-
1856
- ::-webkit-scrollbar-track {
1857
- background: transparent;
1858
- }
1859
-
1860
- ::-webkit-scrollbar-thumb:hover {
1861
- background: #a1a7b3;
1862
- }
1863
- </style>
1425
+ <style lang="scss">
1426
+ @import '../../assets/css/pivot.scss';
1427
+ </style>