mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-03 04:43:01 +00:00
refactor(web): rework usage-logs date range picker and unify status row styling
- Replace native datetime-local inputs with a shadcn Calendar range picker: preset rail (today/7d/this~last week/30d/this~last month), dual-month desktop view, themed time inputs, fresh-range reselect, no outside days - Default log time range now covers the whole current day (00:00-23:59:59) - Fix range highlight invisible inside popovers (muted -> accent) and mark today by primary text instead of a fill that mimicked selection - Extract shared calendar locale map (adds zh-TW) for all date pickers - Generalize disabled-row treatment into status row palettes (error/info/ warning) and apply them to usage-logs desktop rows and mobile cards - Split "Earned this month" from "This month" i18n key to stop the check-in card translation leaking into date presets
This commit is contained in:
+27
-4
@@ -80,8 +80,31 @@ export {
|
||||
} from './hooks/use-data-table-view-mode'
|
||||
export { useDebouncedColumnFilter } from './hooks/use-debounced-column-filter'
|
||||
|
||||
export const DISABLED_ROW_DESKTOP =
|
||||
'[--data-table-card-bg:var(--table-disabled)] hover:[--data-table-card-bg:var(--table-disabled-hover)] data-[state=selected]:![--data-table-card-bg:var(--table-disabled)] data-[state=selected]:hover:![--data-table-card-bg:var(--table-disabled-hover)] [background-color:var(--table-disabled)] hover:[background-color:var(--table-disabled-hover)] [&>td:first-child]:[border-left-color:var(--table-disabled-border)] [&>td:first-child]:border-l-4 [&>td:first-child]:pl-1'
|
||||
// Shared "status row" treatment: tinted background with a hover step and a
|
||||
// 4px accent stripe on the first cell (desktop), tinted background only
|
||||
// (mobile cards). The palette classes below just bind the three CSS
|
||||
// variables, so every status row is structurally identical — only the hue
|
||||
// changes (disabled = gray, error = red, info = blue, warning = amber).
|
||||
const STATUS_ROW_DESKTOP =
|
||||
'[background-color:var(--status-row-bg)] hover:[background-color:var(--status-row-bg-hover)] [--data-table-card-bg:var(--status-row-bg)] hover:[--data-table-card-bg:var(--status-row-bg-hover)] data-[state=selected]:![--data-table-card-bg:var(--status-row-bg)] data-[state=selected]:hover:![--data-table-card-bg:var(--status-row-bg-hover)] [&>td:first-child]:[border-left-color:var(--status-row-border)] [&>td:first-child]:border-l-4 [&>td:first-child]:pl-1'
|
||||
|
||||
export const DISABLED_ROW_MOBILE =
|
||||
'[--data-table-card-bg:var(--table-disabled)] data-[state=selected]:![--data-table-card-bg:var(--table-disabled)] [background-color:var(--table-disabled)]'
|
||||
const STATUS_ROW_MOBILE =
|
||||
'[background-color:var(--status-row-bg)] [--data-table-card-bg:var(--status-row-bg)] data-[state=selected]:![--data-table-card-bg:var(--status-row-bg)]'
|
||||
|
||||
const DISABLED_PALETTE =
|
||||
'[--status-row-bg:var(--table-disabled)] [--status-row-bg-hover:var(--table-disabled-hover)] [--status-row-border:var(--table-disabled-border)]'
|
||||
const ERROR_PALETTE =
|
||||
'[--status-row-bg:var(--table-error)] [--status-row-bg-hover:var(--table-error-hover)] [--status-row-border:var(--table-error-border)]'
|
||||
const INFO_PALETTE =
|
||||
'[--status-row-bg:var(--table-info)] [--status-row-bg-hover:var(--table-info-hover)] [--status-row-border:var(--table-info-border)]'
|
||||
const WARNING_PALETTE =
|
||||
'[--status-row-bg:var(--table-warning)] [--status-row-bg-hover:var(--table-warning-hover)] [--status-row-border:var(--table-warning-border)]'
|
||||
|
||||
export const DISABLED_ROW_DESKTOP = `${STATUS_ROW_DESKTOP} ${DISABLED_PALETTE}`
|
||||
export const DISABLED_ROW_MOBILE = `${STATUS_ROW_MOBILE} ${DISABLED_PALETTE}`
|
||||
export const ERROR_ROW_DESKTOP = `${STATUS_ROW_DESKTOP} ${ERROR_PALETTE}`
|
||||
export const ERROR_ROW_MOBILE = `${STATUS_ROW_MOBILE} ${ERROR_PALETTE}`
|
||||
export const INFO_ROW_DESKTOP = `${STATUS_ROW_DESKTOP} ${INFO_PALETTE}`
|
||||
export const INFO_ROW_MOBILE = `${STATUS_ROW_MOBILE} ${INFO_PALETTE}`
|
||||
export const WARNING_ROW_DESKTOP = `${STATUS_ROW_DESKTOP} ${WARNING_PALETTE}`
|
||||
export const WARNING_ROW_MOBILE = `${STATUS_ROW_MOBILE} ${WARNING_PALETTE}`
|
||||
|
||||
+2
-12
@@ -17,7 +17,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Calendar as CalendarIcon } from 'lucide-react'
|
||||
import { enUS, fr, ja, ru, vi, zhCN } from 'react-day-picker/locale'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
@@ -27,17 +26,9 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { getCalendarLocale } from '@/lib/calendar-locale'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
|
||||
const calendarLocales = {
|
||||
en: enUS,
|
||||
zh: zhCN,
|
||||
fr,
|
||||
ru,
|
||||
ja,
|
||||
vi,
|
||||
} as const
|
||||
|
||||
type DatePickerProps = {
|
||||
selected: Date | undefined
|
||||
onSelect: (date: Date | undefined) => void
|
||||
@@ -51,8 +42,7 @@ export function DatePicker({
|
||||
}: DatePickerProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const placeholderText = placeholder ?? t('Pick a date')
|
||||
const calendarLocale =
|
||||
calendarLocales[i18n.language as keyof typeof calendarLocales] ?? enUS
|
||||
const calendarLocale = getCalendarLocale(i18n.language)
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
|
||||
+2
-12
@@ -18,7 +18,6 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { ChevronDownIcon } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
import { enUS, fr, ja, ru, vi, zhCN } from 'react-day-picker/locale'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
@@ -29,18 +28,10 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { getCalendarLocale } from '@/lib/calendar-locale'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const calendarLocales = {
|
||||
en: enUS,
|
||||
zh: zhCN,
|
||||
fr,
|
||||
ru,
|
||||
ja,
|
||||
vi,
|
||||
} as const
|
||||
|
||||
interface DateTimePickerProps {
|
||||
value?: Date
|
||||
onChange?: (date: Date | undefined) => void
|
||||
@@ -56,8 +47,7 @@ export function DateTimePicker({
|
||||
}: DateTimePickerProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const placeholderText = placeholder ?? t('Select date')
|
||||
const calendarLocale =
|
||||
calendarLocales[i18n.language as keyof typeof calendarLocales] ?? enUS
|
||||
const calendarLocale = getCalendarLocale(i18n.language)
|
||||
const currentYear = new Date().getFullYear()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [date, setDate] = React.useState<Date | undefined>(value)
|
||||
|
||||
+8
-4
@@ -131,16 +131,20 @@ function Calendar({
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
'relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted',
|
||||
// bg-accent (not bg-muted): muted matches the popover surface, so
|
||||
// the range highlight would be invisible inside popover calendars.
|
||||
'relative isolate z-0 rounded-l-(--cell-radius) bg-accent after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-accent',
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn('rounded-none', defaultClassNames.range_middle),
|
||||
range_end: cn(
|
||||
'relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted',
|
||||
'relative isolate z-0 rounded-r-(--cell-radius) bg-accent after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-accent',
|
||||
defaultClassNames.range_end
|
||||
),
|
||||
today: cn(
|
||||
'rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none',
|
||||
// Mark today with primary-colored text only; a filled background
|
||||
// would be indistinguishable from the accent range highlight.
|
||||
'text-primary',
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
@@ -245,7 +249,7 @@ function CalendarDayButton({
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
'relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70',
|
||||
'relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70',
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -332,7 +332,7 @@ export function CheckinCalendarCard({
|
||||
{formatQuotaWithCurrency(monthlyQuota, { digitsLarge: 0 })}
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-0.5 truncate text-xs'>
|
||||
{t('This month')}
|
||||
{t('Earned this month')}
|
||||
</div>
|
||||
</div>
|
||||
<div className='min-w-0 px-3 py-3 text-center sm:py-4'>
|
||||
|
||||
+180
-106
@@ -18,15 +18,19 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { CalendarDays } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { DateRange } from 'react-day-picker'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Input } from '@/components/design-system/input'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { getCalendarLocale } from '@/lib/calendar-locale'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -37,14 +41,107 @@ interface CompactDateTimeRangePickerProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
function toInputValue(date?: Date): string {
|
||||
return date ? dayjs(date).format('YYYY-MM-DDTHH:mm') : ''
|
||||
// Labels are translated at render time; keep them registered in
|
||||
// src/i18n/static-keys.ts so the i18n sync tooling can see them.
|
||||
const RANGE_PRESETS: Array<{
|
||||
label: string
|
||||
getRange: () => { start: Date; end: Date }
|
||||
}> = [
|
||||
{
|
||||
label: 'Today',
|
||||
getRange: () => {
|
||||
const now = dayjs()
|
||||
return {
|
||||
start: now.startOf('day').toDate(),
|
||||
end: now.endOf('day').toDate(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '7 Days',
|
||||
getRange: () => {
|
||||
const now = dayjs()
|
||||
return {
|
||||
start: now.subtract(6, 'day').startOf('day').toDate(),
|
||||
end: now.endOf('day').toDate(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'This week',
|
||||
getRange: () => {
|
||||
const now = dayjs()
|
||||
return {
|
||||
start: now.startOf('week').toDate(),
|
||||
end: now.endOf('week').toDate(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Last week',
|
||||
getRange: () => {
|
||||
const lastWeek = dayjs().subtract(1, 'week')
|
||||
return {
|
||||
start: lastWeek.startOf('week').toDate(),
|
||||
end: lastWeek.endOf('week').toDate(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '30 Days',
|
||||
getRange: () => {
|
||||
const now = dayjs()
|
||||
return {
|
||||
start: now.subtract(29, 'day').startOf('day').toDate(),
|
||||
end: now.endOf('day').toDate(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'This month',
|
||||
getRange: () => {
|
||||
const now = dayjs()
|
||||
return {
|
||||
start: now.startOf('month').toDate(),
|
||||
end: now.endOf('month').toDate(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Last month',
|
||||
getRange: () => {
|
||||
const lastMonth = dayjs().subtract(1, 'month')
|
||||
return {
|
||||
start: lastMonth.startOf('month').toDate(),
|
||||
end: lastMonth.endOf('month').toDate(),
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// Matches the shadcn "date and time picker" pattern (Calendar + time input):
|
||||
// the native picker indicator is hidden so only the themed field shows.
|
||||
const timeInputClassName =
|
||||
'appearance-none tabular-nums [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none'
|
||||
|
||||
function toTimeValue(date: Date | undefined, fallback: string): string {
|
||||
return date ? dayjs(date).format('HH:mm') : fallback
|
||||
}
|
||||
|
||||
function fromInputValue(value: string): Date | undefined {
|
||||
if (!value) return undefined
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? undefined : date
|
||||
function combineDateTime(date: Date, time: string): Date {
|
||||
const [hours = 0, minutes = 0] = time.split(':').map(Number)
|
||||
const combined = new Date(date)
|
||||
combined.setHours(hours, minutes, 0, 0)
|
||||
return combined
|
||||
}
|
||||
|
||||
// Time inputs are minute-precision, so an end of "23:59" must cover the whole
|
||||
// minute (23:59:59.999) — same as the endOf('day') the presets produce.
|
||||
// Otherwise reopening a preset range and confirming would silently trim it.
|
||||
function combineEndDateTime(date: Date, time: string): Date {
|
||||
const combined = combineDateTime(date, time)
|
||||
combined.setSeconds(59, 999)
|
||||
return combined
|
||||
}
|
||||
|
||||
export function CompactDateTimeRangePicker({
|
||||
@@ -53,17 +150,18 @@ export function CompactDateTimeRangePicker({
|
||||
onChange,
|
||||
className,
|
||||
}: CompactDateTimeRangePickerProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const isMobile = useIsMobile()
|
||||
const calendarLocale = getCalendarLocale(i18n.language)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [draftStart, setDraftStart] = useState(toInputValue(start))
|
||||
const [draftEnd, setDraftEnd] = useState(toInputValue(end))
|
||||
const [draftRange, setDraftRange] = useState<DateRange | undefined>()
|
||||
const [startTime, setStartTime] = useState('00:00')
|
||||
const [endTime, setEndTime] = useState('23:59')
|
||||
|
||||
const label = useMemo(() => {
|
||||
if (!start && !end) return t('Date Range')
|
||||
// The popover's <input type="datetime-local"> only supports minute
|
||||
// precision, so seconds are always 00 (manual pick) or 59 (preset
|
||||
// end-of-day). Hide them in the trigger label to keep the button
|
||||
// width compact while still showing the meaningful timestamp.
|
||||
// Times are minute-precision (the time inputs cannot express seconds),
|
||||
// so hide seconds in the trigger label to keep the button compact.
|
||||
const startText = start ? dayjs(start).format('YYYY-MM-DD HH:mm') : '-'
|
||||
const endText = end ? dayjs(end).format('YYYY-MM-DD HH:mm') : '-'
|
||||
return `${startText} ~ ${endText}`
|
||||
@@ -71,47 +169,42 @@ export function CompactDateTimeRangePicker({
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen) {
|
||||
setDraftStart(toInputValue(start))
|
||||
setDraftEnd(toInputValue(end))
|
||||
setDraftRange(start || end ? { from: start, to: end } : undefined)
|
||||
setStartTime(toTimeValue(start, '00:00'))
|
||||
setEndTime(toTimeValue(end, '23:59'))
|
||||
}
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
const handleCalendarSelect = (
|
||||
range: DateRange | undefined,
|
||||
selectedDay: Date
|
||||
) => {
|
||||
// Once a full range exists, the next click starts a fresh range instead
|
||||
// of react-day-picker's default edge adjustment, which feels erratic.
|
||||
if (draftRange?.from && draftRange?.to) {
|
||||
setDraftRange({ from: selectedDay, to: undefined })
|
||||
return
|
||||
}
|
||||
setDraftRange(range)
|
||||
}
|
||||
|
||||
const applyDraft = () => {
|
||||
const from = draftRange?.from
|
||||
// Selecting a single day leaves `to` empty; treat it as a one-day range.
|
||||
const to = draftRange?.to ?? draftRange?.from
|
||||
onChange({
|
||||
start: fromInputValue(draftStart),
|
||||
end: fromInputValue(draftEnd),
|
||||
start: from ? combineDateTime(from, startTime) : undefined,
|
||||
end: to ? combineEndDateTime(to, endTime) : undefined,
|
||||
})
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const applyPreset = (kind: 'today' | '7d' | 'week' | '30d' | 'month') => {
|
||||
const now = dayjs()
|
||||
const presets = {
|
||||
today: {
|
||||
start: now.startOf('day').toDate(),
|
||||
end: now.endOf('day').toDate(),
|
||||
},
|
||||
'7d': {
|
||||
start: now.subtract(6, 'day').startOf('day').toDate(),
|
||||
end: now.endOf('day').toDate(),
|
||||
},
|
||||
week: {
|
||||
start: now.startOf('week').toDate(),
|
||||
end: now.endOf('week').toDate(),
|
||||
},
|
||||
'30d': {
|
||||
start: now.subtract(29, 'day').startOf('day').toDate(),
|
||||
end: now.endOf('day').toDate(),
|
||||
},
|
||||
month: {
|
||||
start: now.startOf('month').toDate(),
|
||||
end: now.endOf('month').toDate(),
|
||||
},
|
||||
}
|
||||
const range = presets[kind]
|
||||
setDraftStart(toInputValue(range.start))
|
||||
setDraftEnd(toInputValue(range.end))
|
||||
const applyPreset = (getRange: () => { start: Date; end: Date }) => {
|
||||
const range = getRange()
|
||||
setDraftRange({ from: range.start, to: range.end })
|
||||
setStartTime(toTimeValue(range.start, '00:00'))
|
||||
setEndTime(toTimeValue(range.end, '23:59'))
|
||||
onChange(range)
|
||||
setOpen(false)
|
||||
}
|
||||
@@ -136,84 +229,65 @@ export function CompactDateTimeRangePicker({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align='start'
|
||||
className='w-[min(520px,calc(100vw-2rem))] p-3'
|
||||
className='w-auto max-w-[calc(100vw-2rem)] p-0'
|
||||
>
|
||||
<div className='space-y-3'>
|
||||
<div className='grid gap-2 sm:grid-cols-[1fr_auto_1fr] sm:items-end'>
|
||||
<div className='space-y-1.5'>
|
||||
<div className='flex max-sm:max-h-[75vh] max-sm:flex-col max-sm:overflow-y-auto'>
|
||||
{/* One-click presets: side rail on desktop, grid on mobile. */}
|
||||
<div className='grid shrink-0 grid-cols-2 gap-1 border-b p-2 sm:flex sm:flex-col sm:border-r sm:border-b-0'>
|
||||
{RANGE_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.label}
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className='justify-start font-normal'
|
||||
onClick={() => applyPreset(preset.getRange)}
|
||||
>
|
||||
{t(preset.label)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='p-3'>
|
||||
<Calendar
|
||||
mode='range'
|
||||
numberOfMonths={isMobile ? 1 : 2}
|
||||
// Outside days would render range days twice across the two
|
||||
// months (e.g. Jul 31 again in August's first row).
|
||||
showOutsideDays={false}
|
||||
selected={draftRange}
|
||||
onSelect={handleCalendarSelect}
|
||||
defaultMonth={draftRange?.from}
|
||||
locale={calendarLocale}
|
||||
/>
|
||||
|
||||
<div className='mt-3 flex items-end gap-2 border-t pt-3'>
|
||||
<div className='min-w-0 flex-1 space-y-1.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t('Start Time')}
|
||||
</div>
|
||||
<Input
|
||||
type='datetime-local'
|
||||
value={draftStart}
|
||||
onChange={(e) => setDraftStart(e.target.value)}
|
||||
className='text-sm leading-5 tabular-nums'
|
||||
type='time'
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
className={timeInputClassName}
|
||||
/>
|
||||
</div>
|
||||
<span className='text-muted-foreground hidden pb-2 text-xs sm:block'>
|
||||
~
|
||||
</span>
|
||||
<div className='space-y-1.5'>
|
||||
<span className='text-muted-foreground pb-2 text-xs'>~</span>
|
||||
<div className='min-w-0 flex-1 space-y-1.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t('End Time')}
|
||||
</div>
|
||||
<Input
|
||||
type='datetime-local'
|
||||
value={draftEnd}
|
||||
onChange={(e) => setDraftEnd(e.target.value)}
|
||||
className='text-sm leading-5 tabular-nums'
|
||||
type='time'
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
className={timeInputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap gap-1.5'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='secondary'
|
||||
className='flex-1'
|
||||
onClick={() => applyPreset('today')}
|
||||
>
|
||||
{t('Today')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='secondary'
|
||||
className='flex-1'
|
||||
onClick={() => applyPreset('7d')}
|
||||
>
|
||||
{t('7 Days')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='secondary'
|
||||
className='flex-1'
|
||||
onClick={() => applyPreset('week')}
|
||||
>
|
||||
{t('This week')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='secondary'
|
||||
className='flex-1'
|
||||
onClick={() => applyPreset('30d')}
|
||||
>
|
||||
{t('30 Days')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='secondary'
|
||||
className='flex-1'
|
||||
onClick={() => applyPreset('month')}
|
||||
>
|
||||
{t('This month')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end'>
|
||||
<Button onClick={applyDraft}>{t('Confirm')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
@@ -27,15 +27,19 @@ import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
DataTableCardField,
|
||||
DataTableCardRow,
|
||||
ERROR_ROW_MOBILE,
|
||||
INFO_ROW_MOBILE,
|
||||
MobileCardList,
|
||||
} from '@/components/data-table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { LOG_TYPE_ENUM } from '../constants'
|
||||
|
||||
const logTypeRowTint: Record<number, string> = {
|
||||
[LOG_TYPE_ENUM.ERROR]: 'bg-destructive/5 border-destructive/20',
|
||||
[LOG_TYPE_ENUM.REFUND]: 'bg-info/5 border-info/20',
|
||||
// Same treatment as disabled cards in the keys/channels/users mobile lists
|
||||
// (tinted card background), only the hue differs.
|
||||
const logTypeRowClass: Record<number, string> = {
|
||||
[LOG_TYPE_ENUM.ERROR]: ERROR_ROW_MOBILE,
|
||||
[LOG_TYPE_ENUM.REFUND]: INFO_ROW_MOBILE,
|
||||
}
|
||||
|
||||
interface UsageLogsMobileListProps<TData> {
|
||||
@@ -182,12 +186,9 @@ export function UsageLogsMobileList<TData>({
|
||||
const logType = (row.original as Record<string, unknown>).type as
|
||||
| number
|
||||
| undefined
|
||||
const tintClass = logType != null ? (logTypeRowTint[logType] ?? '') : ''
|
||||
return cn(
|
||||
'border-l-2 border-l-transparent transition-colors',
|
||||
tintClass,
|
||||
getRowClassName?.(row)
|
||||
)
|
||||
const statusClass =
|
||||
logType != null ? (logTypeRowClass[logType] ?? '') : ''
|
||||
return cn('transition-colors', statusClass, getRowClassName?.(row))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -25,6 +25,10 @@ import { toast } from 'sonner'
|
||||
import {
|
||||
DataTablePage,
|
||||
DataTableRow,
|
||||
ERROR_ROW_DESKTOP,
|
||||
INFO_ROW_DESKTOP,
|
||||
WARNING_ROW_DESKTOP,
|
||||
WARNING_ROW_MOBILE,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
@@ -46,15 +50,13 @@ import { useLogsViewScope } from './usage-logs-provider'
|
||||
|
||||
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
||||
|
||||
const logTypeRowTint: Record<number, string> = {
|
||||
[LOG_TYPE_ENUM.ERROR]: 'bg-destructive/5',
|
||||
[LOG_TYPE_ENUM.REFUND]: 'bg-info/5',
|
||||
// Same structural treatment as disabled rows in the keys/channels/users
|
||||
// tables (tinted row + 4px accent stripe), only the hue differs.
|
||||
const logTypeRowClass: Record<number, string> = {
|
||||
[LOG_TYPE_ENUM.ERROR]: ERROR_ROW_DESKTOP,
|
||||
[LOG_TYPE_ENUM.REFUND]: INFO_ROW_DESKTOP,
|
||||
}
|
||||
|
||||
// Warning tint for logs where a quota conversion saturated (admin-only marker).
|
||||
// Takes precedence over the per-type tint since it flags a billing anomaly.
|
||||
const quotaSaturationRowTint = 'bg-warning/10'
|
||||
|
||||
function getColumnVisibilityStorageKey(
|
||||
logCategory: LogCategory,
|
||||
isAdmin: boolean
|
||||
@@ -206,7 +208,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
|
||||
((row.original as Record<string, unknown>).other as string) ?? ''
|
||||
)
|
||||
return other?.admin_info?.quota_saturation
|
||||
? quotaSaturationRowTint
|
||||
? WARNING_ROW_MOBILE
|
||||
: undefined
|
||||
}}
|
||||
/>
|
||||
@@ -222,14 +224,16 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
|
||||
const logType = (row.original as Record<string, unknown>).type as
|
||||
| number
|
||||
| undefined
|
||||
let tintClass =
|
||||
isCommon && logType != null ? (logTypeRowTint[logType] ?? '') : ''
|
||||
let statusClass =
|
||||
isCommon && logType != null ? (logTypeRowClass[logType] ?? '') : ''
|
||||
if (isCommon && isAdmin) {
|
||||
const other = parseLogOther(
|
||||
((row.original as Record<string, unknown>).other as string) ?? ''
|
||||
)
|
||||
// Quota saturation is an admin-only billing anomaly marker; it
|
||||
// takes precedence over the per-type row treatment.
|
||||
if (other?.admin_info?.quota_saturation) {
|
||||
tintClass = quotaSaturationRowTint
|
||||
statusClass = WARNING_ROW_DESKTOP
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +241,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
|
||||
<DataTableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
className={cn('transition-colors', tintClass)}
|
||||
className={cn('transition-colors', statusClass)}
|
||||
getColumnClassName={(columnId) =>
|
||||
helpers.getCellClassName(columnId, isCommon ? 'py-2' : 'py-3.5')
|
||||
}
|
||||
|
||||
@@ -68,20 +68,6 @@ export const LOG_TYPE_ENUM = {
|
||||
*/
|
||||
export const LOG_TYPE_ALL_VALUE = '0' as const
|
||||
|
||||
// ============================================================================
|
||||
// Time Range Presets
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Quick time range presets for filter dialog
|
||||
*/
|
||||
export const TIME_RANGE_PRESETS = [
|
||||
{ days: 1, label: '24 Hours' },
|
||||
{ days: 7, label: '7 Days' },
|
||||
{ days: 14, label: '14 Days' },
|
||||
{ days: 30, label: '30 Days' },
|
||||
] as const
|
||||
|
||||
// ============================================================================
|
||||
// Common Logs Configuration
|
||||
// ============================================================================
|
||||
|
||||
+3
-2
@@ -73,13 +73,14 @@ export function isPerCallBilling(modelPrice?: number): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default time range (today 00:00:00 to now + 1 hour)
|
||||
* Get default time range (today 00:00:00 to 23:59:59)
|
||||
*/
|
||||
export function getDefaultTimeRange(): { start: Date; end: Date } {
|
||||
const now = new Date()
|
||||
const start = new Date(now)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
const end = new Date(now.getTime() + 3600 * 1000) // +1 hour
|
||||
const end = new Date(now)
|
||||
end.setHours(23, 59, 59, 999)
|
||||
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
Vendored
+3
@@ -1514,6 +1514,7 @@
|
||||
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.",
|
||||
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "Each tier supports up to 2 conditions. The last tier without conditions is the fallback.",
|
||||
"Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.": "Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.",
|
||||
"Earned this month": "Earned this month",
|
||||
"Edit": "Edit",
|
||||
"Edit {{title}}": "Edit {{title}}",
|
||||
"Edit all channels with tag:": "Edit all channels with tag:",
|
||||
@@ -2420,11 +2421,13 @@
|
||||
"Last check time": "Last check time",
|
||||
"Last detected addable models": "Last detected addable models",
|
||||
"Last Login": "Last Login",
|
||||
"Last month": "Last month",
|
||||
"Last Seen": "Last Seen",
|
||||
"Last Tested": "Last Tested",
|
||||
"Last updated:": "Last updated:",
|
||||
"Last Used": "Last Used",
|
||||
"Last used:": "Last used:",
|
||||
"Last week": "Last week",
|
||||
"Latency": "Latency",
|
||||
"Latency check": "Latency check",
|
||||
"Latency short": "Lat.",
|
||||
|
||||
Vendored
+3
@@ -1514,6 +1514,7 @@
|
||||
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "Chaque palier accepte jusqu’à 2 conditions ; le dernier palier sert de repli sans condition. Utilisez la longueur complète de l’entrée pour éviter un mauvais aiguillage lorsque les lectures de cache réduisent les tokens d’entrée facturables.",
|
||||
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "Chaque palier prend en charge jusqu’à 2 conditions. Le dernier palier sans condition sert de repli.",
|
||||
"Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.": "Gagnez des récompenses quand des utilisateurs rejoignent via votre lien de parrainage. Transférez-les vers votre solde à tout moment.",
|
||||
"Earned this month": "Gagné ce mois-ci",
|
||||
"Edit": "Modifier",
|
||||
"Edit {{title}}": "Modifier {{title}}",
|
||||
"Edit all channels with tag:": "Modifier tous les canaux avec l'étiquette :",
|
||||
@@ -2420,11 +2421,13 @@
|
||||
"Last check time": "Dernière vérification",
|
||||
"Last detected addable models": "Derniers modèles ajoutables détectés",
|
||||
"Last Login": "Dernière connexion",
|
||||
"Last month": "Mois dernier",
|
||||
"Last Seen": "Dernier signal",
|
||||
"Last Tested": "Dernier testé",
|
||||
"Last updated:": "Dernière mise à jour :",
|
||||
"Last Used": "Dernière utilisation",
|
||||
"Last used:": "Dernière utilisation :",
|
||||
"Last week": "Semaine dernière",
|
||||
"Latency": "Latence",
|
||||
"Latency check": "Contrôle de latence",
|
||||
"Latency short": "Lat.",
|
||||
|
||||
Vendored
+3
@@ -1514,6 +1514,7 @@
|
||||
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "各階層は最大2つの条件をサポートします。最後の階層は条件なしのフォールバックです。キャッシュヒットで課金対象の入力トークンが減っても誤った階層にならないよう、条件には完全な入力長を使用してください。",
|
||||
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "各段階は最大 2 つの条件に対応します。条件のない最後の段階がフォールバックです。",
|
||||
"Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.": "ユーザーがあなたの紹介リンクから登録すると報酬を獲得できます。貯まった報酬はいつでも残高へ振り替えられます。",
|
||||
"Earned this month": "今月の獲得",
|
||||
"Edit": "編集",
|
||||
"Edit {{title}}": "{{title}}を編集",
|
||||
"Edit all channels with tag:": "タグを持つすべてのチャネルを編集:",
|
||||
@@ -2420,11 +2421,13 @@
|
||||
"Last check time": "最終チェック時刻",
|
||||
"Last detected addable models": "最後に検出された追加可能モデル",
|
||||
"Last Login": "最終ログイン",
|
||||
"Last month": "先月",
|
||||
"Last Seen": "最終報告",
|
||||
"Last Tested": "最終テスト日時",
|
||||
"Last updated:": "最終更新日:",
|
||||
"Last Used": "最終使用",
|
||||
"Last used:": "最終使用日:",
|
||||
"Last week": "先週",
|
||||
"Latency": "レイテンシ",
|
||||
"Latency check": "レイテンシ確認",
|
||||
"Latency short": "遅延",
|
||||
|
||||
Vendored
+3
@@ -1514,6 +1514,7 @@
|
||||
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "Каждый уровень поддерживает до 2 условий; последний уровень является резервным и не содержит условий. Используйте полную длину входа для условий уровня, чтобы кэш-попадания не снижали оплачиваемые входные токены и не приводили к неверному маршруту.",
|
||||
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "Каждый уровень поддерживает до 2 условий. Последний уровень без условий используется как резервный.",
|
||||
"Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.": "Получайте вознаграждения, когда пользователи регистрируются по вашей реферальной ссылке. Переводите накопленные вознаграждения на баланс в любое время.",
|
||||
"Earned this month": "Получено в этом месяце",
|
||||
"Edit": "Редактировать",
|
||||
"Edit {{title}}": "Редактировать {{title}}",
|
||||
"Edit all channels with tag:": "Редактировать все каналы с тегом:",
|
||||
@@ -2420,11 +2421,13 @@
|
||||
"Last check time": "Время последней проверки",
|
||||
"Last detected addable models": "Последние обнаруженные модели для добавления",
|
||||
"Last Login": "Последний вход",
|
||||
"Last month": "Прошлый месяц",
|
||||
"Last Seen": "Последний сигнал",
|
||||
"Last Tested": "Последняя проверка",
|
||||
"Last updated:": "Последнее обновление:",
|
||||
"Last Used": "Последнее использование",
|
||||
"Last used:": "Последнее использование:",
|
||||
"Last week": "Прошлая неделя",
|
||||
"Latency": "Задержка",
|
||||
"Latency check": "Проверка задержки",
|
||||
"Latency short": "Зад.",
|
||||
|
||||
Vendored
+3
@@ -1514,6 +1514,7 @@
|
||||
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "Mỗi tầng hỗ trợ tối đa 2 điều kiện; tầng cuối cùng là tầng dự phòng không có điều kiện. Hãy dùng độ dài đầu vào đầy đủ cho điều kiện tầng để tránh chọn sai tầng khi cache hit làm giảm token đầu vào tính phí.",
|
||||
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "Mỗi tầng hỗ trợ tối đa 2 điều kiện. Tầng cuối cùng không có điều kiện là tầng dự phòng.",
|
||||
"Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.": "Nhận phần thưởng khi người dùng đăng ký qua liên kết giới thiệu của bạn. Chuyển phần thưởng tích lũy vào số dư bất cứ lúc nào.",
|
||||
"Earned this month": "Nhận trong tháng này",
|
||||
"Edit": "Chỉnh sửa",
|
||||
"Edit {{title}}": "Chỉnh sửa {{title}}",
|
||||
"Edit all channels with tag:": "Chỉnh sửa tất cả các kênh với thẻ:",
|
||||
@@ -2420,11 +2421,13 @@
|
||||
"Last check time": "Thời gian kiểm tra gần nhất",
|
||||
"Last detected addable models": "Mô hình có thể thêm được phát hiện gần nhất",
|
||||
"Last Login": "Lần đăng nhập cuối",
|
||||
"Last month": "Tháng trước",
|
||||
"Last Seen": "Lần cuối thấy",
|
||||
"Last Tested": "Được kiểm tra lần cuối",
|
||||
"Last updated:": "Cập nhật lần cuối:",
|
||||
"Last Used": "Dùng lần cuối",
|
||||
"Last used:": "Lần cuối sử dụng:",
|
||||
"Last week": "Tuần trước",
|
||||
"Latency": "Độ trễ",
|
||||
"Latency check": "Kiểm tra độ trễ",
|
||||
"Latency short": "Trễ",
|
||||
|
||||
+4
-1
@@ -1514,6 +1514,7 @@
|
||||
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "每個檔位最多支援 2 個條件;最後一個檔位是不帶條件的兜底檔。建議使用完整輸入長度作為檔位條件,避免緩存命中減少收費輸入 token 後誤判檔位。",
|
||||
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "每個階梯最多支援 2 個條件。最後一個無條件階梯作為兜底。",
|
||||
"Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.": "Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.",
|
||||
"Earned this month": "本月獲得",
|
||||
"Edit": "編輯",
|
||||
"Edit {{title}}": "編輯{{title}}",
|
||||
"Edit all channels with tag:": "編輯所有帶有標籤的渠道:",
|
||||
@@ -2420,11 +2421,13 @@
|
||||
"Last check time": "上次檢測時間",
|
||||
"Last detected addable models": "上次檢測到可加入模型",
|
||||
"Last Login": "最後登入",
|
||||
"Last month": "上個月",
|
||||
"Last Seen": "最後上報",
|
||||
"Last Tested": "上次測試",
|
||||
"Last updated:": "上次更新時間:",
|
||||
"Last Used": "最後使用時間",
|
||||
"Last used:": "上次使用時間:",
|
||||
"Last week": "上週",
|
||||
"Latency": "延遲",
|
||||
"Latency check": "延遲檢測",
|
||||
"Latency short": "延遲",
|
||||
@@ -4521,7 +4524,7 @@
|
||||
"This model has both fixed-price and ratio settings. Saving the current mode will rewrite the conflicting fields.": "該模型同時存在固定價格和比例設定。儲存目前模式會重寫衝突欄位。",
|
||||
"This model has both fixed-price and token-price settings. Saving the current mode will rewrite the conflicting fields.": "該模型同時存在固定價格和按 token 價格設定。儲存目前模式會重寫衝突欄位。",
|
||||
"This model is not available in any group, or no group pricing information is configured.": "此模型在任何分組中均不可用,或未設定分組定價資訊。",
|
||||
"This month": "本月獲得",
|
||||
"This month": "本月",
|
||||
"This page has not been created yet.": "此頁面尚未建立。",
|
||||
"This plan does not allow balance redemption": "該套餐不允許使用餘額兌換",
|
||||
"This project must be used in compliance with the": "此項目的使用必須遵守",
|
||||
|
||||
Vendored
+4
-1
@@ -1514,6 +1514,7 @@
|
||||
"Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "每个档位最多支持 2 个条件;最后一个档位是不带条件的兜底档。建议使用完整输入长度作为档位条件,避免缓存命中减少计费输入 token 后误判档位。",
|
||||
"Each tier supports up to 2 conditions. The last tier without conditions is the fallback.": "每个阶梯最多支持 2 个条件。最后一个无条件阶梯作为兜底。",
|
||||
"Earn rewards when users join through your referral link. Transfer accumulated rewards to your balance anytime.": "用户通过您的推荐链接注册后,您即可获得奖励。可随时将累计奖励转入余额。",
|
||||
"Earned this month": "本月获得",
|
||||
"Edit": "编辑",
|
||||
"Edit {{title}}": "编辑{{title}}",
|
||||
"Edit all channels with tag:": "编辑所有带有标签的渠道:",
|
||||
@@ -2420,11 +2421,13 @@
|
||||
"Last check time": "上次检测时间",
|
||||
"Last detected addable models": "上次检测到可加入模型",
|
||||
"Last Login": "最后登录",
|
||||
"Last month": "上个月",
|
||||
"Last Seen": "最后上报",
|
||||
"Last Tested": "上次测试",
|
||||
"Last updated:": "上次更新时间:",
|
||||
"Last Used": "最后使用时间",
|
||||
"Last used:": "上次使用时间:",
|
||||
"Last week": "上周",
|
||||
"Latency": "延迟",
|
||||
"Latency check": "延迟检测",
|
||||
"Latency short": "延迟",
|
||||
@@ -4521,7 +4524,7 @@
|
||||
"This model has both fixed-price and ratio settings. Saving the current mode will rewrite the conflicting fields.": "该模型同时存在固定价格和比例设置。保存当前模式会重写冲突字段。",
|
||||
"This model has both fixed-price and token-price settings. Saving the current mode will rewrite the conflicting fields.": "该模型同时存在固定价格和按 token 价格设置。保存当前模式会重写冲突字段。",
|
||||
"This model is not available in any group, or no group pricing information is configured.": "此模型在任何分组中均不可用,或未配置分组定价信息。",
|
||||
"This month": "本月获得",
|
||||
"This month": "本月",
|
||||
"This page has not been created yet.": "此页面尚未创建。",
|
||||
"This plan does not allow balance redemption": "该套餐不允许使用余额兑换",
|
||||
"This project must be used in compliance with the": "此项目的使用必须遵守",
|
||||
|
||||
Vendored
+9
@@ -544,6 +544,15 @@ export const STATIC_I18N_KEYS = [
|
||||
'The model that was requested',
|
||||
'The upstream channel that served the requests',
|
||||
|
||||
// Usage logs date range presets
|
||||
'Today',
|
||||
'7 Days',
|
||||
'This week',
|
||||
'Last week',
|
||||
'30 Days',
|
||||
'This month',
|
||||
'Last month',
|
||||
|
||||
// Misc
|
||||
'Cancel',
|
||||
'Status',
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { Locale } from 'react-day-picker'
|
||||
import { enUS, fr, ja, ru, vi, zhCN, zhTW } from 'react-day-picker/locale'
|
||||
|
||||
const calendarLocales: Record<string, Locale> = {
|
||||
en: enUS,
|
||||
zh: zhCN,
|
||||
'zh-TW': zhTW,
|
||||
fr,
|
||||
ru,
|
||||
ja,
|
||||
vi,
|
||||
}
|
||||
|
||||
/** Map the active i18next language to a react-day-picker locale. */
|
||||
export function getCalendarLocale(language: string): Locale {
|
||||
return calendarLocales[language] ?? enUS
|
||||
}
|
||||
Vendored
+56
@@ -294,6 +294,35 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
var(--foreground) 24%,
|
||||
var(--table-row)
|
||||
);
|
||||
/* Semantic status-row palettes (error/info/warning). Same structure as the
|
||||
* disabled palette above so every "special row" treatment looks identical,
|
||||
* only the hue differs. Borders mix a much higher share so the accent
|
||||
* stripe stays recognizable at 4px. */
|
||||
--table-error: color-mix(in oklch, var(--destructive) 5%, var(--table-row));
|
||||
--table-error-hover: color-mix(
|
||||
in oklch,
|
||||
var(--destructive) 8%,
|
||||
var(--table-row)
|
||||
);
|
||||
--table-error-border: color-mix(
|
||||
in oklch,
|
||||
var(--destructive) 60%,
|
||||
var(--table-row)
|
||||
);
|
||||
--table-info: color-mix(in oklch, var(--info) 5%, var(--table-row));
|
||||
--table-info-hover: color-mix(in oklch, var(--info) 8%, var(--table-row));
|
||||
--table-info-border: color-mix(in oklch, var(--info) 60%, var(--table-row));
|
||||
--table-warning: color-mix(in oklch, var(--warning) 10%, var(--table-row));
|
||||
--table-warning-hover: color-mix(
|
||||
in oklch,
|
||||
var(--warning) 13%,
|
||||
var(--table-row)
|
||||
);
|
||||
--table-warning-border: color-mix(
|
||||
in oklch,
|
||||
var(--warning) 65%,
|
||||
var(--table-row)
|
||||
);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -366,4 +395,31 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
var(--foreground) 34%,
|
||||
var(--background)
|
||||
);
|
||||
/* Dark-mode status-row palettes; higher mixes than light mode because dark
|
||||
* surfaces swallow subtle tints (mirrors the disabled palette above). */
|
||||
--table-error: color-mix(in oklch, var(--destructive) 10%, var(--table-row));
|
||||
--table-error-hover: color-mix(
|
||||
in oklch,
|
||||
var(--destructive) 13%,
|
||||
var(--table-row)
|
||||
);
|
||||
--table-error-border: color-mix(
|
||||
in oklch,
|
||||
var(--destructive) 65%,
|
||||
var(--table-row)
|
||||
);
|
||||
--table-info: color-mix(in oklch, var(--info) 10%, var(--table-row));
|
||||
--table-info-hover: color-mix(in oklch, var(--info) 13%, var(--table-row));
|
||||
--table-info-border: color-mix(in oklch, var(--info) 65%, var(--table-row));
|
||||
--table-warning: color-mix(in oklch, var(--warning) 14%, var(--table-row));
|
||||
--table-warning-hover: color-mix(
|
||||
in oklch,
|
||||
var(--warning) 17%,
|
||||
var(--table-row)
|
||||
);
|
||||
--table-warning-border: color-mix(
|
||||
in oklch,
|
||||
var(--warning) 70%,
|
||||
var(--table-row)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user