fix: harden unset price models tab batch copy, feedback, and memo equality

Persist committed editor drafts to the source model during batch copy so
targets never carry pricing the source would lose, surface loading/error
states for the enabled-models query, and compare saved* props in the
visual editor memo so rows leave the unset list reliably after save.
This commit is contained in:
CaIon
2026-07-11 22:57:22 +08:00
parent 8283df169e
commit bde9b2f448
9 changed files with 63 additions and 18 deletions
@@ -18,9 +18,10 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery } from '@tanstack/react-query'
import { Code2, Eye, RotateCcw, Save } from 'lucide-react'
import { memo, useCallback, useRef, useState } from 'react'
import { type UseFormReturn } from 'react-hook-form'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import type { UseFormReturn } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { JsonCodeEditor } from '@/components/json-code-editor'
import { Button } from '@/components/ui/button'
@@ -180,6 +181,18 @@ export const ModelRatioForm = memo(function ModelRatioForm({
enabled: isUnsetVariant,
})
const enabledModelsError = isUnsetVariant
? enabledModelsQuery.isError ||
(enabledModelsQuery.data !== undefined &&
!enabledModelsQuery.data.success)
: false
const enabledModelsErrorMessage = enabledModelsQuery.data?.message
useEffect(() => {
if (!enabledModelsError) return
toast.error(enabledModelsErrorMessage || t('Failed to load enabled models'))
}, [enabledModelsError, enabledModelsErrorMessage, t])
const handleFieldChange = useCallback(
(field: keyof ModelFormValues, value: string) => {
form.setValue(field, value, {
@@ -272,6 +285,9 @@ export const ModelRatioForm = memo(function ModelRatioForm({
candidateModelNames={
isUnsetVariant ? enabledModelsQuery.data?.data : undefined
}
candidateModelsLoading={
isUnsetVariant && enabledModelsQuery.isLoading
}
filterMode={isUnsetVariant ? 'unset' : 'all'}
onSave={handleSave}
isSaving={isSaving}
@@ -16,13 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import {
type ColumnFiltersState,
type OnChangeFn,
type PaginationState,
type RowSelectionState,
type VisibilityState,
type SortingState,
import type {
ColumnFiltersState,
OnChangeFn,
PaginationState,
RowSelectionState,
VisibilityState,
SortingState,
} from '@tanstack/react-table'
import { Copy, Plus } from 'lucide-react'
import {
@@ -51,6 +51,7 @@ import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
import { useMediaQuery } from '@/hooks'
import { safeJsonParse } from '../utils/json-parser'
import type { PricingMode } from './model-pricing-core'
import {
ModelPricingEditorPanel,
type ModelPricingEditorPanelHandle,
@@ -87,6 +88,7 @@ type ModelRatioVisualEditorProps = {
billingMode: string
billingExpr: string
candidateModelNames?: string[]
candidateModelsLoading?: boolean
filterMode?: 'all' | 'unset'
onChange: (field: string, value: string) => void
onSave: () => void | Promise<void>
@@ -125,6 +127,7 @@ const ModelRatioVisualEditorComponent = forwardRef<
billingMode,
billingExpr,
candidateModelNames,
candidateModelsLoading,
filterMode = 'all',
onChange,
onSave,
@@ -219,7 +222,7 @@ const ModelRatioVisualEditorComponent = forwardRef<
...draftByName.keys(),
])
return Array.from(modelNames)
return [...modelNames]
.map((name) => {
const saved = savedByName.get(name)
const draft = draftByName.get(name)
@@ -289,6 +292,12 @@ const ModelRatioVisualEditorComponent = forwardRef<
const handleEdit = useCallback(
(model: ModelRow) => {
const editableModel = model.draft ?? model.saved ?? model
let editBillingMode: PricingMode = 'per-token'
if (editableModel.billingMode === 'tiered_expr') {
editBillingMode = 'tiered_expr'
} else if (editableModel.price && editableModel.price !== '') {
editBillingMode = 'per-request'
}
setEditData({
name: editableModel.name,
price: editableModel.price,
@@ -299,12 +308,7 @@ const ModelRatioVisualEditorComponent = forwardRef<
imageRatio: editableModel.imageRatio,
audioRatio: editableModel.audioRatio,
audioCompletionRatio: editableModel.audioCompletionRatio,
billingMode:
editableModel.billingMode === 'tiered_expr'
? 'tiered_expr'
: editableModel.price && editableModel.price !== ''
? 'per-request'
: 'per-token',
billingMode: editBillingMode,
billingExpr: editableModel.billingExpr,
requestRuleExpr: editableModel.requestRuleExpr,
})
@@ -632,7 +636,11 @@ const ModelRatioVisualEditorComponent = forwardRef<
return
}
persistPricingData(sourceData, targetNames)
// Persist to the source model too, so targets never carry pricing the
// source itself would lose if the editor draft were abandoned.
persistPricingData(sourceData, [
...new Set([sourceData.name, ...targetNames]),
])
table.resetRowSelection()
toast.success(
t('Applied {{name}} pricing to {{count}} models', {
@@ -663,7 +671,9 @@ const ModelRatioVisualEditorComponent = forwardRef<
if (table.getState().globalFilter) {
emptyStateText = t('No models match your search')
} else if (filterMode === 'unset') {
emptyStateText = t('No models with unset prices')
emptyStateText = candidateModelsLoading
? t('Loading...')
: t('No models with unset prices')
}
return (
@@ -821,6 +831,17 @@ export const ModelRatioVisualEditor = memo(
// Custom equality check - only re-render if JSON props actually changed
(prevProps, nextProps) => {
return (
prevProps.savedModelPrice === nextProps.savedModelPrice &&
prevProps.savedModelRatio === nextProps.savedModelRatio &&
prevProps.savedCacheRatio === nextProps.savedCacheRatio &&
prevProps.savedCreateCacheRatio === nextProps.savedCreateCacheRatio &&
prevProps.savedCompletionRatio === nextProps.savedCompletionRatio &&
prevProps.savedImageRatio === nextProps.savedImageRatio &&
prevProps.savedAudioRatio === nextProps.savedAudioRatio &&
prevProps.savedAudioCompletionRatio ===
nextProps.savedAudioCompletionRatio &&
prevProps.savedBillingMode === nextProps.savedBillingMode &&
prevProps.savedBillingExpr === nextProps.savedBillingExpr &&
prevProps.modelPrice === nextProps.modelPrice &&
prevProps.modelRatio === nextProps.modelRatio &&
prevProps.cacheRatio === nextProps.cacheRatio &&
@@ -832,6 +853,7 @@ export const ModelRatioVisualEditor = memo(
prevProps.billingMode === nextProps.billingMode &&
prevProps.billingExpr === nextProps.billingExpr &&
prevProps.candidateModelNames === nextProps.candidateModelNames &&
prevProps.candidateModelsLoading === nextProps.candidateModelsLoading &&
prevProps.filterMode === nextProps.filterMode &&
prevProps.onChange === nextProps.onChange &&
prevProps.onSave === nextProps.onSave &&
+1
View File
@@ -1845,6 +1845,7 @@
"Failed to load": "Failed to load",
"Failed to load API keys": "Failed to load API keys",
"Failed to load billing history": "Failed to load billing history",
"Failed to load enabled models": "Failed to load enabled models",
"Failed to load home page content": "Failed to load home page content",
"Failed to load image": "Failed to load image",
"Failed to load key status": "Failed to load key status",
+1
View File
@@ -1845,6 +1845,7 @@
"Failed to load": "Échec du chargement",
"Failed to load API keys": "Échec du chargement des Clés API",
"Failed to load billing history": "Échec du chargement de l'historique de facturation",
"Failed to load enabled models": "Échec du chargement des modèles activés",
"Failed to load home page content": "Échec du chargement du contenu de la page d'accueil",
"Failed to load image": "Échec du chargement de l'image",
"Failed to load key status": "Échec du chargement du statut des clés",
+1
View File
@@ -1845,6 +1845,7 @@
"Failed to load": "読み込みに失敗しました",
"Failed to load API keys": "APIキーの読み込みに失敗しました",
"Failed to load billing history": "請求履歴の読み込みに失敗しました",
"Failed to load enabled models": "有効なモデルの取得に失敗しました",
"Failed to load home page content": "ホームページの内容の読み込みに失敗しました",
"Failed to load image": "画像の読み込みに失敗しました",
"Failed to load key status": "キー状態の読み込みに失敗しました",
+1
View File
@@ -1845,6 +1845,7 @@
"Failed to load": "Не удалось загрузить",
"Failed to load API keys": "Не удалось загрузить API ключи",
"Failed to load billing history": "Не удалось загрузить историю платежей",
"Failed to load enabled models": "Не удалось загрузить включённые модели",
"Failed to load home page content": "Не удалось загрузить содержимое главной страницы",
"Failed to load image": "Не удалось загрузить изображение",
"Failed to load key status": "Не удалось загрузить статус ключей",
+1
View File
@@ -1845,6 +1845,7 @@
"Failed to load": "Tải thất bại",
"Failed to load API keys": "Không thể tải khóa API",
"Failed to load billing history": "Không thể tải lịch sử thanh toán",
"Failed to load enabled models": "Không thể tải các mô hình đã bật",
"Failed to load home page content": "Không thể tải nội dung trang chủ",
"Failed to load image": "Không thể tải ảnh",
"Failed to load key status": "Không thể tải trạng thái khóa",
+1
View File
@@ -1845,6 +1845,7 @@
"Failed to load": "載入失敗",
"Failed to load API keys": "載入 API 金鑰失敗",
"Failed to load billing history": "載入收費歷史失敗",
"Failed to load enabled models": "獲取啟用模型失敗",
"Failed to load home page content": "載入首頁內容失敗",
"Failed to load image": "無法載入圖像",
"Failed to load key status": "載入金鑰狀態失敗",
+1
View File
@@ -1845,6 +1845,7 @@
"Failed to load": "加载失败",
"Failed to load API keys": "加载 API 密钥失败",
"Failed to load billing history": "加载计费历史失败",
"Failed to load enabled models": "获取启用模型失败",
"Failed to load home page content": "加载首页内容失败",
"Failed to load image": "无法加载图像",
"Failed to load key status": "加载密钥状态失败",