@h-ai/ui 0.1.0-alpha.40 → 0.1.0-alpha.42
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 +8 -3
- package/auto-import.d.ts +1 -0
- package/auto-import.js +1 -0
- package/dist/components/compounds/DataTable.svelte +156 -20
- package/dist/components/compounds/DataTable.svelte.d.ts +6 -0
- package/dist/components/compounds/TableToolbar.svelte +691 -0
- package/dist/components/compounds/TableToolbar.svelte.d.ts +129 -0
- package/dist/components/compounds/index.d.ts +1 -0
- package/dist/components/compounds/index.js +2 -0
- package/dist/components/primitives/Select.svelte +28 -0
- package/dist/components/scenes/crud/CrudPage.svelte +354 -81
- package/dist/components/scenes/crud/CrudPage.svelte.d.ts +83 -1
- package/dist/components/scenes/crud/crud-types.d.ts +25 -0
- package/dist/messages/en-US.json +17 -0
- package/dist/messages/zh-CN.json +17 -0
- package/package.json +2 -2
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
<script lang='ts'>
|
|
14
14
|
import type { Snippet } from 'svelte'
|
|
15
15
|
import type { DataAttributes } from '../../../types.js'
|
|
16
|
-
import type { CrudDensity, CrudFormConfig, CrudPaginationConfig } from './crud-types.js'
|
|
16
|
+
import type { CrudDensity, CrudFormConfig, CrudPaginationConfig, CrudToolbarStyle } from './crud-types.js'
|
|
17
17
|
import type { NavAdapter } from './nav-adapter.js'
|
|
18
18
|
import { SvelteURLSearchParams } from 'svelte/reactivity'
|
|
19
19
|
import { uiM } from '../../../messages.js'
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import DataTable from '../../compounds/DataTable.svelte'
|
|
23
23
|
import PageHeader from '../../compounds/PageHeader.svelte'
|
|
24
24
|
import Pagination from '../../compounds/Pagination.svelte'
|
|
25
|
+
import TableToolbar from '../../compounds/TableToolbar.svelte'
|
|
25
26
|
import Button from '../../primitives/Button.svelte'
|
|
26
27
|
import IconButton from '../../primitives/IconButton.svelte'
|
|
27
28
|
import CrudDeleteConfirm from './CrudDeleteConfirm.svelte'
|
|
@@ -32,54 +33,100 @@
|
|
|
32
33
|
|
|
33
34
|
// ─── 类型定义 ───
|
|
34
35
|
|
|
36
|
+
/** CRUD 字段定义,决定列表、筛选器和表单的字段行为。 */
|
|
35
37
|
type FieldDef = {
|
|
38
|
+
/** 字段稳定标识,同时对应数据记录中的属性名。 */
|
|
36
39
|
id: string
|
|
40
|
+
/** 字段显示文案,允许通过函数延迟读取当前语言。 */
|
|
37
41
|
label: string | (() => string)
|
|
42
|
+
/** 字段控件类型,最终由表单或筛选器解释。 */
|
|
38
43
|
type: string
|
|
44
|
+
/** 是否出现在列表中;未设置时默认显示。 */
|
|
39
45
|
inList?: boolean
|
|
46
|
+
/** 是否出现在筛选器中;只有显式为 true 时显示。 */
|
|
40
47
|
filterable?: boolean
|
|
48
|
+
/** 编辑时是否禁止修改。 */
|
|
41
49
|
readonly?: boolean
|
|
50
|
+
/** 是否出现在新建表单中;未设置时默认显示。 */
|
|
42
51
|
inCreate?: boolean
|
|
52
|
+
/** 是否出现在编辑表单中;未设置时默认显示。 */
|
|
43
53
|
inEdit?: boolean
|
|
54
|
+
/** 是否出现在详情面板中;未设置时默认显示。 */
|
|
44
55
|
inDetail?: boolean
|
|
56
|
+
/** 选择类字段的静态选项或动态选项工厂。 */
|
|
45
57
|
options?: Array<{ label: string, value: string | number | boolean }> | (() => Array<{ label: string, value: string | number | boolean }>)
|
|
58
|
+
/** 表单校验规则。 */
|
|
46
59
|
validation?: { required?: boolean, min?: number, max?: number, pattern?: string, message?: string }
|
|
60
|
+
/** 列宽 CSS 值。 */
|
|
47
61
|
width?: string
|
|
62
|
+
/** 单元格对齐方式。 */
|
|
48
63
|
align?: 'left' | 'center' | 'right'
|
|
64
|
+
/** 列表单元格的自定义渲染函数。 */
|
|
49
65
|
render?: (value: unknown, item: Record<string, unknown>) => string
|
|
66
|
+
/** 输入控件占位文案。 */
|
|
50
67
|
placeholder?: string | (() => string)
|
|
68
|
+
/** 日期范围起始值 key。 */
|
|
69
|
+
startKey?: string
|
|
70
|
+
/** 日期范围结束值 key。 */
|
|
71
|
+
endKey?: string
|
|
72
|
+
/** 新建记录时使用的默认值。 */
|
|
51
73
|
defaultValue?: unknown
|
|
74
|
+
/** 字段排序权重,数值越小越靠前。 */
|
|
52
75
|
order?: number
|
|
53
76
|
}
|
|
54
77
|
|
|
55
78
|
// 使用 method shorthand 语法(双向协变)以兼容来自 @h-ai/kit 的 CrudOperations 类型
|
|
56
79
|
// 详见:https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-6.html#strict-function-types
|
|
80
|
+
/** CrudPage 使用的异步资源操作集合。 */
|
|
57
81
|
type CrudApi = {
|
|
82
|
+
/** 获取当前分页、筛选和排序结果。 */
|
|
58
83
|
list(params: Record<string, unknown>): Promise<{ items: Record<string, unknown>[], total: number, page: number, pageSize: number }>
|
|
84
|
+
/** 按主键获取一条记录。 */
|
|
59
85
|
get?(id: string): Promise<Record<string, unknown> | null>
|
|
86
|
+
/** 创建一条记录。 */
|
|
60
87
|
create?(data: Record<string, unknown>): Promise<Record<string, unknown>>
|
|
88
|
+
/** 更新一条记录。 */
|
|
61
89
|
update?(id: string, data: Record<string, unknown>): Promise<Record<string, unknown>>
|
|
90
|
+
/** 删除一条记录。 */
|
|
62
91
|
remove?(id: string): Promise<void>
|
|
63
92
|
}
|
|
64
93
|
|
|
94
|
+
/** 声明式 CRUD 资源及字段转换能力。 */
|
|
65
95
|
type CrudDef = {
|
|
96
|
+
/** 资源元信息与后端 API。 */
|
|
66
97
|
resource: {
|
|
98
|
+
/** 资源名称,用于识别当前业务实体。 */
|
|
67
99
|
name: string
|
|
100
|
+
/** 资源标题文案。 */
|
|
68
101
|
label: string | (() => string)
|
|
102
|
+
/** 记录主键字段,默认使用 id。 */
|
|
69
103
|
keyField?: string
|
|
104
|
+
/** 资源字段定义。 */
|
|
70
105
|
fields: FieldDef[]
|
|
106
|
+
/** 默认分页条数。 */
|
|
71
107
|
defaultPageSize?: number
|
|
108
|
+
/** 是否显示搜索框。 */
|
|
72
109
|
searchable?: boolean
|
|
110
|
+
/** 搜索框占位文案。 */
|
|
73
111
|
searchPlaceholder?: string | (() => string)
|
|
112
|
+
/** 资源 CRUD API。 */
|
|
74
113
|
api: CrudApi
|
|
75
114
|
}
|
|
115
|
+
/** 获取列表字段。 */
|
|
76
116
|
getListFields: () => FieldDef[]
|
|
117
|
+
/** 获取筛选字段。 */
|
|
77
118
|
getFilterFields: () => FieldDef[]
|
|
119
|
+
/** 获取新建字段。 */
|
|
78
120
|
getCreateFields: () => FieldDef[]
|
|
121
|
+
/** 获取编辑字段。 */
|
|
79
122
|
getEditFields: () => FieldDef[]
|
|
123
|
+
/** 获取详情字段。 */
|
|
80
124
|
getDetailFields: () => FieldDef[]
|
|
125
|
+
/** 转换为 DataTable 列定义。 */
|
|
81
126
|
toTableColumns: () => Array<{ key: string, label: string, width?: string, align?: string, render?: (item: Record<string, unknown>) => string }>
|
|
127
|
+
/** 生成新建表单默认值。 */
|
|
82
128
|
getDefaultValues: () => Record<string, unknown>
|
|
129
|
+
/** API 操作代理。 */
|
|
83
130
|
api: CrudApi
|
|
84
131
|
}
|
|
85
132
|
|
|
@@ -93,10 +140,18 @@
|
|
|
93
140
|
pagination = {},
|
|
94
141
|
density = 'normal',
|
|
95
142
|
rowClickDetail = true,
|
|
143
|
+
showHeader = true,
|
|
96
144
|
listItemActions,
|
|
145
|
+
tableCell,
|
|
146
|
+
onrowclick,
|
|
97
147
|
editFormExtra,
|
|
98
148
|
detailExtra,
|
|
99
149
|
headerActions,
|
|
150
|
+
toolbarStyle = 'toolbar',
|
|
151
|
+
toolbarLeading,
|
|
152
|
+
toolbarActions,
|
|
153
|
+
card,
|
|
154
|
+
sortableColumns = [],
|
|
100
155
|
onbeforedelete,
|
|
101
156
|
onaftersubmit,
|
|
102
157
|
onafterdelete,
|
|
@@ -106,21 +161,53 @@
|
|
|
106
161
|
class: className = '',
|
|
107
162
|
...restProps
|
|
108
163
|
}: {
|
|
164
|
+
/** CRUD 资源定义及其 API 操作。 */
|
|
109
165
|
crud: CrudDef
|
|
166
|
+
/** 当前页数据与服务端分页元数据。 */
|
|
110
167
|
data: { items: Record<string, unknown>[], total: number, page: number, pageSize: number, filters?: Record<string, unknown> }
|
|
168
|
+
/** 新建、编辑、删除权限;未设置的权限默认跟随 API 是否存在。 */
|
|
111
169
|
permissions?: { create?: boolean, update?: boolean, delete?: boolean }
|
|
170
|
+
/** 新建/编辑面板展示配置。 */
|
|
112
171
|
form?: CrudFormConfig
|
|
172
|
+
/** 分页栏展示配置。 */
|
|
113
173
|
pagination?: CrudPaginationConfig
|
|
174
|
+
/** 列表密度,影响行高、操作按钮和面板控件尺寸。 */
|
|
114
175
|
density?: CrudDensity
|
|
176
|
+
/** 点击详情图标是否打开详情面板。 */
|
|
115
177
|
rowClickDetail?: boolean
|
|
178
|
+
/** 是否显示默认页面标题栏;使用 toolbarLeading 承载标题时可设为 false。 */
|
|
179
|
+
showHeader?: boolean
|
|
180
|
+
/** 每行额外操作按钮插槽。 */
|
|
116
181
|
listItemActions?: Snippet<[Record<string, unknown>]>
|
|
182
|
+
/** 自定义表格单元格;参数依次为行记录和列 key。 */
|
|
183
|
+
tableCell?: Snippet<[Record<string, unknown>, string]>
|
|
184
|
+
/** 点击表格行时的回调。 */
|
|
185
|
+
onrowclick?: (item: Record<string, unknown>) => void
|
|
186
|
+
/** 编辑表单额外字段插槽。 */
|
|
117
187
|
editFormExtra?: Snippet<[Record<string, unknown> | null, 'create' | 'edit']>
|
|
188
|
+
/** 详情面板额外内容插槽。 */
|
|
118
189
|
detailExtra?: Snippet<[Record<string, unknown>]>
|
|
190
|
+
/** 页面标题右侧额外操作插槽。 */
|
|
119
191
|
headerActions?: Snippet
|
|
192
|
+
/** 工具栏风格:`toolbar` 使用图标弹层工具栏(默认),`filter-bar` 使用平铺筛选栏。 */
|
|
193
|
+
toolbarStyle?: CrudToolbarStyle
|
|
194
|
+
/** toolbar 风格下工具栏左侧内容插槽,可用于放置页面标题或说明。 */
|
|
195
|
+
toolbarLeading?: Snippet
|
|
196
|
+
/** TableToolbar 风格下工具栏右侧自定义操作插槽。 */
|
|
197
|
+
toolbarActions?: Snippet
|
|
198
|
+
/** 卡片列表项渲染插槽;存在时替代 DataTable,仍复用工具栏、分页和独立滚动容器。 */
|
|
199
|
+
card?: Snippet<[Record<string, unknown>]>
|
|
200
|
+
/** 可排序列配置;为空时默认使用全部列表列。 */
|
|
201
|
+
sortableColumns?: Array<{ key: string, label: string }>
|
|
202
|
+
/** 删除前钩子,返回 false 时取消删除。 */
|
|
120
203
|
onbeforedelete?: (item: Record<string, unknown>) => Promise<boolean> | boolean
|
|
204
|
+
/** 新建或编辑成功后的回调。 */
|
|
121
205
|
onaftersubmit?: (item: Record<string, unknown>, mode: 'create' | 'edit') => void
|
|
206
|
+
/** 删除成功后的回调。 */
|
|
122
207
|
onafterdelete?: (item: Record<string, unknown>) => void
|
|
208
|
+
/** 自定义错误回调。 */
|
|
123
209
|
onerror?: (error: string) => void
|
|
210
|
+
/** 列表路由基础路径。 */
|
|
124
211
|
basePath?: string
|
|
125
212
|
/**
|
|
126
213
|
* 路由适配器:用于读取当前 pathname、同步过滤参数到 URL、列表刷新。
|
|
@@ -129,91 +216,164 @@
|
|
|
129
216
|
* 以获得客户端跳转 + invalidateAll 体验。
|
|
130
217
|
*/
|
|
131
218
|
nav?: NavAdapter
|
|
219
|
+
/** 根节点自定义 class。 */
|
|
132
220
|
class?: string
|
|
133
221
|
} & DataAttributes = $props()
|
|
134
222
|
|
|
223
|
+
/** 透传到根节点的 data-* 属性。 */
|
|
135
224
|
const dataAttributes = $derived(getDataAttributes(restProps))
|
|
136
225
|
// ─── 状态 ───
|
|
137
226
|
|
|
227
|
+
/** 表格行的主键字段。 */
|
|
138
228
|
const keyField = $derived(crud.resource.keyField ?? 'id')
|
|
229
|
+
/** 当前资源的本地化标题。 */
|
|
139
230
|
const resourceLabel = $derived(resolveText(crud.resource.label))
|
|
231
|
+
/** 根据权限和 API 能力判断是否显示新建入口。 */
|
|
140
232
|
const canCreate = $derived(permissions.create !== false && Boolean(crud.api.create))
|
|
233
|
+
/** 根据权限和 API 能力判断是否显示编辑入口。 */
|
|
141
234
|
const canUpdate = $derived(permissions.update !== false && Boolean(crud.api.update))
|
|
235
|
+
/** 根据权限和 API 能力判断是否显示删除入口。 */
|
|
142
236
|
const canDelete = $derived(permissions.delete !== false && Boolean(crud.api.remove))
|
|
237
|
+
/** 当前资源是否支持关键字搜索。 */
|
|
143
238
|
const searchable = $derived(crud.resource.searchable !== false)
|
|
239
|
+
/** 是否使用紧凑列表密度。 */
|
|
144
240
|
const isCompact = $derived(density === 'compact')
|
|
145
|
-
|
|
241
|
+
/** 页面主体垂直间距;列表卡片会占满其余可用高度。 */
|
|
242
|
+
const pageGapClass = 'flex min-h-0 h-full flex-col gap-5'
|
|
243
|
+
/** 行内操作按钮尺寸。 */
|
|
146
244
|
const actionButtonSize = $derived(isCompact ? 'xs' : 'sm')
|
|
245
|
+
/** 行内操作图标尺寸。 */
|
|
147
246
|
const actionIconClass = $derived(isCompact ? 'size-3.5' : 'size-4')
|
|
247
|
+
/** 分页组件尺寸。 */
|
|
148
248
|
const paginationSize = $derived(isCompact ? 'xs' : 'sm')
|
|
149
|
-
|
|
150
|
-
|
|
249
|
+
/**
|
|
250
|
+
* 列表承载容器样式;表格模式使用有边框的 Card,卡片模式移除该外壳以保留业务卡片的原始视觉层级。
|
|
251
|
+
* 两种模式均保留 flex 约束,避免数据区把底部分页推出父容器。
|
|
252
|
+
*/
|
|
253
|
+
const listCardClass = $derived(
|
|
254
|
+
card
|
|
255
|
+
? 'flex min-h-0 flex-1 flex-col overflow-hidden !rounded-none !border-0 !bg-transparent !shadow-none [&>.card-body]:min-h-0 [&>.card-body]:flex-1'
|
|
256
|
+
: 'flex min-h-0 flex-1 flex-col overflow-hidden rounded-[1.25rem] border-base-content/7 shadow-sm [&>.card-body]:min-h-0 [&>.card-body]:flex-1',
|
|
257
|
+
)
|
|
258
|
+
/** 分页栏样式;shrink-0 保证它始终贴在卡片底部。 */
|
|
259
|
+
const paginationBarClass = 'shrink-0 border-t border-base-content/5 px-4 py-3'
|
|
151
260
|
|
|
152
261
|
// 表单展示配置(抽屉 / 弹窗)
|
|
262
|
+
/** 编辑/新建面板类型。 */
|
|
153
263
|
const formVariant = $derived(form.variant ?? 'drawer')
|
|
264
|
+
/** 抽屉尺寸预设。 */
|
|
154
265
|
const formDrawerSize = $derived(form.drawerSize ?? '2xl')
|
|
266
|
+
/** 抽屉自定义宽度。 */
|
|
155
267
|
const formDrawerWidth = $derived(form.drawerWidth)
|
|
268
|
+
/** 弹窗尺寸预设。 */
|
|
156
269
|
const formModalSize = $derived(form.modalSize ?? '2xl')
|
|
270
|
+
/** 弹窗自定义宽度。 */
|
|
157
271
|
const formModalWidth = $derived(form.modalWidth)
|
|
272
|
+
/** 弹窗自定义高度。 */
|
|
158
273
|
const formModalHeight = $derived(form.modalHeight)
|
|
159
274
|
|
|
160
275
|
// 分页栏配置
|
|
276
|
+
/** 是否显示每页条数选择器。 */
|
|
161
277
|
const paginationShowSizeChanger = $derived(pagination.showSizeChanger !== false)
|
|
278
|
+
/** 是否显示跳页输入框。 */
|
|
162
279
|
const paginationShowJumper = $derived(pagination.showJumper !== false)
|
|
280
|
+
/** 是否显示记录总数。 */
|
|
163
281
|
const paginationShowTotal = $derived(pagination.showTotal !== false)
|
|
282
|
+
/** 是否显示当前页 / 总页数文案。 */
|
|
164
283
|
const paginationShowPageInfo = $derived(pagination.showPageInfo !== false)
|
|
284
|
+
/** 是否显示首页和末页按钮。 */
|
|
165
285
|
const paginationShowFirstLast = $derived(pagination.showFirstLast !== false)
|
|
286
|
+
/** 每页条数候选项。 */
|
|
166
287
|
const paginationPageSizeOptions = $derived(pagination.pageSizeOptions ?? [10, 20, 50, 100])
|
|
167
288
|
|
|
168
289
|
// 视图状态
|
|
290
|
+
/** 当前打开的详情/编辑面板模式。 */
|
|
169
291
|
let panelMode = $state<'detail' | 'edit' | 'create' | null>(null)
|
|
292
|
+
/** 当前选中的列表记录。 */
|
|
170
293
|
let selectedItem = $state<Record<string, unknown> | null>(null)
|
|
294
|
+
/** 编辑面板的草稿数据。 */
|
|
171
295
|
let formData = $state<Record<string, unknown>>({})
|
|
296
|
+
/** 新建或编辑请求是否进行中。 */
|
|
172
297
|
let submitting = $state(false)
|
|
298
|
+
/** 当前表单错误文案。 */
|
|
173
299
|
let formError = $state('')
|
|
300
|
+
/** 删除确认框是否打开。 */
|
|
174
301
|
let deleteConfirmOpen = $state(false)
|
|
302
|
+
/** 等待确认删除的记录。 */
|
|
175
303
|
let deletingItem = $state<Record<string, unknown> | null>(null)
|
|
304
|
+
/** 删除请求是否进行中。 */
|
|
176
305
|
let deleting = $state(false)
|
|
177
306
|
|
|
178
307
|
// 过滤状态
|
|
308
|
+
/** 搜索框当前值;提交时才同步到路由。 */
|
|
179
309
|
let searchValue = $state('')
|
|
310
|
+
/** 当前已提交的筛选值。 */
|
|
180
311
|
let filterValues = $state<Record<string, unknown>>({})
|
|
181
312
|
|
|
313
|
+
// 排序状态(toolbarStyle='toolbar' 时使用)
|
|
314
|
+
/** 当前服务端排序字段。 */
|
|
315
|
+
let sortBy = $state('')
|
|
316
|
+
/** 当前服务端排序方向。 */
|
|
317
|
+
let sortDirection = $state<'asc' | 'desc'>('desc')
|
|
318
|
+
|
|
182
319
|
// 基础路径
|
|
320
|
+
/** 用于生成列表查询 URL 的基础路径。 */
|
|
183
321
|
const currentBasePath = $derived(basePath || nav.pathname)
|
|
184
322
|
|
|
185
323
|
// ─── 工具函数 ───
|
|
186
324
|
|
|
325
|
+
/** 解析静态或延迟读取的显示文案。 */
|
|
187
326
|
function resolveText(text: string | (() => string)): string {
|
|
188
327
|
return typeof text === 'function' ? text() : text
|
|
189
328
|
}
|
|
190
329
|
|
|
191
|
-
// 同步 data.filters →
|
|
330
|
+
// 同步 data.filters → 本地过滤/排序状态;没有 filters 时也要清空旧状态。
|
|
192
331
|
$effect(() => {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
332
|
+
const filters = data.filters ?? {}
|
|
333
|
+
searchValue = String(filters.search ?? '')
|
|
334
|
+
sortBy = String(filters.sortBy ?? '')
|
|
335
|
+
sortDirection = filters.sortDirection === 'asc' ? 'asc' : 'desc'
|
|
336
|
+
const fv: Record<string, unknown> = {}
|
|
337
|
+
for (const f of crud.getFilterFields()) {
|
|
338
|
+
if (filters[f.id] !== undefined) {
|
|
339
|
+
fv[f.id] = filters[f.id]
|
|
340
|
+
}
|
|
341
|
+
if (f.type.trim().toLowerCase() === 'date-range') {
|
|
342
|
+
const startKey = f.startKey ?? `${f.id}Start`
|
|
343
|
+
const endKey = f.endKey ?? `${f.id}End`
|
|
344
|
+
if (filters[startKey] !== undefined)
|
|
345
|
+
fv[startKey] = filters[startKey]
|
|
346
|
+
if (filters[endKey] !== undefined)
|
|
347
|
+
fv[endKey] = filters[endKey]
|
|
200
348
|
}
|
|
201
|
-
filterValues = fv
|
|
202
349
|
}
|
|
350
|
+
filterValues = fv
|
|
203
351
|
})
|
|
204
352
|
|
|
205
353
|
// ─── 表格列 ───
|
|
206
354
|
|
|
355
|
+
/** 资源定义生成的基础列表列。 */
|
|
207
356
|
const tableColumns = $derived(crud.toTableColumns())
|
|
357
|
+
/** 供排序弹层显示的列;未传时默认允许所有列表列排序。 */
|
|
358
|
+
const effectiveSortableColumns = $derived(
|
|
359
|
+
sortableColumns.length > 0
|
|
360
|
+
? sortableColumns
|
|
361
|
+
: tableColumns.map(column => ({ key: column.key, label: column.label })),
|
|
362
|
+
)
|
|
208
363
|
|
|
209
364
|
// ─── 导航 ───
|
|
210
365
|
|
|
366
|
+
/** 合并搜索、筛选、排序和分页状态,并交给宿主导航器刷新列表。 */
|
|
211
367
|
function navigateWithParams(overrides: Record<string, string | number>) {
|
|
368
|
+
/** 只包含有效值的查询参数容器。 */
|
|
212
369
|
const params = new SvelteURLSearchParams()
|
|
370
|
+
/** 当前状态与本次覆盖值合并后的完整查询对象。 */
|
|
213
371
|
const merged: Record<string, string | number> = {
|
|
214
372
|
search: searchValue,
|
|
215
373
|
page: data.page,
|
|
216
374
|
pageSize: data.pageSize,
|
|
375
|
+
sortBy,
|
|
376
|
+
sortDirection,
|
|
217
377
|
...Object.fromEntries(
|
|
218
378
|
Object.entries(filterValues)
|
|
219
379
|
.filter(([, v]) => v !== undefined && v !== '')
|
|
@@ -223,22 +383,28 @@
|
|
|
223
383
|
}
|
|
224
384
|
|
|
225
385
|
for (const [key, val] of Object.entries(merged)) {
|
|
386
|
+
/** 统一转换后的查询值。 */
|
|
226
387
|
const strVal = String(val)
|
|
227
388
|
if (strVal && strVal !== '' && !(key === 'page' && strVal === '1') && !(key === 'pageSize' && strVal === String(crud.resource.defaultPageSize ?? 20))) {
|
|
228
389
|
params.set(key, strVal)
|
|
229
390
|
}
|
|
230
391
|
}
|
|
231
392
|
|
|
393
|
+
/** 序列化后的查询字符串。 */
|
|
232
394
|
const qs = params.toString()
|
|
395
|
+
/** 交给宿主路由的目标 URL。 */
|
|
233
396
|
const url = `${currentBasePath}${qs ? `?${qs}` : ''}`
|
|
234
397
|
void Promise.resolve(nav.navigate(url)).then(() => nav.refresh?.())
|
|
235
398
|
}
|
|
236
399
|
|
|
400
|
+
/** 提交关键字搜索并回到第一页。 */
|
|
237
401
|
function handleSearch(search: string) {
|
|
238
402
|
navigateWithParams({ search, page: 1 })
|
|
239
403
|
}
|
|
240
404
|
|
|
405
|
+
/** 提交筛选条件并回到第一页。 */
|
|
241
406
|
function handleFilterChange(filters: Record<string, unknown>) {
|
|
407
|
+
/** 筛选变化时的 URL 覆盖项。 */
|
|
242
408
|
const overrides: Record<string, string | number> = { page: 1 }
|
|
243
409
|
for (const [k, v] of Object.entries(filters)) {
|
|
244
410
|
overrides[k] = String(v ?? '')
|
|
@@ -246,27 +412,48 @@
|
|
|
246
412
|
navigateWithParams(overrides)
|
|
247
413
|
}
|
|
248
414
|
|
|
415
|
+
/** 切换页码。 */
|
|
249
416
|
function handlePageChange(newPage: number) {
|
|
250
417
|
navigateWithParams({ page: newPage })
|
|
251
418
|
}
|
|
252
419
|
|
|
420
|
+
/** 修改每页条数并回到第一页。 */
|
|
253
421
|
function handlePageSizeChange(newPageSize: number) {
|
|
254
422
|
navigateWithParams({ pageSize: newPageSize, page: 1 })
|
|
255
423
|
}
|
|
256
424
|
|
|
425
|
+
/** 清空搜索、筛选、排序并恢复默认分页。 */
|
|
257
426
|
function handleResetFilters() {
|
|
258
427
|
searchValue = ''
|
|
259
428
|
filterValues = {}
|
|
429
|
+
sortBy = ''
|
|
430
|
+
sortDirection = 'desc'
|
|
260
431
|
void Promise.resolve(nav.navigate(currentBasePath)).then(() => nav.refresh?.())
|
|
261
432
|
}
|
|
262
433
|
|
|
434
|
+
/** 提交服务端排序条件。 */
|
|
435
|
+
function handleSort(key: string, direction: 'asc' | 'desc') {
|
|
436
|
+
sortBy = key
|
|
437
|
+
sortDirection = direction
|
|
438
|
+
navigateWithParams({ sortBy: key, sortDirection: direction, page: 1 })
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** 清空服务端排序条件。 */
|
|
442
|
+
function handleClearSort() {
|
|
443
|
+
sortBy = ''
|
|
444
|
+
sortDirection = 'desc'
|
|
445
|
+
navigateWithParams({ sortBy: '', sortDirection: '', page: 1 })
|
|
446
|
+
}
|
|
447
|
+
|
|
263
448
|
// ─── 打开/关闭抽屉 ───
|
|
264
449
|
|
|
450
|
+
/** 打开记录详情面板。 */
|
|
265
451
|
function openDetail(item: Record<string, unknown>) {
|
|
266
452
|
selectedItem = item
|
|
267
453
|
panelMode = 'detail'
|
|
268
454
|
}
|
|
269
455
|
|
|
456
|
+
/** 打开新建面板并填充默认值。 */
|
|
270
457
|
function openCreate() {
|
|
271
458
|
selectedItem = null
|
|
272
459
|
formData = { ...crud.getDefaultValues() }
|
|
@@ -274,6 +461,7 @@
|
|
|
274
461
|
panelMode = 'create'
|
|
275
462
|
}
|
|
276
463
|
|
|
464
|
+
/** 打开编辑面板并复制当前记录作为草稿。 */
|
|
277
465
|
function openEdit(item: Record<string, unknown>) {
|
|
278
466
|
selectedItem = item
|
|
279
467
|
formData = { ...item }
|
|
@@ -281,12 +469,14 @@
|
|
|
281
469
|
panelMode = 'edit'
|
|
282
470
|
}
|
|
283
471
|
|
|
472
|
+
/** 关闭详情或编辑面板并清理临时状态。 */
|
|
284
473
|
function closePanel() {
|
|
285
474
|
panelMode = null
|
|
286
475
|
selectedItem = null
|
|
287
476
|
formError = ''
|
|
288
477
|
}
|
|
289
478
|
|
|
479
|
+
/** 将详情面板切换为当前记录的编辑面板。 */
|
|
290
480
|
function switchToEdit() {
|
|
291
481
|
if (selectedItem) {
|
|
292
482
|
openEdit(selectedItem)
|
|
@@ -295,12 +485,14 @@
|
|
|
295
485
|
|
|
296
486
|
// ─── 提交 ───
|
|
297
487
|
|
|
488
|
+
/** 根据当前面板模式调用新建或更新 API。 */
|
|
298
489
|
async function handleSubmit(submitData: Record<string, unknown>) {
|
|
299
490
|
formError = ''
|
|
300
491
|
submitting = true
|
|
301
492
|
|
|
302
493
|
try {
|
|
303
494
|
if (panelMode === 'create' && crud.api.create) {
|
|
495
|
+
/** 创建 API 返回的记录;空返回时传递空对象以保持回调稳定。 */
|
|
304
496
|
const result = await crud.api.create(submitData)
|
|
305
497
|
const submitted = (result ?? {}) as Record<string, unknown>
|
|
306
498
|
closePanel()
|
|
@@ -308,7 +500,9 @@
|
|
|
308
500
|
await nav.refresh?.()
|
|
309
501
|
}
|
|
310
502
|
else if (panelMode === 'edit' && crud.api.update && selectedItem) {
|
|
503
|
+
/** 当前编辑记录的稳定主键。 */
|
|
311
504
|
const id = String(selectedItem[keyField])
|
|
505
|
+
/** 更新 API 返回的记录。 */
|
|
312
506
|
const result = await crud.api.update(id, submitData)
|
|
313
507
|
const submitted = (result ?? {}) as Record<string, unknown>
|
|
314
508
|
closePanel()
|
|
@@ -317,6 +511,7 @@
|
|
|
317
511
|
}
|
|
318
512
|
}
|
|
319
513
|
catch (e) {
|
|
514
|
+
/** 优先展示 API Error 文案,未知异常回退到内置 i18n。 */
|
|
320
515
|
const msg = e instanceof Error ? e.message : uiM('crud_submit_failed')
|
|
321
516
|
formError = msg
|
|
322
517
|
onerror?.(msg)
|
|
@@ -328,16 +523,19 @@
|
|
|
328
523
|
|
|
329
524
|
// ─── 删除 ───
|
|
330
525
|
|
|
526
|
+
/** 打开删除确认框并记录待删除项。 */
|
|
331
527
|
function requestDelete(item: Record<string, unknown>) {
|
|
332
528
|
deletingItem = item
|
|
333
529
|
deleteConfirmOpen = true
|
|
334
530
|
}
|
|
335
531
|
|
|
532
|
+
/** 执行删除前钩子、删除请求和刷新流程。 */
|
|
336
533
|
async function confirmDelete() {
|
|
337
534
|
if (!deletingItem || !crud.api.remove)
|
|
338
535
|
return
|
|
339
536
|
|
|
340
537
|
if (onbeforedelete) {
|
|
538
|
+
/** 业务删除前钩子的最终决策。 */
|
|
341
539
|
const proceed = await onbeforedelete(deletingItem)
|
|
342
540
|
if (!proceed) {
|
|
343
541
|
deleteConfirmOpen = false
|
|
@@ -348,8 +546,10 @@
|
|
|
348
546
|
|
|
349
547
|
deleting = true
|
|
350
548
|
try {
|
|
549
|
+
/** 待删除记录的稳定主键。 */
|
|
351
550
|
const id = String(deletingItem[keyField])
|
|
352
551
|
await crud.api.remove(id)
|
|
552
|
+
/** 保留删除成功的记录,供回调在状态清空后使用。 */
|
|
353
553
|
const deleted = deletingItem
|
|
354
554
|
deleteConfirmOpen = false
|
|
355
555
|
deletingItem = null
|
|
@@ -361,6 +561,7 @@
|
|
|
361
561
|
await nav.refresh?.()
|
|
362
562
|
}
|
|
363
563
|
catch (e) {
|
|
564
|
+
/** 删除失败提示。 */
|
|
364
565
|
const msg = e instanceof Error ? e.message : uiM('crud_delete_failed')
|
|
365
566
|
onerror?.(msg)
|
|
366
567
|
}
|
|
@@ -369,12 +570,14 @@
|
|
|
369
570
|
}
|
|
370
571
|
}
|
|
371
572
|
|
|
573
|
+
/** 取消删除并清理确认状态。 */
|
|
372
574
|
function cancelDelete() {
|
|
373
575
|
deleteConfirmOpen = false
|
|
374
576
|
deletingItem = null
|
|
375
577
|
}
|
|
376
578
|
|
|
377
579
|
// 搜索占位符
|
|
580
|
+
/** 搜索框最终显示的占位文案。 */
|
|
378
581
|
const searchPlaceholderText = $derived(
|
|
379
582
|
crud.resource.searchPlaceholder
|
|
380
583
|
? resolveText(crud.resource.searchPlaceholder)
|
|
@@ -382,12 +585,17 @@
|
|
|
382
585
|
)
|
|
383
586
|
|
|
384
587
|
// 构建编辑/新建字段
|
|
588
|
+
/** 新建面板字段。 */
|
|
385
589
|
const createFields = $derived(crud.getCreateFields() as FieldDef[])
|
|
590
|
+
/** 编辑面板字段。 */
|
|
386
591
|
const editFields = $derived(crud.getEditFields() as FieldDef[])
|
|
592
|
+
/** 详情面板字段。 */
|
|
387
593
|
const detailFields = $derived(crud.getDetailFields() as FieldDef[])
|
|
594
|
+
/** 工具栏筛选字段。 */
|
|
388
595
|
const filterFields = $derived(crud.getFilterFields() as FieldDef[])
|
|
389
596
|
|
|
390
597
|
// 抽屉标题
|
|
598
|
+
/** 当前详情/编辑面板标题。 */
|
|
391
599
|
const panelTitle = $derived(
|
|
392
600
|
panelMode === 'create'
|
|
393
601
|
? `${uiM('crud_create')}${resourceLabel}`
|
|
@@ -397,17 +605,20 @@
|
|
|
397
605
|
)
|
|
398
606
|
|
|
399
607
|
// 面板 open 状态
|
|
608
|
+
/** 详情面板开关。 */
|
|
400
609
|
const detailOpen = $derived(panelMode === 'detail')
|
|
610
|
+
/** 编辑/新建面板开关。 */
|
|
401
611
|
const editOpen = $derived(panelMode === 'create' || panelMode === 'edit')
|
|
402
612
|
|
|
403
613
|
// 用于 DataTable 的 columns(含自定义渲染)
|
|
614
|
+
/** DataTable 列定义,补充受控排序所需的列元数据。 */
|
|
404
615
|
const dtColumns = $derived(
|
|
405
616
|
tableColumns.map(col => ({
|
|
406
617
|
key: col.key,
|
|
407
618
|
label: col.label,
|
|
408
619
|
width: col.width,
|
|
409
620
|
align: col.align as 'left' | 'center' | 'right' | undefined,
|
|
410
|
-
sortable:
|
|
621
|
+
sortable: effectiveSortableColumns.some(column => column.key === col.key),
|
|
411
622
|
render: col.render
|
|
412
623
|
? (item: Record<string, unknown>) => col.render!(item)
|
|
413
624
|
: undefined,
|
|
@@ -416,81 +627,143 @@
|
|
|
416
627
|
</script>
|
|
417
628
|
|
|
418
629
|
<div {...dataAttributes} class='{pageGapClass} {className}'>
|
|
419
|
-
<!--
|
|
420
|
-
|
|
421
|
-
{
|
|
422
|
-
{#
|
|
423
|
-
{
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
<
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
630
|
+
<!-- 页面标题;toolbarLeading 承载标题时由调用方关闭,避免重复占用垂直空间。 -->
|
|
631
|
+
{#if showHeader}
|
|
632
|
+
<PageHeader title={resourceLabel} class='mb-0 shrink-0'>
|
|
633
|
+
{#snippet actions()}
|
|
634
|
+
{#if headerActions}
|
|
635
|
+
{@render headerActions()}
|
|
636
|
+
{/if}
|
|
637
|
+
{#if canCreate && toolbarStyle === 'filter-bar'}
|
|
638
|
+
<Button variant='primary' size='sm' onclick={openCreate} class='shadow-sm'>
|
|
639
|
+
<span class='icon-[tabler--plus] size-4.5 mr-1.5'></span>
|
|
640
|
+
{uiM('crud_create')}
|
|
641
|
+
</Button>
|
|
642
|
+
{/if}
|
|
643
|
+
{/snippet}
|
|
644
|
+
</PageHeader>
|
|
645
|
+
{/if}
|
|
433
646
|
|
|
434
647
|
<!-- 搜索 + 过滤栏 -->
|
|
435
648
|
{#if searchable || filterFields.length > 0}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
649
|
+
{#if toolbarStyle === 'toolbar'}
|
|
650
|
+
<TableToolbar
|
|
651
|
+
{searchable}
|
|
652
|
+
searchPlaceholder={searchPlaceholderText}
|
|
653
|
+
searchLabel={searchPlaceholderText}
|
|
654
|
+
bind:searchValue
|
|
655
|
+
{filterFields}
|
|
656
|
+
bind:filterValues
|
|
657
|
+
sortableColumns={effectiveSortableColumns}
|
|
658
|
+
bind:sortBy
|
|
659
|
+
bind:sortDirection
|
|
660
|
+
showViewSwitch={false}
|
|
661
|
+
leading={toolbarLeading}
|
|
662
|
+
primaryActionLabel={canCreate ? uiM('crud_create') : ''}
|
|
663
|
+
onPrimaryAction={canCreate ? openCreate : undefined}
|
|
664
|
+
onsearch={handleSearch}
|
|
665
|
+
onfilterchange={handleFilterChange}
|
|
666
|
+
onclearfilters={handleResetFilters}
|
|
667
|
+
onsort={handleSort}
|
|
668
|
+
onclearsort={handleClearSort}
|
|
669
|
+
>
|
|
670
|
+
{#if toolbarActions}
|
|
671
|
+
{@render toolbarActions()}
|
|
672
|
+
{/if}
|
|
673
|
+
</TableToolbar>
|
|
674
|
+
{:else}
|
|
675
|
+
<CrudFilterBar
|
|
676
|
+
{searchable}
|
|
677
|
+
searchPlaceholder={searchPlaceholderText}
|
|
678
|
+
bind:searchValue
|
|
679
|
+
{filterFields}
|
|
680
|
+
bind:filterValues
|
|
681
|
+
onsearch={handleSearch}
|
|
682
|
+
onfilterchange={handleFilterChange}
|
|
683
|
+
onreset={handleResetFilters}
|
|
684
|
+
/>
|
|
685
|
+
{/if}
|
|
446
686
|
{/if}
|
|
447
687
|
|
|
448
688
|
<!-- 数据列表 -->
|
|
449
689
|
<Card padding='none' class={listCardClass}>
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
onclick={() => openDetail(item)}
|
|
464
|
-
>
|
|
465
|
-
<span class='icon-[tabler--eye] {actionIconClass}'></span>
|
|
466
|
-
</IconButton>
|
|
690
|
+
{#if card}
|
|
691
|
+
<!-- 卡片视图使用独立滚动容器,分页栏始终保留在容器外。 -->
|
|
692
|
+
<div class='min-h-0 flex-1 overflow-auto'>
|
|
693
|
+
{#if data.items.length > 0}
|
|
694
|
+
<div class='grid min-h-full grid-cols-[repeat(auto-fill,minmax(min(100%,18rem),1fr))] content-start gap-3'>
|
|
695
|
+
{#each data.items as item (String(item[keyField]))}
|
|
696
|
+
{@render card(item)}
|
|
697
|
+
{/each}
|
|
698
|
+
</div>
|
|
699
|
+
{:else}
|
|
700
|
+
<div class='flex min-h-full items-center justify-center px-4 text-sm text-base-content/55'>
|
|
701
|
+
{uiM('data_table_empty')}
|
|
702
|
+
</div>
|
|
467
703
|
{/if}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
{
|
|
478
|
-
{
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
<
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
704
|
+
</div>
|
|
705
|
+
{:else}
|
|
706
|
+
<!-- DataTable 自身同时承接横向和纵向滚动,确保 sticky 表头与数据行共享滚动上下文。 -->
|
|
707
|
+
<DataTable
|
|
708
|
+
data={data.items}
|
|
709
|
+
columns={dtColumns}
|
|
710
|
+
keyField={keyField}
|
|
711
|
+
loading={false}
|
|
712
|
+
sortKey={sortBy || undefined}
|
|
713
|
+
sortDir={sortBy ? sortDirection : null}
|
|
714
|
+
onsort={handleSort}
|
|
715
|
+
class='min-h-0 min-w-0 flex-1 overflow-auto'
|
|
716
|
+
{density}
|
|
717
|
+
cell={tableCell}
|
|
718
|
+
{onrowclick}
|
|
719
|
+
>
|
|
720
|
+
{#snippet actions(item)}
|
|
721
|
+
{#if rowClickDetail}
|
|
722
|
+
<IconButton
|
|
723
|
+
variant='ghost'
|
|
724
|
+
size={actionButtonSize}
|
|
725
|
+
ariaLabel={uiM('crud_detail')}
|
|
726
|
+
onclick={(event) => {
|
|
727
|
+
event.stopPropagation()
|
|
728
|
+
openDetail(item)
|
|
729
|
+
}}
|
|
730
|
+
>
|
|
731
|
+
<span class='icon-[tabler--eye] {actionIconClass}'></span>
|
|
732
|
+
</IconButton>
|
|
733
|
+
{/if}
|
|
734
|
+
{#if canUpdate}
|
|
735
|
+
<IconButton
|
|
736
|
+
variant='ghost'
|
|
737
|
+
size={actionButtonSize}
|
|
738
|
+
ariaLabel={uiM('crud_edit')}
|
|
739
|
+
onclick={(event) => {
|
|
740
|
+
event.stopPropagation()
|
|
741
|
+
openEdit(item)
|
|
742
|
+
}}
|
|
743
|
+
>
|
|
744
|
+
<span class='icon-[tabler--edit] {actionIconClass}'></span>
|
|
745
|
+
</IconButton>
|
|
746
|
+
{/if}
|
|
747
|
+
{#if canDelete}
|
|
748
|
+
<IconButton
|
|
749
|
+
variant='ghost'
|
|
750
|
+
size={actionButtonSize}
|
|
751
|
+
ariaLabel={uiM('crud_delete')}
|
|
752
|
+
onclick={(event) => {
|
|
753
|
+
event.stopPropagation()
|
|
754
|
+
requestDelete(item)
|
|
755
|
+
}}
|
|
756
|
+
class='hover:text-error'
|
|
757
|
+
>
|
|
758
|
+
<span class='icon-[tabler--trash] {actionIconClass}'></span>
|
|
759
|
+
</IconButton>
|
|
760
|
+
{/if}
|
|
761
|
+
{#if listItemActions}
|
|
762
|
+
{@render listItemActions(item)}
|
|
763
|
+
{/if}
|
|
764
|
+
{/snippet}
|
|
765
|
+
</DataTable>
|
|
766
|
+
{/if}
|
|
494
767
|
|
|
495
768
|
<!-- 分页栏:始终显示,支持每页条数选择与跳页(shadcn table 风格) -->
|
|
496
769
|
<div class={paginationBarClass}>
|