apptify 0.5.0 → 0.5.4

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 (68) hide show
  1. package/README.md +39 -308
  2. package/dist/apptify.css +1 -1
  3. package/dist/apptify.js +2298 -2140
  4. package/dist/components/AppleCard.vue.d.ts +76 -0
  5. package/dist/components/button.d.ts +3 -3
  6. package/dist/components/content.d.ts +0 -90
  7. package/dist/components/foundation.d.ts +201 -129
  8. package/dist/components/link.d.ts +31 -0
  9. package/dist/components/overlays.d.ts +8 -2
  10. package/dist/core/context.d.ts +11 -0
  11. package/dist/core/overlay-surface.d.ts +6 -0
  12. package/dist/core/popup-motion.d.ts +5 -5
  13. package/dist/core/popup-placement.d.ts +3 -2
  14. package/dist/index.d.ts +144 -79
  15. package/docs/COMPONENTS.md +24 -10
  16. package/docs/INPUT-WORKSHOP.md +59 -0
  17. package/docs/PLAYGROUND-TEMPLATE.md +32 -55
  18. package/docs/VALIDATION.md +1 -1
  19. package/package.json +14 -1
  20. package/templates/playground/README.md +14 -10
  21. package/templates/playground/index.html +1 -1
  22. package/templates/playground/package.json +6 -2
  23. package/templates/playground/source-snapshot.json +139 -54
  24. package/templates/playground/{playground → src}/App.vue +5 -4
  25. package/templates/playground/{playground → src}/ComponentDemo.vue +8 -9
  26. package/templates/playground/{playground → src}/catalog.ts +2 -3
  27. package/templates/playground/src/components/ComponentIndex.vue +43 -0
  28. package/templates/playground/src/components/DirectoryDialog.vue +20 -0
  29. package/templates/playground/src/components/PageFooter.vue +7 -0
  30. package/templates/playground/{playground → src}/components/UserMenu.vue +11 -11
  31. package/templates/playground/src/editor/AppleInputExample.vue +78 -0
  32. package/templates/playground/src/editor/AppleInputPreview.vue +82 -0
  33. package/templates/playground/src/editor/ComponentDocumentLink.vue +19 -0
  34. package/templates/playground/src/editor/ComponentWorkshop.vue +81 -0
  35. package/templates/playground/src/editor/ConfigurableEditor.vue +35 -0
  36. package/templates/playground/src/editor/LiveVueEditor.vue +133 -0
  37. package/templates/playground/src/editor/apple-input-source.ts +2 -0
  38. package/templates/playground/src/editor/apple-input.ts +48 -0
  39. package/templates/playground/src/editor/contract.ts +14 -0
  40. package/templates/playground/src/editor/documents.ts +20 -0
  41. package/templates/playground/src/editor/input-playground.ts +59 -0
  42. package/templates/playground/src/editor/runtime.ts +5 -0
  43. package/templates/playground/src/editor/sections.ts +10 -0
  44. package/templates/playground/{playground → src}/style.css +40 -39
  45. package/templates/playground/{playground → src}/views/[...all].vue +2 -2
  46. package/templates/playground/src/views/component-docs/[component].vue +162 -0
  47. package/templates/playground/src/views/components.vue +148 -0
  48. package/templates/playground/src/views/index.vue +73 -0
  49. package/templates/playground/src/views/settings.vue +225 -0
  50. package/templates/playground/tooling/playground-editor.mjs +114 -0
  51. package/templates/playground/tooling/prepare-input-source.mjs +38 -0
  52. package/templates/playground/tsconfig.json +2 -2
  53. package/templates/playground/vite.config.ts +3 -1
  54. package/tooling/playground-editor.mjs +114 -0
  55. package/tooling/prepare-input-source.mjs +38 -0
  56. package/tooling/prepare-playground.mjs +45 -19
  57. package/tooling/test-create-apptify.mjs +86 -0
  58. package/tooling/test-playground-package.mjs +13 -7
  59. package/templates/playground/playground/components/ComponentIndex.vue +0 -62
  60. package/templates/playground/playground/components/PageFooter.vue +0 -3
  61. package/templates/playground/playground/views/components.vue +0 -126
  62. package/templates/playground/playground/views/index.vue +0 -75
  63. package/templates/playground/playground/views/settings.vue +0 -98
  64. /package/templates/playground/{playground → src}/components/not-found-paper-scene.vue +0 -0
  65. /package/templates/playground/{playground → src}/components/not-found-paper-story.ts +0 -0
  66. /package/templates/playground/{playground → src}/env.d.ts +0 -0
  67. /package/templates/playground/{playground → src}/main.ts +0 -0
  68. /package/templates/playground/{playground → src}/router/index.ts +0 -0
