@nebula-skills/nebula-code-standards 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,510 @@
1
+ # 独立仓库微前端子应用接入指南
2
+
3
+ > 业务前端要不要作为独立仓库?怎么接入 nebula-web 主应用?本文给标准答案,**规则自闭环**,不依赖任何具体业务样板项目。
4
+
5
+ 主应用:`nebula-web/apps/nebula-web-main`(端口 `:3000`)。
6
+
7
+ ---
8
+
9
+ ## 一、两种子应用形态对比
10
+
11
+ | 维度 | monorepo 内置 | 独立仓库(推荐业务域) |
12
+ |------|-------------|--------------------|
13
+ | 位置 | `nebula-web/apps/nebula-web-{domain}` | 独立 Git 仓库 `nebula-{domain}-web` |
14
+ | `@nebula-web/*` 来源 | `workspace:*`(monorepo 软链) | `latest`(GitLab Package Registry) |
15
+ | 部署管道 | 主应用统一 build | 自带 `deploy/build-deploy.sh` |
16
+ | 适用场景 | 平台公共域(auth / system) | 业务域 |
17
+ | 修改 `@nebula-web/*` | 直接改源码即时生效 | 需在 nebula-web 改源码 → CI 发布 → `pnpm update @nebula-web/*` |
18
+ | 多团队协作 | 单仓库,统一 PR 流程 | 各业务团队独立仓库,权限隔离 |
19
+
20
+ > 业务子应用 **默认走独立仓库形态**。仅平台公共能力(与认证 / 系统配置绑定的)放进 monorepo。
21
+
22
+ ---
23
+
24
+ ## 二、独立仓库子应用必备文件清单
25
+
26
+ 按本清单创建文件,缺一个都可能导致接入失败。
27
+
28
+ ```
29
+ nebula-{domain}-web/
30
+ ├── package.json # private:true, name=nebula-{domain}-web, scripts: dev/dev:remote/build/typecheck
31
+ ├── pnpm-lock.yaml
32
+ ├── vite.config.ts # base/双入口/iceStarkHtmlFixPlugin/proxy
33
+ ├── tsconfig.json
34
+ ├── index.html # <div id="app"></div> + <script src="/src/main.ts">
35
+ ├── .env.development # 本地内网 IP 后端
36
+ ├── .env.remote # 联调线上域名后端
37
+ ├── .env.production # 生产 API_BASE
38
+ ├── .npmrc # GitLab Package Registry + auth token
39
+ ├── .gitignore # 见 [[init-new-project]] §六-.gitignore
40
+ ├── deploy/
41
+ │ ├── build-deploy.sh # macOS/Linux
42
+ │ ├── build-deploy.bat # Windows CMD
43
+ │ └── build-deploy.ps1 # Windows PowerShell
44
+ ├── README.md
45
+ └── src/
46
+ ├── main.ts # setLibraryName + mount/unmount + isInIcestark
47
+ ├── App.vue # 仅 RouterView
48
+ ├── api/index.ts # createRequest('/nebula-{domain}')
49
+ ├── router/routes.ts # 路由(不含 /{domain}app 前缀,由 getBasename 注入)
50
+ ├── pages/
51
+ ├── stores/
52
+ └── styles/
53
+ ```
54
+
55
+ ---
56
+
57
+ ## 三、关键配置详解
58
+
59
+ ### 3.1 package.json
60
+
61
+ ```json
62
+ {
63
+ "name": "nebula-{domain}-web",
64
+ "version": "0.1.0",
65
+ "private": true,
66
+ "type": "module",
67
+ "scripts": {
68
+ "dev": "vite --mode development --host 0.0.0.0 --port {PORT}",
69
+ "dev:remote": "vite --mode remote --host 0.0.0.0 --port {PORT}",
70
+ "build": "vite build",
71
+ "typecheck": "vue-tsc --noEmit",
72
+ "update:nebula": "pnpm update @nebula-web/components @nebula-web/types @nebula-web/utils"
73
+ },
74
+ "dependencies": {
75
+ "@ice/stark-app": "^1.5.0",
76
+ "@ice/stark-data": "^0.1.3",
77
+ "@nebula-web/components": "latest",
78
+ "@nebula-web/types": "latest",
79
+ "@nebula-web/utils": "latest",
80
+ "axios": "^1.7.0",
81
+ "element-plus": "^2.8.0",
82
+ "pinia": "^2.2.0",
83
+ "vue": "^3.5.0",
84
+ "vue-router": "^4.4.0"
85
+ }
86
+ }
87
+ ```
88
+
89
+ 关键约束:
90
+ - ✅ `"private": true` 防止意外发布
91
+ - ✅ `"name": "nebula-{domain}-web"`(kebab-case 全小写)
92
+ - ✅ 端口必须避开 `3000` / 已被占用的子应用端口(参考主应用 `.env.development`)
93
+
94
+ ### 3.2 vite.config.ts 三大要素
95
+
96
+ ```typescript
97
+ import { fileURLToPath, URL } from 'node:url'
98
+ import { defineConfig, loadEnv } from 'vite'
99
+ import { resolve } from 'path'
100
+ import { readFileSync, writeFileSync } from 'fs'
101
+ import vue from '@vitejs/plugin-vue'
102
+ import AutoImport from 'unplugin-auto-import/vite'
103
+ import Components from 'unplugin-vue-components/vite'
104
+ import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
105
+ import compression from 'vite-plugin-compression'
106
+ import type { Plugin } from 'vite'
107
+
108
+ /**
109
+ * 修复 IceStark loadScriptMode:'import' 时 HTML 引用错误 facade 的问题。
110
+ * 同时把 index.html 和 src/main.ts 作为 Rollup 入口时,
111
+ * - index-xxx.js:HTML 用的 facade(无 mount/unmount 导出)
112
+ * - main-xxx.js :main.ts 用的 facade(有 export { mount, unmount })
113
+ * 默认 HTML 注入 index-xxx.js,IceStark import() 该文件拿不到导出 → 白屏。
114
+ * 此插件在 writeBundle 阶段把 HTML 里的 index facade 替换为 main facade。
115
+ */
116
+ function iceStarkHtmlFixPlugin(): Plugin {
117
+ return {
118
+ name: 'icestark-html-fix',
119
+ apply: 'build',
120
+ writeBundle(options, bundle) {
121
+ let indexFacade: string | null = null
122
+ let mainFacade: string | null = null
123
+ for (const chunk of Object.values(bundle)) {
124
+ if (chunk.type === 'chunk' && chunk.isEntry) {
125
+ if (chunk.name === 'index') indexFacade = chunk.fileName
126
+ if (chunk.name === 'main') mainFacade = chunk.fileName
127
+ }
128
+ }
129
+ if (!indexFacade || !mainFacade) return
130
+ const outDir = options.dir ?? 'dist'
131
+ const htmlPath = resolve(outDir, 'index.html')
132
+ try {
133
+ let html = readFileSync(htmlPath, 'utf-8')
134
+ html = html.replace(indexFacade, mainFacade)
135
+ writeFileSync(htmlPath, html)
136
+ console.log(`[icestark-html-fix] ${indexFacade} → ${mainFacade}`)
137
+ } catch (_e) { /* ignore */ }
138
+ },
139
+ }
140
+ }
141
+
142
+ export default defineConfig(({ mode }) => {
143
+ const env = loadEnv(mode, process.cwd(), '')
144
+ const isProd = mode === 'production'
145
+ const needRewrite = env.VITE_PROXY_REWRITE !== 'false'
146
+
147
+ return {
148
+ // 要素一:base 与 Nginx location 对齐
149
+ base: isProd ? '/{domain}app/' : '/',
150
+ plugins: [
151
+ vue(),
152
+ // 要素二:iceStarkHtmlFixPlugin
153
+ iceStarkHtmlFixPlugin(),
154
+ ...(isProd ? [compression({ ext: '.gz', algorithm: 'gzip', threshold: 10240, level: 9 })] : []),
155
+ AutoImport({
156
+ imports: ['vue', 'vue-router', 'pinia'],
157
+ resolvers: [ElementPlusResolver()],
158
+ dts: 'src/types/auto-imports.d.ts',
159
+ }),
160
+ Components({
161
+ resolvers: [ElementPlusResolver()],
162
+ dts: 'src/types/components.d.ts',
163
+ }),
164
+ ],
165
+ resolve: {
166
+ alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
167
+ },
168
+ // @ice/stark-data 为 CommonJS,预构建后才能正确解析 named export
169
+ optimizeDeps: { include: ['@ice/stark-data'] },
170
+ server: {
171
+ port: {PORT},
172
+ host: '127.0.0.1',
173
+ cors: true,
174
+ // 允许主应用跨域加载子应用资源
175
+ headers: { 'Access-Control-Allow-Origin': '*' },
176
+ proxy: {
177
+ '/nebula-{domain}': {
178
+ target: env.VITE_{DOMAIN}_BACKEND || 'http://localhost:8080',
179
+ changeOrigin: true,
180
+ ...(needRewrite ? { rewrite: (p: string) => p.replace(/^\/nebula-{domain}/, '') } : {}),
181
+ },
182
+ },
183
+ },
184
+ build: {
185
+ target: 'es2015',
186
+ chunkSizeWarningLimit: 1000,
187
+ rollupOptions: {
188
+ // 要素三:双 Rollup 入口 + preserveEntrySignatures
189
+ input: {
190
+ index: fileURLToPath(new URL('./index.html', import.meta.url)),
191
+ main: fileURLToPath(new URL('./src/main.ts', import.meta.url)),
192
+ },
193
+ preserveEntrySignatures: 'exports-only',
194
+ },
195
+ },
196
+ }
197
+ })
198
+ ```
199
+
200
+ **iceStarkHtmlFixPlugin 原理**:
201
+ - Vite 把 `index.html` 作为入口时,会生成 `index-{hash}.js`(**无导出**)和 `main-{hash}.js`(**有 mount/unmount 导出**)两个 facade
202
+ - 默认 HTML 引用了无导出的 `index-xxx.js`,IceStark `import()` 拿不到 mount/unmount → 白屏
203
+ - 此插件在 `writeBundle` 阶段把 HTML 里的 index facade 替换为 main facade
204
+
205
+ ### 3.3 src/main.ts 四个钩子
206
+
207
+ ```typescript
208
+ import { createApp, type App as VueApp } from 'vue'
209
+ import { createRouter, createWebHistory } from 'vue-router'
210
+ import { createPinia } from 'pinia'
211
+ import ElementPlus from 'element-plus'
212
+ import zhCn from 'element-plus/es/locale/lang/zh-cn'
213
+ import NebulaComponents from '@nebula-web/components'
214
+ import isInIcestark from '@ice/stark-app/lib/isInIcestark'
215
+ import getBasename from '@ice/stark-app/lib/getBasename'
216
+ import setLibraryName from '@ice/stark-app/lib/setLibraryName'
217
+ import App from './App.vue'
218
+ import routes from './router/routes'
219
+
220
+ // 钩子一:库名(与主应用 microAppConfig.name 完全一致)
221
+ setLibraryName('nebula{Domain}App')
222
+
223
+ let vueApp: VueApp | null = null
224
+
225
+ function createVueApp(basename: string) {
226
+ const router = createRouter({ history: createWebHistory(basename), routes })
227
+ const app = createApp(App)
228
+ app.use(createPinia())
229
+ .use(router)
230
+ .use(ElementPlus, { locale: zhCn })
231
+ .use(NebulaComponents)
232
+ return { app, router }
233
+ }
234
+
235
+ // 钩子二:safeUnmount(处理 HMR 双挂载 / DOM 残留)
236
+ function safeUnmount(container?: HTMLElement) {
237
+ if (vueApp) { vueApp.unmount(); vueApp = null }
238
+ if (container && (container as any).__vue_app__) {
239
+ ;(container as any).__vue_app__.unmount()
240
+ }
241
+ }
242
+
243
+ // 钩子三:mount
244
+ export function mount({ container }: { container: HTMLElement }) {
245
+ safeUnmount(container)
246
+ const { app } = createVueApp(getBasename()) // → '/{domain}app'
247
+ vueApp = app
248
+ app.mount(container)
249
+ }
250
+
251
+ // 钩子四:unmount
252
+ export function unmount() { safeUnmount() }
253
+
254
+ // 生产 Docker 环境 fallback
255
+ ;(window as any).nebula{Domain}App = { mount, unmount }
256
+
257
+ // 独立运行模式(本地纯 UI 调试,不依赖主应用)
258
+ if (!isInIcestark()) {
259
+ const { app } = createVueApp('/')
260
+ app.mount('#app')
261
+ }
262
+ ```
263
+
264
+ ### 3.4 三套 .env
265
+
266
+ **.env.development**(pnpm dev,本地内网 IP 后端)
267
+ ```env
268
+ VITE_{DOMAIN}_API_BASE=
269
+ VITE_{DOMAIN}_BACKEND=http://localhost:8080
270
+ VITE_PROXY_REWRITE=true
271
+ ```
272
+
273
+ **.env.remote**(pnpm dev:remote,联调线上)
274
+ ```env
275
+ VITE_{DOMAIN}_API_BASE=
276
+ VITE_{DOMAIN}_BACKEND=https://{your-host-domain}
277
+ VITE_PROXY_REWRITE=false
278
+ ```
279
+
280
+ **.env.production**
281
+ ```env
282
+ VITE_{DOMAIN}_API_BASE=/nebula-{domain}
283
+ ```
284
+
285
+ **.npmrc**
286
+ ```ini
287
+ @nebula-web:registry=https://gitlab.{your-gitlab-host}/api/v4/packages/npm/
288
+ //gitlab.{your-gitlab-host}/api/v4/packages/npm/:_authToken=${GITLAB_TOKEN}
289
+ ```
290
+
291
+ ### 3.5 src/api/index.ts
292
+
293
+ ```typescript
294
+ import { createRequest } from '@nebula-web/utils'
295
+
296
+ // 空字符串时回退到 /nebula-{domain},由 proxy 转发
297
+ export const {domain}Request = createRequest(
298
+ import.meta.env.VITE_{DOMAIN}_API_BASE || '/nebula-{domain}'
299
+ )
300
+ export const systemRequest = createRequest(
301
+ import.meta.env.VITE_{DOMAIN}_API_BASE || '/nebula-system'
302
+ )
303
+ ```
304
+
305
+ `createRequest` 内置:JWT 注入、`X-Tenant-Id` 注入、401 静默刷新、`R<T>` 解包。**不要自己 axios.create**。
306
+
307
+ ### 3.6 index.html(极简)
308
+
309
+ ```html
310
+ <!DOCTYPE html>
311
+ <html lang="zh-CN">
312
+ <head>
313
+ <meta charset="UTF-8" />
314
+ <meta name="viewport" content="width=device-width,initial-scale=1.0" />
315
+ <title>nebula-{domain}-web</title>
316
+ </head>
317
+ <body>
318
+ <div id="app"></div>
319
+ <script type="module" src="/src/main.ts"></script>
320
+ </body>
321
+ </html>
322
+ ```
323
+
324
+ ---
325
+
326
+ ## 四、主应用注册步骤
327
+
328
+ 修改 `nebula-web/apps/nebula-web-main/src/layout/tabs/microAppConfig.ts`,追加:
329
+
330
+ ```typescript
331
+ const microApps = [
332
+ // ... 已有
333
+ {
334
+ name: '{domain}app', // ← 与 setLibraryName 去掉前缀和大写后一致
335
+ activePath: '/{domain}app',
336
+ title: '{业务中文名}',
337
+ entry: 'VITE_MICRO_APP_{DOMAIN}',
338
+ },
339
+ ]
340
+ ```
341
+
342
+ 修改 `nebula-web/apps/nebula-web-main/.env.development`:
343
+ ```env
344
+ VITE_MICRO_APP_{DOMAIN}={PORT}
345
+ ```
346
+
347
+ 修改 `nebula-web/apps/nebula-web-main/.env.production`:
348
+ ```env
349
+ VITE_MICRO_APP_{DOMAIN}=/{domain}app/
350
+ ```
351
+
352
+ ---
353
+
354
+ ## 五、本地联调(两个仓库同时启动)
355
+
356
+ ```bash
357
+ # 终端 1:主应用
358
+ cd /path/to/nebula-web
359
+ pnpm dev # 主应用 :3000 + 内置 auth/system 子应用
360
+
361
+ # 终端 2:业务子应用
362
+ cd /path/to/nebula-{domain}-web
363
+ pnpm dev # :{PORT}
364
+ ```
365
+
366
+ 打开 `http://127.0.0.1:3000/` → 登录 → 菜单点击「{业务中文名}」→ 主应用通过 IceStark `import('http://127.0.0.1:{PORT}/')` 加载子应用。
367
+
368
+ **两端 mode 必须一致**:主应用 `pnpm dev:remote` 时子应用也必须 `pnpm dev:remote`,否则后端代理目标不一致。
369
+
370
+ ---
371
+
372
+ ## 六、与主应用通信
373
+
374
+ ### 6.1 共享状态(store)
375
+
376
+ 主应用在登录 / 切换租户后通过 `@ice/stark-data` 推送:
377
+
378
+ ```typescript
379
+ // 主应用(已实现,子应用直接读即可)
380
+ import { store } from '@ice/stark-data'
381
+ store.set('nebulaToken', accessToken)
382
+ store.set('nebulaUser', userInfo)
383
+ store.set('nebulaPermissions', ['xxx:read', 'xxx:write'])
384
+ store.set('nebulaRoles', ['admin'])
385
+ store.set('nebulaTenantId', tenantId)
386
+ store.set('nebulaAuthBaseURL', '/nebula-auth')
387
+ ```
388
+
389
+ 子应用读取:
390
+ ```typescript
391
+ import { store } from '@ice/stark-data'
392
+ const token = store.get('nebulaToken')
393
+ const perms = store.get('nebulaPermissions') ?? []
394
+ ```
395
+
396
+ > 实际开发中,子应用基本不需要直接读 store —— `@nebula-web/utils` 的 `createRequest` 已经在拦截器里自动从 store 拿 token。
397
+
398
+ ### 6.2 事件(event)
399
+
400
+ 子应用主动通知主应用:
401
+
402
+ ```typescript
403
+ import { event } from '@ice/stark-data'
404
+
405
+ event.emit('401ToLogin', 'token 已失效') // 跳登录
406
+ event.emit('toLogout') // 主动登出
407
+ event.emit('routerPush', '/dashboard') // 主应用路由跳转
408
+ event.emit('setGlobalLoading', true) // 全局 loading
409
+ ```
410
+
411
+ ---
412
+
413
+ ## 七、命名一致性约束(最易翻车)⚠️
414
+
415
+ 下面五处命名必须严格对齐,**任一不一致都会导致 IceStark 加载白屏**:
416
+
417
+ | 位置 | 取值规则 | 示例(`{domain}=mes`) |
418
+ |------|---------|-----------------------|
419
+ | 子应用 `src/main.ts` | `setLibraryName('nebula{PascalDomain}App')` | `setLibraryName('nebulaMesApp')` |
420
+ | 主应用 `microAppConfig.ts` | `name: '{domain}app'`(剥除前缀 / 全小写) | `name: 'mesapp'` |
421
+ | 主应用 `microAppConfig.ts` | `activePath: '/{domain}app'` | `activePath: '/mesapp'` |
422
+ | 子应用 `vite.config.ts` | `base: isProd ? '/{domain}app/' : '/'` | `base: '/mesapp/'` |
423
+ | 生产 Nginx | `location /{domain}app/ { ... }` | `location /mesapp/` |
424
+ | 主应用 `.env.production` | `VITE_MICRO_APP_{DOMAIN}=/{domain}app/` | `VITE_MICRO_APP_MES=/mesapp/` |
425
+
426
+ 派生约定:
427
+ - `{PascalDomain}` = 首字母大写(`mes` → `Mes`,`scm` → `Scm`)
428
+ - `{domain}app` = 全小写
429
+ - `{DOMAIN}` = 全大写
430
+
431
+ ---
432
+
433
+ ## 八、部署
434
+
435
+ ### 8.1 deploy/build-deploy.sh 骨架
436
+
437
+ ```bash
438
+ #!/bin/bash
439
+ # 编译并发布到 Nginx 服务器
440
+ set -e
441
+
442
+ SERVER_HOST="${SERVER_HOST:-}" # 必填:通过环境变量传入
443
+ SERVER_USER="${SERVER_USER:-ecs-user}"
444
+ SERVER_PORT="${SERVER_PORT:-22}"
445
+ REMOTE_HTML_BASE="${REMOTE_HTML_BASE:-/data/service/nginx/html/nebula-web/{domain}}"
446
+
447
+ if [ -z "$SERVER_HOST" ]; then
448
+ echo "❌ 请设置 SERVER_HOST 环境变量"
449
+ exit 1
450
+ fi
451
+
452
+ # 1. 检查工具
453
+ command -v pnpm >/dev/null || { echo "❌ pnpm 未安装"; exit 1; }
454
+ command -v scp >/dev/null || { echo "❌ scp 未安装"; exit 1; }
455
+ command -v ssh >/dev/null || { echo "❌ ssh 未安装"; exit 1; }
456
+
457
+ # 2. 安装依赖(可选 -u 更新 @nebula-web/*)
458
+ if [ "$1" = "-u" ]; then
459
+ export GITLAB_TOKEN="${GITLAB_TOKEN:?需要设置 GITLAB_TOKEN}"
460
+ pnpm update @nebula-web/components @nebula-web/types @nebula-web/utils
461
+ else
462
+ pnpm install
463
+ fi
464
+
465
+ # 3. 类型检查(可选 -f 跳过)
466
+ [ "$1" != "-f" ] && pnpm typecheck
467
+
468
+ # 4. 编译
469
+ pnpm build
470
+
471
+ # 5. 上传
472
+ ssh -p "$SERVER_PORT" "$SERVER_USER@$SERVER_HOST" "mkdir -p $REMOTE_HTML_BASE"
473
+ scp -P "$SERVER_PORT" -r dist/* "$SERVER_USER@$SERVER_HOST:$REMOTE_HTML_BASE/"
474
+
475
+ echo "✅ 已发布到 $SERVER_HOST:$REMOTE_HTML_BASE"
476
+ ```
477
+
478
+ ### 8.2 Nginx 配置
479
+
480
+ ```nginx
481
+ location /{domain}app/ {
482
+ alias /data/service/nginx/html/nebula-web/{domain}/;
483
+ try_files $uri $uri/ /{domain}app/index.html;
484
+ gzip_static on;
485
+ }
486
+
487
+ # 后端代理(与开发 proxy 路径一致)
488
+ location /nebula-{domain}/ {
489
+ proxy_pass http://{backend-upstream}/;
490
+ proxy_set_header Authorization $http_authorization;
491
+ proxy_set_header X-Real-IP $remote_addr;
492
+ }
493
+ ```
494
+
495
+ ---
496
+
497
+ ## 九、避坑清单(本主题专属)
498
+
499
+ - ⚠️ `setLibraryName` 与 `microAppConfig.name` 不一致 → 主应用 `import()` 后拿不到 mount/unmount → 白屏
500
+ - ⚠️ `vite.config.ts` 漏掉双 Rollup 入口或 `preserveEntrySignatures: 'exports-only'` → 同上
501
+ - ⚠️ 漏掉 `iceStarkHtmlFixPlugin` → 生产构建后 HTML 引用错误 facade → 白屏
502
+ - ⚠️ `optimizeDeps.include` 没加 `@ice/stark-data` → 开发模式 named export 解析失败
503
+ - ⚠️ `main.ts` 的 `mount` 没 safeUnmount → HMR 后双挂载 / DOM 残留
504
+ - ⚠️ 业务代码直接用 `axios.create` → 应使用 `@nebula-web/utils` 的 `createRequest`
505
+ - ⚠️ 直接读 `localStorage.getItem('token')` → 应通过 `@ice/stark-data` store 或 `@nebula-web/utils` 的 token helper
506
+ - ⚠️ `.npmrc` 没配 `GITLAB_TOKEN` → `pnpm install` 拉不到 `@nebula-web/*`
507
+ - ⚠️ 主子应用 mode 不一致(一边 dev,一边 dev:remote)→ 接口请求落到错误后端
508
+ - ⚠️ 子应用 `index.html` 标题、`README.md`、`package.json` 描述、UI 字面量中残留其他业务样板字眼 → 必须替换为当前业务名
509
+
510
+ 详尽避坑参考 [pitfalls-checklist.md](pitfalls-checklist.md) §G。