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:
parent
28bc2a7251
commit
d719891754
191
qy-lty-admin/components/ai-model/credential-slot-dialog.tsx
Normal file
191
qy-lty-admin/components/ai-model/credential-slot-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user