Files
cc-switch/docs/REFACTORING_REFERENCE.md
Jason 8e4a0a1bbb refactor(types): rename AppType to AppId for semantic clarity
Rename `AppType` to `AppId` across the entire frontend codebase to better
reflect its purpose as an application identifier rather than a type category.
This aligns frontend naming with backend command parameter conventions.

Changes:
- Rename type `AppType` to `AppId` in src/lib/api/types.ts
- Remove `AppType` export from src/lib/api/index.ts
- Update all component props from `appType` to `appId` (43 files)
- Update all variable names from `appType` to `appId`
- Synchronize documentation (CHANGELOG, refactoring plans)
- Update test files and MSW mocks

BREAKING CHANGE: `AppType` type is no longer exported. Use `AppId` instead.
All component props have been renamed from `appType` to `appId`.
2025-10-30 14:59:15 +08:00

17 KiB
Raw Permalink Blame History

重构快速参考指南

常见模式和代码示例的速查表


📑 目录

  1. React Query 使用
  2. react-hook-form 使用
  3. shadcn/ui 组件使用
  4. 代码迁移示例

React Query 使用

基础查询

// 定义查询 Hook
export const useProvidersQuery = (appId: AppId) => {
  return useQuery({
    queryKey: ['providers', appId],
    queryFn: async () => {
      const data = await providersApi.getAll(appId)
      return data
    },
  })
}

// 在组件中使用
function MyComponent() {
  const { data, isLoading, error } = useProvidersQuery('claude')

  if (isLoading) return <div>Loading...</div>
  if (error) return <div>Error: {error.message}</div>

  return <div>{/* 使用 data */}</div>
}

Mutation (变更操作)

// 定义 Mutation Hook
export const useAddProviderMutation = (appId: AppId) => {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async (provider: Provider) => {
      return await providersApi.add(provider, appId)
    },
    onSuccess: () => {
      // 重新获取数据
      queryClient.invalidateQueries({ queryKey: ['providers', appId] })
      toast.success('添加成功')
    },
    onError: (error: Error) => {
      toast.error(`添加失败: ${error.message}`)
    },
  })
}

// 在组件中使用
function AddProviderDialog() {
const mutation = useAddProviderMutation('claude')

  const handleSubmit = (data: Provider) => {
    mutation.mutate(data)
  }

  return (
    <button
      onClick={() => handleSubmit(formData)}
      disabled={mutation.isPending}
    >
      {mutation.isPending ? '添加中...' : '添加'}
    </button>
  )
}

乐观更新

export const useSwitchProviderMutation = (appId: AppId) => {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async (providerId: string) => {
      return await providersApi.switch(providerId, appId)
    },
    // 乐观更新: 在请求发送前立即更新 UI
    onMutate: async (providerId) => {
      // 取消正在进行的查询
      await queryClient.cancelQueries({ queryKey: ['providers', appId] })

      // 保存当前数据(以便回滚)
      const previousData = queryClient.getQueryData(['providers', appId])

      // 乐观更新
      queryClient.setQueryData(['providers', appId], (old: any) => ({
        ...old,
        currentProviderId: providerId,
      }))

      return { previousData }
    },
    // 如果失败,回滚
    onError: (err, providerId, context) => {
      queryClient.setQueryData(['providers', appId], context?.previousData)
      toast.error('切换失败')
    },
    // 无论成功失败,都重新获取数据
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['providers', appId] })
    },
  })
}

依赖查询

// 第二个查询依赖第一个查询的结果
const { data: providers } = useProvidersQuery(appId)
const currentProviderId = providers?.currentProviderId

const { data: currentProvider } = useQuery({
  queryKey: ['provider', currentProviderId],
  queryFn: () => providersApi.getById(currentProviderId!),
  enabled: !!currentProviderId, // 只有当 ID 存在时才执行
})

react-hook-form 使用

基础表单

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

// 定义验证 schema
const schema = z.object({
  name: z.string().min(1, '请输入名称'),
  email: z.string().email('邮箱格式不正确'),
  age: z.number().min(18, '年龄必须大于18'),
})

type FormData = z.infer<typeof schema>

