@h-ai/ui 0.1.0-alpha.21 → 0.1.0-alpha.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/README.md +41 -5
  2. package/auto-import.d.ts +2 -2
  3. package/auto-import.js +2 -2
  4. package/dist/components/compounds/DataTable.svelte +4 -1
  5. package/dist/components/compounds/DataTable.svelte.d.ts +2 -0
  6. package/dist/components/compounds/Drawer.svelte +7 -2
  7. package/dist/components/compounds/Modal.svelte +2 -0
  8. package/dist/components/compounds/Pagination.svelte +35 -8
  9. package/dist/components/scenes/ai/AiDocumentEditor.svelte +102 -0
  10. package/dist/components/scenes/ai/code-preview.js +9 -0
  11. package/dist/components/scenes/ai/document-parse.js +11 -0
  12. package/dist/components/scenes/ai/document-types.d.ts +1 -1
  13. package/dist/components/scenes/ai/mermaid-render.d.ts +14 -0
  14. package/dist/components/scenes/ai/mermaid-render.js +42 -0
  15. package/dist/components/scenes/crud/{CrudDetailDrawer.svelte → CrudDetailPanel.svelte} +60 -23
  16. package/dist/components/scenes/crud/{CrudDetailDrawer.svelte.d.ts → CrudDetailPanel.svelte.d.ts} +12 -6
  17. package/dist/components/scenes/crud/CrudEditPanel.svelte +265 -0
  18. package/dist/components/scenes/crud/{CrudEditDrawer.svelte.d.ts → CrudEditPanel.svelte.d.ts} +12 -6
  19. package/dist/components/scenes/crud/CrudPage.svelte +84 -49
  20. package/dist/components/scenes/crud/CrudPage.svelte.d.ts +6 -4
  21. package/dist/components/scenes/crud/crud-types.d.ts +36 -2
  22. package/dist/components/scenes/crud/index.d.ts +3 -3
  23. package/dist/components/scenes/crud/index.js +3 -3
  24. package/dist/messages/en-US.json +4 -2
  25. package/dist/messages/zh-CN.json +4 -2
  26. package/dist/types.d.ts +10 -0
  27. package/package.json +4 -3
  28. package/dist/components/scenes/crud/CrudEditDrawer.svelte +0 -225
package/README.md CHANGED
@@ -267,16 +267,28 @@ components/
267
267
  | `AiTableEditor` | AI 表格编辑器 | `columns`, `rows`, `editable`, `ondownload` |
268
268
 
269
269
  > `AiDocumentEditor` 默认只允许 Markdown 内置预览。HTML / JS / CSS 等高风险预览需要显式设置 `allowUnsafeCodePreview`,或通过 `oncoderun` 返回受控的预览结果。
270
+ >
271
+ > Mermaid 图表开箱即用:文档(`sourceKind='document'`)里的 ```` ```mermaid ```` 代码块在阅读态自动渲染为图表;code 模式(`sourceKind='code'` + `showCodePreviewToggle`)下可在「代码 / 预览」间切换。Mermaid 以 `securityLevel: 'strict'` 渲染为消毒后的 SVG,无需开启 `allowUnsafeCodePreview`。
270
272
 
271
273
  #### CRUD 场景(5 个)
272
274
 
273
275
  | 组件 | 描述 | 主要属性 / 能力 |
274
276
  | -------------------- | ------------ | ---------------- |
275
- | `CrudPage` | CRUD 主页面 | `definition`, `navAdapter`, `loading` |
276
- | `CrudFilterBar` | 过滤工具栏 | `filters`, `query`, `onchange` |
277
- | `CrudDetailDrawer` | 详情抽屉 | `open`, `item`, `fields` |
278
- | `CrudEditDrawer` | 编辑抽屉 | `open`, `schema`, `onsubmit` |
279
- | `CrudDeleteConfirm` | 删除确认框 | `open`, `item`, `onconfirm` |
277
+ | `CrudPage` | CRUD 主页面 | `crud`, `data`, `permissions`, `form`, `pagination`, `density`, `nav` |
278
+ | `CrudFilterBar` | 过滤工具栏 | `filterFields`, `searchValue`, `onsearch`, `onfilterchange` |
279
+ | `CrudDetailPanel` | 详情面板(抽屉/弹窗) | `open`, `item`, `fields`, `variant`, `drawerWidth`, `modalSize` |
280
+ | `CrudEditPanel` | 编辑面板(抽屉/弹窗) | `open`, `fields`, `variant`, `drawerWidth`, `modalSize`, `onsubmit` |
281
+ | `CrudDeleteConfirm` | 删除确认框 | `open`, `loading`, `onconfirm` |
282
+
283
+ > `CrudPage` 通过 `form` 配置新建/编辑的展示形式:
284
+ >
285
+ > - `form.variant`:`'drawer'`(抽屉,默认)或 `'modal'`(弹出窗口)。
286
+ > - 抽屉:`form.drawerSize` 选预设尺寸,`form.drawerWidth` 传任意 CSS 宽度(优先级更高)。
287
+ > - 弹窗:`form.modalSize` 选预设尺寸,`form.modalWidth` / `form.modalHeight` 传任意 CSS 尺寸。
288
+ >
289
+ > `pagination` 配置分页栏(始终显示):`showSizeChanger`、`pageSizeOptions`、`showJumper`、`showTotal` 默认开启。
290
+ >
291
+ > `density` 配置列表密度:`'normal'`(默认)或 `'compact'`(紧凑)。
280
292
 
281
293
  ## 使用示例
282
294
 
@@ -366,6 +378,30 @@ components/
366
378
  <ToastContainer />
367
379
  ```
