Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions messages/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions messages/ja/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -1448,6 +1448,18 @@
"statusEnabled": "有効",
"statusDisabled": "無効"
},
"inlineEdit": {
"save": "保存",
"cancel": "キャンセル",
"saveSuccess": "保存に成功しました",
"saveFailed": "保存に失敗しました",
"priorityLabel": "優先度",
"weightLabel": "重み",
"costMultiplierLabel": "コスト倍率",
"priorityInvalid": "0 以上の整数を入力してください",
"weightInvalid": "1〜100 の整数を入力してください",
"costMultiplierInvalid": "0以上の数値を入力してください"
},
"schedulingDialog": {
"title": "プロバイダースケジューリングルール",
"description": "システムが高可用性とコスト最適化のために上流プロバイダーをインテリジェントに選択する方法を理解する",
Expand Down
12 changes: 12 additions & 0 deletions messages/ru/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -1448,6 +1448,18 @@
"statusEnabled": "включен",
"statusDisabled": "отключен"
},
"inlineEdit": {
"save": "Сохранить",
"cancel": "Отмена",
"saveSuccess": "Успешно сохранено",
"saveFailed": "Не удалось сохранить",
"priorityLabel": "Приоритет",
"weightLabel": "Вес",
"costMultiplierLabel": "Коэф цены",
"priorityInvalid": "Введите целое число >= 0",
"weightInvalid": "Введите целое число от 1 до 100",
"costMultiplierInvalid": "Введите число не меньше 0"
},
"schedulingDialog": {
"title": "Правила планирования провайдеров",
"description": "Узнайте, как система интеллектуально выбирает вышестоящих провайдеров для высокой доступности и оптимизации затрат",
Expand Down
12 changes: 12 additions & 0 deletions messages/zh-CN/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,18 @@
"statusEnabled": "启用",
"statusDisabled": "禁用"
},
"inlineEdit": {
"save": "保存",
"cancel": "取消",
"saveSuccess": "保存成功",
"saveFailed": "保存失败",
"priorityLabel": "优先级",
"weightLabel": "权重",
"costMultiplierLabel": "成本倍数",
"priorityInvalid": "请输入大于等于 0 的整数",
"weightInvalid": "请输入 1-100 之间的整数",
"costMultiplierInvalid": "请输入大于等于 0 的数字"
},
"schedulingDialog": {
"title": "供应商调度规则说明",
"description": "了解系统如何智能选择上游供应商,确保高可用性和成本优化",
Expand Down
12 changes: 12 additions & 0 deletions messages/zh-TW/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -1454,6 +1454,18 @@
"statusEnabled": "啟用",
"statusDisabled": "禁用"
},
"inlineEdit": {
"save": "保存",
"cancel": "取消",
"saveSuccess": "保存成功",
"saveFailed": "保存失敗",
"priorityLabel": "優先級",
"weightLabel": "權重",
"costMultiplierLabel": "成本倍數",
"priorityInvalid": "請輸入大於等於 0 的整數",
"weightInvalid": "請輸入 1-100 之間的整數",
"costMultiplierInvalid": "請輸入大於等於 0 的數字"
},
"schedulingDialog": {
"title": "供應商調度規則說明",
"description": "了解系統如何智慧選擇上游供應商,確保高可用性和成本優化",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<boolean>; // 返回是否成功
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<HTMLInputElement>(null);
const initialValueRef = useRef<number>(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 (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
className={cn(
"tabular-nums font-medium underline-offset-4 rounded-sm",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
disabled ? "cursor-default text-muted-foreground" : "cursor-pointer hover:underline"
)}
onPointerDown={stopPropagation}
onClick={stopPropagation}
>
{value}
{suffix}
</button>
</PopoverTrigger>

<PopoverContent
align="center"
side="bottom"
sideOffset={6}
className="w-auto p-3"
onPointerDown={stopPropagation}
onClick={stopPropagation}
>
<div className="grid gap-2">
<div className="text-xs font-medium">{label}</div>

<div className="flex items-center gap-2">
<Input
ref={inputRef}
value={draft}
onChange={(e) => 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 && <span className="text-sm text-muted-foreground">{suffix}</span>}
</div>

{validationError && <div className="text-xs text-destructive">{validationError}</div>}

<div className="flex items-center justify-end gap-2 pt-1">
<Button
type="button"
size="sm"
variant="outline"
onClick={handleCancel}
disabled={saving}
>
{t("cancel")}
</Button>
<Button type="button" size="sm" onClick={handleSave} disabled={!canSave}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("save")}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
}
Loading