koishi-plugin-media-luna 0.0.13 → 0.0.15

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.
@@ -53,6 +53,17 @@
53
53
  />
54
54
  </template>
55
55
 
56
+ <!-- Table 类型(数组对象编辑) -->
57
+ <template v-else-if="field.type === 'table' && field.columns">
58
+ <TableFieldEditor
59
+ :columns="field.columns"
60
+ :model-value="getTableRows(field.key)"
61
+ @update:model-value="setFieldValue(field.key, $event)"
62
+ :table-config="field.tableConfig"
63
+ :presets="getPresets(field.tableConfig?.presetsSource)"
64
+ />
65
+ </template>
66
+
56
67
  <!-- Password 类型 -->
57
68
  <template v-else-if="field.type === 'password'">
58
69
  <el-input
@@ -83,8 +94,9 @@
83
94
  </template>
84
95
 
85
96
  <script setup lang="ts">
86
- import { computed } from 'vue'
87
- import type { ConfigField } from '../types'
97
+ import { computed, inject } from 'vue'
98
+ import type { ConfigField, TableColumnDefinition } from '../types'
99
+ import TableFieldEditor from './TableFieldEditor.vue'
88
100
 
89
101
  interface Props {
90
102
  /** 配置字段定义 */
@@ -93,10 +105,13 @@ interface Props {
93
105
  modelValue: Record<string, any>
94
106
  /** 是否显示清除按钮 */
95
107
  clearable?: boolean
108
+ /** 预设数据源(外部注入) */
109
+ presetsMap?: Record<string, Record<string, any>[]>
96
110
  }
97
111
 
98
112
  const props = withDefaults(defineProps<Props>(), {
99
- clearable: false
113
+ clearable: false,
114
+ presetsMap: () => ({})
100
115
  })
101
116
 
102
117
  const emit = defineEmits<{
@@ -120,6 +135,20 @@ const shouldShowField = (field: ConfigField) => {
120
135
  const { field: dependField, value } = field.showWhen
121
136
  return props.modelValue[dependField] === value
122
137
  }
138
+
139
+ // ============ Table 类型支持 ============
140
+
141
+ // 获取表格行数据
142
+ const getTableRows = (key: string): Record<string, any>[] => {
143
+ const value = props.modelValue[key]
144
+ return Array.isArray(value) ? value : []
145
+ }
146
+
147
+ // 获取预设数据
148
+ const getPresets = (source?: string): Record<string, any>[] => {
149
+ if (!source) return []
150
+ return props.presetsMap?.[source] || []
151
+ }
123
152
  </script>
124
153
 
125
154
  <style scoped>
@@ -0,0 +1,587 @@
1
+ <template>
2
+ <div class="table-field-editor">
3
+ <!-- 工具栏 -->
4
+ <div class="toolbar">
5
+ <div class="toolbar-left">
6
+ <el-button type="primary" size="small" @click="addRow">
7
+ <k-icon name="add" />
8
+ 添加
9
+ </el-button>
10
+ <el-button
11
+ v-if="tableConfig?.enableBatchDelete && selectedRows.length > 0"
12
+ type="danger"
13
+ size="small"
14
+ @click="deleteSelected"
15
+ >
16
+ 删除选中 ({{ selectedRows.length }})
17
+ </el-button>
18
+ </div>
19
+ <div class="toolbar-right">
20
+ <el-button
21
+ v-if="presets?.length > 0"
22
+ size="small"
23
+ @click="showPresetsDialog = true"
24
+ >
25
+ 内置预设
26
+ </el-button>
27
+ <el-button
28
+ v-if="tableConfig?.enableImport !== false"
29
+ size="small"
30
+ @click="showImportDialog = true"
31
+ >
32
+ 导入
33
+ </el-button>
34
+ <el-button
35
+ v-if="tableConfig?.enableExport !== false"
36
+ size="small"
37
+ @click="exportData"
38
+ >
39
+ 导出
40
+ </el-button>
41
+ </div>
42
+ </div>
43
+
44
+ <!-- 数据表格 -->
45
+ <div class="table-wrapper">
46
+ <table class="data-table">
47
+ <thead>
48
+ <tr>
49
+ <th v-if="tableConfig?.enableSelection !== false" class="col-check">
50
+ <el-checkbox
51
+ :model-value="isAllSelected"
52
+ :indeterminate="isIndeterminate"
53
+ @change="toggleSelectAll"
54
+ size="small"
55
+ />
56
+ </th>
57
+ <th
58
+ v-for="col in columns"
59
+ :key="col.key"
60
+ :class="['col-' + col.key, { required: col.required }]"
61
+ >
62
+ {{ col.label }}
63
+ </th>
64
+ <th class="col-op">操作</th>
65
+ </tr>
66
+ </thead>
67
+ <tbody>
68
+ <tr
69
+ v-for="(row, index) in rows"
70
+ :key="index"
71
+ :class="{ selected: selectedRows.includes(index) }"
72
+ >
73
+ <td v-if="tableConfig?.enableSelection !== false" class="col-check">
74
+ <el-checkbox
75
+ :model-value="selectedRows.includes(index)"
76
+ @change="toggleRowSelection(index)"
77
+ size="small"
78
+ />
79
+ </td>
80
+ <td v-for="col in columns" :key="col.key" :class="'col-' + col.key">
81
+ <el-input
82
+ v-if="col.type === 'text'"
83
+ :model-value="row[col.key]"
84
+ @update:model-value="updateCell(index, col.key, $event)"
85
+ :placeholder="col.placeholder"
86
+ size="small"
87
+ />
88
+ <el-input-number
89
+ v-else-if="col.type === 'number'"
90
+ :model-value="row[col.key]"
91
+ @update:model-value="updateCell(index, col.key, $event)"
92
+ size="small"
93
+ :controls="false"
94
+ class="num-input"
95
+ />
96
+ <el-switch
97
+ v-else-if="col.type === 'boolean'"
98
+ :model-value="row[col.key]"
99
+ @update:model-value="updateCell(index, col.key, $event)"
100
+ size="small"
101
+ />
102
+ <el-select
103
+ v-else-if="col.type === 'select'"
104
+ :model-value="row[col.key]"
105
+ @update:model-value="updateCell(index, col.key, $event)"
106
+ :placeholder="col.placeholder || '请选择'"
107
+ size="small"
108
+ >
109
+ <el-option
110
+ v-for="opt in col.options"
111
+ :key="String(opt.value)"
112
+ :label="opt.label"
113
+ :value="opt.value"
114
+ />
115
+ </el-select>
116
+ </td>
117
+ <td class="col-op">
118
+ <el-button type="danger" size="small" link @click="removeRow(index)">
119
+ 删除
120
+ </el-button>
121
+ </td>
122
+ </tr>
123
+ <!-- 添加行 -->
124
+ <tr class="add-row" @click="addRow">
125
+ <td :colspan="totalColumns" class="add-cell">
126
+ <span class="add-hint">
127
+ <k-icon name="add" />
128
+ 点击添加一行
129
+ </span>
130
+ </td>
131
+ </tr>
132
+ </tbody>
133
+ </table>
134
+ </div>
135
+
136
+ <!-- 底部统计 -->
137
+ <div class="table-footer" v-if="rows.length > 0">
138
+ 共 {{ rows.length }} 条
139
+ </div>
140
+
141
+ <!-- 导入对话框 -->
142
+ <el-dialog
143
+ v-model="showImportDialog"
144
+ title="导入数据"
145
+ width="550px"
146
+ :close-on-click-modal="false"
147
+ >
148
+ <p class="dialog-tip">粘贴 JSON 数组格式数据:</p>
149
+ <el-input
150
+ v-model="importText"
151
+ type="textarea"
152
+ :rows="10"
153
+ placeholder='[{"alias": "xxx", "repoId": "xxx", ...}]'
154
+ />
155
+ <div class="import-mode">
156
+ <el-radio-group v-model="importMode">
157
+ <el-radio value="append">追加</el-radio>
158
+ <el-radio value="replace">替换</el-radio>
159
+ </el-radio-group>
160
+ </div>
161
+ <template #footer>
162
+ <el-button @click="showImportDialog = false">取消</el-button>
163
+ <el-button type="primary" @click="doImport">导入</el-button>
164
+ </template>
165
+ </el-dialog>
166
+
167
+ <!-- 预设对话框 -->
168
+ <el-dialog
169
+ v-model="showPresetsDialog"
170
+ title="内置预设"
171
+ width="600px"
172
+ :close-on-click-modal="false"
173
+ >
174
+ <div class="presets-header">
175
+ <el-input
176
+ v-model="presetSearch"
177
+ placeholder="搜索..."
178
+ size="small"
179
+ clearable
180
+ class="search-input"
181
+ />
182
+ <el-button size="small" @click="selectAllPresets">全选</el-button>
183
+ <el-button size="small" @click="selectedPresets = []">清空</el-button>
184
+ <span class="sel-count">已选 {{ selectedPresets.length }}</span>
185
+ </div>
186
+ <div class="presets-list">
187
+ <div
188
+ v-for="p in filteredPresets"
189
+ :key="p.alias"
190
+ class="preset-row"
191
+ :class="{ selected: selectedPresets.includes(p.alias), disabled: existingAliases.has(p.alias?.toLowerCase()) }"
192
+ @click="togglePreset(p.alias)"
193
+ >
194
+ <el-checkbox
195
+ :model-value="selectedPresets.includes(p.alias)"
196
+ :disabled="existingAliases.has(p.alias?.toLowerCase())"
197
+ size="small"
198
+ />
199
+ <span class="p-alias">{{ p.alias }}</span>
200
+ <span class="p-desc">{{ p.description || p.repoId }}</span>
201
+ <el-tag v-if="existingAliases.has(p.alias?.toLowerCase())" size="small" type="info">已有</el-tag>
202
+ </div>
203
+ </div>
204
+ <template #footer>
205
+ <el-button @click="showPresetsDialog = false">取消</el-button>
206
+ <el-button type="primary" @click="importPresets" :disabled="selectedPresets.length === 0">
207
+ 导入 ({{ selectedPresets.length }})
208
+ </el-button>
209
+ </template>
210
+ </el-dialog>
211
+ </div>
212
+ </template>
213
+
214
+ <script setup lang="ts">
215
+ import { ref, computed, watch } from 'vue'
216
+ import { ElMessage } from 'element-plus'
217
+ import type { TableColumnDefinition, TableConfig } from '../types'
218
+
219
+ interface Props {
220
+ columns: TableColumnDefinition[]
221
+ modelValue: Record<string, any>[]
222
+ tableConfig?: TableConfig
223
+ presets?: Record<string, any>[]
224
+ }
225
+
226
+ const props = withDefaults(defineProps<Props>(), {
227
+ modelValue: () => [],
228
+ presets: () => []
229
+ })
230
+
231
+ const emit = defineEmits<{
232
+ 'update:modelValue': [value: Record<string, any>[]]
233
+ }>()
234
+
235
+ const rows = computed({
236
+ get: () => props.modelValue || [],
237
+ set: (val) => emit('update:modelValue', val)
238
+ })
239
+
240
+ const selectedRows = ref<number[]>([])
241
+ const isAllSelected = computed(() => rows.value.length > 0 && selectedRows.value.length === rows.value.length)
242
+ const isIndeterminate = computed(() => selectedRows.value.length > 0 && selectedRows.value.length < rows.value.length)
243
+
244
+ const totalColumns = computed(() => {
245
+ let count = props.columns.length + 1 // columns + operation column
246
+ if (props.tableConfig?.enableSelection !== false) count++ // checkbox column
247
+ return count
248
+ })
249
+
250
+ const showImportDialog = ref(false)
251
+ const importText = ref('')
252
+ const importMode = ref<'append' | 'replace'>('append')
253
+
254
+ const showPresetsDialog = ref(false)
255
+ const selectedPresets = ref<string[]>([])
256
+ const presetSearch = ref('')
257
+
258
+ const existingAliases = computed(() => {
259
+ const set = new Set<string>()
260
+ for (const row of rows.value) {
261
+ if (row.alias) set.add(row.alias.toLowerCase())
262
+ }
263
+ return set
264
+ })
265
+
266
+ const filteredPresets = computed(() => {
267
+ if (!presetSearch.value) return props.presets
268
+ const s = presetSearch.value.toLowerCase()
269
+ return props.presets.filter(p =>
270
+ p.alias?.toLowerCase().includes(s) ||
271
+ p.description?.toLowerCase().includes(s) ||
272
+ p.repoId?.toLowerCase().includes(s)
273
+ )
274
+ })
275
+
276
+ function addRow() {
277
+ const newRow: Record<string, any> = {}
278
+ for (const col of props.columns) {
279
+ newRow[col.key] = col.type === 'boolean' ? false : col.type === 'number' ? undefined : ''
280
+ }
281
+ emit('update:modelValue', [...rows.value, newRow])
282
+ }
283
+
284
+ function removeRow(index: number) {
285
+ const arr = [...rows.value]
286
+ arr.splice(index, 1)
287
+ emit('update:modelValue', arr)
288
+ selectedRows.value = selectedRows.value.filter(i => i !== index).map(i => i > index ? i - 1 : i)
289
+ }
290
+
291
+ function updateCell(rowIndex: number, colKey: string, value: any) {
292
+ const arr = [...rows.value]
293
+ arr[rowIndex] = { ...arr[rowIndex], [colKey]: value }
294
+ emit('update:modelValue', arr)
295
+ }
296
+
297
+ function toggleRowSelection(index: number) {
298
+ const idx = selectedRows.value.indexOf(index)
299
+ if (idx >= 0) selectedRows.value.splice(idx, 1)
300
+ else selectedRows.value.push(index)
301
+ }
302
+
303
+ function toggleSelectAll(val: boolean) {
304
+ selectedRows.value = val ? rows.value.map((_, i) => i) : []
305
+ }
306
+
307
+ function deleteSelected() {
308
+ if (selectedRows.value.length === 0) return
309
+ const toDelete = new Set(selectedRows.value)
310
+ emit('update:modelValue', rows.value.filter((_, i) => !toDelete.has(i)))
311
+ ElMessage.success(`已删除 ${toDelete.size} 条`)
312
+ selectedRows.value = []
313
+ }
314
+
315
+ function exportData() {
316
+ const blob = new Blob([JSON.stringify(rows.value, null, 2)], { type: 'application/json' })
317
+ const url = URL.createObjectURL(blob)
318
+ const a = document.createElement('a')
319
+ a.href = url
320
+ a.download = 'data.json'
321
+ a.click()
322
+ URL.revokeObjectURL(url)
323
+ ElMessage.success('导出成功')
324
+ }
325
+
326
+ function doImport() {
327
+ try {
328
+ const data = JSON.parse(importText.value)
329
+ if (!Array.isArray(data)) {
330
+ ElMessage.error('需要 JSON 数组')
331
+ return
332
+ }
333
+ const normalized = data.map(item => {
334
+ const row: Record<string, any> = {}
335
+ for (const col of props.columns) {
336
+ row[col.key] = item[col.key] ?? (col.key === 'repoId' && item.name ? item.name : '')
337
+ }
338
+ return row
339
+ })
340
+ emit('update:modelValue', importMode.value === 'replace' ? normalized : [...rows.value, ...normalized])
341
+ ElMessage.success(`已导入 ${normalized.length} 条`)
342
+ showImportDialog.value = false
343
+ importText.value = ''
344
+ } catch {
345
+ ElMessage.error('JSON 解析失败')
346
+ }
347
+ }
348
+
349
+ function selectAllPresets() {
350
+ selectedPresets.value = filteredPresets.value
351
+ .filter(p => !existingAliases.value.has(p.alias?.toLowerCase()))
352
+ .map(p => p.alias)
353
+ }
354
+
355
+ function togglePreset(alias: string) {
356
+ if (existingAliases.value.has(alias?.toLowerCase())) return
357
+ const idx = selectedPresets.value.indexOf(alias)
358
+ if (idx >= 0) selectedPresets.value.splice(idx, 1)
359
+ else selectedPresets.value.push(alias)
360
+ }
361
+
362
+ function importPresets() {
363
+ const toImport = props.presets.filter(p => selectedPresets.value.includes(p.alias))
364
+ const newRows = toImport.map(preset => {
365
+ const row: Record<string, any> = {}
366
+ for (const col of props.columns) row[col.key] = preset[col.key] ?? ''
367
+ return row
368
+ })
369
+ emit('update:modelValue', [...rows.value, ...newRows])
370
+ ElMessage.success(`已导入 ${newRows.length} 条`)
371
+ showPresetsDialog.value = false
372
+ selectedPresets.value = []
373
+ }
374
+
375
+ watch(showPresetsDialog, (v) => {
376
+ if (v) {
377
+ selectedPresets.value = []
378
+ presetSearch.value = ''
379
+ }
380
+ })
381
+ </script>
382
+
383
+ <style scoped>
384
+ .table-field-editor {
385
+ width: 100%;
386
+ max-width: 100%;
387
+ overflow: hidden;
388
+ }
389
+
390
+ .toolbar {
391
+ display: flex;
392
+ justify-content: space-between;
393
+ align-items: center;
394
+ margin-bottom: 10px;
395
+ flex-wrap: wrap;
396
+ gap: 8px;
397
+ }
398
+
399
+ .toolbar-left, .toolbar-right {
400
+ display: flex;
401
+ gap: 6px;
402
+ }
403
+
404
+ .table-wrapper {
405
+ border: 1px solid var(--k-color-border, #dcdfe6);
406
+ border-radius: 6px;
407
+ overflow-x: auto;
408
+ }
409
+
410
+ .data-table {
411
+ width: 100%;
412
+ min-width: 500px;
413
+ border-collapse: collapse;
414
+ font-size: 13px;
415
+ }
416
+
417
+ .data-table th,
418
+ .data-table td {
419
+ padding: 6px 8px;
420
+ text-align: left;
421
+ border-bottom: 1px solid var(--k-color-border, #ebeef5);
422
+ vertical-align: middle;
423
+ }
424
+
425
+ .data-table th {
426
+ background: var(--k-color-fill, #f5f7fa);
427
+ font-weight: 500;
428
+ color: var(--k-color-text-secondary, #909399);
429
+ font-size: 12px;
430
+ white-space: nowrap;
431
+ }
432
+
433
+ .data-table th.required::after {
434
+ content: '*';
435
+ color: #f56c6c;
436
+ margin-left: 2px;
437
+ }
438
+
439
+ .data-table tbody tr:hover {
440
+ background: var(--k-color-fill-light, #f5f7fa);
441
+ }
442
+
443
+ .data-table tbody tr.selected {
444
+ background: #ecf5ff;
445
+ }
446
+
447
+ .data-table tbody tr:last-child td {
448
+ border-bottom: none;
449
+ }
450
+
451
+ /* 列宽控制 - 使用百分比 */
452
+ .col-check { width: 32px; text-align: center; }
453
+ .col-op { width: 45px; text-align: center; }
454
+ .col-alias { width: 15%; min-width: 70px; }
455
+ .col-repoId { width: 35%; min-width: 120px; }
456
+ .col-triggerWords { width: 25%; min-width: 100px; }
457
+ .col-description { width: 25%; min-width: 100px; }
458
+
459
+ .data-table :deep(.el-input) {
460
+ width: 100%;
461
+ }
462
+
463
+ .data-table :deep(.el-input__wrapper) {
464
+ padding: 0 8px;
465
+ }
466
+
467
+ .data-table :deep(.el-input__inner) {
468
+ height: 28px;
469
+ font-size: 12px;
470
+ }
471
+
472
+ .num-input {
473
+ width: 100%;
474
+ }
475
+
476
+ .empty-tip {
477
+ padding: 30px;
478
+ text-align: center;
479
+ color: var(--k-color-text-description, #c0c4cc);
480
+ font-size: 13px;
481
+ }
482
+
483
+ .add-row {
484
+ cursor: pointer;
485
+ }
486
+
487
+ .add-row:hover {
488
+ background: var(--k-color-fill-light, #f5f7fa);
489
+ }
490
+
491
+ .add-cell {
492
+ text-align: center;
493
+ padding: 12px !important;
494
+ }
495
+
496
+ .add-hint {
497
+ color: var(--k-color-active, #409eff);
498
+ display: inline-flex;
499
+ align-items: center;
500
+ gap: 4px;
501
+ font-size: 13px;
502
+ }
503
+
504
+ .table-footer {
505
+ margin-top: 8px;
506
+ font-size: 12px;
507
+ color: var(--k-color-text-description, #909399);
508
+ text-align: right;
509
+ }
510
+
511
+ /* 对话框 */
512
+ .dialog-tip {
513
+ margin: 0 0 10px;
514
+ font-size: 13px;
515
+ color: var(--k-color-text-secondary);
516
+ }
517
+
518
+ .import-mode {
519
+ margin-top: 10px;
520
+ }
521
+
522
+ /* 预设对话框 */
523
+ .presets-header {
524
+ display: flex;
525
+ align-items: center;
526
+ gap: 8px;
527
+ margin-bottom: 10px;
528
+ }
529
+
530
+ .search-input {
531
+ width: 160px;
532
+ }
533
+
534
+ .sel-count {
535
+ margin-left: auto;
536
+ font-size: 12px;
537
+ color: var(--k-color-text-description);
538
+ }
539
+
540
+ .presets-list {
541
+ max-height: 360px;
542
+ overflow-y: auto;
543
+ border: 1px solid var(--k-color-border, #dcdfe6);
544
+ border-radius: 4px;
545
+ }
546
+
547
+ .preset-row {
548
+ display: flex;
549
+ align-items: center;
550
+ gap: 10px;
551
+ padding: 8px 12px;
552
+ border-bottom: 1px solid var(--k-color-border, #ebeef5);
553
+ cursor: pointer;
554
+ transition: background 0.15s;
555
+ }
556
+
557
+ .preset-row:last-child {
558
+ border-bottom: none;
559
+ }
560
+
561
+ .preset-row:hover:not(.disabled) {
562
+ background: var(--k-color-fill-light, #f5f7fa);
563
+ }
564
+
565
+ .preset-row.selected:not(.disabled) {
566
+ background: #ecf5ff;
567
+ }
568
+
569
+ .preset-row.disabled {
570
+ opacity: 0.5;
571
+ cursor: not-allowed;
572
+ }
573
+
574
+ .p-alias {
575
+ font-weight: 500;
576
+ min-width: 80px;
577
+ }
578
+
579
+ .p-desc {
580
+ flex: 1;
581
+ font-size: 12px;
582
+ color: var(--k-color-text-description);
583
+ overflow: hidden;
584
+ text-overflow: ellipsis;
585
+ white-space: nowrap;
586
+ }
587
+ </style>
@@ -99,6 +99,7 @@
99
99
  <ConfigRenderer
100
100
  :fields="selectedPlugin.configFields"
101
101
  v-model="pluginConfig"
102
+ :presets-map="selectedPlugin.presets"
102
103
  />
103
104
  <div class="config-actions">
104
105
  <k-button type="primary" @click="saveConfig" :loading="saving">
package/client/types.ts CHANGED
@@ -11,6 +11,8 @@ export type {
11
11
  ConfigField,
12
12
  ConfigFieldType,
13
13
  ConfigFieldOption,
14
+ TableColumnDefinition,
15
+ TableConfig,
14
16
  CardField,
15
17
  CardDisplayField,
16
18
  MiddlewareCardField,