@givolt/i3-composer-agent 2.3.1 → 2.3.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.
Files changed (173) hide show
  1. package/.agents/plugins/marketplace.json +1 -1
  2. package/.claude/commands/i3-prototype.md +5 -7
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/.claude-plugin/plugin.json +1 -1
  5. package/.codex-plugin/plugin.json +1 -1
  6. package/.gemini/commands/i3-prototype.toml +5 -7
  7. package/README.md +16 -7
  8. package/bin/agent-targets.json +4 -2
  9. package/bin/i3-composer-agent.cjs +74 -7
  10. package/bin/install.cjs +16 -1
  11. package/bin/native-layout.cjs +1 -1
  12. package/bin/native-migration.cjs +27 -3
  13. package/bin/tool-selector.cjs +24 -7
  14. package/bin/wizard.cjs +89 -15
  15. package/commands/i3-prototype.toml +5 -7
  16. package/docs/codebuddy-setup.md +91 -3
  17. package/docs/codex-setup.md +6 -0
  18. package/docs/other-hosts.md +1 -1
  19. package/docs/ui-prototype-native/MAIN-SESSION-REPORT.md +66 -0
  20. package/docs/ui-prototype-native/USAGE.md +93 -0
  21. package/docs/workbuddy-setup.md +6 -0
  22. package/evals/cases/i3-frontend-ui-engineering.json +16 -0
  23. package/evals/cases/i3-ui-prototype.json +109 -0
  24. package/evals/cases/i3-using-agent-skills.json +28 -0
  25. package/evals/fixtures/i3-ui-prototype/admin-list/assets/reference.css +3 -0
  26. package/evals/fixtures/i3-ui-prototype/admin-list/assets/reference.js +8 -0
  27. package/evals/fixtures/i3-ui-prototype/admin-list/capability-definition.json +67 -0
  28. package/evals/fixtures/i3-ui-prototype/admin-list/index.html +89 -0
  29. package/evals/fixtures/i3-ui-prototype/admin-list/readme.md +52 -0
  30. package/evals/fixtures/i3-ui-prototype/admin-list/template.json +8 -0
  31. package/evals/zh-CN/cases/i3-frontend-ui-engineering.json +17 -0
  32. package/evals/zh-CN/cases/i3-ui-prototype.json +115 -0
  33. package/evals/zh-CN/cases/i3-using-agent-skills.json +30 -0
  34. package/hooks/codebuddy-upstream.cjs +93 -0
  35. package/hooks/i3-prototype.cjs +12 -0
  36. package/hooks/manage.cjs +162 -0
  37. package/package.json +61 -1
  38. package/plugin.json +1 -1
  39. package/scripts/design-provider-test.js +7 -0
  40. package/scripts/enterprise-template-test.js +27 -1
  41. package/scripts/lib/prototype-fixture.cjs +17 -0
  42. package/scripts/prototype-cache-test.js +55 -0
  43. package/scripts/prototype-generation-entry-test.js +6 -0
  44. package/scripts/prototype-generation-evidence-test.js +10 -0
  45. package/scripts/prototype-generation-native-test.js +78 -0
  46. package/scripts/prototype-generation-test.js +99 -0
  47. package/scripts/prototype-inventory-test.js +43 -0
  48. package/scripts/prototype-locations-test.js +79 -0
  49. package/scripts/prototype-project-test.js +183 -0
  50. package/scripts/ui-prototype-codebuddy-hook-test.js +106 -0
  51. package/scripts/ui-prototype-content-test.js +123 -0
  52. package/scripts/ui-prototype-dependencies-test.js +213 -0
  53. package/scripts/ui-prototype-devtools-test.js +53 -0
  54. package/scripts/ui-prototype-execution-evidence-test.js +62 -0
  55. package/scripts/ui-prototype-execution-gates-test.js +66 -0
  56. package/scripts/ui-prototype-execution-policy-test.js +42 -0
  57. package/scripts/ui-prototype-execution-runtime-test.js +27 -0
  58. package/scripts/ui-prototype-ide-source-test.js +73 -0
  59. package/scripts/ui-prototype-native-test.js +504 -0
  60. package/scripts/ui-prototype-registration-test.js +53 -0
  61. package/scripts/ui-prototype-template-test.js +261 -0
  62. package/scripts/ui-prototype-test.js +10 -0
  63. package/scripts/ui-prototype-transcript-auto-test.js +67 -0
  64. package/skills/i3-frontend-ui-engineering/SKILL.md +1 -1
  65. package/skills/i3-incremental-implementation/SKILL.md +1 -1
  66. package/skills/i3-ui-prototype/SKILL.md +158 -19
  67. package/skills/i3-ui-prototype/assets/capabilities/generation.json +833 -0
  68. package/skills/i3-ui-prototype/assets/capabilities/management.json +294 -0
  69. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/acceptance.json +51 -0
  70. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/component-map.json +13 -0
  71. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/generation-component-map.json +300 -0
  72. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/index.html +11 -0
  73. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/package-lock.json +2298 -0
  74. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/package.json +22 -0
  75. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/public/prototype-config.json +6 -0
  76. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/App.vue +136 -0
  77. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/components/DeviceEditor.vue +111 -0
  78. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/config.ts +19 -0
  79. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/content.ts +28 -0
  80. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/domain/devices.ts +50 -0
  81. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/domain/theme.ts +25 -0
  82. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/main.ts +18 -0
  83. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/style.css +50 -0
  84. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/useDevices.ts +66 -0
  85. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/tests/devices.test.ts +44 -0
  86. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/tests/theme.test.ts +26 -0
  87. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/tsconfig.json +15 -0
  88. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/vite.config.ts +4 -0
  89. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/profile.json +174 -0
  90. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/readme.md +61 -0
  91. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/index.html +11 -0
  92. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/package-lock.json +2298 -0
  93. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/package.json +22 -0
  94. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/public/prototype-config.json +21 -0
  95. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/App.vue +62 -0
  96. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/PrototypeShell.vue +59 -0
  97. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/config.ts +23 -0
  98. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/domain/presentation.ts +29 -0
  99. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/domain/theme.ts +25 -0
  100. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/main.ts +18 -0
  101. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/style.css +26 -0
  102. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/tests/presentation.test.ts +23 -0
  103. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/tests/theme.test.ts +26 -0
  104. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/tsconfig.json +15 -0
  105. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/vite.config.ts +4 -0
  106. package/skills/i3-ui-prototype/assets/hosts/codebuddy/profile.json +13 -0
  107. package/skills/i3-ui-prototype/assets/optional-skills/host-targets.json +739 -0
  108. package/skills/i3-ui-prototype/assets/optional-skills/sources.json +27 -0
  109. package/skills/i3-ui-prototype/assets/templates/catalog.js +149 -0
  110. package/skills/i3-ui-prototype/assets/templates/catalog.json +2 -1
  111. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/capability-definition.json +294 -0
  112. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/component-inventory.json +707 -0
  113. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/generation.json +49 -0
  114. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/index.html +507 -2
  115. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/offline-build.json +265 -0
  116. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/offline.js +8888 -0
  117. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/template-inventory.json +28791 -0
  118. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/template.json +9 -0
  119. package/skills/i3-ui-prototype/assets/templates/gallery/index.html +5 -5
  120. package/skills/i3-ui-prototype/assets/templates/generation-options.js +42 -0
  121. package/skills/i3-ui-prototype/assets/templates/index.html +3 -0
  122. package/skills/i3-ui-prototype/assets/templates/presentation.html +17 -0
  123. package/skills/i3-ui-prototype/references/artifact-contract.md +26 -1
  124. package/skills/i3-ui-prototype/references/content-template.md +81 -0
  125. package/skills/i3-ui-prototype/references/enterprise-admin.md +6 -0
  126. package/skills/i3-ui-prototype/references/entry-contract.md +71 -0
  127. package/skills/i3-ui-prototype/references/execution-evidence.md +66 -0
  128. package/skills/i3-ui-prototype/references/expert-adapter.md +33 -5
  129. package/skills/i3-ui-prototype/references/framework-contract.md +76 -0
  130. package/skills/i3-ui-prototype/references/generation-contract.md +122 -0
  131. package/skills/i3-ui-prototype/references/interaction-acceptance.md +169 -0
  132. package/skills/i3-ui-prototype/references/legacy-native.md +58 -0
  133. package/skills/i3-ui-prototype/references/optional-skills.md +63 -0
  134. package/skills/i3-ui-prototype/references/project-layout.md +92 -0
  135. package/skills/i3-ui-prototype/references/template-adapters.md +7 -1
  136. package/skills/i3-ui-prototype/references/workflow-contract.md +62 -4
  137. package/skills/i3-ui-prototype/scripts/adapters/easel-html.cjs +1 -1
  138. package/skills/i3-ui-prototype/scripts/browser-workspace.cjs +72 -0
  139. package/skills/i3-ui-prototype/scripts/codebuddy-context.cjs +75 -0
  140. package/skills/i3-ui-prototype/scripts/codebuddy-hook.cjs +157 -0
  141. package/skills/i3-ui-prototype/scripts/codebuddy-ide-source.cjs +69 -0
  142. package/skills/i3-ui-prototype/scripts/codebuddy-registration.cjs +123 -0
  143. package/skills/i3-ui-prototype/scripts/content-template.cjs +98 -0
  144. package/skills/i3-ui-prototype/scripts/enterprise-template.cjs +13 -6
  145. package/skills/i3-ui-prototype/scripts/execution-assertions.cjs +43 -0
  146. package/skills/i3-ui-prototype/scripts/execution-evidence.cjs +146 -0
  147. package/skills/i3-ui-prototype/scripts/execution-gates.cjs +147 -0
  148. package/skills/i3-ui-prototype/scripts/execution-policy.cjs +41 -0
  149. package/skills/i3-ui-prototype/scripts/execution-runtime.cjs +60 -0
  150. package/skills/i3-ui-prototype/scripts/generation-assets.cjs +18 -0
  151. package/skills/i3-ui-prototype/scripts/generation-contract.cjs +214 -0
  152. package/skills/i3-ui-prototype/scripts/generation-evidence.cjs +50 -0
  153. package/skills/i3-ui-prototype/scripts/generation-replan.cjs +34 -0
  154. package/skills/i3-ui-prototype/scripts/hosts/codebuddy.cjs +16 -0
  155. package/skills/i3-ui-prototype/scripts/manage-baseline.cjs +1 -1
  156. package/skills/i3-ui-prototype/scripts/native-browser.cjs +785 -0
  157. package/skills/i3-ui-prototype/scripts/native-build.cjs +91 -0
  158. package/skills/i3-ui-prototype/scripts/native-delivery.cjs +103 -0
  159. package/skills/i3-ui-prototype/scripts/native-devtools.cjs +92 -0
  160. package/skills/i3-ui-prototype/scripts/native-files.cjs +35 -0
  161. package/skills/i3-ui-prototype/scripts/native-pilot-lib.cjs +292 -0
  162. package/skills/i3-ui-prototype/scripts/native-pilot.cjs +50 -0
  163. package/skills/i3-ui-prototype/scripts/optional-skill-install.cjs +203 -0
  164. package/skills/i3-ui-prototype/scripts/project-registry.cjs +126 -0
  165. package/skills/i3-ui-prototype/scripts/prototype-dependencies.cjs +81 -0
  166. package/skills/i3-ui-prototype/scripts/prototype-hooks.cjs +87 -0
  167. package/skills/i3-ui-prototype/scripts/prototype-lib.cjs +31 -13
  168. package/skills/i3-ui-prototype/scripts/prototype-locations.cjs +69 -0
  169. package/skills/i3-ui-prototype/scripts/sync-template.cjs +19 -0
  170. package/skills/i3-ui-prototype/scripts/template-contract.cjs +407 -0
  171. package/skills/i3-ui-prototype/scripts/template-inventory.cjs +78 -0
  172. package/skills/i3-ui-prototype/scripts/validate-result.cjs +117 -0
  173. package/skills/i3-using-agent-skills/SKILL.md +2 -5
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "admin-list-antdv-prototype",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "packageManager": "npm@11.12.1",
7
+ "engines": { "node": ">=22.18.0", "npm": ">=10" },
8
+ "scripts": {
9
+ "dev": "vite --host 127.0.0.1",
10
+ "typecheck": "vue-tsc --noEmit",
11
+ "test": "node --experimental-strip-types --test tests/*.test.ts",
12
+ "build": "vue-tsc --noEmit && vite build",
13
+ "preview": "vite preview --host 127.0.0.1"
14
+ },
15
+ "dependencies": { "antdv-next": "1.5.3", "vue": "3.5.42" },
16
+ "devDependencies": {
17
+ "@vitejs/plugin-vue": "6.0.4",
18
+ "typescript": "5.9.3",
19
+ "vite": "7.3.1",
20
+ "vue-tsc": "3.3.13"
21
+ }
22
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "instanceId": "admin-list-preview",
3
+ "storageNamespace": "prototype:admin-list-preview",
4
+ "mockVersion": "devices-v1",
5
+ "title": "设备管理"
6
+ }
@@ -0,0 +1,136 @@
1
+ <script setup lang="ts">
2
+ import { computed, h, onBeforeUnmount, ref, watch } from 'vue'
3
+ import { Alert, Button, ConfigProvider, Empty, Input, Select, Space, Table, Tag, theme as antdTheme } from 'antdv-next'
4
+ import zhCN from 'antdv-next/locale/zh_CN'
5
+ import type { PrototypeConfig } from './config'
6
+ import type { PaginationProps } from 'antdv-next'
7
+ import { resolveContent } from './content'
8
+ import type { Device } from './domain/devices'
9
+ import DeviceEditor from './components/DeviceEditor.vue'
10
+ import { useDevices } from './useDevices'
11
+ import { THEME_IDS, THEME_PRESETS, resolveThemeConfig, safeThemeStorage, themeStorageKey, loadThemePreference, saveThemePreference } from './domain/theme'
12
+ import type { ThemeId } from './domain/theme'
13
+
14
+ const props = defineProps<{ config: PrototypeConfig }>()
15
+ const content = resolveContent(props.config)
16
+ const themeBinding = resolveThemeConfig(props.config.theme)
17
+ const preferenceStorage = themeBinding ? safeThemeStorage(() => window.localStorage) : null
18
+ const preferenceKey = themeStorageKey(props.config.storageNamespace)
19
+ const themeId = ref<ThemeId>(themeBinding ? loadThemePreference(preferenceStorage, preferenceKey) : 'light')
20
+ const themeWarning = ref('')
21
+ const themeOptions = THEME_IDS.map(value => ({ value, label: THEME_PRESETS[value].label }))
22
+ function chooseTheme(value: unknown) {
23
+ if (!themeBinding || typeof value !== 'string' || !THEME_IDS.includes(value as ThemeId)) return
24
+ themeId.value = value as ThemeId
25
+ themeWarning.value = saveThemePreference(preferenceStorage, preferenceKey, themeId.value) ? '' : '无法保存主题偏好,本页仍可切换。'
26
+ }
27
+ const documentRoot = document.documentElement
28
+ const oldTheme = documentRoot.getAttribute('data-prototype-theme')
29
+ const oldScheme = documentRoot.style.colorScheme
30
+ const previousVariables = new Map<string,string>()
31
+ watch(themeId, value => {
32
+ if (!themeBinding) return
33
+ const preset=THEME_PRESETS[value]
34
+ documentRoot.dataset.prototypeTheme=value
35
+ documentRoot.style.colorScheme=preset.mode
36
+ for (const name of ['primary','bg','surface','subtle','text','muted','border','accent','onPrimary'] as const) {
37
+ const property=`--proto-${name === 'onPrimary' ? 'on-primary' : name}`
38
+ if (!previousVariables.has(property)) previousVariables.set(property,documentRoot.style.getPropertyValue(property))
39
+ documentRoot.style.setProperty(property,preset[name])
40
+ }
41
+ }, { immediate: true })
42
+ onBeforeUnmount(() => {
43
+ if (!themeBinding) return
44
+ if (oldTheme === null) documentRoot.removeAttribute('data-prototype-theme'); else documentRoot.setAttribute('data-prototype-theme',oldTheme)
45
+ documentRoot.style.colorScheme=oldScheme
46
+ for (const [property,value] of previousVariables) { if (value) documentRoot.style.setProperty(property,value); else documentRoot.style.removeProperty(property) }
47
+ })
48
+ const { devices, nameInput, statusInput, page, selected, mode, notice, storageWarning, filtered, activeCount, query, changePage, bulkDisable, save, setMode, resetData } = useDevices(props.config)
49
+ const editing = ref<Device | null>(null)
50
+ const editorOpen = ref(false)
51
+ const lifecycle = ref('closed')
52
+ let opener: HTMLElement | null = null
53
+ const media = window.matchMedia('(prefers-reduced-motion: reduce)')
54
+ const reducedMotion = ref(media.matches)
55
+ const updateMotion = (event: MediaQueryListEvent) => { reducedMotion.value = event.matches }
56
+ media.addEventListener('change', updateMotion)
57
+ onBeforeUnmount(() => media.removeEventListener('change', updateMotion))
58
+ const theme = computed(() => {
59
+ const base={ colorPrimary: '#176b56', borderRadius: 6, fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif', motion: !reducedMotion.value }
60
+ if (!themeBinding) return { token: base }
61
+ const preset=THEME_PRESETS[themeId.value]
62
+ return { algorithm: preset.mode === 'dark' ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm, token: { ...base, colorPrimary:preset.primary,colorBgBase:preset.bg,colorBgLayout:preset.bg,colorBgContainer:preset.surface,colorBgElevated:preset.surface,colorText:preset.text,colorTextSecondary:preset.muted,colorTextTertiary:preset.muted,colorTextQuaternary:preset.muted,colorTextDescription:preset.muted,colorTextPlaceholder:preset.muted,colorBorder:preset.border,colorBorderSecondary:preset.border,colorFillAlter:preset.subtle,controlItemBgActive:preset.subtle,controlItemBgActiveHover:preset.subtle,controlItemBgHover:preset.subtle,colorErrorText:preset.error,colorErrorTextHover:preset.error,colorErrorTextActive:preset.error,colorTextLightSolid:preset.onPrimary }, components:{Button:{colorLink:preset.accent,colorLinkHover:preset.accent,colorLinkActive:preset.accent},Tag:{colorSuccess:preset.success,colorSuccessBg:preset.subtle,colorSuccessBorder:preset.border},Form:{colorError:preset.error},Table:{headerBg:preset.subtle,rowHoverBg:preset.subtle},Select:{optionSelectedBg:preset.subtle,optionActiveBg:preset.subtle}} }
63
+ })
64
+ const columns = [
65
+ { title: content.fields.code.label, dataIndex: 'code', key: 'code', width: 140 },
66
+ { title: content.fields.name.label, dataIndex: 'name', key: 'name', ellipsis: true },
67
+ { title: content.fields.category.label, dataIndex: 'category', key: 'category', width: 150 },
68
+ { title: content.fields.status.label, dataIndex: 'status', key: 'status', width: 120 },
69
+ { title: '操作', key: 'action', width: 100 },
70
+ ]
71
+ const rowSelection = computed(() => ({ selectedRowKeys: selected.value, onChange: (keys: (string | number)[]) => { selected.value = keys.map(String) }, preserveSelectedRowKeys: false }))
72
+ const renderPaginationItem: PaginationProps['itemRender'] = (_page, type, element) => type === 'page' ? h('span', { 'aria-hidden': 'true' }, String(_page)) : type === 'prev' || type === 'next' ? h('button', { type: 'button', tabIndex: -1, class: 'ant-pagination-item-link' }, type === 'prev' ? content.actions.previousPage : content.actions.nextPage) : element
73
+ const pagination = computed(() => ({ current: page.value, pageSize: 10, total: filtered.value.length, showSizeChanger: false, showTotal: (total: number) => `共 ${total} 条`, itemRender: renderPaginationItem }))
74
+ const statuses = [{ value: 'all', label: '全部状态' }, { value: 'active', label: '运行中' }, { value: 'inactive', label: '已停用' }]
75
+
76
+ function edit(device: Device, event: MouseEvent) {
77
+ opener = event.currentTarget as HTMLElement
78
+ editing.value = { ...device }
79
+ lifecycle.value = 'opening'
80
+ editorOpen.value = true
81
+ }
82
+ function closeEditor() {
83
+ lifecycle.value = 'closing'
84
+ editorOpen.value = false
85
+ }
86
+ function editorClosed() {
87
+ lifecycle.value = 'closed'
88
+ editing.value = null
89
+ if (opener?.isConnected) opener.focus()
90
+ else document.querySelector<HTMLButtonElement>('[data-testid="query"]')?.focus()
91
+ }
92
+ function saved(device: Device) { save(device); closeEditor() }
93
+ </script>
94
+
95
+ <template>
96
+ <ConfigProvider :locale="zhCN" :theme="theme">
97
+ <div class="app-shell" :data-editor-lifecycle="lifecycle" :data-reduced-motion="reducedMotion">
98
+ <header class="app-header">
99
+ <a class="brand" href="./" aria-label="设备管理首页"><span class="brand-mark" aria-hidden="true">E</span><span>设备工作台</span></a>
100
+ <div class="header-actions"><span class="preview-label">交互原型 · 本地演示</span><div v-if="themeBinding" class="theme-control"><label for="theme-picker-main-input">主题</label><Select id="theme-picker-main-input" :value="themeId" :options="themeOptions" data-testid="theme-picker-main" @change="chooseTheme" /></div></div>
101
+ </header>
102
+ <main>
103
+ <Alert v-if="themeWarning" type="warning" :title="themeWarning" role="status" data-testid="theme-storage-warning" />
104
+ <div class="page-heading">
105
+ <div><p class="eyebrow">设备运营 / 基础信息</p><h1>{{ content.pageTitle }}</h1><p class="page-description">{{ content.description }}</p></div>
106
+ <div class="summary" aria-label="设备概览"><strong>{{ devices.length }}</strong><span>台设备</span><span class="summary-divider"></span><strong>{{ activeCount }}</strong><span>运行中</span></div>
107
+ </div>
108
+
109
+ <section class="list-panel" aria-label="设备清单" data-page-id="devices" data-region-id="device-list">
110
+ <form class="filter-row" @submit.prevent="query()" aria-label="筛选设备">
111
+ <div class="filter-field"><label for="name-filter">{{ content.fields.queryName.label }}</label><Input id="name-filter" v-model:value="nameInput" allow-clear :placeholder="content.fields.queryName.placeholder" data-testid="name-filter" /></div>
112
+ <div class="filter-field status-filter"><label for="status-filter-input">{{ content.fields.queryStatus.label }}</label><Select id="status-filter-input" v-model:value="statusInput" :options="statuses" data-testid="status-filter" /></div>
113
+ <Space class="filter-actions"><Button type="primary" html-type="submit" data-testid="query">{{ content.actions.query }}</Button><Button @click="query(true)" data-testid="reset-filter">{{ content.actions.resetQuery }}</Button></Space>
114
+ </form>
115
+ <div class="list-toolbar">
116
+ <div><h2>{{ content.listTitle }}</h2><p class="selection-count" role="status" aria-live="polite" aria-atomic="true" data-testid="selection-count">已选择 {{ selected.length }} 项 <span>· 翻页或筛选后清空选择</span></p></div>
117
+ <Button :disabled="!selected.length || mode !== 'normal'" @click="bulkDisable" data-testid="bulk-disable">{{ content.actions.bulkDisable }}</Button>
118
+ </div>
119
+ <Alert v-if="notice" type="success" :title="notice" show-icon role="status" data-testid="success-notice" class="list-notice" />
120
+ <Alert v-if="storageWarning" type="warning" :title="storageWarning" show-icon class="list-notice" />
121
+ <div v-if="mode === 'error'" class="error-state" role="alert" data-testid="list-error"><h3>设备列表加载失败</h3><p>这是可恢复的演示错误,设备数据未被修改。</p><Button @click="setMode('normal')" data-testid="retry-list">重新加载</Button></div>
122
+ <Table v-else :columns="columns" :data-source="filtered" row-key="id" :pagination="pagination" :row-selection="rowSelection" :loading="mode === 'loading'" :scroll="{ x: 740 }" @change="changePage" data-testid="device-table">
123
+ <template #bodyCell="{ column, record }">
124
+ <template v-if="column.key === 'status'"><Tag :color="record.status === 'active' ? (themeBinding ? 'success' : 'green') : 'default'">{{ record.status === 'active' ? '运行中' : '已停用' }}</Tag></template>
125
+ <template v-else-if="column.key === 'action'"><Button type="link" size="small" :aria-label="`${content.actions.edit} ${record.code}`" :data-testid="`edit-${record.id}`" @click="edit(record, $event)">{{ content.actions.edit }}</Button></template>
126
+ </template>
127
+ <template #emptyText><Empty description="暂无符合条件的设备"><Button @click="query(true)" data-testid="clear-empty">清除筛选</Button></Empty></template>
128
+ </Table>
129
+ </section>
130
+
131
+ <details class="demo-tools"><summary>演示状态与数据</summary><p>演示工具仅影响当前原型实例。恢复初始数据会清除本实例的修改。</p><Space wrap><Button @click="setMode('loading')" data-testid="show-loading">加载中</Button><Button @click="setMode('empty')" data-testid="show-empty">空列表</Button><Button @click="setMode('error')" data-testid="show-error">加载失败</Button><Button @click="setMode('normal')" data-testid="restore-list">恢复列表</Button><Button @click="resetData" data-testid="reset-data">恢复初始数据</Button></Space></details>
132
+ </main>
133
+ <DeviceEditor :open="editorOpen" :device="editing" :content="content" :theme-value="themeBinding ? themeId : undefined" :theme-options="themeOptions" @theme="chooseTheme" @close="closeEditor" @closed="editorClosed" @opened="lifecycle = 'open'" @saved="saved" />
134
+ </div>
135
+ </ConfigProvider>
136
+ </template>
@@ -0,0 +1,111 @@
1
+ <script setup lang="ts">
2
+ import { nextTick, reactive, ref, watch } from 'vue'
3
+ import { Alert, Button, Drawer, Form, FormItem, Input, Select, Space, Switch } from 'antdv-next'
4
+ import type { FormInstance } from 'antdv-next'
5
+ import type { Device, DeviceStatus } from '../domain/devices'
6
+ import type { PrototypeContent } from '../content'
7
+ import type { ThemeId } from '../domain/theme'
8
+
9
+ const props = defineProps<{ open: boolean; device: Device | null; content: PrototypeContent; themeValue?: ThemeId; themeOptions?: {value:ThemeId;label:string}[] }>()
10
+ const emit = defineEmits<{ close: []; closed: []; saved: [device: Device]; opened: []; theme: [value:ThemeId] }>()
11
+ function chooseTheme(value: unknown) { if (typeof value === 'string' && props.themeOptions?.some(option => option.value === value)) emit('theme',value as ThemeId) }
12
+ const form = ref<FormInstance>()
13
+ const values = reactive({ name: '', category: '', status: 'active' as DeviceStatus })
14
+ const saving = ref(false)
15
+ const failNext = ref(false)
16
+ const error = ref('')
17
+ const rules = { name: [{ required: true, whitespace: true, message: `请输入${props.content.fields.editName.label}` }], category: [{ required: true, message: `请选择${props.content.fields.editCategory.label}` }] }
18
+ const categories = ['温度传感器', '控制器', '压力传感器'].map(value => ({ value, label: value }))
19
+ const statuses = [{ value: 'active', label: '运行中' }, { value: 'inactive', label: '已停用' }]
20
+
21
+ watch(() => props.device, device => {
22
+ if (!device) return
23
+ Object.assign(values, { name: device.name, category: device.category, status: device.status })
24
+ error.value = ''
25
+ saving.value = false
26
+ failNext.value = false
27
+ nextTick(() => form.value?.clearValidate())
28
+ })
29
+
30
+ async function submit() {
31
+ if (saving.value || !props.device) return
32
+ try { await form.value?.validateFields() } catch {
33
+ await nextTick()
34
+ document.querySelector<HTMLInputElement>('.prototype-device-editor input[aria-invalid="true"]')?.focus()
35
+ return
36
+ }
37
+ saving.value = true
38
+ error.value = ''
39
+ await new Promise(resolve => setTimeout(resolve, 450))
40
+ if (failNext.value) {
41
+ failNext.value = false
42
+ saving.value = false
43
+ error.value = '保存失败,已保留输入。请重试。'
44
+ return
45
+ }
46
+ emit('saved', { ...props.device, ...values, name: values.name.trim() })
47
+ saving.value = false
48
+ }
49
+
50
+ async function afterOpenChange(open: boolean) {
51
+ if (open) {
52
+ await nextTick()
53
+ document.querySelector<HTMLInputElement>('.prototype-device-editor input[data-testid="edit-name"]')?.focus()
54
+ emit('opened')
55
+ } else emit('closed')
56
+ }
57
+ function close() { if (!saving.value) emit('close') }
58
+ function keepFocus(event: KeyboardEvent) {
59
+ if (event.key !== 'Tab') return
60
+ const dialog = document.querySelector('.prototype-device-editor [role="dialog"]')
61
+ const controls = [...(dialog?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])') ?? [])].filter(element => element.offsetParent !== null && element.tabIndex >= 0)
62
+ const first = controls[0], last = controls.at(-1)
63
+ if (!first || !last) return
64
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
65
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
66
+ }
67
+ </script>
68
+
69
+ <template>
70
+ <Drawer
71
+ :open="open"
72
+ :title="`${content.editorTitle} · ${content.fields.editCode.label} ${device?.code ?? ''}`"
73
+ placement="right"
74
+ :size="520"
75
+ :mask="{ enabled: true, blur: true, closable: !saving }"
76
+ :destroy-on-hidden="true"
77
+ :closable="{ disabled: saving }"
78
+ root-class="prototype-device-editor"
79
+ :after-open-change="afterOpenChange"
80
+ @close="close"
81
+ @keydown="keepFocus"
82
+ >
83
+ <div data-testid="editor-content">
84
+ <div v-if="themeValue" class="theme-control editor-theme-control"><label for="theme-picker-editor-input">主题</label><Select id="theme-picker-editor-input" :value="themeValue" :options="themeOptions" data-testid="theme-picker-editor" @change="chooseTheme" /></div>
85
+ <p class="editor-intro">更新设备基础信息。保存后,列表会同步最新状态。</p>
86
+ <Alert v-if="error" type="error" show-icon :title="error" role="alert" data-testid="save-error" class="editor-error" />
87
+ <Form ref="form" name="device-editor" :model="values" :rules="rules" layout="vertical" @finish="submit">
88
+ <FormItem :label="content.fields.editName.label" name="name">
89
+ <Input v-model:value="values.name" data-testid="edit-name" :maxlength="60" :disabled="saving" :placeholder="content.fields.editName.placeholder ?? `请输入${content.fields.editName.label}`" />
90
+ </FormItem>
91
+ <FormItem :label="content.fields.editCategory.label" name="category">
92
+ <Select v-model:value="values.category" :options="categories" :disabled="saving" data-testid="edit-category" />
93
+ </FormItem>
94
+ <FormItem :label="content.fields.editStatus.label" name="status">
95
+ <Select v-model:value="values.status" :options="statuses" :disabled="saving" data-testid="edit-status" />
96
+ </FormItem>
97
+ </Form>
98
+ <div class="failure-control">
99
+ <Switch v-model:checked="failNext" :disabled="saving" aria-label="下次保存模拟失败" data-testid="fail-next-save" />
100
+ <span>下次保存模拟失败</span>
101
+ </div>
102
+ <p class="help-text">仅用于演示失败后保留输入和重试。</p>
103
+ </div>
104
+ <template #footer>
105
+ <Space class="editor-footer">
106
+ <Button :disabled="saving" @click="close" data-testid="cancel-edit">{{ content.actions.cancel }}</Button>
107
+ <Button type="primary" :loading="saving" @click="submit" data-testid="save-device">{{ content.actions.save }}</Button>
108
+ </Space>
109
+ </template>
110
+ </Drawer>
111
+ </template>
@@ -0,0 +1,19 @@
1
+ import type { PrototypeContent } from './content'
2
+ import { resolveThemeConfig } from './domain/theme'
3
+ import type { ThemeBinding } from './domain/theme'
4
+ export interface PrototypeConfig { instanceId: string; storageNamespace: string; mockVersion: string; title?: string; content?: PrototypeContent; theme?: ThemeBinding }
5
+ export interface PrototypeMeta extends PrototypeConfig { candidateId: string }
6
+ declare global { interface Window { __PROTOTYPE_META__: PrototypeMeta } }
7
+
8
+ export async function loadConfig(): Promise<PrototypeConfig> {
9
+ const response = await fetch(`${import.meta.env.BASE_URL}prototype-config.json`, { cache: 'no-store' })
10
+ if (!response.ok) throw new Error('无法读取原型配置,请检查当前预览构建。')
11
+ const config: unknown = await response.json()
12
+ if (!config || typeof config !== 'object') throw new Error('原型配置无效。')
13
+ const value = config as Record<string, unknown>
14
+ for (const key of ['instanceId', 'storageNamespace', 'mockVersion']) {
15
+ if (typeof value[key] !== 'string' || !(value[key] as string).trim()) throw new Error(`原型配置缺少 ${key}。`)
16
+ }
17
+ resolveThemeConfig(value.theme)
18
+ return value as unknown as PrototypeConfig
19
+ }
@@ -0,0 +1,28 @@
1
+ import type { PrototypeConfig } from './config'
2
+
3
+ export interface PrototypeContent {
4
+ schemaVersion: 1
5
+ templateDigest: string
6
+ pageTitle: string
7
+ description: string
8
+ listTitle: string
9
+ editorTitle: string
10
+ fields: Record<'code' | 'name' | 'category' | 'status' | 'queryName' | 'queryStatus' | 'editCode' | 'editName' | 'editCategory' | 'editStatus', { label: string; placeholder?: string; required?: boolean }>
11
+ actions: Record<'query' | 'resetQuery' | 'bulkDisable' | 'edit' | 'save' | 'cancel' | 'previousPage' | 'nextPage', string>
12
+ }
13
+
14
+ const defaults: PrototypeContent = {
15
+ schemaVersion: 1, templateDigest: 'legacy-starter-defaults', pageTitle: '设备管理', description: '查看设备信息、调整运行状态,并在当前页面完成编辑。', listTitle: '设备清单', editorTitle: '编辑设备',
16
+ fields: {
17
+ code: { label: '设备编号' }, name: { label: '设备名称' }, category: { label: '分类' }, status: { label: '状态' },
18
+ queryName: { label: '设备名称', placeholder: '搜索设备名称' }, queryStatus: { label: '运行状态' },
19
+ editCode: { label: '设备编号' }, editName: { label: '设备名称', required: true }, editCategory: { label: '设备分类' }, editStatus: { label: '运行状态' },
20
+ },
21
+ actions: { query: '查询', resetQuery: '重置', bulkDisable: '批量停用', edit: '编辑', save: '保存', cancel: '取消', previousPage: '上一页', nextPage: '下一页' },
22
+ }
23
+
24
+ export function resolveContent(config: PrototypeConfig): PrototypeContent {
25
+ // v2 instances created before projection retain their fixed original content.
26
+ // New templates are validated by the projector before instance creation.
27
+ return config.content ?? { ...defaults, pageTitle: config.title || defaults.pageTitle }
28
+ }
@@ -0,0 +1,50 @@
1
+ export type DeviceStatus = 'active' | 'inactive'
2
+ export interface Device { id: string; code: string; name: string; category: string; status: DeviceStatus }
3
+ interface StorageReader { getItem(key: string): string | null }
4
+ interface StorageWriter { setItem(key: string, value: string): void }
5
+
6
+ export function createSeed(): Device[] {
7
+ return Array.from({ length: 21 }, (_, index) => ({
8
+ id: `device-${index + 1}`,
9
+ code: `DEV-${String(index + 1).padStart(3, '0')}`,
10
+ name: `${index < 10 ? '北区' : index < 20 ? '南区' : '中心'}设备 ${String(index + 1).padStart(2, '0')}`,
11
+ category: ['温度传感器', '控制器', '压力传感器'][index % 3]!,
12
+ status: index % 4 === 3 ? 'inactive' : 'active',
13
+ }))
14
+ }
15
+
16
+ export function filterDevices(devices: Device[], name: string, status: DeviceStatus | 'all') {
17
+ const keyword = name.trim().toLocaleLowerCase()
18
+ return devices.filter(item => (!keyword || item.name.toLocaleLowerCase().includes(keyword)) && (status === 'all' || item.status === status))
19
+ }
20
+
21
+ export function updateDevices(devices: Device[], ids: string[], patch: Partial<Pick<Device, 'name' | 'category' | 'status'>>) {
22
+ const selected = new Set(ids)
23
+ return devices.map(item => selected.has(item.id) ? { ...item, ...patch } : item)
24
+ }
25
+
26
+ export function storageKey(namespace: string, version: string) {
27
+ if (!namespace.trim() || !version.trim()) throw new Error('storage namespace and mock version are required')
28
+ return `${namespace}:${version}:devices`
29
+ }
30
+
31
+ export function loadDevices(storage: StorageReader, key: string): Device[] {
32
+ try {
33
+ const raw = storage.getItem(key)
34
+ if (!raw) return createSeed()
35
+ const parsed: unknown = JSON.parse(raw)
36
+ if (!Array.isArray(parsed) || parsed.length !== 21 || !parsed.every(isDevice) || new Set(parsed.map(item => item.id)).size !== 21) return createSeed()
37
+ const expected = new Set(createSeed().map(item => item.id))
38
+ return parsed.every(item => expected.has(item.id)) ? parsed : createSeed()
39
+ } catch { return createSeed() }
40
+ }
41
+
42
+ function isDevice(item: unknown): item is Device {
43
+ if (!item || typeof item !== 'object') return false
44
+ const value = item as Record<string, unknown>
45
+ return ['id', 'code', 'name', 'category'].every(key => typeof value[key] === 'string') && (value.status === 'active' || value.status === 'inactive')
46
+ }
47
+
48
+ export function persistDevices(storage: StorageWriter, key: string, devices: Device[]) {
49
+ storage.setItem(key, JSON.stringify(devices))
50
+ }
@@ -0,0 +1,25 @@
1
+ export type ThemeId = 'light' | 'dark' | 'fresh' | 'tech'
2
+ export interface ThemeBinding { defaultTheme: 'light'; presets: ThemeId[] }
3
+ export interface ThemeStorage { getItem(key: string): string | null; setItem(key: string, value: string): void }
4
+ export const THEME_IDS: ThemeId[] = ['light', 'dark', 'fresh', 'tech']
5
+ export const THEME_PRESETS = {
6
+ light: { label: '明亮', mode: 'light', primary: '#155bc2', bg: '#f4f7fc', surface: '#ffffff', subtle: '#f8faff', text: '#172b45', muted: '#506174', border: '#b9c8da', accent: '#155bc2', onPrimary: '#ffffff', error: '#b4232c', success: '#166534' },
7
+ dark: { label: '暗黑', mode: 'dark', primary: '#8fb8ff', bg: '#15181d', surface: '#20242b', subtle: '#1b2028', text: '#edf1f7', muted: '#b2bece', border: '#667387', accent: '#a6c7ff', onPrimary: '#101c2c', error: '#ffb4ab', success: '#9bddad' },
8
+ fresh: { label: '清新', mode: 'light', primary: '#0c6a55', bg: '#edf8f4', surface: '#fbfffd', subtle: '#e6f4ed', text: '#173b31', muted: '#4c665c', border: '#a4c4b5', accent: '#0c6a55', onPrimary: '#ffffff', error: '#b4232c', success: '#166534' },
9
+ tech: { label: '科技', mode: 'dark', primary: '#55ddc6', bg: '#07131f', surface: '#102536', subtle: '#0b1c2b', text: '#e1f4f7', muted: '#a0bdca', border: '#5b8294', accent: '#55ddc6', onPrimary: '#071b20', error: '#ffb4ab', success: '#9bddad' },
10
+ } as const
11
+ export function resolveThemeConfig(value: unknown): ThemeBinding | null {
12
+ if (value === undefined) return null
13
+ const config=value as Partial<ThemeBinding> | null
14
+ if (!config || config.defaultTheme !== 'light' || !Array.isArray(config.presets) || config.presets.length !== 4 || new Set(config.presets).size !== 4 || !THEME_IDS.every(id => config.presets!.includes(id))) throw new Error('主题配置必须包含明亮、暗黑、清新、科技且默认明亮。')
15
+ return { defaultTheme: 'light', presets: [...THEME_IDS] }
16
+ }
17
+ export const themeStorageKey = (namespace: string) => `${namespace}:ui-theme:v1`
18
+ export function safeThemeStorage(access: () => ThemeStorage): ThemeStorage | null { try { return access() } catch { return null } }
19
+ export function loadThemePreference(storage: ThemeStorage | null, key: string): ThemeId {
20
+ try { const value=JSON.parse(storage?.getItem(key) ?? 'null'); if (value?.version===1 && THEME_IDS.includes(value.themeId)) return value.themeId } catch { /* Damaged or denied storage uses the explicit default. */ }
21
+ return 'light'
22
+ }
23
+ export function saveThemePreference(storage: ThemeStorage | null, key: string, themeId: ThemeId): boolean {
24
+ try { if (!storage) return false; storage.setItem(key,JSON.stringify({version:1,themeId})); return true } catch { return false }
25
+ }
@@ -0,0 +1,18 @@
1
+ import { createApp } from 'vue'
2
+ import 'antdv-next/dist/reset.css'
3
+ import App from './App.vue'
4
+ import { loadConfig } from './config'
5
+ import './style.css'
6
+
7
+ loadConfig().then(config => {
8
+ window.__PROTOTYPE_META__ = { ...config, candidateId: import.meta.env.VITE_CANDIDATE_ID || 'unverified-development' }
9
+ createApp(App, { config }).mount('#app')
10
+ }).catch(error => {
11
+ const container = document.querySelector('#app')!
12
+ const heading = document.createElement('h1')
13
+ heading.textContent = '原型无法启动'
14
+ const message = document.createElement('p')
15
+ message.textContent = error instanceof Error ? error.message : '配置读取失败。'
16
+ container.setAttribute('role', 'alert')
17
+ container.append(heading, message)
18
+ })
@@ -0,0 +1,50 @@
1
+ :root { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; color: var(--proto-text, #1c2b29); background: var(--proto-bg, #f3f6f5); font-synthesis: none; text-rendering: optimizeLegibility; }
2
+ * { box-sizing: border-box; }
3
+ body { margin: 0; min-width: 320px; }
4
+ button, input { font: inherit; }
5
+ .app-header { height: 64px; background: var(--proto-surface, #fff); border-bottom: 1px solid var(--proto-border, #e1e8e5); display: flex; align-items: center; justify-content: space-between; padding: 0 40px; }
6
+ .brand { display: inline-flex; align-items: center; gap: 12px; color: var(--proto-text, #213c34); font-size: 16px; font-weight: 600; text-decoration: none; }
7
+ .brand-mark { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 6px; background: var(--proto-primary, #176b56); color: var(--proto-on-primary, #fff); font-weight: 700; }
8
+ .preview-label, .help-text { font-size: 12px; color: var(--proto-muted, #66776f); }
9
+ main { max-width: 1360px; margin: auto; padding: 32px 40px 48px; }
10
+ .page-heading { display: flex; justify-content: space-between; gap: 24px; align-items: flex-end; margin-bottom: 24px; }
11
+ .eyebrow { color: var(--proto-muted, #63776e); font-size: 12px; margin: 0 0 8px; }
12
+ h1 { font-size: 28px; line-height: 1.35; letter-spacing: -.5px; margin: 0; font-weight: 650; }
13
+ .page-description { margin: 10px 0 0; color: var(--proto-muted, #63726c); font-size: 14px; }
14
+ .summary { white-space: nowrap; display: flex; gap: 8px; align-items: baseline; color: var(--proto-muted, #63726c); font-size: 12px; padding-bottom: 4px; }
15
+ .summary strong { color: var(--proto-accent, #264d3f); font-size: 24px; font-weight: 600; }
16
+ .summary-divider { height: 16px; width: 1px; background: var(--proto-border, #d8e2dd); margin: 0 8px; }
17
+ .list-panel { background: var(--proto-surface, #fff); border: 1px solid var(--proto-border, #dfe7e2); border-radius: 8px; overflow: hidden; }
18
+ .filter-row { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-end; padding: 24px; border-bottom: 1px solid var(--proto-border, #edf0ee); background: var(--proto-subtle, #fdfefd); }
19
+ .filter-field { display: flex; flex-direction: column; gap: 8px; width: 260px; }
20
+ .filter-field label { font-size: 13px; color: var(--proto-muted, #46594f); }
21
+ .status-filter { width: 170px; }
22
+ .filter-actions { margin-left: auto; }
23
+ .list-toolbar { display: flex; justify-content: space-between; align-items: center; padding: 20px 24px; gap: 12px; }
24
+ h2 { margin: 0; font-size: 16px; font-weight: 600; }
25
+ .selection-count { margin: 6px 0 0; font-size: 12px; color: var(--proto-muted, #536c5e); }
26
+ .selection-count span { color: var(--proto-muted, #718078); }
27
+ .list-notice { margin: 0 24px 16px; }
28
+ .ant-table-wrapper .ant-table-pagination { margin: 16px 24px; }
29
+ .ant-pagination .ant-pagination-prev, .ant-pagination .ant-pagination-next { width: auto; min-width: 64px; padding: 0 8px; }
30
+ .error-state { padding: 64px 24px; text-align: center; color: var(--proto-muted, #63726c); }
31
+ .error-state h3 { color: var(--proto-text, #283f33); font-size: 16px; }
32
+ .demo-tools { margin-top: 24px; color: var(--proto-muted, #66776f); font-size: 12px; }
33
+ .demo-tools summary { display: list-item; width: fit-content; cursor: pointer; color: var(--proto-muted, #53675d); padding: 8px 0; }
34
+ .demo-tools p { margin: 8px 0 12px; }
35
+ .editor-intro { margin: 0 0 24px; color: var(--proto-muted, #68796e); font-size: 13px; line-height: 1.7; }
36
+ .editor-error { margin-bottom: 20px; }
37
+ .editor-footer { display: flex; justify-content: flex-end; }
38
+ .failure-control { display: flex; gap: 8px; align-items: center; margin-top: 32px; font-size: 12px; color: var(--proto-muted, #68796e); }
39
+ .help-text { margin-top: 8px; }
40
+ :focus-visible { outline: 2px solid var(--proto-primary, #176b56); outline-offset: 3px; }
41
+ @media (max-width: 767px) { .app-header { padding: 0 16px; } main { padding: 24px 16px; } .page-heading { align-items: flex-start; flex-direction: column; gap: 16px; } h1 { font-size: 24px; } .preview-label { font-size: 11px; } .filter-row { padding: 16px; gap: 12px; } .filter-field { width: 100%; } .filter-actions { margin-left: 0; } .list-toolbar { padding: 16px; align-items: flex-start; } .selection-count span { display: block; margin-top: 4px; } .list-notice { margin: 0 16px 16px; } .ant-table-wrapper .ant-table-pagination { margin: 16px; } }
42
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; } }
43
+
44
+ .app-shell { min-height: 100vh; background: var(--proto-bg, #f3f6f5); color: var(--proto-text, #1c2b29); }
45
+ .header-actions, .theme-control { display: flex; align-items: center; gap: 8px; }
46
+ .header-actions { gap: 16px; }
47
+ .theme-control label { color: var(--proto-muted, #66776f); font-size: 12px; white-space: nowrap; }
48
+ .theme-control .ant-select { width: 104px; }
49
+ .editor-theme-control { justify-content: flex-end; margin-bottom: 20px; }
50
+ @media (max-width: 767px) { html[data-prototype-theme] .header-actions .preview-label { display: none; } }
@@ -0,0 +1,66 @@
1
+ import { computed, ref } from 'vue'
2
+ import { createSeed, filterDevices, loadDevices, persistDevices, storageKey, updateDevices } from './domain/devices'
3
+ import type { Device, DeviceStatus } from './domain/devices'
4
+ import type { PrototypeConfig } from './config'
5
+
6
+ export function useDevices(config: PrototypeConfig) {
7
+ const key = storageKey(config.storageNamespace, config.mockVersion)
8
+ const storage = (() => { try { return window.localStorage } catch { return { getItem: () => null, setItem: () => { throw new Error('Storage unavailable') } } } })()
9
+ const devices = ref(loadDevices(storage, key))
10
+ const nameInput = ref('')
11
+ const statusInput = ref<DeviceStatus | 'all'>('all')
12
+ const filters = ref({ name: '', status: 'all' as DeviceStatus | 'all' })
13
+ const page = ref(1)
14
+ const selected = ref<string[]>([])
15
+ const mode = ref<'normal' | 'loading' | 'empty' | 'error'>('normal')
16
+ const notice = ref('')
17
+ const storageWarning = ref('')
18
+ const filtered = computed(() => mode.value === 'empty' ? [] : filterDevices(devices.value, filters.value.name, filters.value.status))
19
+ const activeCount = computed(() => devices.value.filter(item => item.status === 'active').length)
20
+
21
+ function persist(next: Device[]) {
22
+ devices.value = next
23
+ try {
24
+ persistDevices(storage, key, next)
25
+ storageWarning.value = ''
26
+ } catch { storageWarning.value = '浏览器未允许本地存储,本次修改仍在当前页面保留。' }
27
+ }
28
+ function query(reset = false) {
29
+ if (reset) { nameInput.value = ''; statusInput.value = 'all' }
30
+ filters.value = { name: nameInput.value, status: statusInput.value }
31
+ page.value = 1
32
+ selected.value = []
33
+ mode.value = 'normal'
34
+ notice.value = ''
35
+ }
36
+ function changePage(pagination: { current?: number }) {
37
+ page.value = pagination.current ?? 1
38
+ selected.value = []
39
+ }
40
+ function bulkDisable() {
41
+ const ids = [...selected.value]
42
+ if (!ids.length) return
43
+ persist(updateDevices(devices.value, ids, { status: 'inactive' }))
44
+ selected.value = []
45
+ page.value = 1
46
+ notice.value = `已停用 ${ids.length} 台设备。`
47
+ }
48
+ function save(device: Device) {
49
+ persist(updateDevices(devices.value, [device.id], { name: device.name.trim(), category: device.category, status: device.status }))
50
+ selected.value = []
51
+ page.value = 1
52
+ notice.value = `设备 ${device.code} 已保存。`
53
+ }
54
+ function setMode(next: typeof mode.value) {
55
+ mode.value = next
56
+ page.value = 1
57
+ selected.value = []
58
+ notice.value = ''
59
+ }
60
+ function resetData() {
61
+ persist(createSeed())
62
+ query(true)
63
+ notice.value = '已恢复 21 条演示数据。'
64
+ }
65
+ return { devices, nameInput, statusInput, page, selected, mode, notice, storageWarning, filtered, activeCount, query, changePage, bulkDisable, save, setMode, resetData }
66
+ }
@@ -0,0 +1,44 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { createSeed, filterDevices, updateDevices, loadDevices, persistDevices, storageKey } from '../src/domain/devices.ts'
4
+
5
+ test('seed has 21 stable unique records and filters compose', () => {
6
+ const seed = createSeed()
7
+ assert.equal(seed.length, 21)
8
+ assert.equal(new Set(seed.map(item => item.id)).size, 21)
9
+ assert.equal(seed[0]?.code, 'DEV-001')
10
+ assert.deepEqual(createSeed(), seed)
11
+ const result = filterDevices(seed, ' 设备 ', 'inactive')
12
+ assert.ok(result.length > 0 && result.length < seed.length)
13
+ assert.ok(result.every(item => item.status === 'inactive' && item.name.includes('设备')))
14
+ assert.equal(filterDevices(seed, '不存在', 'all').length, 0)
15
+ })
16
+
17
+ test('bulk mutation touches selected identities only, without mutating prior state', () => {
18
+ const seed = createSeed()
19
+ const updated = updateDevices(seed, ['device-1', 'device-3'], { status: 'inactive' })
20
+ assert.equal(updated[0]?.status, 'inactive')
21
+ assert.equal(updated[2]?.status, 'inactive')
22
+ assert.deepEqual(updated[1], seed[1])
23
+ assert.equal(seed[0]?.status, 'active')
24
+ })
25
+
26
+ test('storage is isolated by instance namespace and mock version; reset is deterministic', () => {
27
+ const map = new Map<string, string>()
28
+ const storage = { getItem: (key: string) => map.get(key) ?? null, setItem: (key: string, value: string) => { map.set(key, value) } }
29
+ const a = storageKey('pilot-A', 'v1')
30
+ const b = storageKey('pilot-B', 'v1')
31
+ const seed = createSeed()
32
+ persistDevices(storage, a, updateDevices(seed, ['device-1'], { name: 'changed' }))
33
+ assert.equal(loadDevices(storage, a)[0]?.name, 'changed')
34
+ assert.deepEqual(loadDevices(storage, b), seed)
35
+ assert.deepEqual(loadDevices(storage, storageKey('pilot-A', 'v2')), seed)
36
+ persistDevices(storage, a, seed)
37
+ assert.deepEqual(loadDevices(storage, a), createSeed())
38
+ })
39
+
40
+ test('invalid persisted data recovers to fixed seed and empty namespaces are rejected', () => {
41
+ assert.deepEqual(loadDevices({ getItem: () => '{broken' }, 'key'), createSeed())
42
+ assert.deepEqual(loadDevices({ getItem: () => '[{"id":"fake"}]' }, 'key'), createSeed())
43
+ assert.throws(() => storageKey('', 'v1'), /namespace/)
44
+ })
@@ -0,0 +1,26 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { THEME_PRESETS, resolveThemeConfig, themeStorageKey, loadThemePreference, saveThemePreference, safeThemeStorage } from '../src/domain/theme.ts'
4
+
5
+ const binding = { defaultTheme: 'light', presets: ['light', 'dark', 'fresh', 'tech'] }
6
+ test('four named presets are explicit while an unbound instance stays legacy', () => {
7
+ assert.equal(resolveThemeConfig(undefined), null)
8
+ assert.deepEqual(resolveThemeConfig(binding), binding)
9
+ assert.deepEqual(Object.values(THEME_PRESETS).map(theme => theme.label), ['明亮', '暗黑', '清新', '科技'])
10
+ assert.deepEqual(Object.values(THEME_PRESETS).map(theme => theme.mode), ['light', 'dark', 'light', 'dark'])
11
+ for (const config of [{ ...binding, defaultTheme: 'dark' }, { ...binding, presets: [1,2,3,4] }, { ...binding, presets: ['light','dark','fresh','fresh'] }]) assert.throws(() => resolveThemeConfig(config))
12
+ })
13
+ test('theme preference is namespaced independently and damaged cache falls back', () => {
14
+ const values=new Map<string,string>(), storage={ getItem:(key:string)=>values.get(key)??null,setItem:(key:string,value:string)=>{values.set(key,value)} }
15
+ const a=themeStorageKey('instance-a'),b=themeStorageKey('instance-b')
16
+ assert.notEqual(a,b);assert.match(a,/:ui-theme:v1$/)
17
+ assert.equal(saveThemePreference(storage,a,'tech'),true)
18
+ assert.equal(loadThemePreference(storage,a),'tech');assert.equal(loadThemePreference(storage,b),'light')
19
+ for (const broken of ['{','null','{"version":1,"themeId":2}','{"version":2,"themeId":"tech"}','{"version":1,"themeId":"unknown"}']) { values.set(a,broken);assert.equal(loadThemePreference(storage,a),'light') }
20
+ })
21
+ test('denied storage never prevents local theme changes or returns a fake saved preference', () => {
22
+ const denied={getItem(){throw Error('denied')},setItem(){throw Error('denied')}}
23
+ assert.equal(safeThemeStorage(()=>{throw Error('getter denied')}),null)
24
+ assert.equal(loadThemePreference(denied,'key'),'light');assert.equal(loadThemePreference(null,'key'),'light')
25
+ assert.equal(saveThemePreference(denied,'key','dark'),false);assert.equal(saveThemePreference(null,'key','dark'),false)
26
+ })