@robsun/create-keystone-app 0.1.13 → 0.1.14

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.
Files changed (54) hide show
  1. package/README.md +19 -10
  2. package/bin/create-keystone-app.js +199 -60
  3. package/package.json +1 -1
  4. package/template/.husky/pre-commit +4 -0
  5. package/template/.lintstagedrc.json +5 -0
  6. package/template/.prettierrc +9 -0
  7. package/template/README.md +45 -23
  8. package/template/apps/server/.air.toml +44 -0
  9. package/template/apps/server/README.md +27 -0
  10. package/template/apps/server/cmd/server/main.go +213 -0
  11. package/template/apps/server/config.yaml +51 -0
  12. package/template/apps/server/docs/docs.go +34 -0
  13. package/template/apps/server/go.mod +13 -0
  14. package/template/apps/server/go.sum +72 -0
  15. package/template/apps/server/internal/app/routes/module_routes.go +16 -0
  16. package/template/apps/server/internal/app/routes/routes.go +226 -0
  17. package/template/apps/server/internal/app/startup/startup.go +74 -0
  18. package/template/apps/server/internal/frontend/dist/.gitkeep +1 -0
  19. package/template/apps/server/internal/frontend/embed.go +28 -0
  20. package/template/apps/server/internal/frontend/handler.go +122 -0
  21. package/template/apps/server/internal/{demo/demo.go → modules/demo/handlers.go} +4 -1
  22. package/template/apps/server/internal/modules/demo/module.go +55 -0
  23. package/template/apps/server/internal/modules/manifest.go +11 -0
  24. package/template/apps/server/internal/modules/registry.go +145 -0
  25. package/template/apps/web/.env.example +3 -0
  26. package/template/apps/web/README.md +29 -0
  27. package/template/apps/web/eslint.config.js +35 -0
  28. package/template/apps/web/package.json +27 -10
  29. package/template/apps/web/postcss.config.js +6 -0
  30. package/template/apps/web/src/index.css +3 -0
  31. package/template/apps/web/src/main.tsx +1 -0
  32. package/template/apps/web/src/modules/demo/help/overview.md +12 -0
  33. package/template/apps/web/src/modules/demo/routes.tsx +2 -0
  34. package/template/apps/web/tailwind.config.js +18 -0
  35. package/template/apps/web/tests/setup.ts +37 -0
  36. package/template/apps/web/tsconfig.app.json +3 -3
  37. package/template/apps/web/vite.config.ts +28 -2
  38. package/template/docker-compose.yml +45 -0
  39. package/template/docs/CONVENTIONS.md +61 -88
  40. package/template/package.json +15 -3
  41. package/template/scripts/build.bat +133 -0
  42. package/template/scripts/build.js +25 -0
  43. package/template/scripts/build.sh +99 -0
  44. package/template/scripts/clean.bat +35 -0
  45. package/template/scripts/clean.js +25 -0
  46. package/template/scripts/clean.sh +34 -0
  47. package/template/scripts/dev.bat +82 -0
  48. package/template/scripts/dev.js +25 -0
  49. package/template/scripts/dev.sh +88 -0
  50. package/template/scripts/test.bat +86 -0
  51. package/template/scripts/test.js +25 -0
  52. package/template/scripts/test.sh +86 -0
  53. package/template/apps/server/main.go +0 -28
  54. /package/template/{config.yaml → apps/server/config.example.yaml} +0 -0
package/README.md CHANGED
@@ -1,22 +1,31 @@
1
- # @robsun/create-keystone-app
1
+ # @robsun/create-keystone-app
2
2
 
3
3
  ## 用法
4
4
  ```bash
5
- npx @robsun/create-keystone-app <dir> [--demo]
6
- pnpm dlx @robsun/create-keystone-app <dir> [--demo]
5
+ npx @robsun/create-keystone-app <dir> [options]
6
+ pnpm dlx @robsun/create-keystone-app <dir> [options]
7
7
  ```
8
8
 
