ワンクリックで
csisp-fe-sop
CSISP 前端 + BFF 开发 SOP。Invoke when developing frontend features (Portal, Idp Client, etc.) or BFF interfaces.
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
メニュー
CSISP 前端 + BFF 开发 SOP。Invoke when developing frontend features (Portal, Idp Client, etc.) or BFF interfaces.
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
SOC 職業分類に基づく
确认数据库调整内容后生成 Supabase 迁移文件。Invoke when user wants to create a database migration or has made database schema changes.
CSISP 后端微服务开发 SOP。Invoke when developing new microservices (Integrated Server, Idp Server, etc.) or adding features to existing services.
CSISP Supabase 开发/迁移 SOP。需要进行数据库结构变更、生成迁移、Dev 联调、Prod 发布或排查迁移不一致时调用。
定义与创建 Supabase 数据库函数(RPC)的 SOP。遇到需要将写路径改造为 SECURITY DEFINER RPC 或需要在 Dashboard 创建/更新函数时调用。
撰写与维护文档项目(docs/)的统一 SOP 与 VitePress 写法。需要编写/重构文档或规范时调用。
| name | csisp-fe-sop |
| description | CSISP 前端 + BFF 开发 SOP。Invoke when developing frontend features (Portal, Idp Client, etc.) or BFF interfaces. |
按需使用小节: 本 SOP 的所有小节都是可选的,根据实际需求决定是否执行。
示例场景:
- 新功能完整开发 → 执行所有小节
- 只是修复现有页面 Bug → 可能只需要执行"前端开发"中的部分内容
- 只是新增一个 BFF 接口 → 可能只需要执行"规划阶段(BFF 接口设计)"、"Contracts 定义"、"BFF 开发"
SOP 变更确认: 如果用户的需求与本文档 SOP 不一致,先与用户确认差异。如果用户坚持更改,总结该变更对 SOP 的影响,以及 SOP 需要如何调整,再次确认后才更新 SOP。
| 术语 | 全称 | 解释 |
|---|---|---|
| BFF | Backend-for-Frontend | 前端代理层,负责协议转换、会话透传、统一 API 出口 |
| Contracts | API 契约定义 | 前端与 BFF 之间的类型安全接口定义层,单一数据源 |
| Domain | 业务域 | 按功能划分的业务模块(如 auth、demo、example) |
| Action | 操作 | RPC 风格的接口动作,使用 snake-case 命名 |
| Path Prefix | 路径前缀 | 接口路径的统一前缀常量 |
| Schema | 数据模式 | Zod 定义的数据验证规则,用于运行时校验和类型推导 |
| Frontend-app | 前端应用 | 指代具体的前端项目(如 portal、idp-client、admin) |
| Controller | 控制器 | BFF 层处理 HTTP 请求的 NestJS 组件 |
| Module | 模块 | NestJS 的模块系统,用于组织代码结构 |
| createDomainCall | 域名调用工厂函数 | 用于创建类型安全的 API 调用函数的工厂函数 |
| RPC 风格 | Remote Procedure Call 风格 | 使用 POST /domain/action 格式的 REST 接口设计 |
| 页面级组件 | Page-level Component | 仅单个页面使用的组件,位于 pages/{Domain}/components/ |
| 公共组件 | Shared Component | 项目内复用的组件,位于 src/components/{Domain}/ |
| 消息插值 | Message Interpolation | 翻译文案中使用 {variable} 语法动态替换变量 |
| 类型生成 | Type Generation | 通过 pnpm build 从 Schema 自动生成 TypeScript 类型 |
| *Params | Params 类型 | 从 Body Schema 生成的请求参数类型 |
| *Result | Result 类型 | 从 Response Schema 生成的响应结果类型 |
| *Action | Action 类型 | 从 Routes 生成的 Action 常量类型 |
flowchart TD
Start([开始]) --> Step1[步骤 1: 规划阶段]
Step1 --> P1[1.1 前端页面规划]
Step1 --> P2[1.2 BFF 接口设计]
Step1 --> P3{1.3 是否需要翻译}
P3 -->|是| P3a[规划翻译文案]
P3 -->|否| Step2
P3a --> Step2[步骤 2: Contracts 定义]
Step2 --> C1[2.1 定义 Path Prefix]
Step2 --> C2[2.2 定义 Schema<br/>Body + Response]
Step2 --> C3[2.3 定义 Routes]
Step2 --> C4[⚡ 运行 pnpm build<br/>生成类型文件]
C4 --> Step3[步骤 3: BFF 开发]
Step3 --> D1[3.1 创建目录结构]
Step3 --> D2[3.2 实现 Controller<br/>使用 @TsRest 装饰器]
Step3 --> D3[3.3 注册 Module]
D3 --> Step4[步骤 4: 前端开发]
Step4 --> E1[4.1 确认 UI 组件库]
Step4 --> E2[4.2 创建目录结构]
Step4 --> E3[4.6 API 调用封装<br/>使用 createDomainCall]
Step4 --> E4{4.3-4.5 开发组件}
E4 --> E4a[公共组件]
E4 --> E4b[页面组件]
Step4 --> E5[4.7 配置路由与菜单]
Step4 --> E6{4.8 是否需要翻译}
E6 -->|是| E6a[配置 i18n]
E6 -->|否| Step5
E6a --> Step5[步骤 5: 验证与检查]
Step5 --> V1{5.1 构建测试}
V1 -->|失败| Fix[修复问题]
V1 -->|成功| V2{5.2 类型检查}
V2 -->|失败| Fix
V2 -->|成功| V3{5.3 Lint 检查}
V3 -->|失败| Fix
V3 -->|成功| V4[5.4 格式化]
V4 --> Done([完成])
Fix --> Step5
style C4 fill:#ffeb3b
style V1 fill:#ffcdd2
style V2 fill:#ffcdd2
style V3 fill:#ffcdd2
明确页面结构、路由设计和组件划分。
页面树示例:
/Demo # Demo 列表页
/Demo/:id # Demo 详情页
/Example # Example 列表页
/Example/:id # Example 详情页
组件划分原则:
| 分类 | 位置 | 说明 |
|---|---|---|
| 页面级组件 | pages/{Domain}/components/ | 仅该页面使用 |
| 公共组件 | src/components/{Domain}/ | 项目内复用 |
检查清单:
设计 BFF HTTP API 契约。
路径格式:POST /api/{frontend-app}/{domain}/{action}
示例:
POST /api/portal/demo/createDemo
POST /api/portal/demo/getDemoDetail
POST /api/portal/example/getExampleList
设计原则:
domain/action 格式如果需求涉及新增或修改用户可见文案,需要提前规划翻译工作。
判断标准:页面中任何对用户展示的中英文文字都需要翻译,包括但不限于:
翻译规划步骤:
识别翻译 key:根据现有命名空间确定使用哪个翻译文件
@csisp/idp-client 相关 → idp-client.json@csisp/portal 相关 → portal.jsoncommon.json命名规范:遵循 页面.功能.具体描述 格式
demo.title.label → Demo 页-标题-标签
example.submit → Example 页-提交按钮
example.detail.title → Example 详情-标题
生成翻译模板:运行以下命令生成本地翻译文件
# 为 idp-client 生成翻译模板
pnpm -F @csisp/i18n generate idp-client
# 为 portal 生成翻译模板
pnpm -F @csisp/i18n generate portal
上传翻译平台:将生成的 JSON 文件上传到 SimpleLocalize
拉取翻译:翻译完成后执行
pnpm -F @csisp/i18n pull:idp-client
pnpm -F @csisp/i18n pull:portal
packages/contracts/src/
├── {domain}/
│ └── {domain}.contract.ts
├── constants/
│ └── path-prefix.ts
└── index.ts
文件:packages/contracts/src/constants/path-prefix.ts
export const PATH_PREFIX = {
PORTAL_DEMO: '/api/portal/demo',
PORTAL_EXAMPLE: '/api/portal/example',
} as const;
关键点:使用 as const 确保类型推导。
文件:packages/contracts/src/{domain}/{domain}.contract.ts
import { initContract } from '@ts-rest/core';
import { z } from 'zod';
import { buildActionMapFromRoutes } from '../constants/action';
import { HTTP_METHOD } from '../constants/http';
import {
PORTAL_PATH_PREFIX,
PORTAL_DEMO_PATH_PREFIX,
} from '../constants/path-prefix';
const c = initContract();
export const createDemoBodySchema = z.object({
title: z.string().min(1).max(100),
content: z.string().min(1),
});
export const createDemoResponseSchema = z.object({
demo: z
.object({
id: z.string(),
title: z.string(),
content: z.string(),
authorId: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
})
.optional(),
code: z.number(),
message: z.string(),
});
const portalDemoRoutes = {
createDemo: {
method: HTTP_METHOD.POST,
path: PORTAL_DEMO_PATH_PREFIX + '/createDemo',
body: createDemoBodySchema,
responses: {
200: createDemoResponseSchema,
},
summary: '创建 Demo',
},
getDemoDetail: {
method: HTTP_METHOD.POST,
path: PORTAL_DEMO_PATH_PREFIX + '/getDemoDetail',
body: z.object({ demoId: z.string() }),
responses: {
200: createDemoResponseSchema,
},
summary: '获取 Demo 详情',
},
} as const satisfies Parameters<typeof c.router>[0];
export const portalDemoContract = c.router(portalDemoRoutes, {
pathPrefix: PORTAL_PATH_PREFIX,
strictStatusCodes: true,
});
export const PORTAL_DEMO_ACTION = buildActionMapFromRoutes(portalDemoRoutes);
导出:packages/contracts/src/index.ts
export * from './demo/demo.contract';
export * from './example/example.contract';
export * from './constants/path-prefix';
类型生成说明:
类型文件会通过 pnpm build 自动生成,不需要手动写类型。
| Schema 类型 | 生成的类型 | 使用场景 |
|---|---|---|
| Body Schema | *Params | BFF Controller 参数类型 |
| Response Schema | *Result | 前端接收的响应类型 |
| Routes | *Action | Action 常量类型 |
apps/bff/src/
├── modules/
│ └── {frontend-app}/
│ └── {domain}/
│ ├── {domain}.controller.ts
│ ├── {domain}.module.ts
│ └── index.ts
├── app.module.ts
└── main.ts
文件:apps/bff/src/modules/{frontend-app}/{domain}/{domain}.controller.ts
实现方式:使用标准 NestJS 装饰器 + Zod 验证(推荐)
import { Controller, Post, Body, Inject, Logger } from '@nestjs/common';
import { ClientGrpc } from '@nestjs/microservices';
import { ZodValidationPipe } from '@common/pipes/zod-validation.pipe';
import { firstValueFrom, map } from 'rxjs';
import {
PORTAL_DEMO_ACTION,
PORTAL_DEMO_PATH_PREFIX,
PORTAL_PATH_PREFIX,
createDemoBodySchema,
type CreateDemoParams,
type CreateDemoResult,
} from '@csisp/contracts';
const CONTROLLER_PREFIX = `${PORTAL_PATH_PREFIX}${PORTAL_DEMO_PATH_PREFIX}`;
@Controller(CONTROLLER_PREFIX)
export class DemoController {
private readonly logger = new Logger(DemoController.name);
constructor(
@Inject(INTEGRATED_CLIENT)
private readonly grpcClient: ClientGrpc
) {}
@Post(PORTAL_DEMO_ACTION.CREATE_DEMO)
async createDemo(
@Body(new ZodValidationPipe(createDemoBodySchema))
params: CreateDemoParams
): Promise<CreateDemoResult> {
this.logger.log('Creating demo', params);
// 调用上游 gRPC 服务并返回结果
return firstValueFrom(
this.demoClient
.createDemo({ CreateDemoRequest: params })
.pipe(map(res => res.data))
);
}
}
Controller 实现要点:
@Controller, @Post, @Body)ZodValidationPipe 进行参数验证(从 @csisp/contracts 导入 Schema)@Inject(INTEGRATED_CLIENT))firstValueFrom + map 处理 RxJS Observable{ status, body } 包装文件:apps/bff/src/modules/{frontend-app}/{domain}/{domain}.module.ts
import { Module } from '@nestjs/common';
import { DemoController } from './demo.controller';
@Module({
controllers: [DemoController],
exports: [],
})
export class DemoModule {}
注册到主模块:apps/bff/src/app.module.ts
import { Module } from '@nestjs/common';
import { PortalModule } from './modules/portal/portal.module';
@Module({
imports: [PortalModule],
})
export class AppModule {}
检查清单:
{Domain}Modulecontrollers 数组AppModule 中导入模块在开始开发前,先确认当前项目使用的 UI 组件库。
| 前端应用 | UI 组件库 |
|---|---|
| portal | Naive UI (naive-ui) |
| idp-client | Ant Design (antd) |
组件使用原则:
src/components/{Domain}/apps/frontend/{frontend-app}/src/
├── api/
│ ├── caller.ts
│ ├── index.ts
│ ├── demo.ts
│ └── example.ts
├── components/
│ └── {Domain}/
│ └── {Component}.vue
├── pages/
│ └── {Domain}/
│ ├── index.vue
│ └── components/
│ └── {PageComponent}.vue
├── layouts/
│ └── {App}Layout.vue
└── router/
└── index.ts
目录用途说明:
| 目录 | 用途 |
|---|---|
api/ | API 调用封装 |
components/ | 公共组件 |
pages/ | 页面级组件 |
layouts/ | 布局组件 |
router/ | 路由配置 |
要点说明:
<router-view /> 渲染子路由要点说明:
defineProps 定义输入defineEmits 定义事件检查清单:
onMounted 中加载数据关键代码片段:
// 数据加载
const demos = ref<Demo[]>([]);
onMounted(async () => {
demos.value = await demoApi.getDemoList({ page: 1 });
});
// 路由跳转
const router = useRouter();
router.push(`/Demo/${demoId}`);
API 封装方式:推荐使用对象封装方式,而非简单的 call 方式。
注意:不要手动拼接 action 字符串,请使用 contracts 包中提供的常量。
文件:apps/frontend/{frontend-app}/src/api/portal/demo.ts
import {
PORTAL_PATH_PREFIX,
type PortalDemoAction,
type CreateDemoParams,
type CreateDemoResult,
type GetDemoListParams,
type GetDemoListResult,
type GetDemoDetailParams,
type GetDemoDetailResult,
} from '@csisp/contracts';
import { createDomainCall } from '../caller';
const demoCall = createDomainCall<PortalDemoAction>(PORTAL_PATH_PREFIX, 'demo');
export const demoApi = {
async createDemo(params: CreateDemoParams): Promise<CreateDemoResult> {
return await demoCall<CreateDemoResult>('createDemo', params);
},
async getDemoList(params: GetDemoListParams): Promise<GetDemoListResult> {
return await demoCall<GetDemoListResult>('getDemoList', params);
},
async getDemoDetail(
params: GetDemoDetailParams
): Promise<GetDemoDetailResult> {
return await demoCall<GetDemoDetailResult>('getDemoDetail', params);
},
};
路由配置:apps/frontend/{frontend-app}/src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
import AppLayout from '@/layouts/AppLayout.vue';
const routes = [
{
path: '/',
component: AppLayout,
children: [
{
path: '',
redirect: '/Demo',
},
{
path: 'Demo',
name: 'Demo',
component: () => import('@/pages/Demo/index.vue'),
},
{
path: 'Demo/:id',
name: 'DemoDetail',
component: () => import('@/pages/DemoDetail/index.vue'),
},
],
},
];
export const router = createRouter({
history: createWebHistory(),
routes,
});
检查清单:
项目使用 SimpleLocalize 的 Multi-language JSON 格式,支持消息插值功能。
消息插值示例:
// Vue 3: t('key', { vars }, '默认值')
t('demo.total', { total: 100 }, '共 {total} 条');
// React: t('key', '默认值', { vars })
t('demo.total', '共 {total} 条', { total: 100 });
框架使用差异:
| 项目 | 使用方式 |
|---|---|
| idp-client (React) | useTranslation('common') |
| portal (Vue 3) | useI18n() |
翻译文件位置:
| 项目 | 路径 |
|---|---|
| idp-client | packages/i18n/src/locales/idp-client/{en,zh}/index.json |
| portal | packages/i18n/src/locales/portal/{en,zh}/index.json |
| common | packages/i18n/src/locales/common/{en,zh}/index.json |
新增翻译 key 流程:
pnpm -F @csisp/i18n pull:{project} 拉取最新翻译ref, computed, watch)文件位置:
| 类型 | 位置 | 示例 |
|---|---|---|
| 全局 Store | src/stores/{storeName}.ts | src/stores/locale.ts |
| 模块级 Store | src/{module}/store/index.ts | src/layouts/MainLayout/store/index.ts |
命名规范:
STORE_NAME = 'xxx' (kebab-case)use{PascalCase}Store() (如 useLocaleStore)STORAGE_KEYS = { ... } as const代码模板 (Setup Store 风格):
import localforage from 'localforage';
import { defineStore } from 'pinia';
import { computed, ref, watch } from 'vue';
import { APP_NAME } from '@/constants';
const STORE_NAME = '{storeName}'; // kebab-case
const STORAGE_KEYS = {
{STATE_NAME}: '{stateName}', // camelCase
} as const;
const storage = localforage.createInstance({
name: APP_NAME,
storeName: STORE_NAME,
});
export const use{StoreName}Store = defineStore(STORE_NAME, () => {
// 1. 响应式状态 (State)
const {stateName} = ref<{Type}>({defaultValue});
// 2. 计算属性 (Getters)
const {computedName} = computed(() => {
return /* 基于 stateName 的计算逻辑 */;
});
// 3. 操作方法 (Actions)
const set{StateName} = (value: {Type}) => {
{stateName}.value = value;
storage.setItem(STORAGE_KEYS.{STATE_NAME}, value); // 同步到持久化
};
// 4. 初始化方法 (从本地存储恢复)
const initFromStorage = async () => {
const value = await storage.getItem<{Type}>(STORAGE_KEYS.{STATE_NAME});
if (value !== null) {
{stateName}.value = value;
}
};
// 5. 响应式监听 (可选)
watch(
() => {stateName}.value,
newValue => {
// 执行副作用操作(如同步到 i18n、其他 Store 等)
}
);
return {
// State
{stateName},
// Getters
{computedName},
// Actions
set{StateName},
initFromStorage,
};
});
✅ 推荐: 函数式定义 (Composition API)
export const useLocaleStore = defineStore('locale', () => {
const currentLocale = ref('zh');
return { currentLocale };
});
❌ 不推荐: Options API 风格
export const useLocaleStore = defineStore('locale', {
state: () => ({ currentLocale: 'zh' }),
});
localforage.createInstance() 创建独立存储实例name + storeName 隔离不同 Store 的数据storage.setItem()initFromStorage() 方法用于应用启动时恢复状态示例 (参考 locale.ts):
const storage = localforage.createInstance({
name: APP_NAME, // 应用名称: 'csisp-portal'
storeName: STORE_NAME, // Store 名称: 'locale'
});
// 修改时持久化
const setLocale = (locale: SupportedLocale) => {
currentLocale.value = locale;
storage.setItem(STORAGE_KEYS.CURRENT_LOCALE, locale);
};
// 启动时恢复
const initFromStorage = async () => {
const value = await storage.getItem<SupportedLocale>(
STORAGE_KEYS.CURRENT_LOCALE
);
if (value !== null) {
currentLocale.value = value;
}
};
在 App.vue 的 onMounted 中统一初始化所有 Store:
// App.vue
<script setup lang="ts">
import { onMounted } from 'vue';
import { useMainLayoutStore } from '@/layouts/MainLayout/store';
import { useLocaleStore } from '@/stores/locale';
const localeStore = useLocaleStore();
const mainLayoutStore = useMainLayoutStore();
onMounted(async () => {
await Promise.all([
mainLayoutStore.initFromStorage(),
localeStore.initFromStorage(),
]);
});
</script>
要点:
Promise.all() 并行初始化多个 Store<template>
<n-layout-sider
:collapsed="mainLayoutStore.siderCollapsed"
@update:collapsed="mainLayoutStore.setSiderCollapsed"
>
<!-- 内容 -->
</n-layout-sider>
</template>
<script setup lang="ts">
import { useMainLayoutStore } from './store';
const mainLayoutStore = useMainLayoutStore();
</script>
使用方式:
import { useXxxStore } from '@/stores/xxx' 或 './store'const store = useXxxStore()store.stateName、store.setAction()当 Store 状态影响 UI 库配置时(如语言切换),通过计算属性提供:
// locale Store
import { dateEnUS, dateZhCN, enUS, zhCN } from 'naive-ui';
const naiveLocale = computed(() => {
return currentLocale.value === DEFAULT_LOCALE ? zhCN : enUS;
});
const naiveDateLocale = computed(() => {
return currentLocale.value === DEFAULT_LOCALE ? dateZhCN : dateEnUS;
});
在 App.vue 中使用:
<template>
<n-config-provider
:locale="localeStore.naiveLocale"
:date-locale="localeStore.naiveDateLocale"
>
<RouterView />
</n-config-provider>
</template>
| 原则 | 说明 | 示例 |
|---|---|---|
| 单一职责 | 每个 Store 只负责一个业务域 | localeStore、layoutStore |
| 持久化透明 | 对使用者隐藏存储细节,提供 initFromStorage() | 自动从 localforage 恢复 |
| 响应式优先 | 使用 ref/computed 保持响应式 | 避免 .value 丢失响应性 |
| 类型安全 | 使用 TypeScript 泛型约束存储值的类型 | storage.getItem<string>() |
| 常量命名 | 存储键使用 STORAGE_KEYS 常量对象,避免魔法字符串 | STORAGE_KEYS.CURRENT_LOCALE |
案例 1: 语言管理 Store (locale.ts)
案例 2: 布局状态 Store (MainLayout/store/index.ts)
toggleSiderCollapsed()、setSiderCollapsed(boolean)STORE_NAME 和 STORAGE_KEYS 常量initFromStorage() 方法Portal 应用采用 OAuth 2.0 + PKCE 协议进行身份认证,通过 @csisp/oauth 包实现 SSO 单点登录。
用户访问受保护资源
↓
路由守卫 (guards.ts) 检查会话状态
↓
未登录 → 保存目标路径 → 跳转 IDP 发起 OAuth 登录
↓
IDP 完成认证 → 重定向回 Portal /callback?code=xxx&state=yyy
↓
处理回调 → PKCE token 交换 → 保存 session
↓
跳转到目标页面 ✅
| 文件 | 用途 |
|---|---|
src/router/guards.ts | OAuth 路由守卫,自动拦截未认证请求 |
src/pages/Callback/index.vue | OAuth 回调处理页面 |
src/config/index.ts | OAuth 配置 (clientId, redirectUri) |
文件:src/router/guards.ts
import {
checkAuthStatus,
initiateOAuthLogin,
setRedirectAfterLogin,
initOAuthClient,
} from '@csisp/oauth';
import type { Router } from 'vue-router';
// 不需要认证的路由(公开页面)
const PUBLIC_ROUTES = ['/callback'];
export function setupAuthGuards(router: Router) {
router.beforeEach(async to => {
// 初始化 OAuth 客户端(必须在使用前调用)
initOAuthClient();
// 公开路由直接放行
if (PUBLIC_ROUTES.includes(to.path)) {
return;
}
// 检查当前用户是否已认证
const isAuthenticated = await checkAuthStatus();
if (!isAuthenticated) {
// 保存用户要访问的路径(登录后跳转回来)
await setRedirectAfterLogin(to.fullPath);
// 发起 OAuth 登录流程(重定向到 IDP)
await initiateOAuthLogin({
clientId: config.oauth.clientId, // 从配置读取
redirectUri: config.oauth.redirectUri, // 回调地址
});
// 中止当前导航(因为即将重定向)
return false;
}
});
}
在 router 中启用守卫:
// src/router/index.ts
import { setupAuthGuards } from './guards';
const router = createRouter({
/* ... */
});
setupAuthGuards(router); // ← 注册 OAuth 守卫
export default router;
文件:src/pages/Callback/index.vue
### 3. 配置说明
**文件**:`src/config/index.ts` 或环境变量
```typescript
export const config = {
oauth: {
clientId: import.meta.env.VITE_OAUTH_CLIENT_ID, // OAuth Client ID
redirectUri: `${window.location.origin}/callback`, // 回调地址(必须与 IDP 注册的一致)
},
};
import { signOut } from '@csisp/oauth';
import { useRouter } from 'vue-router';
const router = useRouter();
async function handleLogout() {
await signOut(); // 清除 Supabase session
await router.push('/login'); // 跳转到登录页(可选)
}
验证命令:
# 构建测试
pnpm -F bff build
pnpm -F {frontend-app} build
# 类型检查
pnpm -F bff tsc --noEmit
pnpm -F {frontend-app} tsc --noEmit
# Lint 检查
pnpm -F bff lint
pnpm -F {frontend-app} lint
# 格式化
pnpm -F bff format
pnpm -F {frontend-app} format
验证检查清单:
| 检查项 | 通过标准 |
|---|---|
| 构建测试 | 无错误,输出成功 |
| 类型检查 | 无类型错误 |
| Lint 检查 | 无警告和错误 |
| 格式化 | 代码已格式化 |
| 场景 | 执行步骤 | 关键检查点 |
|---|---|---|
| 完整新功能开发 | 规划 → Contracts → BFF → 前端 → 验证 | Contracts 类型生成 |
| 新增 BFF 接口 | 设计 → Contracts → BFF → 验证 | Contract 更新、类型检查 |
| 新增页面 | 翻译规划 → 前端 → 验证 | 路由配置、国际化 |
| 修复 Bug | 定位 → 修复 → 验证 | 回归测试 |
文档版本:v2.1(更新 BFF Controller 实现方式、新增 Portal OAuth 认证集成章节) 最后更新:2026-05-24