@h-ai/ui 0.1.0-alpha.22 → 0.1.0-alpha.24
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.
- package/README.md +39 -5
- package/auto-import.d.ts +2 -2
- package/auto-import.js +2 -2
- package/dist/components/compounds/Card.svelte +1 -1
- package/dist/components/compounds/DataTable.svelte +32 -7
- package/dist/components/compounds/DataTable.svelte.d.ts +2 -0
- package/dist/components/compounds/Drawer.svelte +7 -2
- package/dist/components/compounds/Modal.svelte +2 -0
- package/dist/components/compounds/Pagination.svelte +57 -16
- package/dist/components/primitives/Input.svelte +23 -1
- package/dist/components/primitives/Select.svelte +2 -2
- package/dist/components/scenes/crud/CrudDetailDrawer.svelte +59 -18
- package/dist/components/scenes/crud/CrudDetailDrawer.svelte.d.ts +8 -2
- package/dist/components/scenes/crud/CrudDetailPanel.svelte +204 -0
- package/dist/components/scenes/crud/CrudDetailPanel.svelte.d.ts +45 -0
- package/dist/components/scenes/crud/CrudEditPanel.svelte +281 -0
- package/dist/components/scenes/crud/{CrudEditDrawer.svelte.d.ts → CrudEditPanel.svelte.d.ts} +13 -6
- package/dist/components/scenes/crud/CrudFilterBar.svelte +22 -18
- package/dist/components/scenes/crud/CrudPage.svelte +105 -60
- package/dist/components/scenes/crud/CrudPage.svelte.d.ts +6 -4
- package/dist/components/scenes/crud/crud-types.d.ts +36 -2
- package/dist/components/scenes/crud/index.d.ts +3 -3
- package/dist/components/scenes/crud/index.js +3 -3
- package/dist/messages/en-US.json +3 -2
- package/dist/messages/zh-CN.json +3 -2
- package/dist/types.d.ts +10 -0
- package/package.json +2 -2
- package/dist/components/scenes/crud/CrudEditDrawer.svelte +0 -225
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component CrudDetailPanel
|
|
3
|
+
CRUD 详情组件(只读展示),支持抽屉(drawer)与弹窗(modal)两种展示形式
|
|
4
|
+
|
|
5
|
+
使用 Svelte 5 Runes ($props, $derived)
|
|
6
|
+
使用 compounds 组件:Drawer, Modal, Badge
|
|
7
|
+
-->
|
|
8
|
+
<script lang='ts'>
|
|
9
|
+
import type { Snippet } from 'svelte'
|
|
10
|
+
import type { Size } from '../../../types.js'
|
|
11
|
+
import type { CrudDensity, CrudFormVariant } from './crud-types.js'
|
|
12
|
+
import { uiM } from '../../../messages.js'
|
|
13
|
+
import Drawer from '../../compounds/Drawer.svelte'
|
|
14
|
+
import Modal from '../../compounds/Modal.svelte'
|
|
15
|
+
import Badge from '../../primitives/Badge.svelte'
|
|
16
|
+
import Button from '../../primitives/Button.svelte'
|
|
17
|
+
|
|
18
|
+
type FieldDef = {
|
|
19
|
+
id: string
|
|
20
|
+
label: string | (() => string)
|
|
21
|
+
type: string
|
|
22
|
+
inDetail?: boolean
|
|
23
|
+
options?: Array<{ label: string, value: string | number | boolean }> | (() => Array<{ label: string, value: string | number | boolean }>)
|
|
24
|
+
render?: (value: unknown, item: Record<string, unknown>) => string
|
|
25
|
+
order?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let {
|
|
29
|
+
open = $bindable(false),
|
|
30
|
+
item = null,
|
|
31
|
+
fields = [],
|
|
32
|
+
density = 'normal' as CrudDensity,
|
|
33
|
+
title = '',
|
|
34
|
+
variant = 'drawer' as CrudFormVariant,
|
|
35
|
+
size = '2xl' as Size,
|
|
36
|
+
drawerWidth,
|
|
37
|
+
modalSize = '2xl' as Size | 'full',
|
|
38
|
+
modalWidth,
|
|
39
|
+
modalHeight,
|
|
40
|
+
canEdit = false,
|
|
41
|
+
onedit,
|
|
42
|
+
onclose,
|
|
43
|
+
detailExtra,
|
|
44
|
+
}: {
|
|
45
|
+
open?: boolean
|
|
46
|
+
item?: Record<string, unknown> | null
|
|
47
|
+
fields?: FieldDef[]
|
|
48
|
+
density?: CrudDensity
|
|
49
|
+
title?: string
|
|
50
|
+
variant?: CrudFormVariant
|
|
51
|
+
size?: Size
|
|
52
|
+
drawerWidth?: string
|
|
53
|
+
modalSize?: Size | 'full'
|
|
54
|
+
modalWidth?: string
|
|
55
|
+
modalHeight?: string
|
|
56
|
+
canEdit?: boolean
|
|
57
|
+
onedit?: () => void
|
|
58
|
+
onclose?: () => void
|
|
59
|
+
detailExtra?: Snippet<[Record<string, unknown>]>
|
|
60
|
+
} = $props()
|
|
61
|
+
|
|
62
|
+
function resolveText(text: string | (() => string)): string {
|
|
63
|
+
return typeof text === 'function' ? text() : text
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolveOptions(options?: FieldDef['options']): Array<{ label: string, value: string | number | boolean }> {
|
|
67
|
+
if (!options)
|
|
68
|
+
return []
|
|
69
|
+
return typeof options === 'function' ? options() : options
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatValue(field: FieldDef, value: unknown): string {
|
|
73
|
+
if (value === null || value === undefined)
|
|
74
|
+
return uiM('crud_detail_empty')
|
|
75
|
+
|
|
76
|
+
if (field.render && item) {
|
|
77
|
+
return field.render(value, item)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (field.type === 'select' || field.type === 'radio') {
|
|
81
|
+
const opts = resolveOptions(field.options)
|
|
82
|
+
const found = opts.find(o => String(o.value) === String(value))
|
|
83
|
+
return found?.label ?? String(value)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (field.type === 'multi-select' && Array.isArray(value)) {
|
|
87
|
+
const opts = resolveOptions(field.options)
|
|
88
|
+
return value.map((v) => {
|
|
89
|
+
const found = opts.find(o => String(o.value) === String(v))
|
|
90
|
+
return found?.label ?? String(v)
|
|
91
|
+
}).join(', ')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (field.type === 'boolean' || field.type === 'checkbox') {
|
|
95
|
+
return value ? '✓' : '✗'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (field.type === 'date' && (typeof value === 'string' || typeof value === 'number' || value instanceof Date)) {
|
|
99
|
+
return new Date(value as string | number).toLocaleDateString()
|
|
100
|
+
}
|
|
101
|
+
if (field.type === 'datetime' && (typeof value === 'string' || typeof value === 'number' || value instanceof Date)) {
|
|
102
|
+
return new Date(value as string | number).toLocaleString()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return String(value)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const detailFields = $derived(
|
|
109
|
+
[...fields]
|
|
110
|
+
.filter(f => f.inDetail !== false)
|
|
111
|
+
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
const isCompact = $derived(density === 'compact')
|
|
115
|
+
const contentClass = $derived(isCompact ? 'space-y-3' : 'space-y-4')
|
|
116
|
+
const labelClass = $derived(isCompact ? 'text-[11px] font-medium text-base-content/50 mb-1' : 'text-xs font-medium text-base-content/50 mb-1')
|
|
117
|
+
const valueClass = $derived(isCompact ? 'text-[13px] text-base-content' : 'text-sm text-base-content')
|
|
118
|
+
const badgeSize = $derived(isCompact ? 'xs' : 'sm')
|
|
119
|
+
const detailExtraClass = $derived(isCompact ? 'border-t border-base-content/5 pt-3' : 'border-t border-base-content/5 pt-4')
|
|
120
|
+
const iconClass = $derived(isCompact ? 'size-3.5 mr-1' : 'size-4 mr-1')
|
|
121
|
+
const drawerBodyClass = $derived(isCompact ? 'pb-16' : 'pb-20')
|
|
122
|
+
const drawerFooterClass = $derived(isCompact ? 'absolute bottom-0 left-0 right-0 p-3 bg-base-200 border-t border-base-content/10 flex justify-end gap-2' : 'absolute bottom-0 left-0 right-0 p-4 bg-base-200 border-t border-base-content/10 flex justify-end gap-2')
|
|
123
|
+
|
|
124
|
+
function handleClose() {
|
|
125
|
+
open = false
|
|
126
|
+
onclose?.()
|
|
127
|
+
}
|
|
128
|
+
</script>
|
|
129
|
+
|
|
130
|
+
{#snippet detailBody()}
|
|
131
|
+
{#if item}
|
|
132
|
+
<div class={contentClass}>
|
|
133
|
+
{#each detailFields as field (field.id)}
|
|
134
|
+
{@const value = item[field.id]}
|
|
135
|
+
<div>
|
|
136
|
+
<div class={labelClass}>
|
|
137
|
+
{resolveText(field.label)}
|
|
138
|
+
</div>
|
|
139
|
+
<div class={valueClass}>
|
|
140
|
+
{#if field.type === 'multi-select' && Array.isArray(value) && value.length > 0}
|
|
141
|
+
<div class='flex flex-wrap gap-1'>
|
|
142
|
+
{#each value as v, index (`${String(v)}:${index}`)}
|
|
143
|
+
{@const opts = resolveOptions(field.options)}
|
|
144
|
+
{@const found = opts.find(o => String(o.value) === String(v))}
|
|
145
|
+
<Badge variant='ghost' size={badgeSize}>{found?.label ?? String(v)}</Badge>
|
|
146
|
+
{/each}
|
|
147
|
+
</div>
|
|
148
|
+
{:else}
|
|
149
|
+
{formatValue(field, value)}
|
|
150
|
+
{/if}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
{/each}
|
|
154
|
+
|
|
155
|
+
{#if detailExtra && item}
|
|
156
|
+
<div class={detailExtraClass}>
|
|
157
|
+
{@render detailExtra(item)}
|
|
158
|
+
</div>
|
|
159
|
+
{/if}
|
|
160
|
+
</div>
|
|
161
|
+
{/if}
|
|
162
|
+
{/snippet}
|
|
163
|
+
|
|
164
|
+
{#snippet footerActions()}
|
|
165
|
+
{#if canEdit}
|
|
166
|
+
<Button variant='primary' size='sm' onclick={onedit}>
|
|
167
|
+
<span class='icon-[tabler--edit] {iconClass}'></span>
|
|
168
|
+
{uiM('crud_edit')}
|
|
169
|
+
</Button>
|
|
170
|
+
{/if}
|
|
171
|
+
<Button variant='ghost' size='sm' onclick={handleClose}>
|
|
172
|
+
{uiM('crud_close')}
|
|
173
|
+
</Button>
|
|
174
|
+
{/snippet}
|
|
175
|
+
|
|
176
|
+
{#if variant === 'modal'}
|
|
177
|
+
<Modal
|
|
178
|
+
bind:open
|
|
179
|
+
{title}
|
|
180
|
+
size={modalSize}
|
|
181
|
+
width={modalWidth}
|
|
182
|
+
height={modalHeight}
|
|
183
|
+
onclose={handleClose}
|
|
184
|
+
>
|
|
185
|
+
{@render detailBody()}
|
|
186
|
+
|
|
187
|
+
{#snippet footer()}
|
|
188
|
+
{@render footerActions()}
|
|
189
|
+
{/snippet}
|
|
190
|
+
</Modal>
|
|
191
|
+
{:else}
|
|
192
|
+
<Drawer bind:open {title} position='right' {size} width={drawerWidth} onclose={handleClose}>
|
|
193
|
+
{#if item}
|
|
194
|
+
<div class={drawerBodyClass}>
|
|
195
|
+
{@render detailBody()}
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<!-- 底部操作栏 -->
|
|
199
|
+
<div class={drawerFooterClass}>
|
|
200
|
+
{@render footerActions()}
|
|
201
|
+
</div>
|
|
202
|
+
{/if}
|
|
203
|
+
</Drawer>
|
|
204
|
+
{/if}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { Size } from '../../../types.js';
|
|
3
|
+
import type { CrudDensity, CrudFormVariant } from './crud-types.js';
|
|
4
|
+
type FieldDef = {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string | (() => string);
|
|
7
|
+
type: string;
|
|
8
|
+
inDetail?: boolean;
|
|
9
|
+
options?: Array<{
|
|
10
|
+
label: string;
|
|
11
|
+
value: string | number | boolean;
|
|
12
|
+
}> | (() => Array<{
|
|
13
|
+
label: string;
|
|
14
|
+
value: string | number | boolean;
|
|
15
|
+
}>);
|
|
16
|
+
render?: (value: unknown, item: Record<string, unknown>) => string;
|
|
17
|
+
order?: number;
|
|
18
|
+
};
|
|
19
|
+
type $$ComponentProps = {
|
|
20
|
+
open?: boolean;
|
|
21
|
+
item?: Record<string, unknown> | null;
|
|
22
|
+
fields?: FieldDef[];
|
|
23
|
+
density?: CrudDensity;
|
|
24
|
+
title?: string;
|
|
25
|
+
variant?: CrudFormVariant;
|
|
26
|
+
size?: Size;
|
|
27
|
+
drawerWidth?: string;
|
|
28
|
+
modalSize?: Size | 'full';
|
|
29
|
+
modalWidth?: string;
|
|
30
|
+
modalHeight?: string;
|
|
31
|
+
canEdit?: boolean;
|
|
32
|
+
onedit?: () => void;
|
|
33
|
+
onclose?: () => void;
|
|
34
|
+
detailExtra?: Snippet<[Record<string, unknown>]>;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* CrudDetailPanel
|
|
38
|
+
* CRUD 详情组件(只读展示),支持抽屉(drawer)与弹窗(modal)两种展示形式
|
|
39
|
+
*
|
|
40
|
+
* 使用 Svelte 5 Runes ($props, $derived)
|
|
41
|
+
* 使用 compounds 组件:Drawer, Modal, Badge
|
|
42
|
+
*/
|
|
43
|
+
declare const CrudDetailPanel: import("svelte").Component<$$ComponentProps, {}, "open">;
|
|
44
|
+
type CrudDetailPanel = ReturnType<typeof CrudDetailPanel>;
|
|
45
|
+
export default CrudDetailPanel;
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component CrudEditPanel
|
|
3
|
+
CRUD 编辑/新建表单组件,支持抽屉(drawer)与弹窗(modal)两种展示形式
|
|
4
|
+
|
|
5
|
+
使用 Svelte 5 Runes ($props, $state, $derived)
|
|
6
|
+
使用 compounds/primitives 组件:Drawer, Modal, FormField, Input, Select, Textarea, Checkbox, Button
|
|
7
|
+
-->
|
|
8
|
+
<script lang='ts'>
|
|
9
|
+
import type { Snippet } from 'svelte'
|
|
10
|
+
import type { Size } from '../../../types.js'
|
|
11
|
+
import type { CrudDensity, CrudFormVariant } from './crud-types.js'
|
|
12
|
+
import { uiM } from '../../../messages.js'
|
|
13
|
+
import Drawer from '../../compounds/Drawer.svelte'
|
|
14
|
+
import FormField from '../../compounds/FormField.svelte'
|
|
15
|
+
import Modal from '../../compounds/Modal.svelte'
|
|
16
|
+
import Button from '../../primitives/Button.svelte'
|
|
17
|
+
import Checkbox from '../../primitives/Checkbox.svelte'
|
|
18
|
+
import Input from '../../primitives/Input.svelte'
|
|
19
|
+
import Select from '../../primitives/Select.svelte'
|
|
20
|
+
import Textarea from '../../primitives/Textarea.svelte'
|
|
21
|
+
|
|
22
|
+
type FieldDef = {
|
|
23
|
+
id: string
|
|
24
|
+
label: string | (() => string)
|
|
25
|
+
type: string
|
|
26
|
+
readonly?: boolean
|
|
27
|
+
options?: Array<{ label: string, value: string | number | boolean }> | (() => Array<{ label: string, value: string | number | boolean }>)
|
|
28
|
+
validation?: { required?: boolean, min?: number, max?: number, pattern?: string, message?: string }
|
|
29
|
+
placeholder?: string | (() => string)
|
|
30
|
+
order?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let {
|
|
34
|
+
open = $bindable(false),
|
|
35
|
+
mode = 'create' as 'create' | 'edit',
|
|
36
|
+
fields = [],
|
|
37
|
+
formData = $bindable<Record<string, unknown>>({}),
|
|
38
|
+
density = 'normal' as CrudDensity,
|
|
39
|
+
title = '',
|
|
40
|
+
variant = 'drawer' as CrudFormVariant,
|
|
41
|
+
size = '2xl' as Size,
|
|
42
|
+
drawerWidth,
|
|
43
|
+
modalSize = '2xl' as Size | 'full',
|
|
44
|
+
modalWidth,
|
|
45
|
+
modalHeight,
|
|
46
|
+
submitting = false,
|
|
47
|
+
error = '',
|
|
48
|
+
onsubmit,
|
|
49
|
+
onclose,
|
|
50
|
+
editFormExtra,
|
|
51
|
+
editingItem = null,
|
|
52
|
+
}: {
|
|
53
|
+
open?: boolean
|
|
54
|
+
mode?: 'create' | 'edit'
|
|
55
|
+
fields?: FieldDef[]
|
|
56
|
+
formData?: Record<string, unknown>
|
|
57
|
+
density?: CrudDensity
|
|
58
|
+
title?: string
|
|
59
|
+
variant?: CrudFormVariant
|
|
60
|
+
size?: Size
|
|
61
|
+
drawerWidth?: string
|
|
62
|
+
modalSize?: Size | 'full'
|
|
63
|
+
modalWidth?: string
|
|
64
|
+
modalHeight?: string
|
|
65
|
+
submitting?: boolean
|
|
66
|
+
error?: string
|
|
67
|
+
onsubmit?: (data: Record<string, unknown>) => Promise<void>
|
|
68
|
+
onclose?: () => void
|
|
69
|
+
editFormExtra?: Snippet<[Record<string, unknown> | null, 'create' | 'edit']>
|
|
70
|
+
editingItem?: Record<string, unknown> | null
|
|
71
|
+
} = $props()
|
|
72
|
+
|
|
73
|
+
function resolveText(text: string | (() => string)): string {
|
|
74
|
+
return typeof text === 'function' ? text() : text
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolveOptions(options?: FieldDef['options']): Array<{ label: string, value: string | number | boolean }> {
|
|
78
|
+
if (!options)
|
|
79
|
+
return []
|
|
80
|
+
return typeof options === 'function' ? options() : options
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const sortedFields = $derived(
|
|
84
|
+
[...fields].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const isCompact = $derived(density === 'compact')
|
|
88
|
+
const controlSize = $derived(isCompact ? 'xs' : 'sm')
|
|
89
|
+
const formClass = $derived(isCompact ? 'space-y-3' : 'space-y-4')
|
|
90
|
+
const extraClass = $derived(isCompact ? 'border-t border-base-content/5 pt-3' : 'border-t border-base-content/5 pt-4')
|
|
91
|
+
const multiSelectWrapClass = $derived(isCompact ? 'flex flex-wrap gap-2 p-2.5 bg-base-200 rounded-lg max-h-48 overflow-y-auto' : 'flex flex-wrap gap-3 p-3 bg-base-200 rounded-lg max-h-48 overflow-y-auto')
|
|
92
|
+
const multiSelectLabelClass = $derived(isCompact ? 'inline-flex items-center gap-1.5 cursor-pointer' : 'inline-flex items-center gap-2 cursor-pointer')
|
|
93
|
+
const multiSelectTextClass = $derived(isCompact ? 'text-xs text-base-content' : 'text-sm text-base-content')
|
|
94
|
+
const drawerFormClass = $derived(isCompact ? 'space-y-3 pb-16' : 'space-y-4 pb-20')
|
|
95
|
+
const drawerFooterClass = $derived(isCompact ? 'absolute bottom-0 left-0 right-0 p-3 bg-base-200 border-t border-base-content/10 flex justify-end gap-2' : 'absolute bottom-0 left-0 right-0 p-4 bg-base-200 border-t border-base-content/10 flex justify-end gap-2')
|
|
96
|
+
|
|
97
|
+
function handleClose() {
|
|
98
|
+
open = false
|
|
99
|
+
onclose?.()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function handleSubmit(e: Event) {
|
|
103
|
+
e.preventDefault()
|
|
104
|
+
await onsubmit?.(formData)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getInputType(fieldType: string): 'email' | 'url' | 'tel' | 'password' | 'number' | 'text' {
|
|
108
|
+
switch (fieldType) {
|
|
109
|
+
case 'email': return 'email'
|
|
110
|
+
case 'url': return 'url'
|
|
111
|
+
case 'tel': return 'tel'
|
|
112
|
+
case 'password': return 'password'
|
|
113
|
+
case 'number': return 'number'
|
|
114
|
+
case 'date': return 'text'
|
|
115
|
+
case 'datetime': return 'text'
|
|
116
|
+
default: return 'text'
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function updateField(fieldId: string, value: unknown) {
|
|
121
|
+
formData = { ...formData, [fieldId]: value }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function toggleMultiSelect(fieldId: string, optValue: string | number | boolean) {
|
|
125
|
+
const current = (formData[fieldId] as Array<string | number | boolean>) ?? []
|
|
126
|
+
const strVal = String(optValue)
|
|
127
|
+
if (current.map(String).includes(strVal)) {
|
|
128
|
+
formData = { ...formData, [fieldId]: current.filter(v => String(v) !== strVal) }
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
formData = { ...formData, [fieldId]: [...current, optValue] }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
</script>
|
|
135
|
+
|
|
136
|
+
{#snippet formFields()}
|
|
137
|
+
{#if error}
|
|
138
|
+
<div class='p-3 bg-error/10 border border-error/20 rounded-lg text-sm text-error flex items-center gap-2'>
|
|
139
|
+
<span class='icon-[tabler--alert-circle] size-4 shrink-0'></span>
|
|
140
|
+
<span>{error}</span>
|
|
141
|
+
</div>
|
|
142
|
+
{/if}
|
|
143
|
+
|
|
144
|
+
{#each sortedFields as field (field.id)}
|
|
145
|
+
{@const isReadonly = field.readonly && mode === 'edit'}
|
|
146
|
+
{@const isRequired = field.validation?.required}
|
|
147
|
+
{@const fieldValue = formData[field.id]}
|
|
148
|
+
{@const placeholder = field.placeholder ? resolveText(field.placeholder) : ''}
|
|
149
|
+
|
|
150
|
+
{#if field.type === 'textarea'}
|
|
151
|
+
<FormField label={resolveText(field.label)} required={isRequired}>
|
|
152
|
+
<Textarea
|
|
153
|
+
id={field.id}
|
|
154
|
+
size={controlSize}
|
|
155
|
+
value={String(fieldValue ?? '')}
|
|
156
|
+
{placeholder}
|
|
157
|
+
disabled={submitting || isReadonly}
|
|
158
|
+
required={isRequired}
|
|
159
|
+
oninput={e => updateField(field.id, (e.target as HTMLTextAreaElement).value)}
|
|
160
|
+
/>
|
|
161
|
+
</FormField>
|
|
162
|
+
|
|
163
|
+
{:else if field.type === 'select' || field.type === 'radio'}
|
|
164
|
+
{@const opts = resolveOptions(field.options)}
|
|
165
|
+
<FormField label={resolveText(field.label)} required={isRequired}>
|
|
166
|
+
<Select
|
|
167
|
+
id={field.id}
|
|
168
|
+
size={controlSize}
|
|
169
|
+
value={String(fieldValue ?? '')}
|
|
170
|
+
disabled={submitting || isReadonly}
|
|
171
|
+
onchange={value => updateField(field.id, value)}
|
|
172
|
+
>
|
|
173
|
+
<option value="">{placeholder || uiM('crud_filter_all')}</option>
|
|
174
|
+
{#each opts as opt (String(opt.value))}
|
|
175
|
+
<option value={String(opt.value)}>{opt.label}</option>
|
|
176
|
+
{/each}
|
|
177
|
+
</Select>
|
|
178
|
+
</FormField>
|
|
179
|
+
|
|
180
|
+
{:else if field.type === 'multi-select'}
|
|
181
|
+
{@const opts = resolveOptions(field.options)}
|
|
182
|
+
{@const selectedValues = ((fieldValue ?? []) as Array<string | number | boolean>).map(String)}
|
|
183
|
+
<FormField label={resolveText(field.label)} required={isRequired}>
|
|
184
|
+
<div class={multiSelectWrapClass}>
|
|
185
|
+
{#each opts as opt (String(opt.value))}
|
|
186
|
+
<label class={multiSelectLabelClass}>
|
|
187
|
+
<Checkbox
|
|
188
|
+
size={controlSize}
|
|
189
|
+
checked={selectedValues.includes(String(opt.value))}
|
|
190
|
+
onchange={() => toggleMultiSelect(field.id, opt.value)}
|
|
191
|
+
disabled={submitting || isReadonly}
|
|
192
|
+
/>
|
|
193
|
+
<span class={multiSelectTextClass}>{opt.label}</span>
|
|
194
|
+
</label>
|
|
195
|
+
{/each}
|
|
196
|
+
{#if opts.length === 0}
|
|
197
|
+
<span class={multiSelectTextClass}>{uiM('crud_no_data')}</span>
|
|
198
|
+
{/if}
|
|
199
|
+
</div>
|
|
200
|
+
</FormField>
|
|
201
|
+
|
|
202
|
+
{:else if field.type === 'boolean' || field.type === 'checkbox'}
|
|
203
|
+
<FormField label={resolveText(field.label)}>
|
|
204
|
+
<Checkbox
|
|
205
|
+
size={controlSize}
|
|
206
|
+
checked={Boolean(fieldValue)}
|
|
207
|
+
onchange={(checked: boolean) => updateField(field.id, checked)}
|
|
208
|
+
disabled={submitting || isReadonly}
|
|
209
|
+
/>
|
|
210
|
+
</FormField>
|
|
211
|
+
|
|
212
|
+
{:else}
|
|
213
|
+
<FormField label={resolveText(field.label)} required={isRequired}>
|
|
214
|
+
<Input
|
|
215
|
+
id={field.id}
|
|
216
|
+
type={getInputType(field.type)}
|
|
217
|
+
size={controlSize}
|
|
218
|
+
value={String(fieldValue ?? '')}
|
|
219
|
+
{placeholder}
|
|
220
|
+
disabled={submitting || isReadonly}
|
|
221
|
+
required={isRequired}
|
|
222
|
+
pattern={field.validation?.pattern}
|
|
223
|
+
minlength={field.type !== 'number' ? field.validation?.min : undefined}
|
|
224
|
+
maxlength={field.type !== 'number' ? field.validation?.max : undefined}
|
|
225
|
+
min={field.type === 'number' ? field.validation?.min : undefined}
|
|
226
|
+
max={field.type === 'number' ? field.validation?.max : undefined}
|
|
227
|
+
oninput={e => updateField(field.id, field.type === 'number' ? Number((e.currentTarget as HTMLInputElement).value) : (e.currentTarget as HTMLInputElement).value)}
|
|
228
|
+
/>
|
|
229
|
+
</FormField>
|
|
230
|
+
{/if}
|
|
231
|
+
{/each}
|
|
232
|
+
|
|
233
|
+
{#if editFormExtra}
|
|
234
|
+
<div class={extraClass}>
|
|
235
|
+
{@render editFormExtra(editingItem, mode)}
|
|
236
|
+
</div>
|
|
237
|
+
{/if}
|
|
238
|
+
{/snippet}
|
|
239
|
+
|
|
240
|
+
{#snippet footerActions()}
|
|
241
|
+
<Button variant='ghost' size='sm' onclick={handleClose} disabled={submitting}>
|
|
242
|
+
{uiM('crud_cancel')}
|
|
243
|
+
</Button>
|
|
244
|
+
<Button variant='primary' size='sm' onclick={handleSubmit} disabled={submitting}>
|
|
245
|
+
{#if submitting}
|
|
246
|
+
<span class='loading loading-spinner loading-xs mr-2'></span>
|
|
247
|
+
{/if}
|
|
248
|
+
{mode === 'create' ? uiM('crud_create') : uiM('crud_save')}
|
|
249
|
+
</Button>
|
|
250
|
+
{/snippet}
|
|
251
|
+
|
|
252
|
+
{#if variant === 'modal'}
|
|
253
|
+
<Modal
|
|
254
|
+
bind:open
|
|
255
|
+
{title}
|
|
256
|
+
size={modalSize}
|
|
257
|
+
width={modalWidth}
|
|
258
|
+
height={modalHeight}
|
|
259
|
+
closeOnBackdrop={false}
|
|
260
|
+
onclose={handleClose}
|
|
261
|
+
>
|
|
262
|
+
<form onsubmit={handleSubmit} class={formClass}>
|
|
263
|
+
{@render formFields()}
|
|
264
|
+
</form>
|
|
265
|
+
|
|
266
|
+
{#snippet footer()}
|
|
267
|
+
{@render footerActions()}
|
|
268
|
+
{/snippet}
|
|
269
|
+
</Modal>
|
|
270
|
+
{:else}
|
|
271
|
+
<Drawer bind:open {title} position='right' {size} width={drawerWidth} onclose={handleClose} closeOnBackdrop={false}>
|
|
272
|
+
<form onsubmit={handleSubmit} class={drawerFormClass}>
|
|
273
|
+
{@render formFields()}
|
|
274
|
+
</form>
|
|
275
|
+
|
|
276
|
+
<!-- 底部操作栏 -->
|
|
277
|
+
<div class={drawerFooterClass}>
|
|
278
|
+
{@render footerActions()}
|
|
279
|
+
</div>
|
|
280
|
+
</Drawer>
|
|
281
|
+
{/if}
|
package/dist/components/scenes/crud/{CrudEditDrawer.svelte.d.ts → CrudEditPanel.svelte.d.ts}
RENAMED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
2
|
import type { Size } from '../../../types.js';
|
|
3
|
+
import type { CrudDensity, CrudFormVariant } from './crud-types.js';
|
|
3
4
|
type FieldDef = {
|
|
4
5
|
id: string;
|
|
5
6
|
label: string | (() => string);
|
|
@@ -27,8 +28,14 @@ type $$ComponentProps = {
|
|
|
27
28
|
mode?: 'create' | 'edit';
|
|
28
29
|
fields?: FieldDef[];
|
|
29
30
|
formData?: Record<string, unknown>;
|
|
31
|
+
density?: CrudDensity;
|
|
30
32
|
title?: string;
|
|
33
|
+
variant?: CrudFormVariant;
|
|
31
34
|
size?: Size;
|
|
35
|
+
drawerWidth?: string;
|
|
36
|
+
modalSize?: Size | 'full';
|
|
37
|
+
modalWidth?: string;
|
|
38
|
+
modalHeight?: string;
|
|
32
39
|
submitting?: boolean;
|
|
33
40
|
error?: string;
|
|
34
41
|
onsubmit?: (data: Record<string, unknown>) => Promise<void>;
|
|
@@ -37,12 +44,12 @@ type $$ComponentProps = {
|
|
|
37
44
|
editingItem?: Record<string, unknown> | null;
|
|
38
45
|
};
|
|
39
46
|
/**
|
|
40
|
-
*
|
|
41
|
-
* CRUD
|
|
47
|
+
* CrudEditPanel
|
|
48
|
+
* CRUD 编辑/新建表单组件,支持抽屉(drawer)与弹窗(modal)两种展示形式
|
|
42
49
|
*
|
|
43
50
|
* 使用 Svelte 5 Runes ($props, $state, $derived)
|
|
44
|
-
* 使用 compounds/primitives 组件:Drawer, FormField, Input, Select, Textarea, Checkbox, Button
|
|
51
|
+
* 使用 compounds/primitives 组件:Drawer, Modal, FormField, Input, Select, Textarea, Checkbox, Button
|
|
45
52
|
*/
|
|
46
|
-
declare const
|
|
47
|
-
type
|
|
48
|
-
export default
|
|
53
|
+
declare const CrudEditPanel: import("svelte").Component<$$ComponentProps, {}, "open" | "formData">;
|
|
54
|
+
type CrudEditPanel = ReturnType<typeof CrudEditPanel>;
|
|
55
|
+
export default CrudEditPanel;
|
|
@@ -57,15 +57,16 @@
|
|
|
57
57
|
}
|
|
58
58
|
</script>
|
|
59
59
|
|
|
60
|
-
<div class='
|
|
61
|
-
<div class='flex
|
|
60
|
+
<div class='grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center'>
|
|
61
|
+
<div class='flex min-w-0 flex-wrap items-center gap-3 [&_.fieldset]:m-0 [&_.fieldset]:min-w-0'>
|
|
62
62
|
{#if searchable}
|
|
63
|
-
<div class='flex-
|
|
64
|
-
<span class='icon-[tabler--search]
|
|
63
|
+
<div class='relative min-w-[15rem] flex-[1_1_20rem]'>
|
|
64
|
+
<span class='icon-[tabler--search] pointer-events-none absolute left-3.5 top-1/2 z-10 size-4 -translate-y-1/2 text-base-content/35'></span>
|
|
65
65
|
<Input
|
|
66
66
|
type='text'
|
|
67
|
+
size='sm'
|
|
67
68
|
placeholder={searchPlaceholder || uiM('crud_search_placeholder')}
|
|
68
|
-
class='pl-10'
|
|
69
|
+
class='pl-10 shadow-none'
|
|
69
70
|
bind:value={searchValue}
|
|
70
71
|
oninput={handleSearchInput}
|
|
71
72
|
autocomplete='off'
|
|
@@ -76,21 +77,24 @@
|
|
|
76
77
|
{#each filterFields as field (field.id)}
|
|
77
78
|
{@const opts = resolveOptions(field.options)}
|
|
78
79
|
{#if opts.length > 0}
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
<option value={
|
|
87
|
-
|
|
88
|
-
|
|
80
|
+
<div class='w-full sm:w-[11rem] sm:flex-none'>
|
|
81
|
+
<Select
|
|
82
|
+
size='sm'
|
|
83
|
+
class='border-base-content/10 bg-base-100 shadow-none'
|
|
84
|
+
value={String(filterValues[field.id] ?? '')}
|
|
85
|
+
onchange={value => handleFilterChange(field.id, value)}
|
|
86
|
+
>
|
|
87
|
+
<option value="">{uiM('crud_filter_all')}</option>
|
|
88
|
+
{#each opts as opt (String(opt.value))}
|
|
89
|
+
<option value={String(opt.value)}>{opt.label}</option>
|
|
90
|
+
{/each}
|
|
91
|
+
</Select>
|
|
92
|
+
</div>
|
|
89
93
|
{/if}
|
|
90
94
|
{/each}
|
|
95
|
+
</div>
|
|
91
96
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
</div>
|
|
97
|
+
<div class='inline-flex items-center justify-self-start rounded-full border border-base-content/8 bg-base-200/60 px-3 py-1 text-xs font-medium text-base-content/60 md:justify-self-end'>
|
|
98
|
+
{uiM('crud_total', { count: total })}
|
|
95
99
|
</div>
|
|
96
100
|
</div>
|