@svadmin/create 0.40.3 → 0.41.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/README.md CHANGED
@@ -119,6 +119,19 @@ bun install
119
119
  bun run dev
120
120
  ```
121
121
 
122
+ ## Product-first Vibe Loop
123
+
124
+ Use a product plan when a feature request is larger than one CRUD resource. The plan keeps business workflows, resources, pages, actions, and navigation separate so generated projects do not expose every backend table as a page.
125
+
126
+ ```bash
127
+ # Read product issues before generating UI
128
+ npx @svadmin/create vibe review ./svadmin.product.json
129
+
130
+ # Preview the experience for one role; omit --role to inspect all roles
131
+ npx @svadmin/create vibe plan ./svadmin.product.json --role operator
132
+ ```
133
+
134
+ The plan uses schema version `product/v1`. Menu entries are driven by actual business needs and role/task context; svadmin does not apply a global menu-count cap. Generated starters include `PRODUCT.md` alongside `DESIGN.md` and `AGENTS.md`.
122
135
  ## Automated Inference CLI / 接口代码推断器
123
136
 
124
137
  Automatically infer ResourceDefinitions, TypeBox schemas, and Svelte 5 CRUD components from REST endpoints, OpenAPI / Swagger specs, GraphQL endpoints (introspection), or local schema/sample files:
@@ -5,7 +5,7 @@ description: Build and refine Svelte admin pages from the installed svadmin page
5
5
 
6
6
  # svadmin Vibe
7
7
 
8
- Read `DESIGN.md`, `svadmin.vibe.json`, and `svadmin.ai.json`. Select the smallest
8
+ Read `PRODUCT.md`, `DESIGN.md`, `svadmin.vibe.json`, and `svadmin.ai.json`. Run `svadmin vibe review ./svadmin.product.json` before generating a multi-resource feature. Select the smallest
9
9
  matching page family from `pages`; inspect its source and the relevant resource
10
10
  contract before editing. The catalog is a starting point, not permission to
11
11
  replace the customer's workflow with this demo's business model.
@@ -22,6 +22,13 @@ Pass an empty object to search all pages. These tools read shipped references
22
22
  only; inspect the current customer's sources separately before making changes.
23
23
  The CLI commands remain the fallback when no MCP client is configured.
24
24
 
25
+ ## Product boundary
26
+
27
+ - Start from the user's outcome and workflows, not from the complete resource registry.
28
+ - Treat resources as data contracts; only create a surface or menu node when a workflow, role, or business context justifies it.
29
+ - Keep menu count proportional to the actual domain. Never remove required business entries to satisfy a global limit.
30
+ - Keep one dominant action per surface and use the review command to catch dense action/section layouts before markup.
31
+
25
32
  ## Implement
26
33
 
27
34
  - Read installed `@svadmin/ui/component-registry` metadata and component type
@@ -0,0 +1,18 @@
1
+ # Product workspace boundaries
2
+
3
+ Product mode separates product decisions from runtime resources:
4
+
5
+ - `svadmin.product.json` is the source of truth for roles, workflows, resources,
6
+ surfaces and menu entries.
7
+ - `src/generated/product-plan.ts` parses the plan at the application boundary.
8
+ - `src/generated/resources.ts` exposes read-only resource metadata to svadmin.
9
+ - `src/generated/navigation.ts` filters product menu entries by role and feature
10
+ flags. There is no fixed menu count.
11
+ - `src/generated/pages.ts` contains the renderer-neutral page plan.
12
+ - `src/generated/page-routes.ts` registers custom surface routes.
13
+ - `src/product-provider.ts` is an empty in-memory adapter for visual review.
14
+
15
+ Product actions are metadata until a typed command or provider contract is
16
+ connected. The generated scaffold never claims that a button performed a real
17
+ mutation. Server authorization, tenancy, audit, idempotency and persistence
18
+ remain backend responsibilities.
@@ -0,0 +1,14 @@
1
+ # Product workspace
2
+
3
+ This blueprint is generated from a validated `svadmin.product.json`. It keeps
4
+ product decisions, navigation and page metadata in generated files so the
5
+ application starts with a coherent information architecture instead of a
6
+ database-shaped menu.
7
+
8
+ The default provider is an empty in-memory provider. It is useful for visual
9
+ review and route acceptance, but it is not a backend, an authorization boundary,
10
+ or a source of business data. Wire an explicit provider and command contract
11
+ before enabling production mutations.
12
+
13
+ Generated files under `src/generated` are derived artifacts. Edit the product
14
+ plan and regenerate the project rather than editing those files by hand.
@@ -0,0 +1,23 @@
1
+ <script lang="ts">
2
+ import { AdminApp } from '@svadmin/ui';
3
+ import { resolveAdminConfig } from '@svadmin/app';
4
+ import config from './svadmin.config';
5
+ import { brand, getDesign } from './design.svelte';
6
+ import { productMenu } from './generated/navigation';
7
+ import { productResources } from './generated/resources';
8
+ import { productPageRoutes } from './generated/page-routes';
9
+
10
+ const resolved = resolveAdminConfig(config);
11
+ const design = $derived(getDesign());
12
+ </script>
13
+
14
+ <AdminApp
15
+ providerBundle={resolved.providers}
16
+ resources={[...productResources]}
17
+ menu={productMenu}
18
+ pageRoutes={productPageRoutes}
19
+ title={brand.name}
20
+ locale="zh-CN"
21
+ themeConfig={design.theme}
22
+ queryClientDefaultOptions={{ queries: { retry: false } }}
23
+ />
@@ -0,0 +1,132 @@
1
+ <script lang="ts">
2
+ import type { ProductPagePlan } from '@svadmin/core/product-pages';
3
+ import { productPagePlans, productSurfaceRoutes } from './generated/pages';
4
+
5
+ interface Props {
6
+ route: string;
7
+ params: Record<string, string | undefined>;
8
+ }
9
+
10
+ type SurfaceState = ProductPagePlan['states'][number];
11
+ const stateByScenario: Record<string, SurfaceState | undefined> = {
12
+ empty: 'empty',
13
+ loading: 'loading',
14
+ error: 'error',
15
+ denied: 'forbidden',
16
+ };
17
+
18
+ let { route }: Props = $props();
19
+ const page = $derived(productPagePlans.find(candidate => productSurfaceRoutes[route] === candidate.id));
20
+ const requestedScenario = typeof location !== 'undefined'
21
+ ? new URLSearchParams(location.search).get('scenario') ?? 'normal'
22
+ : 'normal';
23
+ const scenarioState = $derived.by(() => {
24
+ const candidate = page;
25
+ const state = stateByScenario[requestedScenario];
26
+ return candidate && state !== undefined && candidate.states.includes(state) ? state : undefined;
27
+ });
28
+ </script>
29
+
30
+ <svelte:head>
31
+ <title>{page?.title ?? 'Product surface'}</title>
32
+ </svelte:head>
33
+
34
+ <div class="surface-page">
35
+ {#if page}
36
+ <header class="surface-hero">
37
+ <div>
38
+ <p class="eyebrow">{page.kind}</p>
39
+ <h1>{page.title}</h1>
40
+ <p class="purpose">{page.purpose}</p>
41
+ </div>
42
+ {#if page.primaryAction}
43
+ <span class="action-badge">主操作:{page.primaryAction.label}</span>
44
+ {/if}
45
+ </header>
46
+
47
+ <div class="state-strip">
48
+ {#each page.states as state (state)}
49
+ <span class="state">{state}</span>
50
+ {/each}
51
+ </div>
52
+
53
+ {#if scenarioState}
54
+ <section class="surface-state" data-state={scenarioState}>
55
+ {#if scenarioState === 'loading'}
56
+ <span class="state-icon" aria-hidden="true">◌</span>
57
+ <h2>正在加载工作台</h2>
58
+ <p>正在准备当前工作流需要的数据。</p>
59
+ {:else if scenarioState === 'empty'}
60
+ <span class="state-icon" aria-hidden="true">○</span>
61
+ <h2>当前没有待处理数据</h2>
62
+ <p>工作流已经准备好;当业务数据进入后,这里会显示下一步操作。</p>
63
+ {:else if scenarioState === 'error'}
64
+ <span class="state-icon" aria-hidden="true">!</span>
65
+ <h2>暂时无法加载工作台</h2>
66
+ <p>数据 provider 返回了错误,请稍后重试或联系管理员。</p>
67
+ {:else if scenarioState === 'forbidden'}
68
+ <span class="state-icon" aria-hidden="true">×</span>
69
+ <h2>没有访问权限</h2>
70
+ <p>当前角色没有访问这个工作流的权限。</p>
71
+ {/if}
72
+ </section>
73
+ {:else}
74
+ <div class="surface-grid">
75
+ {#each page.sections as section (section.id)}
76
+ <section class="surface-card">
77
+ <div class="card-heading">
78
+ <h2>{section.title}</h2>
79
+ <span>区块</span>
80
+ </div>
81
+ <p>此区块服务于当前工作流,并展示产品计划声明的资源。</p>
82
+ {#if section.resourceIds.length}
83
+ <div class="resource-list">
84
+ {#each section.resourceIds as resourceId (resourceId)}
85
+ <span>{resourceId}</span>
86
+ {/each}
87
+ </div>
88
+ {:else}
89
+ <p class="muted">此区块暂无数据资源,等待接入真实 provider。</p>
90
+ {/if}
91
+ </section>
92
+ {/each}
93
+ </div>
94
+
95
+ <aside class="contract-note">
96
+ <strong>动作仍是产品元数据</strong>
97
+ <span>接入 defineCommand 或资源契约后,再启用真实提交、审批和写入。</span>
98
+ </aside>
99
+ {/if}
100
+ {:else}
101
+ <section class="surface-card">
102
+ <h1>未找到页面计划</h1>
103
+ <p>路径 {route} 尚未在产品计划中注册。</p>
104
+ </section>
105
+ {/if}
106
+ </div>
107
+
108
+ <style>
109
+ .surface-page { max-width: 1180px; margin: 0 auto; padding: 32px; color: #172033; }
110
+ .surface-hero { display: flex; justify-content: space-between; gap: 24px; align-items: flex-end; margin-bottom: 18px; }
111
+ .eyebrow { margin: 0 0 8px; text-transform: uppercase; letter-spacing: .12em; color: #65708a; font-size: 12px; font-weight: 700; }
112
+ h1, h2, p { margin-top: 0; }
113
+ h1 { margin-bottom: 8px; font-size: clamp(28px, 4vw, 46px); letter-spacing: -.03em; }
114
+ h2 { font-size: 18px; }
115
+ .purpose { max-width: 700px; color: #65708a; font-size: 16px; line-height: 1.6; }
116
+ .action-badge, .state, .resource-list span { border-radius: 999px; background: #e8eefc; color: #3558a8; padding: 7px 11px; font-size: 12px; font-weight: 650; }
117
+ .state-strip { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
118
+ .state { background: #eef0f3; color: #596273; }
119
+ .surface-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
120
+ .surface-state { display: grid; justify-items: center; gap: 8px; min-height: 220px; padding: 42px 24px; border: 1px dashed #cdd5e4; border-radius: 18px; background: #f8faff; text-align: center; }
121
+ .surface-state h2, .surface-state p { margin: 0; }
122
+ .surface-state p { max-width: 520px; color: #65708a; line-height: 1.6; }
123
+ .state-icon { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 50%; background: #e8eefc; color: #3558a8; font-size: 22px; font-weight: 700; }
124
+ .surface-card { min-height: 150px; padding: 22px; border: 1px solid #e4e7ed; border-radius: 18px; background: #fff; box-shadow: 0 10px 30px rgb(32 45 72 / 5%); }
125
+ .card-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
126
+ .card-heading span { color: #8a94a7; font-size: 12px; }
127
+ .surface-card p { color: #65708a; line-height: 1.6; }
128
+ .resource-list { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 18px; }
129
+ .muted { color: #98a1b2 !important; font-size: 14px; }
130
+ .contract-note { display: flex; flex-direction: column; gap: 5px; margin-top: 18px; padding: 16px 18px; border-left: 3px solid #6f8fe8; background: #f0f4ff; color: #33425f; line-height: 1.5; }
131
+ @media (max-width: 640px) { .surface-page { padding: 20px 16px; } .surface-hero { align-items: flex-start; flex-direction: column; } }
132
+ </style>
@@ -0,0 +1,6 @@
1
+ @import "@svadmin/ui/app.css";
2
+
3
+ html { letter-spacing: 0; }
4
+ body { margin: 0; background: #f7f8fa; }
5
+ [data-svadmin-skip-link] { transform: translateY(calc(-100% - 1rem)); }
6
+ [data-svadmin-skip-link]:focus { transform: translateY(0); }
@@ -0,0 +1,7 @@
1
+ import type { ThemeConfig } from '@svadmin/core';
2
+
3
+ export const brand = $state({ name: 'Product workspace' });
4
+
5
+ export function getDesign(): { theme: ThemeConfig } {
6
+ return { theme: { colorPreset: 'neutral', layoutPreset: 'clean-flat' } };
7
+ }
@@ -0,0 +1,81 @@
1
+ import { HttpError, type BaseRecord, type DataProvider, type Filter } from '@svadmin/core';
2
+ import { productResources } from './generated/resources';
3
+
4
+ export type ProductScenario = 'normal' | 'empty' | 'error' | 'loading' | 'denied' | 'readonly';
5
+
6
+ function matches(row: BaseRecord, filter: Filter): boolean {
7
+ if (!('field' in filter)) {
8
+ return filter.operator === 'or'
9
+ ? filter.value.some(candidate => matches(row, candidate))
10
+ : filter.value.every(candidate => matches(row, candidate));
11
+ }
12
+ const value = row[filter.field];
13
+ switch (filter.operator) {
14
+ case 'eq': return value === filter.value;
15
+ case 'ne': return value !== filter.value;
16
+ case 'contains': return String(value ?? '').toLowerCase().includes(String(filter.value).toLowerCase());
17
+ case 'in': return Array.isArray(filter.value) && filter.value.includes(value);
18
+ case 'null': return value === null || value === undefined;
19
+ case 'nnull': return value !== null && value !== undefined;
20
+ default: throw new HttpError('当前脚手架不支持该筛选条件', 400);
21
+ }
22
+ }
23
+
24
+ export function createProductProvider(scenario: ProductScenario = 'normal'): DataProvider {
25
+ const rows: Record<string, BaseRecord[]> = Object.fromEntries(
26
+ productResources.map(resource => [resource.name, [] as BaseRecord[]]),
27
+ );
28
+ async function table(resource: string): Promise<BaseRecord[]> {
29
+ if (scenario === 'denied') throw new HttpError('无权访问', 403);
30
+ if (scenario === 'error') throw new HttpError('演示 provider 暂不可用', 503);
31
+ if (scenario === 'loading') await new Promise(resolve => setTimeout(resolve, 1200));
32
+ const data = rows[resource];
33
+ if (!data) throw new HttpError('资源不存在', 404);
34
+ return data;
35
+ }
36
+ function find(data: BaseRecord[], id: string | number): BaseRecord {
37
+ const row = data.find(record => String(record['id']) === String(id));
38
+ if (!row) throw new HttpError('记录不存在', 404);
39
+ return row;
40
+ }
41
+ return {
42
+ getApiUrl: () => 'memory://svadmin-product',
43
+ async getList({ resource, pagination, sorters = [], filters = [] }) {
44
+ const data = await table(resource);
45
+ let result = (scenario === 'empty' ? [] : data).filter(row => filters.every(filter => matches(row, filter)));
46
+ result = [...result].sort((a, b) => {
47
+ for (const sorter of sorters) {
48
+ const order = String(a[sorter.field] ?? '').localeCompare(String(b[sorter.field] ?? ''), 'zh-CN', { numeric: true });
49
+ if (order) return sorter.order === 'desc' ? -order : order;
50
+ }
51
+ return 0;
52
+ });
53
+ const total = result.length;
54
+ if (pagination?.mode !== 'off') {
55
+ const size = pagination?.pageSize ?? 10;
56
+ const start = ((pagination?.current ?? 1) - 1) * size;
57
+ result = result.slice(start, start + size);
58
+ }
59
+ return { data: structuredClone(result), total };
60
+ },
61
+ async getOne({ resource, id }) { return { data: structuredClone(find(await table(resource), id)) }; },
62
+ async getMany({ resource, ids }) {
63
+ return { data: structuredClone((await table(resource)).filter(row => ids.some(id => String(id) === String(row['id'])))) };
64
+ },
65
+ async create({ resource, variables }) {
66
+ if (scenario === 'readonly') throw new HttpError('仅允许查看', 403);
67
+ const data = await table(resource);
68
+ const record = { ...(variables as BaseRecord), id: crypto.randomUUID() };
69
+ data.push(record);
70
+ return { data: structuredClone(record) };
71
+ },
72
+ async update({ resource, id, variables }) {
73
+ if (scenario === 'readonly') throw new HttpError('仅允许查看', 403);
74
+ const data = await table(resource);
75
+ const record = find(data, id);
76
+ Object.assign(record, variables as BaseRecord);
77
+ return { data: structuredClone(record) };
78
+ },
79
+ async deleteOne() { throw new HttpError('产品脚手架未启用删除', 403); },
80
+ };
81
+ }
@@ -0,0 +1,29 @@
1
+ import { createProviderBundle, defineAdminConfig } from '@svadmin/app';
2
+ import { productResources } from './generated/resources';
3
+ import { createProductProvider, type ProductScenario } from './product-provider';
4
+
5
+ const requested = typeof location !== 'undefined' && import.meta.env.DEV
6
+ ? new URLSearchParams(location.search).get('scenario')
7
+ : null;
8
+ const scenarios: readonly string[] = ['normal', 'empty', 'error', 'loading', 'denied', 'readonly'];
9
+ const scenario: ProductScenario = requested !== null && scenarios.includes(requested)
10
+ ? scenarioFrom(requested)
11
+ : 'normal';
12
+
13
+ function scenarioFrom(value: string): ProductScenario {
14
+ return value as ProductScenario;
15
+ }
16
+
17
+ export default defineAdminConfig({
18
+ name: 'product-workspace',
19
+ providers: createProviderBundle({
20
+ dataProvider: createProductProvider(scenario),
21
+ accessControlProvider: {
22
+ can: async ({ action }) => ({
23
+ can: scenario !== 'denied' && (scenario !== 'readonly' || ['list', 'show', 'field'].includes(action)),
24
+ }),
25
+ options: { buttons: { enableAccessControl: true, hideIfUnauthorized: true } },
26
+ },
27
+ }),
28
+ resources: productResources,
29
+ });
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 1,
3
+ "id": "product-workspace",
4
+ "status": "generated",
5
+ "framework": "svelte5-vite",
6
+ "source": "svadmin.product.json",
7
+ "generated": ["src/generated/product-plan.ts", "src/generated/navigation.ts", "src/generated/pages.ts", "src/generated/resources.ts", "src/generated/page-routes.ts"],
8
+ "dataMode": "empty-in-memory",
9
+ "boundaries": ["No production writes", "No fake authorization", "Actions require explicit command contracts", "Menu count follows business needs"]
10
+ }
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: svadmin-vibe
3
+ description: Build product-first Svelte admin surfaces from a validated svadmin.product.json and generated page plans.
4
+ ---
5
+
6
+ # svadmin Vibe
7
+
8
+ Read PRODUCT.md, DESIGN.md, ARCHITECTURE.md, svadmin.product.json and svadmin.ai.json before changing a generated project. Keep the product plan as the source of truth for roles, workflows, resources, surfaces and menu entries.
9
+
10
+ Start with the user outcome and workflow. Resources are data contracts and do not automatically become pages or sidebar entries. Menu count follows the business domain; do not remove required entries to satisfy a global limit. Keep one dominant action per surface and use progressive disclosure for secondary actions.
11
+
12
+ Generated files under src/generated are derived artifacts. Change svadmin.product.json and regenerate instead of editing them by hand. Product actions are metadata until a typed command or provider contract is connected. Never claim that an empty in-memory provider provides persistence, authorization, audit or production data.
13
+
14
+ Use the installed @svadmin/ui component registry and public type declarations. Keep the Svelte/SvelteKit stack, semantic tokens, responsive layout and state coverage. Verify with bun run check, bun run build and bun run test:ui, then inspect 1440x900 and 1920x1080 captures. Backend authorization, tenancy, audit and durable writes remain server responsibilities.