@morya-ui/mcp 0.2.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * 黄金样例:结果 / 阻断页(System)
4
+ * 默认展示 403;换成 success / 404 / 500 时只改 status + 文案 + #footer 动作。
5
+ * 无数据空态请用 empty-state / MEmpty,不要用 MResult。
6
+ * @see DESIGN.md · surfaces § System · MResult API(操作用 #footer)
7
+ */
8
+ import {
9
+ MBreadcrumb,
10
+ MButton,
11
+ MConfigProvider,
12
+ MLayout,
13
+ MLayoutContent,
14
+ MLayoutHeader,
15
+ MPageContent,
16
+ MResult,
17
+ MSpace,
18
+ zhCN,
19
+ } from 'morya-ui'
20
+ </script>
21
+
22
+ <template>
23
+ <MConfigProvider :locale="zhCN">
24
+ <MLayout fill-viewport>
25
+ <MLayoutHeader padding="var(--m-space-4) var(--m-space-6)">
26
+ <MBreadcrumb
27
+ :model="[
28
+ { label: '首页', to: '/' },
29
+ { label: '项目管理', to: '/projects' },
30
+ { label: '无权限' },
31
+ ]"
32
+ />
33
+ </MLayoutHeader>
34
+
35
+ <MLayoutContent>
36
+ <MPageContent>
37
+ <div class="result-shell">
38
+ <MResult
39
+ status="403"
40
+ title="没有权限查看该项目"
41
+ description="当前账号无权访问「青禾书房」工作区。可返回上一页,或联系管理员开通权限。"
42
+ size="large"
43
+ >
44
+ <template #footer>
45
+ <MSpace>
46
+ <MButton severity="primary">
47
+ 返回上一页
48
+ </MButton>
49
+ <MButton severity="secondary" text>
50
+ 联系管理员
51
+ </MButton>
52
+ </MSpace>
53
+ </template>
54
+ </MResult>
55
+ </div>
56
+ </MPageContent>
57
+ </MLayoutContent>
58
+ </MLayout>
59
+ </MConfigProvider>
60
+ </template>
61
+
62
+ <style scoped>
63
+ .result-shell {
64
+ display: grid;
65
+ place-items: center;
66
+ min-height: min(28rem, 70vh);
67
+ padding: var(--m-space-8) var(--m-space-4);
68
+ border-radius: var(--m-radius-lg);
69
+ background:
70
+ radial-gradient(
71
+ 70% 55% at 50% 0%,
72
+ color-mix(in srgb, var(--m-color-primary) 10%, transparent),
73
+ transparent 60%
74
+ ),
75
+ var(--m-color-surface);
76
+ border: 1px dashed color-mix(in srgb, var(--m-color-border) 80%, transparent);
77
+ }
78
+
79
+ @media (prefers-reduced-motion: reduce) {
80
+ .result-shell {
81
+ background: var(--m-color-surface);
82
+ }
83
+ }
84
+ </style>
@@ -0,0 +1,163 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * 黄金样例:设置 / 配置页
4
+ * 窄栏 + Tabs 分组 + MPageSection form;保存用 message 单行回执。
5
+ * @see DESIGN.md · page-layouts.md § Settings · visual-craft § Ops polish
6
+ */
7
+ import {
8
+ MBreadcrumb,
9
+ MButton,
10
+ MConfigProvider,
11
+ MForm,
12
+ MFormItem,
13
+ MInput,
14
+ MLayout,
15
+ MLayoutContent,
16
+ MLayoutHeader,
17
+ MPageContent,
18
+ MPageHeader,
19
+ MPageSection,
20
+ MSelect,
21
+ MSpace,
22
+ MSwitch,
23
+ MTabs,
24
+ message,
25
+ zhCN,
26
+ } from 'morya-ui'
27
+ import { reactive, ref } from 'vue'
28
+
29
+ const activeTab = ref('profile')
30
+ const submitting = ref(false)
31
+
32
+ const tabs = [
33
+ { label: '资料', value: 'profile' },
34
+ { label: '通知', value: 'notify' },
35
+ { label: '安全', value: 'security' },
36
+ ]
37
+
38
+ const profile = reactive({
39
+ displayName: '林晓',
40
+ email: 'linxiao@example.com',
41
+ language: 'zh-CN' as string | undefined,
42
+ })
43
+
44
+ const notify = reactive({
45
+ emailDigest: true,
46
+ productUpdates: false,
47
+ })
48
+
49
+ const languageOptions = [
50
+ { label: '简体中文', value: 'zh-CN' },
51
+ { label: 'English', value: 'en-US' },
52
+ ]
53
+
54
+ async function saveProfile() {
55
+ submitting.value = true
56
+ try {
57
+ // await api.saveProfile(profile)
58
+ message.success('资料已保存')
59
+ } finally {
60
+ submitting.value = false
61
+ }
62
+ }
63
+
64
+ async function saveNotify() {
65
+ submitting.value = true
66
+ try {
67
+ // await api.saveNotify(notify)
68
+ message.success('通知偏好已保存')
69
+ } finally {
70
+ submitting.value = false
71
+ }
72
+ }
73
+ </script>
74
+
75
+ <template>
76
+ <MConfigProvider :locale="zhCN">
77
+ <MLayout fill-viewport>
78
+ <MLayoutHeader padding="var(--m-space-4) var(--m-space-6)">
79
+ <MBreadcrumb :model="[{ label: '首页', to: '/' }, { label: '系统设置' }]" />
80
+ </MLayoutHeader>
81
+
82
+ <MLayoutContent>
83
+ <MPageContent width="narrow">
84
+ <MPageHeader
85
+ title="系统设置"
86
+ description="按域分组配置;切换 Tab 时保留未保存输入,保存成功用 message 一句话回执。"
87
+ />
88
+
89
+ <MTabs v-model="activeTab" :tabs="tabs">
90
+ <template #default="{ activeValue }">
91
+ <MPageSection v-if="activeValue === 'profile'" variant="form" title="个人资料">
92
+ <MForm @submit.prevent="saveProfile">
93
+ <MFormItem label="显示名称" name="displayName" required>
94
+ <MInput v-model="profile.displayName" fluid />
95
+ </MFormItem>
96
+ <MFormItem label="工作邮箱" name="email" required>
97
+ <MInput v-model="profile.email" type="email" fluid />
98
+ </MFormItem>
99
+ <MFormItem label="界面语言" name="language">
100
+ <MSelect
101
+ v-model="profile.language"
102
+ :options="languageOptions"
103
+ placeholder="选择语言"
104
+ fluid
105
+ />
106
+ </MFormItem>
107
+ <MPageSection variant="actions">
108
+ <MSpace>
109
+ <MButton native-type="submit" severity="primary" :loading="submitting">
110
+ 保存资料
111
+ </MButton>
112
+ <MButton severity="secondary" text native-type="button">
113
+ 恢复默认
114
+ </MButton>
115
+ </MSpace>
116
+ </MPageSection>
117
+ </MForm>
118
+ </MPageSection>
119
+
120
+ <MPageSection v-else-if="activeValue === 'notify'" variant="form" title="通知">
121
+ <MForm @submit.prevent="saveNotify">
122
+ <MFormItem label="邮件摘要" name="emailDigest">
123
+ <MSwitch v-model="notify.emailDigest" />
124
+ </MFormItem>
125
+ <MFormItem label="产品动态" name="productUpdates">
126
+ <MSwitch v-model="notify.productUpdates" />
127
+ </MFormItem>
128
+ <MPageSection variant="actions">
129
+ <MButton native-type="submit" severity="primary" :loading="submitting">
130
+ 保存通知
131
+ </MButton>
132
+ </MPageSection>
133
+ </MForm>
134
+ </MPageSection>
135
+
136
+ <MPageSection v-else variant="form" title="安全">
137
+ <p class="settings-hint">
138
+ 修改密码、两步验证等敏感操作放在此区;危险动作使用 severity="danger" 并配合确认。
139
+ </p>
140
+ <MSpace>
141
+ <MButton severity="secondary">
142
+ 修改密码
143
+ </MButton>
144
+ <MButton severity="danger" text>
145
+ 注销账号
146
+ </MButton>
147
+ </MSpace>
148
+ </MPageSection>
149
+ </template>
150
+ </MTabs>
151
+ </MPageContent>
152
+ </MLayoutContent>
153
+ </MLayout>
154
+ </MConfigProvider>
155
+ </template>
156
+
157
+ <style scoped>
158
+ .settings-hint {
159
+ margin: 0 0 var(--m-space-4);
160
+ color: var(--m-color-text-muted);
161
+ font-size: var(--m-font-size-sm);
162
+ }
163
+ </style>
@@ -0,0 +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>
@@ -74,16 +74,60 @@ describe('@morya-ui/mcp handlers', () => {
74
74
  }));
