diff --git a/messages/en/settings.json b/messages/en/settings.json index 5b3044f2d..e63e3c253 100644 --- a/messages/en/settings.json +++ b/messages/en/settings.json @@ -574,6 +574,18 @@ "statusEnabled": "enabled", "statusDisabled": "disabled" }, + "inlineEdit": { + "save": "Save", + "cancel": "Cancel", + "saveSuccess": "Saved successfully", + "saveFailed": "Save failed", + "priorityLabel": "Priority", + "weightLabel": "Weight", + "costMultiplierLabel": "Cost Multiplier", + "priorityInvalid": "Please enter an integer >= 0", + "weightInvalid": "Please enter an integer between 1 and 100", + "costMultiplierInvalid": "Please enter a non-negative number" + }, "schedulingDialog": { "title": "Provider Scheduling Rules", "description": "Understand how the system intelligently selects upstream providers for high availability and cost optimization", diff --git a/messages/ja/settings.json b/messages/ja/settings.json index 58d493575..a964040a2 100644 --- a/messages/ja/settings.json +++ b/messages/ja/settings.json @@ -1448,6 +1448,18 @@ "statusEnabled": "有効", "statusDisabled": "無効" }, + "inlineEdit": { + "save": "保存", + "cancel": "キャンセル", + "saveSuccess": "保存に成功しました", + "saveFailed": "保存に失敗しました", + "priorityLabel": "優先度", + "weightLabel": "重み", + "costMultiplierLabel": "コスト倍率", + "priorityInvalid": "0 以上の整数を入力してください", + "weightInvalid": "1〜100 の整数を入力してください", + "costMultiplierInvalid": "0以上の数値を入力してください" + }, "schedulingDialog": { "title": "プロバイダースケジューリングルール", "description": "システムが高可用性とコスト最適化のために上流プロバイダーをインテリジェントに選択する方法を理解する", diff --git a/messages/ru/settings.json b/messages/ru/settings.json index 97ed8c673..0a4ff1ecc 100644 --- a/messages/ru/settings.json +++ b/messages/ru/settings.json @@ -1448,6 +1448,18 @@ "statusEnabled": "включен", "statusDisabled": "отключен" }, + "inlineEdit": { + "save": "Сохранить", + "cancel": "Отмена", + "saveSuccess": "Успешно сохранено", + "saveFailed": "Не удалось сохранить", + "priorityLabel": "Приоритет", + "weightLabel": "Вес", + "costMultiplierLabel": "Коэф цены", + "priorityInvalid": "Введите целое число >= 0", + "weightInvalid": "Введите целое число от 1 до 100", + "costMultiplierInvalid": "Введите число не меньше 0" + }, "schedulingDialog": { "title": "Правила планирования провайдеров", "description": "Узнайте, как система интеллектуально выбирает вышестоящих провайдеров для высокой доступности и оптимизации затрат", diff --git a/messages/zh-CN/settings.json b/messages/zh-CN/settings.json index 1e806e995..dfda03d97 100644 --- a/messages/zh-CN/settings.json +++ b/messages/zh-CN/settings.json @@ -187,6 +187,18 @@ "statusEnabled": "启用", "statusDisabled": "禁用" }, + "inlineEdit": { + "save": "保存", + "cancel": "取消", + "saveSuccess": "保存成功", + "saveFailed": "保存失败", + "priorityLabel": "优先级", + "weightLabel": "权重", + "costMultiplierLabel": "成本倍数", + "priorityInvalid": "请输入大于等于 0 的整数", + "weightInvalid": "请输入 1-100 之间的整数", + "costMultiplierInvalid": "请输入大于等于 0 的数字" + }, "schedulingDialog": { "title": "供应商调度规则说明", "description": "了解系统如何智能选择上游供应商,确保高可用性和成本优化", diff --git a/messages/zh-TW/settings.json b/messages/zh-TW/settings.json index 1edfdb61c..320009d6e 100644 --- a/messages/zh-TW/settings.json +++ b/messages/zh-TW/settings.json @@ -1454,6 +1454,18 @@ "statusEnabled": "啟用", "statusDisabled": "禁用" }, + "inlineEdit": { + "save": "保存", + "cancel": "取消", + "saveSuccess": "保存成功", + "saveFailed": "保存失敗", + "priorityLabel": "優先級", + "weightLabel": "權重", + "costMultiplierLabel": "成本倍數", + "priorityInvalid": "請輸入大於等於 0 的整數", + "weightInvalid": "請輸入 1-100 之間的整數", + "costMultiplierInvalid": "請輸入大於等於 0 的數字" + }, "schedulingDialog": { "title": "供應商調度規則說明", "description": "了解系統如何智慧選擇上游供應商,確保高可用性和成本優化", diff --git a/src/app/[locale]/settings/providers/_components/inline-edit-popover.tsx b/src/app/[locale]/settings/providers/_components/inline-edit-popover.tsx new file mode 100644 index 000000000..d6bc237e7 --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/inline-edit-popover.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import type * as React from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +export interface InlineEditPopoverProps { + value: number; + label: string; + onSave: (value: number) => Promise; // 返回是否成功 + validator: (value: string) => string | null; // 返回 null 表示有效,否则返回错误信息 + disabled?: boolean; + suffix?: string; // 如 "x" 用于 costMultiplier 显示 + type?: "integer" | "number"; // 输入类型 +} + +export function InlineEditPopover({ + value, + label, + onSave, + validator, + disabled = false, + suffix, + type = "number", +}: InlineEditPopoverProps) { + const t = useTranslations("settings.providers.inlineEdit"); + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(() => value.toString()); + const [saving, setSaving] = useState(false); + + const inputRef = useRef(null); + const initialValueRef = useRef(value); + + const trimmedDraft = draft.trim(); + + const validationError = useMemo(() => { + return validator(trimmedDraft); + }, [trimmedDraft, validator]); + + const parsedValue = useMemo(() => { + if (trimmedDraft.length === 0) return null; + const numeric = Number(trimmedDraft); + if (Number.isNaN(numeric)) return null; + if (type === "integer" && !Number.isInteger(numeric)) return null; + return numeric; + }, [trimmedDraft, type]); + + const canSave = !disabled && !saving && validationError == null && parsedValue != null; + + useEffect(() => { + if (!open) return; + const raf = requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + return () => cancelAnimationFrame(raf); + }, [open]); + + const stopPropagation = (e: React.SyntheticEvent) => { + e.stopPropagation(); + }; + + const resetDraft = () => { + setDraft(initialValueRef.current.toString()); + }; + + const handleOpenChange = (nextOpen: boolean) => { + if (disabled && nextOpen) return; + + if (nextOpen) { + initialValueRef.current = value; + setDraft(value.toString()); + } else { + resetDraft(); + setSaving(false); + } + + setOpen(nextOpen); + }; + + const handleCancel = () => { + resetDraft(); + setOpen(false); + }; + + const handleSave = async () => { + if (!canSave || parsedValue == null) return; + + setSaving(true); + try { + const ok = await onSave(parsedValue); + if (ok) { + setOpen(false); + } + } finally { + setSaving(false); + } + }; + + return ( + + + + + + +
+
{label}
+ +
+ setDraft(e.target.value)} + disabled={disabled || saving} + className="w-24 tabular-nums" + aria-label={label} + aria-invalid={validationError != null} + type="number" + inputMode="decimal" + step={type === "integer" ? "1" : "any"} + onPointerDown={stopPropagation} + onClick={stopPropagation} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Escape") { + e.preventDefault(); + handleCancel(); + } + if (e.key === "Enter") { + e.preventDefault(); + void handleSave(); + } + }} + /> + {suffix && {suffix}} +
+ + {validationError &&
{validationError}
} + +
+ + +
+
+
+
+ ); +} diff --git a/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx b/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx index facf8a253..30aa6a0fd 100644 --- a/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx @@ -42,7 +42,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Switch } from "@/components/ui/switch"; -import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; +import { PROVIDER_GROUP, PROVIDER_LIMITS } from "@/lib/constants/provider.constants"; import { getProviderTypeConfig, getProviderTypeTranslationKey } from "@/lib/provider-type-utils"; import { copyToClipboard, isClipboardSupported } from "@/lib/utils/clipboard"; import { getContrastTextColor, getGroupColor } from "@/lib/utils/color"; @@ -51,6 +51,7 @@ import { formatCurrency } from "@/lib/utils/currency"; import type { ProviderDisplay } from "@/types/provider"; import type { User } from "@/types/user"; import { ProviderForm } from "./forms/provider-form"; +import { InlineEditPopover } from "./inline-edit-popover"; interface ProviderRichListItemProps { provider: ProviderDisplay; @@ -95,6 +96,35 @@ export function ProviderRichListItem({ const tTypes = useTranslations("settings.providers.types"); const tList = useTranslations("settings.providers.list"); const tTimeout = useTranslations("settings.providers.form.sections.timeout"); + const tInline = useTranslations("settings.providers.inlineEdit"); + + const validatePriority = (raw: string) => { + if (raw.length === 0) return tInline("priorityInvalid"); + const value = Number(raw); + if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0 || value > 2147483647) + return tInline("priorityInvalid"); + return null; + }; + + const validateWeight = (raw: string) => { + if (raw.length === 0) return tInline("weightInvalid"); + const value = Number(raw); + if ( + !Number.isFinite(value) || + !Number.isInteger(value) || + value < PROVIDER_LIMITS.WEIGHT.MIN || + value > PROVIDER_LIMITS.WEIGHT.MAX + ) + return tInline("weightInvalid"); + return null; + }; + + const validateCostMultiplier = (raw: string) => { + if (raw.length === 0) return tInline("costMultiplierInvalid"); + const value = Number(raw); + if (!Number.isFinite(value) || value < 0) return tInline("costMultiplierInvalid"); + return null; + }; // 获取供应商类型配置 const typeConfig = getProviderTypeConfig(provider.providerType); @@ -254,6 +284,32 @@ export function ProviderRichListItem({ }); }; + const createSaveHandler = (fieldName: "priority" | "weight" | "cost_multiplier") => { + return async (value: number) => { + try { + const res = await editProvider(provider.id, { [fieldName]: value } as Parameters< + typeof editProvider + >[1]); + if (res.ok) { + toast.success(tInline("saveSuccess")); + queryClient.invalidateQueries({ queryKey: ["providers"] }); + router.refresh(); + return true; + } + toast.error(tInline("saveFailed"), { description: res.error || tList("unknownError") }); + return false; + } catch (error) { + console.error(`更新 ${fieldName} 失败:`, error); + toast.error(tInline("saveFailed"), { description: tList("unknownError") }); + return false; + } + }; + }; + + const handleSavePriority = createSaveHandler("priority"); + const handleSaveWeight = createSaveHandler("weight"); + const handleSaveCostMultiplier = createSaveHandler("cost_multiplier"); + return ( <>
@@ -393,15 +449,52 @@ export function ProviderRichListItem({
{tList("priority")}
-
{provider.priority}
+
+ {canEdit ? ( + + ) : ( + {provider.priority} + )} +
{tList("weight")}
-
{provider.weight}
+
+ {canEdit ? ( + + ) : ( + {provider.weight} + )} +
{tList("costMultiplier")}
-
{provider.costMultiplier}x
+
+ {canEdit ? ( + + ) : ( + {provider.costMultiplier}x + )} +
diff --git a/src/lib/validation/schemas.test.ts b/src/lib/validation/schemas.test.ts new file mode 100644 index 000000000..f13fabbcd --- /dev/null +++ b/src/lib/validation/schemas.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "vitest"; + +import { CreateProviderSchema, UpdateProviderSchema } from "./schemas"; + +describe("Provider schemas - priority/weight/costMultiplier 规则对齐", () => { + describe("UpdateProviderSchema", () => { + test("priority 接受 0 和正整数,拒绝负数", () => { + expect(UpdateProviderSchema.safeParse({ priority: -100 }).success).toBe(false); + expect(UpdateProviderSchema.safeParse({ priority: -1 }).success).toBe(false); + expect(UpdateProviderSchema.safeParse({ priority: 0 }).success).toBe(true); + expect(UpdateProviderSchema.safeParse({ priority: 123 }).success).toBe(true); + }); + + test("weight 接受 1-100 正整数,拒绝 0 和超出范围的值", () => { + expect(UpdateProviderSchema.safeParse({ weight: 0 }).success).toBe(false); + expect(UpdateProviderSchema.safeParse({ weight: 1 }).success).toBe(true); + expect(UpdateProviderSchema.safeParse({ weight: 100 }).success).toBe(true); + expect(UpdateProviderSchema.safeParse({ weight: 101 }).success).toBe(false); + expect(UpdateProviderSchema.safeParse({ weight: -1 }).success).toBe(false); + }); + + test("costMultiplier 接受 0 和正数(含小数),使用 coerce 支持字符串转换", () => { + expect(UpdateProviderSchema.safeParse({ cost_multiplier: 0 }).success).toBe(true); + expect(UpdateProviderSchema.safeParse({ cost_multiplier: 0.5 }).success).toBe(true); + expect(UpdateProviderSchema.safeParse({ cost_multiplier: 1.5 }).success).toBe(true); + // coerce 会将字符串转为数字 + expect(UpdateProviderSchema.safeParse({ cost_multiplier: "0.5" }).success).toBe(true); + expect(UpdateProviderSchema.safeParse({ cost_multiplier: "1.5" }).success).toBe(true); + // 负数被拒绝 + expect(UpdateProviderSchema.safeParse({ cost_multiplier: -1 }).success).toBe(false); + }); + + test("非法值被拒绝", () => { + // priority: 字符串和 null 被拒绝 + expect(UpdateProviderSchema.safeParse({ priority: "-100" }).success).toBe(false); + expect(UpdateProviderSchema.safeParse({ priority: "abc" }).success).toBe(false); + expect(UpdateProviderSchema.safeParse({ priority: null }).success).toBe(false); + + // weight: 字符串和 null 被拒绝 + expect(UpdateProviderSchema.safeParse({ weight: "0" }).success).toBe(false); + expect(UpdateProviderSchema.safeParse({ weight: null }).success).toBe(false); + + // cost_multiplier: 非数字字符串被拒绝 + expect(UpdateProviderSchema.safeParse({ cost_multiplier: "abc" }).success).toBe(false); + // 注意: null 会被 coerce 转为 0 (Number(null) === 0),所以会通过 + }); + }); + + describe("CreateProviderSchema", () => { + const base = { + name: "测试供应商", + url: "https://api.example.com", + key: "sk-test", + }; + + test("priority 接受 0 和正整数,拒绝负数", () => { + expect(CreateProviderSchema.safeParse({ ...base, priority: -100 }).success).toBe(false); + expect(CreateProviderSchema.safeParse({ ...base, priority: -1 }).success).toBe(false); + expect(CreateProviderSchema.safeParse({ ...base, priority: 0 }).success).toBe(true); + expect(CreateProviderSchema.safeParse({ ...base, priority: 123 }).success).toBe(true); + }); + + test("weight 接受 1-100 正整数,拒绝 0 和超出范围的值", () => { + expect(CreateProviderSchema.safeParse({ ...base, weight: 0 }).success).toBe(false); + expect(CreateProviderSchema.safeParse({ ...base, weight: 1 }).success).toBe(true); + expect(CreateProviderSchema.safeParse({ ...base, weight: 100 }).success).toBe(true); + expect(CreateProviderSchema.safeParse({ ...base, weight: 101 }).success).toBe(false); + expect(CreateProviderSchema.safeParse({ ...base, weight: -1 }).success).toBe(false); + }); + + test("costMultiplier 接受 0 和正数(含小数),使用 coerce 支持字符串转换", () => { + expect(CreateProviderSchema.safeParse({ ...base, cost_multiplier: 0 }).success).toBe(true); + expect(CreateProviderSchema.safeParse({ ...base, cost_multiplier: 0.5 }).success).toBe(true); + expect(CreateProviderSchema.safeParse({ ...base, cost_multiplier: 1.5 }).success).toBe(true); + // coerce 会将字符串转为数字 + expect(CreateProviderSchema.safeParse({ ...base, cost_multiplier: "0.5" }).success).toBe( + true + ); + expect(CreateProviderSchema.safeParse({ ...base, cost_multiplier: "1.5" }).success).toBe( + true + ); + // 负数被拒绝 + expect(CreateProviderSchema.safeParse({ ...base, cost_multiplier: -1 }).success).toBe(false); + }); + + test("非法值被拒绝", () => { + // priority: 字符串和 null 被拒绝 + expect(CreateProviderSchema.safeParse({ ...base, priority: "-100" }).success).toBe(false); + expect(CreateProviderSchema.safeParse({ ...base, priority: "abc" }).success).toBe(false); + expect(CreateProviderSchema.safeParse({ ...base, priority: null }).success).toBe(false); + + // weight: 字符串和 null 被拒绝 + expect(CreateProviderSchema.safeParse({ ...base, weight: "0" }).success).toBe(false); + expect(CreateProviderSchema.safeParse({ ...base, weight: null }).success).toBe(false); + + // cost_multiplier: 非数字字符串被拒绝 + expect(CreateProviderSchema.safeParse({ ...base, cost_multiplier: "abc" }).success).toBe( + false + ); + // 注意: null 会被 coerce 转为 0 (Number(null) === 0),所以会通过 + }); + }); +}); diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index 1dd0baab9..729ab43f1 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -331,9 +331,9 @@ export const CreateProviderSchema = z.object({ is_enabled: z.boolean().optional().default(PROVIDER_DEFAULTS.IS_ENABLED), weight: z .number() - .int() - .min(PROVIDER_LIMITS.WEIGHT.MIN) - .max(PROVIDER_LIMITS.WEIGHT.MAX) + .int("权重必须是整数") + .min(PROVIDER_LIMITS.WEIGHT.MIN, "权重不能小于 1") + .max(PROVIDER_LIMITS.WEIGHT.MAX, "权重不能超过 100") .optional() .default(PROVIDER_DEFAULTS.WEIGHT), priority: z @@ -525,9 +525,9 @@ export const UpdateProviderSchema = z is_enabled: z.boolean().optional(), weight: z .number() - .int() - .min(PROVIDER_LIMITS.WEIGHT.MIN) - .max(PROVIDER_LIMITS.WEIGHT.MAX) + .int("权重必须是整数") + .min(PROVIDER_LIMITS.WEIGHT.MIN, "权重不能小于 1") + .max(PROVIDER_LIMITS.WEIGHT.MAX, "权重不能超过 100") .optional(), priority: z .number()