跳到主要内容

用 Flex 布局对齐 Docusaurus 文档页标题图标

· 阅读需 3 分钟

TL;DR

Docusaurus 文档页标题中的 SVG 图标与文字对齐,推荐使用 display: flex + align-items: center + gap,配合 .theme-doc-markdown 选择器精准隔离 docs 页面,不影响 blog。

问题现象

在 Docusaurus 文档中使用内联 SVG 图标作为标题装饰:

## 🚀 快速开始

或通过 MDX 组件:

## <RocketIcon /> 快速开始

默认情况下,SVG 图标与文字基线对齐,视觉效果偏上:

🚀 快速开始     ← 图标偏上,与文字顶部对齐

传统解法是 vertical-align: middle + margin-right,但存在以下问题:

  1. 图标大小变化时需要调整 margin
  2. 行高变化时对齐可能失效
  3. 多行标题时对齐表现不一致

根因

SVG 默认是 inline 元素,参与行内布局。vertical-align: middle 基于当前行的 x-height 计算,受字体、行高、图标尺寸等多因素影响,难以精确控制。

更深层的问题是选择器作用范围。Docusaurus 的 .markdown 类同时作用于 docs 和 blog 页面,直接修改会影响全局。

解决方案

1. 使用 Flex 布局

Flex 的 align-items: center 基于容器高度计算,与字体无关,对齐更稳定:

/* 文档页标题图标对齐 */
.theme-doc-markdown h1,
.theme-doc-markdown h2,
.theme-doc-markdown h3,
.theme-doc-markdown h4 {
display: flex;
align-items: center;
gap: 0.75rem;
}

2. 重置 SVG 原有样式

覆盖全局 .markdownmargin-rightvertical-align

.theme-doc-markdown h1 svg,
.theme-doc-markdown h2 svg,
.theme-doc-markdown h3 svg,
.theme-doc-markdown h4 svg {
margin-right: 0;
vertical-align: baseline;
flex-shrink: 0; /* 防止图标被压缩 */
}

3. 选择器隔离

Docusaurus 提供了页面特定的类名:

选择器作用范围
.markdowndocs + blog 全局
.theme-doc-markdown仅 docs 文档页
article仅 blog 文章页

使用 .theme-doc-markdown 精准修改文档页,blog 页保持原有样式。

完整代码

/* ========== Docs 文档页样式 ========== */

/* 文档页标题图标对齐 */
.theme-doc-markdown h1,
.theme-doc-markdown h2,
.theme-doc-markdown h3,
.theme-doc-markdown h4 {
display: flex;
align-items: center;
gap: 0.75rem;
}

.theme-doc-markdown h1 svg,
.theme-doc-markdown h2 svg,
.theme-doc-markdown h3 svg,
.theme-doc-markdown h4 svg {
margin-right: 0;
vertical-align: baseline;
flex-shrink: 0;
}

FAQ

Q: Docusaurus 中 .markdown 和 .theme-doc-markdown 有什么区别?

.markdown 是 Docusaurus 全局内容样式类,同时应用于 docs 文档页和 blog 博客页。.theme-doc-markdown 是文档页专用容器类,仅作用于 /docs/* 路径下的页面,适合做文档页专属样式。

Q: 为什么用 gap 而不是 margin-right?

gap 是 Flexbox/Grid 的间距属性,与 align-items: center 配合更自然,不依赖元素自身的 margin。当图标隐藏或不存在时,gap 不会产生多余空白,而 margin-right 会。

Q: flex-shrink: 0 有什么作用?

防止 flex 子项在容器空间不足时被压缩。SVG 图标通常有固定尺寸,压缩会导致变形模糊。设置 flex-shrink: 0 确保图标保持原始大小。

修复 FastAPI SSE 客户端断开时的 CancelledError

· 阅读需 2 分钟

在为客户构建 AI 客服自动化系统时遇到此问题,记录根因与解法。

TL;DR

FastAPI 的 StreamingResponse 在客户端断开连接时会取消生成器任务,导致 asyncio.CancelledError。正确做法是在生成器中捕获该异常并 re-raise,否则会导致异常日志污染和资源泄漏。

问题现象

使用 SSE(Server-Sent Events)实现流式对话时,客户端断开连接后,服务端日志出现大量异常:

ERROR:    Exception in ASGI application
...
asyncio.CancelledError

代码原本写法:

async def event_stream():
async for event in engine.execute(body.message):
yield event

return StreamingResponse(event_stream(), media_type="text/event-stream")

根因

FastAPI/Starlette 的 StreamingResponse 在客户端断开时,会取消正在执行的生成器任务。Python 的 async for 循环被取消时会抛出 asyncio.CancelledError

如果不处理这个异常,它会向上传播,被 ASGI 服务器捕获并记录为错误日志。更严重的是,生成器内的资源(如数据库连接、HTTP 客户端)可能无法正确释放。

解决方案

在生成器内部捕获 CancelledError,记录日志后 必须 re-raise

import asyncio
import logging

logger = logging.getLogger(__name__)

async def event_stream():
try:
async for event in engine.execute(body.message):
yield event
except asyncio.CancelledError:
# 客户端断开连接,正常行为
logger.info("Client disconnected")
raise # 必须 re-raise 以正确终止生成器

return StreamingResponse(event_stream(), media_type="text/event-stream")

为什么必须 re-raise?

CancelledError 是 Python 取消协程的标准机制。捕获后如果不 re-raise:

  1. 生成器不会正确终止
  2. StreamingResponse 认为响应正常完成
  3. 可能导致资源泄漏

FAQ

Q: FastAPI SSE 客户端断开后为什么报 CancelledError?

A: 这是 Python asyncio 的设计行为。客户端断开时,Starlette 取消生成器任务,触发 CancelledError。正确处理方式是捕获并 re-raise。

Q: 捕获 CancelledError 后不 re-raise 会怎样?

A: 生成器无法正确终止,可能导致数据库连接、HTTP 客户端等资源泄漏。同时 StreamingResponse 会误认为响应正常完成。

Q: 如何区分正常断开和异常断开?

A: CancelledError 本身就是正常断开的信号。如果需要在断开时执行清理逻辑(如更新状态),在 except 块中处理后再 re-raise。

React SPA 集成 Google Analytics 4 完整指南

· 阅读需 3 分钟

TL;DR

React SPA 集成 GA4 的关键点:1) 禁用 send_page_view: false 避免重复追踪;2) 用 useLocation 监听路由变化手动发送 pageview;3) 登录后设置 user_id 实现跨设备追踪。

问题现象

在 React SPA 中直接使用 GA4 默认配置会导致:

  1. 首次加载时 page_view 重复计数
  2. 路由切换时不触发 page_view
  3. 无法追踪登录用户的跨设备行为

根因

GA4 默认在脚本加载时自动发送一次 page_view 事件。但 SPA 的路由切换不刷新页面,GA4 无法感知 URL 变化。同时,User-ID 需要在用户登录后手动设置,默认配置无法关联用户身份。

解决方案

1. 禁用自动 page_view

index.html 中加载 GA4 时,设置 send_page_view: false

<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX', { send_page_view: false });
</script>

2. 创建 Analytics 组件追踪路由

// src/components/Analytics.tsx
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore'

declare global {
interface Window {
gtag: (
command: 'config' | 'event' | 'js' | 'set',
targetIdOrDate: string | Date,
params?: Record<string, unknown>
) => void
}
}

export function Analytics() {
const location = useLocation()
const user = useAuthStore((state) => state.user)

useEffect(() => {
if (typeof window.gtag === 'function') {
const params: Record<string, unknown> = {
page_path: location.pathname + location.search,
}
// 已登录用户添加 user_id
if (user?.id) {
params.user_id = user.id
}
window.gtag('config', 'G-XXXXXXXXXX', params)
}
}, [location, user?.id])

return null
}

3. 包裹路由根节点

// src/app/routes.tsx
import { Outlet } from 'react-router-dom'
import { Analytics } from '@/components/Analytics'

function RootLayout() {
return (
<>
<Analytics />
<Outlet />
</>
)
}

export const router = createBrowserRouter([
{
element: <RootLayout />,
children: [
// 你的路由配置...
],
},
])

4. 登录时设置 User-ID(可选增强)

// src/hooks/useAuth.ts
import { supabase } from '@/services/supabase'

export function useAuth() {
useEffect(() => {
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => {
if (event === 'SIGNED_IN' && session) {
// 设置 GA4 User-ID
if (typeof window.gtag === 'function') {
window.gtag('config', 'G-XXXXXXXXXX', {
user_id: session.user.id
})
}
}
}
)
return () => subscription.unsubscribe()
}, [])
}

FAQ

Q: React SPA 中 GA4 为什么不追踪路由变化?

A: GA4 默认只在页面加载时发送 page_view。SPA 路由切换不刷新页面,需要手动调用 gtag('config', ...) 发送 pageview。

Q: GA4 User-ID 有什么用?

A: User-ID 可以关联同一用户在不同设备上的行为,用于跨设备分析、用户留存分析等高级功能。需要在 GA4 后台开启 User-ID 功能视图。

Q: 如何验证 GA4 配置是否正确?

A: 使用 Chrome 扩展 "Google Tag Assistant" 或 GA4 DebugView(需开启 debug_mode)。检查每次路由切换是否触发 page_view 事件,以及 user_id 是否正确设置。

修复 Tailwind Preflight 重置 Docusaurus 面包屑样式

· 阅读需 2 分钟

TL;DR

Docusaurus 引入 Tailwind 后,Preflight 的 CSS Reset 会重置 <ul> 元素的 list-stylemarginpadding,导致面包屑导航样式丢失。解决方法是在 custom.css 中添加显式覆盖样式。

问题现象

在 Docusaurus 项目中引入 Tailwind CSS 后,文档页的面包屑导航(Breadcrumbs)样式异常:

  • 列表样式丢失(list-style 被重置为 none
  • 间距消失(marginpadding 被重置为 0)
  • 布局可能错乱(display 可能被影响)

查看浏览器开发者工具,发现 .breadcrumbs 的计算样式中,这些属性被 Preflight 重置:

/* Tailwind Preflight 重置 */
ul, ol {
list-style: none;
margin: 0;
padding: 0;
}

根因

Tailwind Preflight 是一套基于 modern-normalize 的 CSS Reset,它在 @tailwind base 阶段注入,目的是提供一致的跨浏览器样式基准。

问题在于:Docusaurus 的 .breadcrumbs 组件使用 <ul> 元素,依赖浏览器默认的 flex 布局和间距。Preflight 的重置规则优先级较高,覆盖了 Docusaurus 的默认样式。

由于 Preflight 是全局注入的,任何使用 <ul>/<ol> 的第三方组件都可能受影响。

解决方案

src/css/custom.css 中添加显式覆盖样式,使用 !important 确保优先级:

/* ========== Breadcrumbs 面包屑 ========== */
.theme-doc-breadcrumbs {
margin-bottom: 1.5rem;
}

.breadcrumbs {
display: flex !important;
flex-wrap: wrap;
align-items: center;
list-style: none;
margin: 0;
padding: 0;
}

.breadcrumbs__item {
display: flex !important;
align-items: center;
gap: 0.5rem;
}

关键点

  1. .breadcrumbs 使用 display: flex !important 确保水平布局
  2. list-style: none 是预期行为(面包屑不需要圆点)
  3. .breadcrumbs__item 添加 gap: 0.5rem 控制元素间距

FAQ

Q: 为什么需要 !important?

Tailwind Preflight 在 @tailwind base 阶段注入,其选择器权重可能与 Docusaurus 默认样式相当。使用 !important 可以确保自定义样式生效,避免优先级战争。

Q: 除了面包屑,还有哪些组件可能受影响?

任何使用 <ul>/<ol> 的组件都可能受影响,例如:

  • 导航菜单
  • 分页组件
  • 自定义列表

检查方法:在浏览器开发者工具中搜索 list-style: none 的来源,确认是否来自 Preflight。

Q: 可以禁用 Preflight 吗?

可以,但不推荐。在 tailwind.config.js 中设置:

module.exports = {
corePlugins: {
preflight: false,
},
}

禁用后需要自行处理跨浏览器样式一致性,可能导致更多问题。

修复 httpx async with client.post() 的隐藏坑

· 阅读需 2 分钟

在构建多服务协作的 SaaS 系统时遇到此问题,记录根因与解法。

TL;DR

httpx.AsyncClient 不要用 async with client.post() 模式,应该先创建 client 再调用方法:response = await client.post()

问题现象

import httpx

async def call_api():
async with httpx.AsyncClient() as client:
async with client.post(url, json=data) as response: # 问题代码
return response.json()

这段代码有时正常,有时报错:

httpx.RemoteProtocolError: cannot write to closing transport
RuntimeError: Session is closed

根因

async with client.post() 的陷阱

client.post() 返回的是 Response 对象,不是上下文管理器。用 async with 包装会导致:

  1. 连接过早关闭async with 块结束时立即关闭连接,但响应可能还在读取
  2. 资源竞争:多个并发请求时,连接池状态混乱

正确理解 httpx 上下文管理器

# ✅ 正确:client 是上下文管理器
async with httpx.AsyncClient() as client:
response = await client.post(url, json=data)
return response.json()

# ❌ 错误:把 response 当上下文管理器
async with client.post(url) as response:
...

解决方案

方案 1:单次请求(推荐简单场景)

async def call_api(url: str, data: dict) -> dict:
async with httpx.AsyncClient() as client:
response = await client.post(url, json=data)
response.raise_for_status()
return response.json()

方案 2:复用 client(推荐高频请求)

# 全局或依赖注入
_client = httpx.AsyncClient(timeout=30.0)

async def call_api(url: str, data: dict) -> dict:
response = await _client.post(url, json=data)
response.raise_for_status()
return response.json()

# 应用关闭时
async def shutdown():
await _client.aclose()

方案 3:FastAPI 依赖注入

from fastapi import Depends
from httpx import AsyncClient

async def get_http_client() -> AsyncClient:
async with AsyncClient(timeout=30.0) as client:
yield client

@router.post("/proxy")
async def proxy(
data: dict,
client: AsyncClient = Depends(get_http_client)
):
response = await client.post("https://external.api/endpoint", json=data)
return response.json()

FAQ

Q: httpx async with 怎么用才对?

A: async with 只用于管理 AsyncClient 生命周期,不是包装单个请求。正确模式:async with AsyncClient() as client: response = await client.post(...)

Q: 为什么有时 async with client.post() 也能跑?

A: 单线程、低并发时可能碰巧正常,但高并发或网络延迟时会暴露问题。这是隐藏 bug,不要侥幸。

Q: httpx 超时怎么配置?

A: AsyncClient(timeout=30.0)AsyncClient(timeout=httpx.Timeout(connect=5.0, read=30.0))

解决 Pydantic v2 ORM mode 报错 model_config 被覆盖

· 阅读需 2 分钟

TL;DR

Pydantic v2 不再支持 class Config,需要用 model_config = ConfigDict(from_attributes=True)。如果你的模型有 model_config 字段,必须重命名避免与保留字冲突。

问题现象

报错 1:class Config 不生效

from pydantic import BaseModel

class AgentResponse(BaseModel):
id: str
name: str

class Config:
orm_mode = True # v1 写法
PydanticUserError: `orm_mode` is not a valid config option. Did you mean `from_attributes`?

报错 2:model_config 字段冲突

class Agent(BaseModel):
id: str
model_config: dict # 业务字段,存储 LLM 配置

model_config = ConfigDict(from_attributes=True)
# TypeError: 'dict' object is not callable

模型中有个业务字段叫 model_config(存储 LLM 配置),与 Pydantic v2 保留字冲突。

根因

1. Pydantic v2 配置语法变化

Pydantic v2 使用 model_config 作为配置属性名,不再支持嵌套的 class Config

Pydantic v1Pydantic v2
class Config: orm_mode = Truemodel_config = ConfigDict(from_attributes=True)
class Config: schema_extra = {...}model_config = ConfigDict(json_schema_extra={...})

2. model_config 是保留字

model_config 在 Pydantic v2 中是特殊属性,不能同时作为业务字段名使用。

解决方案

1. 更新 ORM mode 配置

from pydantic import BaseModel, ConfigDict

class AgentResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) # 新写法

id: str
name: str

2. 重命名冲突字段

将业务字段 model_config 改为 llm_config(或任意非保留名):

# models/agent.py
class Agent(BaseModel):
__tablename__ = "agent_agents"

id: str
llm_config: dict # 改名,避免冲突

# schemas/agent.py
class AgentResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)

agent_id: str
llm_config: LlmConfig # 与模型保持一致

3. 数据库迁移(如需要)

如果数据库字段也要改:

# alembic/versions/xxx_rename_model_config.py
def upgrade():
op.alter_column('agent_agents', 'model_config', new_column_name='llm_config')

def downgrade():
op.alter_column('agent_agents', 'llm_config', new_column_name='model_config')

FAQ

Q: Pydantic v2 的 orm_mode 改成什么了?

A: 改为 from_attributes=True,配置方式从 class Config 变成 model_config = ConfigDict(...)

Q: 为什么 model_config 字段报错?

A: model_config 是 Pydantic v2 的保留属性名,用于配置模型行为。如果业务代码中有同名字段,需要重命名。

Q: ConfigDict 还有哪些常用选项?

A: from_attributes (ORM mode)、json_schema_extra (schema 扩展)、str_strip_whitespace (自动去空格)、validate_assignment (赋值时验证)。

Vite 路径别名配置:改了两处才生效

· 阅读需 2 分钟

TL;DR

Vite 路径别名需要同时配置 vite.config.tstsconfig.json,缺一不可:Vite 负责打包时解析,TypeScript 负责类型检查和 IDE 提示。

问题现象

只配了 vite.config.ts

// vite.config.ts
import { defineConfig } from 'vite'
import path from 'path'

export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
})

打包运行正常,但 IDE 报错:

Cannot find module '@/components/Button' or its corresponding type declarations.

只配了 tsconfig.json

// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

IDE 不报错了,但 Vite 构建时报错:

[vite] Internal server error: Failed to resolve import "@/services/api"

根因

两套配置,两个职责

配置文件负责方作用
vite.config.tsVite/esbuild构建时解析路径
tsconfig.jsonTypeScript类型检查、IDE 智能提示

只配置一处:

  • Vite 能打包,但 IDE 满屏红线,无法跳转
  • IDE 正常,但 vite dev / vite build 找不到模块

解决方案

完整配置(两处都要)

// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'

export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
})
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

验证配置生效

// src/services/api.ts
export const api = { ... }

// src/App.tsx - 应该能跳转、有提示、构建正常
import { api } from '@/services/api'

多个别名示例

// vite.config.ts
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@components': path.resolve(__dirname, './src/components'),
'@hooks': path.resolve(__dirname, './src/hooks'),
}
}

// tsconfig.json
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@hooks/*": ["src/hooks/*"]
}

FAQ

Q: 为什么 Vite 路径别名要配两次?

A: Vite(基于 esbuild/rollup)和 TypeScript 是独立工具。Vite 负责打包时的模块解析,TypeScript 负责编译时类型检查和 IDE 支持。两者不共享配置。

Q: 配置后还是报错怎么办?

A: 重启 IDE 和 Vite dev server。VSCode 按 Cmd+Shift+P → "TypeScript: Restart TS Server",终端 Ctrl+C 重启 npm run dev

Q: path.resolve 的 __dirname 报错?

A: 确保是 ES Module 时导入:import path from 'path',或在 package.json 加 "type": "module"。或用 import.meta.url 替代 __dirname

启用 VSCode Copilot Agent Mode 实现自动化编程

· 阅读需 3 分钟

TL;DR

VSCode Copilot Agent Mode 是实验性功能,能让 AI 自动执行多步骤任务(包括编辑文件、运行终端命令)。在 settings.json 中添加 "github.copilot.chat.agent.enabled": true 即可启用,适合处理重复性重构、批量文件修改等场景。

问题现象

传统 Copilot Chat 只能建议代码片段,每次都要:

  1. 手动复制代码
  2. 切换到目标文件
  3. 粘贴并调整
  4. 重复以上步骤

遇到需要修改多个文件的任务时,这种模式效率极低。

根因

Copilot 的 Ask Mode 设计为「建议者」角色:只输出代码,不执行操作。这是安全设计,但对于信任 AI 的开发者来说,增加了大量手动操作。

Agent Mode 则是「执行者」角色:AI 可以直接编辑文件、运行命令,实现真正的自动化编程。

解决方案

1. 启用 Agent Mode

在 VSCode settings.json 中添加:

{
"github.copilot.chat.agent.enabled": true
}

或在设置界面搜索 @id:github.copilot.chat.agent.enabled 勾选启用。

2. 切换到 Agent Mode

在 Copilot Chat 面板中,点击模式下拉框,从「Ask」切换到「Agent」:

┌─────────────────────────────┐
│ Ask ▼ │ Agent ▼ │ Edit │
└─────────────────────────────┘

3. 使用示例

场景:批量重命名函数

将 src/utils 目录下所有文件中的 getUserName 改为 fetchUserProfile

Agent Mode 会自动:

  1. 扫描 src/utils 目录
  2. 找到所有包含 getUserName 的文件
  3. 逐个修改并保存

场景:添加 TypeScript 类型

为 src/api/*.ts 中所有导出的函数添加返回类型注解

4. 工具权限控制

Agent Mode 执行敏感操作前会请求确认。可在设置中调整:

{
"github.copilot.chat.agent.autoToolConfirmation": {
"readFile": true, // 自动允许读文件
"editFile": false, // 编辑文件需确认
"runInTerminal": false // 运行命令需确认
}
}

5. 可用工具列表

Agent Mode 可调用以下工具:

工具功能
readFile读取文件内容
editFile编辑文件
createFile创建新文件
deleteFile删除文件
runInTerminal执行终端命令
listDirectory列出目录内容
search搜索代码

FAQ

Q: Agent Mode 和 Ask Mode 有什么区别?

Ask Mode 只建议代码,需要手动复制粘贴;Agent Mode 可以直接执行文件编辑和终端命令,实现自动化。

Q: Agent Mode 安全吗?

Agent Mode 在执行敏感操作(如删除文件、运行命令)前会请求确认。建议在版本控制的仓库中使用,便于回滚。

Q: 为什么找不到 Agent Mode 选项?

确保已安装最新版 Copilot Chat 扩展(v0.15+),并在设置中启用 github.copilot.chat.agent.enabled

Q: Agent Mode 能执行哪些终端命令?

理论上可以执行任何命令,但建议用于安全的开发命令(如 npm installnpm run build),避免执行删除、部署等高风险操作。

实现 React 级联选择下拉框

· 阅读需 4 分钟

TL;DR

级联选择的核心是:父级变化时,必须重置子级为有效值。使用 Record<string, Option[]> 类型映射数据,在 onValueChange 回调中同步更新子级状态。

问题现象

实现 Provider → Model 级联选择时,切换 Provider 后:

// 切换前:provider = "openai", model = "gpt-4o"
// 切换后:provider = "anthropic", model = "gpt-4o" ❌

// Model 下拉框显示为空,因为 "gpt-4o" 不在 anthropic 的模型列表中
<Select value={model}> // model 值不在 options 中,显示空白

或者提交表单时,Model 值是上一个 Provider 的模型,导致后端验证失败。

根因

React 受控组件的 value 必须存在于 options 中。当 Provider 变化时,Model 的 options 列表更新了,但 model state 仍保留旧值。如果旧值不在新的 options 中,Select 组件会显示为空。

关键问题:只更新了 options 数据,没有同步更新 state 值

解决方案

1. 定义数据结构

const AVAILABLE_PROVIDERS = [
{ value: 'deepseek', label: 'DeepSeek' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'anthropic', label: 'Anthropic' },
]

// 使用 Record 类型建立映射关系
const AVAILABLE_MODELS: Record<string, { value: string; label: string }[]> = {
deepseek: [
{ value: 'deepseek-chat', label: 'DeepSeek Chat' },
{ value: 'deepseek-reasoner', label: 'DeepSeek Reasoner' },
],
openai: [
{ value: 'gpt-4o', label: 'GPT-4o' },
{ value: 'gpt-4o-mini', label: 'GPT-4o Mini' },
],
anthropic: [
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
{ value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
],
}

2. State 初始化

const [provider, setProvider] = useState('deepseek')
const [model, setModel] = useState('deepseek-chat') // 初始值必须是 provider 对应的第一个模型

3. 关键:Provider 变化时重置 Model

const handleProviderChange = (value: string | null) => {
if (value) {
setProvider(value)
// 核心:重置 model 到新 provider 的第一个选项
const models = AVAILABLE_MODELS[value]
if (models && models.length > 0) {
setModel(models[0].value)
}
}
}

4. 完整组件示例

import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'

function CascadeSelect() {
const [provider, setProvider] = useState('deepseek')
const [model, setModel] = useState('deepseek-chat')

const handleProviderChange = (value: string | null) => {
if (value) {
setProvider(value)
const models = AVAILABLE_MODELS[value]
if (models && models.length > 0) {
setModel(models[0].value)
}
}
}

return (
<>
{/* Provider 选择 */}
<Select value={provider} onValueChange={handleProviderChange}>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{AVAILABLE_PROVIDERS.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>

{/* Model 选择 - 动态根据 provider 显示选项 */}
<Select value={model} onValueChange={(v) => v && setModel(v)}>
<SelectTrigger>
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent>
{(AVAILABLE_MODELS[provider] || []).map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)
}