75
75
  expect(result.matchedPattern).toBe('admin-list');
76
76
  });
77
- it('returns a dashboard scaffold when includeScaffold is true', () => {
77
+ it('returns golden-page source as scaffold when available', () => {
78
78
  const result = read(handlers.recommendPage({
79
79
  intent: '生产监控仪表盘',
80
80
  pageType: 'dashboard',
81
81
  includeScaffold: true,
82
82
  }));
83
83
  expect(result.matchedPattern).toBe('dashboard');
84
+ expect(result.scaffold.source).toBe('golden-page');
85
+ expect(result.scaffold.goldenPage).toBe('dashboard-page');
84
86
  expect(result.scaffold.files.component).toContain('MGrid');
85
87
  expect(result.scaffold.files.component).toContain('MPageStat');
86
- expect(result.scaffold.files.component).toContain('MSkeleton');
88
+ expect(result.scaffold.files.component).toContain('黄金样例');
89
+ expect(result.nextStep).toContain('validate_usage');
90
+ });
91
+ it('prefers list-status-dot for status cell queries', () => {
92
+ const result = read(handlers.getPageSnippet({ section: '状态' }));
93
+ expect(result.id).toBe('list-status-dot');
94
+ expect(result.template).toContain('MStatus');
95
+ });
96
+ it('returns result-page golden sample with MResult footer slot', () => {
97
+ const result = read(handlers.getGoldenPage({ page: 'result-page' }));
98
+ expect(result.id).toBe('result-page');
99
+ expect(result.source).toContain('MResult');
100
+ expect(result.source).toContain('status="403"');
101
+ expect(result.source).toContain('<template #footer>');
102
+ expect(result.source).not.toContain('<template #extra>');
103
+ });
104
+ it('returns settings-page and wizard-form golden samples with correct APIs', () => {
105
+ const settings = read(handlers.getGoldenPage({ page: 'settings-page' }));
106
+ expect(settings.id).toBe('settings-page');
107
+ expect(settings.source).toContain('MTabs');
108
+ expect(settings.source).toContain(':tabs=');
109
+ expect(settings.source).not.toContain(':items=');
110
+ const wizard = read(handlers.getGoldenPage({ page: 'wizard-form' }));
111
+ expect(wizard.id).toBe('wizard-form');
112
+ expect(wizard.source).toContain('MStepper');
113
+ expect(wizard.source).toContain(':steps=');
114
+ expect(wizard.source).not.toContain(':items=');
115
+ });
116
+ it('scaffolds settings from golden page source', () => {
117
+ const result = read(handlers.recommendPage({
118
+ intent: '系统设置页',
119
+ pageType: 'settings',
120
+ includeScaffold: true,
121
+ }));
122
+ expect(result.scaffold.source).toBe('golden-page');
123
+ expect(result.scaffold.goldenPage).toBe('settings-page');
124
+ expect(result.scaffold.files.component).toContain(':tabs=');
125
+ });
126
+ it('flags MTabs items and MStepper items as contract advisories', () => {
127
+ const tabs = read(handlers.validatePage({ code: `<MTabs :items="[]" />` }));
128
+ expect(tabs.suggestions.some((item) => item.type === 'tabs-items-prop')).toBe(true);
129
+ const stepper = read(handlers.validatePage({ code: `<MStepper :items="['a']" />` }));
130
+ expect(stepper.suggestions.some((item) => item.type === 'stepper-items-prop')).toBe(true);
87
131
  });
88
132
  it('returns a golden page source sample', () => {
89
133
  const result = read(handlers.getGoldenPage({ page: 'list-page' }));
@@ -138,14 +182,15 @@ describe('@morya-ui/mcp handlers', () => {
138
182
  it('keeps recommend_page focused on pattern matching', () => {
139
183
  const result = read(handlers.recommendPage({ intent: '用户列表页', pageType: 'list' }));
140
184
  expect(result.matchedPattern).toBe('admin-list');
141
- expect(result.nextStep).toContain('get_design_rules');
185
+ expect(result.nextStep).toContain('validate_usage');
186
+ expect(result.nextStep).toContain('get_golden_page');
142
187
  expect(result.pageStandards).toBeUndefined();
143
188
  expect(result.scrollStandards).toBeUndefined();
144
189
  });
145
190
  it('keeps get_golden_page focused on source code', () => {
146
191
  const result = read(handlers.getGoldenPage({ page: 'list-page' }));
147
192
  expect(result.source).toContain('MLayout');
148
- expect(result.nextStep).toContain('get_design_rules');
193
+ expect(result.nextStep).toContain('validate_usage');
149
194
  expect(result.pageStandards).toBeUndefined();
150
195
  expect(result.scrollStandards).toBeUndefined();
151
196
  });
@@ -188,6 +233,44 @@ describe('@morya-ui/mcp handlers', () => {
188
233
  expect(result.ok).toBe(true);
189
234
  expect(result.suggestions.some((item) => item.type === 'menu-missing-icons')).toBe(false);
190
235
  });
236
+ it('flags MTable data prop as contract advisory', () => {
237
+ const result = read(handlers.validatePage({
238
+ code: '<MTable :columns="columns" :data="rows" />',
239
+ }));
240
+ expect(result.ok).toBe(true);
241
+ expect(result.suggestions.some((item) => item.type === 'table-data-prop')).toBe(true);
242
+ });
243
+ it('flags MMessage severity as inline-alert misuse', () => {
244
+ const result = read(handlers.validatePage({
245
+ code: '<MMessage severity="error">登录失败</MMessage>',
246
+ }));
247
+ expect(result.ok).toBe(true);
248
+ expect(result.suggestions.some((item) => item.type === 'mmessage-as-alert')).toBe(true);
249
+ });
250
+ it('flags one-line toast string as message preference', () => {
251
+ const result = read(handlers.validatePage({
252
+ code: `toast.success('已保存')`,
253
+ }));
254
+ expect(result.ok).toBe(true);
255
+ expect(result.suggestions.some((item) => item.type === 'toast-one-liner')).toBe(true);
256
+ });
257
+ it('flags MTag in cell-status when MStatus is missing', () => {
258
+ const result = read(handlers.validatePage({
259
+ code: `<template #cell-status="{ value }"><MTag :value="value" /></template>`,
260
+ }));
261
+ expect(result.ok).toBe(true);
262
+ expect(result.suggestions.some((item) => item.type === 'status-cell-tag')).toBe(true);
263
+ });
264
+ it('returns detail-page and form-in-dialog golden samples', () => {
265
+ const detail = read(handlers.getGoldenPage({ page: 'detail-page' }));
266
+ expect(detail.id).toBe('detail-page');
267
+ expect(detail.source).toContain('MPageHeader');
268
+ expect(detail.source).toContain('MStatus');
269
+ const dialog = read(handlers.getGoldenPage({ page: 'form-in-dialog' }));
270
+ expect(dialog.id).toBe('form-in-dialog');
271
+ expect(dialog.source).toContain('MDialog');
272
+ expect(dialog.source).toContain('message.success');
273
+ });
191
274
  it('lists component decision guides when query is omitted', () => {
192
275
  const result = read(handlers.recommendComponent({ limit: 5 }));
193
276
  expect(result.kind).toBe('decisions');
@@ -199,6 +282,16 @@ describe('@morya-ui/mcp handlers', () => {
199
282
  expect(result.id).toBe('overlay-choice');
200
283
  expect(result.options.some((option) => option.component === 'Drawer')).toBe(true);
201
284
  });
285
+ it('covers current selection, status, empty, and menu guides', () => {
286
+ const selection = read(handlers.recommendComponent({ decision: 'selection-choice' }));
287
+ expect(selection.options.map((option) => option.component)).toEqual(expect.arrayContaining(['Select', 'CascadeSelect', 'Listbox', 'SelectButton', 'Radio']));
288
+ const status = read(handlers.recommendComponent({ decision: 'status-label-choice' }));
289
+ expect(status.id).toBe('status-label-choice');
290
+ const empty = read(handlers.recommendComponent({ decision: 'empty-result-choice' }));
291
+ expect(empty.options.map((option) => option.component)).toEqual(['Empty', 'Result']);
292
+ const menu = read(handlers.recommendComponent({ decision: 'action-menu-choice' }));
293
+ expect(menu.options.some((option) => option.component === 'ContextMenu')).toBe(true);
294
+ });
202
295
  it('exposes catalog health in version metadata', () => {
203
296
  const result = read(handlers.version());
204
297
  expect(result.health.ok).toBe(true);