lty/qy-lty-admin/components/ai-model/credential-slot-dialog.tsx
pmc d719891754 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 主体
2026-05-08 12:31:59 +08:00

192 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
)
}