فهرست منبع

fix: improve tiered pricing number input editing (#4536)

* fix: follow required marker styling convention

* fix: improve tiered pricing number input editing
yyhhyyyyyy 1 هفته پیش
والد
کامیت
5f86839c7e
1فایلهای تغییر یافته به همراه123 افزوده شده و 40 حذف شده
  1. 123 40
      web/default/src/features/system-settings/models/tiered-pricing-editor.tsx

+ 123 - 40
web/default/src/features/system-settings/models/tiered-pricing-editor.tsx

@@ -1,4 +1,15 @@
-import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import {
+  memo,
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+  type ChangeEvent,
+  type FocusEvent,
+  type InputHTMLAttributes,
+  type MouseEvent as ReactMouseEvent,
+} from 'react'
 import { Copy, Plus, Trash2 } from 'lucide-react'
 import { Copy, Plus, Trash2 } from 'lucide-react'
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
@@ -285,6 +296,93 @@ function formatTokenHint(n: number | string | null | undefined): string {
   return `= ${v.toLocaleString()} tokens`
   return `= ${v.toLocaleString()} tokens`
 }
 }
 
 
+function formatNumberDraft(value: number | string): string {
+  if (value === '') return ''
+  if (typeof value === 'number')
+    return Number.isFinite(value) ? String(value) : '0'
+  return value
+}
+
+function parseNumberDraft(value: string): number {
+  if (value.trim() === '') return 0
+  const next = Number(value)
+  return Number.isFinite(next) ? next : 0
+}
+
+function isZeroDraft(value: string): boolean {
+  return value.trim() !== '' && parseNumberDraft(value) === 0
+}
+
+type DraftNumberInputProps = Omit<
+  InputHTMLAttributes<HTMLInputElement>,
+  'type' | 'value' | 'onChange'
+> & {
+  value: number | string
+  onValueChange: (next: number) => void
+  selectZeroOnFocus?: boolean
+}
+
+function DraftNumberInput({
+  value,
+  onValueChange,
+  selectZeroOnFocus = true,
+  onBlur,
+  onFocus,
+  onMouseUp,
+  ...props
+}: DraftNumberInputProps) {
+  const [draft, setDraft] = useState(() => formatNumberDraft(value))
+  const [focused, setFocused] = useState(false)
+
+  useEffect(() => {
+    if (!focused) {
+      setDraft(formatNumberDraft(value))
+    }
+  }, [focused, value])
+
+  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
+    const nextDraft = event.target.value
+    setDraft(nextDraft)
+    onValueChange(parseNumberDraft(nextDraft))
+  }
+
+  const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
+    setFocused(true)
+    onFocus?.(event)
+    if (selectZeroOnFocus && isZeroDraft(event.currentTarget.value)) {
+      event.currentTarget.select()
+    }
+  }
+
+  const handleMouseUp = (event: ReactMouseEvent<HTMLInputElement>) => {
+    onMouseUp?.(event)
+    if (selectZeroOnFocus && isZeroDraft(event.currentTarget.value)) {
+      event.preventDefault()
+      event.currentTarget.select()
+    }
+  }
+
+  const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
+    const normalized = parseNumberDraft(event.currentTarget.value)
+    setFocused(false)
+    setDraft(String(normalized))
+    onValueChange(normalized)
+    onBlur?.(event)
+  }
+
+  return (
+    <Input
+      {...props}
+      type='number'
+      value={draft}
+      onChange={handleChange}
+      onFocus={handleFocus}
+      onMouseUp={handleMouseUp}
+      onBlur={handleBlur}
+    />
+  )
+}
+
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 // Tier condition row
 // Tier condition row
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
@@ -332,13 +430,10 @@ function ConditionRow({ condition, onChange, onRemove }: ConditionRowProps) {
           ))}
           ))}
         </SelectContent>
         </SelectContent>
       </Select>
       </Select>
-      <Input
-        type='number'
+      <DraftNumberInput
         min={0}
         min={0}
-        value={condition.value === '' ? '' : Number(condition.value)}
-        onChange={(event) =>
-          onChange({ ...condition, value: event.target.value })
-        }
+        value={condition.value}
+        onValueChange={(value) => onChange({ ...condition, value })}
         placeholder='tokens'
         placeholder='tokens'
         className='w-32'
         className='w-32'
       />
       />
