jianghu-ui 1.0.1

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 (112) hide show
  1. package/README.md +376 -0
  2. package/dist/jianghu-ui.css +2318 -0
  3. package/dist/jianghu-ui.js +2 -0
  4. package/dist/jianghu-ui.js.LICENSE.txt +1 -0
  5. package/package.json +56 -0
  6. package/src/Design.stories.mdx +195 -0
  7. package/src/Introduction.stories.mdx +148 -0
  8. package/src/components/JhAddressSelect/JhAddressSelect.md +250 -0
  9. package/src/components/JhAddressSelect/JhAddressSelect.stories.js +282 -0
  10. package/src/components/JhAddressSelect/JhAddressSelect.vue +261 -0
  11. package/src/components/JhCard/JhCard.md +246 -0
  12. package/src/components/JhCard/JhCard.stories.js +688 -0
  13. package/src/components/JhCard/JhCard.vue +604 -0
  14. package/src/components/JhCheckCard/JhCheckCard.md +245 -0
  15. package/src/components/JhCheckCard/JhCheckCard.stories.js +750 -0
  16. package/src/components/JhCheckCard/JhCheckCard.vue +476 -0
  17. package/src/components/JhConfirmDialog/JhConfirmDialog.md +70 -0
  18. package/src/components/JhConfirmDialog/JhConfirmDialog.stories.js +550 -0
  19. package/src/components/JhConfirmDialog/JhConfirmDialog.vue +181 -0
  20. package/src/components/JhDateRangePicker/JhDateRangePicker.md +56 -0
  21. package/src/components/JhDateRangePicker/JhDateRangePicker.stories.js +320 -0
  22. package/src/components/JhDateRangePicker/JhDateRangePicker.vue +307 -0
  23. package/src/components/JhDescriptions/JhDescriptions.md +724 -0
  24. package/src/components/JhDescriptions/JhDescriptions.stories.js +858 -0
  25. package/src/components/JhDescriptions/JhDescriptions.vue +933 -0
  26. package/src/components/JhDraggable/JhDraggable.md +66 -0
  27. package/src/components/JhDraggable/JhDraggable.stories.js +161 -0
  28. package/src/components/JhDraggable/JhDraggable.vue +254 -0
  29. package/src/components/JhDrawer/JhDrawer.md +68 -0
  30. package/src/components/JhDrawer/JhDrawer.stories.js +478 -0
  31. package/src/components/JhDrawer/JhDrawer.vue +281 -0
  32. package/src/components/JhDrawerForm/JhDrawerForm.md +69 -0
  33. package/src/components/JhDrawerForm/JhDrawerForm.stories.js +492 -0
  34. package/src/components/JhDrawerForm/JhDrawerForm.vue +297 -0
  35. package/src/components/JhEditableTable/JhEditableTable.md +507 -0
  36. package/src/components/JhEditableTable/JhEditableTable.stories.js +615 -0
  37. package/src/components/JhEditableTable/JhEditableTable.vue +685 -0
  38. package/src/components/JhFileInput/JhFileInput.md +56 -0
  39. package/src/components/JhFileInput/JhFileInput.stories.js +103 -0
  40. package/src/components/JhFileInput/JhFileInput.vue +253 -0
  41. package/src/components/JhForm/JhForm.md +676 -0
  42. package/src/components/JhForm/JhForm.stories.js +1375 -0
  43. package/src/components/JhForm/JhForm.vue +657 -0
  44. package/src/components/JhFormField/JhFormField.stories.js +217 -0
  45. package/src/components/JhFormField/JhFormField.vue +439 -0
  46. package/src/components/JhFormFields/JhFormFields.md +647 -0
  47. package/src/components/JhFormFields/JhFormFields.stories.js +922 -0
  48. package/src/components/JhFormFields/JhFormFields.vue +998 -0
  49. package/src/components/JhFormList/JhFormList.md +303 -0
  50. package/src/components/JhFormList/JhFormList.stories.js +661 -0
  51. package/src/components/JhFormList/JhFormList.vue +1127 -0
  52. package/src/components/JhJsonEditor/JhJsonEditor.md +54 -0
  53. package/src/components/JhJsonEditor/JhJsonEditor.stories.js +157 -0
  54. package/src/components/JhJsonEditor/JhJsonEditor.vue +178 -0
  55. package/src/components/JhLayout/JhLayout.md +580 -0
  56. package/src/components/JhLayout/JhLayout.stories.js +414 -0
  57. package/src/components/JhLayout/JhLayout.vue +387 -0
  58. package/src/components/JhList/JhList.md +441 -0
  59. package/src/components/JhList/JhList.stories.js +524 -0
  60. package/src/components/JhList/JhList.vue +571 -0
  61. package/src/components/JhMarkdownEditor/JhMarkdownEditor.md +56 -0
  62. package/src/components/JhMarkdownEditor/JhMarkdownEditor.stories.js +191 -0
  63. package/src/components/JhMarkdownEditor/JhMarkdownEditor.vue +188 -0
  64. package/src/components/JhMask/JhMask.md +62 -0
  65. package/src/components/JhMask/JhMask.stories.js +270 -0
  66. package/src/components/JhMask/JhMask.vue +123 -0
  67. package/src/components/JhMenu/JhMenu.md +85 -0
  68. package/src/components/JhMenu/JhMenu.stories.js +384 -0
  69. package/src/components/JhMenu/JhMenu.vue +545 -0
  70. package/src/components/JhModal/JhModal.md +68 -0
  71. package/src/components/JhModal/JhModal.stories.js +562 -0
  72. package/src/components/JhModal/JhModal.vue +235 -0
  73. package/src/components/JhModalForm/JhModalForm.md +69 -0
  74. package/src/components/JhModalForm/JhModalForm.stories.js +592 -0
  75. package/src/components/JhModalForm/JhModalForm.vue +298 -0
  76. package/src/components/JhPageContainer/JhPageContainer.md +409 -0
  77. package/src/components/JhPageContainer/JhPageContainer.stories.js +209 -0
  78. package/src/components/JhPageContainer/JhPageContainer.vue +72 -0
  79. package/src/components/JhQueryFilter/JhQueryFilter.md +77 -0
  80. package/src/components/JhQueryFilter/JhQueryFilter.stories.js +684 -0
  81. package/src/components/JhQueryFilter/JhQueryFilter.vue +429 -0
  82. package/src/components/JhScene/JhScene.md +64 -0
  83. package/src/components/JhScene/JhScene.stories.js +317 -0
  84. package/src/components/JhScene/JhScene.vue +376 -0
  85. package/src/components/JhStatisticCard/JhStatisticCard.md +363 -0
  86. package/src/components/JhStatisticCard/JhStatisticCard.stories.js +847 -0
  87. package/src/components/JhStatisticCard/JhStatisticCard.vue +459 -0
  88. package/src/components/JhStepsForm/JhStepsForm.md +666 -0
  89. package/src/components/JhStepsForm/JhStepsForm.stories.js +1224 -0
  90. package/src/components/JhStepsForm/JhStepsForm.vue +749 -0
  91. package/src/components/JhTable/JhTable.md +730 -0
  92. package/src/components/JhTable/JhTable.stories.js +1444 -0
  93. package/src/components/JhTable/JhTable.vue +2298 -0
  94. package/src/components/JhTableAttachment/JhTableAttachment.md +70 -0
  95. package/src/components/JhTableAttachment/JhTableAttachment.stories.js +198 -0
  96. package/src/components/JhTableAttachment/JhTableAttachment.vue +264 -0
  97. package/src/components/JhToast/JhToast.md +67 -0
  98. package/src/components/JhToast/JhToast.stories.js +386 -0
  99. package/src/components/JhToast/JhToast.vue +239 -0
  100. package/src/components/JhTreeSelect/JhTreeSelect.md +82 -0
  101. package/src/components/JhTreeSelect/JhTreeSelect.stories.js +391 -0
  102. package/src/components/JhTreeSelect/JhTreeSelect.vue +727 -0
  103. package/src/components/JhWaterMark/JhWaterMark.md +190 -0
  104. package/src/components/JhWaterMark/JhWaterMark.stories.js +675 -0
  105. package/src/components/JhWaterMark/JhWaterMark.vue +351 -0
  106. package/src/components/README.md +52 -0
  107. package/src/index.js +135 -0
  108. package/src/style/globalCSSJHV4.css +348 -0
  109. package/src/style/globalCSSVuetifyV4.css +637 -0
  110. package/src/style/storybook.css +4 -0
  111. package/src/tailwind.css +3 -0
  112. package/src/utils/vuetify.js +31 -0