9
- ## 参数
10
- - `<dir>`:目标目录(必填)。新目录名或 `.`(当前目录)。
11
- - `--demo`:生成 Demo 模块(默认不生成)。
12
- - 目标目录必须为空;不为空会退出。
9
+ ## 选项
10
+ - `<dir>`:目标目录(必填),可为新目录名或 `.`(当前目录)。
11
+ - `--profile <starter|full>`:模板档位,默认 `starter`。
12
+ - `--demo` / `--no-demo`:是否包含 Demo 模块(`full` 默认包含)。
13
+ - `--db <sqlite|postgres>`:数据库驱动(默认 `sqlite`)。
14
+ - `--queue <memory|redis>`:队列驱动(默认 `memory`)。
15
+ - `--storage <local|s3>`:存储驱动(默认 `local`)。
16
+
17
+ ## 示例
18
+ ```bash
19
+ npx @robsun/create-keystone-app my-app --profile=full --db=postgres --queue=redis
20
+ ```
13
21
 
14
22
  ## 初始化后操作
15
23
  ```bash
16
24
  cd <dir>
17
25
  pnpm install
18
- pnpm server:dev
19
26
  pnpm dev
20
27
  ```
21
- - Web 默认端口:`3000`,后端默认端口:`8080`。
22
- - `--demo` 模式下:Demo API 为 `/api/v1/demo/tasks`。
28
+
29
+ ## 端口与 Demo
30
+ - Web 默认端口:`3000`;后端默认端口:`8080`。
31
+ - Demo API:`/api/v1/demo/tasks`(仅在包含 Demo 时可用)。
@@ -3,45 +3,44 @@ const fs = require('fs');
3
3
  const path = require('path');
4
4
 
5
5
  const usage = [
6
- 'Usage: create-keystone-app <dir> [--demo]',
6
+ 'Usage: create-keystone-app <dir> [options]',
7
7
  '',
8
8
  'Options:',
9
- ' --demo Include demo module',
9
+ ' --profile <starter|full> Template profile (default: starter)',
10
+ ' --demo Include demo module',
11
+ ' --no-demo Exclude demo module',
12
+ ' --db <sqlite|postgres> Database driver (default: sqlite)',
13
+ ' --queue <memory|redis> Queue driver (default: memory)',
14
+ ' --storage <local|s3> Storage driver (default: local)',
15
+ ' -h, --help Show help',
10
16
  ].join('\n');