@@ -381,12 +476,11 @@ function PriceField({
     <div className='space-y-1'>
     <div className='space-y-1'>
       <Label className='text-xs'>{label}</Label>
       <Label className='text-xs'>{label}</Label>
       <div className='flex items-center gap-2'>
       <div className='flex items-center gap-2'>
-        <Input
-          type='number'
+        <DraftNumberInput
           min={0}
           min={0}
           step={0.01}
           step={0.01}
           value={Number.isFinite(value) ? value : 0}
           value={Number.isFinite(value) ? value : 0}
-          onChange={(event) => onChange(Number(event.target.value) || 0)}
+          onValueChange={onChange}
           className='w-32'
           className='w-32'
         />
         />
         {showSuffix && (
         {showSuffix && (
@@ -802,32 +896,29 @@ function RuleConditionRow({
       </Select>
       </Select>
       {timeCond.mode === MATCH_RANGE ? (
       {timeCond.mode === MATCH_RANGE ? (
         <>
         <>
-          <Input
-            type='number'
+          <DraftNumberInput
             value={timeCond.rangeStart}
             value={timeCond.rangeStart}
-            onChange={(event) =>
-              onChange({ ...timeCond, rangeStart: event.target.value })
+            onValueChange={(value) =>
+              onChange({ ...timeCond, rangeStart: String(value) })
             }
             }
             placeholder='start'
             placeholder='start'
             className='w-20'
             className='w-20'
           />
           />
           <span className='text-muted-foreground text-xs'>~</span>
           <span className='text-muted-foreground text-xs'>~</span>
-          <Input
-            type='number'
+          <DraftNumberInput
             value={timeCond.rangeEnd}
             value={timeCond.rangeEnd}
-            onChange={(event) =>
-              onChange({ ...timeCond, rangeEnd: event.target.value })
+            onValueChange={(value) =>
+              onChange({ ...timeCond, rangeEnd: String(value) })
             }
             }
             placeholder='end'
             placeholder='end'
             className='w-20'
             className='w-20'
           />
           />
         </>
         </>
       ) : (
       ) : (
-        <Input
-          type='number'
+        <DraftNumberInput
           value={timeCond.value}
           value={timeCond.value}
-          onChange={(event) =>
-            onChange({ ...timeCond, value: event.target.value })
+          onValueChange={(value) =>
+            onChange({ ...timeCond, value: String(value) })
           }
           }
           placeholder='value'
           placeholder='value'
           className='w-24'
           className='w-24'
@@ -991,13 +1082,12 @@ function RuleGroupCard({
 
 
       <div className='flex items-center gap-2'>
       <div className='flex items-center gap-2'>
         <Label className='text-xs'>{t('Multiplier')}</Label>
         <Label className='text-xs'>{t('Multiplier')}</Label>
-        <Input
-          type='number'
+        <DraftNumberInput
           min={0}
           min={0}
           step={0.01}
           step={0.01}
           value={group.multiplier}
           value={group.multiplier}
-          onChange={(event) =>
-            onChange({ ...group, multiplier: event.target.value })
+          onValueChange={(value) =>
+            onChange({ ...group, multiplier: String(value) })
           }
           }
           className='w-32'
           className='w-32'
           placeholder='1.0'
           placeholder='1.0'
@@ -1114,24 +1204,18 @@ function CostEstimator({ effectiveExpr }: EstimatorProps) {
       <div className='grid grid-cols-2 gap-3'>
       <div className='grid grid-cols-2 gap-3'>
         <div className='space-y-1'>
         <div className='space-y-1'>
           <Label className='text-xs'>{t('Input tokens')} (p)</Label>
           <Label className='text-xs'>{t('Input tokens')} (p)</Label>
-          <Input
-            type='number'
+          <DraftNumberInput
             min={0}
             min={0}
             value={promptTokens}
             value={promptTokens}
-            onChange={(event) =>
-              setPromptTokens(Number(event.target.value) || 0)
-            }
+            onValueChange={setPromptTokens}
           />
           />
         </div>
         </div>
         <div className='space-y-1'>
         <div className='space-y-1'>
           <Label className='text-xs'>{t('Output tokens')} (c)</Label>
           <Label className='text-xs'>{t('Output tokens')} (c)</Label>
-          <Input
-            type='number'
+          <DraftNumberInput
             min={0}
             min={0}
             value={completionTokens}
             value={completionTokens}
-            onChange={(event) =>
-              setCompletionTokens(Number(event.target.value) || 0)
-            }
+            onValueChange={setCompletionTokens}
           />
           />
         </div>
         </div>
       </div>
       </div>
@@ -1151,14 +1235,13 @@ function CostEstimator({ effectiveExpr }: EstimatorProps) {
                 <Label className='text-xs'>
                 <Label className='text-xs'>
                   {variable.shortLabel} ({variable.key})
                   {variable.shortLabel} ({variable.key})
                 </Label>
                 </Label>
-                <Input
-                  type='number'
+                <DraftNumberInput
                   min={0}
                   min={0}
                   value={extras[stateKey]}
                   value={extras[stateKey]}
-                  onChange={(event) =>
+                  onValueChange={(value) =>
                     setExtras((prev) => ({
                     setExtras((prev) => ({
                       ...prev,
                       ...prev,
-                      [stateKey]: Number(event.target.value) || 0,
+                      [stateKey]: value,
                     }))
                     }))
                   }
                   }
                 />
                 />