5. 表单重置

关闭 Dialog 时重置表单,避免下次打开时保留旧状态:

const resetForm = () => {
setProvider('deepseek')
setModel('deepseek-chat') // 重置为 provider 对应的默认值
}

const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
resetForm()
}
onOpenChange(newOpen)
}

FAQ

Q: React 级联选择下拉框切换后子级显示为空怎么办?

A: 在父级 onValueChange 回调中,同步更新子级 state 为新选项列表的第一个值。受控组件的 value 必须存在于 options 中。

Q: 如何用 TypeScript 定义级联选择的数据类型?

A: 使用 Record<string, Option[]> 类型建立父级到子级的映射,例如 Record<string, { value: string; label: string }[]>,类型安全且易于扩展。

Q: Select 组件的 value 和 options 不匹配会怎样?

A: 大多数 UI 库(Radix、MUI、Ant Design)会显示空白或 placeholder,不会报错。这是受控组件的预期行为——确保 value 始终是有效的选项值。

集成 Supabase Auth 到 FastAPI 的三个坑

· 阅读需 4 分钟

在为客户构建 SaaS 认证系统时遇到此问题,记录根因与解法。

TL;DR

Supabase Auth + FastAPI 集成有三个常见坑:JWKS 路径不是标准路径、ES256 签名需转换为 DER 格式、用户首次登录时本地数据库无记录。本文提供完整解决方案。

问题现象

坑 1:JWKS 路径 404

GET https://xxx.supabase.co/.well-known/jwks.json
# 404 Not Found

所有 JWT 验证请求返回 401 Invalid Token。

坑 2:ES256 签名验证失败

from jose import jwt
payload = jwt.decode(token, key, algorithms=["ES256"])
# JWTError: Signature verification failed

明明公钥是对的,但签名验证总是失败。

坑 3:用户首次登录无本地记录

# 创建 Agent 时
agent = Agent(user_id=current_user["user_id"], ...)
db.add(agent)
# ForeignKeyViolation: user_id 不存在

Supabase Auth 用户通过了 JWT 验证,但本地 agent_users 表没有该用户记录。

根因

坑 1:Supabase 非标准 JWKS 路径

标准 OAuth/OIDC 服务器 JWKS 在 /.well-known/jwks.json,但 Supabase 把认证服务放在 /auth/v1/ 子路径下:

标准路径Supabase 路径
/.well-known/jwks.json/auth/v1/.well-known/jwks.json

坑 2:ES256 原始签名 vs DER 格式

Supabase JWT 使用 ES256(P-256 曲线)签名。JWT 中的签名是 raw 格式r || s 拼接,64 字节),但 Python cryptography 库的 verify() 方法需要 DER-encoded ASN.1 格式

Raw:     r (32 bytes) || s (32 bytes) = 64 bytes
DER: 0x30 <len> 0x02 <r_len> <r> 0x02 <s_len> <s>

python-josejwt.decode() 在处理 ES256 时有兼容性问题,需要手动验证签名。

