@svadmin/create 0.34.0 → 0.35.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.
- 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 +135 -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,56 @@
|
|
|
1
|
+
# Customer Application Boundaries
|
|
2
|
+
|
|
3
|
+
This starter is a modular application, not a microservice platform. Keep the
|
|
4
|
+
existing backend and adapt it through providers. The in-memory demo does not
|
|
5
|
+
provide server authorization, durable storage, tenants, or a trusted audit log.
|
|
6
|
+
|
|
7
|
+
## Ownership
|
|
8
|
+
|
|
9
|
+
| Layer | Owner | Public entry |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| Composition | Brand, navigation, page registration | `src/App.svelte` |
|
|
12
|
+
| Services | Providers and application configuration | `src/svadmin.config.ts` |
|
|
13
|
+
| Resources | Registered resource definitions | `src/resources.ts` |
|
|
14
|
+
| Business feature | Contracts, queries, actions, pages | `src/features/<name>/index.ts` |
|
|
15
|
+
| Headless feature data | Contracts/types without Svelte imports | `src/features/<name>/data.ts` |
|
|
16
|
+
| Presentation | Shared tokens and design presets | `DESIGN.md`, `src/design.svelte.ts` |
|
|
17
|
+
|
|
18
|
+
Use `create-svadmin add resource <name>` to preview a new feature, then explicitly
|
|
19
|
+
apply it with `--write` and register its public definition. Keep feature-specific
|
|
20
|
+
state inside the feature; communicate through public contracts. Do not reach
|
|
21
|
+
into another feature's component, store, or contract implementation.
|
|
22
|
+
|
|
23
|
+
`index.ts` exports UI composition. Use optional `data.ts` when CLI, server, or
|
|
24
|
+
resource registration needs contracts without loading Svelte components.
|
|
25
|
+
Customer customization belongs in application composition, providers and
|
|
26
|
+
feature sources, not private `@svadmin/*/src` or `dist` imports.
|
|
27
|
+
|
|
28
|
+
## Generation
|
|
29
|
+
|
|
30
|
+
Search the shipped catalog with `create-svadmin vibe catalog --query "approval"`.
|
|
31
|
+
Read a selected reference using `create-svadmin vibe inspect approval`.
|
|
32
|
+
The JSON includes source, contracts, composition, design guidance and acceptance
|
|
33
|
+
requirements. It is reference context, not a standalone page installer.
|
|
34
|
+
For existing applications inspect the customer's current code first; never
|
|
35
|
+
replace their schema, provider, permissions or workflows with demo equivalents.
|
|
36
|
+
|
|
37
|
+
Generation is separate from execution. A generated mutation must use an explicit
|
|
38
|
+
resource/command contract. Browser permission visibility does not authorize it.
|
|
39
|
+
Keep record-level permissions, tenant isolation, approval transitions, audit,
|
|
40
|
+
idempotency and persistence on the server. Review changes before applying them;
|
|
41
|
+
never automatically retry a write whose outcome is unknown.
|
|
42
|
+
|
|
43
|
+
## Verification
|
|
44
|
+
|
|
45
|
+
`bun run check` first runs `check:architecture`, then the existing type check.
|
|
46
|
+
The architecture check parses TypeScript and both Svelte script blocks. It
|
|
47
|
+
checks imports, re-exports, import types, import-equals and literal dynamic imports
|
|
48
|
+
(including Svelte template expressions); rejects parse errors,
|
|
49
|
+
computed module imports and private package paths; resolves TypeScript aliases
|
|
50
|
+
and resolves existing Svelte aliases, failing unresolved aliases closed. It allows cross-feature imports only via
|
|
51
|
+
`index.ts` or `data.ts` (including a feature directory import).
|
|
52
|
+
|
|
53
|
+
This is an import-boundary check, not a security sandbox, dependency-cycle
|
|
54
|
+
detector or proof of backend authorization. Keep public entrypoints small.
|
|
55
|
+
Follow it with `bun run build`, `bun run test:ui`, screenshot inspection and
|
|
56
|
+
human review of real business rules before claiming customer acceptance.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Customer Workspace
|
|
2
|
+
|
|
3
|
+
Svelte 5 + svadmin 的可修改页面样板,包含列表、详情、分组表单、工作台、
|
|
4
|
+
设置和审批六类页面,以及三套应用级设计预设。不是已接通后端的业务产品。
|
|
5
|
+
|
|
6
|
+
`previews/` 随包提供六类页面的桌面与移动端参考截图。它们用于挑选样板,
|
|
7
|
+
不能代替客户修改后的重新验收;实际结果以本地 `test-results/` 为准。
|
|
8
|
+
|
|
9
|
+
## 本地运行
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun install
|
|
13
|
+
bun run check
|
|
14
|
+
bun run dev
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
在编码助手中提出需求即可,例如:
|
|
18
|
+
|
|
19
|
+
> 基于当前客户工作台,把全站调整为高密度运营,保留客户 CRUD 和跟进流程。
|
|
20
|
+
> 先读取 svadmin.vibe.json 和设计规范,修改后运行检查并查看移动端截图。
|
|
21
|
+
|
|
22
|
+
`AGENTS.md` 提供通用规则,`.agents/skills/svadmin-vibe/SKILL.md` 提供生成与
|
|
23
|
+
验收流程。其他助手可以直接读取该文件。`svadmin.vibe.json` 可检索页面源码、
|
|
24
|
+
组件、预设、状态与验收入口;已安装组件的声明才是 API 事实源。
|
|
25
|
+
|
|
26
|
+
## 设计与业务
|
|
27
|
+
|
|
28
|
+
- `src/design-selection.ts`:初始预设。
|
|
29
|
+
- `src/design.svelte.ts`:品牌名称、主题、密度、宽度、表单列数、详情布局。
|
|
30
|
+
- `src/features/customers/`:六类页面及 TypeBox 资源契约。
|
|
31
|
+
- `src/resources.ts`:字段与导航元数据。
|
|
32
|
+
- `src/svadmin.config.ts`:替换数据、身份和权限 Provider 的唯一入口。
|
|
33
|
+
- `src/demo/provider.ts`:内存演示数据;刷新页面即恢复,不上传任何数据。
|
|
34
|
+
|
|
35
|
+
工作区设置仅在当前会话内生效,持久默认值请修改设计配置。内置主题选择仍遵循
|
|
36
|
+
svadmin 已保存的个人偏好,不应把颜色选择当成授权或数据隔离。
|
|
37
|
+
|
|
38
|
+
客户与跟进支持创建、编辑,审批支持修改结果及意见;删除不开放。
|
|
39
|
+
审批样板仅演示 UI,不具备真实审批流、不可变审计或服务端权限控制。
|
|
40
|
+
接入生产前必须实现这些能力,并移除演示 Provider 和开发状态开关。
|
|
41
|
+
|
|
42
|
+
## 验收
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
bun run build
|
|
46
|
+
bunx playwright install chromium
|
|
47
|
+
bun run test:ui
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Playwright 自动启动本地开发服务,生成桌面与移动端截图到 `test-results/`,
|
|
51
|
+
检查页面、交互、浏览器异常和页面级溢出。宽表格允许在自己的滚动区内横向滚动。
|
|
52
|
+
视觉品味、品牌一致性、真实权限和业务正确性仍需要人工验收。
|
|
53
|
+
|
|
54
|
+
开发状态可以通过 URL 查询参数切换,例如 `/?scenario=empty#/customers`:
|
|
55
|
+
`empty`、`loading`、`error`、`denied`、`partial`、`readonly`。生产构建不读取该开关。
|
|
56
|
+
|
|
57
|
+
## 演进边界
|
|
58
|
+
|
|
59
|
+
这是一条开发者生成路径,不内置模型调用或密钥管理。没有承诺任意应用生成;
|
|
60
|
+
无代码仪表盘仍应使用受策略约束的 `@svadmin/surface`。首次接入真实后端时,
|
|
61
|
+
同步资源契约、`svadmin.ai.json`、权限与针对性测试,避免 UI 与能力声明漂移。
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineConfig, devices } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
testDir: './tests',
|
|
5
|
+
fullyParallel: true,
|
|
6
|
+
timeout: 60_000,
|
|
7
|
+
use: { baseURL: 'http://127.0.0.1:5198', trace: 'retain-on-failure', reducedMotion: 'reduce' },
|
|
8
|
+
webServer: {
|
|
9
|
+
command: 'bun run dev --host 127.0.0.1 --port 5198 --strictPort',
|
|
10
|
+
url: 'http://127.0.0.1:5198',
|
|
11
|
+
reuseExistingServer: false,
|
|
12
|
+
},
|
|
13
|
+
projects: [
|
|
14
|
+
{ name: 'desktop', use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 } } },
|
|
15
|
+
{ name: 'mobile', use: { ...devices['Desktop Chrome'], viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true } },
|
|
16
|
+
],
|
|
17
|
+
});
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { dirname, extname, relative, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import ts from 'typescript';
|
|
5
|
+
import { parse } from 'svelte/compiler';
|
|
6
|
+
|
|
7
|
+
const extensions = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.svelte']);
|
|
8
|
+
|
|
9
|
+
function walk(directory) {
|
|
10
|
+
return readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
|
|
11
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') return [];
|
|
12
|
+
const file = resolve(directory, entry.name);
|
|
13
|
+
return entry.isDirectory() ? walk(file) : extensions.has(extname(file)) ? [file] : [];
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function templateImports(node, file, failures) {
|
|
18
|
+
if (!node || typeof node !== 'object') return [];
|
|
19
|
+
const found = [];
|
|
20
|
+
const value = node.type === 'ImportExpression' ? node.source
|
|
21
|
+
: node.type === 'CallExpression' && node.callee?.name === 'require' ? node.arguments[0] : undefined;
|
|
22
|
+
if (node.type === 'ImportExpression' || (node.type === 'CallExpression' && node.callee?.name === 'require')) {
|
|
23
|
+
if (value?.type === 'Literal' && typeof value.value === 'string') found.push(value.value);
|
|
24
|
+
else failures.push(`${file}: computed module imports cannot be checked`);
|
|
25
|
+
}
|
|
26
|
+
for (const child of Object.values(node)) {
|
|
27
|
+
if (Array.isArray(child)) found.push(...child.flatMap(item => templateImports(item, file, failures)));
|
|
28
|
+
else if (child && typeof child === 'object') found.push(...templateImports(child, file, failures));
|
|
29
|
+
}
|
|
30
|
+
return found;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fileImports(file, failures) {
|
|
34
|
+
const text = readFileSync(file, 'utf8');
|
|
35
|
+
if (!file.endsWith('.svelte')) return imports(file, text, failures);
|
|
36
|
+
const ast = parse(text, { modern: true });
|
|
37
|
+
return [
|
|
38
|
+
...[ast.module, ast.instance].filter(Boolean).flatMap(script =>
|
|
39
|
+
imports(file, text.slice(script.content.start, script.content.end), failures)),
|
|
40
|
+
...templateImports(ast.fragment, file, failures),
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function imports(file, source, failures) {
|
|
45
|
+
const specifiers = [];
|
|
46
|
+
const kind = ['.tsx', '.jsx'].includes(extname(file)) ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
47
|
+
const tree = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind);
|
|
48
|
+
for (const error of tree.parseDiagnostics) {
|
|
49
|
+
failures.push(`${file}: parse error: ${ts.flattenDiagnosticMessageText(error.messageText, '\n')}`);
|
|
50
|
+
}
|
|
51
|
+
function visit(node) {
|
|
52
|
+
let value;
|
|
53
|
+
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) value = node.moduleSpecifier;
|
|
54
|
+
else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) value = node.moduleReference.expression;
|
|
55
|
+
else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) value = node.argument.literal;
|
|
56
|
+
else if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword
|
|
57
|
+
|| (ts.isIdentifier(node.expression) && node.expression.text === 'require'))) {
|
|
58
|
+
value = node.arguments[0];
|
|
59
|
+
if (!value || !ts.isStringLiteralLike(value)) failures.push(`${file}: computed module imports cannot be checked`);
|
|
60
|
+
}
|
|
61
|
+
if (value && ts.isStringLiteralLike(value)) specifiers.push(value.text);
|
|
62
|
+
ts.forEachChild(node, visit);
|
|
63
|
+
}
|
|
64
|
+
visit(tree);
|
|
65
|
+
return specifiers;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function featurePath(root, file) {
|
|
69
|
+
const parts = relative(resolve(root, 'src/features'), file).split(/[\\/]/u);
|
|
70
|
+
if (parts[0] === '..' || parts[0] === '') return undefined;
|
|
71
|
+
return { name: parts[0], entry: parts.slice(1).join('/') };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolveImport(specifier, file, options, failures) {
|
|
75
|
+
if (specifier.startsWith('.')) return resolve(dirname(file), specifier);
|
|
76
|
+
const resolved = ts.resolveModuleName(specifier, file, options, ts.sys).resolvedModule?.resolvedFileName;
|
|
77
|
+
if (resolved) return resolved;
|
|
78
|
+
// 未解析的路径别名可能隐藏跨模块引用,不能当作普通 npm 依赖放行。
|
|
79
|
+
const aliases = Object.keys(options.paths ?? {});
|
|
80
|
+
const alias = aliases.filter(alias => alias.includes('*')
|
|
81
|
+
? specifier.startsWith(alias.split('*')[0]) && specifier.endsWith(alias.split('*')[1])
|
|
82
|
+
: specifier === alias).sort((a, b) =>
|
|
83
|
+
Number(b === specifier) - Number(a === specifier)
|
|
84
|
+
|| b.split('*')[0].length - a.split('*')[0].length)[0];
|
|
85
|
+
if (alias) {
|
|
86
|
+
const [prefix, suffix = ''] = alias.split('*');
|
|
87
|
+
const wildcard = specifier.slice(prefix.length, specifier.length - suffix.length);
|
|
88
|
+
for (const mapping of options.paths[alias]) {
|
|
89
|
+
const candidate = resolve(options.baseUrl ?? options.pathsBasePath, mapping.replace('*', wildcard));
|
|
90
|
+
if (candidate.endsWith('.svelte') && existsSync(candidate)) return candidate;
|
|
91
|
+
}
|
|
92
|
+
failures.push(`${file}: unresolved path alias ${specifier}`);
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function checkImport(root, file, specifier, options, failures) {
|
|
98
|
+
if (/^@svadmin\/[^/]+\/(?:src|dist)(?:\/|$)/u.test(specifier)) {
|
|
99
|
+
failures.push(`${relative(root, file)}: use public package exports, not ${specifier}`);
|
|
100
|
+
}
|
|
101
|
+
const target = resolveImport(specifier, file, options, failures);
|
|
102
|
+
if (!target) return;
|
|
103
|
+
const from = featurePath(root, file);
|
|
104
|
+
const to = featurePath(root, target);
|
|
105
|
+
if (!to || from?.name === to.name) return;
|
|
106
|
+
if (!['', 'index', 'data'].includes(to.entry.replace(/\.(?:ts|js|mts|cts|mjs|cjs)$/u, ''))) {
|
|
107
|
+
failures.push(`${relative(root, file)}: ${specifier} bypasses the ${to.name} public index.ts/data.ts entry`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function checkArchitecture(root) {
|
|
112
|
+
const failures = [];
|
|
113
|
+
const configFile = resolve(root, 'tsconfig.json');
|
|
114
|
+
const config = ts.readConfigFile(configFile, ts.sys.readFile);
|
|
115
|
+
if (config.error) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'));
|
|
116
|
+
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root);
|
|
117
|
+
const configErrors = parsed.errors.filter(error => error.code !== 18003);
|
|
118
|
+
if (configErrors.length) throw new Error(configErrors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n'));
|
|
119
|
+
const features = resolve(root, 'src/features');
|
|
120
|
+
if (existsSync(features)) {
|
|
121
|
+
for (const entry of readdirSync(features, { withFileTypes: true })) {
|
|
122
|
+
if (entry.isDirectory() && !existsSync(resolve(features, entry.name, 'index.ts'))) {
|
|
123
|
+
failures.push(`src/features/${entry.name}: missing public index.ts`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const source = resolve(root, 'src');
|
|
128
|
+
for (const file of existsSync(source) ? walk(source) : []) {
|
|
129
|
+
for (const specifier of fileImports(file, failures)) {
|
|
130
|
+
checkImport(root, file, specifier, parsed.options, failures);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return failures;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
137
|
+
const failures = checkArchitecture(process.cwd());
|
|
138
|
+
if (failures.length) {
|
|
139
|
+
console.error(failures.join('\n'));
|
|
140
|
+
process.exitCode = 1;
|
|
141
|
+
} else console.info('Feature architecture boundaries passed.');
|
|
142
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
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 { CustomerList, CustomerForm, CustomerDetail, CustomerDashboard, ApprovalReview, WorkspaceSettings } from './features/customers';
|
|
7
|
+
const resolved = resolveAdminConfig(config);
|
|
8
|
+
const design = $derived(getDesign());
|
|
9
|
+
const resourcePages = {
|
|
10
|
+
customers: { list: CustomerList, create: CustomerForm, edit: CustomerForm, clone: CustomerForm, show: CustomerDetail },
|
|
11
|
+
followups: { list: CustomerList, create: CustomerForm, edit: CustomerForm },
|
|
12
|
+
approvals: { list: CustomerList, edit: ApprovalReview, show: ApprovalReview },
|
|
13
|
+
workspace_settings: { list: WorkspaceSettings },
|
|
14
|
+
};
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<AdminApp providerBundle={resolved.providers} resources={[...resolved.resources]}
|
|
18
|
+
title={brand.name} locale="zh-CN" themeConfig={design.theme} {resourcePages}
|
|
19
|
+
queryClientDefaultOptions={{ queries: { retry: false } }}>
|
|
20
|
+
{#snippet dashboard()}<CustomerDashboard />{/snippet}
|
|
21
|
+
</AdminApp>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { HttpError, type BaseRecord, type DataProvider, type Filter } from '@svadmin/core';
|
|
2
|
+
import { parseContractCreateInput, parseContractUpdateInput } from '@svadmin/core/resource-contract';
|
|
3
|
+
import { customers, followups, approvals, type Customer, type Followup, type Approval } from '../features/customers/data';
|
|
4
|
+
|
|
5
|
+
export type DemoScenario = 'normal' | 'empty' | 'error' | 'loading' | 'denied' | 'partial' | 'readonly';
|
|
6
|
+
|
|
7
|
+
export const demoCustomers: Customer[] = [
|
|
8
|
+
{ id: 'c1', name: '澄川科技', contact: '林悦', email: 'lin@example.test', status: 'active', owner: '陈晨', notes: '下周确认第二阶段交付范围。' },
|
|
9
|
+
{ id: 'c2', name: '远山设计事务所', contact: '周宁', email: 'zhou@example.test', status: 'potential', owner: '李沐', notes: '' },
|
|
10
|
+
{ id: 'c3', name: '海川城市服务与可持续基础设施联合研究中心', contact: '张文', email: 'zhang@example.test', status: 'active', owner: '陈晨', notes: '涉及多个业务部门,合同审批由统一联系人协调。' },
|
|
11
|
+
{ id: 'c4', name: '青禾教育', contact: '许安', email: 'xu@example.test', status: 'paused', owner: '李沐', notes: '等待预算确认。' },
|
|
12
|
+
];
|
|
13
|
+
const demoFollowups: Followup[] = [
|
|
14
|
+
{ id: 'f1', customerId: 'c1', summary: '已完成需求访谈,客户确认先上线客户与审批模块。', owner: '陈晨', date: '2026-09-21' },
|
|
15
|
+
{ id: 'f2', customerId: 'c1', summary: '发送实施方案,等待客户确认。', owner: '陈晨', date: '2026-09-23' },
|
|
16
|
+
{ id: 'f3', customerId: 'c3', summary: '整理跨部门的数据字段与权限边界。', owner: '李沐', date: '2026-09-22' },
|
|
17
|
+
];
|
|
18
|
+
const demoApprovals: Approval[] = [
|
|
19
|
+
{ id: 'a1', title: '澄川科技合同变更', applicant: '陈晨', status: 'pending', reason: '增加跟进记录模块,需要确认交付范围。' },
|
|
20
|
+
{ id: 'a2', title: '远山设计试用延期', applicant: '李沐', status: 'pending', reason: '申请延长试用期七天。' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function matches(row: BaseRecord, filter: Filter): boolean {
|
|
24
|
+
if (!('field' in filter)) {
|
|
25
|
+
return filter.operator === 'or'
|
|
26
|
+
? filter.value.some(f => matches(row, f))
|
|
27
|
+
: filter.value.every(f => matches(row, f));
|
|
28
|
+
}
|
|
29
|
+
const value = row[filter.field];
|
|
30
|
+
switch (filter.operator) {
|
|
31
|
+
case 'eq': return value === filter.value;
|
|
32
|
+
case 'ne': return value !== filter.value;
|
|
33
|
+
case 'contains': return String(value ?? '').toLowerCase().includes(String(filter.value).toLowerCase());
|
|
34
|
+
case 'in': return Array.isArray(filter.value) && filter.value.includes(value);
|
|
35
|
+
case 'null': return value === null || value === undefined;
|
|
36
|
+
case 'nnull': return value !== null && value !== undefined;
|
|
37
|
+
default: throw new HttpError(`演示数据不支持筛选条件 ${filter.operator}`, 400);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createDemoProvider(scenario: DemoScenario = 'normal'): DataProvider {
|
|
42
|
+
const rows: Record<string, BaseRecord[]> = {
|
|
43
|
+
customers: structuredClone(demoCustomers),
|
|
44
|
+
followups: structuredClone(demoFollowups),
|
|
45
|
+
approvals: structuredClone(demoApprovals),
|
|
46
|
+
};
|
|
47
|
+
async function table(resource: string): Promise<BaseRecord[]> {
|
|
48
|
+
if (scenario === 'denied') throw new HttpError('无权访问', 403);
|
|
49
|
+
if (scenario === 'error' || (scenario === 'partial' && resource === 'approvals')) throw new HttpError('演示服务暂不可用', 503);
|
|
50
|
+
if (scenario === 'loading') await new Promise(resolve => setTimeout(resolve, 2500));
|
|
51
|
+
const data = rows[resource];
|
|
52
|
+
if (!data) throw new HttpError('资源不存在', 404);
|
|
53
|
+
return data;
|
|
54
|
+
}
|
|
55
|
+
function find(data: BaseRecord[], id: string | number): BaseRecord {
|
|
56
|
+
const row = data.find(record => record['id'] === String(id));
|
|
57
|
+
if (!row) throw new HttpError('记录不存在', 404);
|
|
58
|
+
return row;
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
getApiUrl: () => 'memory://svadmin-demo',
|
|
62
|
+
async getList({ resource, pagination, sorters = [], filters = [] }) {
|
|
63
|
+
const data = await table(resource);
|
|
64
|
+
let result = scenario === 'empty' ? [] : data.filter(row => filters.every(f => matches(row, f)));
|
|
65
|
+
result = [...result].sort((a, b) => {
|
|
66
|
+
for (const sorter of sorters) {
|
|
67
|
+
const order = String(a[sorter.field] ?? '').localeCompare(String(b[sorter.field] ?? ''), 'zh-CN', { numeric: true });
|
|
68
|
+
if (order) return sorter.order === 'desc' ? -order : order;
|
|
69
|
+
}
|
|
70
|
+
return 0;
|
|
71
|
+
});
|
|
72
|
+
const total = result.length;
|
|
73
|
+
if (pagination?.mode !== 'off') {
|
|
74
|
+
const size = pagination?.pageSize ?? 10;
|
|
75
|
+
const start = ((pagination?.current ?? 1) - 1) * size;
|
|
76
|
+
result = result.slice(start, start + size);
|
|
77
|
+
}
|
|
78
|
+
return { data: structuredClone(result), total };
|
|
79
|
+
},
|
|
80
|
+
async getOne({ resource, id }) {
|
|
81
|
+
return { data: structuredClone(find(await table(resource), id)) };
|
|
82
|
+
},
|
|
83
|
+
async getMany({ resource, ids }) {
|
|
84
|
+
return { data: structuredClone((await table(resource)).filter(row => ids.some(id => String(id) === row['id']))) };
|
|
85
|
+
},
|
|
86
|
+
async create({ resource, variables }) {
|
|
87
|
+
if (scenario === 'readonly') throw new HttpError('仅允许查看', 403);
|
|
88
|
+
const data = await table(resource);
|
|
89
|
+
const input = resource === 'customers' ? parseContractCreateInput(customers, variables)
|
|
90
|
+
: resource === 'followups' ? parseContractCreateInput(followups, variables)
|
|
91
|
+
: (() => { throw new HttpError('该资源不允许创建', 403); })();
|
|
92
|
+
if (resource === 'followups' && 'customerId' in input) find(await table('customers'), String(input.customerId));
|
|
93
|
+
const record = { ...input, id: crypto.randomUUID() };
|
|
94
|
+
data.push(record);
|
|
95
|
+
return { data: structuredClone(record) };
|
|
96
|
+
},
|
|
97
|
+
async update({ resource, id, variables }) {
|
|
98
|
+
if (scenario === 'readonly') throw new HttpError('仅允许查看', 403);
|
|
99
|
+
const data = await table(resource);
|
|
100
|
+
const row = find(data, id);
|
|
101
|
+
const input = resource === 'customers' ? parseContractUpdateInput(customers, variables)
|
|
102
|
+
: resource === 'followups' ? parseContractUpdateInput(followups, variables)
|
|
103
|
+
: parseContractUpdateInput(approvals, variables);
|
|
104
|
+
if (resource === 'followups' && 'customerId' in input) find(await table('customers'), String(input.customerId));
|
|
105
|
+
Object.assign(row, input);
|
|
106
|
+
return { data: structuredClone(row) };
|
|
107
|
+
},
|
|
108
|
+
async deleteOne() { throw new HttpError('演示项目未启用删除', 403); },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ThemeConfig } from '@svadmin/core';
|
|
2
|
+
import { initialPreset } from './design-selection';
|
|
3
|
+
|
|
4
|
+
interface DesignPreset {
|
|
5
|
+
label: string;
|
|
6
|
+
density: 'compact' | 'comfortable';
|
|
7
|
+
width: 'wide' | 'default';
|
|
8
|
+
formColumns: 1 | 2;
|
|
9
|
+
detailLayout: 'list' | 'grid';
|
|
10
|
+
theme: ThemeConfig;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const designPresets = {
|
|
14
|
+
operations: {
|
|
15
|
+
label: '高密度运营', density: 'compact', width: 'wide',
|
|
16
|
+
formColumns: 2, detailLayout: 'list',
|
|
17
|
+
theme: { colorPreset: 'neutral', layoutPreset: 'clean-flat' },
|
|
18
|
+
},
|
|
19
|
+
enterprise: {
|
|
20
|
+
label: '标准企业', density: 'comfortable', width: 'wide',
|
|
21
|
+
formColumns: 2, detailLayout: 'grid',
|
|
22
|
+
theme: { colorPreset: 'stripe', layoutPreset: 'clean-flat' },
|
|
23
|
+
},
|
|
24
|
+
collaboration: {
|
|
25
|
+
label: '轻量协作', density: 'comfortable', width: 'default',
|
|
26
|
+
formColumns: 1, detailLayout: 'list',
|
|
27
|
+
theme: { colorPreset: 'green', layoutPreset: 'clean-flat' },
|
|
28
|
+
},
|
|
29
|
+
} as const satisfies Record<string, DesignPreset>;
|
|
30
|
+
|
|
31
|
+
export type DesignPresetId = keyof typeof designPresets;
|
|
32
|
+
export const brand = $state({ name: '客户工作台', preset: initialPreset as DesignPresetId });
|
|
33
|
+
|
|
34
|
+
export function getDesign(): DesignPreset {
|
|
35
|
+
return designPresets[brand.preset];
|
|
36
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { useList, useCan } from '@svadmin/core';
|
|
3
|
+
import { AutoTable, Button, DashboardPage, DataState, MetricBlock, PageSection } from '@svadmin/ui';
|
|
4
|
+
import { customers, followups, approvals } from './contracts';
|
|
5
|
+
import { getDesign } from '../../design.svelte';
|
|
6
|
+
const design = $derived(getDesign());
|
|
7
|
+
const access = useCan(() => ({ resource: 'customers', action: 'list' }));
|
|
8
|
+
const followupAccess = useCan(() => ({ resource: 'followups', action: 'list' }));
|
|
9
|
+
const approvalAccess = useCan(() => ({ resource: 'approvals', action: 'list' }));
|
|
10
|
+
const customerQuery = useList(() => ({ resource: customers, pagination: { current: 1, pageSize: 1 }, queryOptions: { enabled: access.allowed } }));
|
|
11
|
+
const followupQuery = useList(() => ({ resource: followups, pagination: { current: 1, pageSize: 1 }, queryOptions: { enabled: followupAccess.allowed } }));
|
|
12
|
+
const pendingQuery = useList(() => ({
|
|
13
|
+
resource: approvals, pagination: { current: 1, pageSize: 1 },
|
|
14
|
+
filters: [{ field: 'status' as const, operator: 'eq' as const, value: 'pending' as const }],
|
|
15
|
+
queryOptions: { enabled: approvalAccess.allowed },
|
|
16
|
+
}));
|
|
17
|
+
const stats = $derived([
|
|
18
|
+
{ label: '客户总数', query: customerQuery, access },
|
|
19
|
+
{ label: '跟进记录', query: followupQuery, access: followupAccess },
|
|
20
|
+
{ label: '待审批', query: pendingQuery, access: approvalAccess },
|
|
21
|
+
]);
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<DashboardPage title="客户工作台">
|
|
25
|
+
{#snippet actions()}<Button href="#/customers">查看客户</Button>{/snippet}
|
|
26
|
+
{#snippet metrics()}
|
|
27
|
+
{#each stats as stat (stat.label)}
|
|
28
|
+
<MetricBlock label={stat.label}
|
|
29
|
+
value={!stat.access.allowed || stat.query.isError ? '—' : stat.query.data?.total ?? '—'}
|
|
30
|
+
loading={stat.access.isLoading || (stat.access.allowed && stat.query.isLoading)} />
|
|
31
|
+
{/each}
|
|
32
|
+
{/snippet}
|
|
33
|
+
{#if stats.some(s => s.access.allowed && s.query.isError)}
|
|
34
|
+
<DataState state="error" title="部分数据暂不可用" retry={() => {
|
|
35
|
+
for (const stat of stats) if (stat.access.allowed && stat.query.isError) void stat.query.refetch();
|
|
36
|
+
}} />
|
|
37
|
+
{/if}
|
|
38
|
+
<PageSection title="客户">
|
|
39
|
+
<AutoTable resourceName="customers" showHeader={false} selectable={false}
|
|
40
|
+
syncWithLocation={false} density={design.density} />
|
|
41
|
+
</PageSection>
|
|
42
|
+
{#snippet secondary()}
|
|
43
|
+
<PageSection title="待办">
|
|
44
|
+
<Button variant="ghost" href="#/approvals">处理审批</Button>
|
|
45
|
+
<Button variant="ghost" href="#/followups/create">记录跟进</Button>
|
|
46
|
+
</PageSection>
|
|
47
|
+
{/snippet}
|
|
48
|
+
</DashboardPage>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { useCan, useList } from '@svadmin/core';
|
|
3
|
+
import { Button, ContentPageShell, DataState, PageSection, ShowPage } from '@svadmin/ui';
|
|
4
|
+
import { getDesign } from '../../design.svelte';
|
|
5
|
+
import { followups } from './contracts';
|
|
6
|
+
let { resourceName, id }: { resourceName: string; id?: string | number } = $props();
|
|
7
|
+
const design = $derived(getDesign());
|
|
8
|
+
const permission = useCan(() => ({ resource: 'followups', action: 'list' }));
|
|
9
|
+
const records = useList(() => ({
|
|
10
|
+
resource: followups,
|
|
11
|
+
filters: [{ field: 'customerId', operator: 'eq', value: String(id) }],
|
|
12
|
+
sorters: [{ field: 'date', order: 'desc' }],
|
|
13
|
+
pagination: { current: 1, pageSize: 5 },
|
|
14
|
+
queryOptions: { enabled: id !== undefined && permission.allowed },
|
|
15
|
+
}));
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<ContentPageShell pageId="customer-detail" width={design.width} density={design.density}>
|
|
19
|
+
{#if id !== undefined}
|
|
20
|
+
<ShowPage {resourceName} {id} layout={design.detailLayout} density={design.density}>
|
|
21
|
+
<PageSection title="最近跟进">
|
|
22
|
+
{#snippet actions()}<Button variant="ghost" href="#/followups">全部记录</Button>{/snippet}
|
|
23
|
+
{#if permission.isLoading || records.isLoading}
|
|
24
|
+
<DataState state="loading" />
|
|
25
|
+
{:else if !permission.allowed}
|
|
26
|
+
<DataState state="forbidden" />
|
|
27
|
+
{:else if records.isError}
|
|
28
|
+
<DataState state="error" retry={() => { void records.refetch(); }} />
|
|
29
|
+
{:else if !records.data?.data.length}
|
|
30
|
+
<DataState state="empty" title="暂无跟进记录" />
|
|
31
|
+
{:else}
|
|
32
|
+
<ol class="followups">
|
|
33
|
+
{#each records.data.data as record (record.id)}
|
|
34
|
+
<li><p>{record.summary}</p><span>{record.owner} · {record.date}</span></li>
|
|
35
|
+
{/each}
|
|
36
|
+
</ol>
|
|
37
|
+
{/if}
|
|
38
|
+
</PageSection>
|
|
39
|
+
</ShowPage>
|
|
40
|
+
{:else}
|
|
41
|
+
<DataState state="error" title="缺少客户编号" />
|
|
42
|
+
{/if}
|
|
43
|
+
</ContentPageShell>
|
|
44
|
+
|
|
45
|
+
<style>
|
|
46
|
+
.followups { list-style: none; padding: 0; margin: 0; }
|
|
47
|
+
li { padding: 1rem 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
|
|
48
|
+
p { margin: 0 0 .375rem; }
|
|
49
|
+
span { color: var(--muted-foreground); font-size: .8125rem; }
|
|
50
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { AutoForm, ContentPageShell } from '@svadmin/ui';
|
|
3
|
+
import { getDesign } from '../../design.svelte';
|
|
4
|
+
let { resourceName, mode = 'create', id }: {
|
|
5
|
+
resourceName: string; mode?: 'create' | 'edit' | 'clone'; id?: string | number;
|
|
6
|
+
} = $props();
|
|
7
|
+
const design = $derived(getDesign());
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<ContentPageShell pageId="customer-form" width={design.width} density={design.density}>
|
|
11
|
+
<AutoForm {resourceName} {mode} {...(id === undefined ? {} : { id })}
|
|
12
|
+
columns={design.formColumns} density={design.density} />
|
|
13
|
+
</ContentPageShell>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { AutoTable, ContentPageShell } from '@svadmin/ui';
|
|
3
|
+
import { getDesign } from '../../design.svelte';
|
|
4
|
+
let { resourceName }: { resourceName: string } = $props();
|
|
5
|
+
const design = $derived(getDesign());
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<ContentPageShell pageId="customer-list" width={design.width} density={design.density}>
|
|
9
|
+
<AutoTable {resourceName} density={design.density} />
|
|
10
|
+
</ContentPageShell>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { AutoForm, ContentPageShell, DataState, PageSection, ShowPage } from '@svadmin/ui';
|
|
3
|
+
import { getDesign } from '../../design.svelte';
|
|
4
|
+
let { resourceName, id }: { resourceName: string; id?: string | number } = $props();
|
|
5
|
+
const design = $derived(getDesign());
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<ContentPageShell pageId="approval-review" width="default" density={design.density}>
|
|
9
|
+
{#if id !== undefined}
|
|
10
|
+
<ShowPage {resourceName} {id} density={design.density}>
|
|
11
|
+
<PageSection title="审批决定">
|
|
12
|
+
<AutoForm {resourceName} {id} mode="edit" showHeader={false} columns={1} />
|
|
13
|
+
</PageSection>
|
|
14
|
+
</ShowPage>
|
|
15
|
+
{:else}
|
|
16
|
+
<DataState state="error" title="缺少审批编号" />
|
|
17
|
+
{/if}
|
|
18
|
+
</ContentPageShell>
|