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 { ...@@ -51,7 +51,12 @@ import {
SheetTitle, SheetTitle,
} from '@/components/ui/sheet' } from '@/components/ui/sheet'
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency' 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 { addTimeToDate } from '@/lib/time'
import { createRedemption, updateRedemption, getRedemption } from '../api' import { createRedemption, updateRedemption, getRedemption } from '../api'
...@@ -63,7 +68,7 @@ import { ...@@ -63,7 +68,7 @@ import {
transformFormDataToPayload, transformFormDataToPayload,
transformRedemptionToFormDefaults, transformRedemptionToFormDefaults,
} from '../lib' } from '../lib'
import { type Redemption } from '../types' import type { Redemption } from '../types'
import { useRedemptions } from './redemptions-provider' import { useRedemptions } from './redemptions-provider'
type RedemptionsMutateDrawerProps = { type RedemptionsMutateDrawerProps = {
...@@ -79,8 +84,15 @@ export function RedemptionsMutateDrawer({ ...@@ -79,8 +84,15 @@ export function RedemptionsMutateDrawer({
}: RedemptionsMutateDrawerProps) { }: RedemptionsMutateDrawerProps) {
const { t } = useTranslation() const { t } = useTranslation()
const isUpdate = !!currentRow const isUpdate = !!currentRow
const redemptionId = currentRow?.id
const { triggerRefresh } = useRedemptions() const { triggerRefresh } = useRedemptions()
const [isSubmitting, setIsSubmitting] = useState(false) 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>({ const form = useForm<RedemptionFormValues>({
resolver: zodResolver(getRedemptionFormSchema(t)), resolver: zodResolver(getRedemptionFormSchema(t)),
...@@ -89,27 +101,76 @@ export function RedemptionsMutateDrawer({ ...@@ -89,27 +101,76 @@ export function RedemptionsMutateDrawer({
// Load existing data when updating // Load existing data when updating
useEffect(() => { useEffect(() => {
if (open && isUpdate && currentRow) { if (!open) {
// For update, fetch fresh data setRedemptionLoadState('idle')
getRedemption(currentRow.id).then((result) => { setLoadedRedemption(null)
if (result.success && result.data) { return
form.reset(transformRedemptionToFormDefaults(result.data))
} }
})
} else if (open && !isUpdate) { if (!isUpdate || redemptionId === undefined) {
// For create, reset to defaults form.reset(REDEMPTION_FORM_DEFAULT_VALUES)
setRedemptionLoadState('ready')
setLoadedRedemption(null)
return
}
let ignoreResult = false
form.reset(REDEMPTION_FORM_DEFAULT_VALUES) 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) => { const onSubmit = async (data: RedemptionFormValues) => {
if (isUpdate && (!currentRow || !loadedRedemption || !isUpdateReady)) {
return
}
setIsSubmitting(true) setIsSubmitting(true)
try { try {
const basePayload = transformFormDataToPayload(data) 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({ const result = await updateRedemption({
...basePayload, ...basePayload,
quota,
id: currentRow.id, id: currentRow.id,
}) })
if (result.success) { if (result.success) {
...@@ -158,10 +219,17 @@ export function RedemptionsMutateDrawer({ ...@@ -158,10 +219,17 @@ export function RedemptionsMutateDrawer({
const { meta: currencyMeta } = getCurrencyDisplay() const { meta: currencyMeta } = getCurrencyDisplay()
const currencyLabel = getCurrencyLabel() const currencyLabel = getCurrencyLabel()
const tokensOnly = currencyMeta.kind === 'tokens' const tokensOnly = currencyMeta.kind === 'tokens'
const quotaStep = getEditableQuotaStep()
const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel }) const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel })
const quotaPlaceholder = tokensOnly const quotaPlaceholder = tokensOnly
? t('Enter quota in tokens') ? t('Enter quota in tokens')
: t('Enter quota in {{currency}}', { currency: currencyLabel }) : t('Enter quota in {{currency}}', { currency: currencyLabel })
let submitButtonLabel = t('Save changes')
if (isLoadingRedemption) {
submitButtonLabel = t('Loading...')
} else if (isSubmitting) {
submitButtonLabel = t('Saving...')
}
return ( return (
<Sheet <Sheet
...@@ -194,6 +262,11 @@ export function RedemptionsMutateDrawer({ ...@@ -194,6 +262,11 @@ export function RedemptionsMutateDrawer({
id='redemption-form' id='redemption-form'
onSubmit={handleSubmit} onSubmit={handleSubmit}
className={sideDrawerFormClassName()} className={sideDrawerFormClassName()}
aria-busy={isLoadingRedemption}
>
<fieldset
disabled={!isUpdateReady || isSubmitting}
className='contents'
> >
<SideDrawerSection> <SideDrawerSection>
<FormField <FormField
...@@ -223,10 +296,12 @@ export function RedemptionsMutateDrawer({ ...@@ -223,10 +296,12 @@ export function RedemptionsMutateDrawer({
<Input <Input
{...field} {...field}
type='number' type='number'
step={tokensOnly ? 1 : 0.01} step={quotaStep}
placeholder={quotaPlaceholder} placeholder={quotaPlaceholder}
onChange={(e) => onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0) field.onChange(
Number.parseFloat(e.target.value) || 0
)
} }
/> />
</FormControl> </FormControl>
...@@ -314,12 +389,16 @@ export function RedemptionsMutateDrawer({ ...@@ -314,12 +389,16 @@ export function RedemptionsMutateDrawer({
max='100' max='100'
placeholder={t('Number of codes to create')} placeholder={t('Number of codes to create')}
onChange={(e) => onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 1) field.onChange(
Number.parseInt(e.target.value, 10) || 1
)
} }
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{t('Create multiple redemption codes at once (1-100)')} {t(
'Create multiple redemption codes at once (1-100)'
)}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
...@@ -327,14 +406,19 @@ export function RedemptionsMutateDrawer({ ...@@ -327,14 +406,19 @@ export function RedemptionsMutateDrawer({
/> />
)} )}
</SideDrawerSection> </SideDrawerSection>
</fieldset>
</form> </form>
</Form> </Form>
<SheetFooter className={sideDrawerFooterClassName()}> <SheetFooter className={sideDrawerFooterClassName()}>
<SheetClose render={<Button variant='outline' />}> <SheetClose render={<Button variant='outline' />}>
{t('Close')} {t('Close')}
</SheetClose> </SheetClose>
<Button form='redemption-form' type='submit' disabled={isSubmitting}> <Button
{isSubmitting ? t('Saving...') : t('Save changes')} form='redemption-form'
type='submit'
disabled={isSubmitting || !isUpdateReady}
>
{submitButtonLabel}
</Button> </Button>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
......
...@@ -19,13 +19,16 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -19,13 +19,16 @@ For commercial licensing, please contact support@quantumnous.com
import type { TFunction } from 'i18next' import type { TFunction } from 'i18next'
import { z } from 'zod' import { z } from 'zod'
import { parseQuotaFromDollars, quotaUnitsToDollars } from '@/lib/format' import {
parseQuotaFromDollars,
quotaUnitsToEditableAmount,
} from '@/lib/format'
import { import {
REDEMPTION_VALIDATION, REDEMPTION_VALIDATION,
getRedemptionFormErrorMessages, getRedemptionFormErrorMessages,
} from '../constants' } 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) // Form Schema (use getRedemptionFormSchema(t) in components for i18n messages)
...@@ -94,7 +97,7 @@ export function transformRedemptionToFormDefaults( ...@@ -94,7 +97,7 @@ export function transformRedemptionToFormDefaults(
): RedemptionFormValues { ): RedemptionFormValues {
return { return {
name: redemption.name, name: redemption.name,
quota_dollars: quotaUnitsToDollars(redemption.quota), quota_dollars: quotaUnitsToEditableAmount(redemption.quota),
expired_time: expired_time:
redemption.expired_time > 0 redemption.expired_time > 0
? new Date(redemption.expired_time * 1000) ? new Date(redemption.expired_time * 1000)
......
...@@ -244,6 +244,23 @@ function mergeOptions( ...@@ -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 { function removeTrailingZeros(str: string): string {
if (!str.includes('.')) return str if (!str.includes('.')) return str
return str.replace(/(\.[0-9]*?)0+$/, '$1').replace(/\.$/, '') return str.replace(/(\.[0-9]*?)0+$/, '$1').replace(/\.$/, '')
...@@ -261,7 +278,7 @@ function formatNumberWithSuffix( ...@@ -261,7 +278,7 @@ function formatNumberWithSuffix(
return `${removeTrailingZeros(result.toFixed(1))}k` return `${removeTrailingZeros(result.toFixed(1))}k`
} }
const digits = abs >= 1 ? digitsLarge : digitsSmall const digits = getFractionDigits(value, digitsLarge, digitsSmall)
return removeTrailingZeros(value.toFixed(digits)) return removeTrailingZeros(value.toFixed(digits))
} }
...@@ -300,8 +317,11 @@ function formatCurrencyValue( ...@@ -300,8 +317,11 @@ function formatCurrencyValue(
) )
} }
const digits = const digits = getFractionDigits(
Math.abs(value) >= 1 ? options.digitsLarge : options.digitsSmall value,
options.digitsLarge,
options.digitsSmall
)
const adjustedValue = adjustForMinimum(value, digits, options.minimumNonZero) const adjustedValue = adjustForMinimum(value, digits, options.minimumNonZero)
if (meta.kind === 'currency') { if (meta.kind === 'currency') {
......
...@@ -22,6 +22,7 @@ import { ...@@ -22,6 +22,7 @@ import {
formatCurrencyFromUSD, formatCurrencyFromUSD,
formatQuotaWithCurrency, formatQuotaWithCurrency,
getCurrencyDisplay, getCurrencyDisplay,
getCurrencyFractionDigits,
} from './currency' } from './currency'
// ============================================================================ // ============================================================================
...@@ -104,16 +105,44 @@ export function parseQuotaFromDollars(amount: number): number { ...@@ -104,16 +105,44 @@ export function parseQuotaFromDollars(amount: number): number {
*/ */
export function quotaUnitsToDollars(units: number): number { export function quotaUnitsToDollars(units: number): number {
const { config, meta } = getCurrencyDisplay() 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') { if (meta.kind === 'tokens') {
return units return units
} }
const usdAmount = units / config.quotaPerUnit return (units / quotaPerUnit) * meta.exchangeRate
const exchangeRate = }
meta.kind === 'currency' || meta.kind === 'custom' ? meta.exchangeRate : 1
/**
* 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