@morya-ui/mcp 0.3.3 → 0.3.5

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.
@@ -1,182 +1,182 @@
1
- <script setup lang="ts">
2
- /**
3
- * 黄金样例:分步向导(wizard-form)
4
- * MStepper + 每步一个主任务;完成态可接 result-page。
5
- * @see DESIGN.md · surfaces § Flow · pattern wizard-form
6
- */
7
- import {
8
- MBreadcrumb,
9
- MButton,
10
- MConfigProvider,
11
- MForm,
12
- MFormItem,
13
- MInput,
14
- MInputTags,
15
- MLayout,
16
- MLayoutContent,
17
- MLayoutHeader,
18
- MPageContent,
19
- MPageHeader,
20
- MPageSection,
21
- MSelect,
22
- MSpace,
23
- MStepper,
24
- message,
25
- zhCN,
26
- } from 'morya-ui'
27
- import { computed, reactive, ref } from 'vue'
28
-
29
- const activeStep = ref(0)
30
- const submitting = ref(false)
31
-
32
- const steps = [
33
- { label: '工作空间', description: '名称与地区' },
34
- { label: '邀请成员', description: '可选邮箱' },
35
- { label: '确认', description: '核对后创建' },
36
- ]
37
-
38
- const model = reactive({
39
- name: '',
40
- region: undefined as string | undefined,
41
- invites: [] as string[],
42
- })
43
-
44
- const regionOptions = [
45
- { label: '中国大陆', value: 'cn' },
46
- { label: '亚太', value: 'apac' },
47
- { label: '北美', value: 'na' },
48
- ]
49
-
50
- const canNext = computed(() => {
51
- if (activeStep.value === 0) return Boolean(model.name && model.region)
52
- return true
53
- })
54
-
55
- function back() {
56
- if (activeStep.value > 0) activeStep.value -= 1
57
- }
58
-
59
- function next() {
60
- if (!canNext.value) return
61
- if (activeStep.value < steps.length - 1) activeStep.value += 1
62
- }
63
-
64
- async function finish() {
65
- submitting.value = true
66
- try {
67
- // await api.createWorkspace(model)
68
- message.success('工作空间已创建')
69
- // 成功页可镜像 get_golden_page('result-page'),将 status 改为 success
70
- } finally {
71
- submitting.value = false
72
- }
73
- }
74
- </script>
75
-
76
- <template>
77
- <MConfigProvider :locale="zhCN">
78
- <MLayout fill-viewport>
79
- <MLayoutHeader padding="var(--m-space-4) var(--m-space-6)">
80
- <MBreadcrumb
81
- :model="[
82
- { label: '首页', to: '/' },
83
- { label: '工作空间', to: '/workspaces' },
84
- { label: '创建' },
85
- ]"
86
- />
87
- </MLayoutHeader>
88
-
89
- <MLayoutContent>
90
- <MPageContent width="narrow">
91
- <MPageHeader
92
- title="创建工作空间"
93
- description="三步完成;每步只做一件事。Stepper 使用 steps(不是 items)。"
94
- />
95
-
96
- <MStepper v-model="activeStep" :steps="steps" linear />
97
-
98
- <MPageSection variant="form">
99
- <MForm v-if="activeStep === 0" @submit.prevent="next">
100
- <MFormItem label="名称" name="name" required>
101
- <MInput v-model="model.name" placeholder="例如:青禾书房" fluid />
102
- </MFormItem>
103
- <MFormItem label="地区" name="region" required>
104
- <MSelect
105
- v-model="model.region"
106
- :options="regionOptions"
107
- placeholder="选择地区"
108
- fluid
109
- />
110
- </MFormItem>
111
- </MForm>
112
-
113
- <MForm v-else-if="activeStep === 1" @submit.prevent="next">
114
- <MFormItem label="邀请邮箱" name="invites">
115
- <MInputTags
116
- v-model="model.invites"
117
- placeholder="输入邮箱后回车"
118
- />
119
- </MFormItem>
120
- <p class="wizard-hint">
121
- 可跳过;稍后也可在成员管理中邀请。
122
- </p>
123
- </MForm>
124
-
125
- <div v-else class="wizard-summary">
126
- <p><strong>名称</strong> {{ model.name || '—' }}</p>
127
- <p><strong>地区</strong> {{ model.region || '—' }}</p>
128
- <p><strong>邀请</strong> {{ model.invites.length ? model.invites.join('、') : '暂无' }}</p>
129
- </div>
130
-
131
- <MPageSection variant="actions">
132
- <MSpace>
133
- <MButton
134
- severity="secondary"
135
- text
136
- :disabled="activeStep === 0 || submitting"
137
- @click="back"
138
- >
139
- 上一步
140
- </MButton>
141
- <MButton
142
- v-if="activeStep < steps.length - 1"
143
- severity="primary"
144
- :disabled="!canNext"
145
- @click="next"
146
- >
147
- 下一步
148
- </MButton>
149
- <MButton
150
- v-else
151
- severity="primary"
152
- :loading="submitting"
153
- @click="finish"
154
- >
155
- 创建
156
- </MButton>
157
- </MSpace>
158
- </MPageSection>
159
- </MPageSection>
160
- </MPageContent>
161
- </MLayoutContent>
162
- </MLayout>
163
- </MConfigProvider>
164
- </template>
165
-
166
- <style scoped>
167
- .wizard-hint,
168
- .wizard-summary {
169
- margin: 0;
170
- color: var(--m-color-text-muted);
171
- font-size: var(--m-font-size-sm);
172
- }
173
-
174
- .wizard-summary {
175
- display: grid;
176
- gap: var(--m-space-2);
177
- }
178
-
179
- .wizard-summary p {
180
- margin: 0;
181
- }
182
- </style>
1
+ <script setup lang="ts">
2
+ /**
3
+ * 黄金样例:分步向导(wizard-form)
4
+ * MStepper + 每步一个主任务;完成态可接 result-page。
5
+ * @see DESIGN.md · surfaces § Flow · pattern wizard-form
6
+ */
7
+ import {
8
+ MBreadcrumb,
9
+ MButton,
10
+ MConfigProvider,
11
+ MForm,
12
+ MFormItem,
13
+ MInput,
14
+ MInputTags,
15
+ MLayout,
16
+ MLayoutContent,
17
+ MLayoutHeader,
18
+ MPageContent,
19
+ MPageHeader,
20
+ MPageSection,
21
+ MSelect,
22
+ MSpace,
23
+ MStepper,
24
+ message,
25
+ zhCN,
26
+ } from 'morya-ui'
27
+ import { computed, reactive, ref } from 'vue'
28
+
29
+ const activeStep = ref(0)
30
+ const submitting = ref(false)
31
+
32
+ const steps = [
33
+ { label: '工作空间', description: '名称与地区' },
34
+ { label: '邀请成员', description: '可选邮箱' },
35
+ { label: '确认', description: '核对后创建' },
36
+ ]
37
+
38
+ const model = reactive({
39
+ name: '',
40
+ region: undefined as string | undefined,
41
+ invites: [] as string[],
42
+ })
43
+
44
+ const regionOptions = [
45
+ { label: '中国大陆', value: 'cn' },
46
+ { label: '亚太', value: 'apac' },
47
+ { label: '北美', value: 'na' },
48
+ ]
49
+
50
+ const canNext = computed(() => {
51
+ if (activeStep.value === 0) return Boolean(model.name && model.region)
52
+ return true
53
+ })
54
+
55
+ function back() {
56
+ if (activeStep.value > 0) activeStep.value -= 1
57
+ }
58
+
59
+ function next() {
60
+ if (!canNext.value) return
61
+ if (activeStep.value < steps.length - 1) activeStep.value += 1
62
+ }
63
+
64
+ async function finish() {
65
+ submitting.value = true
66
+ try {
67
+ // await api.createWorkspace(model)
68
+ message.success('工作空间已创建')
69
+ // 成功页可镜像 get_golden_page('result-page'),将 status 改为 success
70
+ } finally {
71
+ submitting.value = false
72
+ }
73
+ }
74
+ </script>
75
+
76
+ <template>
77
+ <MConfigProvider :locale="zhCN">
78
+ <MLayout fill-viewport>
79
+ <MLayoutHeader padding="var(--m-space-4) var(--m-space-6)">
80
+ <MBreadcrumb
81
+ :model="[
82
+ { label: '首页', to: '/' },
83
+ { label: '工作空间', to: '/workspaces' },
84
+ { label: '创建' },
85
+ ]"
86
+ />
87
+ </MLayoutHeader>
88
+
89
+ <MLayoutContent>
90
+ <MPageContent width="narrow">
91
+ <MPageHeader
92
+ title="创建工作空间"
93
+ description="三步完成;每步只做一件事。Stepper 使用 steps(不是 items)。"
94
+ />
95
+
96
+ <MStepper v-model="activeStep" :steps="steps" linear />
97
+
98
+ <MPageSection variant="form">
99
+ <MForm v-if="activeStep === 0" @submit.prevent="next">
100
+ <MFormItem label="名称" name="name" required>
101
+ <MInput v-model="model.name" placeholder="例如:青禾书房" fluid />
102
+ </MFormItem>
103
+ <MFormItem label="地区" name="region" required>
104
+ <MSelect
105
+ v-model="model.region"
106
+ :options="regionOptions"
107
+ placeholder="选择地区"
108
+ fluid
109
+ />
110
+ </MFormItem>
111
+ </MForm>
112
+
113
+ <MForm v-else-if="activeStep === 1" @submit.prevent="next">
114
+ <MFormItem label="邀请邮箱" name="invites">
115
+ <MInputTags
116
+ v-model="model.invites"
117
+ placeholder="输入邮箱后回车"
118
+ />
119
+ </MFormItem>
120
+ <p class="wizard-hint">
121
+ 可跳过;稍后也可在成员管理中邀请。
122
+ </p>
123
+ </MForm>
124
+
125
+ <div v-else class="wizard-summary">
126
+ <p><strong>名称</strong> {{ model.name || '—' }}</p>
127
+ <p><strong>地区</strong> {{ model.region || '—' }}</p>
128
+ <p><strong>邀请</strong> {{ model.invites.length ? model.invites.join('、') : '暂无' }}</p>
129
+ </div>
130
+
131
+ <MPageSection variant="actions">
132
+ <MSpace>
133
+ <MButton
134
+ severity="secondary"
135
+ text
136
+ :disabled="activeStep === 0 || submitting"
137
+ @click="back"
138
+ >
139
+ 上一步
140
+ </MButton>
141
+ <MButton
142
+ v-if="activeStep < steps.length - 1"
143
+ severity="primary"
144
+ :disabled="!canNext"
145
+ @click="next"
146
+ >
147
+ 下一步
148
+ </MButton>
149
+ <MButton
150
+ v-else
151
+ severity="primary"
152
+ :loading="submitting"
153
+ @click="finish"
154
+ >
155
+ 创建
156
+ </MButton>
157
+ </MSpace>
158
+ </MPageSection>
159
+ </MPageSection>
160
+ </MPageContent>
161
+ </MLayoutContent>
162
+ </MLayout>
163
+ </MConfigProvider>
164
+ </template>
165
+
166
+ <style scoped>
167
+ .wizard-hint,
168
+ .wizard-summary {
169
+ margin: 0;
170
+ color: var(--m-color-text-muted);
171
+ font-size: var(--m-font-size-sm);
172
+ }
173
+
174
+ .wizard-summary {
175
+ display: grid;
176
+ gap: var(--m-space-2);
177
+ }
178
+
179
+ .wizard-summary p {
180
+ margin: 0;
181
+ }
182
+ </style>
@@ -1,6 +1,35 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { filterPageSnippets, findPageSnippet, scorePageSnippet } from '../page-snippets.js';
3
3
  describe('page snippets catalog', () => {
4
+ it('finds composition-first snippets by id', () => {
5
+ for (const id of [
6
+ 'form-in-dialog',
7
+ 'form-in-drawer',
8
+ 'confirm-delete',
9
+ 'row-actions-menu',
10
+ 'wizard-steps',
11
+ 'auth-split-shell',
12
+ 'page-header-actions',
13
+ 'list-filters-stack',
14
+ 'list-filters-dense',
15
+ ]) {
16
+ expect(findPageSnippet(id)?.id).toBe(id);
17
+ }
18
+ });
19
+ it('list-filters-stack includes chips, advanced, and #actions', () => {
20
+ const stack = findPageSnippet('list-filters-stack');
21
+ expect(stack.template).toContain('MPageFilterChips');
22
+ expect(stack.template).toContain('#advanced');
23
+ expect(stack.template).toContain('#actions');
24
+ expect(stack.template).toContain('collapsible');
25
+ expect(stack.scriptSetup).toContain('activeFilters');
26
+ expect(stack.scriptSetup).toContain('resetFilters');
27
+ });
28
+ it('scores filter stack keywords', () => {
29
+ const stack = findPageSnippet('list-filters-stack');
30
+ expect(scorePageSnippet(stack, '筛选组合')).toBeGreaterThan(0);
31
+ expect(scorePageSnippet(stack, 'filter stack')).toBeGreaterThan(0);
32
+ });
4
33
  it('finds snippet by exact id', () => {
5
34
  expect(findPageSnippet('form-actions')?.id).toBe('form-actions');
6
35
  });
@@ -173,6 +173,25 @@ describe('@morya-ui/mcp handlers', () => {
173
173
  expect(result.ok).toBe(true);
174
174
  expect(result.suggestions.some((item) => item.type === 'native-scroll')).toBe(true);
175
175
  });
176
+ it('suggests list fill height only for fillViewport admin lists missing fill', () => {
177
+ const result = read(handlers.validatePage({
178
+ code: '<MLayout fill-viewport><MPageContent><MTable :columns="c" :rows="r" paginator /></MPageContent></MLayout>',
179
+ }));
180
+ expect(result.ok).toBe(true);
181
+ expect(result.suggestions.some((item) => item.type === 'list-fill-height' && item.standardId === 'page-sections')).toBe(true);
182
+ });
183
+ it('does not suggest list fill when PageContent and Table already fill', () => {
184
+ const result = read(handlers.validatePage({
185
+ code: '<MLayout fill-viewport><MPageContent fill><MTable fill paginator :columns="c" :rows="r" /></MPageContent></MLayout>',
186
+ }));
187
+ expect(result.suggestions.some((item) => item.type === 'list-fill-height')).toBe(false);
188
+ });
189
+ it('does not suggest list fill for paginator tables outside fillViewport shells', () => {
190
+ const result = read(handlers.validatePage({
191
+ code: '<MPageContent><MTable :columns="c" :rows="r" paginator /></MPageContent>',
192
+ }));
193
+ expect(result.suggestions.some((item) => item.type === 'list-fill-height')).toBe(false);
194
+ });
176
195
  it('returns scrollable-panel snippet for scroll queries', () => {
177
196
  const result = read(handlers.getPageSnippet({ section: 'scrollable-panel' }));
178
197
  expect(result.id).toBe('scrollable-panel');
@@ -183,9 +202,72 @@ describe('@morya-ui/mcp handlers', () => {
183
202
  const result = read(handlers.recommendPage({ intent: '用户列表页', pageType: 'list' }));
184
203
  expect(result.matchedPattern).toBe('admin-list');
185
204
  expect(result.nextStep).toContain('validate_usage');
205
+ expect(result.nextStep).toMatch(/get_page_snippet|suggestedSnippets|relatedSnippets/);
186
206
  expect(result.nextStep).toContain('get_golden_page');
207
+ expect(result.nextStep).toMatch(/可选|Optional/);
208
+ expect(result.suggestedSnippets?.length).toBeGreaterThan(0);
209
+ expect(result.suggestedSnippets).toContain('list-filters-stack');
210
+ expect(result.suggestedSnippets).toContain('list-table');
187
211
  expect(result.pageStandards).toBeUndefined();
188
212
  expect(result.scrollStandards).toBeUndefined();
213
+ expect(result.styleDirection?.resolution).toBeTruthy();
214
+ });
215
+ it('resolves an explicit free-text style on recommend_page', () => {
216
+ const result = read(handlers.recommendPage({ intent: '用户列表', pageType: 'list', style: '简洁留白,少装饰' }));
217
+ expect(result.styleDirection.resolution).toBe('explicit');
218
+ expect(result.styleDirection.summary).toContain('简洁留白');
219
+ expect(result.goldenPage).toContain('list-page');
220
+ });
221
+ it('routes dense/rail craft cues to list golden variants', () => {
222
+ const dense = read(handlers.recommendPage({
223
+ intent: '监控告警列表',
224
+ pageType: 'list',
225
+ style: 'dense',
226
+ includeScaffold: true,
227
+ }));
228
+ expect(dense.goldenPage).toContain('list-page-dense');
229
+ expect(dense.scaffold.goldenPage).toBe('list-page-dense');
230
+ expect(dense.scaffold.files.component).toContain('density="compact"');
231
+ expect(dense.nextStep).toContain('list-page-dense');
232
+ expect(dense.suggestedSnippets).toContain('list-filters-dense');
233
+ expect(dense.suggestedSnippets).not.toContain('list-filters-stack');
234
+ const rail = read(handlers.recommendPage({
235
+ intent: '品牌色后台列表',
236
+ pageType: 'list',
237
+ style: 'rail',
238
+ includeScaffold: true,
239
+ }));
240
+ expect(rail.goldenPage).toContain('list-page-rail');
241
+ expect(rail.scaffold.goldenPage).toBe('list-page-rail');
242
+ expect(rail.scaffold.files.component).toContain('gp-rail-sider');
243
+ expect(rail.suggestedSnippets).toContain('list-filters-stack');
244
+ });
245
+ it('returns dense and rail golden page sources', () => {
246
+ const dense = read(handlers.getGoldenPage({ page: 'list-page-dense' }));
247
+ expect(dense.id).toBe('list-page-dense');
248
+ expect(dense.style).toBe('dense');
249
+ expect(dense.variantOf).toBe('list-page');
250
+ expect(dense.source).toContain('size="small"');
251
+ const rail = read(handlers.getGoldenPage({ page: 'list-page-rail' }));
252
+ expect(rail.id).toBe('list-page-rail');
253
+ expect(rail.style).toBe('rail');
254
+ expect(rail.source).toContain('color-mix');
255
+ });
256
+ it('exposes style direction rules without a preset catalog', () => {
257
+ const result = read(handlers.getStyleDirection({ mode: 'zh-CN' }));
258
+ expect(result.resolution).toBe('ask');
259
+ expect(result.priority[0]).toContain('参考');
260
+ expect(result.hardRules.some((rule) => rule.includes('没有命名风格预设'))).toBe(true);
261
+ expect(result.note).toContain('没有命名风格预设');
262
+ const explicit = read(handlers.getStyleDirection({ style: '毛玻璃一点', mode: 'zh-CN' }));
263
+ expect(explicit.resolution).toBe('explicit');
264
+ expect(explicit.summary).toContain('毛玻璃');
265
+ });
266
+ it('exposes style direction from get_design_rules', () => {
267
+ const result = read(handlers.getDesignRules({ mode: 'zh-CN' }));
268
+ expect(result.styles.resolution[0]).toContain('参考');
269
+ expect(result.styles.presets).toBeUndefined();
270
+ expect(result.styles.note).toContain('没有命名风格预设');
189
271
  });
190
272
  it('keeps get_golden_page focused on source code', () => {
191
273
  const result = read(handlers.getGoldenPage({ page: 'list-page' }));
@@ -282,6 +364,14 @@ describe('@morya-ui/mcp handlers', () => {
282
364
  expect(result.id).toBe('overlay-choice');
283
365
  expect(result.options.some((option) => option.component === 'Drawer')).toBe(true);
284
366
  });
367
+ it('exposes relatedSnippets on decision options', () => {
368
+ const result = read(handlers.recommendComponent({ decision: 'confirm-choice' }));
369
+ const confirm = result.options.find((option) => option.component === 'ConfirmDialog');
370
+ expect(confirm?.relatedSnippets).toContain('confirm-delete');
371
+ const formSurface = read(handlers.recommendComponent({ decision: 'form-surface-choice' }));
372
+ const dialog = formSurface.options.find((option) => option.component === 'Dialog');
373
+ expect(dialog?.relatedSnippets).toContain('form-in-dialog');
374
+ });
285
375
  it('returns L2 recipes and antiPatterns on decision reads', () => {
286
376
  const result = read(handlers.recommendComponent({ decision: 'data-display-choice' }));
287
377
  const table = result.options.find((option) => option.component === 'Table');
@@ -317,9 +407,12 @@ describe('@morya-ui/mcp handlers', () => {
317
407
  expect(result.counts.decisions).toBeGreaterThan(0);
318
408
  expect(result.counts.resources).toBeGreaterThan(100);
319
409
  expect(result.counts.resourceTemplates).toBe(3);
320
- expect(result.tools).toHaveLength(18);
410
+ expect(result.tools).toHaveLength(19);
321
411
  expect(result.tools).toContain('get_golden_page');
322
412
  expect(result.tools).toContain('get_page_snippet');
413
+ expect(result.tools).toContain('get_style_direction');
414
+ expect(result.tools).not.toContain('list_style_presets');
415
+ expect(result.tools).not.toContain('get_style_preset');
323
416
  expect(result.tools).toContain('validate_page');
324
417
  expect(result.tools).not.toContain('create_page');
325
418
  });
@@ -16,6 +16,8 @@ export interface ComponentDecisionOption {
16
16
  recipe: ComponentDecisionRecipe;
17
17
  antiPatterns: string[];
18
18
  antiPatternsEn: string[];
19
+ /** Page-snippet ids to compose after choosing this option (composition-first). */
20
+ relatedSnippets?: string[];
19
21
  }
20
22
  export interface ComponentDecision {
21
23
  id: string;