Commit e926e5ca by lihu-001 Committed by GitHub

fix: 修复兑换码额度精度损失 (#6685)

* fix: 修复兑换码额度精度损失(#6680)

* fix(redemption): guard update data integrity
parent 1da23d6b
......@@ -51,7 +51,12 @@ import {
SheetTitle,
} from '@/components/ui/sheet'
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
import { formatQuota, parseQuotaFromDollars } from '@/lib/format'
import {
formatQuota,
getEditableQuotaStep,
parseQuotaFromDollars,
} from '@/lib/format'
import { handleServerError } from '@/lib/handle-server-error'
import { addTimeToDate } from '@/lib/time'
import { createRedemption, updateRedemption, getRedemption } from '../api'
......@@ -63,7 +68,7 @@ import {
transformFormDataToPayload,
transformRedemptionToFormDefaults,
} from '../lib'
import { type Redemption } from '../types'
import type { Redemption } from '../types'
import { useRedemptions } from './redemptions-provider'
type RedemptionsMutateDrawerProps = {
......@@ -79,8 +84,15 @@ export function RedemptionsMutateDrawer({
}: RedemptionsMutateDrawerProps) {
const { t } = useTranslation()
const isUpdate = !!currentRow
const redemptionId = currentRow?.id
const { triggerRefresh } = useRedemptions()
const [isSubmitting, setIsSubmitting] = useState(false)
const [redemptionLoadState, setRedemptionLoadState] = useState<
'idle' | 'loading' | 'ready' | 'error'
>('idle')
const [loadedRedemption, setLoadedRedemption] = useState<Redemption | null>(
null
)
const form = useForm<RedemptionFormValues>({
resolver: zodResolver(getRedemptionFormSchema(t)),
......@@ -89,27 +101,76 @@ export function RedemptionsMutateDrawer({
// Load existing data when updating
useEffect(() => {
if (open && isUpdate && currentRow) {
// For update, fetch fresh data
getRedemption(currentRow.id).then((result) => {
if (result.success && result.data) {
form.reset(transformRedemptionToFormDefaults(result.data))
if (!open) {
setRedemptionLoadState('idle')
setLoadedRedemption(null)
return
}
})
} else if (open && !isUpdate) {
// For create, reset to defaults
if (!isUpdate || redemptionId === undefined) {
form.reset(REDEMPTION_FORM_DEFAULT_VALUES)
setRedemptionLoadState('ready')
setLoadedRedemption(null)
return
}
let ignoreResult = false
form.reset(REDEMPTION_FORM_DEFAULT_VALUES)
setRedemptionLoadState('loading')
setLoadedRedemption(null)
void getRedemption(redemptionId)
.then((result) => {
if (ignoreResult) return
if (
!result.success ||
!result.data ||
result.data.id !== redemptionId
) {
setRedemptionLoadState('error')
toast.error(t('Failed to load'))
return
}
form.reset(transformRedemptionToFormDefaults(result.data))
setLoadedRedemption(result.data)
setRedemptionLoadState('ready')
})
.catch((error: unknown) => {
if (ignoreResult) return
setRedemptionLoadState('error')
handleServerError(error)
})
return () => {
ignoreResult = true
}
}, [open, isUpdate, currentRow, form])
}, [open, isUpdate, redemptionId, form, t])
const isUpdateReady =
!isUpdate ||
(redemptionLoadState === 'ready' && loadedRedemption?.id === redemptionId)
const isLoadingRedemption = redemptionLoadState === 'loading'
const onSubmit = async (data: RedemptionFormValues) => {
if (isUpdate && (!currentRow || !loadedRedemption || !isUpdateReady)) {
return
}
setIsSubmitting(true)
try {
const basePayload = transformFormDataToPayload(data)
if (isUpdate && currentRow) {
if (isUpdate && currentRow && loadedRedemption) {
const quota = form.getFieldState('quota_dollars').isDirty
? basePayload.quota
: loadedRedemption.quota
const result = await updateRedemption({
...basePayload,
quota,
id: currentRow.id,
})
if (result.success) {
......@@ -158,10 +219,17 @@ export function RedemptionsMutateDrawer({
const { meta: currencyMeta } = getCurrencyDisplay()
const currencyLabel = getCurrencyLabel()
const tokensOnly = currencyMeta.kind === 'tokens'
const quotaStep = getEditableQuotaStep()
const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel })
const quotaPlaceholder = tokensOnly
? t('Enter quota in tokens')
: t('Enter quota in {{currency}}', { currency: currencyLabel })
let submitButtonLabel = t('Save changes')
if (isLoadingRedemption) {
submitButtonLabel = t('Loading...')
} else if (isSubmitting) {
submitButtonLabel = t('Saving...')
}
return (
<Sheet
......@@ -194,6 +262,11 @@ export function RedemptionsMutateDrawer({
id='redemption-form'
onSubmit={handleSubmit}
className={sideDrawerFormClassName()}
aria-busy={isLoadingRedemption}
>
<fieldset
disabled={!isUpdateReady || isSubmitting}
className='contents'
>
<SideDrawerSection>
<FormField
......@@ -223,10 +296,12 @@ export function RedemptionsMutateDrawer({
<Input
{...field}
type='number'
step={tokensOnly ? 1 : 0.01}
step={quotaStep}
placeholder={quotaPlaceholder}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
field.onChange(
Number.parseFloat(e.target.value) || 0
)
}
/>
</FormControl>
......@@ -314,12 +389,16 @@ export function RedemptionsMutateDrawer({
max='100'
placeholder={t('Number of codes to create')}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 1)
field.onChange(
Number.parseInt(e.target.value, 10) || 1
)
}
/>
</FormControl>
<FormDescription>
{t('Create multiple redemption codes at once (1-100)')}
{t(
'Create multiple redemption codes at once (1-100)'
)}
</FormDescription>
<FormMessage />
</FormItem>
......@@ -327,14 +406,19 @@ export function RedemptionsMutateDrawer({
/>
)}
</SideDrawerSection>
</fieldset>
</form>
</Form>
<SheetFooter className={sideDrawerFooterClassName()}>
<SheetClose render={<Button variant='outline' />}>
{t('Close')}
</SheetClose>
<Button form='redemption-form' type='submit' disabled={isSubmitting}>
{isSubmitting ? t('Saving...') : t('Save changes')}
<Button
form='redemption-form'
type='submit'
disabled={isSubmitting || !isUpdateReady}
>
{submitButtonLabel}
</Button>
</SheetFooter>
</SheetContent>
......
......@@ -19,13 +19,16 @@ For commercial licensing, please contact support@quantumnous.com
import type { TFunction } from 'i18next'
import { z } from 'zod'
import { parseQuotaFromDollars, quotaUnitsToDollars } from '@/lib/format'
import {
parseQuotaFromDollars,
quotaUnitsToEditableAmount,
} from '@/lib/format'
import {
REDEMPTION_VALIDATION,
getRedemptionFormErrorMessages,
} from '../constants'
import { type RedemptionFormData, type Redemption } from '../types'
import type { RedemptionFormData, Redemption } from '../types'
// ============================================================================
// Form Schema (use getRedemptionFormSchema(t) in components for i18n messages)
......@@ -94,7 +97,7 @@ export function transformRedemptionToFormDefaults(
): RedemptionFormValues {
return {
name: redemption.name,
quota_dollars: quotaUnitsToDollars(redemption.quota),
quota_dollars: quotaUnitsToEditableAmount(redemption.quota),
expired_time:
redemption.expired_time > 0
? new Date(redemption.expired_time * 1000)
......
......@@ -244,6 +244,23 @@ function mergeOptions(
}
}
function getFractionDigits(
value: number,
digitsLarge: number,
digitsSmall: number
): number {
return Math.abs(value) >= 1 ? digitsLarge : digitsSmall
}
/** Return the configured fraction digits for a plain currency value. */
export function getCurrencyFractionDigits(
value: number,
options?: CurrencyFormatOptions
): number {
const merged = mergeOptions(options)
return getFractionDigits(value, merged.digitsLarge, merged.digitsSmall)
}
function removeTrailingZeros(str: string): string {
if (!str.includes('.')) return str
return str.replace(/(\.[0-9]*?)0+$/, '$1').replace(/\.$/, '')
......@@ -261,7 +278,7 @@ function formatNumberWithSuffix(
return `${removeTrailingZeros(result.toFixed(1))}k`
}
const digits = abs >= 1 ? digitsLarge : digitsSmall
const digits = getFractionDigits(value, digitsLarge, digitsSmall)
return removeTrailingZeros(value.toFixed(digits))
}
......@@ -300,8 +317,11 @@ function formatCurrencyValue(
)
}
const digits =
Math.abs(value) >= 1 ? options.digitsLarge : options.digitsSmall
const digits = getFractionDigits(
value,
options.digitsLarge,
options.digitsSmall
)
const adjustedValue = adjustForMinimum(value, digits, options.minimumNonZero)
if (meta.kind === 'currency') {
......
......@@ -22,6 +22,7 @@ import {
formatCurrencyFromUSD,
formatQuotaWithCurrency,
getCurrencyDisplay,
getCurrencyFractionDigits,
} from './currency'
// ============================================================================
......@@ -104,16 +105,44 @@ export function parseQuotaFromDollars(amount: number): number {
*/
export function quotaUnitsToDollars(units: number): number {
const { config, meta } = getCurrencyDisplay()
return quotaUnitsToDisplayAmount(units, config.quotaPerUnit, meta)
}
function quotaUnitsToDisplayAmount(
units: number,
quotaPerUnit: number,
meta: ReturnType<typeof getCurrencyDisplay>['meta']
): number {
if (meta.kind === 'tokens') {
return units
}
const usdAmount = units / config.quotaPerUnit
const exchangeRate =
meta.kind === 'currency' || meta.kind === 'custom' ? meta.exchangeRate : 1
return (units / quotaPerUnit) * meta.exchangeRate
}
/**
* Convert quota units to a plain number suitable for an editable input.
* Uses the same precision as quota list formatting without symbols or suffixes.
*/
export function quotaUnitsToEditableAmount(units: number): number {
const { config, meta } = getCurrencyDisplay()
const amount = quotaUnitsToDisplayAmount(units, config.quotaPerUnit, meta)
if (meta.kind === 'tokens') {
return Math.round(amount)
}
return Number(amount.toFixed(getCurrencyFractionDigits(amount)))
}
/** Return the input step matching the configured editable quota precision. */
export function getEditableQuotaStep(): number {
const { meta } = getCurrencyDisplay()
if (meta.kind === 'tokens') {
return 1
}
return usdAmount * exchangeRate
return 10 ** -getCurrencyFractionDigits(0)
}
// ============================================================================
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment