feat(03-02): 新建 CredentialSlotDialog 组件 (RHF + Zod + Sonner + handleApiError)

- 新增 components/ai-model/credential-slot-dialog.tsx(kebab-case,191 行)
- RHF + Zod schema:appId / accessToken 强制非空(CONTEXT D-提交逻辑 锁定)
- 打开时 useEffect 调 getCredentialSlot 拉数据 + reset 表单(accessToken 永远空串)
- placeholder 用 slot.accessTokenMasked(仅视觉提示,不回填脱敏掩码)
- 提交成功 toast.success + 自动关闭;失败 toast.error + 对话框保持打开 + 表单值不丢
- 错误经 handleApiError(lib/api/error-handler.ts,不走 barrel)映射为中文
- 落地 CRED-FE-04 + CRED-FE-05 主体
This commit is contained in:
pmc 2026-05-08 12:31:59 +08:00
parent 28bc2a7251
commit d719891754

View File

@ -0,0 +1,191 @@
"use client"
import { useEffect, useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { toast } from "sonner"
import { Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import {
getCredentialSlot,
updateCredentialSlot,
type CredentialSlot,
} from "@/lib/api/credential-slot"
import { handleApiError } from "@/lib/api/error-handler"
// ───── Zod schema ──────────────────────────────────────────────────────
// access_token 强制输入CONTEXT D-提交逻辑 锁定):
// - 后端 PUT 是全字段覆写语义;前端无法识别脱敏掩码格式
// - 「留空保留旧值」需后端配合识别掩码格式,已记入候选下一周期 milestone
// - 本 phase 退化为「每次保存都要重输 access_token」—— UX 略差但语义正确
const formSchema = z.object({
appId: z.string().min(1, { message: "App ID 不能为空" }),
accessToken: z.string().min(1, { message: "请输入 Access Token" }),
})
type FormValues = z.infer<typeof formSchema>
interface CredentialSlotDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function CredentialSlotDialog({ open, onOpenChange }: CredentialSlotDialogProps) {
const [slot, setSlot] = useState<CredentialSlot | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { appId: "", accessToken: "" },
})
// open=true 时拉数据 + reset 表单accessToken 永远默认空串,绝不回填脱敏掩码)
useEffect(() => {
if (!open) return
let cancelled = false
setIsLoading(true)
getCredentialSlot()
.then((data) => {
if (cancelled) return
setSlot(data)
form.reset({ appId: data.appId, accessToken: "" })
})
.catch((e) => {
if (cancelled) return
toast.error("加载失败", { description: handleApiError(e) })
})
.finally(() => {
if (!cancelled) setIsLoading(false)
})
return () => {
cancelled = true
}
}, [open, form])
const handleOpenChange = (next: boolean) => {
onOpenChange(next)
// 关闭时清表单 + 清 slot避免下次打开残留上次输入
if (!next) {
form.reset({ appId: "", accessToken: "" })
setSlot(null)
}
}
const handleSubmit = async (values: FormValues) => {
setIsSubmitting(true)
try {
await updateCredentialSlot({
appId: values.appId,
accessToken: values.accessToken,
})
toast.success("凭据槽位已更新", { description: "配置已生效" })
handleOpenChange(false)
} catch (e) {
// 失败时不关闭对话框、不清空表单值CONTEXT D-错误处理 锁定)
toast.error("保存失败", { description: handleApiError(e) })
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
APP ID Access Token
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 py-2">
<FormField
control={form.control}
name="appId"
render={({ field }) => (
<FormItem>
<FormLabel>APP ID</FormLabel>
<FormControl>
<Input placeholder="输入 APP ID" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessToken"
render={({ field }) => (
<FormItem>
<FormLabel>Access Token</FormLabel>
<FormControl>
<Input
placeholder={slot?.accessTokenMasked ?? "输入 Access Token"}
{...field}
/>
</FormControl>
<FormDescription>
Access Token
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{slot && (
<p className="text-sm text-muted-foreground">
{new Date(slot.updatedAt).toLocaleString("zh-CN")}
</p>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isSubmitting}
>
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"保存"
)}
</Button>
</DialogFooter>
</form>
</Form>
)}
</DialogContent>
</Dialog>
)
}