원클릭으로
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