@antdv-next/docs-plugins 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,746 @@
1
+ <script setup lang="ts">
2
+ import type { CSSProperties } from 'vue'
3
+ import type { DemoExtraFile, DemoModule, DemoSourceData } from '../../demo/types'
4
+ import { CheckOutlined, CodeOutlined, CopyOutlined, EditOutlined, ThunderboltOutlined } from '@antdv-next/icons'
5
+ import { aquaBlue, atomDark } from '@codesandbox/sandpack-themes'
6
+ import { useClipboard, useDebounceFn } from '@vueuse/core'
7
+ import { Alert, Flex, Skeleton, Spin, Tabs, Tooltip } from 'antdv-next'
8
+ import { createStyles } from 'antdv-style'
9
+ import { SandpackProvider } from 'sandpack-vue3'
10
+ import { loadDemo } from 'virtual:demos'
11
+ import { computed, defineAsyncComponent, markRaw, nextTick, onBeforeUnmount, shallowRef, watch } from 'vue'
12
+ import { useRoute, useRouter } from 'vue-router'
13
+ import { getDemoId } from '../../demo/get-demo-id'
14
+ import CodeEditorBridge from './code-editor-bridge.vue'
15
+ import { compileSfcSource } from './compile-sfc'
16
+ import { resolveLabel, useDemoContext } from './context'
17
+ import ExpandIcon from './expand-icon.vue'
18
+ import ExternalLinkIcon from './external-link-icon.vue'
19
+
20
+ defineOptions({
21
+ name: 'DemoPanel',
22
+ })
23
+
24
+ const props = withDefaults(defineProps<{
25
+ src: string
26
+ /** 使用 iframe 模式渲染独立示例页(需要站点提供 iframeRenderer) */
27
+ iframe?: string
28
+ /** 紧凑模式:预览区无内边距 */
29
+ compact?: boolean
30
+ /** 'grey' 时预览区使用灰色背景 */
31
+ background?: string
32
+ simplify?: boolean
33
+ /** Debug demos are shown in development only and hidden in the production docs build. */
34
+ debug?: boolean
35
+ }>(), {
36
+ iframe: undefined,
37
+ compact: false,
38
+ background: '',
39
+ simplify: false,
40
+ debug: false,
41
+ })
42
+
43
+ const TabPane = Tabs.TabPane
44
+
45
+ const context = useDemoContext()
46
+
47
+ const useStyles = createStyles(({ token }) => ({
48
+ root: {
49
+ 'breakInside': 'avoid',
50
+ 'display': 'flow-root',
51
+ 'overflow': 'hidden',
52
+ 'position': 'relative',
53
+ 'boxSizing': 'border-box',
54
+ 'border': `1px solid ${token.colorSplit}`,
55
+ 'borderRadius': token.borderRadiusLG,
56
+ 'background': token.colorBgContainer,
57
+ 'transition': 'border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease',
58
+ 'margin': '16px 0',
59
+ '&.border-primary': {
60
+ borderColor: token.colorPrimary,
61
+ boxShadow: `0 0 0 3px color-mix(in srgb, ${token.colorPrimary} 12%, transparent)`,
62
+ },
63
+ '&.ant-doc-demo-box-debug': {
64
+ borderColor: '#d3adf7',
65
+ },
66
+ '& .ant-doc-demo-box-demo': {
67
+ padding: '24px',
68
+ borderBottom: `1px solid ${token.colorSplit}`,
69
+ borderRadius: '8px 8px 0 0',
70
+ background: token.colorBgContainer,
71
+ },
72
+ '& .ant-doc-demo-box-skeleton': {
73
+ minHeight: 160,
74
+ },
75
+ '&.ant-doc-demo-box-simplify': {
76
+ borderRadius: 0,
77
+ background: 'transparent',
78
+ },
79
+ '&.ant-doc-demo-box-simplify .ant-doc-demo-box-demo': {
80
+ padding: 0,
81
+ borderBottom: 0,
82
+ background: 'transparent',
83
+ },
84
+ '& .ant-doc-demo-box-meta.markdown': {
85
+ position: 'relative',
86
+ width: '100%',
87
+ fontSize: 14,
88
+ borderRadius: '0 0 6px 6px',
89
+ transition: 'background-color 0.4s',
90
+ },
91
+ '& .ant-doc-demo-box-meta-description': {
92
+ padding: '18px 12px 24px',
93
+ },
94
+ '& .ant-doc-demo-box-meta-description p': {
95
+ margin: 0,
96
+ },
97
+ '& .ant-doc-demo-box-title': {
98
+ position: 'absolute',
99
+ top: -16,
100
+ marginLeft: 16,
101
+ padding: '1px 8px',
102
+ borderRadius: '6px 6px 0 0',
103
+ backgroundColor: token.colorBgContainer,
104
+ transition: 'background-color 0.4s',
105
+ },
106
+ '& .ant-doc-demo-box-title a': {
107
+ color: token.colorText,
108
+ textDecoration: 'none',
109
+ fontSize: 16,
110
+ fontWeight: 500,
111
+ },
112
+ '& .ant-doc-demo-box-actions': {
113
+ display: 'flex',
114
+ justifyContent: 'center',
115
+ padding: '12px 0',
116
+ borderTop: `1px dashed ${token.colorSplit}`,
117
+ opacity: 0.7,
118
+ transition: 'opacity 0.3s',
119
+ },
120
+ '&:hover .ant-doc-demo-box-actions': {
121
+ opacity: 1,
122
+ },
123
+ '& .ant-doc-demo-box-code-action': {
124
+ display: 'inline-flex',
125
+ alignItems: 'center',
126
+ justifyContent: 'center',
127
+ width: 16,
128
+ height: 16,
129
+ border: 0,
130
+ padding: 0,
131
+ background: 'transparent',
132
+ color: token.colorTextSecondary,
133
+ cursor: 'pointer',
134
+ transition: 'color 0.24s ease',
135
+ },
136
+ '& .ant-doc-demo-box-code-action:hover': {
137
+ color: token.colorPrimary,
138
+ },
139
+ '& .ant-doc-demo-box-edit-icon': {
140
+ marginInlineStart: 4,
141
+ color: token.colorTextTertiary,
142
+ },
143
+ '& .ant-doc-demo-box-code': {
144
+ 'position': 'relative',
145
+ 'lineHeight': 2,
146
+ 'padding': `${token.paddingSM}px ${token.padding}px`,
147
+ // 与 antdv-next 原 code-demo 一致:去除 sandpack 默认表面样式并隐藏 Run 按钮
148
+ '& .sp-wrapper': {
149
+ background: 'transparent !important',
150
+ },
151
+ '& .sp-layout': {
152
+ background: 'transparent !important',
153
+ border: 'none !important',
154
+ },
155
+ '& .cm-editor': {
156
+ 'background': 'transparent',
157
+ 'fontSize': 14,
158
+ '& .cm-content': {
159
+ lineHeight: 2,
160
+ },
161
+ // CodeMirror 挂载/聚焦时首行会带激活行样式与聚焦描边,与 antdv-next 原
162
+ // code-demo 一致地隐藏,避免展开/收起代码时首行出现样式变化
163
+ // (选择器特异性足够,无需 !important)
164
+ '& .cm-activeLine, & .cm-activeLineGutter': {
165
+ background: 'transparent',
166
+ },
167
+ '&.cm-focused': {
168
+ outline: 'none',
169
+ },
170
+ '&.cm-focused .cm-activeLine, &.cm-focused .cm-activeLineGutter': {
171
+ background: 'transparent',
172
+ },
173
+ },
174
+ '& .cm-gutters': {
175
+ background: 'transparent',
176
+ border: 'none',
177
+ },
178
+ '& .sp-stack': {
179
+ height: 'auto !important',
180
+ background: 'transparent',
181
+ },
182
+ '& [class*="sp-code-editor"]': {
183
+ background: 'transparent !important',
184
+ },
185
+ // 隐藏 sandpack 内置 Run 按钮与只读徽标
186
+ '& .sp-button, & .sp-read-only': {
187
+ display: 'none',
188
+ },
189
+ },
190
+ '& .ant-doc-demo-box-code-loading': {
191
+ display: 'flex',
192
+ justifyContent: 'center',
193
+ paddingBlock: token.paddingLG,
194
+ },
195
+ '& .ant-doc-demo-box-compile-error': {
196
+ margin: 0,
197
+ padding: '8px 16px',
198
+ background: token.colorErrorBg,
199
+ color: token.colorError,
200
+ fontSize: 12,
201
+ lineHeight: 1.6,
202
+ whiteSpace: 'pre-wrap',
203
+ },
204
+ '& .ant-doc-demo-box-code-tabs': {
205
+ borderTop: `1px dashed ${token.colorSplit}`,
206
+ },
207
+ '& .ant-doc-demo-box-code-tabs .ant-tabs-nav': {
208
+ marginBottom: 0,
209
+ },
210
+ '& .ant-doc-demo-box-code-tabs .ant-tabs-tab': {
211
+ fontSize: 12,
212
+ },
213
+ '& .ant-doc-demo-box-code-copy': {
214
+ position: 'absolute',
215
+ top: 10,
216
+ right: 10,
217
+ zIndex: 1,
218
+ display: 'inline-flex',
219
+ alignItems: 'center',
220
+ justifyContent: 'center',
221
+ width: 24,
222
+ height: 24,
223
+ border: 0,
224
+ padding: 0,
225
+ background: 'transparent',
226
+ color: token.colorIcon,
227
+ cursor: 'pointer',
228
+ },
229
+ '& .ant-doc-demo-box-code-copied': {
230
+ color: token.colorSuccess,
231
+ },
232
+ },
233
+ }))
234
+
235
+ function t(key: Parameters<typeof resolveLabel>[1]) {
236
+ return resolveLabel(context, key)
237
+ }
238
+
239
+ const { styles } = useStyles()
240
+
241
+ // Debug demos are visible while developing but stripped from the production docs.
242
+ const hidden = computed(() => Boolean(props.debug) && import.meta.env.PROD)
243
+
244
+ // demo 模块经 virtual:demos 注册表懒加载(代码分割,按需拉取)
245
+ const demo = shallowRef<DemoModule | undefined>(undefined)
246
+ let demoLoadToken = 0
247
+
248
+ watch(() => props.src, (newSrc) => {
249
+ const token = ++demoLoadToken
250
+ demo.value = undefined
251
+ if (!newSrc)
252
+ return
253
+ loadDemo(newSrc).then((mod) => {
254
+ if (token === demoLoadToken && mod)
255
+ demo.value = mod
256
+ }).catch(() => {})
257
+ }, { immediate: true })
258
+
259
+ // 按需加载的源码数据
260
+ const sourceData = shallowRef<DemoSourceData | null>(null)
261
+ const sourceLoading = shallowRef(false)
262
+ const sourceLoadError = shallowRef<Error | null>(null)
263
+ let sourceLoadPromise: Promise<void> | null = null
264
+ let sourceAbortController: AbortController | null = null
265
+ // HMR 发生在面板收起期间时标记过期,下次展开时重新加载
266
+ let sourceStale = false
267
+
268
+ function releaseSource() {
269
+ sourceAbortController?.abort()
270
+ sourceAbortController = null
271
+ sourceData.value = null
272
+ sourceLoadError.value = null
273
+ sourceLoadPromise = null
274
+ sourceLoading.value = false
275
+ sourceStale = false
276
+ }
277
+
278
+ async function ensureSourceLoaded() {
279
+ // 如果源码已过期(HMR 发生在收起期间),先释放旧数据
280
+ if (sourceStale)
281
+ releaseSource()
282
+ if (sourceData.value || !demo.value)
283
+ return
284
+ if (sourceLoadPromise)
285
+ return sourceLoadPromise
286
+
287
+ const currentDemo = demo.value
288
+ const abortController = new AbortController()
289
+ sourceAbortController = abortController
290
+ sourceLoading.value = true
291
+ sourceLoadError.value = null
292
+
293
+ const request = currentDemo
294
+ .loadSource(abortController.signal)
295
+ .then((data) => {
296
+ if (demo.value === currentDemo && !abortController.signal.aborted)
297
+ sourceData.value = data
298
+ })
299
+ .catch((error) => {
300
+ if (abortController.signal.aborted)
301
+ return
302
+ const loadError
303
+ = error instanceof Error ? error : new Error(String(error))
304
+ if (demo.value === currentDemo)
305
+ sourceLoadError.value = loadError
306
+ throw loadError
307
+ })
308
+ .finally(() => {
309
+ if (sourceLoadPromise === request) {
310
+ sourceAbortController = null
311
+ sourceLoadPromise = null
312
+ sourceLoading.value = false
313
+ }
314
+ })
315
+
316
+ sourceLoadPromise = request
317
+ return request
318
+ }
319
+
320
+ const route = useRoute()
321
+ const router = useRouter()
322
+
323
+ const hasJsSource = computed(() => {
324
+ const jsSource = sourceData.value?.jsSource?.trim()
325
+ return Boolean(jsSource)
326
+ })
327
+
328
+ const extraFiles = computed<DemoExtraFile[]>(() => sourceData.value?.extraFiles ?? [])
329
+
330
+ // 与线上一致:仅当存在 JS 版本或伴生文件时才显示代码页签栏
331
+ const hasCodeTabs = computed(() => hasJsSource.value || extraFiles.value.length > 0)
332
+
333
+ const codeTabKeys = computed(() => {
334
+ const keys: string[] = ['ts']
335
+ if (hasJsSource.value)
336
+ keys.push('js')
337
+ for (const file of extraFiles.value)
338
+ keys.push(file.name)
339
+ return keys
340
+ })
341
+
342
+ // ts/js 切换:每个 Demo 实例维护独立的偏好,初始值取自站点提供的持久化
343
+ // (不通过 computed 依赖全局状态,避免一个 demo 切换导致所有 demo 同步切换)
344
+ const localTsJs = shallowRef<'ts' | 'js'>(context.preferredCodeType?.get() ?? 'ts')
345
+ const localCodeKey = shallowRef<string | null>(null)
346
+
347
+ const activeCodeType = computed<string>({
348
+ get() {
349
+ if (localCodeKey.value && codeTabKeys.value.includes(localCodeKey.value))
350
+ return localCodeKey.value
351
+ const preferred = localTsJs.value
352
+ if (codeTabKeys.value.includes(preferred))
353
+ return preferred
354
+ return 'ts'
355
+ },
356
+ set(value) {
357
+ if (value === 'ts' || value === 'js') {
358
+ localTsJs.value = value
359
+ context.preferredCodeType?.set(value)
360
+ localCodeKey.value = null
361
+ }
362
+ else {
363
+ localCodeKey.value = value
364
+ }
365
+ },
366
+ })
367
+
368
+ const activeExtraFile = computed(() =>
369
+ extraFiles.value.find(file => file.name === activeCodeType.value),
370
+ )
371
+
372
+ /** 将 demo 相对导入路径映射为 sandpack 虚拟文件路径 */
373
+ function extraFileToSandpackPath(name: string) {
374
+ return `/src/${name.replace(/^(\.\.?\/)+/, '')}`
375
+ }
376
+
377
+ /** 代码 tab 显示名(去掉 ./ 前缀) */
378
+ function displayFileName(name: string) {
379
+ return name.replace(/^(\.\.?\/)+/, '')
380
+ }
381
+
382
+ const mainSourceCode = computed(() => {
383
+ if (activeCodeType.value === 'js')
384
+ return sourceData.value?.jsSource ?? sourceData.value?.source ?? ''
385
+ return sourceData.value?.source ?? ''
386
+ })
387
+
388
+ // 编辑器当前展示的源码(主 demo 或伴生文件)
389
+ const activeSourceCode = computed(() => {
390
+ if (activeExtraFile.value)
391
+ return activeExtraFile.value.code
392
+ return mainSourceCode.value
393
+ })
394
+
395
+ const description = computed(() => {
396
+ const locales = demo.value?.locales ?? {}
397
+ const locale = context.locale?.() ?? 'zh-CN'
398
+ const localeData = locales[locale] || locales['zh-CN'] || locales['en-US'] || Object.values(locales)[0] || {}
399
+ return localeData?.html ?? ''
400
+ })
401
+
402
+ const component = computed(() => typeof demo.value?.component === 'function' ? defineAsyncComponent(demo.value.component) : demo.value?.component)
403
+ const id = computed(() => {
404
+ if (!props.src)
405
+ return ''
406
+ return getDemoId(props.src)
407
+ })
408
+ const showCode = shallowRef(false)
409
+ const liveComponent = shallowRef<any>(null)
410
+ const compileError = shallowRef<string | null>(null)
411
+ const currentCode = shallowRef<string | null>(null)
412
+ const editorBridgeRef = shallowRef<{ resetCode: (code: string) => void }>()
413
+
414
+ function handleShowCode() {
415
+ showCode.value = !showCode.value
416
+ if (!showCode.value) {
417
+ // 收起时只重置实时编辑状态,保留源码以便快速重新展开
418
+ liveComponent.value = null
419
+ compileError.value = null
420
+ currentCode.value = null
421
+ }
422
+ else {
423
+ // 展开时按需加载源码(ensureSourceLoaded 内部会处理过期重载)
424
+ void ensureSourceLoaded().catch(() => {})
425
+ }
426
+ }
427
+
428
+ // 切换 demo 时释放旧源码并重置多文件 tab
429
+ watch(demo, () => {
430
+ localCodeKey.value = null
431
+ releaseSource()
432
+ }, { flush: 'sync' })
433
+
434
+ // 展开代码面板时触发加载(收起时不释放,保留源码)
435
+ watch([showCode, demo], ([visible, currentDemo]) => {
436
+ if (!visible)
437
+ return
438
+ if (currentDemo)
439
+ void ensureSourceLoaded().catch(() => {})
440
+ })
441
+
442
+ // HMR 触发的 sourceVersion 变化:展开时立即重载,收起时标记过期
443
+ watch(
444
+ () => demo.value?.sourceVersion,
445
+ (version, previousVersion) => {
446
+ if (version === previousVersion)
447
+ return
448
+ // 如果用户正在编辑,暂缓重载,避免覆盖编辑内容
449
+ if (currentCode.value !== null || liveComponent.value !== null) {
450
+ sourceStale = true
451
+ return
452
+ }
453
+ if (showCode.value) {
454
+ releaseSource()
455
+ void ensureSourceLoaded().catch(() => {})
456
+ }
457
+ else if (sourceData.value) {
458
+ sourceStale = true
459
+ }
460
+ },
461
+ )
462
+
463
+ onBeforeUnmount(releaseSource)
464
+
465
+ // Reset live component and editor code when tab changes
466
+ watch(activeCodeType, () => {
467
+ liveComponent.value = null
468
+ compileError.value = null
469
+ currentCode.value = null
470
+ // 等 sandpack 完成 activeFile 切换后再重置内容,避免写入错误的文件
471
+ nextTick(() => {
472
+ editorBridgeRef.value?.resetCode(activeSourceCode.value)
473
+ })
474
+ })
475
+
476
+ const debouncedCompile = useDebounceFn(async (newCode: string) => {
477
+ // 伴生文件 tab 不参与主 demo 的实时编译
478
+ if (activeExtraFile.value)
479
+ return
480
+ // Code matches original source (e.g. after tab switch reset), skip compilation
481
+ if (newCode === mainSourceCode.value) {
482
+ liveComponent.value = null
483
+ compileError.value = null
484
+ return
485
+ }
486
+ const { component: comp, error } = await compileSfcSource(newCode, context.modules)
487
+ if (comp) {
488
+ liveComponent.value = markRaw(comp)
489
+ compileError.value = null
490
+ }
491
+ else {
492
+ compileError.value = error
493
+ }
494
+ }, 300)
495
+
496
+ function handleCodeChange(newCode: string) {
497
+ currentCode.value = newCode
498
+ debouncedCompile(newCode)
499
+ }
500
+
501
+ const sandpackTheme = computed(() => context.isDark?.() ? atomDark : aquaBlue)
502
+
503
+ const sandpackFiles = computed(() => {
504
+ const files: Record<string, string> = {
505
+ '/src/App.vue': mainSourceCode.value,
506
+ }
507
+ for (const file of extraFiles.value) {
508
+ files[extraFileToSandpackPath(file.name)] = file.code
509
+ }
510
+ return files
511
+ })
512
+
513
+ // 当前激活的 sandpack 文件(多文件 tab 时切换)
514
+ const sandpackActiveFile = computed(() => {
515
+ if (activeExtraFile.value)
516
+ return extraFileToSandpackPath(activeExtraFile.value.name)
517
+ return '/src/App.vue'
518
+ })
519
+
520
+ const sandpackOptions = computed(() => ({
521
+ autorun: false,
522
+ activeFile: sandpackActiveFile.value,
523
+ }))
524
+
525
+ const active = computed(() => route.hash === `#${id.value}`)
526
+ function handleScroll(e: Event) {
527
+ e.preventDefault()
528
+ e.stopPropagation()
529
+ router.push({
530
+ path: route.path,
531
+ hash: `#${id.value}`,
532
+ })
533
+ }
534
+
535
+ const titleRef = shallowRef<HTMLElement>()
536
+
537
+ async function handleStackBlitz() {
538
+ if (!context.openStackBlitz)
539
+ return
540
+ try {
541
+ await ensureSourceLoaded()
542
+ }
543
+ catch {
544
+ showCode.value = true
545
+ return
546
+ }
547
+ if (mainSourceCode.value) {
548
+ context.openStackBlitz({
549
+ title: titleRef.value?.textContent || 'Demo',
550
+ code: mainSourceCode.value,
551
+ })
552
+ }
553
+ }
554
+
555
+ async function handleOpenPlayground() {
556
+ if (!context.openPlayground)
557
+ return
558
+ // 与线上一致:面板未展开过时先加载源码,失败则展开面板展示错误
559
+ try {
560
+ await ensureSourceLoaded()
561
+ }
562
+ catch {
563
+ showCode.value = true
564
+ return
565
+ }
566
+ if (mainSourceCode.value) {
567
+ context.openPlayground(mainSourceCode.value)
568
+ }
569
+ }
570
+
571
+ const demoStyle = computed(() => {
572
+ const style: CSSProperties = {}
573
+ if (props.compact) {
574
+ style.padding = '0px'
575
+ style.overflow = 'hidden'
576
+ }
577
+ if (props.background === 'grey') {
578
+ style.backgroundColor = 'var(--ant-color-bg-layout)'
579
+ }
580
+ return style
581
+ })
582
+
583
+ const copySource = computed(() => currentCode.value ?? activeSourceCode.value)
584
+
585
+ const { copied, copy } = useClipboard({
586
+ source: copySource,
587
+ legacy: true,
588
+ })
589
+
590
+ // 与线上一致:操作栏复制按钮在面板未展开时也可用(先加载源码再复制)
591
+ async function handleCopy() {
592
+ try {
593
+ await ensureSourceLoaded()
594
+ await copy()
595
+ }
596
+ catch {
597
+ showCode.value = true
598
+ }
599
+ }
600
+
601
+ const cls = computed(() => {
602
+ const cls: string[] = []
603
+ if (active.value) {
604
+ cls.push('border-primary')
605
+ }
606
+ if (props.simplify) {
607
+ cls.push('ant-doc-demo-box-simplify')
608
+ }
609
+ if (props.debug) {
610
+ cls.push('ant-doc-demo-box-debug')
611
+ }
612
+ return cls
613
+ })
614
+ </script>
615
+
616
+ <template>
617
+ <section v-if="!hidden" :id="id" class="ant-doc-demo-box" :class="[styles.root, cls]">
618
+ <template v-if="simplify">
619
+ <section class="ant-doc-demo-box-demo vp-raw" :style="demoStyle">
620
+ <Skeleton v-if="!demo" active :paragraph="{ rows: 5 }" />
621
+ <component :is="liveComponent || component" v-else-if="liveComponent || demo?.component" />
622
+ </section>
623
+ </template>
624
+ <template v-else>
625
+ <!-- Preview area: always visible, shows live-compiled or original component -->
626
+ <section v-if="!iframe" class="ant-doc-demo-box-demo vp-raw" :style="demoStyle">
627
+ <Skeleton v-if="!demo" active :paragraph="{ rows: 5 }" />
628
+ <Suspense v-else>
629
+ <component :is="liveComponent || component" v-if="liveComponent || demo?.component" />
630
+ <template #fallback>
631
+ <Skeleton active :paragraph="{ rows: 5 }" />
632
+ </template>
633
+ </Suspense>
634
+ </section>
635
+ <template v-else-if="context.iframeRenderer">
636
+ <component :is="context.iframeRenderer(id, props.iframe)" />
637
+ </template>
638
+ <!-- Compile error hint -->
639
+ <div v-if="compileError && showCode" class="ant-doc-demo-box-compile-error">
640
+ <pre>{{ compileError }}</pre>
641
+ </div>
642
+ <!-- Meta: title, description, actions -->
643
+ <section class="ant-doc-demo-box-meta markdown">
644
+ <div class="ant-doc-demo-box-title">
645
+ <a ref="titleRef" :href="`#${id}`" @click="handleScroll">
646
+ <slot />
647
+ </a>
648
+ <a
649
+ v-if="context.editUrl"
650
+ class="ant-doc-demo-box-edit-icon"
651
+ :href="context.editUrl(id)"
652
+ target="_blank"
653
+ rel="noopener"
654
+ >
655
+ <EditOutlined />
656
+ </a>
657
+ </div>
658
+ <div v-if="description" class="ant-doc-demo-box-meta-description">
659
+ <div v-html="description" />
660
+ </div>
661
+ <Flex class="ant-doc-demo-box-actions" wrap gap="middle">
662
+ <div v-if="context.copyCode !== false" class="ant-doc-demo-box-code-action" @click="handleCopy">
663
+ <Tooltip :title="t(`action.${copied ? 'copied' : 'copy'}`)">
664
+ <CheckOutlined v-if="copied" />
665
+ <CopyOutlined v-else />
666
+ </Tooltip>
667
+ </div>
668
+ <a v-if="context.openStackBlitz" class="ant-doc-demo-box-code-action" @click="handleStackBlitz">
669
+ <Tooltip :title="t('action.stackblitz')">
670
+ <ThunderboltOutlined />
671
+ </Tooltip>
672
+ </a>
673
+ <a
674
+ v-if="context.demoPageUrl"
675
+ class="ant-doc-demo-box-code-action"
676
+ :href="context.demoPageUrl(id)"
677
+ target="_blank"
678
+ rel="noopener"
679
+ >
680
+ <Tooltip :title="t('action.externalLink')">
681
+ <ExternalLinkIcon />
682
+ </Tooltip>
683
+ </a>
684
+ <div v-if="context.openPlayground" class="ant-doc-demo-box-code-action" @click="handleOpenPlayground">
685
+ <Tooltip :title="t('action.openPlayground')">
686
+ <CodeOutlined />
687
+ </Tooltip>
688
+ </div>
689
+ <div class="ant-doc-demo-box-expand-icon ant-doc-demo-box-code-action" @click="handleShowCode">
690
+ <Tooltip :title="t(`action.${showCode ? 'expandedCode' : 'expandCode'}`)">
691
+ <ExpandIcon :expanded="showCode" />
692
+ </Tooltip>
693
+ </div>
694
+ </Flex>
695
+ </section>
696
+ <!-- Code editor (only when expanded) -->
697
+ <template v-if="showCode">
698
+ <!-- 加载中 -->
699
+ <div v-if="sourceLoading" class="ant-doc-demo-box-code-loading">
700
+ <Spin />
701
+ </div>
702
+ <!-- 加载失败 -->
703
+ <div v-else-if="sourceLoadError" class="ant-doc-demo-box-code">
704
+ <Alert type="error" :message="t('action.loadError')" />
705
+ </div>
706
+ <!-- 正常展示 -->
707
+ <template v-else>
708
+ <div v-if="hasCodeTabs" class="ant-doc-demo-box-code-tabs">
709
+ <Tabs
710
+ v-model:active-key="activeCodeType"
711
+ centered
712
+ size="small"
713
+ >
714
+ <TabPane key="ts" :tab="t('type.typescript')" />
715
+ <TabPane v-if="hasJsSource" key="js" :tab="t('type.javascript')" />
716
+ <TabPane
717
+ v-for="file in extraFiles"
718
+ :key="file.name"
719
+ :tab="displayFileName(file.name)"
720
+ />
721
+ </Tabs>
722
+ </div>
723
+ <div class="ant-doc-demo-box-code">
724
+ <Tooltip :title="t(`action.${copied ? 'copied' : 'copy'}`)">
725
+ <div class="ant-doc-demo-box-code-copy" :class="copied ? 'ant-doc-demo-box-code-copied' : ''" @click="copy()">
726
+ <CopyOutlined v-if="!copied" />
727
+ <CheckOutlined v-else />
728
+ </div>
729
+ </Tooltip>
730
+ <SandpackProvider
731
+ template="vite-vue-ts"
732
+ :files="sandpackFiles"
733
+ :theme="sandpackTheme"
734
+ :options="sandpackOptions"
735
+ >
736
+ <CodeEditorBridge
737
+ ref="editorBridgeRef"
738
+ @update:code="handleCodeChange"
739
+ />
740
+ </SandpackProvider>
741
+ </div>
742
+ </template>
743
+ </template>
744
+ </template>
745
+ </section>
746
+ </template>