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