368
380
 
381
+ ### 声明式 CRUD 页面(CrudPage)
382
+
383
+ ```svelte
384
+ <script lang='ts'>
385
+ import { createSvelteKitNavAdapter } from '@h-ai/kit/client'
386
+ import { CrudPage } from '@h-ai/ui'
387
+
388
+ let { data } = $props()
389
+ const nav = createSvelteKitNavAdapter()
390
+ </script>
391
+
392
+ <CrudPage
393
+ crud={roleCrud}
394
+ {data}
395
+ permissions={{ create: true, update: true, delete: true }}
396
+ form={{ variant: 'modal', modalSize: 'lg' }}
397
+ pagination={{ showSizeChanger: true, showJumper: true, pageSizeOptions: [10, 20, 50] }}
398
+ density='compact'
399
+ {nav}
400
+ />
401
+ ```
402
+
403
+ > 抽屉形式可改用 `form={{ variant: 'drawer', drawerWidth: '40rem' }}`。
404
+
369
405
  ### 登录页面
370
406
 
371
407
  ```svelte
package/auto-import.d.ts CHANGED
@@ -110,8 +110,8 @@ declare global {
110
110
 
111
111
  // scenes - crud
112
112
  const CrudDeleteConfirm: typeof import('@h-ai/ui')['CrudDeleteConfirm']
113
- const CrudDetailDrawer: typeof import('@h-ai/ui')['CrudDetailDrawer']
114
- const CrudEditDrawer: typeof import('@h-ai/ui')['CrudEditDrawer']
113
+ const CrudDetailPanel: typeof import('@h-ai/ui')['CrudDetailPanel']
114
+ const CrudEditPanel: typeof import('@h-ai/ui')['CrudEditPanel']
115
115
  const CrudFilterBar: typeof import('@h-ai/ui')['CrudFilterBar']
116
116
  const CrudPage: typeof import('@h-ai/ui')['CrudPage']
117
117
  }
package/auto-import.js CHANGED
@@ -95,8 +95,8 @@ const UI_COMPONENTS = new Set([
95
95
  'MarkdownRenderer',
96
96
  // scenes - crud
97
97
  'CrudDeleteConfirm',
98
- 'CrudDetailDrawer',
99
- 'CrudEditDrawer',
98
+ 'CrudDetailPanel',
99
+ 'CrudEditPanel',
100
100
  'CrudFilterBar',
101
101
  'CrudPage',
102
102
  ])
@@ -10,6 +10,7 @@
10
10
  @prop {boolean} loading - 是否加载中
11
11
  @prop {boolean} striped - 是否斑马纹
12
12
  @prop {boolean} hoverable - 是否悬停高亮
13
+ @prop {'normal' | 'compact'} density - 显示密度,compact 为紧凑布局
13
14
 
14
15
  @example
15
16
  <DataTable
@@ -39,6 +40,7 @@
39
40
  loading = false,
40
41
  striped = true,
41
42
  hoverable = true,
43
+ density = 'normal',
42
44
  class: className = '',
43
45
  }: {
44
46
  data: T[]
@@ -49,6 +51,7 @@
49
51
  loading?: boolean
50
52
  striped?: boolean
51
53
  hoverable?: boolean
54
+ density?: 'normal' | 'compact'
52
55
  class?: string
53
56
  } = $props()
54
57
 
@@ -79,7 +82,7 @@
79
82
  </script>
80
83
 
81
84
  <div class='overflow-x-auto {className}'>
82
- <table class='table w-full text-[13px]' class:table-zebra={striped}>
85
+ <table class='table w-full {density === `compact` ? `table-sm text-xs` : `text-[13px]`}' class:table-zebra={striped}>
83
86
  <thead>
84
87
  <tr class='text-xs text-base-content/50 border-b border-base-content/6'>
85
88
  {#each columns as col (String(col.key))}
@@ -15,6 +15,7 @@ declare function $$render<T>(): {
15
15
  loading?: boolean;
16
16
  striped?: boolean;
17
17
  hoverable?: boolean;
18
+ density?: "normal" | "compact";
18
19
  class?: string;
19
20
  };
20
21
  exports: {};
@@ -48,6 +49,7 @@ interface $$IsomorphicComponent {
48
49
  * @prop {boolean} loading - 是否加载中
49
50
  * @prop {boolean} striped - 是否斑马纹
50
51
  * @prop {boolean} hoverable - 是否悬停高亮
52
+ * @prop {'normal' | 'compact'} density - 显示密度,compact 为紧凑布局
51
53
  *
52
54
  * @example
53
55
  * <DataTable
@@ -20,6 +20,7 @@
20
20
  title = '',
21
21
  position = 'right',
22
22
  size = 'md',
23
+ width,
23
24
  closeOnBackdrop = true,
24
25
  showClose = true,
25
26
  class: className = '',
@@ -40,6 +41,10 @@
40
41
  '4xl': 'w-[42rem]',
41
42
  }
42
43
 
44
+ // width 优先:传入自定义 CSS 宽度时通过 inline style 覆盖预设宽度类
45
+ const hasCustomWidth = $derived(typeof width === 'string' && width.trim().length > 0)
46
+ const widthStyle = $derived(hasCustomWidth ? `width: ${width!.trim()}; max-width: 100vw` : undefined)
47
+
43
48
  const drawerClass = $derived(
44
49
  cn(
45
50
  'drawer',
@@ -56,7 +61,7 @@
56
61
  const contentClass = $derived(
57
62
  cn(
58
63
  'menu bg-base-200 text-base-content min-h-full p-4',
59
- sizeMap[size],
64
+ !hasCustomWidth && sizeMap[size],
60
65
  className,
61
66
  ),
62
67
  )
@@ -86,7 +91,7 @@
86
91
  onkeydown={e => e.key === 'Enter' && handleBackdropClick()}
87
92
  ></div>
88
93
 
89
- <div class={contentClass}>
94
+ <div class={contentClass} style={widthStyle}>
90
95
  <div class='flex items-center justify-between mb-4'>
91
96
  {#if title}
92
97
  <h3 class='text-lg font-bold'>{title}</h3>
@@ -210,6 +210,7 @@
210
210
  max-height: calc(100vh - clamp(2rem, 5.2vw, 4rem));
211
211
  max-height: calc(100dvh - clamp(2rem, 5.2vw, 4rem));
212
212
  border-radius: 1.4rem;
213
+ overflow: hidden;
213
214
  color: var(--color-base-content);
214
215
  outline: none;
215
216
  box-shadow:
@@ -224,6 +225,7 @@
224
225
 
225
226
  .hai-modal__footer {
226
227
  box-shadow: inset 0 1px 0 color-mix(in srgb, var(--color-base-content) 6%, transparent);
228
+ border-radius: 0 0 1.4rem 1.4rem;
227
229
  }
228
230
 
229
231
  .hai-modal__close {
@@ -14,6 +14,7 @@
14
14
  import BareButton from '../primitives/BareButton.svelte'
15
15
 
16
16
  import Input from '../primitives/Input.svelte'
17
+ import Select from '../primitives/Select.svelte'
17
18
 
18
19
  let {
19
20
  page = $bindable(1),
@@ -22,15 +23,18 @@
22
23
  size = 'md',
23
24
  showTotal = true,
24
25
  showJumper = false,
26
+ showSizeChanger = false,
27
+ pageSizeOptions = [10, 20, 50, 100],
25
28
  labels = {},
26
29
  class: className = '',
27
30
  onchange,
31
+ onpagesizechange,
28
32
  }: PaginationProps = $props()
29
33
 
30
34
  // labels 优先,缺省回退到内置消息 uiM(...)
31
35
 
32
- // 计算总页数
33
- const totalPages = $derived(Math.ceil(total / pageSize))
36
+ // 计算总页数(至少 1 页,保证分页栏在空数据时仍可渲染)
37
+ const totalPages = $derived(Math.max(1, Math.ceil(total / pageSize)))
34
38
 
35
39
  // 生成页码列表
36
40
  const pages = $derived(() => {
@@ -99,15 +103,38 @@
99
103
  jumperValue = ''
100
104
  }
101
105
  }
106
+
107
+ function handlePageSizeChange(value: string) {
108
+ const next = Number.parseInt(value, 10)
109
+ if (!Number.isNaN(next) && next !== pageSize) {
110
+ onpagesizechange?.(next)
111
+ }
112
+ }
102
113
  </script>
103
114
 
104
- <div class='flex items-center gap-4'>
115
+ <div class='flex flex-wrap items-center gap-4'>
105
116
  {#if showTotal}
106
117
  <span class='text-sm text-base-content/70'>
107
118
  {(labels.total ?? uiM('pagination_total')).replace('{count}', String(total))}
108
119
  </span>
109
120
  {/if}
110
121
 
122
+ {#if showSizeChanger}
123
+ <div class='w-28 shrink-0'>
124
+ <Select
125
+ size='sm'
126
+ value={String(pageSize)}
127
+ onchange={handlePageSizeChange}
128
+ >
129
+ {#each pageSizeOptions as opt (opt)}
130
+ <option value={String(opt)}>
131
+ {(labels.pageSize ?? uiM('pagination_page_size')).replace('{size}', String(opt))}
132
+ </option>
133
+ {/each}
134
+ </Select>
135
+ </div>
136
+ {/if}
137
+
111
138
  <div class={joinClass}>
112
139
  <BareButton
113
140
  class={btnClass}
@@ -140,18 +167,18 @@
140
167
  </div>
141
168
 
142
169
  {#if showJumper}
143
- <div class='flex items-center gap-2'>
144
- <span class='text-sm'>{labels.jumpTo ?? uiM('pagination_jump_to')}</span>
170
+ <div class='flex min-w-fit items-center gap-2 whitespace-nowrap'>
171
+ <span class='shrink-0 text-sm'>{labels.jumpTo ?? uiM('pagination_jump_to')}</span>
145
172
  <Input
146
173
  type='number'
147
- size='sm'
148
- class='w-16'
174
+ size='xs'
175
+ class='w-14 shrink-0'
149
176
  min={1}
150
177
  max={totalPages}
151
178
  bind:value={jumperValue}
152
179
  onkeydown={(e: KeyboardEvent & { currentTarget: HTMLInputElement }) => e.key === 'Enter' && handleJump()}
153
180
  />
154
- <span class='text-sm'>{labels.page ?? uiM('pagination_page')}</span>
181
+ <span class='shrink-0 text-sm'>{labels.page ?? uiM('pagination_page')}</span>
155
182
  </div>
156
183
  {/if}
157
184
  </div>
@@ -36,6 +36,7 @@
36
36
  import { resolveDocumentMarkdownContent } from './document-download.js'
37
37
  import { renderMarkdownDocument } from './document-parse.js'
38
38
  import { parseMarkdown } from './markdown-parse.js'
39
+ import { renderMermaidDiagram } from './mermaid-render.js'
39
40
 
40
41
  interface SelectionToolbarPosition {
41
42
  /** 选区工具条相对滚动容器的 top 坐标。 */
@@ -471,6 +472,7 @@
471
472
 
472
473
  requestAnimationFrame(() => {
473
474
  syncCodePreviewHosts()
475
+ syncMermaidHosts()
474
476
  syncActiveHeadingFromScroll()
475
477
  syncPinnedTitleVisibility()
476
478
  })
@@ -909,6 +911,15 @@
909
911
  ? `<p class="hai-md-preview-desc">${escapePreviewText(result.description)}</p>`
910
912
  : ''
911
913
 
914
+ if (result.kind === 'mermaid') {
915
+ host.innerHTML = `<div class="hai-md-preview-card hai-md-preview-mermaid"><div class="hai-md-preview-head">${previewTitle}</div>${previewDesc}<div class="hai-md-mermaid hai-md-mermaid-preview"></div></div>`
916
+ const mermaidEl = host.querySelector<HTMLElement>('.hai-md-mermaid-preview')
917
+ if (mermaidEl) {
918
+ void renderMermaidHost(mermaidEl, result.content)
919
+ }
920
+ return
921
+ }
922
+
912
923
  if (result.kind === 'html') {
913
924
  const previewSandbox = escapePreviewAttribute(
914
925
  resolvePreviewSandbox(result.allowScripts),
@@ -931,6 +942,55 @@
931
942
  host.innerHTML = `<div class="hai-md-preview-card"><div class="hai-md-preview-head">${previewTitle}</div>${previewDesc}<pre>${escapePreviewText(result.content)}</pre></div>`
932
943
  }
933
944
 
945
+ /**
946
+ * mermaid 图表位于 `{@html}` 注入的 DOM 内部,渲染是异步的。
947
+ * 阅读态的图表块和 code 模式预览槽都复用这里把源码渲染为 SVG。
948
+ */
949
+ function syncMermaidHosts(): void {
950
+ if (!previewHost) {
951
+ return
952
+ }
953
+
954
+ const hosts = previewHost.querySelectorAll<HTMLElement>('[data-mermaid-host]')
955
+ for (const host of hosts) {
956
+ if (
957
+ host.dataset.mermaidStatus === 'ready'
958
+ || host.dataset.mermaidStatus === 'rendering'
959
+ ) {
960
+ continue
961
+ }
962
+
963
+ const blockId = host.dataset.mermaidHost
964
+ if (!blockId) {
965
+ continue
966
+ }
967
+
968
+ const block = lookupCodeBlock(blockId)
969
+ if (!block) {
970
+ continue
971
+ }
972
+
973
+ void renderMermaidHost(host, block.code)
974
+ }
975
+ }
976
+
977
+ /**
978
+ * 把单个 mermaid 源码渲染进目标节点;语法错误时展示本地化错误提示。
979
+ * `mermaidStatus` 既避免同一帧重复渲染,也兜住多次 effect 触发。
980
+ */
981
+ async function renderMermaidHost(host: HTMLElement, code: string): Promise<void> {
982
+ host.dataset.mermaidStatus = 'rendering'
983
+ try {
984
+ const svg = await renderMermaidDiagram(code)
985
+ host.innerHTML = svg
986
+ host.dataset.mermaidStatus = 'ready'
987
+ }
988
+ catch (error) {
989
+ host.dataset.mermaidStatus = 'error'
990
+ host.innerHTML = `<div class="hai-md-mermaid-error">${escapePreviewText(error instanceof Error ? error.message : uiM('markdown_mermaid_failed'))}</div>`
991
+ }
992
+ }
993
+
934
994
  function escapePreviewText(value: string): string {
935
995
  return value
936
996
  .replace(/&/g, '&amp;')
@@ -4418,6 +4478,48 @@
4418
4478
  color: oklch(var(--bc));
4419
4479
  }
4420
4480
 
4481
+ /* mermaid 图表:阅读态自动渲染,code 预览态复用同一容器。 */
4482
+ .hai-markdown :global(.hai-md-mermaid) {
4483
+ display: flex;
4484
+ align-items: center;
4485
+ justify-content: center;
4486
+ margin: 1.25em 0;
4487
+ padding: 1rem;
4488
+ border-radius: 0.95rem;
4489
+ border: 1px solid oklch(var(--bc) / 0.08);
4490
+ background: oklch(var(--b1));
4491
+ overflow-x: auto;
4492
+ }
4493
+
4494
+ .hai-markdown :global(.hai-md-mermaid svg) {
4495
+ max-width: 100%;
4496
+ height: auto;
4497
+ }
4498
+
4499
+ .hai-markdown :global(.hai-md-mermaid-error) {
4500
+ width: 100%;
4501
+ padding: 0.75rem 1rem;
4502
+ border-radius: 0.6rem;
4503
+ background: oklch(var(--er, 0.6 0.2 25) / 0.08);
4504
+ color: oklch(var(--er, 0.6 0.2 25));
4505
+ font-size: 0.85rem;
4506
+ line-height: 1.5;
4507
+ white-space: pre-wrap;
4508
+ text-align: left;
4509
+ }
4510
+
4511
+ :global(.hai-md-preview-mermaid) {
4512
+ display: flex;
4513
+ flex-direction: column;
4514
+ gap: 0.5rem;
4515
+ }
4516
+
4517
+ :global(.hai-md-mermaid-preview) {
4518
+ margin: 0;
4519
+ border: none;
4520
+ background: transparent;
4521
+ }
4522
+
4421
4523
  .hai-markdown-code-only :global(.hai-md-code-block[data-code-view='preview'] .hai-md-preview-card) {
4422
4524
  display: flex;
4423
4525
  flex-direction: column;
@@ -1,3 +1,4 @@
1
+ import { isMermaidLanguage } from './mermaid-render.js';
1
2
  const HTML_DOCUMENT_PATTERN = /<\s*(?:!doctype|html|body|head|div|section|article|main|span|style|script|svg|canvas)\b/i;
2
3
  const HTML_LANGUAGE_SET = new Set(['html', 'htm', 'xhtml', 'xml', 'svg']);
3
4
  const JAVASCRIPT_LANGUAGE_SET = new Set(['javascript', 'js', 'typescript', 'ts']);
@@ -12,6 +13,14 @@ export function resolvePreviewSandbox(allowScripts) {
12
13
  }
13
14
  export function createBuiltInCodePreview(request, options) {
14
15
  const language = (request.language ?? '').trim().toLowerCase();
16
+ // mermaid 由 securityLevel:'strict' 渲染为消毒后的 SVG,无需 opt-in 高风险开关。
17
+ if (isMermaidLanguage(language)) {
18
+ return {
19
+ kind: 'mermaid',
20
+ title: options.previewTitle,
21
+ content: request.code,
22
+ };
23
+ }
15
24
  if (language === 'markdown' || language === 'md') {
16
25
  return {
17
26
  kind: 'markdown',
@@ -6,6 +6,7 @@
6
6
  import { Marked } from 'marked';
7
7
  import { createEditorMarkdownExtensions, extractPlainTextFromTokens, } from './editor-markdown-extensions.js';
8
8
  import { highlightCode, isLanguageSupported } from './highlight.js';
9
+ import { isMermaidLanguage } from './mermaid-render.js';
9
10
  /** Default parsing options for document mode. */
10
11
  const DEFAULT_OPTIONS = {
11
12
  enableHighlight: true,
@@ -103,6 +104,16 @@ function createRendererObject(options, state) {
103
104
  code({ text, lang }) {
104
105
  // rawLanguage is the original info string from the fence.
105
106
  const rawLanguage = lang?.trim() || '';
107
+ // mermaid 块在阅读态自动渲染为图表;只有 code 模式的代码/预览切换才保留源码视图。
108
+ if (isMermaidLanguage(rawLanguage) && !options.showCodePreviewToggle) {
109
+ const mermaidBlockId = `hai-md-code-${state.codeBlocks.length + 1}`;
110
+ state.codeBlocks.push({
111
+ id: mermaidBlockId,
112
+ code: text,
113
+ language: rawLanguage,
114
+ });
115
+ return `<div class="hai-md-mermaid" data-mermaid-host="${escapeHtml(mermaidBlockId)}"></div>`;
116
+ }
106
117
  // highlightLanguage is validated against supported languages.
107
118
  const highlightLanguage = rawLanguage && isLanguageSupported(rawLanguage)
108
119
  ? rawLanguage
@@ -34,7 +34,7 @@ export interface MarkdownCodeRunRequest {
34
34
  }
35
35
  export interface MarkdownCodeRunResult {
36
36
  /** Output kind that drives preview rendering. */
37
- kind?: 'text' | 'markdown' | 'html';
37
+ kind?: 'text' | 'markdown' | 'html' | 'mermaid';
38
38
  /** Preview output content. */
39
39
  content: string;
40
40
  /** Preview title; default text is used when omitted. */
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @h-ai/ui — Mermaid 图表渲染助手
3
+ *
4
+ * 以懒加载方式引入 mermaid,把图表源码渲染为安全的 SVG。
5
+ * 使用 `securityLevel: 'strict'`,由 mermaid 自身完成消毒,避免 {@html} 注入风险。
6
+ */
7
+ /**
8
+ * 判断 fence 语言是否为 mermaid。
9
+ */
10
+ export declare function isMermaidLanguage(language: string | undefined | null): boolean;
11
+ /**
12
+ * 把 mermaid 源码渲染为 SVG 字符串;语法错误时抛出由调用方兜底展示。
13
+ */
14
+ export declare function renderMermaidDiagram(code: string): Promise<string>;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @h-ai/ui — Mermaid 图表渲染助手
3
+ *
4
+ * 以懒加载方式引入 mermaid,把图表源码渲染为安全的 SVG。
5
+ * 使用 `securityLevel: 'strict'`,由 mermaid 自身完成消毒,避免 {@html} 注入风险。
6
+ */
7
+ /** mermaid 模块只在首次渲染时动态加载,并缓存初始化后的实例。 */
8
+ let mermaidLoader = null;
9
+ /** render id 需要在页面内唯一,递增计数器即可保证不冲突。 */
10
+ let renderSeq = 0;
11
+ /**
12
+ * 判断 fence 语言是否为 mermaid。
13
+ */
14
+ export function isMermaidLanguage(language) {
15
+ return (language ?? '').trim().toLowerCase() === 'mermaid';
16
+ }
17
+ /**
18
+ * 懒加载并初始化 mermaid,多次调用复用同一实例。
19
+ */
20
+ async function loadMermaid() {
21
+ if (!mermaidLoader) {
22
+ mermaidLoader = import('mermaid').then((module) => {
23
+ const mermaid = module.default;
24
+ mermaid.initialize({
25
+ startOnLoad: false,
26
+ securityLevel: 'strict',
27
+ theme: 'default',
28
+ });
29
+ return mermaid;
30
+ });
31
+ }
32
+ return mermaidLoader;
33
+ }
34
+ /**
35
+ * 把 mermaid 源码渲染为 SVG 字符串;语法错误时抛出由调用方兜底展示。
36
+ */
37
+ export async function renderMermaidDiagram(code) {
38
+ const mermaid = await loadMermaid();
39
+ const renderId = `hai-mermaid-${Date.now()}-${renderSeq++}`;
40
+ const { svg } = await mermaid.render(renderId, code);
41
+ return svg;
42
+ }
@@ -1,15 +1,17 @@
1
1
  <!--
2
- @component CrudDetailDrawer
3
- CRUD 详情抽屉组件(只读展示)
2
+ @component CrudDetailPanel
3
+ CRUD 详情组件(只读展示),支持抽屉(drawer)与弹窗(modal)两种展示形式
4
4
 
5
5
  使用 Svelte 5 Runes ($props, $derived)
6
- 使用 compounds 组件:Drawer, Badge
6
+ 使用 compounds 组件:Drawer, Modal, Badge
7
7
  -->
8
8
  <script lang='ts'>
9
9
  import type { Snippet } from 'svelte'
10
10
  import type { Size } from '../../../types.js'
11
+ import type { CrudFormVariant } from './crud-types.js'
11
12
  import { uiM } from '../../../messages.js'
12
13
  import Drawer from '../../compounds/Drawer.svelte'
14
+ import Modal from '../../compounds/Modal.svelte'
13
15
  import Badge from '../../primitives/Badge.svelte'
14
16
  import Button from '../../primitives/Button.svelte'
15
17
 
@@ -28,7 +30,12 @@
28
30
  item = null,
29
31
  fields = [],
30
32
  title = '',
33
+ variant = 'drawer' as CrudFormVariant,
31
34
  size = '2xl' as Size,
35
+ drawerWidth,
36
+ modalSize = '2xl' as Size | 'full',
37
+ modalWidth,
38
+ modalHeight,
32
39
  canEdit = false,
33
40
  onedit,
34
41
  onclose,
@@ -38,7 +45,12 @@
38
45
  item?: Record<string, unknown> | null
39
46
  fields?: FieldDef[]
40
47
  title?: string
48
+ variant?: CrudFormVariant
41
49
  size?: Size
50
+ drawerWidth?: string
51
+ modalSize?: Size | 'full'
52
+ modalWidth?: string
53
+ modalHeight?: string
42
54
  canEdit?: boolean
43
55
  onedit?: () => void
44
56
  onclose?: () => void
@@ -63,14 +75,12 @@
63
75
  return field.render(value, item)
64
76
  }
65
77
 
66
- // 选项类型:将 value 映射为 label
67
78
  if (field.type === 'select' || field.type === 'radio') {
68
79
  const opts = resolveOptions(field.options)
69
80
  const found = opts.find(o => String(o.value) === String(value))
70
81
  return found?.label ?? String(value)
71
82
  }
72
83
 
73
- // 多选类型
74
84
  if (field.type === 'multi-select' && Array.isArray(value)) {
75
85
  const opts = resolveOptions(field.options)
76
86
  return value.map((v) => {
@@ -79,12 +89,10 @@
79
89
  }).join(', ')
80
90
  }
81
91
 
82
- // 布尔
83
92
  if (field.type === 'boolean' || field.type === 'checkbox') {
84
93
  return value ? '✓' : '✗'
85
94
  }
86
95
 
87
- // 日期/时间
88
96
  if (field.type === 'date' && (typeof value === 'string' || typeof value === 'number' || value instanceof Date)) {
89
97
  return new Date(value as string | number).toLocaleDateString()
90
98
  }
@@ -107,9 +115,9 @@
107
115
  }
108
116
  </script>
109
117
 
110
- <Drawer bind:open {title} position='right' {size} onclose={handleClose}>
118
+ {#snippet detailBody()}
111
119
  {#if item}
112
- <div class='space-y-4 pb-20'>
120
+ <div class='space-y-4'>
113
121
  {#each detailFields as field (field.id)}
114
122
  {@const value = item[field.id]}
115
123
  <div>
@@ -138,18 +146,47 @@
138
146
  </div>
139
147
  {/if}
140
148
  </div>
141
-
142
- <!-- 底部操作栏 -->
143
- <div class='absolute bottom-0 left-0 right-0 p-4 bg-base-200 border-t border-base-content/10 flex justify-end gap-2'>
144
- {#if canEdit}
145
- <Button variant='primary' onclick={onedit}>
146
- <span class='icon-[tabler--edit] size-4 mr-1'></span>
147
- {uiM('crud_edit')}
148
- </Button>
149
- {/if}
150
- <Button variant='ghost' onclick={handleClose}>
151
- {uiM('crud_close')}
152
- </Button>
153
- </div>
154
149
  {/if}
155
- </Drawer>
150
+ {/snippet}
151
+
152
+ {#snippet footerActions()}
153
+ {#if canEdit}
154
+ <Button variant='primary' onclick={onedit}>
155
+ <span class='icon-[tabler--edit] size-4 mr-1'></span>
156
+ {uiM('crud_edit')}
157
+ </Button>
158
+ {/if}
159
+ <Button variant='ghost' onclick={handleClose}>
160
+ {uiM('crud_close')}
161
+ </Button>
162
+ {/snippet}
163
+
164
+ {#if variant === 'modal'}
165
+ <Modal
166
+ bind:open
167
+ {title}
168
+ size={modalSize}
169
+ width={modalWidth}
170
+ height={modalHeight}
171
+ onclose={handleClose}
172
+ >
173
+ {@render detailBody()}
174
+
175
+ {#snippet footer()}
176
+ {@render footerActions()}
177
+ {/snippet}
178
+ </Modal>
179
+ {:else}
180
+ <Drawer bind:open {title} position='right' {size} width={drawerWidth} onclose={handleClose}>
181
+ {#if item}
182
+ <div class='pb-20'>
183
+ {@render detailBody()}
184
+ </div>
185
+
186
+ <!-- 底部操作栏 -->
187
+ <div class='absolute bottom-0 left-0 right-0 p-4 bg-base-200 border-t border-base-content/10 flex justify-end gap-2'>
188
+ {@render footerActions()}
189
+ </div>
190
+ {/if}
191
+ </Drawer>
192
+ {/if}