坑 3:认证与数据分离

Supabase Auth 是独立服务,用户注册/登录后只存在于 Supabase 的 auth.users 表。本地数据库的 agent_users 表需要手动同步。

解决方案

1. 正确的 JWKS URL

# config.py
class Settings(BaseSettings):
supabase_url: str = "https://xxx.supabase.co"

@property
def jwks_url(self) -> str:
# 关键:/auth/v1/ 前缀
return f"{self.supabase_url}/auth/v1/.well-known/jwks.json"

2. ES256 签名验证(完整代码)

import json
import base64
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature

def _base64url_decode(data: str) -> bytes:
"""Base64url 解码,自动补 padding"""
rem = len(data) % 4
if rem > 0:
data += "=" * (4 - rem)
return base64.urlsafe_b64decode(data)

def _raw_to_der_signature(raw_sig: bytes) -> bytes:
"""将 raw ECDSA 签名 (r||s) 转为 DER 格式"""
# P-256: r 和 s 各 32 字节
r = int.from_bytes(raw_sig[:32], "big")
s = int.from_bytes(raw_sig[32:], "big")
return encode_dss_signature(r, s)

def verify_es256_signature(token: str, public_key_jwk: dict) -> dict:
"""验证 ES256 JWT 签名,返回 payload"""
parts = token.split(".")
if len(parts) != 3:
raise ValueError("Invalid JWT format")

header_b64, payload_b64, signature_b64 = parts

# 1. 构建 EC 公钥
x = _base64url_decode(public_key_jwk["x"])
y = _base64url_decode(public_key_jwk["y"])
x_int = int.from_bytes(x, "big")
y_int = int.from_bytes(y, "big")

public_key = ec.EllipticCurvePublicNumbers(
x_int, y_int, ec.SECP256R1()
).public_key(default_backend())

# 2. 验证签名
message = f"{header_b64}.{payload_b64}".encode()
raw_signature = _base64url_decode(signature_b64)
der_signature = _raw_to_der_signature(raw_signature)

public_key.verify(
der_signature,
message,
ec.ECDSA(hashes.SHA256())
)

# 3. 返回 payload
return json.loads(_base64url_decode(payload_b64))

3. 用户同步服务

# app/services/user_service.py
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import AgentUser

async def ensure_user_exists(
db: AsyncSession,
user_id: str,
email: str,
plan: str = "free"
) -> AgentUser:
"""确保用户存在于本地数据库(从 Supabase Auth 同步)"""
# 检查是否存在
result = await db.execute(
select(AgentUser).where(AgentUser.user_id == user_id)
)
user = result.scalar_one_or_none()

if user:
return user

# 创建新用户
user = AgentUser(
user_id=user_id,
email=email,
plan=plan,
role="user"
)
db.add(user)
await db.commit()
await db.refresh(user)
return user

4. 在创建资源前调用

# app/routers/agents.py
@router.post("/")
async def create_agent(
input: CreateAgentInput,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
# 关键:确保用户存在
user = await ensure_user_exists(
db,
user_id=current_user["user_id"],
email=current_user["email"],
plan=current_user["plan"]
)

# 现在可以安全创建 Agent
agent = Agent(
user_id=user.user_id,
name=input.name,
llm_config=input.llm_config.model_dump()
)
...

FAQ

Q: Supabase JWT 验证返回 404 怎么办?

A: Supabase 的 JWKS 路径是 /auth/v1/.well-known/jwks.json,不是标准的 /.well-known/jwks.json。检查你的 JWKS URL 配置。

Q: python-jose 验证 ES256 签名失败怎么解决?

A: python-jose 对 ES256 支持不完善。使用 cryptography 库手动验证,需要将 JWT 的 raw 签名(r||s 64字节)转换为 DER 格式。

Q: FastAPI 如何同步 Supabase Auth 用户到本地数据库?

A: 在需要用户记录的 API(如创建资源)入口处调用 ensure_user_exists(),从 JWT 提取用户信息并同步到本地表。

Q: Supabase JWT 中的 user_id 在哪个字段?

A: sub 字段包含用户 UUID,email 字段包含邮箱,app_metadata.plan 包含订阅计划(自定义字段)。