@@ -0,0 +1,2298 @@
1
+ <template>
2
+ <div :class="wrapperClass" :style="wrapperStyle">
3
+ <!-- 表格标题区 -->
4
+ <div v-if="headerTitle || $slots['header-title'] || $slots['toolbar-extra']" class="jh-pro-table-header">
5
+ <div class="jh-pro-table-header-left">
6
+ <!-- 标题 -->
7
+ <div v-if="headerTitle || $slots['header-title']" class="jh-pro-table-title">
8
+ <slot name="header-title">
9
+ <span class="jh-pro-table-title-text">{{ headerTitle }}</span>
10
+ <v-tooltip v-if="tooltip" bottom>
11
+ <template v-slot:activator="{ on, attrs }">
12
+ <v-icon small class="ml-1" v-bind="attrs" v-on="on">mdi-help-circle-outline</v-icon>
13
+ </template>
14
+ <span>{{ tooltip }}</span>
15
+ </v-tooltip>
16
+ </slot>
17
+ </div>
18
+ </div>
19
+ <div class="jh-pro-table-header-right">
20
+ <!-- 右侧额外内容 -->
21
+ <slot name="toolbar-extra"></slot>
22
+ </div>
23
+ </div>
24
+
25
+ <!-- 高级筛选栏 -->
26
+ <jh-query-filter
27
+ v-if="hasFilterFields"
28
+ ref="queryFilterRef"
29
+ :fields="filterFieldsResolved"
30
+ :initial-values="computedFilterInitialValues"
31
+ :collapsible="filterCollapsible"
32
+ :default-collapsed="filterDefaultCollapsed"
33
+ :default-visible-count="filterDefaultVisibleCount"
34
+ :col-span="filterColSpan"
35
+ :dense="true"
36
+ :outlined="false"
37
+ :show-labels="false"
38
+ :search-text="filterSearchText"
39
+ :reset-text="filterResetText"
40
+ class="pa-0"
41
+ @search="handleFilterSearch"
42
+ @reset="handleFilterReset"
43
+ >
44
+ <!-- 自定义字段插槽透传 -->
45
+ <template v-for="field in filterFieldsResolved" v-slot:[`field-${field.key}`]="slotProps">
46
+ <slot :name="`filter-field-${field.key}`" v-bind="slotProps"></slot>
47
+ </template>
48
+
49
+ <!-- 自定义按钮插槽透传 -->
50
+ <template v-slot:buttons="slotProps">
51
+ <slot name="filter-buttons" v-bind="slotProps"></slot>
52
+ </template>
53
+ </jh-query-filter>
54
+
55
+ <!-- 批量操作提示栏 -->
56
+ <div v-if="showSelectionAlertBar" class="jh-pro-table-alert">
57
+ <div v-if="hasAlertContent" class="jh-pro-table-alert-info">
58
+ <template v-if="$scopedSlots.alert">
59
+ <slot name="alert" :selected-row-keys="selectedRowKeys" :selected-rows="selectedItems"></slot>
60
+ </template>
61
+ <render-function
62
+ v-else-if="typeof tableAlertRender === 'function'"
63
+ :render="tableAlertRender"
64
+ :params="tableAlertScope"
65
+ />
66
+ <template v-else>
67
+ <v-icon small class="mr-2" color="primary">mdi-checkbox-marked-circle</v-icon>
68
+ <span>已选择 <strong class="primary--text">{{ selectedItems.length }}</strong> 项</span>
69
+ <v-btn text x-small class="ml-2" @click="clearSelection">清空</v-btn>
70
+ </template>
71
+ </div>
72
+ <div v-if="hasAlertActionsContent" class="jh-pro-table-alert-actions">
73
+ <template v-if="$scopedSlots['alert-actions']">
74
+ <slot name="alert-actions" :selected-row-keys="selectedRowKeys" :selected-rows="selectedItems"></slot>
75
+ </template>
76
+ <render-function
77
+ v-else
78
+ :render="tableAlertOptionRender"
79
+ :params="tableAlertScope"
80
+ />
81
+ </div>
82
+ </div>
83
+
84
+ <!-- 工具栏 -->
85
+ <v-row v-if="toolbar !== false" class="jh-pro-table-toolbar ma-0 pb-3 pa-3 pa-md-0" align="center">
86
+ <!-- 左侧操作按钮插槽(仅在没有标题区时显示) -->
87
+ <div v-if="!headerTitle && !$slots['header-title']" class="flex items-center gap-2">
88
+ <slot name="toolbar-actions">
89
+ <v-btn
90
+ v-if="showCreateButton"
91
+ color="success"
92
+ class="mr-2"
93
+ @click.stop="$emit('create-click')"
94
+ small
95
+ >
96
+ <v-icon left small>mdi-plus</v-icon>
97
+ <span class="d-none d-sm-inline">新增</span>
98
+ </v-btn>
99
+ </slot>
100
+ </div>
101
+
102
+ <v-spacer></v-spacer>
103
+
104
+ <!-- 右侧工具栏 -->
105
+ <div class="flex items-center gap-2 justify-end flex-wrap">
106
+ <!-- 筛选搜索框 -->
107
+ <v-text-field
108
+ v-if="toolbarConfig.search"
109
+ v-model="searchInputInternal"
110
+ :prefix="isMobile ? '' : '筛选'"
111
+ :placeholder="isMobile ? '筛选' : ''"
112
+ class="jh-v-input jh-toolbar-search border"
113
+ dense
114
+ filled
115
+ single-line
116
+ hide-details
117
+ clearable
118
+ ></v-text-field>
119
+
120
+ <!-- 刷新按钮 -->
121
+ <v-btn
122
+ v-if="toolbarConfig.refresh"
123
+ icon
124
+ small
125
+ @click="handleRefresh"
126
+ title="刷新"
127
+ >
128
+ <v-icon>mdi-refresh</v-icon>
129
+ </v-btn>
130
+
131
+ <!-- 密度切换 -->
132
+ <v-menu v-if="toolbarConfig.density" offset-y>
133
+ <template v-slot:activator="{ on, attrs }">
134
+ <v-btn
135
+ icon
136
+ small
137
+ v-bind="attrs"
138
+ v-on="on"
139
+ title="密度"
140
+ >
141
+ <v-icon>mdi-format-line-spacing</v-icon>
142
+ </v-btn>
143
+ </template>
144
+ <v-list dense>
145
+ <v-list-item @click="currentDensity = 'default'">
146
+ <v-list-item-title>
147
+ <v-icon v-if="currentDensity === 'default'" small left>mdi-check</v-icon>
148
+ 默认
149
+ </v-list-item-title>
150
+ </v-list-item>
151
+ <v-list-item @click="currentDensity = 'medium'">
152
+ <v-list-item-title>
153
+ <v-icon v-if="currentDensity === 'medium'" small left>mdi-check</v-icon>
154
+ 中等
155
+ </v-list-item-title>
156
+ </v-list-item>
157
+ <v-list-item @click="currentDensity = 'compact'">
158
+ <v-list-item-title>
159
+ <v-icon v-if="currentDensity === 'compact'" small left>mdi-check</v-icon>
160
+ 紧凑
161
+ </v-list-item-title>
162
+ </v-list-item>
163
+ </v-list>
164
+ </v-menu>
165
+
166
+ <!-- 列设置 -->
167
+ <v-menu v-if="toolbarConfig.setting" offset-y left max-height="400">
168
+ <template v-slot:activator="{ on, attrs }">
169
+ <v-btn
170
+ icon
171
+ small
172
+ v-bind="attrs"
173
+ v-on="on"
174
+ title="列设置"
175
+ >
176
+ <v-icon>mdi-cog-outline</v-icon>
177
+ </v-btn>
178
+ </template>
179
+ <v-card min-width="200">
180
+ <v-card-text class="pa-2">
181
+ <div class="text-caption font-weight-bold mb-2">列显示</div>
182
+ <v-list dense>
183
+ <v-list-item
184
+ v-for="col in internalColumns"
185
+ :key="col.value"
186
+ @click="toggleColumn(col)"
187
+ class="px-2"
188
+ >
189
+ <v-list-item-action class="mr-2">
190
+ <v-checkbox
191
+ :input-value="col.visible !== false"
192
+ hide-details
193
+ dense
194
+ @click.stop="toggleColumn(col)"
195
+ ></v-checkbox>
196
+ </v-list-item-action>
197
+ <v-list-item-content>
198
+ <v-list-item-title class="text-body-2">{{ col.text || col.title }}</v-list-item-title>
199
+ </v-list-item-content>
200
+ </v-list-item>
201
+ </v-list>
202
+ <v-divider class="my-2"></v-divider>
203
+ <v-btn text small block @click="resetColumns">重置</v-btn>
204
+ </v-card-text>
205
+ </v-card>
206
+ </v-menu>
207
+
208
+ <!-- 全屏 -->
209
+ <v-btn
210
+ v-if="toolbarConfig.fullscreen"
211
+ icon
212
+ small
213
+ @click="toggleFullscreen"
214
+ :title="isFullscreen ? '退出全屏' : '全屏'"
215
+ >
216
+ <v-icon>{{ isFullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen' }}</v-icon>
217
+ </v-btn>
218
+ </div>
219
+ </v-row>
220
+
221
+ <!-- 表格额外内容区 -->
222
+ <div v-if="$slots['table-extra']" class="jh-pro-table-extra">
223
+ <slot name="table-extra"></slot>
224
+ </div>
225
+
226
+ <!-- 表格 -->
227
+ <v-data-table
228
+ ref="dataTable"
229
+ v-bind="mergedDataTableProps"
230
+ v-on="dataTableListeners"
231
+ :headers="visibleHeaders"
232
+ :items="currentItems"
233
+ :search="searchInputInternal"
234
+ :footer-props="currentFooterProps"
235
+ :items-per-page="currentItemsPerPage"
236
+ :page="currentPage"
237
+ :server-items-length="serverItemsLength"
238
+ :mobile-breakpoint="mobileBreakpoint"
239
+ :loading="currentLoading"
240
+ :checkbox-color="checkboxColor"
241
+ :class="[tableClassComputed, densityClass]"
242
+ :fixed-header="fixedHeader"
243
+ :show-select="showSelectComputed"
244
+ :single-select="singleSelectComputed"
245
+ :value="selectedItems"
246
+ :item-key="rowKey"
247
+ :dense="dense"
248
+ :multi-sort="multiSort"
249
+ :must-sort="mustSort"
250
+ :sort-by="internalSortBy"
251
+ :sort-desc="internalSortDesc"
252
+ class="jh-fixed-table-height elevation-0 mb-xs-4 mx-3 mx-md-0 mt-3"
253
+ @click:row="handleRowClick"
254
+ @input="handleSelectionChange"
255
+ @update:page="handlePageChange"
256
+ @update:items-per-page="handleItemsPerPageChange"
257
+ @update:sort-by="handleSortByUpdate"
258
+ @update:sort-desc="handleSortDescUpdate"
259
+ @update:options="handleOptionsUpdate"
260
+ >
261
+ <!-- 自定义表头插槽 -->
262
+ <template
263
+ v-for="header in customHeaderSlots"
264
+ v-slot:[`header.${header.value}`]="{ header: h }"
265
+ >
266
+ <slot :name="`header.${header.value}`" :header="h">{{ h.text || h.title }}</slot>
267
+ </template>
268
+
269
+ <!-- 自定义列插槽 -->
270
+ <template
271
+ v-for="header in visibleHeaders"
272
+ v-slot:[`item.${header.value}`]="{ item, value, index }"
273
+ >
274
+ <!-- 操作列特殊处理 -->
275
+ <template v-if="header.value === 'action'">
276
+ <slot name="item.action" :item="item">
277
+ <!-- PC端默认操作 -->
278
+ <div v-if="!isMobile" class="flex items-center gap-1">
279
+ <!-- 配置的操作按钮 -->
280
+ <template v-if="actionColumn && actionColumn.buttons">
281
+ <template v-for="(btn, idx) in getVisibleButtons(item)">
282
+ <v-tooltip v-if="btn.tooltip" :key="`tooltip-${idx}`" bottom>
283
+ <template v-slot:activator="{ on, attrs }">
284
+ <v-btn
285
+ x-small
286
+ :text="btn.type === 'link'"
287
+ :icon="btn.type === 'icon'"
288
+ :color="btn.color || 'primary'"
289
+ v-bind="attrs"
290
+ v-on="on"
291
+ @click.stop="handleActionClick(btn, item)"
292
+ >
293
+ <v-icon v-if="btn.icon" small>{{ btn.icon }}</v-icon>
294
+ <span v-if="btn.type !== 'icon'">{{ btn.text }}</span>
295
+ </v-btn>
296
+ </template>
297
+ <span>{{ btn.tooltip }}</span>
298
+ </v-tooltip>
299
+ <v-btn
300
+ v-else
301
+ :key="`btn-${idx}`"
302
+ x-small
303
+ :text="btn.type === 'link'"
304
+ :icon="btn.type === 'icon'"
305
+ :color="btn.color || 'primary'"
306
+ @click.stop="handleActionClick(btn, item)"
307
+ >
308
+ <v-icon v-if="btn.icon" small>{{ btn.icon }}</v-icon>
309
+ <span v-if="btn.type !== 'icon'">{{ btn.text }}</span>
310
+ </v-btn>
311
+ </template>
312
+ </template>
313
+
314
+ <!-- 默认操作按钮 -->
315
+ <template v-else>
316
+ <span
317
+ v-if="showUpdateAction"
318
+ role="button"
319
+ class="success--text font-weight-medium font-size-2 mr-2 cursor-pointer"
320
+ @click.stop="$emit('update-click', item)"
321
+ >
322
+ <v-icon size="16" class="success--text">mdi-note-edit-outline</v-icon>详情
323
+ </span>
324
+ <span
325
+ v-if="showDeleteAction"
326
+ role="button"
327
+ class="error--text font-weight-medium font-size-2 mr-2 cursor-pointer"
328
+ @click.stop="$emit('delete-click', item)"
329
+ >
330
+ <v-icon size="16" class="error--text">mdi-trash-can-outline</v-icon>删除
331
+ </span>
332
+ </template>
333
+ </div>
334
+
335
+ <!-- 手机端菜单 -->
336
+ <v-menu v-if="isMobile" offset-y>
337
+ <template v-slot:activator="{ on, attrs }">
338
+ <span
339
+ role="button"
340
+ class="success--text font-weight-medium font-size-2"
341
+ v-bind="attrs"
342
+ v-on="on"
343
+ >
344
+ <v-icon size="20" class="success--text">mdi-chevron-down</v-icon>操作
345
+ </span>
346
+ </template>
347
+ <v-list dense>
348
+ <template v-if="actionColumn && actionColumn.buttons">
349
+ <v-list-item
350
+ v-for="(btn, idx) in getVisibleButtons(item)"
351
+ :key="idx"
352
+ @click.stop="handleActionClick(btn, item)"
353
+ >
354
+ <v-list-item-icon v-if="btn.icon">
355
+ <v-icon small>{{ btn.icon }}</v-icon>
356
+ </v-list-item-icon>
357
+ <v-list-item-title>{{ btn.text }}</v-list-item-title>
358
+ </v-list-item>
359
+ </template>
360
+ <template v-else>
361
+ <v-list-item v-if="showUpdateAction" @click.stop="$emit('update-click', item)">
362
+ <v-list-item-title>详情</v-list-item-title>
363
+ </v-list-item>
364
+ <v-list-item v-if="showDeleteAction" @click.stop="$emit('delete-click', item)">
365
+ <v-list-item-title>删除</v-list-item-title>
366
+ </v-list-item>
367
+ </template>
368
+ </v-list>
369
+ </v-menu>
370
+ </slot>
371
+ </template>
372
+
373
+ <!-- 普通列 -->
374
+ <template v-else>
375
+ <!-- 自定义插槽 -->
376
+ <slot v-if="header.slot || $scopedSlots[`item.${header.value}`]" :name="`item.${header.value}`" :item="item" :value="value"></slot>
377
+
378
+ <!-- 列配置渲染 -->
379
+ <div v-else class="jh-table-schema-cell d-flex align-center flex-wrap">
380
+ <!-- 状态标签 -->
381
+ <template v-if="header.valueType === 'status'">
382
+ <v-chip
383
+ small
384
+ label
385
+ v-bind="getStatusChipProps(header, value, item)"
386
+ class="mr-2"
387
+ >
388
+ <v-icon v-if="getStatusChipProps(header, value, item).icon" left x-small>
389
+ {{ getStatusChipProps(header, value, item).icon }}
390
+ </v-icon>
391
+ {{ getStatusText(header, value, item) }}
392
+ </v-chip>
393
+ </template>
394
+
395
+ <!-- 进度类型 -->
396
+ <template v-else-if="header.valueType === 'progress'">
397
+ <div class="d-flex align-center w-100">
398
+ <v-progress-linear
399
+ v-bind="getProgressProps(header)"
400
+ :value="getProgressValue(value)"
401
+ class="flex-grow-1"
402
+ ></v-progress-linear>
403
+ <span v-if="getProgressProps(header).showValue !== false" class="ml-2 text-caption">
404
+ {{ formatPercentValue(getProgressValue(value), header.valueProps) }}
405
+ </span>
406
+ </div>
407
+ </template>
408
+
409
+ <!-- 头像类型 -->
410
+ <template v-else-if="header.valueType === 'avatar'">
411
+ <v-avatar v-bind="getAvatarProps(header)">
412
+ <img v-if="getAvatarSrc(value)" :src="getAvatarSrc(value)" :alt="formatColumnValue(header, item, value, index)">
413
+ <span v-else>{{ getAvatarInitials(formatColumnValue(header, item, value, index)) }}</span>
414
+ </v-avatar>
415
+ <span class="ml-2">{{ formatColumnValue(header, item, value, index) }}</span>
416
+ </template>
417
+
418
+ <!-- JSON 展示 -->
419
+ <pre v-else-if="header.valueType === 'json'" class="jh-table-json mr-2">
420
+ {{ formatJsonValue(value) }}
421
+ </pre>
422
+
423
+ <!-- 索引类型 -->
424
+ <span v-else-if="header.valueType === 'index'">{{ getDisplayIndex(index) }}</span>
425
+
426
+ <!-- 代码块 -->
427
+ <code v-else-if="header.valueType === 'code'" class="jh-table-code">{{ formatColumnValue(header, item, value, index) }}</code>
428
+
429
+ <!-- 日期范围 -->
430
+ <span v-else-if="header.valueType === 'dateRange'">{{ formatDateRangeValue(value, header.valueProps) }}</span>
431
+
432
+ <!-- 日期/时间 -->
433
+ <span v-else-if="header.valueType === 'date' || header.valueType === 'dateTime' || header.valueType === 'time'">
434
+ {{ formatDateValue(value, header.valueType, header.valueProps) }}
435
+ </span>
436
+
437
+ <!-- 金额 -->
438
+ <span v-else-if="header.valueType === 'money'">{{ formatMoneyValue(value, header.valueProps) }}</span>
439
+
440
+ <!-- 百分比 -->
441
+ <span v-else-if="header.valueType === 'percent'">{{ formatPercentValue(value, header.valueProps) }}</span>
442
+
443
+ <!-- 数字 -->
444
+ <span v-else-if="header.valueType === 'digit'">{{ formatDigitValue(value, header.valueProps) }}</span>
445
+
446
+ <!-- 默认文本,包含省略逻辑 -->
447
+ <template v-else>
448
+ <v-tooltip v-if="shouldShowEllipsis(header, value, item, index)" bottom>
449
+ <template v-slot:activator="{ on, attrs }">
450
+ <span
451
+ v-bind="attrs"
452
+ v-on="on"
453
+ class="text-truncate d-inline-block"
454
+ style="max-width: 200px;"
455
+ >
456
+ {{ formatColumnValue(header, item, value, index) }}
457
+ </span>
458
+ </template>
459
+ <span>{{ formatColumnValue(header, item, value, index) }}</span>
460
+ </v-tooltip>
461
+ <span v-else>{{ formatColumnValue(header, item, value, index) }}</span>
462
+ </template>
463
+
464
+ <!-- 复制按钮 -->
465
+ <v-btn
466
+ v-if="header.copyable && hasCopyValue(header, item, value)"
467
+ icon
468
+ x-small
469
+ class="ml-1"
470
+ @click.stop="copyToClipboard(getCopyValue(header, item, value))"
471
+ >
472
+ <v-icon x-small>mdi-content-copy</v-icon>
473
+ </v-btn>
474
+ </div>
475
+ </template>
476
+ </template>
477
+
478
+ <!-- 加载中 -->
479
+ <template v-slot:loading>
480
+ <div class="jh-no-data pa-6">
481
+ <v-progress-circular indeterminate color="primary"></v-progress-circular>
482
+ <div class="mt-2">数据加载中...</div>
483
+ </div>
484
+ </template>
485
+
486
+ <!-- 无数据 -->
487
+ <template v-slot:no-data>
488
+ <div class="jh-no-data pa-6">
489
+ <v-icon large color="grey lighten-1">mdi-inbox-outline</v-icon>
490
+ <div class="mt-2 text-body-2 grey--text">暂无数据</div>
491
+ </div>
492
+ </template>
493
+
494
+ <!-- 无结果 -->
495
+ <template v-slot:no-results>
496
+ <div class="jh-no-data pa-6">
497
+ <v-icon large color="grey lighten-1">mdi-magnify</v-icon>
498
+ <div class="mt-2 text-body-2 grey--text">未找到匹配的数据</div>
499
+ </div>
500
+ </template>
501
+
502
+ <!-- 分页文本 -->
503
+ <template v-slot:[`footer.page-text`]="pagination">
504
+ <span>{{ pagination.pageStart }}-{{ pagination.pageStop }}</span>
505
+ <span class="ml-1">共{{ pagination.itemsLength }}条</span>
506
+ </template>
507
+ </v-data-table>
508
+ </div>
509
+ </template>
510
+
511
+ <script>
512
+ import JhQueryFilter from '../JhQueryFilter/JhQueryFilter.vue';
513
+
514
+ const DEFAULT_STATUS_COLOR_MAP = {
515
+ success: { color: 'success', textColor: 'white' },
516
+ warning: { color: 'warning', textColor: 'white' },
517
+ error: { color: 'error', textColor: 'white' },
518
+ processing: { color: 'info', textColor: 'white' },
519
+ default: { color: 'grey lighten-2', textColor: 'grey darken-2' }
520
+ };
521
+
522
+ const RenderFunction = {
523
+ name: 'JhTableRenderFunction',
524
+ functional: true,
525
+ props: {
526
+ render: {
527
+ type: Function,
528
+ default: null
529
+ },
530
+ params: {
531
+ type: Object,
532
+ default: () => ({})
533
+ }
534
+ },
535
+ render(h, context) {
536
+ const renderFn = context.props.render;
537
+ if (typeof renderFn !== 'function') {
538
+ return null;
539
+ }
540
+ const params = context.props.params || {};
541
+ if (renderFn.length >= 2) {
542
+ return renderFn(h, params);
543
+ }
544
+ return renderFn(params);
545
+ }
546
+ };
547
+
548
+ export default {
549
+ name: 'JhTable',
550
+ inheritAttrs: false,
551
+ components: {
552
+ JhQueryFilter,
553
+ RenderFunction
554
+ },
555
+ props: {
556
+ // ========== 数据相关 ==========
557
+ // 表格表头配置
558
+ headers: {
559
+ type: Array,
560
+ required: true,
561
+ default: () => []
562
+ },
563
+ // 表格数据(客户端分页)
564
+ items: {
565
+ type: Array,
566
+ default: () => []
567
+ },
568
+ // 数据请求函数(服务端分页)
569
+ request: {
570
+ type: Function,
571
+ default: null
572
+ },
573
+
574
+ // ========== 高级筛选栏配置 ==========
575
+ // 是否显示筛选栏
576
+ showFilter: {
577
+ type: Boolean,
578
+ default: false
579
+ },
580
+ // 筛选字段配置
581
+ filterFields: {
582
+ type: Array,
583
+ default: () => []
584
+ },
585
+ // 是否根据列配置自动生成筛选字段
586
+ autoFilterFromHeaders: {
587
+ type: Boolean,
588
+ default: true
589
+ },
590
+ // 筛选初始值
591
+ filterInitialValues: {
592
+ type: Object,
593
+ default: () => ({})
594
+ },
595
+ // 筛选栏是否可折叠
596
+ filterCollapsible: {
597
+ type: Boolean,
598
+ default: true
599
+ },
600
+ // 筛选栏默认是否折叠
601
+ filterDefaultCollapsed: {
602
+ type: Boolean,
603
+ default: true
604
+ },
605
+ // 筛选栏默认显示字段数量
606
+ filterDefaultVisibleCount: {
607
+ type: Number,
608
+ default: 3
609
+ },
610
+ // 筛选栏列宽配置
611
+ filterColSpan: {
612
+ type: Object,
613
+ default: () => ({
614
+ xs: 24,
615
+ sm: 12,
616
+ md: 6,
617
+ lg: 4,
618
+ })
619
+ },
620
+ // 筛选栏查询按钮文本
621
+ filterSearchText: {
622
+ type: String,
623
+ default: '查询'
624
+ },
625
+ // 筛选栏重置按钮文本
626
+ filterResetText: {
627
+ type: String,
628
+ default: '重置'
629
+ },
630
+
631
+ // ========== 工具栏配置 ==========
632
+ // 工具栏配置
633
+ toolbar: {
634
+ type: [Object, Boolean],
635
+ default: () => ({
636
+ search: true,
637
+ refresh: true,
638
+ setting: true,
639
+ density: true,
640
+ fullscreen: false
641
+ })
642
+ },
643
+ // 显示搜索框
644
+ showSearch: {
645
+ type: Boolean,
646
+ default: true
647
+ },
648
+ searchInput: {
649
+ type: String,
650
+ default: null
651
+ },
652
+ // 原始 v-data-table 透传配置
653
+ dataTableProps: {
654
+ type: Object,
655
+ default: () => ({})
656
+ },
657
+ sortBy: {
658
+ type: [Array, String],
659
+ default: () => []
660
+ },
661
+ sortDesc: {
662
+ type: [Array, Boolean],
663
+ default: () => []
664
+ },
665
+ multiSort: {
666
+ type: Boolean,
667
+ default: false
668
+ },
669
+ mustSort: {
670
+ type: Boolean,
671
+ default: false
672
+ },
673
+
674
+ // ========== 按钮配置 ==========
675
+ showCreateButton: {
676
+ type: Boolean,
677
+ default: true
678
+ },
679
+ showUpdateAction: {
680
+ type: Boolean,
681
+ default: true
682
+ },
683
+ showDeleteAction: {
684
+ type: Boolean,
685
+ default: true
686
+ },
687
+
688
+ // ========== 操作列配置 ==========
689
+ // 操作列配置
690
+ actionColumn: {
691
+ type: [Object, Boolean],
692
+ default: null
693
+ // {
694
+ // title: '操作',
695
+ // width: 180,
696
+ // fixed: 'right',
697
+ // buttons: [
698
+ // {
699
+ // text: '编辑',
700
+ // type: 'link', // link / icon / button
701
+ // icon: 'mdi-pencil',
702
+ // color: 'primary',
703
+ // tooltip: '编辑记录',
704
+ // onClick: (row) => {},
705
+ // visible: (row) => true,
706
+ // confirm: '确认编辑?'
707
+ // }
708
+ // ]
709
+ // }
710
+ },
711
+ // 列状态配置
712
+ columnsState: {
713
+ type: Object,
714
+ default: () => ({
715
+ persistenceKey: '',
716
+ defaultVisible: null,
717
+ value: null
718
+ })
719
+ },
720
+
721
+ // ========== 分页配置 ==========
722
+ pagination: {
723
+ type: [Object, Boolean],
724
+ default: () => ({
725
+ current: 1,
726
+ pageSize: 20,
727
+ pageSizeOptions: [10, 20, 50, 100],
728
+ showTotal: true
729
+ })
730
+ },
731
+ itemsPerPage: {
732
+ type: Number,
733
+ default: 20
734
+ },
735
+
736
+ // ========== 选择配置 ==========
737
+ // 行选择
738
+ rowSelection: {
739
+ type: [Object, Boolean],
740
+ default: null
741
+ },
742
+ tableAlertRender: {
743
+ type: [Function, Boolean],
744
+ default: null
745
+ },
746
+ tableAlertOptionRender: {
747
+ type: [Function, Boolean],
748
+ default: null
749
+ },
750
+ showSelect: {
751
+ type: Boolean,
752
+ default: false
753
+ },
754
+ singleSelect: {
755
+ type: Boolean,
756
+ default: false
757
+ },
758
+
759
+ // ========== 标题和样式配置 ==========
760
+ // 表格标题
761
+ headerTitle: {
762
+ type: String,
763
+ default: ''
764
+ },
765
+ // 标题提示信息
766
+ tooltip: {
767
+ type: String,
768
+ default: ''
769
+ },
770
+ // 是否显示卡片边框
771
+ cardBordered: {
772
+ type: Boolean,
773
+ default: true
774
+ },
775
+ // 幽灵模式(无边框无背景)
776
+ ghost: {
777
+ type: Boolean,
778
+ default: false
779
+ },
780
+
781
+ // ========== 轮询配置 ==========
782
+ // 轮询间隔(毫秒),0 表示不轮询
783
+ polling: {
784
+ type: Number,
785
+ default: 0
786
+ },
787
+ // 搜索防抖时间(毫秒)
788
+ debounceTime: {
789
+ type: Number,
790
+ default: 300
791
+ },
792
+
793
+ // ========== 其他配置 ==========
794
+ loading: {
795
+ type: Boolean,
796
+ default: false
797
+ },
798
+ rowKey: {
799
+ type: String,
800
+ default: 'id'
801
+ },
802
+ size: {
803
+ type: String,
804
+ default: 'default', // default / medium / compact
805
+ validator: (v) => ['default', 'medium', 'compact'].includes(v)
806
+ },
807
+ footerProps: {
808
+ type: Object,
809
+ default: () => ({
810
+ itemsPerPageOptions: [10, 20, 50, 100],
811
+ itemsPerPageText: '每页',
812
+ itemsPerPageAllText: '所有',
813
+ showFirstLastPage: true,
814
+ showCurrentPage: true
815
+ })
816
+ },
817
+ mobileBreakpoint: {
818
+ type: Number,
819
+ default: 600
820
+ },
821
+ checkboxColor: {
822
+ type: String,
823
+ default: 'success'
824
+ },
825
+ tableClass: {
826
+ type: [String, Object, Array],
827
+ default: () => ({ zebraLine: true })
828
+ },
829
+ fixedHeader: {
830
+ type: Boolean,
831
+ default: true
832
+ },
833
+ dense: {
834
+ type: Boolean,
835
+ default: false
836
+ }
837
+ },
838
+ data() {
839
+ return {
840
+ isMobile: window.innerWidth < 500,
841
+ searchInputInternal: this.searchInput,
842
+ currentLoading: this.loading,
843
+ currentItems: this.items || [],
844
+ currentPage: this.pagination ? this.pagination.current || 1 : 1,
845
+ currentItemsPerPage: this.itemsPerPage,
846
+ serverItemsLength: -1,
847
+ internalColumns: [],
848
+ currentDensity: this.size,
849
+ isFullscreen: false,
850
+ selectedItems: [],
851
+ filterValues: {},
852
+ pollingTimer: null,
853
+ searchDebounceTimer: null,
854
+ internalSortBy: this.normalizeSortBy(this.sortBy),
855
+ internalSortDesc: this.normalizeSortDesc(this.sortDesc),
856
+ sortChangeTimer: null,
857
+ hasAppliedDefaultRowSelection: false
858
+ };
859
+ },
860
+ computed: {
861
+ // 选中的行 keys
862
+ selectedRowKeys() {
863
+ return this.selectedItems.map(item => item[this.rowKey]);
864
+ },
865
+
866
+ tableAlertScope() {
867
+ return {
868
+ selectedRowKeys: this.selectedRowKeys,
869
+ selectedRows: this.selectedItems,
870
+ onCleanSelected: this.clearSelection
871
+ };
872
+ },
873
+ hasFilterFields() {
874
+ return this.showFilter && this.filterFieldsResolved.length > 0;
875
+ },
876
+ filterFieldsResolved() {
877
+ const manualFields = Array.isArray(this.filterFields) ? this.filterFields.map(field => ({ ...field })) : [];
878
+ const manualKeys = new Set(manualFields.map(field => field?.key).filter(Boolean));
879
+ const autoFields = this.autoFilterConfig.fields.filter(field => !manualKeys.has(field.key));
880
+ return [...manualFields, ...autoFields];
881
+ },
882
+ computedFilterInitialValues() {
883
+ const initialValues = {
884
+ ...this.autoFilterConfig.initialValues,
885
+ ...(this.filterInitialValues || {})
886
+ };
887
+ this.filterFieldsResolved.forEach(field => {
888
+ if (!field || !field.key) return;
889
+ if (initialValues[field.key] === undefined) {
890
+ if (field.initialValue !== undefined) {
891
+ initialValues[field.key] = field.initialValue;
892
+ } else if (field.defaultValue !== undefined) {
893
+ initialValues[field.key] = field.defaultValue;
894
+ }
895
+ }
896
+ });
897
+ return initialValues;
898
+ },
899
+ autoFilterConfig() {
900
+ const result = {
901
+ fields: [],
902
+ meta: {},
903
+ initialValues: {}
904
+ };
905
+ if (!this.autoFilterFromHeaders || !Array.isArray(this.internalColumns) || !this.internalColumns.length) {
906
+ return result;
907
+ }
908
+ this.internalColumns.forEach(column => {
909
+ const config = this.buildAutoFilterField(column);
910
+ if (config) {
911
+ result.fields.push(config.field);
912
+ result.meta[config.field.key] = { transform: config.transform };
913
+ if (config.initialValue !== undefined) {
914
+ result.initialValues[config.field.key] = config.initialValue;
915
+ }
916
+ }
917
+ });
918
+ return result;
919
+ },
920
+ filterFieldMetaMap() {
921
+ const meta = { ...this.autoFilterConfig.meta };
922
+ (this.filterFields || []).forEach(field => {
923
+ if (field && field.key) {
924
+ meta[field.key] = {
925
+ ...(meta[field.key] || {}),
926
+ transform: field.transform || (meta[field.key] && meta[field.key].transform)
927
+ };
928
+ }
929
+ });
930
+ this.filterFieldsResolved.forEach(field => {
931
+ if (field && field.key && field.transform) {
932
+ meta[field.key] = {
933
+ ...(meta[field.key] || {}),
934
+ transform: field.transform
935
+ };
936
+ }
937
+ });
938
+ return meta;
939
+ },
940
+ hasAlertContent() {
941
+ if (this.$scopedSlots.alert) {
942
+ return true;
943
+ }
944
+ if (typeof this.tableAlertRender === 'function') {
945
+ return true;
946
+ }
947
+ return this.tableAlertRender !== false;
948
+ },
949
+ hasAlertActionsContent() {
950
+ return !!this.$scopedSlots['alert-actions'] || typeof this.tableAlertOptionRender === 'function';
951
+ },
952
+ showSelectionAlertBar() {
953
+ if (!this.showSelectComputed) return false;
954
+ if (!this.selectedItems.length) return false;
955
+ return this.hasAlertContent || this.hasAlertActionsContent;
956
+ },
957
+ // 工具栏配置
958
+ toolbarConfig() {
959
+ if (this.toolbar === false) return {};
960
+ if (typeof this.toolbar === 'object') {
961
+ return {
962
+ search: this.showSearch,
963
+ refresh: true,
964
+ setting: true,
965
+ density: true,
966
+ fullscreen: false,
967
+ ...this.toolbar
968
+ };
969
+ }
970
+ return {
971
+ search: this.showSearch,
972
+ refresh: true,
973
+ setting: true,
974
+ density: true,
975
+ fullscreen: false
976
+ };
977
+ },
978
+ // 可见的表头
979
+ visibleHeaders() {
980
+ return this.internalColumns.filter(h => h.visible !== false);
981
+ },
982
+ // 需要自定义表头插槽的列
983
+ customHeaderSlots() {
984
+ return this.internalColumns.filter(h => this.$scopedSlots[`header.${h.value}`]);
985
+ },
986
+ // 表格样式类
987
+ tableClassComputed() {
988
+ return [
989
+ this.tableClass,
990
+ {
991
+ 'jh-table-fullscreen': this.isFullscreen
992
+ }
993
+ ];
994
+ },
995
+ // 密度样式类
996
+ densityClass() {
997
+ return {
998
+ 'jh-table-default': this.currentDensity === 'default',
999
+ 'jh-table-medium': this.currentDensity === 'medium',
1000
+ 'jh-table-compact': this.currentDensity === 'compact'
1001
+ };
1002
+ },
1003
+ // 分页配置
1004
+ currentFooterProps() {
1005
+ const defaultProps = {
1006
+ itemsPerPageOptions: [10, 20, 50, 100],
1007
+ itemsPerPageText: '每页',
1008
+ itemsPerPageAllText: '所有',
1009
+ showFirstLastPage: true,
1010
+ showCurrentPage: true
1011
+ };
1012
+ return { ...defaultProps, ...this.footerProps };
1013
+ },
1014
+ showSelectComputed() {
1015
+ if (this.rowSelection === false) return false;
1016
+ if (this.rowSelection && typeof this.rowSelection === 'object') {
1017
+ return this.rowSelection.show !== false;
1018
+ }
1019
+ return this.showSelect;
1020
+ },
1021
+ singleSelectComputed() {
1022
+ if (this.rowSelection && this.rowSelection.type === 'radio') {
1023
+ return true;
1024
+ }
1025
+ return this.singleSelect;
1026
+ },
1027
+ isRowSelectionControlled() {
1028
+ return !!(this.rowSelection && Array.isArray(this.rowSelection.selectedRowKeys));
1029
+ },
1030
+ wrapperClass() {
1031
+ return [
1032
+ 'jh-pro-table',
1033
+ this.$attrs.class,
1034
+ {
1035
+ 'jh-pro-table-card': this.cardBordered,
1036
+ 'jh-pro-table-ghost': this.ghost
1037
+ }
1038
+ ];
1039
+ },
1040
+ wrapperStyle() {
1041
+ return this.$attrs.style || null;
1042
+ },
1043
+ mergedDataTableProps() {
1044
+ // 只排除组件内部明确处理的属性,其他所有属性都透传给 v-data-table
1045
+ // 这些属性在组件内部有特殊处理逻辑,需要排除避免冲突
1046
+ const excludedAttrs = [
1047
+ 'class', 'style',
1048
+ // 这些属性在组件内部有特殊处理
1049
+ 'headers', 'items', 'search', 'footer-props',
1050
+ 'items-per-page', 'page', 'server-items-length', 'mobile-breakpoint',
1051
+ 'loading', 'checkbox-color', 'fixed-header', 'show-select',
1052
+ 'single-select', 'value', 'item-key', 'dense', 'multi-sort',
1053
+ 'must-sort', 'sort-by', 'sort-desc',
1054
+ // JhTable 特有的 props(不在 v-data-table 中)
1055
+ 'request', 'headerTitle', 'tooltip', 'cardBordered', 'ghost',
1056
+ 'toolbar', 'showSearch', 'searchInput', 'showFilter', 'filterFields',
1057
+ 'filterInitialValues', 'autoFilterFromHeaders', 'filterCollapsible',
1058
+ 'filterDefaultCollapsed', 'filterDefaultVisibleCount', 'filterColSpan',
1059
+ 'filterSearchText', 'filterResetText', 'showCreateButton', 'showUpdateAction',
1060
+ 'showDeleteAction', 'actionColumn', 'columnsState', 'pagination',
1061
+ 'itemsPerPage', 'rowSelection', 'tableAlertRender', 'tableAlertOptionRender',
1062
+ 'showSelect', 'singleSelect', 'rowKey', 'size', 'footerProps',
1063
+ 'tableClass', 'polling', 'debounceTime', 'dataTableProps'
1064
+ ];
1065
+
1066
+ const { class: cls, style, ...rest } = this.$attrs || {};
1067
+ const filteredAttrs = {};
1068
+
1069
+ Object.keys(rest).forEach(key => {
1070
+ const keyKebab = key.replace(/([A-Z])/g, '-$1').toLowerCase();
1071
+ // 只排除明确处理的属性,其他都透传
1072
+ if (!excludedAttrs.includes(key) && !excludedAttrs.includes(keyKebab)) {
1073
+ filteredAttrs[key] = rest[key];
1074
+ }
1075
+ });
1076
+
1077
+ // dataTableProps 优先级最高,可以覆盖任何属性(包括透传的属性)
1078
+ return {
1079
+ ...filteredAttrs,
1080
+ ...this.dataTableProps
1081
+ };
1082
+ },
1083
+ dataTableListeners() {
1084
+ // 只排除组件内部明确处理的事件,其他所有事件都透传给 v-data-table
1085
+ // 这样用户可以使用 v-data-table 的所有原生事件
1086
+ const excludedEvents = [
1087
+ // 这些事件在组件内部有特殊处理逻辑
1088
+ 'click:row', 'input', 'update:page', 'update:items-per-page',
1089
+ 'update:sort-by', 'update:sort-desc', 'update:options',
1090
+ // JhTable 特有的事件(不在 v-data-table 中)
1091
+ 'create-click', 'update-click', 'delete-click', 'row-click',
1092
+ 'selection-change', 'refresh', 'copy-success', 'request-error',
1093
+ 'filter-search', 'filter-reset', 'columns-state-change', 'sort-change',
1094
+ 'update:searchInput'
1095
+ ];
1096
+
1097
+ const listeners = { ...this.$listeners || {} };
1098
+ const filteredListeners = {};
1099
+
1100
+ Object.keys(listeners).forEach(key => {
1101
+ // 只排除明确处理的事件,其他都透传
1102
+ if (!excludedEvents.includes(key)) {
1103
+ filteredListeners[key] = listeners[key];
1104
+ }
1105
+ });
1106
+
1107
+ return filteredListeners;
1108
+ }
1109
+ },
1110
+ watch: {
1111
+ headers: {
1112
+ immediate: true,
1113
+ handler(val) {
1114
+ this.initColumns(val);
1115
+ }
1116
+ },
1117
+ items(val) {
1118
+ if (!this.request) {
1119
+ this.currentItems = val || [];
1120
+ this.$nextTick(() => this.applySelectionState());
1121
+ }
1122
+ },
1123
+ loading(val) {
1124
+ this.currentLoading = val;
1125
+ },
1126
+ searchInput(val) {
1127
+ this.searchInputInternal = val;
1128
+ },
1129
+ searchInputInternal(val) {
1130
+ this.$emit('update:searchInput', val);
1131
+ if (this.request) {
1132
+ // 使用防抖
1133
+ if (this.searchDebounceTimer) {
1134
+ clearTimeout(this.searchDebounceTimer);
1135
+ }
1136
+ this.searchDebounceTimer = setTimeout(() => {
1137
+ this.reload();
1138
+ }, this.debounceTime);
1139
+ }
1140
+ },
1141
+ size(val) {
1142
+ this.currentDensity = val;
1143
+ },
1144
+ sortBy(val) {
1145
+ this.internalSortBy = this.normalizeSortBy(val);
1146
+ this.scheduleSortChangeEmit();
1147
+ },
1148
+ sortDesc(val) {
1149
+ this.internalSortDesc = this.normalizeSortDesc(val);
1150
+ this.scheduleSortChangeEmit();
1151
+ },
1152
+ rowSelection: {
1153
+ deep: true,
1154
+ handler() {
1155
+ this.hasAppliedDefaultRowSelection = false;
1156
+ this.$nextTick(() => this.applySelectionState());
1157
+ }
1158
+ },
1159
+ columnsState: {
1160
+ deep: true,
1161
+ handler(newVal) {
1162
+ if (newVal && typeof newVal.value !== 'undefined' && newVal.value !== null) {
1163
+ this.applyExternalColumnState(newVal.value);
1164
+ }
1165
+ }
1166
+ },
1167
+ showSelect() {
1168
+ this.$nextTick(() => this.applySelectionState());
1169
+ },
1170
+ currentItems() {
1171
+ this.$nextTick(() => this.applySelectionState());
1172
+ }
1173
+ },
1174
+ mounted() {
1175
+ window.addEventListener('resize', this.handleResize);
1176
+ // 如果有 request 函数,自动加载数据
1177
+ if (this.request) {
1178
+ this.reload();
1179
+ }
1180
+ // 启动轮询
1181
+ this.startPolling();
1182
+ this.$nextTick(() => this.applySelectionState());
1183
+ },
1184
+ beforeDestroy() {
1185
+ window.removeEventListener('resize', this.handleResize);
1186
+ this.stopPolling();
1187
+ if (this.searchDebounceTimer) {
1188
+ clearTimeout(this.searchDebounceTimer);
1189
+ }
1190
+ if (this.sortChangeTimer) {
1191
+ clearTimeout(this.sortChangeTimer);
1192
+ }
1193
+ },
1194
+ methods: {
1195
+ // 初始化列配置
1196
+ initColumns(headers) {
1197
+ const persistedState = this.getPersistedColumnState();
1198
+ const externalState = this.columnsState?.value;
1199
+ const defaultVisible = this.columnsState?.defaultVisible;
1200
+ this.internalColumns = headers.map(h => {
1201
+ const value = h.value || h.dataIndex || h.key;
1202
+ const baseVisible = h.visible !== false;
1203
+ const visible = this.resolveColumnVisible({
1204
+ value,
1205
+ baseVisible,
1206
+ externalState,
1207
+ persistedState,
1208
+ defaultVisible
1209
+ });
1210
+ return {
1211
+ ...h,
1212
+ visible,
1213
+ initiallyVisible: baseVisible,
1214
+ text: h.text || h.title,
1215
+ value
1216
+ };
1217
+ });
1218
+ },
1219
+ getColumnRawValue(header, item, value) {
1220
+ if (header && typeof header.valueGetter === 'function') {
1221
+ return header.valueGetter(item, header);
1222
+ }
1223
+ if (value !== undefined) return value;
1224
+ const key = header?.value;
1225
+ if (!key) return undefined;
1226
+ return item ? item[key] : value;
1227
+ },
1228
+ applyValueFormatter(header, rawValue, item, index = 0) {
1229
+ if (header && typeof header.valueFormatter === 'function') {
1230
+ const formatted = header.valueFormatter(rawValue, item, header, index);
1231
+ if (formatted !== undefined && formatted !== null) {
1232
+ return formatted;
1233
+ }
1234
+ }
1235
+ return null;
1236
+ },
1237
+ getValueEnumOption(header, rawValue) {
1238
+ const valueEnum = header?.valueEnum;
1239
+ if (!valueEnum) return null;
1240
+ if (Array.isArray(valueEnum)) {
1241
+ return valueEnum.find(option => option?.value === rawValue) || null;
1242
+ }
1243
+ return valueEnum[rawValue] || null;
1244
+ },
1245
+ getStatusText(header, value, item) {
1246
+ const rawValue = this.getColumnRawValue(header, item, value);
1247
+ const formatted = this.applyValueFormatter(header, rawValue, item);
1248
+ if (formatted !== null) return formatted;
1249
+ const option = this.getValueEnumOption(header, rawValue);
1250
+ if (option) {
1251
+ if (typeof option === 'object') {
1252
+ return option.text || option.label || rawValue || '--';
1253
+ }
1254
+ return option;
1255
+ }
1256
+ if (rawValue === null || rawValue === undefined || rawValue === '') return '--';
1257
+ return rawValue;
1258
+ },
1259
+ getStatusChipProps(header, value, item) {
1260
+ const rawValue = this.getColumnRawValue(header, item, value);
1261
+ const option = this.getValueEnumOption(header, rawValue);
1262
+ const statusKey = (option && option.status) || rawValue || 'default';
1263
+ const defaultColor = DEFAULT_STATUS_COLOR_MAP[statusKey] || DEFAULT_STATUS_COLOR_MAP.default;
1264
+ const overrides = header?.valueEnumStatusMap?.[statusKey] || {};
1265
+ return {
1266
+ color: overrides.color || option?.color || defaultColor.color,
1267
+ textColor: overrides.textColor || option?.textColor || defaultColor.textColor,
1268
+ outlined: overrides.outlined ?? option?.outlined ?? false,
1269
+ dark: overrides.dark ?? option?.dark ?? false,
1270
+ icon: option?.icon
1271
+ };
1272
+ },
1273
+ getProgressProps(header) {
1274
+ return {
1275
+ height: 6,
1276
+ color: 'primary',
1277
+ rounded: true,
1278
+ ...header?.valueProps
1279
+ };
1280
+ },
1281
+ getProgressValue(value) {
1282
+ const num = Number(value);
1283
+ if (!Number.isFinite(num)) return 0;
1284
+ if (num < 0) return 0;
1285
+ if (num > 100) return 100;
1286
+ return num;
1287
+ },
1288
+ formatMoneyValue(value, valueProps = {}) {
1289
+ if (value === null || value === undefined || value === '') return '--';
1290
+ const precision = typeof valueProps.precision === 'number' ? valueProps.precision : 2;
1291
+ const currency = valueProps.currencySymbol || '¥';
1292
+ const locale = valueProps.locale || 'zh-CN';
1293
+ const amount = Number(value);
1294
+ if (!Number.isFinite(amount)) return value;
1295
+ return `${currency} ${amount.toLocaleString(locale, {
1296
+ minimumFractionDigits: precision,
1297
+ maximumFractionDigits: precision
1298
+ })}`;
1299
+ },
1300
+ formatPercentValue(value, valueProps = {}) {
1301
+ if (value === null || value === undefined || value === '') return '--';
1302
+ const precision = typeof valueProps.precision === 'number' ? valueProps.precision : 0;
1303
+ const numberValue = Number(value);
1304
+ if (!Number.isFinite(numberValue)) return value;
1305
+ const unit = valueProps.unit || '%';
1306
+ return `${numberValue.toFixed(precision)}${unit}`;
1307
+ },
1308
+ formatDigitValue(value, valueProps = {}) {
1309
+ if (value === null || value === undefined || value === '') return '--';
1310
+ const locale = valueProps.locale || 'zh-CN';
1311
+ const numberValue = Number(value);
1312
+ if (!Number.isFinite(numberValue)) return value;
1313
+ return numberValue.toLocaleString(locale);
1314
+ },
1315
+ formatDateValue(value, type = 'date', valueProps = {}) {
1316
+ if (!value) return '--';
1317
+ const date = new Date(value);
1318
+ if (Number.isNaN(date.getTime())) return value;
1319
+ const format = valueProps.format
1320
+ || (type === 'dateTime' ? 'YYYY-MM-DD HH:mm:ss'
1321
+ : type === 'time' ? 'HH:mm:ss'
1322
+ : 'YYYY-MM-DD');
1323
+ const year = date.getFullYear();
1324
+ const month = String(date.getMonth() + 1).padStart(2, '0');
1325
+ const day = String(date.getDate()).padStart(2, '0');
1326
+ const hours = String(date.getHours()).padStart(2, '0');
1327
+ const minutes = String(date.getMinutes()).padStart(2, '0');
1328
+ const seconds = String(date.getSeconds()).padStart(2, '0');
1329
+ return format
1330
+ .replace('YYYY', year)
1331
+ .replace('MM', month)
1332
+ .replace('DD', day)
1333
+ .replace('HH', hours)
1334
+ .replace('mm', minutes)
1335
+ .replace('ss', seconds);
1336
+ },
1337
+ formatDateRangeValue(value, valueProps = {}) {
1338
+ if (!Array.isArray(value) || value.length === 0) {
1339
+ return '--';
1340
+ }
1341
+ const [start, end] = value;
1342
+ const separator = valueProps.separator || ' ~ ';
1343
+ const type = valueProps.valueType || 'date';
1344
+ return `${this.formatDateValue(start, type, valueProps)}${separator}${this.formatDateValue(end, type, valueProps)}`;
1345
+ },
1346
+ formatJsonValue(value) {
1347
+ if (value === null || value === undefined || value === '') return '--';
1348
+ if (typeof value === 'string') {
1349
+ try {
1350
+ const parsed = JSON.parse(value);
1351
+ return JSON.stringify(parsed, null, 2);
1352
+ } catch (error) {
1353
+ return value;
1354
+ }
1355
+ }
1356
+ if (typeof value === 'object') {
1357
+ return JSON.stringify(value, null, 2);
1358
+ }
1359
+ return String(value);
1360
+ },
1361
+ shouldShowEllipsis(header, value, item, index = 0) {
1362
+ if (!header?.ellipsis) return false;
1363
+ const text = this.formatColumnValue(header, item, value, index);
1364
+ if (text === null || text === undefined) return false;
1365
+ const threshold = header.ellipsisLength || 20;
1366
+ return String(text).length > threshold;
1367
+ },
1368
+ formatColumnValue(header, item, value, index = 0) {
1369
+ const rawValue = this.getColumnRawValue(header, item, value);
1370
+ const formatted = this.applyValueFormatter(header, rawValue, item, index);
1371
+ if (formatted !== null) {
1372
+ return formatted;
1373
+ }
1374
+ if (header?.valueType === 'money') {
1375
+ return this.formatMoneyValue(rawValue, header.valueProps);
1376
+ }
1377
+ if (header?.valueType === 'percent' || header?.valueType === 'progress') {
1378
+ return this.formatPercentValue(rawValue, header.valueProps);
1379
+ }
1380
+ if (header?.valueType === 'digit') {
1381
+ return this.formatDigitValue(rawValue, header.valueProps);
1382
+ }
1383
+ if (header?.valueType === 'date' || header?.valueType === 'dateTime' || header?.valueType === 'time') {
1384
+ return this.formatDateValue(rawValue, header.valueType, header.valueProps);
1385
+ }
1386
+ if (header?.valueType === 'dateRange') {
1387
+ return this.formatDateRangeValue(rawValue, header.valueProps);
1388
+ }
1389
+ if (header?.valueType === 'json') {
1390
+ return this.formatJsonValue(rawValue);
1391
+ }
1392
+ if (header?.valueType === 'index') {
1393
+ return this.getDisplayIndex(index);
1394
+ }
1395
+ const option = this.getValueEnumOption(header, rawValue);
1396
+ if (option) {
1397
+ if (typeof option === 'object') {
1398
+ return option.text || option.label || option.value || rawValue || '--';
1399
+ }
1400
+ return option;
1401
+ }
1402
+ if (rawValue === null || rawValue === undefined || rawValue === '') return '--';
1403
+ return rawValue;
1404
+ },
1405
+ hasCopyValue(header, item, value) {
1406
+ const rawValue = this.getColumnRawValue(header, item, value);
1407
+ return rawValue !== undefined && rawValue !== null && rawValue !== '';
1408
+ },
1409
+ getCopyValue(header, item, value) {
1410
+ const rawValue = this.getColumnRawValue(header, item, value);
1411
+ if (rawValue === null || rawValue === undefined) return '';
1412
+ if (typeof rawValue === 'object') {
1413
+ try {
1414
+ return JSON.stringify(rawValue);
1415
+ } catch (error) {
1416
+ return String(rawValue);
1417
+ }
1418
+ }
1419
+ return String(rawValue);
1420
+ },
1421
+ getDisplayIndex(index) {
1422
+ if (typeof index !== 'number') return '--';
1423
+ if (this.request) {
1424
+ return (this.currentPage - 1) * this.currentItemsPerPage + index + 1;
1425
+ }
1426
+ return index + 1;
1427
+ },
1428
+ getAvatarProps(header) {
1429
+ return {
1430
+ size: 32,
1431
+ color: 'primary lighten-5',
1432
+ ...header?.valueProps
1433
+ };
1434
+ },
1435
+ getAvatarSrc(value) {
1436
+ if (!value) return null;
1437
+ if (typeof value === 'string') return value;
1438
+ if (typeof value === 'object' && value.src) return value.src;
1439
+ return null;
1440
+ },
1441
+ getAvatarInitials(text) {
1442
+ if (!text) return '--';
1443
+ const str = String(text).trim();
1444
+ if (!str) return '--';
1445
+ const segments = str.split(/\s+/);
1446
+ if (segments.length === 1) {
1447
+ return segments[0].charAt(0).toUpperCase();
1448
+ }
1449
+ return `${segments[0].charAt(0)}${segments[segments.length - 1].charAt(0)}`.toUpperCase();
1450
+ },
1451
+ // 切换列显示
1452
+ toggleColumn(col) {
1453
+ col.visible = !col.visible;
1454
+ this.emitColumnStateChange();
1455
+ this.$forceUpdate();
1456
+ },
1457
+ // 重置列配置
1458
+ resetColumns() {
1459
+ this.internalColumns.forEach(col => {
1460
+ col.visible = col.initiallyVisible;
1461
+ });
1462
+ this.clearPersistedColumnState();
1463
+ this.emitColumnStateChange();
1464
+ this.$forceUpdate();
1465
+ },
1466
+ // 刷新表格
1467
+ async handleRefresh() {
1468
+ this.$emit('refresh');
1469
+ if (this.request) {
1470
+ await this.reload();
1471
+ }
1472
+ },
1473
+ // 切换全屏
1474
+ toggleFullscreen() {
1475
+ this.isFullscreen = !this.isFullscreen;
1476
+ if (this.isFullscreen) {
1477
+ this.$el.requestFullscreen?.();
1478
+ } else {
1479
+ document.exitFullscreen?.();
1480
+ }
1481
+ },
1482
+ // 获取可见的操作按钮
1483
+ getVisibleButtons(row) {
1484
+ if (!this.actionColumn || !this.actionColumn.buttons) return [];
1485
+ return this.actionColumn.buttons.filter(btn => {
1486
+ if (typeof btn.visible === 'function') {
1487
+ return btn.visible(row);
1488
+ }
1489
+ return btn.visible !== false;
1490
+ });
1491
+ },
1492
+ // 处理操作按钮点击
1493
+ async handleActionClick(btn, row) {
1494
+ if (btn.confirm) {
1495
+ const confirmed = confirm(btn.confirm);
1496
+ if (!confirmed) return;
1497
+ }
1498
+ if (btn.onClick) {
1499
+ await btn.onClick(row);
1500
+ }
1501
+ },
1502
+ // 复制到剪贴板
1503
+ copyToClipboard(text) {
1504
+ if (navigator.clipboard) {
1505
+ navigator.clipboard.writeText(text).then(() => {
1506
+ this.$emit('copy-success', text);
1507
+ // 可以显示提示
1508
+ console.log('已复制:', text);
1509
+ });
1510
+ } else {
1511
+ // 降级方案
1512
+ const textarea = document.createElement('textarea');
1513
+ textarea.value = text;
1514
+ document.body.appendChild(textarea);
1515
+ textarea.select();
1516
+ document.execCommand('copy');
1517
+ document.body.removeChild(textarea);
1518
+ this.$emit('copy-success', text);
1519
+ console.log('已复制:', text);
1520
+ }
1521
+ },
1522
+ // 行点击
1523
+ handleRowClick(item, event) {
1524
+ this.$emit('row-click', item, event);
1525
+ this.$emit('click:row', item, event);
1526
+ },
1527
+ // 选择改变
1528
+ handleSelectionChange(selectedItems) {
1529
+ this.selectedItems = selectedItems;
1530
+ const payload = {
1531
+ selectedRowKeys: selectedItems.map(item => item[this.rowKey]),
1532
+ selectedRows: selectedItems
1533
+ };
1534
+ this.$emit('selection-change', payload);
1535
+ this.$emit('input', selectedItems);
1536
+ if (this.rowSelection && typeof this.rowSelection.onChange === 'function') {
1537
+ this.rowSelection.onChange(payload.selectedRowKeys, selectedItems);
1538
+ }
1539
+ },
1540
+ // 页码改变
1541
+ handlePageChange(page) {
1542
+ this.currentPage = page;
1543
+ this.$emit('update:page', page);
1544
+ if (this.request) {
1545
+ this.reload();
1546
+ }
1547
+ },
1548
+ // 每页条数改变
1549
+ handleItemsPerPageChange(itemsPerPage) {
1550
+ this.currentItemsPerPage = itemsPerPage;
1551
+ this.currentPage = 1;
1552
+ this.$emit('update:items-per-page', itemsPerPage);
1553
+ if (this.request) {
1554
+ this.reload();
1555
+ }
1556
+ },
1557
+ // 处理 v-data-table 的 options 更新事件(Vuetify 2.4+)
1558
+ handleOptionsUpdate(options) {
1559
+ if (options && typeof options === 'object') {
1560
+ if (options.page !== undefined && options.page !== this.currentPage) {
1561
+ this.handlePageChange(options.page);
1562
+ }
1563
+ if (options.itemsPerPage !== undefined && options.itemsPerPage !== this.currentItemsPerPage) {
1564
+ this.handleItemsPerPageChange(options.itemsPerPage);
1565
+ }
1566
+ if (options.sortBy !== undefined) {
1567
+ this.handleSortByUpdate(options.sortBy);
1568
+ }
1569
+ if (options.sortDesc !== undefined) {
1570
+ this.handleSortDescUpdate(options.sortDesc);
1571
+ }
1572
+ }
1573
+ this.$emit('update:options', options);
1574
+ },
1575
+ // 高级筛选-查询
1576
+ handleFilterSearch(queryData) {
1577
+ const filters = this.transformFilterValues(queryData);
1578
+ this.filterValues = filters;
1579
+ this.currentPage = 1; // 重置到第一页
1580
+ this.$emit('filter-search', filters);
1581
+ if (this.request) {
1582
+ this.reload();
1583
+ }
1584
+ },
1585
+ // 高级筛选-重置
1586
+ handleFilterReset() {
1587
+ this.filterValues = {};
1588
+ this.currentPage = 1; // 重置到第一页
1589
+ this.$emit('filter-reset');
1590
+ if (this.request) {
1591
+ this.reload();
1592
+ }
1593
+ },
1594
+ transformFilterValues(queryData) {
1595
+ const filters = {};
1596
+ Object.keys(queryData || {}).forEach(key => {
1597
+ const value = queryData[key];
1598
+ const meta = this.filterFieldMetaMap[key];
1599
+ if (meta && typeof meta.transform === 'function') {
1600
+ const result = meta.transform(value, queryData);
1601
+ if (result && typeof result === 'object' && !Array.isArray(result)) {
1602
+ Object.assign(filters, result);
1603
+ return;
1604
+ }
1605
+ if (result !== undefined) {
1606
+ filters[key] = result;
1607
+ }
1608
+ return;
1609
+ }
1610
+ filters[key] = value;
1611
+ });
1612
+ return filters;
1613
+ },
1614
+ buildAutoFilterField(column) {
1615
+ const searchConfig = this.normalizeSearchConfig(column);
1616
+ if (!searchConfig) return null;
1617
+ const fieldType = this.mapValueTypeToFilterType(searchConfig.valueType, column);
1618
+ const field = {
1619
+ key: searchConfig.key,
1620
+ label: searchConfig.label,
1621
+ type: fieldType,
1622
+ placeholder: searchConfig.placeholder || this.formatAutoFieldPlaceholder(searchConfig.label, fieldType),
1623
+ options: [],
1624
+ props: {
1625
+ clearable: true,
1626
+ dense: true,
1627
+ ...(searchConfig.formItemProps || {})
1628
+ },
1629
+ initialValue: searchConfig.initialValue,
1630
+ defaultValue: searchConfig.initialValue,
1631
+ hideDetails: true
1632
+ };
1633
+ if (fieldType === 'select') {
1634
+ field.options = this.buildValueEnumOptions(column, searchConfig);
1635
+ }
1636
+ if (fieldType === 'date') {
1637
+ field.pickerProps = {
1638
+ range: searchConfig.valueType === 'dateRange',
1639
+ ...(searchConfig.formItemProps?.pickerProps || {})
1640
+ };
1641
+ }
1642
+ if (searchConfig.multiple) {
1643
+ field.multiple = true;
1644
+ }
1645
+ if (searchConfig.fieldProps && typeof searchConfig.fieldProps === 'object') {
1646
+ Object.assign(field, searchConfig.fieldProps);
1647
+ }
1648
+ return {
1649
+ field,
1650
+ transform: searchConfig.transform,
1651
+ initialValue: searchConfig.initialValue
1652
+ };
1653
+ },
1654
+ normalizeSearchConfig(column) {
1655
+ if (!column || column.value === 'action') return null;
1656
+ const searchProp = column.search;
1657
+ if (searchProp === undefined || searchProp === null || searchProp === false) return null;
1658
+ const search = searchProp === true
1659
+ ? {}
1660
+ : typeof searchProp === 'object'
1661
+ ? { ...searchProp }
1662
+ : {};
1663
+ const key = search.key || column.filterKey || column.value || column.dataIndex || column.key;
1664
+ if (!key) return null;
1665
+ return {
1666
+ key,
1667
+ label: search.label || column.searchLabel || column.text || column.title || key,
1668
+ valueType: search.valueType || column.valueType || 'text',
1669
+ placeholder: search.placeholder,
1670
+ formItemProps: search.formItemProps || {},
1671
+ transform: search.transform,
1672
+ initialValue: search.initialValue,
1673
+ valueEnumKey: search.valueEnumKey || column.valueEnumKey,
1674
+ fieldProps: search.fieldProps || {},
1675
+ multiple: search.multiple || search.formItemProps?.multiple,
1676
+ search
1677
+ };
1678
+ },
1679
+ mapValueTypeToFilterType(valueType, column) {
1680
+ switch (valueType) {
1681
+ case 'status':
1682
+ case 'option':
1683
+ case 'select':
1684
+ return column?.valueEnum ? 'select' : 'text';
1685
+ case 'date':
1686
+ case 'dateTime':
1687
+ case 'time':
1688
+ case 'dateRange':
1689
+ return 'date';
1690
+ case 'digit':
1691
+ case 'money':
1692
+ case 'percent':
1693
+ return 'text';
1694
+ default:
1695
+ return 'text';
1696
+ }
1697
+ },
1698
+ buildValueEnumOptions(column, searchConfig) {
1699
+ const valueEnum = column?.valueEnum;
1700
+ if (!valueEnum) return [];
1701
+ const options = [];
1702
+ if (Array.isArray(valueEnum)) {
1703
+ valueEnum.forEach(option => {
1704
+ if (!option) return;
1705
+ options.push({
1706
+ text: option.text || option.label || option.value,
1707
+ value: option.value
1708
+ });
1709
+ });
1710
+ return options;
1711
+ }
1712
+ Object.keys(valueEnum).forEach(key => {
1713
+ const option = valueEnum[key];
1714
+ if (typeof option === 'object') {
1715
+ const targetKey = searchConfig.valueEnumKey && option[searchConfig.valueEnumKey] !== undefined
1716
+ ? option[searchConfig.valueEnumKey]
1717
+ : option.value !== undefined ? option.value : key;
1718
+ options.push({
1719
+ text: option.text || option.label || key,
1720
+ value: targetKey
1721
+ });
1722
+ } else {
1723
+ options.push({
1724
+ text: option,
1725
+ value: key
1726
+ });
1727
+ }
1728
+ });
1729
+ return options;
1730
+ },
1731
+ formatAutoFieldPlaceholder(label, type) {
1732
+ if (!label) return '';
1733
+ return type === 'select' ? `请选择${label}` : `请输入${label}`;
1734
+ },
1735
+ // 重新加载数据(服务端分页)
1736
+ async reload() {
1737
+ if (!this.request) return;
1738
+
1739
+ this.currentLoading = true;
1740
+ try {
1741
+ const params = {
1742
+ page: this.currentPage,
1743
+ pageSize: this.currentItemsPerPage,
1744
+ search: this.searchInputInternal,
1745
+ sorter: this.buildSorterPayload(),
1746
+ filters: this.filterValues // 包含高级筛选的值
1747
+ };
1748
+
1749
+ const response = await this.request(params);
1750
+
1751
+ if (response && response.success !== false) {
1752
+ this.currentItems = response.data || response.list || [];
1753
+ this.serverItemsLength = response.total || response.totalCount || this.currentItems.length;
1754
+ this.$nextTick(() => this.applySelectionState());
1755
+ }
1756
+ } catch (error) {
1757
+ console.error('Failed to load data:', error);
1758
+ this.$emit('request-error', error);
1759
+ } finally {
1760
+ this.currentLoading = false;
1761
+ }
1762
+ },
1763
+ // 重置到第一页
1764
+ reset() {
1765
+ this.currentPage = 1;
1766
+ this.reload();
1767
+ },
1768
+ // 清空选择
1769
+ clearSelection() {
1770
+ this.selectedItems = [];
1771
+ this.$refs.dataTable?.clearSelection?.();
1772
+ const payload = { selectedRowKeys: [], selectedRows: [] };
1773
+ this.$emit('selection-change', payload);
1774
+ this.$emit('input', []);
1775
+ if (this.rowSelection && typeof this.rowSelection.onChange === 'function') {
1776
+ this.rowSelection.onChange([], []);
1777
+ }
1778
+ },
1779
+ // 获取选中的行
1780
+ getSelectedRows() {
1781
+ return this.selectedItems;
1782
+ },
1783
+ // 窗口大小改变
1784
+ handleResize() {
1785
+ this.isMobile = window.innerWidth < 500;
1786
+ },
1787
+ // 启动轮询
1788
+ startPolling() {
1789
+ if (this.polling > 0 && this.request) {
1790
+ this.pollingTimer = setInterval(() => {
1791
+ this.reload();
1792
+ }, this.polling);
1793
+ }
1794
+ },
1795
+ // 停止轮询
1796
+ stopPolling() {
1797
+ if (this.pollingTimer) {
1798
+ clearInterval(this.pollingTimer);
1799
+ this.pollingTimer = null;
1800
+ }
1801
+ },
1802
+ resolveColumnVisible({ value, baseVisible, externalState, persistedState, defaultVisible }) {
1803
+ if (externalState && Object.prototype.hasOwnProperty.call(externalState, value)) {
1804
+ return externalState[value];
1805
+ }
1806
+ if (persistedState && Object.prototype.hasOwnProperty.call(persistedState, value)) {
1807
+ return persistedState[value];
1808
+ }
1809
+ if (defaultVisible && Object.prototype.hasOwnProperty.call(defaultVisible, value)) {
1810
+ return defaultVisible[value];
1811
+ }
1812
+ return baseVisible;
1813
+ },
1814
+ getColumnStateSnapshot() {
1815
+ return this.internalColumns.reduce((acc, col) => {
1816
+ acc[col.value] = col.visible !== false;
1817
+ return acc;
1818
+ }, {});
1819
+ },
1820
+ emitColumnStateChange() {
1821
+ const snapshot = this.getColumnStateSnapshot();
1822
+ this.$emit('columns-state-change', snapshot);
1823
+ if (this.columnsState && typeof this.columnsState.onChange === 'function') {
1824
+ this.columnsState.onChange(snapshot);
1825
+ }
1826
+ this.persistColumnState(snapshot);
1827
+ },
1828
+ getPersistedColumnState() {
1829
+ if (!this.columnsState || !this.columnsState.persistenceKey) return null;
1830
+ if (typeof window === 'undefined') return null;
1831
+ try {
1832
+ const raw = window.localStorage.getItem(this.columnsState.persistenceKey);
1833
+ return raw ? JSON.parse(raw) : null;
1834
+ } catch (error) {
1835
+ console.warn('[JhTable] Failed to parse persisted column state:', error);
1836
+ return null;
1837
+ }
1838
+ },
1839
+ persistColumnState(state) {
1840
+ if (!this.columnsState || !this.columnsState.persistenceKey) return;
1841
+ if (typeof window === 'undefined') return;
1842
+ try {
1843
+ if (state) {
1844
+ window.localStorage.setItem(this.columnsState.persistenceKey, JSON.stringify(state));
1845
+ } else {
1846
+ window.localStorage.removeItem(this.columnsState.persistenceKey);
1847
+ }
1848
+ } catch (error) {
1849
+ console.warn('[JhTable] Failed to persist column state:', error);
1850
+ }
1851
+ },
1852
+ clearPersistedColumnState() {
1853
+ if (!this.columnsState || !this.columnsState.persistenceKey) return;
1854
+ if (typeof window === 'undefined') return;
1855
+ try {
1856
+ window.localStorage.removeItem(this.columnsState.persistenceKey);
1857
+ } catch (error) {
1858
+ console.warn('[JhTable] Failed to clear column state:', error);
1859
+ }
1860
+ },
1861
+ applyExternalColumnState(state) {
1862
+ if (!state) return;
1863
+ this.internalColumns = this.internalColumns.map(col => ({
1864
+ ...col,
1865
+ visible: Object.prototype.hasOwnProperty.call(state, col.value) ? state[col.value] : col.visible
1866
+ }));
1867
+ this.$forceUpdate();
1868
+ },
1869
+ normalizeSortBy(value) {
1870
+ if (Array.isArray(value)) return value;
1871
+ if (typeof value === 'string' && value) return [value];
1872
+ return [];
1873
+ },
1874
+ normalizeSortDesc(value) {
1875
+ if (Array.isArray(value)) return value;
1876
+ if (typeof value === 'boolean') return [value];
1877
+ return [];
1878
+ },
1879
+ handleSortByUpdate(val) {
1880
+ this.internalSortBy = this.normalizeSortBy(val);
1881
+ this.scheduleSortChangeEmit();
1882
+ },
1883
+ handleSortDescUpdate(val) {
1884
+ if (Array.isArray(val)) {
1885
+ this.internalSortDesc = val;
1886
+ } else if (typeof val === 'boolean') {
1887
+ this.internalSortDesc = [val];
1888
+ } else {
1889
+ this.internalSortDesc = [];
1890
+ }
1891
+ this.scheduleSortChangeEmit();
1892
+ },
1893
+ scheduleSortChangeEmit() {
1894
+ if (this.sortChangeTimer) {
1895
+ clearTimeout(this.sortChangeTimer);
1896
+ }
1897
+ this.sortChangeTimer = setTimeout(() => {
1898
+ this.sortChangeTimer = null;
1899
+ this.emitSortChange();
1900
+ }, 0);
1901
+ },
1902
+ emitSortChange() {
1903
+ this.$emit('update:sortBy', this.internalSortBy);
1904
+ this.$emit('update:sortDesc', this.internalSortDesc);
1905
+ this.$emit('sort-change', {
1906
+ sortBy: this.internalSortBy,
1907
+ sortDesc: this.internalSortDesc,
1908
+ sorter: this.buildSorterPayload()
1909
+ });
1910
+ if (this.request) {
1911
+ this.currentPage = 1;
1912
+ this.$emit('update:page', 1);
1913
+ this.reload();
1914
+ }
1915
+ },
1916
+ buildSorterPayload() {
1917
+ if (!this.internalSortBy.length) return {};
1918
+ const order = {};
1919
+ this.internalSortBy.forEach((key, index) => {
1920
+ order[key] = this.internalSortDesc[index] ? 'desc' : 'asc';
1921
+ });
1922
+ return {
1923
+ sortBy: this.internalSortBy,
1924
+ sortDesc: this.internalSortDesc,
1925
+ order
1926
+ };
1927
+ },
1928
+ applySelectionState() {
1929
+ if (!this.showSelectComputed) {
1930
+ if (this.selectedItems.length) {
1931
+ this.selectedItems = [];
1932
+ this.$emit('input', []);
1933
+ }
1934
+ return;
1935
+ }
1936
+ const controlledKeys = this.getControlledRowKeys();
1937
+ if (controlledKeys) {
1938
+ this.syncSelectionByKeys(controlledKeys);
1939
+ } else {
1940
+ this.applyDefaultRowSelection();
1941
+ }
1942
+ },
1943
+ getControlledRowKeys() {
1944
+ if (this.rowSelection && Array.isArray(this.rowSelection.selectedRowKeys)) {
1945
+ return this.rowSelection.selectedRowKeys;
1946
+ }
1947
+ return null;
1948
+ },
1949
+ syncSelectionByKeys(keys) {
1950
+ if (!Array.isArray(keys)) return;
1951
+ const keySet = new Set(keys);
1952
+ const matched = this.currentItems.filter(item => keySet.has(item[this.rowKey]));
1953
+ this.selectedItems = matched;
1954
+ this.$emit('input', matched);
1955
+ },
1956
+ applyDefaultRowSelection() {
1957
+ if (this.hasAppliedDefaultRowSelection) return;
1958
+ if (!this.rowSelection || !Array.isArray(this.rowSelection.defaultSelectedRowKeys)) return;
1959
+ const keySet = new Set(this.rowSelection.defaultSelectedRowKeys);
1960
+ const matched = this.currentItems.filter(item => keySet.has(item[this.rowKey]));
1961
+ if (matched.length) {
1962
+ this.selectedItems = matched;
1963
+ this.$emit('input', matched);
1964
+ }
1965
+ this.hasAppliedDefaultRowSelection = true;
1966
+ }
1967
+ }
1968
+ };
1969
+ </script>
1970
+
1971
+ <style scoped>
1972
+ /* 表格容器 */
1973
+ .jh-pro-table {
1974
+ border-radius: 8px;
1975
+ position: relative;
1976
+ }
1977
+
1978
+
1979
+ /* 幽灵模式 */
1980
+ .jh-pro-table-ghost {
1981
+ background: transparent;
1982
+ border: none;
1983
+ box-shadow: none;
1984
+ }
1985
+
1986
+ /* 表格标题区 */
1987
+ .jh-pro-table-header {
1988
+ display: flex;
1989
+ align-items: center;
1990
+ justify-content: space-between;
1991
+ padding: 16px 24px;
1992
+ border-bottom: 1px solid #f0f0f0;
1993
+ min-height: 64px;
1994
+ }
1995
+
1996
+ .jh-pro-table-header-left {
1997
+ display: flex;
1998
+ align-items: center;
1999
+ gap: 16px;
2000
+ flex: 1;
2001
+ }
2002
+
2003
+ .jh-pro-table-header-right {
2004
+ display: flex;
2005
+ align-items: center;
2006
+ gap: 8px;
2007
+ }
2008
+
2009
+ .jh-pro-table-title {
2010
+ display: flex;
2011
+ align-items: center;
2012
+ font-size: 16px;
2013
+ font-weight: 500;
2014
+ color: rgba(0, 0, 0, 0.85);
2015
+ }
2016
+
2017
+ .jh-pro-table-title-text {
2018
+ line-height: 24px;
2019
+ }
2020
+
2021
+ .jh-pro-table-header-actions {
2022
+ display: flex;
2023
+ align-items: center;
2024
+ gap: 8px;
2025
+ }
2026
+
2027
+ /* 批量操作提示栏 */
2028
+ .jh-pro-table-alert {
2029
+ display: flex;
2030
+ align-items: center;
2031
+ justify-content: space-between;
2032
+ padding: 12px 24px;
2033
+ background: #e6f7ff;
2034
+ border: 1px solid #91d5ff;
2035
+ border-radius: 4px;
2036
+ margin: 16px 0 0;
2037
+ }
2038
+
2039
+ .jh-pro-table-alert-info {
2040
+ display: flex;
2041
+ align-items: center;
2042
+ font-size: 14px;
2043
+ color: rgba(0, 0, 0, 0.65);
2044
+ }
2045
+
2046
+ .jh-pro-table-alert-actions {
2047
+ display: flex;
2048
+ align-items: center;
2049
+ gap: 8px;
2050
+ }
2051
+
2052
+ /* 工具栏 */
2053
+ .jh-pro-table-toolbar {
2054
+ padding: 16px 0 !important;
2055
+ }
2056
+
2057
+ /* 表格额外内容区 */
2058
+ .jh-pro-table-extra {
2059
+ padding: 16px 24px;
2060
+ border-top: 1px solid #f0f0f0;
2061
+ }
2062
+
2063
+ /* 移动端适配 */
2064
+ @media (max-width: 600px) {
2065
+ .jh-pro-table-header {
2066
+ flex-direction: column;
2067
+ align-items: flex-start;
2068
+ padding: 12px 16px;
2069
+ min-height: auto;
2070
+ }
2071
+
2072
+ .jh-pro-table-header-left {
2073
+ flex-direction: column;
2074
+ align-items: flex-start;
2075
+ gap: 8px;
2076
+ width: 100%;
2077
+ }
2078
+
2079
+ .jh-pro-table-header-right {
2080
+ width: 100%;
2081
+ justify-content: flex-end;
2082
+ margin-top: 8px;
2083
+ }
2084
+
2085
+ .jh-pro-table-alert {
2086
+ flex-direction: column;
2087
+ align-items: flex-start;
2088
+ gap: 8px;
2089
+ margin: 12px 16px 0;
2090
+ padding: 8px 12px;
2091
+ }
2092
+
2093
+ .jh-pro-table-alert-actions {
2094
+ width: 100%;
2095
+ justify-content: flex-start;
2096
+ }
2097
+
2098
+ .jh-pro-table-toolbar {
2099
+ padding: 12px 16px !important;
2100
+ }
2101
+
2102
+ .jh-pro-table-extra {
2103
+ padding: 12px 16px;
2104
+ }
2105
+ }
2106
+
2107
+ /* 工具栏搜索框响应式 */
2108
+ .jh-toolbar-search {
2109
+ max-width: 200px;
2110
+ min-width: 120px;
2111
+ }
2112
+
2113
+ @media (max-width: 600px) {
2114
+ .jh-toolbar-search {
2115
+ max-width: 150px;
2116
+ min-width: 100px;
2117
+ }
2118
+ }
2119
+
2120
+ /* 表格密度 */
2121
+ .jh-table-default >>> .v-data-table > .v-data-table__wrapper > table > tbody > tr > td,
2122
+ .jh-table-default >>> .v-data-table > .v-data-table__wrapper > table > thead > tr > th {
2123
+ height: 48px !important;
2124
+ padding: 0 16px !important;
2125
+ }
2126
+
2127
+ .jh-table-medium >>> .v-data-table > .v-data-table__wrapper > table > tbody > tr > td,
2128
+ .jh-table-medium >>> .v-data-table > .v-data-table__wrapper > table > thead > tr > th {
2129
+ height: 40px !important;
2130
+ padding: 0 12px !important;
2131
+ }
2132
+
2133
+ .jh-table-compact >>> .v-data-table > .v-data-table__wrapper > table > tbody > tr > td,
2134
+ .jh-table-compact >>> .v-data-table > .v-data-table__wrapper > table > thead > tr > th {
2135
+ height: 32px !important;
2136
+ padding: 0 8px !important;
2137
+ font-size: 13px !important;
2138
+ }
2139
+
2140
+ /* 移动端表格密度调整 */
2141
+ @media (max-width: 600px) {
2142
+ .jh-table-default >>> .v-data-table > .v-data-table__wrapper > table > tbody > tr > td,
2143
+ .jh-table-default >>> .v-data-table > .v-data-table__wrapper > table > thead > tr > th {
2144
+ height: 44px !important;
2145
+ padding: 0 12px !important;
2146
+ font-size: 14px !important;
2147
+ }
2148
+
2149
+ .jh-table-medium >>> .v-data-table > .v-data-table__wrapper > table > tbody > tr > td,
2150
+ .jh-table-medium >>> .v-data-table > .v-data-table__wrapper > table > thead > tr > th {
2151
+ height: 40px !important;
2152
+ padding: 0 10px !important;
2153
+ font-size: 13px !important;
2154
+ }
2155
+
2156
+ .jh-table-compact >>> .v-data-table > .v-data-table__wrapper > table > tbody > tr > td,
2157
+ .jh-table-compact >>> .v-data-table > .v-data-table__wrapper > table > thead > tr > th {
2158
+ height: 36px !important;
2159
+ padding: 0 8px !important;
2160
+ font-size: 12px !important;
2161
+ }
2162
+ }
2163
+
2164
+ /* 移动端操作列样式优化 */
2165
+ @media (max-width: 600px) {
2166
+ /* 操作列下拉菜单触发器 */
2167
+ .jh-pro-table >>> .v-data-table td[data-action-column] {
2168
+ padding: 0 8px !important;
2169
+ }
2170
+
2171
+ /* 操作按钮文字在小屏幕隐藏 */
2172
+ .jh-pro-table >>> .v-btn--small .v-btn__content > span:not(.v-icon) {
2173
+ font-size: 13px;
2174
+ }
2175
+ }
2176
+
2177
+ /* 全屏样式 */
2178
+ .jh-table-fullscreen {
2179
+ position: fixed;
2180
+ top: 0;
2181
+ left: 0;
2182
+ right: 0;
2183
+ bottom: 0;
2184
+ z-index: 9999;
2185
+ background: white;
2186
+ padding: 16px;
2187
+ overflow: auto;
2188
+ }
2189
+
2190
+ @media (max-width: 600px) {
2191
+ .jh-table-fullscreen {
2192
+ padding: 8px;
2193
+ }
2194
+ }
2195
+
2196
+ /* 无数据样式 */
2197
+ .jh-no-data {
2198
+ text-align: center;
2199
+ padding: 40px 0;
2200
+ color: rgba(0, 0, 0, 0.45);
2201
+ }
2202
+
2203
+ .jh-table-schema-cell {
2204
+ min-width: 0;
2205
+ }
2206
+
2207
+ .jh-table-json {
2208
+ margin: 0;
2209
+ font-family: 'Fira Code', 'JetBrains Mono', Consolas, monospace;
2210
+ font-size: 12px;
2211
+ white-space: pre-wrap;
2212
+ word-break: break-word;
2213
+ }
2214
+
2215
+ .jh-table-code {
2216
+ font-family: 'Fira Code', 'JetBrains Mono', Consolas, monospace;
2217
+ font-size: 12px;
2218
+ background: #f4f5f7;
2219
+ padding: 2px 6px;
2220
+ border-radius: 4px;
2221
+ }
2222
+
2223
+ @media (max-width: 600px) {
2224
+ .jh-no-data {
2225
+ padding: 24px 0;
2226
+ }
2227
+
2228
+ .jh-no-data .v-icon {
2229
+ font-size: 48px !important;
2230
+ }
2231
+ }
2232
+
2233
+ /* 游标指针 */
2234
+ .cursor-pointer {
2235
+ cursor: pointer;
2236
+ }
2237
+
2238
+ /* Flex 工具类(如果没有 Tailwind) */
2239
+ .flex {
2240
+ display: flex;
2241
+ }
2242
+
2243
+ .items-center {
2244
+ align-items: center;
2245
+ }
2246
+
2247
+ .justify-end {
2248
+ justify-content: flex-end;
2249
+ }
2250
+
2251
+ .flex-wrap {
2252
+ flex-wrap: wrap;
2253
+ }
2254
+
2255
+ .gap-1 {
2256
+ gap: 4px;
2257
+ }
2258
+
2259
+ .gap-2 {
2260
+ gap: 8px;
2261
+ }
2262
+
2263
+ /* 移动端工具栏响应式 */
2264
+ @media (max-width: 600px) {
2265
+ .jh-pro-table >>> .v-row {
2266
+ margin: 0 !important;
2267
+ }
2268
+
2269
+ /* 工具栏按钮间距调整 */
2270
+ .flex.gap-2 {
2271
+ gap: 4px;
2272
+ }
2273
+
2274
+ /* 密度和列设置菜单按钮在移动端可选择隐藏 */
2275
+ .jh-pro-table >>> .v-btn--icon.v-size--small {
2276
+ width: 32px;
2277
+ height: 32px;
2278
+ }
2279
+ }
2280
+
2281
+ /* 分页器移动端优化 */
2282
+ @media (max-width: 600px) {
2283
+ .jh-pro-table >>> .v-data-footer {
2284
+ flex-wrap: wrap;
2285
+ justify-content: center;
2286
+ padding: 8px 4px !important;
2287
+ }
2288
+
2289
+ .jh-pro-table >>> .v-data-footer__select {
2290
+ margin-left: 8px !important;
2291
+ margin-right: 8px !important;
2292
+ }
2293
+
2294
+ .jh-pro-table >>> .v-data-footer__pagination {
2295
+ margin: 4px 8px !important;
2296
+ }
2297
+ }
2298
+ </style>