11
- const args = process.argv.slice(2);
12
- let rawTarget = null;
13
- let includeDemo = false;
14
-
15
- for (const arg of args) {
16
- if (arg === '--demo') {
17
- includeDemo = true;
18
- continue;
19
- }
20
- if (arg === '--help' || arg === '-h') {
21
- console.log(usage);
22
- process.exit(0);
23
- }
24
- if (arg.startsWith('-')) {
25
- console.error(`Unknown option: ${arg}`);
26
- console.error(usage);
27
- process.exit(1);
28
- }
29
- if (!rawTarget) {
30
- rawTarget = arg;
31
- continue;
32
- }
33
- console.error('Too many arguments.');
34
- console.error(usage);
35
- process.exit(1);
17
+
18
+ const args = parseArgs(process.argv.slice(2));
19
+ if (args.help) {
20
+ console.log(usage);
21
+ process.exit(0);
36
22
  }
37
23
 
38
- if (!rawTarget) {
24
+ if (!args.target) {
39
25
  console.error(usage);
40
26
  process.exit(1);
41
27
  }
42
28
 
43
- const targetDir = path.resolve(process.cwd(), rawTarget);
44
- const targetName = rawTarget === '.'
29
+ const profile = normalizeChoice(args.profile, ['starter', 'full'], 'profile') || 'starter';
30
+ const db = normalizeChoice(args.db, ['sqlite', 'postgres'], 'db') || 'sqlite';
31
+ const queue = normalizeChoice(args.queue, ['memory', 'redis'], 'queue') || 'memory';
32
+ const storage = normalizeChoice(args.storage, ['local', 's3'], 'storage') || 'local';
33
+
34
+ let includeDemo = profile === 'full';
35
+ if (args.demo === true) {
36
+ includeDemo = true;
37
+ }
38
+ if (args.demo === false) {
39
+ includeDemo = false;
40
+ }
41
+
42
+ const targetDir = path.resolve(process.cwd(), args.target);
43
+ const targetName = args.target === '.'
45
44
  ? path.basename(process.cwd())
46
45
  : path.basename(targetDir);
47
46
 
@@ -61,13 +60,15 @@ copyDir(templateDir, targetDir, {
61
60
  '__RAW_NAME__': targetName,
62
61
  });
63
62
 
63
+ applyConfigOptions(targetDir, { db, queue, storage });
64
+
64
65
  if (!includeDemo) {
65
66
  stripDemo(targetDir);
66
67
  }
67
68
 
68
69
  console.log(`Created ${targetName}`);
69
70
  console.log('Next steps:');
70
- console.log(` cd ${rawTarget}`);
71
+ console.log(` cd ${args.target}`);
71
72
  console.log(' pnpm install');
72
73
  console.log(' pnpm server:dev');
73
74
  console.log(' pnpm dev');
@@ -102,11 +103,18 @@ function copyFile(src, dest, replacements) {
102
103
  content = content.split(key).join(value);
103
104
  }
104
105
  fs.writeFileSync(dest, content, 'utf8');
106
+ if (shouldMakeExecutable(dest)) {
107
+ try {
108
+ fs.chmodSync(dest, 0o755);
109
+ } catch (err) {
110
+ // Ignore chmod errors on platforms that don't support it.
111
+ }
112
+ }
105
113
  }
106
114
 
107
115
  function stripDemo(targetDir) {
108
116
  removePath(path.join(targetDir, 'apps', 'web', 'src', 'modules', 'demo'));
109
- removePath(path.join(targetDir, 'apps', 'server', 'internal', 'demo'));
117
+ removePath(path.join(targetDir, 'apps', 'server', 'internal', 'modules', 'demo'));
110
118
 
111
119
  updateFile(path.join(targetDir, 'apps', 'web', 'src', 'main.tsx'), (content) =>
112
120
  content.replace(/^\s*import ['"]\.\/modules\/demo['"];?\r?\n/m, '')
@@ -114,38 +122,71 @@ function stripDemo(targetDir) {
114
122
  updateFile(path.join(targetDir, 'apps', 'web', 'src', 'app.config.ts'), (content) =>
115
123
  content.replace(/,\s*['"]demo['"]/, '')
116
124
  );
117
- updateFile(path.join(targetDir, 'config.yaml'), (content) =>
125
+ updateFile(path.join(targetDir, 'apps', 'server', 'config.yaml'), (content) =>
126
+ content.replace(/\r?\n\s*-\s*['"]demo['"]\s*/g, '')
127
+ );
128
+ updateFile(path.join(targetDir, 'apps', 'server', 'config.example.yaml'), (content) =>
118
129
  content.replace(/\r?\n\s*-\s*['"]demo['"]\s*/g, '')
119
130
  );
120
- updateFile(path.join(targetDir, 'README.md'), (content) => {
121
- let next = content.replace(
122
- 'Minimal Keystone platform shell (web + server) with a demo module.',
123
- 'Minimal Keystone platform shell (web + server).'
124
- );
125
- next = next.replace(/\n+Demo module:[\s\S]*$/m, '\n');
126
- return `${next.trimEnd()}\n`;
127
- });
128
-
129
- const serverMainPath = path.join(targetDir, 'apps', 'server', 'main.go');
130
- const serverMain = `package main
131
-
132
- import (
133
- \t"log"
134
-
135
- \t"github.com/robsuncn/keystone/server"
136
- )
137
-
138
- func main() {
139
- \tapp, err := server.New()
140
- \tif err != nil {
141
- \t\tlog.Fatalf("failed to initialize server: %v", err)
142
- \t}
143
- \tif err := app.Run(); err != nil {
144
- \t\tlog.Fatalf("server stopped: %v", err)
145
- \t}
131
+ updateFile(path.join(targetDir, 'README.md'), (content) =>
132
+ content.replace(/<!-- DEMO_START -->[\s\S]*?<!-- DEMO_END -->\s*/m, '')
133
+ );
134
+
135
+ const manifestPath = path.join(
136
+ targetDir,
137
+ 'apps',
138
+ 'server',
139
+ 'internal',
140
+ 'modules',
141
+ 'manifest.go'
142
+ );
143
+ const manifest = `package modules
144
+
145
+ // RegisterAll wires the module registry for this app.
146
+ func RegisterAll() {
147
+ \tClear()
146
148
  }
147
149
  `;
148
- fs.writeFileSync(serverMainPath, serverMain, 'utf8');
150
+ fs.writeFileSync(manifestPath, manifest, 'utf8');
151
+ }
152
+
153
+ function applyConfigOptions(targetDir, options) {
154
+ const configFiles = [
155
+ path.join(targetDir, 'apps', 'server', 'config.yaml'),
156
+ path.join(targetDir, 'apps', 'server', 'config.example.yaml'),
157
+ ];
158
+
159
+ for (const filePath of configFiles) {
160
+ updateFile(filePath, (content) => applyYamlOptions(content, options));
161
+ }
162
+
163
+ }
164
+
165
+ function applyYamlOptions(content, options) {
166
+ let next = content;
167
+ if (options.db) {
168
+ next = updateYamlSectionValue(next, 'database', 'driver', options.db);
169
+ if (options.db === 'sqlite') {
170
+ next = updateYamlSectionValue(next, 'database', 'path', './data/keystone-local.db');
171
+ } else if (options.db === 'postgres') {
172
+ next = updateYamlSectionValue(next, 'database', 'path', '');
173
+ }
174
+ }
175
+ if (options.queue) {
176
+ next = updateYamlSectionValue(next, 'queue', 'driver', options.queue);
177
+ }
178
+ if (options.storage) {
179
+ next = updateYamlSectionValue(next, 'storage', 'driver', options.storage);
180
+ }
181
+ return next;
182
+ }
183
+
184
+ function updateYamlSectionValue(content, section, key, value) {
185
+ const pattern = new RegExp(`(^${section}:\\s*[\\s\\S]*?^\\s*${key}:\\s*\")([^\"]*)(\")`, 'm');
186
+ if (!pattern.test(content)) {
187
+ return content;
188
+ }
189
+ return content.replace(pattern, `$1${value}$3`);
149
190
  }
150
191
 
151
192
  function removePath(target) {
@@ -164,3 +205,101 @@ function updateFile(filePath, updater) {
164
205
  fs.writeFileSync(filePath, next, 'utf8');
165
206
  }
166
207
  }
208
+
209
+ function shouldMakeExecutable(filePath) {
210
+ const normalized = String(filePath).replace(/\\/g, '/');
211
+ if (normalized.endsWith('.sh')) {
212
+ return true;
213
+ }
214
+ return normalized.includes('/.husky/');
215
+ }
216
+
217
+ function parseArgs(argv) {
218
+ const out = { demo: null };
219
+ for (let i = 0; i < argv.length; i++) {
220
+ const arg = argv[i];
221
+ if (arg === '--demo') {
222
+ out.demo = true;
223
+ continue;
224
+ }
225
+ if (arg === '--no-demo') {
226
+ out.demo = false;
227
+ continue;
228
+ }
229
+ if (arg === '--help' || arg === '-h') {
230
+ out.help = true;
231
+ continue;
232
+ }
233
+
234
+ const profile = readValueOption(arg, argv, i, '--profile');
235
+ if (profile) {
236
+ out.profile = profile.value;
237
+ i += profile.skip;
238
+ continue;
239
+ }
240
+ const db = readValueOption(arg, argv, i, '--db');
241
+ if (db) {
242
+ out.db = db.value;
243
+ i += db.skip;
244
+ continue;
245
+ }
246
+ const queue = readValueOption(arg, argv, i, '--queue');
247
+ if (queue) {
248
+ out.queue = queue.value;
249
+ i += queue.skip;
250
+ continue;
251
+ }
252
+ const storage = readValueOption(arg, argv, i, '--storage');
253
+ if (storage) {
254
+ out.storage = storage.value;
255
+ i += storage.skip;
256
+ continue;
257
+ }
258
+
259
+ if (arg.startsWith('-')) {
260
+ fail(`Unknown option: ${arg}`);
261
+ }
262
+ if (!out.target) {
263
+ out.target = arg;
264
+ continue;
265
+ }
266
+ fail('Too many arguments.');
267
+ }
268
+ return out;
269
+ }
270
+
271
+ function readValueOption(arg, argv, index, name) {
272
+ const prefix = `${name}=`;
273
+ if (arg.startsWith(prefix)) {
274
+ const value = arg.slice(prefix.length);
275
+ if (!value) {
276
+ fail(`Missing value for ${name}`);
277
+ }
278
+ return { value, skip: 0 };
279
+ }
280
+ if (arg === name) {
281
+ const value = argv[index + 1];
282
+ if (!value || value.startsWith('-')) {
283
+ fail(`Missing value for ${name}`);
284
+ }
285
+ return { value, skip: 1 };
286
+ }
287
+ return null;
288
+ }
289
+
290
+ function normalizeChoice(value, choices, label) {
291
+ if (!value) {
292
+ return null;
293
+ }
294
+ const normalized = String(value).trim().toLowerCase();
295
+ if (!choices.includes(normalized)) {
296
+ fail(`Invalid ${label}: ${value}`);
297
+ }
298
+ return normalized;
299
+ }
300
+
301
+ function fail(message) {
302
+ console.error(message);
303
+ console.error(usage);
304
+ process.exit(1);
305
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robsun/create-keystone-app",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env sh
2
+ . "$(dirname -- "$0")/_/husky.sh"
3
+
4
+ pnpm lint-staged
@@ -0,0 +1,5 @@
1
+ {
2
+ "apps/web/**/*.{ts,tsx,js,jsx}": ["pnpm --filter web lint --fix", "prettier --write"],
3
+ "*.{json,yml,yaml,md}": ["prettier --write"],
4
+ "apps/server/**/*.go": ["gofmt -w"]
5
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "semi": false,
3
+ "singleQuote": true,
4
+ "tabWidth": 2,
5
+ "trailingComma": "es5",
6
+ "printWidth": 100,
7
+ "endOfLine": "lf",
8
+ "arrowParens": "always"
9
+ }
@@ -1,41 +1,63 @@
1
- # __RAW_NAME__
2
- Minimal Keystone platform shell (web + server) with a demo module.
1
+ # __RAW_NAME__
3
2
 
4
- ## Guides
5
- - `docs/CONVENTIONS.md` - module structure, routing/menu/permission rules, API patterns, and tests.
3
+ Keystone 平台脚手架项目(Web + Server)。
6
4
 
7
- ## Prereqs
8
- - Node 18+ and pnpm
9
- - Go 1.23+
10
-
11
- ## Setup
12
- 1) Install dependencies:
5
+ ## 快速开始
6
+ 1) 安装依赖
13
7
  ```bash
14
8
  pnpm install
15
9
  ```
16
10
 
17
- 2) Start the Keystone server:
11
+ 2) 启动开发环境(前后端一起)
18
12
  ```bash
19
- pnpm server:dev
13
+ pnpm dev
20
14
  ```
21
15
 
22
- 3) Start the web shell:
16
+ 3) 单独启动
23
17
  ```bash
24
- pnpm dev
18
+ pnpm server:dev
19
+ pnpm web:dev
25
20
  ```
26
21
 
27
- ## Project Structure
28
- - `apps/web/`: React + Vite shell and business modules (`src/modules/*`).
29
- - `apps/server/`: Go API and module wiring (demo routes by default).
30
- - `config.yaml`: runtime config (ports, database, queue, storage).
31
-
32
- Demo module:
22
+ ## 初始化后建议
23
+ - 复制 `apps/web/.env.example` `apps/web/.env`(Vite 会读取)。
24
+ - 检查 `apps/server/config.yaml`(Go 运行配置)。
25
+ - 需要外部依赖时参考 `docker-compose.yml`。
26
+ - 建议阅读:`docs/CONVENTIONS.md`、`apps/web/README.md`、`apps/server/README.md`。
27
+
28
+ ## 常用命令
29
+ - `pnpm dev`:跨平台开发启动(Air + Vite)。
30
+ - `pnpm build`:构建并嵌入前端,生成可执行后端产物。
31
+ - `pnpm test`:Web 类型检查 + Lint + Vitest + Go 测试。
32
+ - `pnpm clean`:清理构建产物。
33
+
34
+ ## 目录结构
35
+ - `apps/web/`:React + Vite 壳与业务模块(`src/modules/*`)。
36
+ - `apps/server/`:Go API + 模块注册/迁移/种子数据(含 `config.yaml`)。
37
+ - `apps/server/internal/frontend/`:嵌入式前端目录(`dist/`)。
38
+ - `docs/CONVENTIONS.md`:项目规范与最佳实践。
39
+ - `apps/web/README.md`:前端壳与模块开发说明。
40
+ - `apps/server/README.md`:后端模块与运行说明。
41
+ - `scripts/`:跨平台 dev/build/test/clean。
33
42
 
34
- - Menu: Demo Tasks
35
- - API: `/api/v1/demo/tasks`
43
+ ```
44
+ .
45
+ ├─ apps/
46
+ │ ├─ web/ # 前端壳与模块
47
+ │ └─ server/ # Go 服务与模块
48
+ ├─ docs/ # 开发规范与说明
49
+ └─ scripts/ # 跨平台脚本
50
+ ```
36
51
 
37
- Default login:
52
+ <!-- DEMO_START -->
53
+ ## Demo 模块(可选)
54
+ 如果需要 Demo,可在创建时使用 `--demo` 或 `--profile=full`。
55
+ - 菜单:Demo Tasks
56
+ - API:`/api/v1/demo/tasks`
57
+ - 权限:`demo:task:view`、`demo:task:manage`
58
+ <!-- DEMO_END -->
38
59
 
60
+ ## 默认登录
39
61
  - Tenant code: `default`
40
62
  - Identifier: `admin`
41
63
  - Password: `Admin123!`
@@ -0,0 +1,44 @@
1
+ # Air - Live reload for Go apps
2
+ # https://github.com/air-verse/air
3
+ root = "."
4
+ testdata_dir = "testdata"
5
+ tmp_dir = "tmp"
6
+
7
+ [build]
8
+ bin = "./tmp/main.exe"
9
+ cmd = "go build -o ./tmp/main.exe ./cmd/server"
10
+ delay = 1000
11
+ exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules"]
12
+ exclude_file = []
13
+ exclude_regex = ["_test.go"]
14
+ exclude_unchanged = false
15
+ follow_symlink = false
16
+ include_dir = []
17
+ include_ext = ["go", "tpl", "tmpl", "html", "yaml", "yml"]
18
+ include_file = []
19
+ kill_delay = "0s"
20
+ log = "build-errors.log"
21
+ poll = false
22
+ poll_interval = 0
23
+ rerun = false
24
+ rerun_delay = 500
25
+ send_interrupt = false
26
+ stop_on_error = false
27
+
28
+ [color]
29
+ app = ""
30
+ build = "yellow"
31
+ main = "magenta"
32
+ runner = "green"
33
+ watcher = "cyan"
34
+
35
+ [log]
36
+ main_only = false
37
+ time = false
38
+
39
+ [misc]
40
+ clean_on_exit = true
41
+
42
+ [screen]
43
+ clear_on_rebuild = false
44
+ keep_scroll = true
@@ -0,0 +1,27 @@
1
+ # Server App
2
+
3
+ ## 定位
4
+ - Go 后端应用入口在 `cmd/server/main.go`。
5
+ - 业务模块统一放在 `internal/modules/*`。
6
+
7
+ ## 配置
8
+ - 主配置:`apps/server/config.yaml`。
9
+ - 模板配置:`apps/server/config.example.yaml`。
10
+ - 环境变量:需通过 Shell/工具注入(服务端不读取 `.env`)。
11
+
12
+ ## 数据与存储
13
+ - SQLite:`apps/server/data/keystone-local.db`。
14
+ - 本地存储:`apps/server/storage/`(`storage.driver=local`)。
15
+
16
+ ## 模块开发
17
+ 1) 在 `internal/modules/<module>` 增加模块实现。
18
+ 2) 在 `internal/modules/manifest.go` 注册模块。
19
+ 3) 在 `apps/server/config.yaml` 的 `modules.enabled` 启用模块。
20
+
21
+ ## 嵌入式前端
22
+ - 前端产物输出到 `internal/frontend/dist`。
23
+ - Go 使用 `//go:embed` 直接嵌入静态资源。
24
+
25
+ ## 常用命令
26
+ - 开发:`pnpm server:dev`
27
+ - 测试:`go -C apps/server test ./...`