@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.
- package/.agents/plugins/marketplace.json +1 -1
- package/.claude/commands/i3-prototype.md +5 -7
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.gemini/commands/i3-prototype.toml +5 -7
- package/README.md +16 -7
- package/bin/agent-targets.json +4 -2
- package/bin/i3-composer-agent.cjs +74 -7
- package/bin/install.cjs +16 -1
- package/bin/native-layout.cjs +1 -1
- package/bin/native-migration.cjs +27 -3
- package/bin/tool-selector.cjs +24 -7
- package/bin/wizard.cjs +89 -15
- package/commands/i3-prototype.toml +5 -7
- package/docs/codebuddy-setup.md +91 -3
- package/docs/codex-setup.md +6 -0
- package/docs/other-hosts.md +1 -1
- package/docs/ui-prototype-native/MAIN-SESSION-REPORT.md +66 -0
- package/docs/ui-prototype-native/USAGE.md +93 -0
- package/docs/workbuddy-setup.md +6 -0
- package/evals/cases/i3-frontend-ui-engineering.json +16 -0
- package/evals/cases/i3-ui-prototype.json +109 -0
- package/evals/cases/i3-using-agent-skills.json +28 -0
- package/evals/fixtures/i3-ui-prototype/admin-list/assets/reference.css +3 -0
- package/evals/fixtures/i3-ui-prototype/admin-list/assets/reference.js +8 -0
- package/evals/fixtures/i3-ui-prototype/admin-list/capability-definition.json +67 -0
- package/evals/fixtures/i3-ui-prototype/admin-list/index.html +89 -0
- package/evals/fixtures/i3-ui-prototype/admin-list/readme.md +52 -0
- package/evals/fixtures/i3-ui-prototype/admin-list/template.json +8 -0
- package/evals/zh-CN/cases/i3-frontend-ui-engineering.json +17 -0
- package/evals/zh-CN/cases/i3-ui-prototype.json +115 -0
- package/evals/zh-CN/cases/i3-using-agent-skills.json +30 -0
- package/hooks/codebuddy-upstream.cjs +93 -0
- package/hooks/i3-prototype.cjs +12 -0
- package/hooks/manage.cjs +162 -0
- package/package.json +61 -1
- package/plugin.json +1 -1
- package/scripts/design-provider-test.js +7 -0
- package/scripts/enterprise-template-test.js +27 -1
- package/scripts/lib/prototype-fixture.cjs +17 -0
- package/scripts/prototype-cache-test.js +55 -0
- package/scripts/prototype-generation-entry-test.js +6 -0
- package/scripts/prototype-generation-evidence-test.js +10 -0
- package/scripts/prototype-generation-native-test.js +78 -0
- package/scripts/prototype-generation-test.js +99 -0
- package/scripts/prototype-inventory-test.js +43 -0
- package/scripts/prototype-locations-test.js +79 -0
- package/scripts/prototype-project-test.js +183 -0
- package/scripts/ui-prototype-codebuddy-hook-test.js +106 -0
- package/scripts/ui-prototype-content-test.js +123 -0
- package/scripts/ui-prototype-dependencies-test.js +213 -0
- package/scripts/ui-prototype-devtools-test.js +53 -0
- package/scripts/ui-prototype-execution-evidence-test.js +62 -0
- package/scripts/ui-prototype-execution-gates-test.js +66 -0
- package/scripts/ui-prototype-execution-policy-test.js +42 -0
- package/scripts/ui-prototype-execution-runtime-test.js +27 -0
- package/scripts/ui-prototype-ide-source-test.js +73 -0
- package/scripts/ui-prototype-native-test.js +504 -0
- package/scripts/ui-prototype-registration-test.js +53 -0
- package/scripts/ui-prototype-template-test.js +261 -0
- package/scripts/ui-prototype-test.js +10 -0
- package/scripts/ui-prototype-transcript-auto-test.js +67 -0
- package/skills/i3-frontend-ui-engineering/SKILL.md +1 -1
- package/skills/i3-incremental-implementation/SKILL.md +1 -1
- package/skills/i3-ui-prototype/SKILL.md +158 -19
- package/skills/i3-ui-prototype/assets/capabilities/generation.json +833 -0
- package/skills/i3-ui-prototype/assets/capabilities/management.json +294 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/acceptance.json +51 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/component-map.json +13 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/generation-component-map.json +300 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/index.html +11 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/package-lock.json +2298 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/package.json +22 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/public/prototype-config.json +6 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/App.vue +136 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/components/DeviceEditor.vue +111 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/config.ts +19 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/content.ts +28 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/domain/devices.ts +50 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/domain/theme.ts +25 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/main.ts +18 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/style.css +50 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/useDevices.ts +66 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/tests/devices.test.ts +44 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/tests/theme.test.ts +26 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/tsconfig.json +15 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/vite.config.ts +4 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/profile.json +174 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/readme.md +61 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/index.html +11 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/package-lock.json +2298 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/package.json +22 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/public/prototype-config.json +21 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/App.vue +62 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/PrototypeShell.vue +59 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/config.ts +23 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/domain/presentation.ts +29 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/domain/theme.ts +25 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/main.ts +18 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/style.css +26 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/tests/presentation.test.ts +23 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/tests/theme.test.ts +26 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/tsconfig.json +15 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/vite.config.ts +4 -0
- package/skills/i3-ui-prototype/assets/hosts/codebuddy/profile.json +13 -0
- package/skills/i3-ui-prototype/assets/optional-skills/host-targets.json +739 -0
- package/skills/i3-ui-prototype/assets/optional-skills/sources.json +27 -0
- package/skills/i3-ui-prototype/assets/templates/catalog.js +149 -0
- package/skills/i3-ui-prototype/assets/templates/catalog.json +2 -1
- package/skills/i3-ui-prototype/assets/templates/enterprise-admin/capability-definition.json +294 -0
- package/skills/i3-ui-prototype/assets/templates/enterprise-admin/component-inventory.json +707 -0
- package/skills/i3-ui-prototype/assets/templates/enterprise-admin/generation.json +49 -0
- package/skills/i3-ui-prototype/assets/templates/enterprise-admin/index.html +507 -2
- package/skills/i3-ui-prototype/assets/templates/enterprise-admin/offline-build.json +265 -0
- package/skills/i3-ui-prototype/assets/templates/enterprise-admin/offline.js +8888 -0
- package/skills/i3-ui-prototype/assets/templates/enterprise-admin/template-inventory.json +28791 -0
- package/skills/i3-ui-prototype/assets/templates/enterprise-admin/template.json +9 -0
- package/skills/i3-ui-prototype/assets/templates/gallery/index.html +5 -5
- package/skills/i3-ui-prototype/assets/templates/generation-options.js +42 -0
- package/skills/i3-ui-prototype/assets/templates/index.html +3 -0
- package/skills/i3-ui-prototype/assets/templates/presentation.html +17 -0
- package/skills/i3-ui-prototype/references/artifact-contract.md +26 -1
- package/skills/i3-ui-prototype/references/content-template.md +81 -0
- package/skills/i3-ui-prototype/references/enterprise-admin.md +6 -0
- package/skills/i3-ui-prototype/references/entry-contract.md +71 -0
- package/skills/i3-ui-prototype/references/execution-evidence.md +66 -0
- package/skills/i3-ui-prototype/references/expert-adapter.md +33 -5
- package/skills/i3-ui-prototype/references/framework-contract.md +76 -0
- package/skills/i3-ui-prototype/references/generation-contract.md +122 -0
- package/skills/i3-ui-prototype/references/interaction-acceptance.md +169 -0
- package/skills/i3-ui-prototype/references/legacy-native.md +58 -0
- package/skills/i3-ui-prototype/references/optional-skills.md +63 -0
- package/skills/i3-ui-prototype/references/project-layout.md +92 -0
- package/skills/i3-ui-prototype/references/template-adapters.md +7 -1
- package/skills/i3-ui-prototype/references/workflow-contract.md +62 -4
- package/skills/i3-ui-prototype/scripts/adapters/easel-html.cjs +1 -1
- package/skills/i3-ui-prototype/scripts/browser-workspace.cjs +72 -0
- package/skills/i3-ui-prototype/scripts/codebuddy-context.cjs +75 -0
- package/skills/i3-ui-prototype/scripts/codebuddy-hook.cjs +157 -0
- package/skills/i3-ui-prototype/scripts/codebuddy-ide-source.cjs +69 -0
- package/skills/i3-ui-prototype/scripts/codebuddy-registration.cjs +123 -0
- package/skills/i3-ui-prototype/scripts/content-template.cjs +98 -0
- package/skills/i3-ui-prototype/scripts/enterprise-template.cjs +13 -6
- package/skills/i3-ui-prototype/scripts/execution-assertions.cjs +43 -0
- package/skills/i3-ui-prototype/scripts/execution-evidence.cjs +146 -0
- package/skills/i3-ui-prototype/scripts/execution-gates.cjs +147 -0
- package/skills/i3-ui-prototype/scripts/execution-policy.cjs +41 -0
- package/skills/i3-ui-prototype/scripts/execution-runtime.cjs +60 -0
- package/skills/i3-ui-prototype/scripts/generation-assets.cjs +18 -0
- package/skills/i3-ui-prototype/scripts/generation-contract.cjs +214 -0
- package/skills/i3-ui-prototype/scripts/generation-evidence.cjs +50 -0
- package/skills/i3-ui-prototype/scripts/generation-replan.cjs +34 -0
- package/skills/i3-ui-prototype/scripts/hosts/codebuddy.cjs +16 -0
- package/skills/i3-ui-prototype/scripts/manage-baseline.cjs +1 -1
- package/skills/i3-ui-prototype/scripts/native-browser.cjs +785 -0
- package/skills/i3-ui-prototype/scripts/native-build.cjs +91 -0
- package/skills/i3-ui-prototype/scripts/native-delivery.cjs +103 -0
- package/skills/i3-ui-prototype/scripts/native-devtools.cjs +92 -0
- package/skills/i3-ui-prototype/scripts/native-files.cjs +35 -0
- package/skills/i3-ui-prototype/scripts/native-pilot-lib.cjs +292 -0
- package/skills/i3-ui-prototype/scripts/native-pilot.cjs +50 -0
- package/skills/i3-ui-prototype/scripts/optional-skill-install.cjs +203 -0
- package/skills/i3-ui-prototype/scripts/project-registry.cjs +126 -0
- package/skills/i3-ui-prototype/scripts/prototype-dependencies.cjs +81 -0
- package/skills/i3-ui-prototype/scripts/prototype-hooks.cjs +87 -0
- package/skills/i3-ui-prototype/scripts/prototype-lib.cjs +31 -13
- package/skills/i3-ui-prototype/scripts/prototype-locations.cjs +69 -0
- package/skills/i3-ui-prototype/scripts/sync-template.cjs +19 -0
- package/skills/i3-ui-prototype/scripts/template-contract.cjs +407 -0
- package/skills/i3-ui-prototype/scripts/template-inventory.cjs +78 -0
- package/skills/i3-ui-prototype/scripts/validate-result.cjs +117 -0
- 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,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
|
+
}
|
package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/domain/devices.ts
ADDED
|
@@ -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
|
+
}
|
package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/domain/theme.ts
ADDED
|
@@ -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; } }
|
package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/useDevices.ts
ADDED
|
@@ -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
|
+
}
|
package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/tests/devices.test.ts
ADDED
|
@@ -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
|
+
})
|
package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/tests/theme.test.ts
ADDED
|
@@ -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
|
+
})
|