fix: round-2 audit fixes — security, code quality, and UI improvements
Security (HIGH): - Normalize all Redis cache keys to lowercase (verifyCode, passwordReset) - Fix verify code TTL renewal on failed attempts: use remaining TTL via ExpiresAt field instead of resetting to full 15-minute window - Add 3 missing fields to diffSettings audit log (promo_code, invitation_code, custom_endpoints) Code quality (MEDIUM): - Extract filterVerifiedEmails shared helper (balance_notify_service.go) - Add Pricing array non-empty validation for channel pricing rules - Add platform token semantics comment in gateway_service.go - Complete validatePlanPatch test coverage (+10 test cases) - Replace string types with QuotaThresholdType/QuotaResetMode across frontend - Remove duplicate getPlatformTextColor/getRateBadgeClass in ChannelsView - Return EMAIL_NOT_FOUND error on RemoveNotifyEmail miss UI improvements: - Reorder cost tooltip: user billing above separator, account billing below - Add NaN guard to accountBilled function - Move timezone selector inline into reset-mode row (no longer standalone)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import QuotaNotifyToggle from './QuotaNotifyToggle.vue'
|
||||
import type { QuotaThresholdType, QuotaResetMode } from '@/constants/account'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -11,9 +12,9 @@ const props = defineProps<{
|
||||
quotaNotifyGlobalEnabled: boolean
|
||||
notifyEnabled: boolean | null
|
||||
notifyThreshold: number | null
|
||||
notifyThresholdType: string | null
|
||||
notifyThresholdType: QuotaThresholdType | null
|
||||
// Reset mode (only for daily/weekly, null for total)
|
||||
resetMode: 'rolling' | 'fixed' | null
|
||||
resetMode: QuotaResetMode | null
|
||||
resetHour: number | null
|
||||
resetDay: number | null // weekly only
|
||||
resetTimezone: string | null
|
||||
@@ -22,14 +23,15 @@ const props = defineProps<{
|
||||
// Shared options passed from parent
|
||||
hourOptions: number[]
|
||||
dayOptions: { value: number; key: string }[]
|
||||
timezoneOptions?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:limit': [value: number | null]
|
||||
'update:notifyEnabled': [value: boolean | null]
|
||||
'update:notifyThreshold': [value: number | null]
|
||||
'update:notifyThresholdType': [value: string | null]
|
||||
'update:resetMode': [value: 'rolling' | 'fixed' | null]
|
||||
'update:notifyThresholdType': [value: QuotaThresholdType | null]
|
||||
'update:resetMode': [value: QuotaResetMode | null]
|
||||
'update:resetHour': [value: number | null]
|
||||
'update:resetDay': [value: number | null]
|
||||
'update:resetTimezone': [value: string | null]
|
||||
@@ -43,7 +45,7 @@ const onLimitInput = (e: Event) => {
|
||||
}
|
||||
|
||||
const onModeChange = (e: Event) => {
|
||||
const val = (e.target as HTMLSelectElement).value as 'rolling' | 'fixed'
|
||||
const val = (e.target as HTMLSelectElement).value as QuotaResetMode
|
||||
emit('update:resetMode', val)
|
||||
if (val === 'fixed') {
|
||||
if (props.resetHour == null) emit('update:resetHour', 0)
|
||||
@@ -51,6 +53,17 @@ const onModeChange = (e: Event) => {
|
||||
if (!props.resetTimezone) emit('update:resetTimezone', 'UTC')
|
||||
}
|
||||
}
|
||||
|
||||
function getTimezoneOffsetLabel(tz: string): string {
|
||||
try {
|
||||
const dtf = new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'shortOffset' })
|
||||
const parts = dtf.formatToParts(new Date())
|
||||
const tzPart = parts.find(p => p.type === 'timeZoneName')
|
||||
return tzPart ? (tzPart.value === 'GMT' ? 'GMT+0' : tzPart.value) : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -95,6 +108,11 @@ const onModeChange = (e: Event) => {
|
||||
<select :value="resetHour ?? 0" @change="emit('update:resetHour', Number(($event.target as HTMLSelectElement).value))" class="input py-1 text-xs w-24">
|
||||
<option v-for="h in hourOptions" :key="h" :value="h">{{ String(h).padStart(2, '0') }}:00</option>
|
||||
</select>
|
||||
<template v-if="timezoneOptions && timezoneOptions.length > 0">
|
||||
<select :value="resetTimezone || 'UTC'" @change="emit('update:resetTimezone', ($event.target as HTMLSelectElement).value)" class="input py-1 text-xs w-auto">
|
||||
<option v-for="tz in timezoneOptions" :key="tz" :value="tz">{{ tz }} ({{ getTimezoneOffsetLabel(tz) }})</option>
|
||||
</select>
|
||||
</template>
|
||||
</template>
|
||||
<span class="text-[11px] text-gray-500 dark:text-gray-400">
|
||||
<template v-if="resetMode === 'fixed'">{{ hintFixed }}</template>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import QuotaDimensionRow from './QuotaDimensionRow.vue'
|
||||
import type { QuotaThresholdType, QuotaResetMode } from '@/constants/account'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -9,22 +10,22 @@ const props = withDefaults(defineProps<{
|
||||
totalLimit: number | null
|
||||
dailyLimit: number | null
|
||||
weeklyLimit: number | null
|
||||
dailyResetMode: 'rolling' | 'fixed' | null
|
||||
dailyResetMode: QuotaResetMode | null
|
||||
dailyResetHour: number | null
|
||||
weeklyResetMode: 'rolling' | 'fixed' | null
|
||||
weeklyResetMode: QuotaResetMode | null
|
||||
weeklyResetDay: number | null
|
||||
weeklyResetHour: number | null
|
||||
resetTimezone: string | null
|
||||
quotaNotifyGlobalEnabled?: boolean
|
||||
quotaNotifyDailyEnabled?: boolean | null
|
||||
quotaNotifyDailyThreshold?: number | null
|
||||
quotaNotifyDailyThresholdType?: string | null
|
||||
quotaNotifyDailyThresholdType?: QuotaThresholdType | null
|
||||
quotaNotifyWeeklyEnabled?: boolean | null
|
||||
quotaNotifyWeeklyThreshold?: number | null
|
||||
quotaNotifyWeeklyThresholdType?: string | null
|
||||
quotaNotifyWeeklyThresholdType?: QuotaThresholdType | null
|
||||
quotaNotifyTotalEnabled?: boolean | null
|
||||
quotaNotifyTotalThreshold?: number | null
|
||||
quotaNotifyTotalThresholdType?: string | null
|
||||
quotaNotifyTotalThresholdType?: QuotaThresholdType | null
|
||||
}>(), {
|
||||
quotaNotifyGlobalEnabled: false,
|
||||
quotaNotifyDailyEnabled: null,
|
||||
@@ -42,21 +43,21 @@ const emit = defineEmits<{
|
||||
'update:totalLimit': [value: number | null]
|
||||
'update:dailyLimit': [value: number | null]
|
||||
'update:weeklyLimit': [value: number | null]
|
||||
'update:dailyResetMode': [value: 'rolling' | 'fixed' | null]
|
||||
'update:dailyResetMode': [value: QuotaResetMode | null]
|
||||
'update:dailyResetHour': [value: number | null]
|
||||
'update:weeklyResetMode': [value: 'rolling' | 'fixed' | null]
|
||||
'update:weeklyResetMode': [value: QuotaResetMode | null]
|
||||
'update:weeklyResetDay': [value: number | null]
|
||||
'update:weeklyResetHour': [value: number | null]
|
||||
'update:resetTimezone': [value: string | null]
|
||||
'update:quotaNotifyDailyEnabled': [value: boolean | null]
|
||||
'update:quotaNotifyDailyThreshold': [value: number | null]
|
||||
'update:quotaNotifyDailyThresholdType': [value: string | null]
|
||||
'update:quotaNotifyDailyThresholdType': [value: QuotaThresholdType | null]
|
||||
'update:quotaNotifyWeeklyEnabled': [value: boolean | null]
|
||||
'update:quotaNotifyWeeklyThreshold': [value: number | null]
|
||||
'update:quotaNotifyWeeklyThresholdType': [value: string | null]
|
||||
'update:quotaNotifyWeeklyThresholdType': [value: QuotaThresholdType | null]
|
||||
'update:quotaNotifyTotalEnabled': [value: boolean | null]
|
||||
'update:quotaNotifyTotalThreshold': [value: number | null]
|
||||
'update:quotaNotifyTotalThresholdType': [value: string | null]
|
||||
'update:quotaNotifyTotalThresholdType': [value: QuotaThresholdType | null]
|
||||
}>()
|
||||
|
||||
const enabled = computed(() =>
|
||||
@@ -89,11 +90,6 @@ watch(localEnabled, (val) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Whether any fixed mode is active (to show timezone selector)
|
||||
const hasFixedMode = computed(() =>
|
||||
props.dailyResetMode === 'fixed' || props.weeklyResetMode === 'fixed'
|
||||
)
|
||||
|
||||
// Common timezone options
|
||||
const timezoneOptions = [
|
||||
'UTC', 'Asia/Shanghai', 'Asia/Tokyo', 'Asia/Seoul', 'Asia/Singapore', 'Asia/Kolkata',
|
||||
@@ -102,18 +98,6 @@ const timezoneOptions = [
|
||||
'America/Sao_Paulo', 'Australia/Sydney', 'Pacific/Auckland',
|
||||
]
|
||||
|
||||
// Compute GMT offset label (e.g. "GMT+8", "GMT-5") for a given IANA timezone.
|
||||
function getTimezoneOffsetLabel(tz: string): string {
|
||||
try {
|
||||
const dtf = new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'shortOffset' })
|
||||
const parts = dtf.formatToParts(new Date())
|
||||
const tzPart = parts.find(p => p.type === 'timeZoneName')
|
||||
return tzPart ? (tzPart.value === 'GMT' ? 'GMT+0' : tzPart.value) : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// Hours for dropdown (0-23)
|
||||
const hourOptions = Array.from({ length: 24 }, (_, i) => i)
|
||||
|
||||
@@ -197,6 +181,7 @@ const dailyFixedHint = computed(() =>
|
||||
:hint-fixed="dailyFixedHint"
|
||||
:hour-options="hourOptions"
|
||||
:day-options="dayOptions"
|
||||
:timezone-options="timezoneOptions"
|
||||
@update:limit="emit('update:dailyLimit', $event)"
|
||||
@update:notify-enabled="emit('update:quotaNotifyDailyEnabled', $event)"
|
||||
@update:notify-threshold="emit('update:quotaNotifyDailyThreshold', $event)"
|
||||
@@ -223,6 +208,7 @@ const dailyFixedHint = computed(() =>
|
||||
:hint-fixed="weeklyFixedHint"
|
||||
:hour-options="hourOptions"
|
||||
:day-options="dayOptions"
|
||||
:timezone-options="timezoneOptions"
|
||||
@update:limit="emit('update:weeklyLimit', $event)"
|
||||
@update:notify-enabled="emit('update:quotaNotifyWeeklyEnabled', $event)"
|
||||
@update:notify-threshold="emit('update:quotaNotifyWeeklyThreshold', $event)"
|
||||
@@ -233,14 +219,6 @@ const dailyFixedHint = computed(() =>
|
||||
@update:reset-timezone="emit('update:resetTimezone', $event)"
|
||||
/>
|
||||
|
||||
<!-- Timezone selector (shared by daily/weekly when fixed mode is active) -->
|
||||
<div v-if="hasFixedMode">
|
||||
<label class="input-label">{{ t('admin.accounts.quotaResetTimezone') }}</label>
|
||||
<select :value="resetTimezone || 'UTC'" @change="emit('update:resetTimezone', ($event.target as HTMLSelectElement).value)" class="input text-sm">
|
||||
<option v-for="tz in timezoneOptions" :key="tz" :value="tz">{{ tz }} ({{ getTimezoneOffsetLabel(tz) }})</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Total quota -->
|
||||
<QuotaDimensionRow
|
||||
dim="total"
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { QUOTA_THRESHOLD_TYPE_FIXED, QUOTA_THRESHOLD_TYPE_PERCENTAGE } from '@/constants/account'
|
||||
import { QUOTA_THRESHOLD_TYPE_FIXED, QUOTA_THRESHOLD_TYPE_PERCENTAGE, type QuotaThresholdType } from '@/constants/account'
|
||||
|
||||
defineProps<{
|
||||
enabled: boolean | null
|
||||
threshold: number | null
|
||||
thresholdType: string | null // "fixed" (default) or "percentage"
|
||||
thresholdType: QuotaThresholdType | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:enabled': [value: boolean | null]
|
||||
'update:threshold': [value: number | null]
|
||||
'update:thresholdType': [value: string | null]
|
||||
'update:thresholdType': [value: QuotaThresholdType | null]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -43,7 +43,7 @@ const emit = defineEmits<{
|
||||
/>
|
||||
<select
|
||||
:value="thresholdType || QUOTA_THRESHOLD_TYPE_FIXED"
|
||||
@change="emit('update:thresholdType', ($event.target as HTMLSelectElement).value)"
|
||||
@change="emit('update:thresholdType', ($event.target as HTMLSelectElement).value as QuotaThresholdType)"
|
||||
class="input py-1 text-xs w-[4.5rem] flex-shrink-0 text-center"
|
||||
>
|
||||
<option :value="QUOTA_THRESHOLD_TYPE_FIXED">$</option>
|
||||
|
||||
@@ -313,10 +313,6 @@
|
||||
<span class="text-gray-400">{{ t('usage.rate') }}</span>
|
||||
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.rate_multiplier || 1) }}x</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
|
||||
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.account_rate_multiplier ?? 1) }}x</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-gray-400">{{ t('usage.original') }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
|
||||
@@ -325,7 +321,12 @@
|
||||
<span class="text-gray-400">{{ t('usage.userBilled') }}</span>
|
||||
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span>
|
||||
</div>
|
||||
<!-- Account billing (separated from user billing) -->
|
||||
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
|
||||
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
|
||||
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.account_rate_multiplier ?? 1) }}x</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-gray-400">{{ t('usage.accountBilled') }}</span>
|
||||
<span class="font-semibold text-green-400">
|
||||
${{ accountBilled({
|
||||
@@ -355,7 +356,8 @@ import { getBillingModeLabel, getBillingModeBadgeClass, BILLING_MODE_TOKEN, BILL
|
||||
/** Compute the account-billed cost for display: (account_stats_cost ?? total_cost) * rate_multiplier */
|
||||
function accountBilled(row: { total_cost?: number | null; account_stats_cost?: number | null; account_rate_multiplier?: number | null }): number {
|
||||
const base = row.account_stats_cost != null ? row.account_stats_cost : (row.total_cost ?? 0)
|
||||
return base * (row.account_rate_multiplier ?? 1)
|
||||
const result = base * (row.account_rate_multiplier ?? 1)
|
||||
return Number.isNaN(result) ? 0 : result
|
||||
}
|
||||
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { reactive, ref } from 'vue'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { QUOTA_THRESHOLD_TYPE_FIXED } from '@/constants/account'
|
||||
import { QUOTA_THRESHOLD_TYPE_FIXED, type QuotaThresholdType } from '@/constants/account'
|
||||
|
||||
export const QUOTA_NOTIFY_DIMS = ['daily', 'weekly', 'total'] as const
|
||||
export type QuotaNotifyDim = (typeof QUOTA_NOTIFY_DIMS)[number]
|
||||
@@ -8,7 +8,7 @@ export type QuotaNotifyDim = (typeof QUOTA_NOTIFY_DIMS)[number]
|
||||
interface DimState {
|
||||
enabled: boolean | null
|
||||
threshold: number | null
|
||||
thresholdType: string | null
|
||||
thresholdType: QuotaThresholdType | null
|
||||
}
|
||||
|
||||
export function useQuotaNotifyState() {
|
||||
@@ -34,7 +34,7 @@ export function useQuotaNotifyState() {
|
||||
for (const d of QUOTA_NOTIFY_DIMS) {
|
||||
state[d].enabled = (extra?.[`quota_notify_${d}_enabled`] as boolean) ?? null
|
||||
state[d].threshold = (extra?.[`quota_notify_${d}_threshold`] as number) ?? null
|
||||
state[d].thresholdType = (extra?.[`quota_notify_${d}_threshold_type`] as string) ?? null
|
||||
state[d].thresholdType = (extra?.[`quota_notify_${d}_threshold_type`] as QuotaThresholdType) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,3 +8,8 @@ export type WebSearchMode = typeof WEB_SEARCH_MODE_DEFAULT | typeof WEB_SEARCH_M
|
||||
export const QUOTA_THRESHOLD_TYPE_FIXED = 'fixed' as const
|
||||
export const QUOTA_THRESHOLD_TYPE_PERCENTAGE = 'percentage' as const
|
||||
export type QuotaThresholdType = typeof QUOTA_THRESHOLD_TYPE_FIXED | typeof QUOTA_THRESHOLD_TYPE_PERCENTAGE
|
||||
|
||||
/** Quota reset mode values */
|
||||
export const QUOTA_RESET_MODE_ROLLING = 'rolling' as const
|
||||
export const QUOTA_RESET_MODE_FIXED = 'fixed' as const
|
||||
export type QuotaResetMode = typeof QUOTA_RESET_MODE_ROLLING | typeof QUOTA_RESET_MODE_FIXED
|
||||
|
||||
@@ -166,8 +166,8 @@
|
||||
class="channel-tab group"
|
||||
:class="activeTab === section.platform ? 'channel-tab-active' : 'channel-tab-inactive'"
|
||||
>
|
||||
<PlatformIcon :platform="section.platform" size="xs" :class="getPlatformTextColor(section.platform)" />
|
||||
<span :class="getPlatformTextColor(section.platform)">{{ t('admin.groups.platforms.' + section.platform, section.platform) }}</span>
|
||||
<PlatformIcon :platform="section.platform" size="xs" :class="platformTextClass(section.platform)" />
|
||||
<span :class="platformTextClass(section.platform)">{{ t('admin.groups.platforms.' + section.platform, section.platform) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -246,8 +246,8 @@
|
||||
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
@change="togglePlatform(p)"
|
||||
/>
|
||||
<PlatformIcon :platform="p" size="xs" :class="getPlatformTextColor(p)" />
|
||||
<span :class="getPlatformTextColor(p)">{{ t('admin.groups.platforms.' + p, p) }}</span>
|
||||
<PlatformIcon :platform="p" size="xs" :class="platformTextClass(p)" />
|
||||
<span :class="platformTextClass(p)">{{ t('admin.groups.platforms.' + p, p) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -310,9 +310,9 @@
|
||||
class="h-3 w-3 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
@change="toggleGroupInSection(sIdx, group.id)"
|
||||
/>
|
||||
<span :class="['font-medium', getPlatformTextColor(group.platform)]">{{ group.name }}</span>
|
||||
<span :class="['font-medium', platformTextClass(group.platform)]">{{ group.name }}</span>
|
||||
<span
|
||||
:class="['rounded-full px-1 py-0 text-[10px]', getRateBadgeClass(group.platform)]"
|
||||
:class="['rounded-full px-1 py-0 text-[10px]', platformBadgeLightClass(group.platform)]"
|
||||
>{{ group.rate_multiplier }}x</span>
|
||||
<span class="text-[10px] text-gray-400">{{ group.account_count || 0 }}</span>
|
||||
<span
|
||||
@@ -363,7 +363,7 @@
|
||||
:value="srcModel"
|
||||
type="text"
|
||||
class="input flex-1 text-xs"
|
||||
:class="getPlatformTextColor(section.platform)"
|
||||
:class="platformTextClass(section.platform)"
|
||||
:placeholder="t('admin.channels.form.mappingSource', 'Source model')"
|
||||
@change="renameMappingKey(sIdx, srcModel, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
@@ -372,7 +372,7 @@
|
||||
:value="section.model_mapping[srcModel]"
|
||||
type="text"
|
||||
class="input flex-1 text-xs"
|
||||
:class="getPlatformTextColor(section.platform)"
|
||||
:class="platformTextClass(section.platform)"
|
||||
:placeholder="t('admin.channels.form.mappingTarget', 'Target model')"
|
||||
@input="section.model_mapping[srcModel] = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
@@ -464,7 +464,7 @@
|
||||
: 'border-gray-200 hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700'"
|
||||
>
|
||||
<input type="checkbox" :checked="rule.group_ids.includes(gid)" class="h-3 w-3 rounded border-gray-300 text-primary-600 focus:ring-primary-500" @change="rule.group_ids.includes(gid) ? rule.group_ids.splice(rule.group_ids.indexOf(gid), 1) : rule.group_ids.push(gid)" />
|
||||
<span>{{ getGroupNameById(gid) }}</span>
|
||||
<span :class="['font-medium', platformTextClass(section.platform)]">{{ getGroupNameById(gid) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="section.group_ids.length === 0" class="mt-1 text-xs text-gray-400">
|
||||
@@ -481,7 +481,7 @@
|
||||
:key="accountId"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-primary-300 bg-primary-50 px-2 py-0.5 text-xs dark:border-primary-700 dark:bg-primary-900/20"
|
||||
>
|
||||
<span>{{ getRuleAccountLabel(accountId) }}</span>
|
||||
<span :class="['font-medium', platformTextClass(section.platform)]">{{ getRuleAccountLabel(accountId) }}</span>
|
||||
<button type="button" @click="removeRuleAccount(rule, accountId)" class="text-gray-400 hover:text-red-500">
|
||||
<Icon name="x" size="xs" />
|
||||
</button>
|
||||
@@ -595,7 +595,7 @@ import type { PricingFormEntry } from '@/components/admin/channel/types'
|
||||
import { mTokToPerToken, perTokenToMTok, apiIntervalsToForm, formIntervalsToAPI, findModelConflict, validateIntervals } from '@/components/admin/channel/types'
|
||||
import type { AdminGroup, GroupPlatform } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import { platformTextClass } from '@/utils/platformColors'
|
||||
import { platformTextClass, platformBadgeLightClass } from '@/utils/platformColors'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
@@ -720,26 +720,6 @@ let abortController: AbortController | null = null
|
||||
// ── Platform config ──
|
||||
const platformOrder: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity']
|
||||
|
||||
function getPlatformTextColor(platform: string): string {
|
||||
switch (platform) {
|
||||
case 'anthropic': return 'text-orange-600 dark:text-orange-400'
|
||||
case 'openai': return 'text-emerald-600 dark:text-emerald-400'
|
||||
case 'gemini': return 'text-blue-600 dark:text-blue-400'
|
||||
case 'antigravity': return 'text-purple-600 dark:text-purple-400'
|
||||
default: return 'text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
function getRateBadgeClass(platform: string): string {
|
||||
switch (platform) {
|
||||
case 'anthropic': return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||
case 'openai': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
case 'gemini': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
case 'antigravity': return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
function formatDate(value: string): string {
|
||||
if (!value) return '-'
|
||||
|
||||
Reference in New Issue
Block a user