function MyForm() {
  const form = useForm<FormData>({
    resolver: zodResolver(schema),
    defaultValues: {
      name: '',
      email: '',
      age: 0,
    },
  })

  const onSubmit = (data: FormData) => {
    console.log(data)
  }

  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      <input {...form.register('name')} />
      {form.formState.errors.name && (
        <span>{form.formState.errors.name.message}</span>
      )}

      <button type="submit">提交</button>
    </form>
  )
}

使用 shadcn/ui Form 组件

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'

function MyForm() {
  const form = useForm<FormData>({
    resolver: zodResolver(schema),
  })

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
        <FormField
          control={form.control}
          name="name"
          render={({ field }) => (
            <FormItem>
              <FormLabel>名称</FormLabel>
              <FormControl>
                <Input placeholder="请输入名称" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <Button type="submit">提交</Button>
      </form>
    </Form>
  )
}

动态表单验证

// 根据条件动态验证
const schema = z.object({
  type: z.enum(['official', 'custom']),
  apiKey: z.string().optional(),
  baseUrl: z.string().optional(),
}).refine(
  (data) => {
    // 如果是自定义供应商,必须填写 baseUrl
    if (data.type === 'custom') {
      return !!data.baseUrl
    }
    return true
  },
  {
    message: '自定义供应商必须填写 Base URL',
    path: ['baseUrl'],
  }
)

手动触发验证

function MyForm() {
  const form = useForm<FormData>()

  const handleBlur = async () => {
    // 验证单个字段
    await form.trigger('name')

    // 验证多个字段
    await form.trigger(['name', 'email'])

    // 验证所有字段
    const isValid = await form.trigger()
  }

  return <form>...</form>
}

shadcn/ui 组件使用

Dialog (对话框)

import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
  DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'

function MyDialog() {
  const [open, setOpen] = useState(false)

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>标题</DialogTitle>
          <DialogDescription>描述信息</DialogDescription>
        </DialogHeader>

        {/* 内容 */}
        <div>对话框内容</div>

        <DialogFooter>
          <Button variant="outline" onClick={() => setOpen(false)}>
            取消
          </Button>
          <Button onClick={handleConfirm}>确认</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

Select (选择器)

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

function MySelect() {
  const [value, setValue] = useState('')

  return (
    <Select value={value} onValueChange={setValue}>
      <SelectTrigger>
        <SelectValue placeholder="请选择" />
      </SelectTrigger>
      <SelectContent>
        <SelectItem value="option1">选项1</SelectItem>
        <SelectItem value="option2">选项2</SelectItem>
        <SelectItem value="option3">选项3</SelectItem>
      </SelectContent>
    </Select>
  )
}

Tabs (标签页)

import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'

function MyTabs() {
  return (
    <Tabs defaultValue="tab1">
      <TabsList>
        <TabsTrigger value="tab1">标签1</TabsTrigger>
        <TabsTrigger value="tab2">标签2</TabsTrigger>
        <TabsTrigger value="tab3">标签3</TabsTrigger>
      </TabsList>

      <TabsContent value="tab1">
        <div>标签1的内容</div>
      </TabsContent>

      <TabsContent value="tab2">
        <div>标签2的内容</div>
      </TabsContent>

      <TabsContent value="tab3">
        <div>标签3的内容</div>
      </TabsContent>
    </Tabs>
  )
}

Toast 通知 (Sonner)

import { toast } from 'sonner'

// 成功通知
toast.success('操作成功')

// 错误通知
toast.error('操作失败')

// 加载中
const toastId = toast.loading('处理中...')
// 完成后更新
toast.success('处理完成', { id: toastId })
// 或
toast.dismiss(toastId)

// 自定义持续时间
toast.success('消息', { duration: 5000 })

// 带操作按钮
toast('确认删除?', {
  action: {
    label: '删除',
    onClick: () => handleDelete(),
  },
})

代码迁移示例

示例 1: 状态管理迁移

旧代码 (手动状态管理):

const [providers, setProviders] = useState<Record<string, Provider>>({})
const [currentProviderId, setCurrentProviderId] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)

useEffect(() => {
  const load = async () => {
    setLoading(true)
    setError(null)
    try {
      const data = await window.api.getProviders(appType)
      const currentId = await window.api.getCurrentProvider(appType)
      setProviders(data)
      setCurrentProviderId(currentId)
    } catch (err) {
      setError(err as Error)
    } finally {
      setLoading(false)
    }
  }
  load()
}, [appId])

新代码 (React Query):

const { data, isLoading, error } = useProvidersQuery(appId)
const providers = data?.providers || {}
const currentProviderId = data?.currentProviderId || ''

减少: 从 20+ 行到 3 行


示例 2: 表单验证迁移

旧代码 (手动验证):

const [name, setName] = useState('')
const [nameError, setNameError] = useState('')
const [apiKey, setApiKey] = useState('')
const [apiKeyError, setApiKeyError] = useState('')

const validate = () => {
  let valid = true

  if (!name.trim()) {
    setNameError('请输入名称')
    valid = false
  } else {
    setNameError('')
  }

  if (!apiKey.trim()) {
    setApiKeyError('请输入 API Key')
    valid = false
  } else if (apiKey.length < 10) {
    setApiKeyError('API Key 长度不足')
    valid = false
  } else {
    setApiKeyError('')
  }

  return valid
}

const handleSubmit = () => {
  if (validate()) {
    // 提交
  }
}

return (
  <form>
    <input value={name} onChange={e => setName(e.target.value)} />
    {nameError && <span>{nameError}</span>}

    <input value={apiKey} onChange={e => setApiKey(e.target.value)} />
    {apiKeyError && <span>{apiKeyError}</span>}

    <button onClick={handleSubmit}>提交</button>
  </form>
)

新代码 (react-hook-form + zod):

const schema = z.object({
  name: z.string().min(1, '请输入名称'),
  apiKey: z.string().min(10, 'API Key 长度不足'),
})

const form = useForm({
  resolver: zodResolver(schema),
})

return (
  <Form {...form}>
    <form onSubmit={form.handleSubmit(onSubmit)}>
      <FormField
        control={form.control}
        name="name"
        render={({ field }) => (
          <FormItem>
            <FormControl>
              <Input {...field} />
            </FormControl>
            <FormMessage />
          </FormItem>
        )}
      />

      <FormField
        control={form.control}
        name="apiKey"
        render={({ field }) => (
          <FormItem>
            <FormControl>
              <Input {...field} />
            </FormControl>
            <FormMessage />
          </FormItem>
        )}
      />

      <Button type="submit">提交</Button>
    </form>
  </Form>
)

减少: 从 40+ 行到 30 行,且更健壮


示例 3: 通知系统迁移

旧代码 (自定义通知):

const [notification, setNotification] = useState<{
  message: string
  type: 'success' | 'error'
} | null>(null)
const [isVisible, setIsVisible] = useState(false)

const showNotification = (message: string, type: 'success' | 'error') => {
  setNotification({ message, type })
  setIsVisible(true)
  setTimeout(() => {
    setIsVisible(false)
    setTimeout(() => setNotification(null), 300)
  }, 3000)
}

return (
  <>
    {notification && (
      <div className={`notification ${isVisible ? 'visible' : ''} ${notification.type}`}>
        {notification.message}
      </div>
    )}
    {/* 其他内容 */}
  </>
)

新代码 (Sonner):

import { toast } from 'sonner'

// 在需要的地方直接调用
toast.success('操作成功')
toast.error('操作失败')

// 在 main.tsx 中只需添加一次
import { Toaster } from '@/components/ui/sonner'

<Toaster />

减少: 从 20+ 行到 1 行调用


示例 4: 对话框迁移

旧代码 (自定义 Modal):

const [isOpen, setIsOpen] = useState(false)

return (
  <>
    <button onClick={() => setIsOpen(true)}>打开</button>

    {isOpen && (
      <div className="modal-backdrop" onClick={() => setIsOpen(false)}>
        <div className="modal-content" onClick={e => e.stopPropagation()}>
          <div className="modal-header">
            <h2>标题</h2>
            <button onClick={() => setIsOpen(false)}>×</button>
          </div>
          <div className="modal-body">
            {/* 内容 */}
          </div>
          <div className="modal-footer">
            <button onClick={() => setIsOpen(false)}>取消</button>
            <button onClick={handleConfirm}>确认</button>
          </div>
        </div>
      </div>
    )}
  </>
)

新代码 (shadcn/ui Dialog):

import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'

const [isOpen, setIsOpen] = useState(false)

return (
  <>
    <Button onClick={() => setIsOpen(true)}>打开</Button>

    <Dialog open={isOpen} onOpenChange={setIsOpen}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>标题</DialogTitle>
        </DialogHeader>
        {/* 内容 */}
        <DialogFooter>
          <Button variant="outline" onClick={() => setIsOpen(false)}>取消</Button>
          <Button onClick={handleConfirm}>确认</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  </>
)

优势:

  • 无需自定义样式
  • 内置无障碍支持
  • 自动管理焦点和 ESC 键

示例 5: API 调用迁移

旧代码 (window.api):

// 添加供应商
const handleAdd = async (provider: Provider) => {
  try {
    await window.api.addProvider(provider, appType)
    await loadProviders()
    showNotification('添加成功', 'success')
  } catch (error) {
    showNotification('添加失败', 'error')
  }
}

新代码 (React Query Mutation):

// 在组件中
const addMutation = useAddProviderMutation(appId)

const handleAdd = (provider: Provider) => {
  addMutation.mutate(provider)
  // 成功和错误处理已在 mutation 定义中处理
}

优势:

  • 自动处理 loading 状态
  • 统一的错误处理
  • 自动刷新数据
  • 更少的样板代码

常见问题

Q: 如何在 mutation 成功后关闭对话框?

const mutation = useAddProviderMutation(appId)

const handleSubmit = (data: Provider) => {
  mutation.mutate(data, {
    onSuccess: () => {
      setIsOpen(false) // 关闭对话框
    },
  })
}

Q: 如何在表单中使用异步验证?

const schema = z.object({
  name: z.string().refine(
    async (name) => {
      // 检查名称是否已存在
      const exists = await checkNameExists(name)
      return !exists
    },
    { message: '名称已存在' }
  ),
})

Q: 如何手动刷新 Query 数据?

const queryClient = useQueryClient()

// 方式1: 使缓存失效,触发重新获取
queryClient.invalidateQueries({ queryKey: ['providers', appId] })

// 方式2: 直接刷新
queryClient.refetchQueries({ queryKey: ['providers', appId] })

// 方式3: 更新缓存数据
queryClient.setQueryData(['providers', appId], newData)

Q: 如何在组件外部使用 toast?

// 直接导入并使用即可
import { toast } from 'sonner'

export const someUtil = () => {
  toast.success('工具函数中的通知')
}

调试技巧

React Query DevTools

// 在 main.tsx 中添加
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

<QueryClientProvider client={queryClient}>
  <App />
  <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>

查看表单状态

const form = useForm()

// 在开发模式下打印表单状态
console.log('Form values:', form.watch())
console.log('Form errors:', form.formState.errors)
console.log('Is valid:', form.formState.isValid)

性能优化建议

1. 避免不必要的重渲染

// 使用 React.memo
export const ProviderCard = React.memo(({ provider, onEdit }: Props) => {
  // ...
})

// 或使用 useMemo
const sortedProviders = useMemo(
  () => Object.values(providers).sort(...),
  [providers]
)

2. Query 配置优化

const { data } = useQuery({
  queryKey: ['providers', appId],
  queryFn: fetchProviders,
  staleTime: 1000 * 60 * 5, // 5分钟内不重新获取
  gcTime: 1000 * 60 * 10, // 10分钟后清除缓存
})

3. 表单性能优化

// 使用 mode 控制验证时机
const form = useForm({
  mode: 'onBlur', // 失去焦点时验证
  // mode: 'onChange', // 每次输入都验证(较慢)
  // mode: 'onSubmit', // 提交时验证(最快)
})

提示: 将此文档保存在浏览器书签或编辑器中,方便随时查阅!