@@ -8,7 +8,7 @@ export const catalog: CatalogItem[] = [
8
8
  item('AppleContainer', '内容容器', '基础', '在不同屏幕上保留合适的内容宽度。', 'width(number|string) | slot: default', '<apple-container :width="960">\n <apple-stack>内容</apple-stack>\n</apple-container>'),
9
9
  item('AppleStack', '弹性布局', '基础', '有序排列,自然换行。', 'direction(row|column) · gap · align · wrap | slot: default', '<apple-stack direction="row" :gap="16" align="center">\n <apple-button>继续</apple-button>\n <apple-button variant="secondary">取消</apple-button>\n</apple-stack>'),
10
10
  item('AppleGrid', '网格布局', '基础', '让列数随内容空间变化。', 'min(number) · gap(number) | slot: default', '<apple-grid :min="240" :gap="20">\n <div v-for="item in items" :key="item.id">{{ item.label }}</div>\n</apple-grid>'),
11
- item('AppleCard', '卡片', '基础', '来自 Apple Store 的产品表达。', 'title · subtitle · text · image · zoom · shadow · motion | slots: media, title, icon, actions', '<apple-card title="AirPods Max" subtitle="声音,出类拔萃。" motion="auto" zoom="small">\n <template #actions><apple-button>进一步了解</apple-button></template>\n</apple-card>'),
11
+ item('AppleCard', '卡片', '基础', '来自 Apple Store 的产品表达。', 'title · subtitle · text · image · zoom · shadow · motion | slots: media, title, icon, actions', '<apple-card title="AirPods Max" subtitle="声音,出类拔萃。" motion="auto">\n <template #actions><apple-button>进一步了解</apple-button></template>\n</apple-card>'),
12
12
  item('AppleImage', '图片与预览', '基础', '让值得细看的内容更近一步。', 'gallery(object|array) · index · preview · carousel · disabled · label · galleryLayout(compact|tiled|tiled-wrap) · v-model:index | item({item,index,active}) · squared · aspectRatio · fit', '<apple-image :gallery="images" gallery-layout="compact" :index="0" />'),
13
13
  item('AppleAvatar', '头像', '基础', '为每一位用户保留位置。', 'src · name · size', '<apple-avatar name="林初" :size="48" />'),
14
14
  item('AppleAvatarGroup', '头像组', '基础', '一起参与的人,一眼可见。', 'items[{name,src,value}] · max · size · label | slot: default', '<apple-avatar-group :items="members" :max="4" :size="40" label="项目成员" />'),
@@ -62,10 +62,9 @@ export const catalog: CatalogItem[] = [
62
62
  item('ApplePullRefresh', '下拉刷新', '移动交互', '轻轻下拉,获取新内容。', 'v-model(refreshing) · threshold · label · disabled | refresh(done)', '<apple-pull-refresh @refresh="async done => { await reload(); done() }">内容</apple-pull-refresh>'),
63
63
  item('AppleInfiniteScroll', '无限滚动', '移动交互', '浏览,不必被翻页打断。', 'loading · finished · error · disabled · distance | load(done), retry(done)', '<apple-infinite-scroll :finished="finished" @load="async done => { await loadMore(); done() }">内容</apple-infinite-scroll>'),
64
64
  item('AppleBackTop', '回到顶部', '移动交互', '回到开始,毫不费力。', 'threshold · label', '<apple-back-top :threshold="400" />'),
65
- item('AppleFloatingGroup', '悬浮操作组', '移动交互', '常用操作,触手可及。', 'backTop · threshold · target · label · motion | default', '<apple-floating-group :threshold="300">\n <apple-button icon-only :icon="Plus" label="添加" />\n</apple-floating-group>'),
65
+ item('AppleFloatingGroup', '悬浮操作组', '移动交互', '常用操作,触手可及。', 'backTop · threshold · target · label · motion | default', '<apple-floating-group :threshold="300" />'),
66
66
  item('AppleAutoSize', '自适应尺寸', '基础', '内容变化,布局随行。', 'axis(height|both) · motion | default', '<apple-auto-size><div v-if="expanded">详情内容</div></apple-auto-size>'),
67
67
  item('AppleTransition', '动效容器', '基础', '为页面保留连续感。', 'name(page|slide-x|slide-y|fade) · mode · appear · motion | default', '<apple-transition name="page"><router-view /></apple-transition>'),
68
- item('AppleMarquee', '通知栏', '移动交互', '简短消息,保持可见。', 'text · duration(seconds) · paused · label · motion', '<apple-marquee text="新的内容已经准备好。" :duration="24" />'),
69
68
  ]
70
69
  export const groups = ['全部组件', '基础', '表单', '导航', '数据展示', '反馈', '移动交互']
71
70
 
@@ -0,0 +1,43 @@
1
+ <template>
2
+ <nav class="component-index" aria-label="组件定位">
3
+ <AppleTree :items="treeItems" :model-value="activeId || activeGroup" :expanded="expandedGroups" label="组件目录树" @update:expanded="expanded = $event" @select="navigate" />
4
+ <p v-if="!treeItems.length" class="index-empty" role="status">未找到组件,请尝试其他关键词。</p>
5
+ </nav>
6
+ </template>
7
+
8
+ <script setup lang="ts">
9
+ import { computed } from 'vue'
10
+ import { useRoute, useRouter } from 'vue-router'
11
+ import { AppleTree } from 'apptify'
12
+ import type { AppleTreeItem, AppleValue } from 'apptify'
13
+ import { sections, componentId } from '../catalog'
14
+
15
+ const props = defineProps<{ activeGroup: string; activeId: string; search?: string }>()
16
+ const emit = defineEmits<{ navigate: [] }>()
17
+ const router = useRouter()
18
+ const route = useRoute()
19
+ const expanded = defineModel<AppleValue[]>('expanded', { default: () => sections.map(section => section.id) })
20
+ const query = computed(() => props.search?.trim().toLocaleLowerCase() ?? '')
21
+ const treeItems = computed<AppleTreeItem[]>(() => sections.flatMap(section => {
22
+ const groupMatches = section.label.toLocaleLowerCase().includes(query.value)
23
+ const items = section.items.filter(item => groupMatches || `${item.label} ${item.name} ${componentId(item.name)} ${item.description}`.toLocaleLowerCase().includes(query.value))
24
+ return items.length ? [{ label: section.label, value: section.id, children: items.map(item => ({ label: item.label, value: componentId(item.name) })) }] : []
25
+ }))
26
+ const expandedGroups = computed(() => query.value ? treeItems.value.map(item => item.value) : expanded.value)
27
+ function navigate(item: AppleTreeItem) {
28
+ if (item.children?.length) return
29
+ const hash = `#${item.value}`
30
+ if (route.hash === hash) {
31
+ const target = document.getElementById(String(item.value))
32
+ if (target) window.scrollTo({ top: target.getBoundingClientRect().top + window.scrollY - 96, behavior: 'instant' })
33
+ } else void router.push(`/components${hash}`)
34
+ emit('navigate')
35
+ }
36
+ </script>
37
+
38
+ <style scoped>
39
+ .component-index { min-width: 0; }
40
+ .component-index :deep(.apple-tree__row) { min-height: 34px; }
41
+ .component-index :deep(.apple-tree__row > span:last-child) { min-width: 0; overflow-wrap: anywhere; }
42
+ .index-empty { margin: 12px 8px; font-size: 12px; line-height: 1.7; color: var(--apple-secondary); }
43
+ </style>
@@ -0,0 +1,20 @@
1
+ <template>
2
+ <apple-floating-group label="页面导航">
3
+ <apple-button class="directory-toggle" variant="secondary" icon-only :icon="Menu" :label="`展开${title}`" aria-haspopup="dialog" :aria-expanded="modelValue" @click="modelValue = true" />
4
+ </apple-floating-group>
5
+ <apple-dialog v-model="modelValue" :title="title" :show-footer="false" width="480px">
6
+ <div class="directory-dialog-content"><slot /></div>
7
+ </apple-dialog>
8
+ </template>
9
+
10
+ <script setup lang="ts">
11
+ import { Menu } from 'lucide-vue-next'
12
+ defineProps<{ title: string }>()
13
+ const modelValue = defineModel<boolean>({ default: false })
14
+ </script>
15
+
16
+ <style scoped>
17
+ .directory-toggle { display: none; order: 1001; width: 44px; height: 44px; padding: 0; border-radius: 50%; box-shadow: var(--apple-shadow); }
18
+ .directory-dialog-content { min-width: 0; }
19
+ @media (max-width: 900px) { .directory-toggle { display: inline-flex; } }
20
+ </style>
@@ -0,0 +1,7 @@
1
+ <script setup lang="ts">
2
+ </script>
3
+ <template>
4
+ <apple-container>
5
+ <footer class="page-footer"><span>Write your own footer here</span></footer>
6
+ </apple-container>
7
+ </template>
@@ -1,19 +1,19 @@
1
1
  <template>
2
2
  <apple-popover v-model="opened" label="用户菜单" align="end" :width="280" panel-class="user-popup">
3
3
  <template #activator>
4
- <apple-button class="user-trigger user-account-trigger" variant="ghost" icon-only label="打开用户菜单" title="用户菜单"><UserRound :size="20" /></apple-button>
4
+ <apple-link as="button" class="user-trigger user-account-trigger" icon-only label="打开用户菜单" title="用户菜单"><UserRound :size="20" /></apple-link>
5
5
  </template>
6
6
  <div class="user-summary">
7
7
  <apple-avatar name="林初" :size="48" />
8
8
  <div><strong>林初 <small>演示账户</small></strong><span>lin.chu@example.com</span></div>
9
9
  </div>
10
10
  <div class="user-menu-list">
11
- <apple-button variant="ghost" @click="showProfile"><UserRound :size="17" /><span>个人资料</span><ArrowUpRight :size="14" aria-hidden="true" /></apple-button>
11
+ <apple-link as="button" @click="showProfile"><UserRound :size="17" /><span>个人资料</span><ArrowUpRight :size="14" aria-hidden="true" /></apple-link>
12
12
  <router-link to="/settings" @click="opened = false"><Settings2 :size="17" /><span>偏好设置</span><ArrowUpRight :size="14" aria-hidden="true" /></router-link>
13
13
  </div>
14
14
  <div class="user-menu-list user-menu-examples">
15
- <apple-button variant="ghost" @click="openAuth('login')"><LogIn :size="17" /><span>登录 <small>Login</small></span><em>示例</em></apple-button>
16
- <apple-button variant="ghost" @click="openAuth('register')"><UserPlus :size="17" /><span>注册 <small>Register</small></span><em>示例</em></apple-button>
15
+ <apple-link as="button" @click="openAuth('login')"><LogIn :size="17" /><span>登录 <small>Login</small></span><em>示例</em></apple-link>
16
+ <apple-link as="button" @click="openAuth('register')"><UserPlus :size="17" /><span>注册 <small>Register</small></span><em>示例</em></apple-link>
17
17
  </div>
18
18
  </apple-popover>
19
19
  <apple-dialog v-model="authOpen" class="user-auth-dialog" :width="560" :title="authMode === 'login' ? '登录示例' : '注册示例'" :show-footer="false" @after-close="password = ''">
@@ -34,12 +34,12 @@
34
34
  <apple-input v-model="password" label="密码" type="password" :autocomplete="authMode === 'login' ? 'current-password' : 'new-password'" required>
35
35
  <template #prefix><LockKeyhole :size="18" aria-hidden="true" /></template>
36
36
  </apple-input>
37
- <apple-button v-if="authMode === 'login'" class="user-auth-forgot" variant="ghost" size="small" @click="showAuthEntry('forgot')">忘记密码?</apple-button>
37
+ <apple-link as="button" v-if="authMode === 'login'" class="user-auth-forgot" @click="showAuthEntry('forgot')">忘记密码?</apple-link>
38
38
  </div>
39
39
  <p class="user-auth-note">这是表单交互示例,尚未接入账户服务。填写内容不会提交到服务器。</p>
40
40
  <div class="user-auth-actions">
41
- <apple-button class="user-auth-submit" type="submit" size="large">{{ authMode === 'login' ? '体验登录' : '体验注册' }}</apple-button>
42
- <apple-button class="user-auth-email" variant="outline" size="large" @click="showAuthEntry('email')">邮箱验证码登录</apple-button>
41
+ <apple-button class="user-auth-submit" type="submit">{{ authMode === 'login' ? '体验登录' : '体验注册' }}</apple-button>
42
+ <apple-button class="user-auth-email" variant="outline" @click="showAuthEntry('email')">邮箱验证码登录</apple-button>
43
43
  </div>
44
44
  </apple-form>
45
45
  <div class="user-auth-alternatives">
@@ -99,10 +99,10 @@ function showProfile() {
99
99
 
100
100
  <style>
101
101
  .user-popup.apple-popover { border-radius: 24px; background: var(--apple-surface); -webkit-backdrop-filter: none; backdrop-filter: none; }
102
- .user-popup .user-menu-list > .apple-button > .apple-button__content { width: 100%; justify-content: flex-start; gap: 11px; }
103
- .user-popup .user-menu-list > .apple-button { min-height: 44px; font-weight: 400; line-height: inherit; }
104
- .user-popup .user-menu-list > .apple-button:hover:where(:not([data-apple-touch] *)):not(:disabled) { background: var(--apple-hover); color: var(--apple-text); }
105
- .user-popup .user-menu-list > .apple-button:active:not(:disabled) { background: var(--apple-pressed); color: var(--apple-text); }
102
+ .user-popup .user-menu-list > .apple-link > .apple-link__content { width: 100%; justify-content: flex-start; gap: 11px; }
103
+ .user-popup .user-menu-list > .apple-link { min-height: 44px; font-weight: 400; line-height: inherit; }
104
+ .user-popup .user-menu-list > .apple-link:hover:where(:not([data-apple-touch] *)):not(:disabled) { background: var(--apple-hover); }
105
+ .user-popup .user-menu-list > .apple-link:active:not(:disabled) { background: var(--apple-pressed); }
106
106
  .user-trigger.user-account-trigger { width: 44px; height: 44px; border: 0; background: transparent; }
107
107
  .user-trigger.user-account-trigger:focus-visible { outline: 3px solid var(--apple-accent); outline-offset: 2px; }
108
108
  .user-auth-dialog.apple-modal { border-radius: 24px; background: var(--apple-surface); -webkit-backdrop-filter: none; backdrop-filter: none; }
@@ -0,0 +1,78 @@
1
+ <script>
2
+ import { AppleInput, AppleTextarea, AppleLink } from 'apptify'
3
+ import { Mail } from 'lucide-vue-next'
4
+
5
+ export default {
6
+ components: { AppleInput, AppleTextarea, AppleLink, Mail },
7
+ data() {
8
+ return { value: 'Apptify', events: [], eventId: 0 }
9
+ },
10
+ computed: {
11
+ eventLog() {
12
+ return this.events.map(event => `${event.id}. ${event.name} ${event.value === undefined ? '无参数' : JSON.stringify(event.value)}`).join('\n')
13
+ },
14
+ },
15
+ watch: {
16
+ eventLog() {
17
+ this.$nextTick(() => {
18
+ const textarea = this.$refs.logInput?.$el.querySelector('textarea')
19
+ if (textarea) textarea.scrollTop = textarea.scrollHeight
20
+ })
21
+ },
22
+ },
23
+ methods: {
24
+ record(name, value) {
25
+ this.events.push({ id: ++this.eventId, name, value })
26
+ if (this.events.length > 12) this.events.shift()
27
+ },
28
+ },
29
+ }
30
+ </script>
31
+
32
+ <template>
33
+ <div class="example">
34
+ <section class="input-section" aria-label="输入框">
35
+ <form @submit.prevent="record('submit', value)">
36
+ <AppleInput
37
+ ref="input"
38
+ v-model="value"
39
+ label="姓名"
40
+ placeholder="怎么称呼你"
41
+ hint="用于在应用中显示你的名称。"
42
+ clearable
43
+ @update:model-value="record('update:modelValue', $event)"
44
+ @change="record('change', $event)"
45
+ @clear="record('clear')"
46
+ @focus="record('focus')"
47
+ @blur="record('blur')"
48
+ >
49
+ <!-- prefix -->
50
+ <!-- suffix -->
51
+ </AppleInput>
52
+ <div class="actions">
53
+ <AppleLink as="button" :disabled="false" @click="$refs.input.focus()">focus()</AppleLink>
54
+ <AppleLink as="button" @click="value = 0">赋值为 0</AppleLink>
55
+ <AppleLink as="button" type="submit">表单校验</AppleLink>
56
+ </div>
57
+ </form>
58
+ <output>v-model = {{ JSON.stringify(value) }} · {{ typeof value }}</output>
59
+ </section>
60
+ <section class="events-section" aria-label="事件记录">
61
+ <div class="event-heading"><h3>事件记录</h3><AppleLink as="button" @click="events = []">清除记录</AppleLink></div>
62
+ <AppleTextarea ref="logInput" :model-value="eventLog" readonly :rows="6" :resize="false" class="event-log" aria-label="事件记录内容" placeholder="等待操作…" />
63
+ </section>
64
+ </div>
65
+ </template>
66
+
67
+ <style scoped>
68
+ .example { display: grid; gap: 28px; max-width: 560px; margin-inline: auto; padding: 28px 24px; }
69
+ .input-section { display: grid; gap: 16px; min-width: 0; }
70
+ form { display: grid; gap: 16px; min-width: 0; }
71
+ .actions { display: flex; flex-wrap: wrap; align-items: center; gap: 24px; }
72
+ output { color: var(--apple-secondary); font-size: 12px; line-height: 1.6; font-family: ui-monospace, SFMono-Regular, monospace; overflow-wrap: anywhere; }
73
+ .events-section { display: grid; gap: 12px; min-width: 0; border-top: 1px solid var(--apple-border); padding-top: 20px; }
74
+ .event-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
75
+ .event-heading h3 { margin: 0; font-size: 14px; font-weight: 600; }
76
+ .example :deep(.event-log) { height: 240px; max-height: 240px; font-size: 12px; font-family: ui-monospace, SFMono-Regular, monospace; }
77
+ @media (max-width: 480px) { .example { padding: 24px 16px; } }
78
+ </style>
@@ -0,0 +1,82 @@
1
+ <template>
2
+ <div class="input-playground" data-testid="input-playground">
3
+ <LiveVueEditor :files="files" :baseline-files="baselineFiles" reset-label="恢复当前配置" active-file="App.vue" preview-surface @restore="reset">
4
+ <template #configuration>
5
+ <section class="configuration" role="region" aria-label="输入框配置">
6
+ <div class="config-heading"><h3>配置输入框</h3><AppleLink as="button" @click="reset">重置</AppleLink></div>
7
+ <div class="preset-segments"><AppleSegmentedControl :model-value="preset" label="快速场景" :items="presets" @update:model-value="applyPreset(String($event))" /></div>
8
+ <div class="preset-select"><AppleSelect :model-value="preset" label="快速场景" :items="presets" @update:model-value="applyPreset(String($event))" /></div>
9
+ <AppleSegmentedControl v-model="config.type" label="输入类型" :items="types" />
10
+ <div class="config-grid">
11
+ <AppleInput v-model="config.label" label="标签" hint="label" />
12
+ <AppleInput v-model="config.placeholder" label="占位文字" hint="placeholder" />
13
+ <AppleInput v-model="config.hint" label="帮助文字" hint="hint" />
14
+ <AppleInput v-model="config.error" label="错误信息" hint="error" placeholder="留空恢复正常" />
15
+ </div>
16
+ <div class="switch-grid">
17
+ <AppleSwitch v-for="toggle in toggles" :key="toggle.key" v-model="config[toggle.key]" :label="toggle.label" :hint="toggle.key" />
18
+ </div>
19
+ <AppleAccordion class="advanced-config" :items="[{ label: '插槽、原生属性与动效', value: 'advanced' }]">
20
+ <template #item>
21
+ <div class="config-grid">
22
+ <AppleSegmentedControl v-model="config.prefixMode" label="前缀插槽" hint="prefix" :items="affixModes" />
23
+ <AppleInput v-if="config.prefixMode === 'text'" v-model="config.prefix" label="前缀文字" placeholder="例如 ¥" />
24
+ <AppleInput v-if="config.prefixMode === 'icon'" v-model="config.prefixIcon" label="前缀图标代号" placeholder="Mail" hint="Lucide 代号,例如 Mail、User、Search。" :error="iconCodeError(config.prefixIcon)" />
25
+ <AppleSegmentedControl v-model="config.suffixMode" label="后缀插槽" hint="suffix" :items="affixModes" />
26
+ <AppleInput v-if="config.suffixMode === 'text'" v-model="config.suffix" label="后缀文字" placeholder="例如 kg" />
27
+ <AppleInput v-if="config.suffixMode === 'icon'" v-model="config.suffixIcon" label="后缀图标代号" placeholder="Search" hint="Lucide 代号,例如 Search、Check、ArrowRight。" :error="iconCodeError(config.suffixIcon)" />
28
+ <AppleSelect v-model="config.motion" label="动效" hint="motion" :items="motions" />
29
+ <AppleInput v-model="config.name" label="表单字段" hint="name" />
30
+ <AppleInput v-model="config.ariaLabel" label="可访问名称" hint="aria-label" />
31
+ <AppleInput v-model="config.maxlength" label="最大长度" hint="maxlength" type="number" min="0" placeholder="不限制" />
32
+ <AppleSelect v-model="config.autocomplete" label="自动填充" hint="autocomplete" :items="autocompletes" />
33
+ <AppleInput v-model="config.min" label="最小值" hint="min" type="number" :disabled="config.type !== 'number'" />
34
+ <AppleInput v-model="config.max" label="最大值" hint="max" type="number" :disabled="config.type !== 'number'" />
35
+ <AppleInput v-model="config.step" label="步长" hint="step" type="number" min="0" :disabled="config.type !== 'number'" />
36
+ </div>
37
+ </template>
38
+ </AppleAccordion>
39
+ </section>
40
+ </template>
41
+ </LiveVueEditor>
42
+ </div>
43
+ </template>
44
+ <script setup lang="ts">
45
+ import { computed, defineAsyncComponent, reactive, ref } from 'vue'
46
+ import { AppleInput, AppleButton, AppleLink, AppleSelect, AppleSegmentedControl, AppleSwitch, AppleAccordion } from 'apptify'
47
+ import { createInputConfig, generateInputCode, iconCodeError } from './input-playground'
48
+ const LiveVueEditor = defineAsyncComponent(() => import('./LiveVueEditor.vue'))
49
+ const baselineFiles = { 'App.vue': generateInputCode(createInputConfig(), 'Apptify') }
50
+ const config = reactive(createInputConfig())
51
+ const value = ref<string | number>('Apptify')
52
+ const preset = ref('basic')
53
+ const options = (values: string[]) => values.map(value => ({ label: value, value }))
54
+ const presets = [{ label: '基础', value: 'basic' }, { label: '密码', value: 'password' }, { label: '邮箱', value: 'email' }, { label: '金额', value: 'amount' }]
55
+ const types = [{ label: '文本', value: 'text' }, { label: '密码', value: 'password' }, { label: '数字', value: 'number' }, { label: '邮箱', value: 'email' }, { label: '搜索', value: 'search' }, { label: '电话', value: 'tel' }, { label: '网址', value: 'url' }]
56
+ const affixModes = [{ label: '无', value: 'none' }, { label: '文字', value: 'text' }, { label: '图标', value: 'icon' }]
57
+ const motions = options(['inherit', 'auto', 'full', 'reduced', 'none'])
58
+ const autocompletes = options(['off', 'on', 'name', 'email', 'username', 'current-password', 'new-password', 'tel', 'url'])
59
+ const toggles = [{ key: 'clearable', label: '允许清空' }, { key: 'disabled', label: '禁用' }, { key: 'loading', label: '加载' }, { key: 'required', label: '必填' }] as const
60
+ function applyPreset(next: string) {
61
+ Object.assign(config, createInputConfig())
62
+ preset.value = next
63
+ value.value = 'Apptify'
64
+ if (next === 'password') { Object.assign(config, { type: 'password', label: '密码', placeholder: '请输入密码', hint: '点击眼睛切换密码可见性。', autocomplete: 'current-password' }); value.value = 'apple123' }
65
+ if (next === 'email') { Object.assign(config, { type: 'email', label: '电子邮箱', placeholder: 'name@example.com', hint: '提交时体验原生邮箱校验。', required: true, autocomplete: 'email', prefixMode: 'icon', prefixIcon: 'Mail' }); value.value = '' }
66
+ if (next === 'amount') { Object.assign(config, { type: 'number', label: '金额', placeholder: '0.00', hint: '数字输入事件仍返回字符串。', prefixMode: 'text', prefix: '¥', suffixMode: 'text', suffix: '元', min: '0', step: '0.01' }); value.value = 128 }
67
+ }
68
+ function reset() { applyPreset('basic') }
69
+ const files = computed(() => ({ 'App.vue': generateInputCode(config, value.value) }))
70
+ </script>
71
+ <style scoped>
72
+ .input-playground { display: grid; gap: 24px; min-width: 0; }
73
+ .preset-select { display: none; }
74
+ .configuration { display: grid; gap: 20px; }
75
+ .config-heading { display: flex; align-items: center; justify-content: space-between; }
76
+ .config-heading h3 { margin: 0; font-size: 16px; }
77
+ .config-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
78
+ .switch-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
79
+ .advanced-config :deep(.apple-accordion__item > h3) { font-size: 14px; }
80
+ .advanced-config :deep(.apple-accordion__content) { padding: 8px 12px 20px; }
81
+ @media (max-width: 1100px) { .preset-segments { display: none; } .preset-select { display: block; } .config-grid, .switch-grid { grid-template-columns: minmax(0, 1fr); } }
82
+ </style>
@@ -0,0 +1,19 @@
1
+ <template>
2
+ <footer class="documentation-entry">
3
+ <router-link :to="href" custom v-slot="{ href: target, navigate }">
4
+ <apple-link :href="target" @click="navigate"><span>{{ label || '代码与 API' }}</span><ArrowUpRight :size="16" :stroke-width="1.5" aria-hidden="true" /></apple-link>
5
+ </router-link>
6
+ </footer>
7
+ </template>
8
+ <script setup lang="ts">
9
+ import { ArrowUpRight } from 'lucide-vue-next'
10
+ defineProps<{ href: string; label?: string }>()
11
+ </script>
12
+ <style scoped>
13
+ .documentation-entry { border-top: 1px solid color-mix(in srgb, var(--apple-border) 45%, transparent); border-radius: 0 0 18px 18px; overflow: hidden; }
14
+ .documentation-entry :deep(.apple-link) { display: flex; align-items: center; justify-content: space-between; width: 100%; min-height: 44px; gap: 8px; padding: 13px 28px; color: var(--apple-secondary); font-size: 11px; font-weight: 400; line-height: 1.25; transition: color var(--apple-fast), background-color var(--apple-fast); }
15
+ .documentation-entry :deep(.apple-link__content) { width: 100%; justify-content: space-between; }
16
+ .documentation-entry :deep(.apple-link:hover) { color: var(--apple-secondary); background: var(--apple-hover); }
17
+ .documentation-entry :deep(.apple-link:active) { background: var(--apple-pressed); }
18
+ @media (max-width: 800px) { .documentation-entry :deep(.apple-link) { padding-inline: 18px; } }
19
+ </style>
@@ -0,0 +1,81 @@
1
+ <template>
2
+ <div class="workshop" data-testid="component-document">
3
+ <section v-for="section in apiSections" :key="section.id" :id="section.id" class="api-section">
4
+ <header class="section-heading"><h2>{{ section.title }}</h2><span>{{ section.entries.length }} 项接口</span></header>
5
+ <div class="api-group">
6
+ <article v-for="entry in section.entries" :key="entry.id" :id="entry.id" class="api-entry" :data-api="entry.id">
7
+ <div class="api-summary">
8
+ <div class="api-signature">
9
+ <h3><code>{{ entry.name }}</code></h3>
10
+ <code class="api-type">{{ entry.type }}</code>
11
+ <div v-if="entry.default !== undefined" class="api-default"><span>默认值</span><code>{{ entry.default }}</code></div>
12
+ </div>
13
+ <p class="api-description">{{ entry.description }}</p>
14
+ </div>
15
+ <apple-accordion v-if="entry.example" class="api-accordion" :model-value="activeApi === entry.id ? entry.id : []" :items="[{ label: '交互示例', value: entry.id }]" @update:model-value="value => toggleExample(entry.id, value)">
16
+ <template #item="{ open }">
17
+ <div v-if="open && activeApi === entry.id" class="api-example">
18
+ <div class="example-introduction"><h4>{{ entry.example.title }}</h4><p>{{ entry.example.description }}</p></div>
19
+ <LiveVueEditor :key="entry.id" :files="apiFiles(entry)" :baseline-files="{ 'App.vue': entry.example.code }" active-file="App.vue" @save="files => saveApi(entry.id, files)" />
20
+ </div>
21
+ </template>
22
+ </apple-accordion>
23
+ </article>
24
+ </div>
25
+ </section>
26
+ <section id="accessibility" class="api-section">
27
+ <header class="section-heading"><h2>交互与无障碍</h2></header>
28
+ <ul class="api-notes"><li v-for="note in doc.notes" :key="note">{{ note }}</li></ul>
29
+ </section>
30
+ </div>
31
+ </template>
32
+ <script setup lang="ts">
33
+ import { computed, defineAsyncComponent, ref } from 'vue'
34
+ import type { ApiEntry, ComponentDocument } from './contract'
35
+ import { documentSections } from './sections'
36
+ const props = defineProps<{ doc: ComponentDocument }>()
37
+ // Additional editors are mounted only for examples not covered by the main demo.
38
+ const LiveVueEditor = defineAsyncComponent(() => import('./LiveVueEditor.vue'))
39
+ const activeApi = ref<string>()
40
+ const apiDrafts = new Map<string, Record<string, string>>()
41
+ const apiSections = computed(() => documentSections(props.doc))
42
+ function toggleExample(id: string, value: unknown) { activeApi.value = value === id ? id : undefined }
43
+ function apiFiles(entry: ApiEntry) { return apiDrafts.get(entry.id) || { 'App.vue': entry.example!.code } }
44
+ function saveApi(id: string, files: Record<string, string>) { apiDrafts.set(id, files) }
45
+ </script>
46
+ <style scoped>
47
+ .workshop { min-width: 0; }
48
+ .api-section { scroll-margin-top: 100px; margin-top: 40px; }
49
+ .section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
50
+ .section-heading h2 { margin: 0; font-size: 22px; letter-spacing: -.02em; }
51
+ .section-heading > span { color: var(--apple-secondary); font-size: 12px; }
52
+ .api-group { border-top: 1px solid var(--apple-border); }
53
+ .api-entry { scroll-margin-top: 100px; }
54
+ .api-entry + .api-entry { border-top: 1px solid var(--apple-border); }
55
+ .api-summary { display: grid; grid-template-columns: minmax(0, 220px) minmax(0, 1fr); gap: 28px; padding: 24px 0; }
56
+ .api-signature { min-width: 0; }
57
+ .api-signature h3 { margin: 0 0 8px; font-size: 15px; line-height: 1.5; overflow-wrap: anywhere; }
58
+ .api-signature code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
59
+ .api-type { display: block; color: var(--apple-secondary); font-size: 12px; line-height: 1.65; overflow-wrap: anywhere; }
60
+ .api-default { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; margin-top: 12px; font-size: 12px; }
61
+ .api-default > span { color: var(--apple-secondary); }
62
+ .api-default > code { padding: 2px 6px; border-radius: 4px; background: var(--apple-surface-alt); overflow-wrap: anywhere; }
63
+ .api-description { margin: 0; font-size: 14px; line-height: 1.85; overflow-wrap: anywhere; }
64
+ .api-accordion { margin: 0; }
65
+ .api-accordion :deep(.apple-accordion__item) { border-bottom: 0; }
66
+ .api-accordion :deep(.apple-accordion__item > h3) { margin: 0; }
67
+ .api-accordion :deep(.apple-accordion__item > h3 > button) { padding: 16px 24px; font-size: 13px; }
68
+ .api-accordion :deep(.apple-accordion__content) { padding: 0 24px 24px; }
69
+ .example-introduction { padding: 16px 0 8px; }
70
+ .example-introduction h4 { margin: 0 0 6px; font-size: 16px; }
71
+ .example-introduction p { margin: 0; color: var(--apple-secondary); font-size: 13px; line-height: 1.7; }
72
+ .api-notes { padding-left: 18px; margin: 0; font-size: 14px; line-height: 1.85; }
73
+ .api-notes li + li { margin-top: 14px; }
74
+ @media (max-width: 1100px) { .api-summary { grid-template-columns: minmax(0, 180px) minmax(0, 1fr); gap: 20px; } }
75
+ @media (max-width: 720px) {
76
+ .api-summary { grid-template-columns: minmax(0, 1fr); padding: 20px 0; gap: 14px; }
77
+ .api-default { margin-top: 8px; }
78
+ .section-heading h2 { font-size: 20px; }
79
+ .api-accordion :deep(.apple-accordion__content) { padding: 0 20px 20px; }
80
+ }
81
+ </style>
@@ -0,0 +1,35 @@
1
+ <template>
2
+ <div class="configurable-editor">
3
+ <div class="mode-selector"><AppleTabs v-model="mode" :items="[{ label: '低代码', value: 'controls' }, { label: 'App.vue', value: 'code' }]" label="编辑模式" /><slot name="actions" /></div>
4
+ <div v-show="mode === 'controls'" class="controls-pane"><slot /></div>
5
+ <div v-show="mode === 'code'" class="source-pane"><CodeMirror ref="editor" v-bind="editorAttrs" :value="String(editorAttrs.value ?? '')" :filename="String(editorAttrs.filename ?? 'App.vue')" /></div>
6
+ </div>
7
+ </template>
8
+ <script setup lang="ts">
9
+ import { ref, watch, nextTick } from 'vue'
10
+ import CodeMirror from '@vue/repl/codemirror-editor'
11
+ import { AppleTabs } from 'apptify'
12
+ defineProps<{ editorAttrs: Record<string, unknown> }>()
13
+ const mode = ref('controls')
14
+ const editor = ref<InstanceType<typeof CodeMirror>>()
15
+ watch(mode, async mode => {
16
+ await nextTick()
17
+ if (mode === 'code') editor.value?.getEditorIns<'codemirror'>()?.refresh()
18
+ })
19
+ defineExpose({ getEditorIns: () => editor.value?.getEditorIns() })
20
+ </script>
21
+ <style scoped>
22
+ .configurable-editor { height: 100%; display: flex; flex-direction: column; min-height: 0; background: var(--apple-surface); }
23
+ .mode-selector { box-sizing: border-box; flex: 0 0 var(--header-height, 49px); display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 12px; border-bottom: 1px solid var(--apple-border); }
24
+ .mode-selector :deep(.apple-tabs__list) { border-bottom: 0; }
25
+ .mode-selector :deep(.apple-tabs__viewport) { display: none; }
26
+ .controls-pane { flex: 1; min-height: 0; overflow: auto; padding: 20px; }
27
+ /* Vue REPL resets every descendant button, including the library select trigger. */
28
+ .controls-pane :deep(.apple-select) { border: 1px solid var(--apple-border); background: var(--apple-surface); }
29
+ .controls-pane :deep(.apple-select:hover:where(:not([data-apple-touch] *)):not(:disabled)) { border-color: color-mix(in srgb, var(--apple-secondary) 60%, var(--apple-border)); }
30
+ .controls-pane :deep(.apple-select:active:not(:disabled)) { background: color-mix(in srgb, var(--apple-text) 7%, var(--apple-surface)); }
31
+ .controls-pane :deep(.apple-select:focus) { border-color: var(--apple-accent); }
32
+ .controls-pane :deep(.apple-field.has-error .apple-select) { border-color: var(--apple-danger); }
33
+ .controls-pane :deep(.apple-select:disabled) { border-color: var(--apple-surface-alt); background: var(--apple-surface-alt); cursor: not-allowed; }
34
+ .source-pane { flex: 1; min-height: 0; position: relative; }
35
+ </style>
@@ -0,0 +1,133 @@
1
+ <template>
2
+ <div ref="editorRoot" class="live-editor" :class="{ dark: theme === 'dark', 'has-configuration': !!slots.configuration }">
3
+ <EditorActions v-if="!slots.configuration" />
4
+ <Repl ref="repl" :store="store" :editor="slots.configuration ? configuredEditor : CodeMirror" layout="horizontal" layout-reverse preview-theme :theme="theme" :show-import-map="false" :show-ts-config="false" :show-compile-output="false" :show-ssr-output="false" :auto-resize="false" :clear-console="false" :preview-options="previewOptions" :editor-options="{ showErrorText: slots.configuration ? false : '显示错误', autoSaveText: false }" />
5
+ <p class="editor-status" role="status">{{ copied ? '已复制当前文件' : '实时预览 · Vue 源码 · 自动编译' }}</p>
6
+ </div>
7
+ </template>
8
+ <script setup lang="ts">
9
+ import { computed, defineComponent, h, onBeforeUnmount, onMounted, ref, useSlots, watch, nextTick } from 'vue'
10
+ import { File, Repl, useStore } from '@vue/repl'
11
+ import CodeMirror from '@vue/repl/codemirror-editor'
12
+ import ConfigurableEditor from './ConfigurableEditor.vue'
13
+ import { AppleLink, AppleTooltip, useApple } from 'apptify'
14
+ import { Copy, RefreshCw, RotateCcw } from 'lucide-vue-next'
15
+ const props = defineProps<{ files: Record<string, string>; activeFile?: string; resetLabel?: string; baselineFiles?: Record<string, string>; previewSurface?: boolean }>()
16
+ const emit = defineEmits<{ close: []; save: [files: Record<string, string>]; restore: [] }>()
17
+ const slots = useSlots()
18
+ const configuredEditor = defineComponent({
19
+ inheritAttrs: false,
20
+ setup(_, { attrs, expose }) {
21
+ const pane = ref<InstanceType<typeof ConfigurableEditor>>()
22
+ expose({ getEditorIns: () => pane.value?.getEditorIns() })
23
+ return () => h(ConfigurableEditor, { ref: pane, editorAttrs: attrs }, { default: slots.configuration, actions: () => h(EditorActions) })
24
+ },
25
+ })
26
+ const EditorActions = () => h('div', { class: 'editor-actions', role: 'group', 'aria-label': '示例操作' }, [
27
+ { label: '重新运行', icon: RefreshCw, click: rerun },
28
+ { label: props.resetLabel || '恢复示例', icon: RotateCcw, click: restore },
29
+ { label: '复制当前文件', icon: Copy, click: copyCode },
30
+ ].map(action => {
31
+ const button = () => h(AppleLink, {
32
+ as: 'button', iconOnly: !!slots.configuration,
33
+ icon: slots.configuration ? action.icon : undefined,
34
+ label: action.label, onClick: action.click,
35
+ }, { default: () => slots.configuration ? null : action.label })
36
+ return slots.configuration ? h(AppleTooltip, { text: action.label }, { default: button }) : button()
37
+ }))
38
+ const apple = useApple()
39
+ const theme = computed(() => apple.theme.value.current.scheme === 'dark' ? 'dark' : 'light')
40
+ const repl = ref<InstanceType<typeof Repl>>()
41
+ const editorRoot = ref<HTMLElement>()
42
+ const copied = ref(false)
43
+ let copyTimer: ReturnType<typeof setTimeout> | undefined
44
+ const url = (name: string) => new URL(`${import.meta.env.BASE_URL}assets/editor/${name}`, location.origin).href
45
+ const importMap = { imports: { vue: url('vue.js'), apptify: url('runtime.js'), 'lucide-vue-next': url('runtime.js') } }
46
+ const normalize = (name: string) => name.startsWith('src/') ? name : `src/${name}`
47
+ const initialFiles: Record<string, File> = Object.fromEntries(Object.entries(props.files)
48
+ .filter(([name]) => name.endsWith('.vue'))
49
+ .map(([name, code]) => [normalize(name), new File(normalize(name), code)]))
50
+ // REPL reads the map synchronously during useStore/init and after setFiles.
51
+ // Seed it before either call, rather than allowing a missing-map error to linger.
52
+ initialFiles['import-map.json'] = new File('import-map.json', JSON.stringify(importMap), true)
53
+ const store = useStore({
54
+ files: ref(initialFiles), mainFile: ref('src/App.vue'),
55
+ template: ref({ welcomeSFC: props.files['App.vue'] || props.files['src/App.vue'], newSFC: '<template></template>' }),
56
+ activeFilename: ref('src/App.vue'),
57
+ builtinImportMap: ref(importMap), vueVersion: ref(null),
58
+ resourceLinks: ref({ esModuleShims: url('shims.js') }),
59
+ })
60
+ let disposed = false
61
+ let applying = Promise.resolve(true)
62
+ const previewOptions = computed(() => ({
63
+ headHTML: `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' blob: ${location.origin}; style-src 'unsafe-inline' blob: ${location.origin}; connect-src blob: ${location.origin}; img-src data: blob:; font-src data:; form-action 'none'; base-uri 'none'"><link rel="stylesheet" href="${url('runtime.css')}"><script>window.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); parent.postMessage({ action: 'apptify-workshop-close' }, '*') } })<\/script><style>body{margin:0;background:var(${props.previewSurface ? '--apple-surface' : '--apple-bg'});color:var(--apple-text);min-height:100vh}html{min-height:100%}</style>`,
64
+ customCode: { importCode: "import { watchEffect } from 'vue'; import { createAppleUI, themeStyle, resolveMotion } from 'apptify'", useCode: `const ui = createAppleUI(${JSON.stringify({ theme: 'preview', themes: { preview: apple.theme.value.current }, persist: false, motion: apple.motion.value.mode })}); app.use(ui); ui.attach(); document.documentElement.classList.add('apple-provider'); const stopPreviewPreferences = watchEffect(() => { for (const [key, value] of Object.entries(themeStyle(ui))) document.documentElement.style.setProperty(key, value); document.documentElement.style.colorScheme = ui.theme.value.current.scheme; document.documentElement.dataset.appleMotion = resolveMotion('inherit', ui.motion.value.mode, ui.motion.value.reduced); }); app.onUnmount(() => { stopPreviewPreferences(); ui.detach(); });` },
65
+ showRuntimeError: true, showRuntimeWarning: true,
66
+ }))
67
+ function apply(files: Record<string, string>) {
68
+ // Serialize restores so rapid clicks cannot install an older compilation last.
69
+ applying = applying.then(async () => {
70
+ if (disposed) return false
71
+ const editable = Object.keys(store.files).filter(name => name.endsWith('.vue'))
72
+ const incoming = Object.keys(files).filter(name => name.endsWith('.vue'))
73
+ if (editable.length === incoming.length && incoming.every(name => store.files[normalize(name)])) {
74
+ // Editing the existing File lets REPL compile once without rebuilding its iframe.
75
+ for (const name of incoming) store.files[normalize(name)].code = files[name]
76
+ } else {
77
+ await store.setFiles({ ...files, 'import-map.json': JSON.stringify(importMap) }, 'App.vue')
78
+ }
79
+ if (!disposed && props.activeFile && store.files[normalize(props.activeFile)]) store.setActive(normalize(props.activeFile))
80
+ return !disposed
81
+ }).catch(error => { if (!disposed) store.errors = [error instanceof Error ? error : String(error)]; return false })
82
+ return applying
83
+ }
84
+ function rerun() {
85
+ if (!repl.value) return
86
+ try { repl.value.reload(); apple.notify('正在重新运行示例', { tone: 'info' }) }
87
+ catch { apple.notify('重新运行失败,请查看编辑器中的错误', { tone: 'danger' }) }
88
+ }
89
+ async function restore() {
90
+ emit('restore')
91
+ await nextTick()
92
+ const restored = await apply(props.baselineFiles || props.files)
93
+ if (!disposed) apple.notify(restored ? (slots.configuration ? '已恢复默认配置和代码' : '已恢复示例代码') : '恢复失败,请查看编辑器中的错误', { tone: restored ? 'success' : 'danger' })
94
+ }
95
+ watch(() => props.files, () => apply(props.files))
96
+ async function copyCode() {
97
+ try { await navigator.clipboard.writeText(store.activeFile.code); apple.notify('已复制当前文件代码', { tone: 'success' }); copied.value = true; clearTimeout(copyTimer); copyTimer = setTimeout(() => copied.value = false, 2000) }
98
+ catch { apple.notify('无法访问剪贴板,请在编辑器中选择代码复制', { tone: 'warning' }) }
99
+ }
100
+ function previewMessage(event: MessageEvent) {
101
+ const frame = editorRoot.value?.querySelector<HTMLIFrameElement>('.vue-repl iframe')
102
+ if (frame && event.source === frame.contentWindow && event.data?.action === 'apptify-workshop-close') emit('close')
103
+ }
104
+ onMounted(() => {
105
+ // init() compiles the active file and skips mainFile in its remaining loop.
106
+ // Start on App.vue so the preview entry is compiled, then reveal the source.
107
+ if (props.activeFile) store.setActive(normalize(props.activeFile))
108
+ window.addEventListener('message', previewMessage)
109
+ })
110
+ onBeforeUnmount(() => { disposed = true; clearTimeout(copyTimer); window.removeEventListener('message', previewMessage); emit('save', store.getFiles()) })
111
+ </script>
112
+ <style scoped>
113
+ .has-configuration :deep(.vue-repl) { --header-height: 49px; height: 780px; }
114
+ .has-configuration :deep(.tab-buttons button) { height: 100%; display: inline-flex; align-items: center; }
115
+ .has-configuration :deep(.split-pane > .right .file-selector) { display: none; }
116
+ .has-configuration :deep(.split-pane > .right .editor-container) { height: 100%; }
117
+ .editor-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
118
+ .has-configuration .editor-actions { flex-wrap: nowrap; gap: 2px; margin: 0; }
119
+ .editor-status { margin: 8px 0; color: var(--apple-secondary); font-size: 12px; }
120
+ :deep(.vue-repl) { height: 580px; border: 1px solid var(--apple-separator); border-radius: 12px; overflow: hidden; }
121
+
122
+ @media (max-width: 720px) {
123
+ :deep(.vue-repl) { height: 740px; }
124
+ .has-configuration :deep(.vue-repl) { height: 1200px; }
125
+ .live-editor:not(.has-configuration) .editor-actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; }
126
+ .editor-actions :deep(button) { padding-inline: 8px; font-size: 12px; min-height: 36px; }
127
+ :deep(.split-pane) { display: flex !important; flex-direction: column !important; }
128
+ :deep(.split-pane > .left), :deep(.split-pane > .right) { position: relative !important; inset: auto !important; z-index: 0 !important; pointer-events: auto !important; display: block !important; height: 50% !important; width: 100% !important; }
129
+ :deep(.split-pane > .left) { height: 55% !important; }
130
+ :deep(.split-pane > .right) { height: 45% !important; }
131
+ :deep(.split-pane .toggler) { display: none !important; }
132
+ }
133
+ </style>