vue3-smart-table 0.0.4 → 1.0.0

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.
@@ -0,0 +1,307 @@
1
+ /**
2
+ * 内置渲染器集合
3
+ * 可以按需引入或批量注册
4
+ */
5
+ import { h } from 'vue'
6
+ import { ElButton, ElTag, ElImage, ElMessage } from 'element-plus'
7
+ import { DocumentCopy, CopyDocument } from '@element-plus/icons-vue'
8
+ import type { ColumnConfig } from '../types'
9
+ import { getValueByPath } from '../utils/path'
10
+ import { wrapSFCComponent, createFunctionalRenderer } from '../renderer'
11
+ import EditableInput from './input.vue'
12
+ import EditableNumber from './inputNumber.vue'
13
+ import EditableSelect from './select.vue'
14
+
15
+ /**
16
+ * 包装 SFC 组件
17
+ */
18
+ const input = wrapSFCComponent(EditableInput)
19
+ const inputNumber = wrapSFCComponent(EditableNumber)
20
+ const select = wrapSFCComponent(EditableSelect)
21
+
22
+ /**
23
+ * button 渲染器
24
+ */
25
+ const button = createFunctionalRenderer((props) => {
26
+ const rp = props.col.renderProps || {}
27
+ const val = getValueByPath(props.row, props.col.key)
28
+ return h(ElButton as any, {
29
+ type: rp.type || 'primary',
30
+ ...rp,
31
+ onClick: () => props.onClick?.(props.row, props.col)
32
+ }, () => rp.label || val)
33
+ })
34
+
35
+ /**
36
+ * link 渲染器
37
+ */
38
+ const link = createFunctionalRenderer((props) => {
39
+ const rp = props.col.renderProps || {}
40
+ const val = getValueByPath(props.row, props.col.key)
41
+ return h('a', {
42
+ href: rp.href || '#',
43
+ target: rp.blank ? '_blank' : '_self',
44
+ style: rp.style || 'color:#409EFF;cursor:pointer;',
45
+ }, rp.label || val)
46
+ })
47
+
48
+ /**
49
+ * html 渲染器
50
+ */
51
+ const html = createFunctionalRenderer((props) => {
52
+ const val = getValueByPath(props.row, props.col.key)
53
+ return h('div', {
54
+ class: 'line-clamp-2',
55
+ innerHTML: val ?? '',
56
+ ...(props.col?.renderProps || {})
57
+ })
58
+ })
59
+
60
+ /**
61
+ * copy 渲染器
62
+ */
63
+ const copy = createFunctionalRenderer((props) => {
64
+ const val = getValueByPath(props.row, props.col.key) ?? ''
65
+ const rp = props.col.renderProps ?? {}
66
+ const butStyle = {
67
+ 'position': 'absolute',
68
+ 'right': '-5px',
69
+ 'top': '50%',
70
+ 'transform': 'translateY(-50%)',
71
+ 'cursor': 'pointer',
72
+ 'display': 'none',
73
+ 'font-size': '12px',
74
+ 'color': rp.iconColor || '#409EFF',
75
+ 'user-select': 'none'
76
+ }
77
+ return h('div', {
78
+ class: 'st_copy_wrapper',
79
+ style: 'width: 100%; position: relative; display: inline-block;'
80
+ },
81
+ [
82
+ h('span', {
83
+ class: 'st_copy_text line-clamp-1',
84
+ style: 'padding-right: 10px; display: block;',
85
+ }, val),
86
+ val && h('span', {
87
+ class: 'st_copy_btn',
88
+ style: butStyle,
89
+ title: rp.copyTitle || '复制',
90
+ onClick: () => {
91
+ if (!val) return
92
+ try {
93
+ if (navigator.clipboard && navigator.clipboard.writeText) {
94
+ navigator.clipboard.writeText(val).then(() => {
95
+ ElMessage.success(rp.successText ?? '复制成功')
96
+ }).catch(() => {
97
+ ElMessage.error(rp.errorText ?? '复制失败')
98
+ })
99
+ } else {
100
+ const textarea = document.createElement('textarea')
101
+ textarea.value = val
102
+ textarea.style.position = 'fixed'
103
+ textarea.style.opacity = '0'
104
+ document.body.appendChild(textarea)
105
+ textarea.select()
106
+ const successful = document.execCommand('copy')
107
+ document.body.removeChild(textarea)
108
+
109
+ if (successful) {
110
+ ElMessage.success(rp.successText ?? '复制成功')
111
+ } else {
112
+ ElMessage.error(rp.errorText ?? '复制失败')
113
+ }
114
+ }
115
+ } catch (err) {
116
+ ElMessage.error(rp.errorText ?? '复制失败')
117
+ }
118
+ }
119
+ }, [h(DocumentCopy, {
120
+ style: 'width: 1em; height: 1em;'
121
+ })])
122
+ ].filter(Boolean)
123
+ )
124
+ })
125
+
126
+ /**
127
+ * img 渲染器
128
+ */
129
+ const img = createFunctionalRenderer((props) => {
130
+ const val = getValueByPath(props.row, props.col.key) ?? ''
131
+ const rp = props.col?.renderProps || {}
132
+
133
+ const getImageList = () => {
134
+ if (!val) return []
135
+ if (Array.isArray(val)) {
136
+ return val.filter(item => item && typeof item === 'string')
137
+ }
138
+ return [val]
139
+ }
140
+
141
+ const imageList = getImageList()
142
+
143
+ if (imageList.length === 0) {
144
+ return rp.placeholder || ''
145
+ }
146
+
147
+ const defaultStyle = {
148
+ width: rp.width || '80px',
149
+ height: rp.height || '80px',
150
+ marginRight: imageList.length > 1 ? '4px' : '0',
151
+ ...(rp.style || {})
152
+ }
153
+
154
+ if (imageList.length === 1) {
155
+ return h(ElImage, {
156
+ src: imageList[0],
157
+ previewSrcList: rp.previewSrcList || imageList,
158
+ fit: rp.fit || 'contain',
159
+ style: defaultStyle,
160
+ ...rp
161
+ })
162
+ }
163
+
164
+ return h('div',
165
+ {
166
+ class: 'st_img_wrapper',
167
+ style: 'display: flex; align-items: center; position: relative'
168
+ },
169
+ [
170
+ h(ElImage, {
171
+ src: imageList[0],
172
+ previewSrcList: rp.previewSrcList || imageList,
173
+ fit: rp.fit || 'contain',
174
+ style: defaultStyle,
175
+ ...rp
176
+ }),
177
+ imageList.length > 1 && h('span', {
178
+ class: 'st_img_total',
179
+ style: `position: absolute; top: 0; right: 0; `,
180
+ title: `${imageList.length}`
181
+ }, [h(CopyDocument, { style: `width: 1em; height: 1em; ` })])
182
+ ]
183
+ )
184
+ })
185
+
186
+ /**
187
+ * dict 渲染器
188
+ */
189
+ const dict = createFunctionalRenderer((props) => {
190
+ const val = getValueByPath(props.row, props.col.key) ?? ''
191
+ const rp = props.col.renderProps || {}
192
+ const options = rp.options ?? []
193
+ const showValue = rp.showValue ?? false
194
+
195
+ if (val === null || val === undefined || val === '') return ''
196
+
197
+ const values = Array.isArray(val) ? val.map(String) : [String(val)]
198
+ const matchedOptions = options.filter((opt: any) => values.includes(String(opt.value)))
199
+ const unmatched = values.filter(v => !options.some((opt: any) => String(opt.value) === v))
200
+
201
+ const children = matchedOptions.map((item: any, _index: number) => {
202
+ return h(
203
+ ElTag,
204
+ { key: item.value, type: item.listClass, class: item.cssClass, disableTransitions: true },
205
+ { default: () => item.label + ' ' }
206
+ )
207
+ })
208
+
209
+ if (showValue && unmatched.length > 0) {
210
+ children.push(h('span', {}, unmatched.join(' ')))
211
+ }
212
+
213
+ return h('div', {}, children)
214
+ })
215
+
216
+ /**
217
+ * map 渲染器
218
+ */
219
+ const map = createFunctionalRenderer((props) => {
220
+ const val = getValueByPath(props.row, props.col.key) ?? ''
221
+ const options = (props.col.renderProps?.options ?? {}) as Record<string, any>
222
+ return val != null ? options[val] ?? '' : ''
223
+ })
224
+
225
+ /**
226
+ * formatter 渲染器
227
+ */
228
+ export function isDataColumn(
229
+ col: ColumnConfig
230
+ ): col is any {
231
+ return typeof (col as any).formatter === 'function'
232
+ }
233
+
234
+ const formatter = createFunctionalRenderer((props) => {
235
+ const { col, row } = props
236
+ const val = getValueByPath(props.row, props.col.key) ?? ''
237
+ if (isDataColumn(col)) {
238
+ return col.formatter?.(val, row)
239
+ }
240
+ return val ?? ''
241
+ })
242
+
243
+ /**
244
+ * icon 渲染器
245
+ */
246
+ const icon = createFunctionalRenderer((props) => {
247
+ const val = getValueByPath(props.row, props.col.key) ?? ''
248
+ const rp = props.col.renderProps || {}
249
+ if (!val) return ''
250
+ // 判断网络图片
251
+ if (/^https?:\/\//.test(val)) {
252
+ return h(ElImage, {
253
+ src: val,
254
+ previewSrcList: [val],
255
+ fit: 'contain',
256
+ style: 'width:40px;height:40px',
257
+ ...rp
258
+ })
259
+ }
260
+ // 判断 svg 源码
261
+ if (/^\s*<svg[\s\S]*<\/svg>\s*$/.test(val)) {
262
+ return h('div', {
263
+ innerHTML: val,
264
+ style: `width:40px;height:40px;display:inline-block;${rp.style || ''}`,
265
+ ...rp
266
+ })
267
+ }
268
+ // 默认当作 iconfont
269
+ return h('i', {
270
+ class: val,
271
+ style: `font-size:20px;${rp.style || ''}`,
272
+ ...rp
273
+ })
274
+ })
275
+
276
+ /**
277
+ * 所有内置渲染器
278
+ */
279
+ export const builtInRenderers = {
280
+ input,
281
+ 'input-number': inputNumber,
282
+ select,
283
+ button,
284
+ link,
285
+ html,
286
+ copy,
287
+ img,
288
+ dict,
289
+ map,
290
+ formatter,
291
+ icon,
292
+ }
293
+
294
+ /**
295
+ * 安装所有内置渲染器
296
+ */
297
+ export function registerBuiltInRenderers(registry: { registerMultiple: (renderers: Record<string, any>) => void }) {
298
+ registry.registerMultiple(builtInRenderers)
299
+ }
300
+
301
+ /**
302
+ * 创建默认渲染器集合(兼容旧 API)
303
+ * @deprecated 建议使用插件化架构
304
+ */
305
+ export function createRenderer() {
306
+ return builtInRenderers
307
+ }
@@ -0,0 +1,31 @@
1
+ <template>
2
+ <el-input
3
+ v-model="value"
4
+ v-bind="{ placeholder: '', size: 'small', clearable: true, ...col.renderProps }"
5
+ @blur="onBlur"
6
+ @keyup.enter="onEnter"
7
+ />
8
+ </template>
9
+
10
+ <script setup lang="ts">
11
+ import { ref, watch } from 'vue'
12
+ import type { ColumnConfig } from '../types'
13
+ import { getValueByPath, setValueByPath } from '../utils/path'
14
+
15
+ interface Props {
16
+ readonly row: any
17
+ readonly col: ColumnConfig
18
+ onCellBlur?: (row: any, col: ColumnConfig) => void
19
+ onCellEnter?: (row: any, col: ColumnConfig) => void
20
+ }
21
+
22
+ const props = defineProps<Props>()
23
+ const value = ref(getValueByPath(props.row, props.col.key))
24
+
25
+ watch(value, (v) => {
26
+ setValueByPath(props.row, props.col.key, v)
27
+ })
28
+
29
+ const onBlur = () => props.onCellBlur?.(props.row, props.col)
30
+ const onEnter = () => props.onCellEnter?.(props.row, props.col)
31
+ </script>
@@ -0,0 +1,33 @@
1
+ <template>
2
+ <el-input-number
3
+ v-model="value"
4
+ v-bind="{ min: 0, max: 99999, controls: false, size: 'small', ...col.renderProps }"
5
+ @blur="onBlur"
6
+ @keyup.enter="onEnter"
7
+ />
8
+ </template>
9
+
10
+ <script setup lang="ts">
11
+ import { ref, watch } from 'vue'
12
+ import type { ColumnConfig } from '../types'
13
+ import { getValueByPath, setValueByPath } from '../utils/path'
14
+
15
+ interface Props {
16
+ readonly row: any
17
+ readonly col: ColumnConfig
18
+ onCellChange?: (row: any, col: ColumnConfig) => void
19
+ onCellBlur?: (row: any, col: ColumnConfig) => void
20
+ onCellEnter?: (row: any, col: ColumnConfig) => void
21
+ }
22
+
23
+ const props = defineProps<Props>()
24
+ const value = ref(getValueByPath(props.row, props.col.key))
25
+
26
+ watch(value, (v) => {
27
+ setValueByPath(props.row, props.col.key, v)
28
+ props.onCellChange?.(props.row, props.col)
29
+ })
30
+
31
+ const onBlur = () => props.onCellBlur?.(props.row, props.col)
32
+ const onEnter = () => props.onCellEnter?.(props.row, props.col)
33
+ </script>
@@ -0,0 +1,41 @@
1
+ <template>
2
+ <el-select
3
+ v-model="value"
4
+ v-bind="{ placeholder: '请选择', size: 'small', clearable: true, ...col.renderProps }"
5
+ @change="onChange"
6
+ @blur="onBlur"
7
+ @keyup.enter="onEnter"
8
+ >
9
+ <el-option
10
+ v-for="opt in col.renderProps?.options || []"
11
+ :key="opt.value"
12
+ :label="opt.label"
13
+ :value="opt.value"
14
+ />
15
+ </el-select>
16
+ </template>
17
+
18
+ <script setup lang="ts">
19
+ import { ref, watch } from 'vue'
20
+ import type { ColumnConfig } from '../types'
21
+ import { getValueByPath, setValueByPath } from '../utils/path'
22
+
23
+ interface Props {
24
+ readonly row: any
25
+ readonly col: ColumnConfig
26
+ onCellChange?: (row: any, col: ColumnConfig) => void
27
+ onCellBlur?: (row: any, col: ColumnConfig) => void
28
+ onCellEnter?: (row: any, col: ColumnConfig) => void
29
+ }
30
+
31
+ const props = defineProps<Props>()
32
+ const value = ref(getValueByPath(props.row, props.col.key))
33
+
34
+ watch(value, (v) => {
35
+ setValueByPath(props.row, props.col.key, v)
36
+ })
37
+
38
+ const onChange = () => props.onCellChange?.(props.row, props.col)
39
+ const onBlur = () => props.onCellBlur?.(props.row, props.col)
40
+ const onEnter = () => props.onCellEnter?.(props.row, props.col)
41
+ </script>
@@ -0,0 +1,206 @@
1
+ /**
2
+ * SmartTable 主题样式
3
+ * 使用 CSS 变量实现主题定制
4
+ */
5
+
6
+ :root {
7
+ /* 默认主题色 - 可通过 CSS 变量覆盖 */
8
+ --st-primary-color: #409EFF;
9
+ --st-success-color: #67C23A;
10
+ --st-warning-color: #E6A23C;
11
+ --st-danger-color: #F56C6C;
12
+ --st-info-color: #909399;
13
+
14
+ /* 文本颜色 */
15
+ --st-text-primary: #303133;
16
+ --st-text-regular: #606266;
17
+ --st-text-secondary: #909399;
18
+ --st-text-placeholder: #C0C4CC;
19
+
20
+ /* 边框颜色 */
21
+ --st-border-color: #DCDFE6;
22
+ --st-border-color-light: #E4E7ED;
23
+ --st-border-color-lighter: #EBEEF5;
24
+ --st-border-color-extra-light: #F2F6FC;
25
+
26
+ /* 背景颜色 */
27
+ --st-bg-color: #FFFFFF;
28
+ --st-bg-color-page: #F2F3F5;
29
+
30
+ /* 间距 */
31
+ --st-spacing-xs: 4px;
32
+ --st-spacing-sm: 8px;
33
+ --st-spacing-md: 16px;
34
+ --st-spacing-lg: 24px;
35
+ --st-spacing-xl: 32px;
36
+
37
+ /* 圆角 */
38
+ --st-border-radius-sm: 2px;
39
+ --st-border-radius-md: 4px;
40
+ --st-border-radius-lg: 8px;
41
+
42
+ /* 字体 */
43
+ --st-font-size-xs: 12px;
44
+ --st-font-size-sm: 14px;
45
+ --st-font-size-md: 16px;
46
+ --st-font-size-lg: 18px;
47
+
48
+ /* 阴影 */
49
+ --st-box-shadow-light: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
50
+ --st-box-shadow-base: 0 2px 4px rgba(0, 0, 0, .12), 0 0 6px rgba(0, 0, 0, .04);
51
+ --st-box-shadow-dark: 0 2px 8px rgba(0, 0, 0, .15);
52
+ }
53
+
54
+ /* ========== 表格样式 ========== */
55
+ .smart-table {
56
+ --st-table-border-color: var(--st-border-color-lighter);
57
+ --st-table-header-bg: var(--st-bg-color);
58
+ --st-table-row-hover-bg: var(--st-border-color-extra-light);
59
+ --st-table-cell-padding: var(--st-spacing-md);
60
+ }
61
+
62
+ /* ========== 操作列样式 ========== */
63
+ .st-operation-buttons {
64
+ display: flex;
65
+ gap: var(--st-spacing-sm);
66
+ align-items: center;
67
+ justify-content: center;
68
+ }
69
+
70
+ /* ========== 复制按钮样式 ========== */
71
+ .st_copy_wrapper {
72
+ position: relative;
73
+ display: inline-block;
74
+ width: 100%;
75
+ }
76
+
77
+ .st_copy_text {
78
+ padding-right: 10px;
79
+ display: block;
80
+ overflow: hidden;
81
+ text-overflow: ellipsis;
82
+ white-space: nowrap;
83
+ }
84
+
85
+ .st_copy_btn {
86
+ position: absolute;
87
+ right: -5px;
88
+ top: 50%;
89
+ transform: translateY(-50%);
90
+ cursor: pointer;
91
+ display: none;
92
+ font-size: 12px;
93
+ color: var(--st-primary-color);
94
+ user-select: none;
95
+ transition: all 0.2s;
96
+ }
97
+
98
+ .st_copy_wrapper:hover .st_copy_btn {
99
+ display: inline-block !important;
100
+ }
101
+
102
+ .st_copy_btn:hover {
103
+ transform: translateY(-50%) scale(1.1);
104
+ }
105
+
106
+ /* ========== 图片样式 ========== */
107
+ .st_img_wrapper {
108
+ display: flex;
109
+ align-items: center;
110
+ position: relative;
111
+ }
112
+
113
+ .st_img_total {
114
+ position: absolute;
115
+ top: 0;
116
+ right: 0;
117
+ background: rgba(0, 0, 0, 0.5);
118
+ color: white;
119
+ padding: 2px 6px;
120
+ border-radius: 10px;
121
+ font-size: 12px;
122
+ }
123
+
124
+ /* ========== 文本截断 ========== */
125
+ .line-clamp-1 {
126
+ overflow: hidden;
127
+ text-overflow: ellipsis;
128
+ white-space: nowrap;
129
+ }
130
+
131
+ .line-clamp-2 {
132
+ display: -webkit-box;
133
+ -webkit-line-clamp: 2;
134
+ -webkit-box-orient: vertical;
135
+ overflow: hidden;
136
+ text-overflow: ellipsis;
137
+ }
138
+
139
+ /* ========== 可编辑单元格样式 ========== */
140
+ .st-editable-cell {
141
+ padding: 0;
142
+ }
143
+
144
+ .st-editable-cell .el-input,
145
+ .st-editable-cell .el-input-number,
146
+ .st-editable-cell .el-select {
147
+ width: 100%;
148
+ }
149
+
150
+ /* ========== 加载状态 ========== */
151
+ .st-loading-overlay {
152
+ position: absolute;
153
+ top: 0;
154
+ left: 0;
155
+ right: 0;
156
+ bottom: 0;
157
+ background: rgba(255, 255, 255, 0.8);
158
+ display: flex;
159
+ align-items: center;
160
+ justify-content: center;
161
+ z-index: 1000;
162
+ }
163
+
164
+ /* ========== 空状态 ========== */
165
+ .st-empty-state {
166
+ padding: var(--st-spacing-xl);
167
+ text-align: center;
168
+ color: var(--st-text-secondary);
169
+ }
170
+
171
+ .st-empty-state__icon {
172
+ font-size: 48px;
173
+ margin-bottom: var(--st-spacing-md);
174
+ opacity: 0.5;
175
+ }
176
+
177
+ .st-empty-state__text {
178
+ font-size: var(--st-font-size-sm);
179
+ }
180
+
181
+ /* ========== 响应式 ========== */
182
+ @media (max-width: 768px) {
183
+ .smart-table {
184
+ font-size: var(--st-font-size-xs);
185
+ }
186
+
187
+ .st-operation-buttons {
188
+ flex-direction: column;
189
+ gap: var(--st-spacing-xs);
190
+ }
191
+ }
192
+
193
+ /* ========== 深色主题支持 ========== */
194
+ @media (prefers-color-scheme: dark) {
195
+ :root {
196
+ --st-text-primary: #E5EAF3;
197
+ --st-text-regular: #CFD3DC;
198
+ --st-text-secondary: #A3A6AD;
199
+ --st-border-color: #4C4D4F;
200
+ --st-border-color-light: #414243;
201
+ --st-border-color-lighter: #363637;
202
+ --st-border-color-extra-light: #2B2B2C;
203
+ --st-bg-color: #1D1E1F;
204
+ --st-bg-color-page: #141414;
205
+ }
206
+ }