@svadmin/create 0.34.0 → 0.35.1
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/blueprints/customer-workspace/ARCHITECTURE.md +56 -0
- package/blueprints/customer-workspace/README.md +61 -0
- package/blueprints/customer-workspace/playwright.config.ts +17 -0
- package/blueprints/customer-workspace/previews/approval-desktop.png +0 -0
- package/blueprints/customer-workspace/previews/approval-mobile.png +0 -0
- package/blueprints/customer-workspace/previews/dashboard-desktop.png +0 -0
- package/blueprints/customer-workspace/previews/dashboard-mobile.png +0 -0
- package/blueprints/customer-workspace/previews/detail-desktop.png +0 -0
- package/blueprints/customer-workspace/previews/detail-mobile.png +0 -0
- package/blueprints/customer-workspace/previews/form-desktop.png +0 -0
- package/blueprints/customer-workspace/previews/form-mobile.png +0 -0
- package/blueprints/customer-workspace/previews/list-desktop.png +0 -0
- package/blueprints/customer-workspace/previews/list-mobile.png +0 -0
- package/blueprints/customer-workspace/previews/settings-desktop.png +0 -0
- package/blueprints/customer-workspace/previews/settings-mobile.png +0 -0
- package/blueprints/customer-workspace/scripts/check-architecture.mjs +142 -0
- package/blueprints/customer-workspace/src/App.svelte +21 -0
- package/blueprints/customer-workspace/src/app.css +8 -0
- package/blueprints/customer-workspace/src/demo/provider.ts +110 -0
- package/blueprints/customer-workspace/src/design.svelte.ts +36 -0
- package/blueprints/customer-workspace/src/features/customers/Dashboard.svelte +48 -0
- package/blueprints/customer-workspace/src/features/customers/Detail.svelte +50 -0
- package/blueprints/customer-workspace/src/features/customers/Form.svelte +13 -0
- package/blueprints/customer-workspace/src/features/customers/List.svelte +10 -0
- package/blueprints/customer-workspace/src/features/customers/Review.svelte +18 -0
- package/blueprints/customer-workspace/src/features/customers/Settings.svelte +32 -0
- package/blueprints/customer-workspace/src/features/customers/contracts.ts +44 -0
- package/blueprints/customer-workspace/src/features/customers/data.ts +2 -0
- package/blueprints/customer-workspace/src/features/customers/index.ts +7 -0
- package/blueprints/customer-workspace/src/resources.ts +45 -0
- package/blueprints/customer-workspace/src/svadmin.config.ts +21 -0
- package/blueprints/customer-workspace/svadmin.vibe.json +45 -0
- package/blueprints/customer-workspace/tests/workspace.spec.ts +150 -0
- package/blueprints/customer-workspace/vibe-skill.md +58 -0
- package/dist/index.js +7920 -256
- package/package.json +6 -3
- package/scaffold-manifest.json +21 -21
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { ContentPageShell, Input, SettingsFieldRow, SettingsGroup } from '@svadmin/ui';
|
|
3
|
+
import { brand, designPresets, type DesignPresetId } from '../../design.svelte';
|
|
4
|
+
let { resourceName }: { resourceName: string } = $props();
|
|
5
|
+
function choosePreset(value: string) {
|
|
6
|
+
if (Object.hasOwn(designPresets, value)) brand.preset = value as DesignPresetId;
|
|
7
|
+
}
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<ContentPageShell title="工作区设置" pageId={resourceName} width="narrow">
|
|
11
|
+
<SettingsGroup title="品牌">
|
|
12
|
+
<SettingsFieldRow label="工作区名称">
|
|
13
|
+
{#snippet control()}<Input aria-label="工作区名称" bind:value={brand.name} maxlength={40} />{/snippet}
|
|
14
|
+
</SettingsFieldRow>
|
|
15
|
+
</SettingsGroup>
|
|
16
|
+
<SettingsGroup title="界面">
|
|
17
|
+
<SettingsFieldRow label="设计预设">
|
|
18
|
+
{#snippet control()}
|
|
19
|
+
<select aria-label="设计预设" value={brand.preset} onchange={event => choosePreset(event.currentTarget.value)}>
|
|
20
|
+
{#each Object.entries(designPresets) as [id, preset] (id)}
|
|
21
|
+
<option value={id}>{preset.label}</option>
|
|
22
|
+
{/each}
|
|
23
|
+
</select>
|
|
24
|
+
{/snippet}
|
|
25
|
+
</SettingsFieldRow>
|
|
26
|
+
</SettingsGroup>
|
|
27
|
+
</ContentPageShell>
|
|
28
|
+
|
|
29
|
+
<style>
|
|
30
|
+
select { max-width: 100%; min-height: 2.5rem; padding: .5rem; border: 1px solid var(--border); border-radius: .375rem; background: var(--background); color: var(--foreground); font: inherit; }
|
|
31
|
+
select:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
|
|
32
|
+
</style>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Type, type Static } from '@sinclair/typebox';
|
|
2
|
+
import { defineResource } from '@svadmin/core/resource-contract';
|
|
3
|
+
|
|
4
|
+
export const customerInput = Type.Object({
|
|
5
|
+
name: Type.String({ minLength: 1, maxLength: 160 }),
|
|
6
|
+
contact: Type.String({ minLength: 1, maxLength: 80 }),
|
|
7
|
+
email: Type.String({ minLength: 1, maxLength: 254, pattern: '^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$' }),
|
|
8
|
+
status: Type.Union([Type.Literal('potential'), Type.Literal('active'), Type.Literal('paused')]),
|
|
9
|
+
owner: Type.String({ minLength: 1 }),
|
|
10
|
+
notes: Type.String({ maxLength: 4000 }),
|
|
11
|
+
});
|
|
12
|
+
export const customerRecord = Type.Object({ id: Type.String(), ...customerInput.properties });
|
|
13
|
+
export type Customer = Static<typeof customerRecord>;
|
|
14
|
+
export const customers = defineResource('customers', {
|
|
15
|
+
record: customerRecord, create: customerInput, update: Type.Partial(customerInput),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const followupInput = Type.Object({
|
|
19
|
+
customerId: Type.String({ minLength: 1 }),
|
|
20
|
+
summary: Type.String({ minLength: 1, maxLength: 2000 }),
|
|
21
|
+
owner: Type.String({ minLength: 1 }),
|
|
22
|
+
date: Type.String({ pattern: '^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$' }),
|
|
23
|
+
});
|
|
24
|
+
export const followupRecord = Type.Object({ id: Type.String(), ...followupInput.properties });
|
|
25
|
+
export type Followup = Static<typeof followupRecord>;
|
|
26
|
+
export const followups = defineResource('followups', {
|
|
27
|
+
record: followupRecord, create: followupInput, update: Type.Partial(followupInput),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const approvalInput = Type.Object({
|
|
31
|
+
title: Type.String({ minLength: 1 }),
|
|
32
|
+
applicant: Type.String({ minLength: 1 }),
|
|
33
|
+
status: Type.Union([Type.Literal('pending'), Type.Literal('approved'), Type.Literal('rejected')]),
|
|
34
|
+
reason: Type.String({ minLength: 1 }),
|
|
35
|
+
});
|
|
36
|
+
export const approvalRecord = Type.Object({ id: Type.String(), ...approvalInput.properties });
|
|
37
|
+
export type Approval = Static<typeof approvalRecord>;
|
|
38
|
+
export const approvals = defineResource('approvals', {
|
|
39
|
+
record: approvalRecord, update: Type.Pick(approvalInput, ['status', 'reason']),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const workspaceSettings = defineResource('workspace_settings', {
|
|
43
|
+
record: Type.Object({ id: Type.String() }),
|
|
44
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { customers, followups, approvals, workspaceSettings } from './data';
|
|
2
|
+
export { default as CustomerList } from './List.svelte';
|
|
3
|
+
export { default as CustomerForm } from './Form.svelte';
|
|
4
|
+
export { default as CustomerDetail } from './Detail.svelte';
|
|
5
|
+
export { default as CustomerDashboard } from './Dashboard.svelte';
|
|
6
|
+
export { default as ApprovalReview } from './Review.svelte';
|
|
7
|
+
export { default as WorkspaceSettings } from './Settings.svelte';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { AdminResourceDefinition } from '@svadmin/core';
|
|
2
|
+
import { customers, followups, approvals, workspaceSettings } from './features/customers/data';
|
|
3
|
+
|
|
4
|
+
export const uiOnlyResources: readonly string[] = ['workspace_settings'];
|
|
5
|
+
|
|
6
|
+
export const resources: AdminResourceDefinition[] = [
|
|
7
|
+
{
|
|
8
|
+
name: 'customers', label: '客户', icon: 'users', contract: customers, canDelete: false,
|
|
9
|
+
fields: [
|
|
10
|
+
{ key: 'id', label: '编号', type: 'text', showInForm: false, showInList: false },
|
|
11
|
+
{ key: 'name', label: '客户名称', type: 'text', required: true, searchable: true, group: '基本信息' },
|
|
12
|
+
{ key: 'status', label: '状态', type: 'select', required: true, defaultValue: 'potential', group: '基本信息',
|
|
13
|
+
options: [{ label: '潜在客户', value: 'potential' }, { label: '合作中', value: 'active' }, { label: '已暂停', value: 'paused' }] },
|
|
14
|
+
{ key: 'contact', label: '联系人', type: 'text', required: true, group: '联系信息' },
|
|
15
|
+
{ key: 'email', label: '邮箱', type: 'email', required: true, showInList: false, group: '联系信息' },
|
|
16
|
+
{ key: 'owner', label: '负责人', type: 'text', required: true, group: '跟进安排' },
|
|
17
|
+
{ key: 'notes', label: '备注', type: 'textarea', defaultValue: '', showInList: false, group: '跟进安排' },
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: 'followups', label: '跟进记录', icon: 'calendar', contract: followups, canDelete: false,
|
|
22
|
+
fields: [
|
|
23
|
+
{ key: 'id', label: '编号', type: 'text', showInForm: false, showInList: false },
|
|
24
|
+
{ key: 'customerId', label: '客户', type: 'relation', resource: 'customers', optionLabel: 'name', optionValue: 'id', required: true },
|
|
25
|
+
{ key: 'summary', label: '跟进内容', type: 'textarea', required: true, searchable: true },
|
|
26
|
+
{ key: 'owner', label: '负责人', type: 'text', required: true },
|
|
27
|
+
{ key: 'date', label: '跟进日期', type: 'date', required: true },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'approvals', label: '审批', icon: 'check', contract: approvals, canCreate: false, canDelete: false,
|
|
32
|
+
fields: [
|
|
33
|
+
{ key: 'id', label: '编号', type: 'text', showInForm: false, showInList: false },
|
|
34
|
+
{ key: 'title', label: '申请事项', type: 'text', showInForm: false, searchable: true },
|
|
35
|
+
{ key: 'applicant', label: '申请人', type: 'text', showInForm: false },
|
|
36
|
+
{ key: 'status', label: '审批结果', type: 'select', required: true,
|
|
37
|
+
options: [{ label: '待审批', value: 'pending' }, { label: '已通过', value: 'approved' }, { label: '已驳回', value: 'rejected' }] },
|
|
38
|
+
{ key: 'reason', label: '审批意见', type: 'textarea', required: true, showInList: false },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'workspace_settings', label: '工作区设置', icon: 'settings', contract: workspaceSettings, fields: [],
|
|
43
|
+
canCreate: false, canEdit: false, canDelete: false, canShow: false,
|
|
44
|
+
},
|
|
45
|
+
];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createProviderBundle, defineAdminConfig } from '@svadmin/app';
|
|
2
|
+
import { resources } from './resources';
|
|
3
|
+
import { createDemoProvider, type DemoScenario } from './demo/provider';
|
|
4
|
+
|
|
5
|
+
const requested = import.meta.env.DEV ? new URLSearchParams(location.search).get('scenario') : null;
|
|
6
|
+
const scenarios: readonly string[] = ['normal', 'empty', 'error', 'loading', 'denied', 'partial', 'readonly'];
|
|
7
|
+
const scenario: DemoScenario = requested !== null && scenarios.includes(requested) ? requested as DemoScenario : 'normal';
|
|
8
|
+
|
|
9
|
+
export default defineAdminConfig({
|
|
10
|
+
name: 'customer-workspace',
|
|
11
|
+
providers: createProviderBundle({
|
|
12
|
+
dataProvider: createDemoProvider(scenario),
|
|
13
|
+
accessControlProvider: {
|
|
14
|
+
can: async ({ action }) => ({
|
|
15
|
+
can: scenario !== 'denied' && (scenario !== 'readonly' || ['list', 'show', 'field'].includes(action)),
|
|
16
|
+
}),
|
|
17
|
+
options: { buttons: { enableAccessControl: true, hideIfUnauthorized: true } },
|
|
18
|
+
},
|
|
19
|
+
}),
|
|
20
|
+
resources,
|
|
21
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"id": "customer-workspace",
|
|
4
|
+
"status": "starter",
|
|
5
|
+
"framework": "svelte5-vite",
|
|
6
|
+
"design": "src/design.svelte.ts",
|
|
7
|
+
"selection": "src/design-selection.ts",
|
|
8
|
+
"presets": ["operations", "enterprise", "collaboration"],
|
|
9
|
+
"contracts": "src/features/customers/contracts.ts",
|
|
10
|
+
"resources": "src/resources.ts",
|
|
11
|
+
"provider": "src/svadmin.config.ts",
|
|
12
|
+
"dataMode": "in-memory-demo",
|
|
13
|
+
"previews": {
|
|
14
|
+
"desktop": "previews/{pageId}-desktop.png",
|
|
15
|
+
"mobile": "previews/{pageId}-mobile.png",
|
|
16
|
+
"note": "Reference captures of this starter, not proof that subsequent customer edits passed acceptance."
|
|
17
|
+
},
|
|
18
|
+
"pages": [
|
|
19
|
+
{ "id": "list", "source": "src/features/customers/List.svelte", "route": "/customers", "intent": "检索、筛选和进入客户记录", "tags": ["列表", "客户", "search", "filter", "table", "crud"], "components": ["ContentPageShell", "AutoTable"] },
|
|
20
|
+
{ "id": "detail", "source": "src/features/customers/Detail.svelte", "route": "/customers/show/c1", "intent": "查看客户与最近跟进", "tags": ["详情", "跟进", "record", "activity"], "components": ["ShowPage", "PageSection", "DataState"] },
|
|
21
|
+
{ "id": "form", "source": "src/features/customers/Form.svelte", "route": "/customers/create", "intent": "分组录入并校验客户信息", "tags": ["表单", "创建", "编辑", "create", "edit", "validation"], "components": ["AutoForm", "ContentPageShell"] },
|
|
22
|
+
{ "id": "dashboard", "source": "src/features/customers/Dashboard.svelte", "route": "/", "intent": "查看业务总量并处理下一项工作", "tags": ["概览", "工作台", "指标", "overview", "metrics"], "components": ["DashboardPage", "MetricBlock", "PageSection", "AutoTable"] },
|
|
23
|
+
{ "id": "settings", "source": "src/features/customers/Settings.svelte", "route": "/workspace_settings", "intent": "调整会话内品牌和应用级设计预设", "tags": ["设置", "品牌", "主题", "brand", "theme"], "components": ["SettingsGroup", "SettingsFieldRow", "Input"] },
|
|
24
|
+
{ "id": "approval", "source": "src/features/customers/Review.svelte", "route": "/approvals/show/a1", "intent": "审阅申请、记录决定与意见", "tags": ["审批", "审核", "review", "decision"], "components": ["ShowPage", "AutoForm", "PageSection"] }
|
|
25
|
+
],
|
|
26
|
+
"states": ["normal", "empty", "error", "loading", "denied", "partial", "readonly"],
|
|
27
|
+
"acceptance": {
|
|
28
|
+
"commands": ["bun run check", "bun run build", "bun run test:ui"],
|
|
29
|
+
"viewports": [{"width": 1440, "height": 900}, {"width": 390, "height": 844}],
|
|
30
|
+
"screenshots": "test-results",
|
|
31
|
+
"maxRepairRounds": 2,
|
|
32
|
+
"humanReview": ["视觉层级与品牌契合度", "真实业务字段与审批权限", "接入生产前的数据边界"]
|
|
33
|
+
},
|
|
34
|
+
"componentApi": {
|
|
35
|
+
"package": "@svadmin/ui",
|
|
36
|
+
"metadata": "@svadmin/ui/component-registry",
|
|
37
|
+
"rule": "Read the installed version's declarations for components absent from the registry; never invent props."
|
|
38
|
+
},
|
|
39
|
+
"boundaries": [
|
|
40
|
+
"No model credentials or production writes",
|
|
41
|
+
"No generic application generation through Surface",
|
|
42
|
+
"No claims of server authorization or durable data",
|
|
43
|
+
"Do not replace existing business workflows during visual edits"
|
|
44
|
+
]
|
|
45
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { test, expect, type Page } from '@playwright/test';
|
|
2
|
+
import catalog from '../svadmin.vibe.json' with { type: 'json' };
|
|
3
|
+
|
|
4
|
+
function visibleText(page: Page, text: string | RegExp) {
|
|
5
|
+
return page.getByText(text, { exact: typeof text === 'string' }).filter({ visible: true });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
test.beforeEach(async ({ page }) => {
|
|
9
|
+
// 隔离外部字体服务,页面验收仅依赖本地应用和系统备用字体。
|
|
10
|
+
await page.route('https://fonts.bunny.net/**', route => route.abort());
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('keyboard skip link does not cover navigation', async ({ page, isMobile }) => {
|
|
14
|
+
await page.goto('/#/customers');
|
|
15
|
+
const skip = page.locator('[data-svadmin-skip-link]');
|
|
16
|
+
await expect(skip).toBeAttached();
|
|
17
|
+
await expect.poll(() => skip.evaluate(el => el.getBoundingClientRect().bottom)).toBeLessThanOrEqual(0);
|
|
18
|
+
await skip.focus();
|
|
19
|
+
await expect.poll(() => skip.evaluate(el => el.getBoundingClientRect().top)).toBeGreaterThanOrEqual(0);
|
|
20
|
+
await page.keyboard.press('Enter');
|
|
21
|
+
await expect(page.locator('main')).toBeFocused();
|
|
22
|
+
if (isMobile) {
|
|
23
|
+
await page.getByRole('button', { name: '菜单', exact: true }).click();
|
|
24
|
+
await expect(page.locator('a[href="#/customers"]').filter({ visible: true })).toBeVisible();
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
for (const entry of catalog.pages) {
|
|
29
|
+
test(`${entry.id} renders without page overflow`, async ({ page }, testInfo) => {
|
|
30
|
+
const errors: string[] = [];
|
|
31
|
+
page.on('pageerror', error => errors.push(error.message));
|
|
32
|
+
await page.goto(`/#${entry.route}`);
|
|
33
|
+
await expect(page.locator('main')).toBeVisible();
|
|
34
|
+
await expect(page.locator('main')).not.toBeEmpty();
|
|
35
|
+
const pageId = {
|
|
36
|
+
list: 'customer-list', detail: 'customer-detail', form: 'customer-form',
|
|
37
|
+
dashboard: 'dashboard', settings: 'workspace_settings', approval: 'approval-review',
|
|
38
|
+
}[entry.id];
|
|
39
|
+
await expect(page.locator(`[data-svadmin-content-page="${pageId}"]`)).toBeVisible();
|
|
40
|
+
if (entry.id === 'detail') await expect(page.getByText('发送实施方案,等待客户确认。')).toBeVisible();
|
|
41
|
+
if (entry.id === 'approval') await expect(page.getByRole('heading', { name: '审批决定' })).toBeVisible();
|
|
42
|
+
if (entry.id === 'list') await expect(visibleText(page, '澄川科技')).toBeVisible();
|
|
43
|
+
await expect(page.locator('[aria-busy="true"]')).toHaveCount(0);
|
|
44
|
+
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
|
45
|
+
await page.screenshot({ path: testInfo.outputPath(`${entry.id}.png`), fullPage: true, animations: 'disabled' });
|
|
46
|
+
expect(errors).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test('customer creation, detail, edit, and validation', async ({ page }) => {
|
|
51
|
+
await page.goto('/#/customers/create');
|
|
52
|
+
await page.getByRole('button', { name: '保存', exact: true }).click();
|
|
53
|
+
await expect(page.locator('[aria-invalid="true"]').first()).toBeVisible();
|
|
54
|
+
await page.getByLabel('客户名称', { exact: false }).fill('验收客户');
|
|
55
|
+
await page.getByLabel('联系人', { exact: false }).fill('林悦');
|
|
56
|
+
await page.getByLabel('邮箱', { exact: false }).fill('acceptance@example.test');
|
|
57
|
+
await page.getByLabel('负责人', { exact: false }).fill('陈晨');
|
|
58
|
+
await page.getByRole('button', { name: '保存', exact: true }).click();
|
|
59
|
+
await expect(page).toHaveURL(/#\/customers(?:\?|$)/);
|
|
60
|
+
await expect(visibleText(page, '验收客户')).toBeVisible();
|
|
61
|
+
await page.evaluate(() => { location.hash = '/customers/show/c1'; });
|
|
62
|
+
await expect(page.getByText('发送实施方案,等待客户确认。')).toBeVisible();
|
|
63
|
+
await page.evaluate(() => { location.hash = '/customers/edit/c1'; });
|
|
64
|
+
await page.getByLabel('客户名称', { exact: false }).fill('澄川科技更新');
|
|
65
|
+
await page.getByRole('button', { name: '保存', exact: true }).click();
|
|
66
|
+
await expect(visibleText(page, '澄川科技更新')).toBeVisible();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('global design preset changes without losing customer data', async ({ page }) => {
|
|
70
|
+
await page.goto('/#/workspace_settings');
|
|
71
|
+
await page.getByRole('combobox', { name: '设计预设', exact: true }).selectOption('operations');
|
|
72
|
+
await page.evaluate(() => { location.hash = '/customers'; });
|
|
73
|
+
await expect(page.locator('[data-svadmin-content-page="customer-list"]')).toHaveAttribute('data-density', 'compact');
|
|
74
|
+
await expect(visibleText(page, '澄川科技')).toBeVisible();
|
|
75
|
+
await page.evaluate(() => { location.hash = '/workspace_settings'; });
|
|
76
|
+
await page.getByRole('combobox', { name: '设计预设', exact: true }).selectOption('collaboration');
|
|
77
|
+
await page.evaluate(() => { location.hash = '/customers/create'; });
|
|
78
|
+
await expect(page.locator('[data-svadmin-content-page="customer-form"]')).toHaveAttribute('data-svadmin-content-page-width', 'default');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
for (const preset of catalog.presets) {
|
|
82
|
+
test(`design preset: ${preset}`, async ({ page }, testInfo) => {
|
|
83
|
+
await page.goto('/#/workspace_settings');
|
|
84
|
+
await page.getByRole('combobox', { name: '设计预设', exact: true }).selectOption(preset);
|
|
85
|
+
await page.evaluate(() => { location.hash = '/customers'; });
|
|
86
|
+
await expect(visibleText(page, '澄川科技')).toBeVisible();
|
|
87
|
+
await expect(page.locator('[data-svadmin-content-page="customer-list"]')).toHaveAttribute('data-density', preset === 'operations' ? 'compact' : 'comfortable');
|
|
88
|
+
await page.screenshot({ path: testInfo.outputPath(`${preset}.png`), fullPage: true, animations: 'disabled' });
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
test('approval decision and followup editing persist in the session', async ({ page }) => {
|
|
93
|
+
await page.goto('/#/approvals/show/a1');
|
|
94
|
+
await page.getByRole('combobox', { name: '审批结果', exact: false }).selectOption({ label: '已通过' });
|
|
95
|
+
await page.getByLabel('审批意见', { exact: false }).fill('已核实交付范围,可以通过。');
|
|
96
|
+
await page.getByRole('button', { name: '保存', exact: true }).click();
|
|
97
|
+
await expect(page).toHaveURL(/#\/approvals(?:\?|$)/);
|
|
98
|
+
await page.evaluate(() => { location.hash = '/approvals/show/a1'; });
|
|
99
|
+
await expect(page.getByLabel('审批意见', { exact: false })).toHaveValue('已核实交付范围,可以通过。');
|
|
100
|
+
await expect(page.getByRole('combobox', { name: '审批结果', exact: false }).locator('option:checked')).toHaveText('已通过');
|
|
101
|
+
await page.evaluate(() => { location.hash = '/followups/edit/f1'; });
|
|
102
|
+
await page.getByLabel('跟进内容', { exact: false }).fill('确认合同与实施时间。');
|
|
103
|
+
await page.getByRole('button', { name: '保存', exact: true }).click();
|
|
104
|
+
await expect(visibleText(page, '确认合同与实施时间。')).toBeVisible();
|
|
105
|
+
await page.evaluate(() => { location.hash = '/followups/create'; });
|
|
106
|
+
await page.locator('button#customerId').click();
|
|
107
|
+
await page.getByRole('option', { name: '远山设计事务所', exact: true }).click();
|
|
108
|
+
await page.getByLabel('跟进内容', { exact: false }).fill('完成首次需求访谈。');
|
|
109
|
+
await page.getByLabel('负责人', { exact: false }).fill('李沐');
|
|
110
|
+
await page.getByLabel('跟进日期', { exact: false }).fill('2026-09-24');
|
|
111
|
+
await page.getByRole('button', { name: '保存', exact: true }).click();
|
|
112
|
+
await expect(visibleText(page, '完成首次需求访谈。')).toBeVisible();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('read-only access allows detail but refuses edits', async ({ page }) => {
|
|
116
|
+
await page.goto('/?scenario=readonly#/customers/show/c1');
|
|
117
|
+
await expect(page.getByText('澄川科技', { exact: true })).toBeVisible();
|
|
118
|
+
await page.evaluate(() => { location.hash = '/customers/edit/c1'; });
|
|
119
|
+
await expect(page.getByText(/无权|权限|拒绝/).first()).toBeVisible();
|
|
120
|
+
await expect(page.getByRole('button', { name: '保存', exact: true })).toHaveCount(0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
for (const scenario of ['empty', 'error', 'denied', 'loading', 'partial']) {
|
|
124
|
+
test(`provider state: ${scenario}`, async ({ page }, testInfo) => {
|
|
125
|
+
if (scenario === 'loading') {
|
|
126
|
+
// 在导航前暂停演示延迟,避免慢速加载错过骨架屏。
|
|
127
|
+
await page.clock.install({ time: new Date('2026-09-25T00:00:00Z') });
|
|
128
|
+
await page.clock.pauseAt(new Date('2026-09-25T00:01:00Z'));
|
|
129
|
+
}
|
|
130
|
+
await page.goto(`/?scenario=${scenario}#${scenario === 'partial' ? '/' : '/customers'}`);
|
|
131
|
+
if (scenario === 'loading') {
|
|
132
|
+
await expect(page.locator('[data-slot="skeleton"]').filter({ visible: true }).first()).toBeVisible();
|
|
133
|
+
} else if (scenario === 'partial') {
|
|
134
|
+
await expect(page.getByText('部分数据暂不可用')).toBeVisible();
|
|
135
|
+
} else if (scenario === 'denied') {
|
|
136
|
+
await expect(page.getByText(/无权|权限|拒绝/).first()).toBeVisible();
|
|
137
|
+
await expect(page.getByText('澄川科技', { exact: true })).toHaveCount(0);
|
|
138
|
+
} else if (scenario === 'empty') {
|
|
139
|
+
await expect(page.getByRole('heading', { name: '暂无数据', exact: true })).toBeVisible();
|
|
140
|
+
} else {
|
|
141
|
+
await expect(page.getByText(/失败|不可用|错误/).first()).toBeVisible();
|
|
142
|
+
}
|
|
143
|
+
await page.screenshot({ path: testInfo.outputPath(`${scenario}.png`), fullPage: true, animations: 'disabled' });
|
|
144
|
+
if (scenario === 'loading') {
|
|
145
|
+
await page.clock.runFor(2500);
|
|
146
|
+
await expect(visibleText(page, '澄川科技')).toBeVisible();
|
|
147
|
+
await expect(page.locator('[data-slot="skeleton"]').filter({ visible: true })).toHaveCount(0);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: svadmin-vibe
|
|
3
|
+
description: Build and refine Svelte admin pages from the installed svadmin page catalog, shared design presets, and resource contracts. Use for customer-workspace UI changes and visual acceptance, not production deployment.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# svadmin Vibe
|
|
7
|
+
|
|
8
|
+
Read `DESIGN.md`, `svadmin.vibe.json`, and `svadmin.ai.json`. Select the smallest
|
|
9
|
+
matching page family from `pages`; inspect its source and the relevant resource
|
|
10
|
+
contract before editing. The catalog is a starting point, not permission to
|
|
11
|
+
replace the customer's workflow with this demo's business model.
|
|
12
|
+
Inspect the selected page's `previews/{pageId}-desktop.png` and mobile capture
|
|
13
|
+
when deciding layout. These shipped references are not current test evidence.
|
|
14
|
+
For shipped references, `create-svadmin vibe catalog --query "<keywords>"`
|
|
15
|
+
matches IDs, component names and Chinese/English tags (all words must match).
|
|
16
|
+
`create-svadmin vibe inspect <pageId>` returns read-only reference context.
|
|
17
|
+
Read `ARCHITECTURE.md`; cross-feature imports use public `index.ts` or headless
|
|
18
|
+
`data.ts`. Do not confuse shipped reference source with the customer's edits.
|
|
19
|
+
When the read-only svadmin MCP tools are available, use `svadmin_vibe_search`,
|
|
20
|
+
then `svadmin_vibe_inspect` and `svadmin_vibe_preview` for desktop and mobile.
|
|
21
|
+
Pass an empty object to search all pages. These tools read shipped references
|
|
22
|
+
only; inspect the current customer's sources separately before making changes.
|
|
23
|
+
The CLI commands remain the fallback when no MCP client is configured.
|
|
24
|
+
|
|
25
|
+
## Implement
|
|
26
|
+
|
|
27
|
+
- Read installed `@svadmin/ui/component-registry` metadata and component type
|
|
28
|
+
declarations. Match the versions in package.json; do not invent props or copy
|
|
29
|
+
internal/example-only imports.
|
|
30
|
+
- Translate "compact", "standard", or "lightweight" requests into the existing
|
|
31
|
+
application-wide presets in `src/design.svelte.ts`. Keep layout, form columns,
|
|
32
|
+
table density, and detail layout consistent. Change `src/design-selection.ts`
|
|
33
|
+
for the initial preset. Do not silently reset a user's saved preferences.
|
|
34
|
+
- Preserve resource contracts, providers, permissions, routes, and active form
|
|
35
|
+
values when making visual-only changes. Keep metric values provider-backed.
|
|
36
|
+
- Modify the chosen feature source, not a new parallel implementation. Use
|
|
37
|
+
semantic tokens and public component composition. No decorative nested cards.
|
|
38
|
+
- Demo data and simulated errors are under `src/demo/` and development-only
|
|
39
|
+
scenario selection. Do not copy demo permission grants into a real backend.
|
|
40
|
+
Business authorization, approval rules, audit, and persistence belong on the
|
|
41
|
+
server, not in model output.
|
|
42
|
+
|
|
43
|
+
## Verify
|
|
44
|
+
|
|
45
|
+
Run `bun run check`, `bun run build`, and `bun run test:ui`. Inspect the captured
|
|
46
|
+
desktop/mobile screenshots, not only test exit codes. Tests cover six page
|
|
47
|
+
families, CRUD navigation, form validation, provider states, and preset changes.
|
|
48
|
+
Extend focused tests when adding business behavior.
|
|
49
|
+
|
|
50
|
+
Use at most two targeted visual repair rounds. Report failures and evidence
|
|
51
|
+
paths after that; never claim screenshots or deterministic checks prove taste.
|
|
52
|
+
Ask for human judgement when brand direction is ambiguous. Do not change
|
|
53
|
+
business logic to make a visual check pass, update screenshot baselines blindly,
|
|
54
|
+
or contact production services for preview data.
|
|
55
|
+
|
|
56
|
+
Deliver changed files, check results, screenshot paths, and remaining
|
|
57
|
+
human/backend acceptance. Publication, deployment, and model-provider accounts
|
|
58
|
+
are separate authorization boundaries.
|