feat(web): polish themed data views and add task log details

This commit is contained in:
t0ng7u
2026-07-11 14:30:06 +08:00
parent b2a890e755
commit 308e3e347a
79 changed files with 1376 additions and 727 deletions
-3
View File
@@ -102,7 +102,6 @@
"marked": "^18.0.5",
"motion": "^12.42.2",
"nanoid": "^5.1.16",
"next-themes": "^0.4.6",
"qrcode.react": "catalog:",
"react": "catalog:",
"react-day-picker": "^10.0.1",
@@ -2243,8 +2242,6 @@
"newapi-web": ["newapi-web@workspace:default"],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
"node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
+16
View File
@@ -1021,6 +1021,22 @@ html.dark .with-pastel-balls::before {
padding: 10px !important;
}
/* Keep badge text complete and on one line. */
.semi-tag {
flex-shrink: 0;
min-width: max-content;
max-width: none;
overflow: visible;
white-space: nowrap;
}
.semi-tag-content {
max-width: none;
overflow: visible;
text-overflow: clip;
white-space: nowrap;
}
/* ==================== 使用日志: channel affinity tag ==================== */
.semi-tag.channel-affinity-tag {
border: 1px solid rgba(var(--semi-cyan-5), 0.35);
+1 -1
View File
@@ -18,7 +18,7 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "inverted",
"menuColor": "default",
"menuAccent": "subtle",
"registries": {
"@ai-elements": "https://registry.ai-sdk.dev/{name}.json"
-1
View File
@@ -53,7 +53,6 @@
"marked": "^18.0.5",
"motion": "^12.42.2",
"nanoid": "^5.1.16",
"next-themes": "^0.4.6",
"qrcode.react": "catalog:",
"react": "catalog:",
"react-day-picker": "^10.0.1",
@@ -32,32 +32,28 @@ import type {
const alertConfig = {
note: {
label: 'Note',
className:
'border-blue-500/40 bg-blue-500/8 text-blue-950 dark:text-blue-100',
markerClassName: 'text-blue-600 dark:text-blue-300',
className: 'border-info/25 bg-info/8 text-foreground',
markerClassName: 'text-status-info',
},
tip: {
label: 'Tip',
className:
'border-emerald-500/40 bg-emerald-500/8 text-emerald-950 dark:text-emerald-100',
markerClassName: 'text-emerald-600 dark:text-emerald-300',
className: 'border-success/25 bg-success/8 text-foreground',
markerClassName: 'text-status-success',
},
important: {
label: 'Important',
className:
'border-violet-500/40 bg-violet-500/8 text-violet-950 dark:text-violet-100',
markerClassName: 'text-violet-600 dark:text-violet-300',
className: 'border-border bg-muted/40 text-foreground',
markerClassName: 'text-foreground',
},
warning: {
label: 'Warning',
className:
'border-amber-500/40 bg-amber-500/8 text-amber-950 dark:text-amber-100',
markerClassName: 'text-amber-600 dark:text-amber-300',
className: 'border-warning/30 bg-warning/8 text-foreground',
markerClassName: 'text-status-warning',
},
caution: {
label: 'Caution',
className: 'border-red-500/40 bg-red-500/8 text-red-950 dark:text-red-100',
markerClassName: 'text-red-600 dark:text-red-300',
className: 'border-destructive/25 bg-destructive/8 text-foreground',
markerClassName: 'text-status-destructive',
},
} satisfies Record<AlertKind, AlertConfig>
+1 -1
View File
@@ -27,7 +27,7 @@ export function BadgeCell({ className, ...props }: BadgeCellProps) {
<div
data-slot='badge-cell'
className={cn(
'flex max-w-full min-w-0 items-center gap-1 overflow-hidden [&_[data-slot=status-badge]]:max-w-full [&_[data-slot=status-badge]]:min-w-0',
'flex max-w-full min-w-0 items-center gap-1 overflow-visible',
className
)}
{...props}
@@ -60,8 +60,8 @@ function getPinnedColumnClassName(
) {
const edgeClassName =
pinnedColumn.side === 'left'
? 'shadow-[8px_0_10px_-10px_hsl(var(--foreground))]'
: 'shadow-[-8px_0_10px_-10px_hsl(var(--foreground))]'
? 'shadow-[8px_0_10px_-10px_var(--foreground)]'
: 'shadow-[-8px_0_10px_-10px_var(--foreground)]'
return cn(
'sticky whitespace-nowrap',
@@ -58,9 +58,9 @@ function DataTableRowInner<TData>({
className={cn(
'max-w-full min-w-0',
contentMode === 'full' &&
'max-w-none overflow-visible [&_.truncate]:overflow-visible [&_.truncate]:text-clip [&_[data-slot=status-badge]]:max-w-none [&_[data-slot=status-badge]]:overflow-visible [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip',
'max-w-none overflow-visible [&_.truncate]:overflow-visible [&_.truncate]:text-clip',
contentMode === 'wrap' &&
'whitespace-normal break-words [overflow-wrap:anywhere] [&_.truncate]:overflow-visible [&_.truncate]:text-clip [&_.truncate]:whitespace-normal [&_.whitespace-nowrap]:whitespace-normal [&_[data-slot=status-badge]]:h-auto [&_[data-slot=status-badge]]:overflow-visible [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal',
'whitespace-normal break-words [overflow-wrap:anywhere] [&_.truncate]:overflow-visible [&_.truncate]:text-clip [&_.truncate]:whitespace-normal',
contentMode === 'summary' &&
'whitespace-normal break-words [overflow-wrap:anywhere]',
getColumnClassName?.(cell.column.id, 'cell')
@@ -23,7 +23,7 @@ import { cn } from '@/lib/utils'
export type DataTableContentMode = 'full' | 'wrap' | 'summary'
const VALUE_WRAP_CLASS =
'whitespace-normal break-words [overflow-wrap:anywhere] [&_.truncate]:overflow-visible [&_.truncate]:text-clip [&_.truncate]:whitespace-normal [&_.whitespace-nowrap]:whitespace-normal [&_[data-slot=status-badge]]:h-auto [&_[data-slot=status-badge]]:overflow-visible [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
'whitespace-normal break-words [overflow-wrap:anywhere] [&_.truncate]:overflow-visible [&_.truncate]:text-clip [&_.truncate]:whitespace-normal'
interface DataTableCardFieldProps {
children: ReactNode
@@ -77,12 +77,12 @@ export function CardRowContent<TData>(props: {
return (
<div className='flex min-w-0 flex-col'>
{props.compact && (titleCell || badgeCell) && (
<div className='flex min-w-0 items-start justify-between gap-3'>
<div className='min-w-0 flex-1 text-[15px] leading-tight font-semibold [overflow-wrap:anywhere] break-words whitespace-normal [&_.truncate]:overflow-visible [&_.truncate]:text-clip [&_.truncate]:whitespace-normal [&_[data-slot=status-badge-label]]:whitespace-normal [&_[data-slot=status-badge]]:h-auto [&_[data-slot=status-badge]]:max-w-full'>
<div className='flex min-w-0 flex-wrap items-start justify-between gap-3'>
<div className='min-w-0 flex-1 text-[15px] leading-tight font-semibold [overflow-wrap:anywhere] break-words whitespace-normal [&_.truncate]:overflow-visible [&_.truncate]:text-clip [&_.truncate]:whitespace-normal'>
{titleCell ? renderCellContent(titleCell) : null}
</div>
{badgeCell && (
<div className='max-w-1/2 shrink text-right'>
<div className='shrink-0 text-right'>
{renderCellContent(badgeCell)}
</div>
)}
@@ -19,12 +19,12 @@ For commercial licensing, please contact support@quantumnous.com
import { Grid2X2, Table2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Tabs, TabsList, TabsTrigger } from '@/components/design-system/tabs'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import {
DATA_TABLE_VIEW_MODES,
@@ -37,12 +37,6 @@ export type DataTableViewModeToggleProps = {
className?: string
}
type Segment = {
value: DataTableViewMode
icon: React.ComponentType<{ className?: string }>
tooltip: string
}
/**
* Reusable icon segmented control for switching a data table between table and
* card views. Shared, accessible version of the local control used by the
@@ -51,56 +45,42 @@ type Segment = {
export function DataTableViewModeToggle(props: DataTableViewModeToggleProps) {
const { t } = useTranslation()
const segments: Segment[] = [
{
value: DATA_TABLE_VIEW_MODES.CARD,
icon: Grid2X2,
tooltip: t('Card view'),
},
{
value: DATA_TABLE_VIEW_MODES.TABLE,
icon: Table2,
tooltip: t('Table view'),
},
]
return (
<div
role='group'
aria-label={t('View mode')}
className={cn(
'bg-muted/60 inline-flex h-7 items-center rounded-lg border p-0.5 sm:h-8',
props.className
)}
<Tabs
value={props.value}
onValueChange={(value) => props.onChange(value as DataTableViewMode)}
className={props.className}
>
{segments.map((segment) => {
const Icon = segment.icon
const isActive = segment.value === props.value
return (
<Tooltip key={segment.value}>
<TooltipTrigger
render={
<button
type='button'
onClick={() => props.onChange(segment.value)}
aria-pressed={isActive}
className={cn(
'inline-flex h-full w-7 items-center justify-center rounded-md text-xs font-medium transition-all',
isActive
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
<Icon className='size-3.5' />
</button>
}
/>
<TooltipContent side='bottom' className='text-xs'>
{segment.tooltip}
</TooltipContent>
</Tooltip>
)
})}
</div>
<TabsList aria-label={t('View mode')}>
<Tooltip>
<TooltipTrigger
render={
<TabsTrigger
value={DATA_TABLE_VIEW_MODES.TABLE}
className='px-2'
/>
}
>
<Table2 aria-hidden='true' className='size-3.5' />
<span className='sr-only'>{t('Table view')}</span>
</TooltipTrigger>
<TooltipContent side='bottom'>{t('Table view')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<TabsTrigger
value={DATA_TABLE_VIEW_MODES.CARD}
className='px-2'
/>
}
>
<Grid2X2 aria-hidden='true' className='size-3.5' />
<span className='sr-only'>{t('Card view')}</span>
</TooltipTrigger>
<TooltipContent side='bottom'>{t('Card view')}</TooltipContent>
</Tooltip>
</TabsList>
</Tabs>
)
}
+3 -2
View File
@@ -23,8 +23,9 @@ import { cn } from '@/lib/utils'
export const sideDrawerContentClassName = (className?: string) =>
cn(
// Width: pass `sm:max-w-*` (or `sm:max-w-none`) in className. SheetContent
// defaults to `sm:max-w-sm` for left/right; plain utilities merge correctly.
'bg-background text-foreground flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 shadow-none',
// defaults to 75% width on mobile and `sm:max-w-sm` for left/right.
// Match its side variants so product drawers occupy the full mobile viewport.
'bg-background text-foreground flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 shadow-none data-[side=left]:w-full data-[side=right]:w-full',
className
)
+11 -3
View File
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { getIdentityTextColorClass } from '@/lib/colors'
import { cn } from '@/lib/utils'
import { StatusBadge, type StatusBadgeProps } from './status-badge'
@@ -55,6 +56,7 @@ export function GroupBadge(props: GroupBadgeProps) {
const groupName = group?.trim()
const isAutoGroup = groupName === 'auto'
const isEmptyGroup = !groupName
const colorKey = groupName || labelOverride || 'group'
const label = getGroupLabel({
labelOverride,
groupName,
@@ -67,7 +69,12 @@ export function GroupBadge(props: GroupBadgeProps) {
<StatusBadge
{...badgeProps}
variant='neutral'
className={cn('min-w-0 shrink overflow-hidden', className)}
appearance='plain'
className={cn(
'shrink-0 overflow-visible',
getIdentityTextColorClass(colorKey),
className
)}
>
{label}
</StatusBadge>
@@ -78,10 +85,11 @@ export function GroupBadge(props: GroupBadgeProps) {
}
return (
<span className='inline-flex max-w-full min-w-0 items-center gap-2 text-xs'>
<span className='max-w-full min-w-0 overflow-hidden'>{badge}</span>
<span className='inline-flex w-max shrink-0 items-center gap-2 text-xs whitespace-nowrap'>
<span className='inline-flex shrink-0'>{badge}</span>
<StatusBadge
variant={getGroupRatioVariant(ratio)}
appearance='plain'
className='shrink-0 tabular-nums'
>
{ratio}x
+2 -2
View File
@@ -50,13 +50,13 @@ export function Glow({ className, variant, ...props }: GlowProps) {
>
<div
className={cn(
'absolute left-1/2 h-[256px] w-[60%] -translate-x-1/2 scale-[2.5] rounded-[50%] bg-radial from-amber-500/60 from-10% to-amber-500/0 to-60% opacity-40 sm:h-[512px] dark:opacity-80',
'from-primary/60 to-primary/0 absolute left-1/2 h-[256px] w-[60%] -translate-x-1/2 scale-[2.5] rounded-[50%] bg-radial from-10% to-60% opacity-40 sm:h-[512px]',
variant === 'center' && '-translate-y-1/2'
)}
/>
<div
className={cn(
'absolute left-1/2 h-[128px] w-[40%] -translate-x-1/2 scale-200 rounded-[50%] bg-radial from-yellow-400/50 from-10% to-yellow-400/0 to-60% opacity-30 sm:h-[256px] dark:opacity-70',
'from-primary/40 to-primary/0 absolute left-1/2 h-[128px] w-[40%] -translate-x-1/2 scale-200 rounded-[50%] bg-radial from-10% to-60% opacity-30 sm:h-[256px]',
variant === 'center' && '-translate-y-1/2'
)}
/>
+1 -8
View File
@@ -32,12 +32,5 @@ export function NavigationProgress() {
}
}, [state.status])
return (
<LoadingBar
color='var(--muted-foreground)'
ref={ref}
shadow={true}
height={2}
/>
)
return <LoadingBar color='var(--primary)' ref={ref} shadow height={2} />
}
+1 -1
View File
@@ -38,7 +38,7 @@ export function ProviderBadge({
<StatusBadge
{...badgeProps}
variant='neutral'
className={cn('min-w-0 shrink overflow-hidden', className)}
className={cn('shrink-0 overflow-visible', className)}
>
{icon && (
<span
+12 -5
View File
@@ -35,8 +35,11 @@ export type StatusVariant =
export type StatusBadgeAppearance = 'soft' | 'outline' | 'plain'
export const StatusBadgeAppearanceContext =
React.createContext<StatusBadgeAppearance | null>(null)
export const statusBadgeVariants = cva(
'focus-visible:ring-ring/50 inline-flex w-fit max-w-full min-w-0 shrink items-center justify-center overflow-hidden rounded-md text-ellipsis whitespace-nowrap font-medium tracking-normal outline-none transition-[color,background-color,border-color,filter] duration-150 focus-visible:ring-[3px] has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 [&_[data-icon]]:pointer-events-none [&_[data-icon]]:size-3 [&_[data-icon]]:shrink-0',
'focus-visible:ring-ring/50 inline-flex w-fit max-w-none shrink-0 items-center justify-center overflow-visible rounded-md whitespace-nowrap font-medium tracking-normal outline-none transition-[color,background-color,border-color,filter] duration-150 focus-visible:ring-[3px] has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 [&_.truncate]:max-w-none [&_.truncate]:overflow-visible [&_.truncate]:text-clip [&_.truncate]:whitespace-nowrap [&_[data-icon]]:pointer-events-none [&_[data-icon]]:size-3 [&_[data-icon]]:shrink-0',
{
variants: {
size: {
@@ -110,7 +113,7 @@ export const statusBadgeVariants = cva(
},
],
defaultVariants: {
appearance: 'soft',
appearance: 'plain',
size: 'sm',
variant: 'neutral',
},
@@ -129,7 +132,8 @@ export function StatusBadge({
variant: variantProp,
...props
}: StatusBadgeProps) {
const appearance = appearanceProp ?? 'soft'
const contextAppearance = React.useContext(StatusBadgeAppearanceContext)
const appearance = appearanceProp ?? contextAppearance ?? 'plain'
const size = sizeProp ?? 'sm'
const variant = variantProp ?? 'neutral'
const leadingIcons: React.ReactNode[] = []
@@ -163,7 +167,10 @@ export function StatusBadge({
<>
{leadingIcons}
{labelChildren.length > 0 && (
<span data-slot='status-badge-label' className='min-w-0 truncate'>
<span
data-slot='status-badge-label'
className='max-w-none shrink-0 overflow-visible whitespace-nowrap'
>
{labelChildren}
</span>
)}
@@ -249,7 +256,7 @@ export function StatusBadgeList<T>(props: StatusBadgeListProps<T>) {
return (
<div
className={cn(
'flex max-w-full min-w-0 items-center gap-1 overflow-hidden',
'flex max-w-full min-w-0 items-center gap-1 overflow-visible',
className
)}
{...domProps}
+3 -3
View File
@@ -32,15 +32,15 @@ import { cn } from '@/lib/utils'
export function ThemeSwitch() {
const { t } = useTranslation()
const { theme, setTheme } = useTheme()
const { resolvedTheme, theme, setTheme } = useTheme()
/* Update theme-color meta tag
* when theme is updated */
useEffect(() => {
const themeColor = theme === 'dark' ? '#020817' : '#fff'
const themeColor = resolvedTheme === 'dark' ? '#2c2c2c' : '#fff'
const metaThemeColor = document.querySelector("meta[name='theme-color']")
if (metaThemeColor) metaThemeColor.setAttribute('content', themeColor)
}, [theme])
}, [resolvedTheme])
return (
<DropdownMenu modal={false}>
+1 -1
View File
@@ -140,7 +140,7 @@ function ComboboxContent({
data-slot='combobox-content'
data-chips={!!anchor}
className={cn(
'dark group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
'group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className
)}
{...props}
+2 -2
View File
@@ -72,7 +72,7 @@ function ContextMenuContent({
<ContextMenuPrimitive.Popup
data-slot='context-menu-content'
className={cn(
'dark z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
'z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className
)}
{...props}
@@ -171,7 +171,7 @@ function ContextMenuSubContent({
return (
<ContextMenuContent
data-slot='context-menu-sub-content'
className='dark shadow-lg'
className='shadow-lg'
side='right'
{...props}
/>
+2 -2
View File
@@ -59,7 +59,7 @@ function DropdownMenuContent({
<MenuPrimitive.Popup
data-slot='dropdown-menu-content'
className={cn(
'dark z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95',
'z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95',
className
)}
{...props}
@@ -160,7 +160,7 @@ function DropdownMenuSubContent({
<DropdownMenuContent
data-slot='dropdown-menu-sub-content'
className={cn(
'dark w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
'w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className
)}
align={align}
+1 -1
View File
@@ -23,7 +23,7 @@ function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
<kbd
data-slot='kbd'
className={cn(
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-muted in-data-[slot=tooltip-content]:text-muted-foreground [&_svg:not([class*='size-'])]:size-3",
className
)}
{...props}
+2 -2
View File
@@ -100,7 +100,7 @@ function MenubarContent({
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
'dark min-w-36 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95',
'min-w-36 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95',
className
)}
{...props}
@@ -275,7 +275,7 @@ function MenubarSubContent({
<DropdownMenuSubContent
data-slot='menubar-sub-content'
className={cn(
'dark min-w-32 rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
'min-w-32 rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className
)}
{...props}
+1 -1
View File
@@ -112,7 +112,7 @@ function SelectContent({
data-slot='select-content'
data-align-trigger={alignItemWithTrigger}
className={cn(
'dark relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
'relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className
)}
{...props}
+27 -3
View File
@@ -26,15 +26,16 @@ import {
Loading03Icon,
} from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import { useTheme } from 'next-themes'
import { Toaster as Sonner, type ToasterProps } from 'sonner'
import { useTheme } from '@/context/theme-provider'
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme()
const { resolvedTheme } = useTheme()
return (
<Sonner
theme={theme as ToasterProps['theme']}
theme={resolvedTheme}
className='toaster group'
icons={{
success: (
@@ -78,6 +79,29 @@ const Toaster = ({ ...props }: ToasterProps) => {
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--success-bg':
'color-mix(in oklch, var(--success) 10%, var(--popover))',
'--success-text':
'color-mix(in oklch, var(--success) 55%, var(--foreground))',
'--success-border':
'color-mix(in oklch, var(--success) 32%, var(--border))',
'--info-bg': 'color-mix(in oklch, var(--info) 10%, var(--popover))',
'--info-text':
'color-mix(in oklch, var(--info) 55%, var(--foreground))',
'--info-border':
'color-mix(in oklch, var(--info) 32%, var(--border))',
'--warning-bg':
'color-mix(in oklch, var(--warning) 10%, var(--popover))',
'--warning-text':
'color-mix(in oklch, var(--warning) 55%, var(--foreground))',
'--warning-border':
'color-mix(in oklch, var(--warning) 32%, var(--border))',
'--error-bg':
'color-mix(in oklch, var(--destructive) 10%, var(--popover))',
'--error-text':
'color-mix(in oklch, var(--destructive) 55%, var(--foreground))',
'--error-border':
'color-mix(in oklch, var(--destructive) 32%, var(--border))',
'--border-radius': 'var(--radius)',
} as React.CSSProperties
}
+6 -4
View File
@@ -21,7 +21,7 @@ import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'
import { cn } from '@/lib/utils'
function TooltipProvider({
delay = 0,
delay = 300,
...props
}: TooltipPrimitive.Provider.Props) {
return (
@@ -44,7 +44,7 @@ function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
function TooltipContent({
className,
side = 'top',
sideOffset = 4,
sideOffset = 8,
align = 'center',
alignOffset = 0,
children,
@@ -66,13 +66,15 @@ function TooltipContent({
<TooltipPrimitive.Popup
data-slot='tooltip-content'
className={cn(
'z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
'bg-popover text-popover-foreground border-border z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs leading-snug shadow-md transition-opacity duration-100 has-data-[slot=kbd]:pr-1.5 data-[ending-style]:opacity-0 data-[instant]:transition-none data-[starting-style]:opacity-0 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm',
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className='bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5' />
<TooltipPrimitive.Arrow className='z-50 block h-1.5 w-3 overflow-hidden data-[side=bottom]:-top-1.5 data-[side=inline-end]:-left-2 data-[side=inline-end]:-rotate-90 data-[side=inline-start]:-right-2 data-[side=inline-start]:rotate-90 data-[side=left]:-right-2 data-[side=left]:rotate-90 data-[side=right]:-left-2 data-[side=right]:-rotate-90 data-[side=top]:-bottom-1.5 data-[side=top]:rotate-180'>
<span className='border-border bg-popover absolute bottom-0 left-1/2 size-2 -translate-x-1/2 translate-y-1/2 rotate-45 border' />
</TooltipPrimitive.Arrow>
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
+95 -115
View File
@@ -17,22 +17,24 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { flexRender, type Row } from '@tanstack/react-table'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import {
BadgeListCellDisplayContext,
DataTableCardField,
DataTableCardRow,
} from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { cn } from '@/lib/utils'
import { isTagAggregateRow } from '../lib'
import { CHANNEL_STATUS } from '../constants'
import { isTagAggregateRow, parseGroupsList } from '../lib'
import type { Channel } from '../types'
import { ChannelRowActionsLayoutContext } from './channel-row-actions-context'
import { useChannels } from './channels-provider'
const SENSITIVE_MASK = '••••'
/**
* Bespoke channel card for the card view. Reuses every column's existing cell
* renderer via `flexRender`, so the table's information and interactions are
* preserved. All fields are always visible — no "More" disclosure.
* renderer via `flexRender`, preserving the original compact two-column
* hierarchy and all row interactions.
*/
function ChannelCardComponent({
row,
@@ -42,9 +44,9 @@ function ChannelCardComponent({
isSelected: boolean
}) {
const { t } = useTranslation()
const { sensitiveVisible } = useChannels()
const isTagRow = isTagAggregateRow(row.original)
const cells = row.getVisibleCells()
const visibleColumnIds = new Set(cells.map((cell) => cell.column.id))
const cells = row.getAllCells()
const renderCell = (id: string) => {
const cell = cells.find((candidate) => candidate.column.id === id)
@@ -54,129 +56,107 @@ function ChannelCardComponent({
return flexRender(cell.column.columnDef.cell, cell.getContext())
}
const fieldLabels: Record<string, string> = {
balance: t('Used / Remaining'),
response_time: t('Response'),
test_time: t('Last Tested'),
}
const groups = parseGroupsList(row.original.group ?? '')
const selectCell = renderCell('select')
const typeCell = renderCell('type')
const idCell = renderCell('id')
const nameCell = renderCell('name')
const statusCell = renderCell('status')
const actionsCell = renderCell('actions')
const priorityCell = renderCell('priority')
const weightCell = renderCell('weight')
const balanceCell = renderCell('balance')
const modelsCell = renderCell('models')
const groupsCell = renderCell('group')
const tagCell = renderCell('tag')
const responseCell = renderCell('response_time')
const testCell = renderCell('test_time')
const showId = !isTagRow && visibleColumnIds.has('id')
const showTag = !isTagRow && visibleColumnIds.has('tag')
const showModels = !isTagRow && visibleColumnIds.has('models')
const showTestTime = !isTagRow && visibleColumnIds.has('test_time')
const hasStatRows =
showId ||
showTag ||
showTestTime ||
visibleColumnIds.has('balance') ||
visibleColumnIds.has('response_time') ||
visibleColumnIds.has('priority') ||
visibleColumnIds.has('weight')
const hasBadgeSections = showModels || visibleColumnIds.has('group')
const labelClass = 'text-muted-foreground text-xs font-medium select-none'
const showStatusBadge =
isTagRow ||
(row.original.status !== CHANNEL_STATUS.ENABLED &&
row.original.status !== CHANNEL_STATUS.MANUAL_DISABLED)
return (
<ChannelRowActionsLayoutContext.Provider value='card'>
<BadgeListCellDisplayContext.Provider value='full'>
<div
data-state={isSelected ? 'selected' : undefined}
className='flex h-full min-w-0 flex-col'
>
<div className='flex min-w-0 items-start gap-2.5'>
{!isTagRow && selectCell && (
<span className='mt-0.5 shrink-0'>{selectCell}</span>
<div
data-state={isSelected ? 'selected' : undefined}
className='flex flex-col gap-3'
>
<div className='flex items-center justify-between gap-2'>
<div className='flex min-w-0 flex-1 items-center gap-2'>
{!isTagRow && selectCell && (
<span className='shrink-0'>{selectCell}</span>
)}
<div className='min-w-0'>{typeCell}</div>
</div>
<div className='flex shrink-0 items-center gap-1.5'>
{showStatusBadge && statusCell}
<ChannelRowActionsLayoutContext.Provider value='card'>
{actionsCell}
</ChannelRowActionsLayoutContext.Provider>
</div>
</div>
<div className='flex items-start justify-between gap-3'>
<div className='flex min-w-0 flex-1 flex-col gap-3'>
<div className='min-w-0 text-sm'>
{!isTagRow && (
<div className={labelClass}>
#{sensitiveVisible ? row.original.id : SENSITIVE_MASK}
</div>
)}
<div className='min-w-0 flex-1'>
{visibleColumnIds.has('name') && (
<div className='min-w-0 text-[15px] leading-tight font-semibold break-words'>
{nameCell}
</div>
)}
{visibleColumnIds.has('type') && (
<div className='mt-1.5 min-w-0'>{typeCell}</div>
)}
</div>
<div className='flex shrink-0 items-center gap-1'>
{visibleColumnIds.has('status') && statusCell}
{actionsCell}
</div>
{nameCell}
</div>
{hasStatRows && (
<div className='mt-3 space-y-0.5 border-t pt-3'>
{showId && (
<DataTableCardRow label={t('ID')} contentMode='full'>
{idCell}
</DataTableCardRow>
)}
{visibleColumnIds.has('balance') && (
<DataTableCardRow
label={t('Used / Remaining')}
contentMode='full'
>
{balanceCell}
</DataTableCardRow>
)}
{visibleColumnIds.has('response_time') && (
<DataTableCardRow label={t('Response')} contentMode='full'>
{responseCell}
</DataTableCardRow>
)}
{showTestTime && (
<DataTableCardRow label={t('Last Tested')} contentMode='full'>
{testCell}
</DataTableCardRow>
)}
{visibleColumnIds.has('priority') && (
<DataTableCardRow label={t('Priority')} contentMode='full'>
{priorityCell}
</DataTableCardRow>
)}
{visibleColumnIds.has('weight') && (
<DataTableCardRow label={t('Weight')} contentMode='full'>
{weightCell}
</DataTableCardRow>
)}
{showTag && (
<DataTableCardRow label={t('Tag')} contentMode='wrap'>
{tagCell}
</DataTableCardRow>
)}
<div className='min-w-0'>
<div className={cn('mb-1', labelClass)}>{fieldLabels.balance}</div>
<div className='min-w-0 text-sm'>
{balanceCell ?? <span className='text-muted-foreground'>-</span>}
</div>
)}
{hasBadgeSections && (
<div className='mt-3 space-y-3 border-t pt-3'>
{visibleColumnIds.has('group') && (
<DataTableCardField label={t('Groups')} contentMode='full'>
{groupsCell ?? (
<span className='text-muted-foreground'>-</span>
)}
</DataTableCardField>
)}
{showModels && (
<DataTableCardField label={t('Models')} contentMode='full'>
{modelsCell ?? (
<span className='text-muted-foreground'>-</span>
)}
</DataTableCardField>
)}
</div>
)}
</div>
</div>
</BadgeListCellDisplayContext.Provider>
</ChannelRowActionsLayoutContext.Provider>
<div className='grid shrink-0 grid-cols-[auto_auto] items-center gap-x-3 gap-y-1'>
<span className={labelClass}>{t('Priority')}</span>
<span className={labelClass}>{t('Weight')}</span>
<div className='flex justify-start'>{priorityCell}</div>
<div className='flex justify-start'>{weightCell}</div>
<span className={cn('mt-2', labelClass)}>
{fieldLabels.response_time}
</span>
<span className={cn('mt-2', labelClass)}>
{fieldLabels.test_time}
</span>
<div className='text-sm'>
{responseCell ?? <span className='text-muted-foreground'>-</span>}
</div>
<div className='text-sm'>
{testCell ?? <span className='text-muted-foreground'>-</span>}
</div>
</div>
</div>
<div className='min-w-0'>
{groups.length > 0 ? (
<div className='-ml-1.5 flex flex-wrap gap-1'>
{groups.map((group) => (
<GroupBadge
key={group}
group={group}
label={sensitiveVisible ? undefined : SENSITIVE_MASK}
size='sm'
/>
))}
</div>
) : (
<span className='text-muted-foreground text-sm'>-</span>
)}
</div>
</div>
)
}
export const ChannelCard = ChannelCardComponent
export const ChannelCard = memo(ChannelCardComponent)
@@ -456,7 +456,7 @@ function BalanceCell({ channel }: { channel: Channel }) {
variant={remainingBadgeVariant}
size='sm'
render={<button type='button' />}
className='cursor-pointer'
className='cursor-pointer underline-offset-2 hover:underline'
onClick={handleClickUpdate}
>
{remainingBadgeLabel}
@@ -732,7 +732,7 @@ export function useChannelsColumns(
: undefined
return (
<div className='flex max-w-full min-w-0 items-center gap-2 overflow-hidden'>
<div className='flex w-max items-center gap-2'>
{isMultiKey && (
<TooltipProvider delay={100}>
<Tooltip>
@@ -751,15 +751,10 @@ export function useChannelsColumns(
)}
<TooltipProvider delay={300}>
<Tooltip>
<TooltipTrigger
render={
<div className='max-w-full min-w-0 overflow-hidden' />
}
>
<TooltipTrigger render={<div className='shrink-0' />}>
<ProviderBadge
iconKey={`${iconName}.Color`}
label={typeName}
className='max-w-full min-w-0 overflow-hidden'
/>
</TooltipTrigger>
<TooltipContent side='top'>{typeName}</TooltipContent>
@@ -163,13 +163,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
}
return (
<div
className={
layout === 'card'
? 'flex items-center'
: '-ml-1.5 flex items-center gap-1'
}
>
<div className='-ml-1.5 flex items-center gap-1'>
{layout !== 'card' && (
<Tooltip>
<TooltipTrigger
@@ -191,54 +185,71 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
</Tooltip>
)}
{layout !== 'card' && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={handleDirectTest}
disabled={isTesting}
aria-label={t('Test Connection')}
/>
}
>
{isTesting ? (
<Loader2 className='size-4 animate-spin' />
) : (
<Gauge className='size-4' />
)}
</TooltipTrigger>
<TooltipContent>{t('Test Connection')}</TooltipContent>
</Tooltip>
{layout === 'card' && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={handleDirectTest}
disabled={isTesting}
aria-label={t('Test Connection')}
onClick={(event) => {
event.stopPropagation()
handleTest()
}}
aria-label={t('Test Channel Connection')}
/>
}
>
{isTesting ? (
<Loader2 className='size-4 animate-spin' />
) : (
<Gauge className='size-4' />
)}
<PlugZap className='size-4' />
</TooltipTrigger>
<TooltipContent>{t('Test Connection')}</TooltipContent>
<TooltipContent>{t('Test Channel Connection')}</TooltipContent>
</Tooltip>
)}
{layout !== 'card' && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={handleToggleStatus}
disabled={isTogglingStatus}
aria-label={isEnabled ? t('Disable') : t('Enable')}
className={
isEnabled
? 'text-destructive hover:text-destructive'
: 'text-success hover:text-success'
}
/>
}
>
{statusIcon}
</TooltipTrigger>
<TooltipContent>
{isEnabled ? t('Disable') : t('Enable')}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={handleToggleStatus}
disabled={isTogglingStatus}
aria-label={isEnabled ? t('Disable') : t('Enable')}
className={
isEnabled
? 'text-destructive hover:text-destructive'
: 'text-success hover:text-success'
}
/>
}
>
{statusIcon}
</TooltipTrigger>
<TooltipContent>
{isEnabled ? t('Disable') : t('Enable')}
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger
@@ -270,21 +281,6 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<PlugZap size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{layout === 'card' && (
<DropdownMenuItem
disabled={isTogglingStatus}
onClick={() => void handleToggleStatus()}
className={
isEnabled
? 'text-destructive focus:text-destructive'
: 'text-success focus:text-success'
}
>
{isEnabled ? t('Disable') : t('Enable')}
<DropdownMenuShortcut>{statusIcon}</DropdownMenuShortcut>
</DropdownMenuItem>
)}
{/* Query Balance */}
<DropdownMenuItem onClick={handleQueryBalance}>
{t('Query Balance')}
@@ -1190,7 +1190,7 @@ function TestStatusCell({ result }: { result?: TestResult }) {
return (
<StatusBadge variant='info'>
<Loader2 className='size-3.5 shrink-0 animate-spin' />
<span className='min-w-0 truncate leading-normal'>
<span className='leading-normal whitespace-nowrap'>
{t('Testing...')}
</span>
</StatusBadge>
@@ -1363,7 +1363,7 @@ function FailureDetailsSheet({
onClick={() => copyToClipboard(details.details)}
>
{copiedText === details.details ? (
<Check className='mr-2 h-4 w-4 text-green-600' />
<Check className='text-success mr-2 h-4 w-4' />
) : (
<Copy className='mr-2 h-4 w-4' />
)}
@@ -1057,7 +1057,7 @@ export function CodexUsageDialog({
>
<div className='flex flex-col gap-4'>
{errorMessage && (
<div className='rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400'>
<div className='border-destructive/25 bg-destructive/8 text-status-destructive rounded-lg border px-4 py-3 text-sm'>
{errorMessage}
</div>
)}
@@ -76,7 +76,7 @@ export function MissingModelsConfirmationDialog({
'The following models in the model redirect have not been added to the "Models" list and may fail during invocation due to missing available models:'
)}
</div>
<div className='rounded-md bg-red-50 p-2 font-mono text-xs break-all text-red-600 dark:bg-red-950/50 dark:text-red-400'>
<div className='bg-destructive/8 text-status-destructive rounded-md p-2 font-mono text-xs break-all'>
{missingModels.join(', ')}
</div>
<div>
@@ -964,7 +964,6 @@ const validateOperations = (
if (headers.length === 0)
return t('Rule {{line}} pass_headers format is invalid', { line })
}
}
return ''
}
@@ -96,7 +96,7 @@ export function NumericSpinnerInput({
const commitValue = () => {
setEditing(false)
const num = Number(localValue)
if (isNaN(num) || localValue === '' || localValue === '-') {
if (Number.isNaN(num) || localValue === '' || localValue === '-') {
setLocalValue(String(value ?? 0))
return
}
@@ -219,7 +219,7 @@ function ModelBadge(props: { model: PerfModelSummary }) {
return (
<StatusBadge variant={variant}>
<span className='mr-1 max-w-[10rem] truncate'>{model.model_name}</span>
<span className='mr-1 whitespace-nowrap'>{model.model_name}</span>
<span className='tabular-nums'>
{formatUptimePct(model.success_rate)}
</span>
+4 -4
View File
@@ -23,12 +23,12 @@ import type { PingStatus } from '@/features/dashboard/types'
*/
export function getLatencyColorClass(latency: number): string {
if (latency < 200) {
return 'text-green-600 dark:text-green-400'
return 'text-status-success'
}
if (latency < 500) {
return 'text-yellow-600 dark:text-yellow-400'
return 'text-status-warning'
}
return 'text-red-600 dark:text-red-400'
return 'text-status-destructive'
}
/**
@@ -46,7 +46,7 @@ export async function testUrlLatency(url: string): Promise<PingStatus> {
const latency = Math.round(endTime - startTime)
return { latency, testing: false, error: false }
} catch (_error) {
} catch {
return { latency: null, testing: false, error: true }
}
}
@@ -58,7 +58,7 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
if (isLoading) {
copyIcon = <Loader2 className='size-3.5 animate-spin' />
} else if (isCopied) {
copyIcon = <Check className='size-3.5 text-green-600' />
copyIcon = <Check className='text-success size-3.5' />
}
let copyTooltip = t('Copy API key')
@@ -74,7 +74,7 @@ function LoadingStep({
{status === 'loading' && (
<Loader2 className='text-primary h-5 w-5 animate-spin' />
)}
{status === 'done' && <CheckCircle2 className='h-5 w-5 text-green-500' />}
{status === 'done' && <CheckCircle2 className='text-success h-5 w-5' />}
{status === 'pending' && (
<Circle className='text-muted-foreground/40 h-5 w-5' />
)}
@@ -172,8 +172,8 @@ export function DeploymentAccessGuard({
return (
<div className='mx-auto mt-8 max-w-md'>
<div className='text-center'>
<div className='mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-red-100 dark:bg-red-900/20'>
<WifiOff className='h-8 w-8 text-red-600 dark:text-red-400' />
<div className='bg-destructive/10 text-destructive mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl'>
<WifiOff className='size-8' />
</div>
<h3 className='mb-6 text-xl font-semibold'>
{t('Connection failed')}
@@ -199,5 +199,5 @@ export function DeploymentAccessGuard({
)
}
return <>{children}</>
return children
}
@@ -78,12 +78,7 @@ export function useDeploymentsColumns(opts: {
cell: ({ getValue }) => {
const name = String(getValue() || '-') || '-'
return (
<CopyableStatusBadge
value={name}
variant='neutral'
size='sm'
className='h-auto overflow-visible [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
>
<CopyableStatusBadge value={name} variant='neutral' size='sm'>
{name}
</CopyableStatusBadge>
)
@@ -131,11 +126,7 @@ export function useDeploymentsColumns(opts: {
return <span className='text-muted-foreground text-xs'>-</span>
}
return (
<StatusBadge
variant='neutral'
size='sm'
className='h-auto overflow-visible [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
>
<StatusBadge variant='neutral' size='sm'>
{String(provider)}
</StatusBadge>
)
@@ -232,7 +223,6 @@ export function useDeploymentsColumns(opts: {
value={String(hardware)}
variant='neutral'
size='sm'
className='h-auto overflow-visible [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
>
{String(hardware)}
</CopyableStatusBadge>
@@ -155,28 +155,32 @@ export function ViewLogsDialog({
if (isLoadingContainers || isLoadingLogs) {
logsContent = (
<div className='flex items-center justify-center py-8'>
<Loader2 className='h-6 w-6 animate-spin text-gray-400' />
<Loader2 className='text-muted-foreground size-6 animate-spin' />
</div>
)
} else if (containers.length === 0) {
logsContent = (
<div className='py-8 text-center text-gray-400'>{t('No containers')}</div>
<div className='text-muted-foreground py-8 text-center'>
{t('No containers')}
</div>
)
} else if (!containerId) {
logsContent = (
<div className='py-8 text-center text-gray-400'>
<div className='text-muted-foreground py-8 text-center'>
{t('Please select a container')}
</div>
)
} else if (!logsText.trim()) {
logsContent = (
<div className='py-8 text-center text-gray-400'>{t('No logs')}</div>
<div className='text-muted-foreground py-8 text-center'>
{t('No logs')}
</div>
)
} else {
logsContent = (
<div className='font-mono text-sm'>
{keyedLogLines.map(({ key, line }) => (
<div key={key} className='whitespace-pre-wrap text-gray-200'>
<div key={key} className='text-foreground whitespace-pre-wrap'>
{line}
</div>
))}
@@ -143,12 +143,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
cell: ({ row }) => {
const name = row.getValue('model_name') as string
return (
<CopyableStatusBadge
value={name}
variant='neutral'
size='sm'
className='h-auto overflow-visible [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
>
<CopyableStatusBadge value={name} variant='neutral' size='sm'>
{name}
</CopyableStatusBadge>
)
@@ -255,11 +250,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
return (
<BadgeCell className='overflow-visible'>
<ProviderBadge
iconKey={vendor.icon}
label={vendor.name}
className='h-auto overflow-visible [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
/>
<ProviderBadge iconKey={vendor.icon} label={vendor.name} />
</BadgeCell>
)
},
@@ -123,10 +123,7 @@ function PlaygroundParameterContent({
>
{t(control.labelKey)}
</label>
<StatusBadge
appearance='outline'
className='max-w-24 shrink-0'
>
<StatusBadge appearance='outline'>
{t(getParameterControlValueText(control.key, value))}
</StatusBadge>
</div>
@@ -110,7 +110,7 @@ export function MessageActions({
if (hasContent) {
actions.push({
className: isCopied ? 'text-green-600' : '',
className: isCopied ? 'text-success' : '',
icon: isCopied ? Check : Copy,
label: isCopied
? MESSAGE_ACTION_LABELS.COPIED
@@ -63,7 +63,7 @@ export function MessageError({
return (
<Alert variant='default' className={className}>
<AlertTriangle className='text-orange-500' />
<AlertTriangle className='text-warning' />
<AlertTitle>{t('Model Price Not Configured')}</AlertTitle>
<AlertDescription className='space-y-2'>
<p>{content}</p>
@@ -260,7 +260,7 @@ export function DynamicPricingBreakdown({
{t('Tiered price table')}
</div>
<div className='space-y-1.5 sm:hidden'>
{tiers.map((tier, i) => {
{tiers.map((tier) => {
const condSummary = formatConditionSummary(tier.conditions, t)
const isMatched =
matchedTierLabel != null &&
@@ -268,7 +268,7 @@ export function DynamicPricingBreakdown({
tier.label === matchedTierLabel
return (
<div
key={`tier-mobile-${i}`}
key={`tier-mobile-${tier.label || condSummary || 'default'}`}
className={cn(
'rounded-md border p-2',
isMatched && 'border-success/40 bg-success/10'
@@ -416,27 +416,30 @@ export function DynamicPricingBreakdown({
{t('Conditional multipliers')}
</div>
<ul className='space-y-1.5'>
{ruleGroups.map((group, gi) => (
<li
key={`group-${gi}`}
className='bg-muted/50 flex items-center justify-between gap-3 rounded-md px-3 py-2'
>
<span
className={cn(
'text-foreground break-all',
compact ? 'text-xs' : 'text-sm'
)}
{ruleGroups.map((group) => {
const description = describeGroup(group, t)
return (
<li
key={`${description}-${group.multiplier}`}
className='bg-muted/50 flex items-center justify-between gap-3 rounded-md px-3 py-2'
>
{describeGroup(group, t)}
</span>
<Badge
variant='secondary'
className='shrink-0 bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300'
>
{group.multiplier}x
</Badge>
</li>
))}
<span
className={cn(
'text-foreground break-all',
compact ? 'text-xs' : 'text-sm'
)}
>
{description}
</span>
<Badge
variant='secondary'
className='border-warning/30 bg-warning/10 text-status-warning shrink-0'
>
{group.multiplier}x
</Badge>
</li>
)
})}
</ul>
</div>
)}
@@ -60,8 +60,8 @@ export function TelegramBindDialog({
</Alert>
<div className='flex flex-col items-center justify-center gap-4 rounded-lg border p-6'>
<div className='flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100 dark:bg-blue-900'>
<Send className='h-6 w-6 text-blue-600 dark:text-blue-400' />
<div className='bg-info/10 text-info flex h-12 w-12 items-center justify-center rounded-xl'>
<Send className='size-6' />
</div>
<div className='text-center'>
+2 -2
View File
@@ -42,9 +42,9 @@ export function AdminStep({ form, rootInitialized }: AdminStepProps) {
const { t } = useTranslation()
if (rootInitialized) {
return (
<Alert className='border-sky-200 bg-sky-50 dark:border-sky-900/60 dark:bg-sky-950/40'>
<Alert className='border-info/25 bg-info/8'>
<AlertDescription className='flex items-start gap-2'>
<ShieldCheck className='mt-0.5 size-4 text-sky-500' />
<ShieldCheck className='text-info mt-0.5 size-4' />
{t(
'The administrator account is already initialized. You can keep your existing credentials and continue to the next step.'
)}
@@ -52,7 +52,7 @@ export function CompleteStep({ status, values }: CompleteStepProps) {
return (
<div className='flex flex-col items-center gap-6 text-center'>
<div className='rounded-2xl bg-emerald-500/10 p-4 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-300'>
<div className='bg-success/10 text-success rounded-xl p-4'>
<CheckCircle2 className='size-8' />
</div>
<div className='space-y-2'>
@@ -107,9 +107,9 @@ export function DatabaseStep({ status }: DatabaseStepProps) {
</div>
{status?.database_type === 'sqlite' && (
<Alert className='border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/40'>
<Alert className='border-warning/30 bg-warning/8'>
<AlertTitle className='flex items-center gap-2'>
<HardDrive className='size-4 text-amber-500' />
<HardDrive className='text-warning size-4' />
{t('Persist your data file')}
</AlertTitle>
<AlertDescription>
@@ -119,7 +119,7 @@ export function DatabaseStep({ status }: DatabaseStepProps) {
)}
</p>
{isElectron && electronDataDir && (
<p className='mt-3 rounded-md bg-amber-100/70 px-3 py-2 font-mono text-xs text-amber-800 dark:bg-amber-900/30 dark:text-amber-200'>
<p className='bg-warning/10 text-status-warning mt-3 rounded-md px-3 py-2 font-mono text-xs'>
{t('Data directory:')} {electronDataDir}
</p>
)}
@@ -135,9 +135,9 @@ export function DatabaseStep({ status }: DatabaseStepProps) {
)}
{status?.database_type === 'mysql' && (
<Alert className='border-emerald-200 bg-emerald-50 dark:border-emerald-900/60 dark:bg-emerald-950/40'>
<Alert className='border-success/25 bg-success/8'>
<AlertTitle className='flex items-center gap-2'>
<Server className='size-4 text-emerald-500' />
<Server className='text-success size-4' />
{t('MySQL detected')}
</AlertTitle>
<AlertDescription>
@@ -149,9 +149,9 @@ export function DatabaseStep({ status }: DatabaseStepProps) {
)}
{status?.database_type === 'postgres' && (
<Alert className='border-sky-200 bg-sky-50 dark:border-sky-900/60 dark:bg-sky-950/40'>
<Alert className='border-info/25 bg-info/8'>
<AlertTitle className='flex items-center gap-2'>
<Server className='size-4 text-sky-500' />
<Server className='text-info size-4' />
{t('PostgreSQL detected')}
</AlertTitle>
<AlertDescription>
@@ -100,31 +100,31 @@ const typeOptions = [
{
value: 'default',
label: 'Default',
color: 'bg-gray-500',
color: 'bg-neutral',
badgeVariant: 'neutral' as const,
},
{
value: 'ongoing',
label: 'Ongoing',
color: 'bg-blue-500',
color: 'bg-info',
badgeVariant: 'info' as const,
},
{
value: 'success',
label: 'Success',
color: 'bg-green-500',
color: 'bg-success',
badgeVariant: 'success' as const,
},
{
value: 'warning',
label: 'Warning',
color: 'bg-orange-500',
color: 'bg-warning',
badgeVariant: 'warning' as const,
},
{
value: 'error',
label: 'Error',
color: 'bg-red-500',
color: 'bg-destructive',
badgeVariant: 'destructive' as const,
},
]
@@ -156,7 +156,7 @@ export function AmountDiscountDialog({
placeholder={t('e.g., 100')}
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 0)
field.onChange(Number.parseInt(e.target.value) || 0)
}
disabled={isEditMode}
/>
@@ -188,14 +188,14 @@ export function AmountDiscountDialog({
placeholder={t('e.g., 0.95')}
{...field}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
field.onChange(Number.parseFloat(e.target.value) || 0)
}
/>
</FormControl>
<FormDescription>
{t('Final price multiplier (0.95 = 5% discount')}
{discountPercentage > 0 && (
<span className='ml-1 font-medium text-green-600 dark:text-green-400'>
<span className='text-status-success ml-1 font-medium'>
= {discountPercentage}
{t('% off')}
</span>
@@ -234,7 +234,7 @@ export function IoNetDeploymentSettingsSection({
{testState.ok === true ? (
<Alert variant='default' className='flex items-center gap-2'>
<CheckCircle2 className='size-4 text-green-600' />
<CheckCircle2 className='text-success size-4' />
<div>
<AlertTitle>{t('Connection successful')}</AlertTitle>
<AlertDescription>
@@ -1251,24 +1251,24 @@ export function PaymentSettingsSection({
</p>
</div>
<div className='rounded-md bg-blue-50 p-4 text-sm text-blue-900 dark:bg-blue-950 dark:text-blue-100'>
<div className='border-info/25 bg-info/8 rounded-md border p-4 text-sm'>
<p className='mb-2 font-medium'>
{t('Webhook Configuration:')}
</p>
<ul className='list-inside list-disc space-y-1'>
<li>
{t('Webhook URL:')}{' '}
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
<code className='bg-info/10 rounded px-1 py-0.5 text-xs'>
{'<ServerAddress>/api/stripe/webhook'}
</code>
</li>
<li>
{t('Required events:')}{' '}
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
<code className='bg-info/10 rounded px-1 py-0.5 text-xs'>
{t('checkout.session.completed')}
</code>{' '}
{t('and')}{' '}
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
<code className='bg-info/10 rounded px-1 py-0.5 text-xs'>
{t('checkout.session.expired')}
</code>
</li>
@@ -1443,14 +1443,14 @@ export function PaymentSettingsSection({
</p>
</div>
<div className='rounded-md bg-blue-50 p-4 text-sm text-blue-900 dark:bg-blue-950 dark:text-blue-100'>
<div className='border-info/25 bg-info/8 rounded-md border p-4 text-sm'>
<p className='mb-2 font-medium'>
{t('Webhook Configuration:')}
</p>
<ul className='list-inside list-disc space-y-1'>
<li>
{t('Webhook URL:')}{' '}
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
<code className='bg-info/10 rounded px-1 py-0.5 text-xs'>
{'<ServerAddress>/api/creem/webhook'}
</code>
</li>
@@ -384,19 +384,19 @@ export function WaffoPancakeSettingsSection({
</p>
</div>
<div className='grid min-w-0 gap-x-5 gap-y-4 lg:grid-cols-2'>
{/* Blue box — webhook configuration only. */}
<div className='rounded-md bg-blue-50 p-4 text-sm text-blue-900 lg:col-span-2 dark:bg-blue-950 dark:text-blue-100'>
{/* Webhook configuration notice. */}
<div className='border-info/25 bg-info/8 rounded-md border p-4 text-sm lg:col-span-2'>
<p className='mb-2 font-medium'>{t('Webhook Configuration:')}</p>
<ul className='list-inside list-disc space-y-1'>
<li>
{t('Webhook URL (Test):')}{' '}
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
<code className='bg-info/10 rounded px-1 py-0.5 text-xs'>
{'<ServerAddress>/api/waffo-pancake/webhook/test'}
</code>
</li>
<li>
{t('Webhook URL (Production):')}{' '}
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
<code className='bg-info/10 rounded px-1 py-0.5 text-xs'>
{'<ServerAddress>/api/waffo-pancake/webhook/prod'}
</code>
</li>
@@ -474,7 +474,7 @@ export function WaffoPancakeSettingsSection({
for. Subscriptions reuse the same Store but get their own
per-plan product, configured in the Subscriptions admin.
*/}
<div className='rounded-md border border-blue-200 bg-blue-50 p-3 text-xs text-blue-900 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-100'>
<div className='border-info/25 bg-info/8 rounded-md border p-3 text-xs'>
<p className='mb-1 font-medium'>
{t('Why only one store + product?')}
</p>
@@ -86,7 +86,7 @@ export function createTimestampColumn<T>(config: {
cell: ({ row }) => {
const timestamp = row.getValue(accessorKey) as number
if (!timestamp) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
return <span className='text-subtle-foreground text-xs'>-</span>
}
return (
<span className='text-xs tabular-nums'>
@@ -135,7 +135,7 @@ export function createDurationColumn<T>(config: {
)
if (!duration) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
return <span className='text-subtle-foreground text-xs'>-</span>
}
const variant =
@@ -173,7 +173,7 @@ export function createChannelColumn<T>(config: {
cell: ({ row }) => {
const channelId = row.getValue(accessorKey) as number
if (!channelId) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
return <span className='text-subtle-foreground text-xs'>-</span>
}
return (
<CopyableStatusBadge
@@ -215,7 +215,7 @@ export function createFailReasonColumn<T>(config: {
const [dialogOpen, setDialogOpen] = useState(false)
if (!failReason) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
return <span className='text-subtle-foreground text-xs'>-</span>
}
return (
@@ -265,7 +265,7 @@ export function createProgressColumn<T>(config: {
cell: ({ row }) => {
const progress = row.getValue(accessorKey) as string
if (!progress) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
return <span className='text-subtle-foreground text-xs'>-</span>
}
return (
<StatusBadge variant='neutral' className='font-mono'>
@@ -442,7 +442,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
)}
</div>
{log.channel_name && (
<span className='text-muted-foreground/70 truncate text-xs'>
<span className='text-subtle-foreground truncate text-xs'>
{channelName}
</span>
)}
@@ -579,25 +579,20 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
if (groupRatioText) metaParts.push(groupRatioText)
return (
<div className='flex max-w-[200px] flex-col gap-0.5'>
<div className='flex w-max flex-col gap-0.5'>
<TooltipProvider delay={300}>
<Tooltip>
<TooltipTrigger render={<div className='max-w-full' />}>
<TooltipTrigger render={<div className='shrink-0' />}>
{sensitiveVisible ? (
<CopyableStatusBadge
value={tokenName}
variant='neutral'
size='sm'
className='max-w-full'
>
{displayName}
</CopyableStatusBadge>
) : (
<StatusBadge
variant='neutral'
size='sm'
className='max-w-full'
>
<StatusBadge variant='neutral' size='sm'>
{displayName}
</StatusBadge>
)}
@@ -610,7 +605,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
</Tooltip>
</TooltipProvider>
{metaParts.length > 0 && (
<span className='text-muted-foreground/60 text-xs [overflow-wrap:anywhere] break-words'>
<span className='text-subtle-foreground text-xs [overflow-wrap:anywhere] break-words'>
{metaParts.join(' · ')}
</span>
)}
@@ -700,7 +695,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
))}
</div>
<div className='flex items-center gap-1 text-xs leading-none'>
<span className='text-muted-foreground/60 text-xs leading-none'>
<span className='text-subtle-foreground text-xs leading-none'>
{log.is_stream ? t('Stream') : t('Non-stream')}
{tokensPerSecond != null && (
<>
@@ -782,12 +777,12 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
{(cacheReadTokens > 0 || cacheWriteTokens > 0) && (
<div className='flex items-center gap-1 text-xs'>
{cacheReadTokens > 0 && (
<span className='text-muted-foreground/60'>
<span className='text-subtle-foreground'>
{t('Cache')} {cacheReadTokens.toLocaleString()}
</span>
)}
{cacheWriteTokens > 0 && (
<span className='text-muted-foreground/60'>
<span className='text-subtle-foreground'>
{cacheWriteTokens.toLocaleString()}
</span>
)}
@@ -860,7 +855,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
const segments = buildDetailSegments(log, other, t, isAdmin)
const primary = segments[0]
const hasMore = segments.length > 1
let detailsContent = <span className='text-muted-foreground/40'></span>
let detailsContent = <span className='text-faint-foreground'></span>
if (log.content) {
detailsContent = (
@@ -873,7 +868,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
if (primary) {
let primaryClassName = 'text-foreground'
if (primary.muted) {
primaryClassName = 'text-muted-foreground/60'
primaryClassName = 'text-subtle-foreground'
} else if (primary.danger) {
primaryClassName = 'text-destructive'
}
@@ -887,7 +882,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
>
{primary.text}
{hasMore && (
<span className='text-muted-foreground/40 ml-0.5'>
<span className='text-faint-foreground ml-0.5'>
+{segments.length - 1}
</span>
)}
@@ -905,7 +900,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
>
{detailsContent}
{ip && (
<span className='text-muted-foreground/60 max-w-full truncate tabular-nums'>
<span className='text-subtle-foreground max-w-full truncate tabular-nums'>
{ip}
</span>
)}
@@ -149,16 +149,16 @@ export function useDrawingLogsColumns(
const mjId = row.getValue('mj_id') as string
if (!mjId) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
return <span className='text-subtle-foreground text-xs'>-</span>
}
return (
<div className='flex max-w-[160px] flex-col gap-0.5'>
<div className='flex w-max flex-col gap-0.5'>
<CopyableStatusBadge
value={mjId}
variant='neutral'
size='sm'
className='h-auto max-w-full overflow-visible font-mono [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
className='font-mono'
>
{mjId}
</CopyableStatusBadge>
@@ -215,7 +215,7 @@ export function useDrawingLogsColumns(
const [dialogOpen, setDialogOpen] = useState(false)
if (!imageUrl) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
return <span className='text-subtle-foreground text-xs'>-</span>
}
return (
@@ -254,7 +254,7 @@ export function useDrawingLogsColumns(
const [dialogOpen, setDialogOpen] = useState(false)
if (!prompt) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
return <span className='text-subtle-foreground text-xs'>-</span>
}
return (
@@ -17,9 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { ColumnDef } from '@tanstack/react-table'
import { Music } from 'lucide-react'
/* eslint-disable react-refresh/only-export-components */
import { useState, useMemo } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CopyableStatusBadge, StatusBadge } from '@/components/status-badge'
@@ -28,14 +27,13 @@ import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
import { formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
import { TASK_ACTIONS, TASK_STATUS } from '../../constants'
import { taskActionMapper, taskStatusMapper } from '../../lib/mappers'
import type { TaskLog } from '../../types'
import {
AudioPreviewDialog,
type AudioClip,
} from '../dialogs/audio-preview-dialog'
import { FailReasonDialog } from '../dialogs/fail-reason-dialog'
getTaskPlatformName,
taskActionMapper,
taskStatusMapper,
} from '../../lib/mappers'
import type { TaskLog } from '../../types'
import { TaskDetailsDialog } from '../dialogs/task-details-dialog'
import { useUsageLogsContext } from '../usage-logs-provider'
import {
createDurationColumn,
@@ -43,53 +41,6 @@ import {
createProgressColumn,
} from './column-helpers'
function parseTaskData(data: unknown): unknown[] {
if (Array.isArray(data)) return data
if (typeof data === 'string') {
try {
const parsed = JSON.parse(data)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
return []
}
function AudioPreviewCell({ log }: { log: TaskLog }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const clips = useMemo(() => {
const data = parseTaskData(log.data)
return data.filter(
(c) =>
c && typeof c === 'object' && (c as Record<string, unknown>).audio_url
)
}, [log.data])
if (clips.length === 0) return null
return (
<>
<button
type='button'
className='group flex items-center gap-1 text-left text-xs'
onClick={() => setOpen(true)}
>
<Music className='text-muted-foreground size-3' />
<span className='text-foreground leading-snug group-hover:underline'>
{t('Click to preview audio')}
</span>
</button>
<AudioPreviewDialog
open={open}
onOpenChange={setOpen}
clips={clips as AudioClip[]}
/>
</>
)
}
export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
const { t } = useTranslation()
const columns: ColumnDef<TaskLog>[] = [
@@ -106,11 +57,11 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
{formatTimestampToDate(submitTime, 'seconds')}
</span>
{log.finish_time ? (
<span className='text-muted-foreground/60 text-xs tabular-nums'>
<span className='text-subtle-foreground text-xs tabular-nums'>
{formatTimestampToDate(log.finish_time, 'seconds')}
</span>
) : (
<span className='text-muted-foreground/50 text-xs'>-</span>
<span className='text-subtle-foreground text-xs'>-</span>
)}
</div>
)
@@ -180,20 +131,21 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
const log = row.original
const taskId = row.getValue('task_id') as string
if (!taskId) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
return <span className='text-subtle-foreground text-xs'>-</span>
}
return (
<div className='flex max-w-[170px] flex-col gap-0.5'>
<div className='flex w-max flex-col gap-0.5'>
<CopyableStatusBadge
value={taskId}
variant='neutral'
size='sm'
className='h-auto max-w-full overflow-visible font-mono [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
className='font-mono'
>
{taskId}
</CopyableStatusBadge>
<span className='text-muted-foreground/60 text-xs [overflow-wrap:anywhere] break-words'>
{t(log.platform)} · {t(taskActionMapper.getLabel(log.action))}
<span className='text-subtle-foreground text-xs [overflow-wrap:anywhere] break-words'>
{getTaskPlatformName(log.platform)} ·{' '}
{t(taskActionMapper.getLabel(log.action))}
</span>
</div>
)
@@ -229,79 +181,14 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
},
createProgressColumn<TaskLog>({ headerLabel: t('Progress') }),
{
accessorKey: 'fail_reason',
id: 'details',
header: t('Details'),
enableSorting: false,
cell: function DetailsCell({ row }) {
const log = row.original
const failReason = row.getValue('fail_reason') as string
const status = log.status
const [dialogOpen, setDialogOpen] = useState(false)
const isSunoSuccess =
log.platform === 'suno' && status === TASK_STATUS.SUCCESS
if (isSunoSuccess) {
const data = parseTaskData(log.data)
if (
data.some(
(c) =>
c &&
typeof c === 'object' &&
(c as Record<string, unknown>).audio_url
)
) {
return <AudioPreviewCell log={log} />
}
}
const isVideoTask =
log.action === TASK_ACTIONS.GENERATE ||
log.action === TASK_ACTIONS.TEXT_GENERATE ||
log.action === TASK_ACTIONS.FIRST_TAIL_GENERATE ||
log.action === TASK_ACTIONS.REFERENCE_GENERATE ||
log.action === TASK_ACTIONS.REMIX_GENERATE
const isSuccess = status === TASK_STATUS.SUCCESS
const isUrl = failReason?.startsWith('http')
if (isSuccess && isVideoTask && isUrl) {
const videoUrl = `/v1/videos/${log.task_id}/content`
return (
<a
href={videoUrl}
target='_blank'
rel='noopener noreferrer'
className='text-foreground text-xs hover:underline'
>
{t('Click to preview video')}
</a>
)
}
if (!failReason) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
}
return (
<>
<button
type='button'
className='group flex max-w-[200px] items-center gap-1 text-left text-xs'
onClick={() => setDialogOpen(true)}
title={t('Click to view full error message')}
>
<span className='text-destructive truncate leading-snug group-hover:underline'>
{failReason}
</span>
</button>
<FailReasonDialog
failReason={failReason}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
</>
)
return <TaskDetailsCell log={row.original} isAdmin={isAdmin} />
},
size: 200,
maxSize: 220,
size: 120,
maxSize: 140,
meta: {
cardRole: 'secondary',
cardOrder: 20,
@@ -313,3 +200,34 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
return columns
}
function TaskDetailsCell({ log, isAdmin }: { log: TaskLog; isAdmin: boolean }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const isFailed = !!log.fail_reason && log.fail_reason.trim() !== ''
return (
<>
<button
type='button'
className={cn(
'text-xs leading-snug hover:underline',
isFailed ? 'text-destructive' : 'text-foreground'
)}
onClick={(e) => {
e.stopPropagation()
setOpen(true)
}}
title={t('View the complete details for this task')}
>
{t('View')}
</button>
<TaskDetailsDialog
log={log}
isAdmin={isAdmin}
open={open}
onOpenChange={setOpen}
/>
</>
)
}
@@ -16,15 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ExternalLink, Copy, Music } from 'lucide-react'
import { ExternalLink, Copy } from 'lucide-react'
import { useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/design-system/button'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { ScrollArea } from '@/components/ui/scroll-area'
export interface AudioClip {
clip_id?: string
@@ -41,12 +39,6 @@ export interface AudioClip {
}
}
interface AudioPreviewDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
clips: AudioClip[]
}
function formatDuration(seconds?: number): string {
if (!seconds || seconds <= 0) return '--:--'
const m = Math.floor(seconds / 60)
@@ -54,7 +46,7 @@ function formatDuration(seconds?: number): string {
return `${m}:${s.toString().padStart(2, '0')}`
}
function AudioClipCard({ clip }: { clip: AudioClip }) {
export function AudioClipCard({ clip }: { clip: AudioClip }) {
const { t } = useTranslation()
const [hasError, setHasError] = useState(false)
const audioRef = useRef<HTMLAudioElement>(null)
@@ -135,39 +127,3 @@ function AudioClipCard({ clip }: { clip: AudioClip }) {
</div>
)
}
export function AudioPreviewDialog(props: AudioPreviewDialogProps) {
const { t } = useTranslation()
const clips = Array.isArray(props.clips) ? props.clips : []
return (
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={
<>
<Music className='h-5 w-5' />
{t('Audio Preview')}
</>
}
contentClassName='sm:max-w-lg'
titleClassName='flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
>
{clips.length === 0 ? (
<p className='text-muted-foreground py-4 text-center text-sm'>
{t('None')}
</p>
) : (
<ScrollArea className='max-h-[60vh]'>
<div className='space-y-3 pr-2'>
{clips.map((clip, idx) => (
<AudioClipCard key={clip.clip_id || clip.id || idx} clip={clip} />
))}
</div>
</ScrollArea>
)}
</Dialog>
)
}
@@ -55,7 +55,7 @@ export function FailReasonDialog({
<Label className='text-sm font-semibold'>
{t('Error Message')}
</Label>
<div className='bg-muted/50 relative rounded-md border border-red-200 p-3'>
<div className='border-destructive/25 bg-destructive/8 relative rounded-md border p-3'>
<Button
variant='ghost'
size='icon'
@@ -64,12 +64,12 @@ export function FailReasonDialog({
title={t('Copy to clipboard')}
>
{copiedText === failReason ? (
<Check className='size-4 text-green-600' />
<Check className='text-success size-4' />
) : (
<Copy className='size-4' />
)}
</Button>
<p className='overflow-wrap-anywhere pr-10 text-sm leading-relaxed break-all whitespace-pre-wrap text-red-600'>
<p className='text-status-destructive overflow-wrap-anywhere pr-10 text-sm leading-relaxed break-all whitespace-pre-wrap'>
{failReason || '-'}
</p>
</div>
@@ -65,7 +65,7 @@ export function PromptDialog({
title={t('Copy to clipboard')}
>
{copiedText === prompt ? (
<Check className='size-4 text-green-600' />
<Check className='text-success size-4' />
) : (
<Copy className='size-4' />
)}
@@ -91,7 +91,7 @@ export function PromptDialog({
title={t('Copy to clipboard')}
>
{copiedText === promptEn ? (
<Check className='size-4 text-green-600' />
<Check className='text-success size-4' />
) : (
<Copy className='size-4' />
)}
@@ -0,0 +1,633 @@
/*
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 {
AlertTriangle,
Check,
Copy,
ExternalLink,
Music,
Video,
} from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { Label } from '@/components/ui/label'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { formatLogQuota, formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
import { TASK_ACTIONS, TASK_PLATFORMS, TASK_STATUS } from '../../constants'
import { formatDuration } from '../../lib/format'
import {
getTaskPlatformName,
taskActionMapper,
taskStatusMapper,
} from '../../lib/mappers'
import type { TaskLog } from '../../types'
import { AudioClipCard, type AudioClip } from './audio-preview-dialog'
const VIDEO_ACTIONS = new Set<string>([
TASK_ACTIONS.GENERATE,
TASK_ACTIONS.TEXT_GENERATE,
TASK_ACTIONS.FIRST_TAIL_GENERATE,
TASK_ACTIONS.REFERENCE_GENERATE,
TASK_ACTIONS.REMIX_GENERATE,
])
const MAX_VALUE_LENGTH = 800
function parseJson(raw: unknown): unknown {
if (raw == null || raw === '') return null
if (typeof raw === 'string') {
try {
return JSON.parse(raw)
} catch {
return raw
}
}
return raw
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
// snake_case / camelCase -> "Title Case" with common acronym fixes, so any
// upstream provider payload renders with readable labels.
function humanizeKey(key: string): string {
const spaced = key
.replaceAll('_', ' ')
.replaceAll(/([a-z0-9])([A-Z])/g, '$1 $2')
.trim()
return spaced
.replaceAll(/\b\w/g, (c) => c.toUpperCase())
.replaceAll(/\bUrl\b/g, 'URL')
.replaceAll(/\bId\b/g, 'ID')
.replaceAll(/\bApi\b/g, 'API')
.replaceAll(/\bFps\b/g, 'FPS')
}
function formatScalar(value: unknown): string {
if (value == null) return '-'
const str = typeof value === 'string' ? value : String(value)
if (str.length > MAX_VALUE_LENGTH) return `${str.slice(0, MAX_VALUE_LENGTH)}`
return str
}
interface FlatEntry {
label: string
value: string
mono?: boolean
}
// Flatten a nested upstream payload into readable rows. Nested objects are
// prefixed with their parent label ("Cost Currency"); arrays of scalars are
// joined, arrays of objects are shown as compact JSON.
function flattenEntries(
obj: Record<string, unknown>,
parent = ''
): FlatEntry[] {
const entries: FlatEntry[] = []
for (const [key, value] of Object.entries(obj)) {
const label = parent ? `${parent} ${humanizeKey(key)}` : humanizeKey(key)
if (isPlainObject(value)) {
entries.push(...flattenEntries(value, label))
} else if (Array.isArray(value)) {
const allScalar = value.every((v) => v == null || typeof v !== 'object')
if (allScalar) {
entries.push({
label,
value: value.map(formatScalar).join(', ') || '-',
})
} else {
entries.push({
label,
value: formatScalar(JSON.stringify(value)),
mono: true,
})
}
} else {
entries.push({
label,
value: formatScalar(value),
mono: typeof value !== 'boolean',
})
}
}
return entries
}
// The DTO's result_url falls back to fail_reason when no dedicated URL is
// stored, so only treat http(s) links or the internal video proxy path as a
// playable URL.
function resolveVideoUrl(log: TaskLog): string {
const raw = (log.result_url ?? '').trim()
const isHttp = /^https?:\/\//i.test(raw)
const isProxy = raw.startsWith('/v1/videos/')
if (isHttp || isProxy) return raw
if (VIDEO_ACTIONS.has(log.action) && log.status === TASK_STATUS.SUCCESS) {
if (log.task_id) return `/v1/videos/${log.task_id}/content`
}
return ''
}
function DetailRow(props: {
label: React.ReactNode
value: React.ReactNode
mono?: boolean
}) {
return (
<div className='grid min-w-0 grid-cols-[6rem_minmax(0,1fr)] gap-2 text-sm sm:grid-cols-[8rem_minmax(0,1fr)] sm:gap-3'>
<span className='text-muted-foreground min-w-0 text-xs'>
{props.label}
</span>
<span
className={cn(
'max-w-full min-w-0 text-xs break-all sm:wrap-break-word',
props.mono && 'font-mono'
)}
>
{props.value}
</span>
</div>
)
}
function DetailSection(props: {
icon?: React.ReactNode
label: string
variant?: 'default' | 'destructive'
action?: React.ReactNode
children: React.ReactNode
}) {
const isDestructive = props.variant === 'destructive'
return (
<div className='min-w-0 space-y-1.5'>
<div className='flex items-center justify-between gap-2'>
<Label
className={cn(
'flex items-center gap-1.5 text-xs font-semibold',
isDestructive && 'text-destructive'
)}
>
{props.icon}
{props.label}
</Label>
{props.action}
</div>
<div
className={cn(
'min-w-0 space-y-1 overflow-hidden rounded-md border p-2.5 max-sm:p-2',
isDestructive
? 'border-destructive/25 bg-destructive/10'
: 'bg-muted/30'
)}
>
{props.children}
</div>
</div>
)
}
function CopyButton(props: {
text: string
copied: boolean
onCopy: () => void
}) {
const { t } = useTranslation()
return (
<Button
variant='ghost'
size='icon-xs'
onClick={props.onCopy}
title={t('Copy to clipboard')}
aria-label={t('Copy to clipboard')}
>
{props.copied ? (
<Check className='text-success size-3' />
) : (
<Copy className='size-3' />
)}
</Button>
)
}
function VideoPreview({ url }: { url: string }) {
const { t } = useTranslation()
const [hasError, setHasError] = useState(false)
if (hasError) {
return (
<div className='flex flex-wrap items-center gap-2'>
<span className='text-muted-foreground text-xs'>
{t('Failed to load video')}
</span>
<Button
variant='outline'
size='sm'
onClick={() => window.open(url, '_blank', 'noopener,noreferrer')}
>
<ExternalLink className='size-3' />
{t('Open in new tab')}
</Button>
</div>
)
}
return (
<video
src={url}
controls
preload='metadata'
onError={() => setHasError(true)}
className='bg-background max-h-[420px] w-full rounded-md border'
/>
)
}
function AudioPreview({ clips }: { clips: AudioClip[] }) {
return (
<div className='space-y-3'>
{clips.map((clip, idx) => (
<AudioClipCard key={clip.clip_id || clip.id || idx} clip={clip} />
))}
</div>
)
}
interface TaskDetailsDialogProps {
log: TaskLog
isAdmin: boolean
open: boolean
onOpenChange: (open: boolean) => void
}
export function TaskDetailsDialog(props: TaskDetailsDialogProps) {
const { t } = useTranslation()
const { log, isAdmin } = props
const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
const platformName = getTaskPlatformName(log.platform)
const duration = formatDuration(log.submit_time, log.finish_time, 'seconds')
const videoUrl = resolveVideoUrl(log)
const resultUrl = (log.result_url ?? '').trim()
const isResultLink =
/^https?:\/\//i.test(resultUrl) || resultUrl.startsWith('/v1/videos/')
const parsedData = useMemo(() => parseJson(log.data), [log.data])
const props_ = useMemo(() => {
if (isPlainObject(log.properties)) return log.properties
const parsed = parseJson(log.properties)
return isPlainObject(parsed) ? parsed : null
}, [log.properties])
const asString = (v: unknown): string | undefined =>
typeof v === 'string' && v !== '' ? v : undefined
const originModel = asString(props_?.origin_model_name)
const upstreamModel = asString(props_?.upstream_model_name)
const dataModel = isPlainObject(parsedData)
? asString(parsedData.model)
: undefined
const model = originModel || upstreamModel || dataModel
const audioClips = useMemo(() => {
if (log.platform !== TASK_PLATFORMS.SUNO) return []
if (log.status !== TASK_STATUS.SUCCESS) return []
if (!Array.isArray(parsedData)) return []
return parsedData.filter(
(c) =>
c && typeof c === 'object' && (c as Record<string, unknown>).audio_url
) as AudioClip[]
}, [log.platform, log.status, parsedData])
const upstreamEntries = useMemo(
() => (isPlainObject(parsedData) ? flattenEntries(parsedData) : []),
[parsedData]
)
const upstreamRaw = useMemo(() => {
if (isPlainObject(parsedData) || parsedData == null) return ''
try {
return JSON.stringify(parsedData, null, 2)
} catch {
return String(parsedData)
}
}, [parsedData])
const rawJson = useMemo(() => {
try {
return JSON.stringify(log, null, 2)
} catch {
return ''
}
}, [log])
const hasFailReason = !!log.fail_reason && log.fail_reason.trim() !== ''
return (
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={
<>
{t('Task Details')}
<StatusBadge
variant={taskStatusMapper.getVariant(log.status)}
size='sm'
>
{t(
taskStatusMapper.getLabel(log.status, log.status || 'Submitting')
)}
</StatusBadge>
</>
}
description={t('View the complete details for this task')}
contentClassName='min-w-0 overflow-hidden max-sm:max-h-[calc(100dvh-1.5rem)] max-sm:w-[calc(100vw-1.5rem)] max-sm:max-w-[calc(100vw-1.5rem)] max-sm:p-4 sm:max-w-lg'
headerClassName='max-sm:gap-1'
titleClassName='flex items-center gap-2 text-base'
descriptionClassName='sr-only'
contentHeight='min(78dvh, 760px)'
bodyClassName='pr-2 sm:pr-4'
>
<div className='w-full max-w-full min-w-0 space-y-3 overflow-x-hidden py-1'>
{/* Overview */}
<DetailSection label={t('Overview')}>
{log.task_id && (
<DetailRow
label={t('Task ID')}
value={
<span className='flex items-start gap-1'>
<span className='min-w-0 break-all'>{log.task_id}</span>
<button
type='button'
className='text-muted-foreground hover:text-foreground mt-0.5 shrink-0'
onClick={() => copyToClipboard(log.task_id)}
title={t('Copy to clipboard')}
aria-label={t('Copy to clipboard')}
>
{copiedText === log.task_id ? (
<Check className='text-success size-3' />
) : (
<Copy className='size-3' />
)}
</button>
</span>
}
mono
/>
)}
{log.id > 0 && (
<DetailRow label={t('Internal ID')} value={String(log.id)} mono />
)}
<DetailRow label={t('Platform')} value={platformName} />
<DetailRow
label={t('Action')}
value={t(taskActionMapper.getLabel(log.action, log.action))}
/>
<DetailRow
label={t('Status')}
value={
<StatusBadge
variant={taskStatusMapper.getVariant(log.status)}
size='sm'
>
{t(
taskStatusMapper.getLabel(
log.status,
log.status || 'Submitting'
)
)}
</StatusBadge>
}
/>
{log.progress && (
<DetailRow
label={t('Progress')}
value={
<StatusBadge variant='neutral' size='sm' className='font-mono'>
{log.progress}
</StatusBadge>
}
/>
)}
{model && <DetailRow label={t('Model')} value={model} mono />}
</DetailSection>
{/* Timing */}
<DetailSection label={t('Timing')}>
{log.created_at ? (
<DetailRow
label={t('Created At')}
value={formatTimestampToDate(log.created_at, 'seconds')}
mono
/>
) : null}
{log.updated_at ? (
<DetailRow
label={t('Updated At')}
value={formatTimestampToDate(log.updated_at, 'seconds')}
mono
/>
) : null}
<DetailRow
label={t('Submit Time')}
value={formatTimestampToDate(log.submit_time, 'seconds')}
mono
/>
{log.start_time ? (
<DetailRow
label={t('Start Time')}
value={formatTimestampToDate(log.start_time, 'seconds')}
mono
/>
) : null}
{log.finish_time ? (
<DetailRow
label={t('Finish Time')}
value={formatTimestampToDate(log.finish_time, 'seconds')}
mono
/>
) : null}
{duration && (
<DetailRow
label={t('Duration')}
value={
<StatusBadge
variant={duration.variant}
size='sm'
className='tabular-nums'
>
{duration.durationSec.toFixed(1)}s
</StatusBadge>
}
/>
)}
</DetailSection>
{/* Billing */}
<DetailSection label={t('Billing')}>
{typeof log.quota === 'number' && (
<DetailRow
label={t('Cost')}
value={formatLogQuota(log.quota)}
mono
/>
)}
{log.group && <DetailRow label={t('Group')} value={log.group} mono />}
{isAdmin && log.channel_id > 0 && (
<DetailRow label={t('Channel')} value={`#${log.channel_id}`} mono />
)}
{isAdmin && log.username && (
<DetailRow label={t('User')} value={log.username} />
)}
{isAdmin && log.user_id > 0 && (
<DetailRow label={t('User ID')} value={String(log.user_id)} mono />
)}
</DetailSection>
{/* Request properties */}
{(originModel || upstreamModel) && (
<DetailSection label={t('Request Properties')}>
{originModel && (
<DetailRow
label={t('Original Model Name')}
value={originModel}
mono
/>
)}
{upstreamModel && (
<DetailRow
label={t('Upstream Model Name')}
value={upstreamModel}
mono
/>
)}
</DetailSection>
)}
{/* Fail reason */}
{hasFailReason && (
<DetailSection
icon={<AlertTriangle className='size-3.5' aria-hidden='true' />}
label={t('Fail Reason')}
variant='destructive'
action={
<CopyButton
text={log.fail_reason ?? ''}
copied={copiedText === log.fail_reason}
onCopy={() => copyToClipboard(log.fail_reason ?? '')}
/>
}
>
<p className='text-destructive text-xs leading-relaxed break-all whitespace-pre-wrap sm:wrap-break-word'>
{log.fail_reason}
</p>
</DetailSection>
)}
{/* Result */}
{(videoUrl || isResultLink || audioClips.length > 0) && (
<DetailSection
icon={<Video className='size-3.5' aria-hidden='true' />}
label={t('Result')}
action={
isResultLink ? (
<div className='flex items-center gap-1'>
<Button
variant='ghost'
size='icon-xs'
onClick={() =>
window.open(resultUrl, '_blank', 'noopener,noreferrer')
}
title={t('Open in new tab')}
aria-label={t('Open in new tab')}
>
<ExternalLink className='size-3' />
</Button>
<CopyButton
text={resultUrl}
copied={copiedText === resultUrl}
onCopy={() => copyToClipboard(resultUrl)}
/>
</div>
) : undefined
}
>
{isResultLink && (
<DetailRow label={t('Result URL')} value={resultUrl} mono />
)}
{videoUrl && <VideoPreview url={videoUrl} />}
{audioClips.length > 0 && (
<div className='flex items-center gap-1.5 pt-1'>
<Music
className='text-muted-foreground size-3.5'
aria-hidden='true'
/>
<span className='text-xs font-medium'>
{t('Audio Preview')}
</span>
</div>
)}
{audioClips.length > 0 && <AudioPreview clips={audioClips} />}
</DetailSection>
)}
{/* Upstream response (parsed task data) */}
{(upstreamEntries.length > 0 || upstreamRaw) && (
<DetailSection label={t('Upstream Response')}>
{upstreamEntries.length > 0 ? (
upstreamEntries.map((entry) => (
<DetailRow
key={entry.label}
label={entry.label}
value={entry.value}
mono={entry.mono}
/>
))
) : (
<pre className='bg-background/60 max-h-64 min-w-0 overflow-auto rounded border p-2 font-mono text-xs leading-relaxed whitespace-pre'>
{upstreamRaw}
</pre>
)}
</DetailSection>
)}
{/* Raw JSON */}
{rawJson && (
<DetailSection
label={t('Raw Data')}
action={
<CopyButton
text={rawJson}
copied={copiedText === rawJson}
onCopy={() => copyToClipboard(rawJson)}
/>
}
>
<pre className='bg-background/60 max-h-72 min-w-0 overflow-auto rounded border p-2 font-mono text-xs leading-relaxed whitespace-pre'>
{rawJson}
</pre>
</DetailSection>
)}
</div>
</Dialog>
)
}
@@ -150,9 +150,10 @@ function ModelBadgeContent(
return (
<StatusBadge
variant='neutral'
appearance='soft'
size='sm'
className={cn(
'h-5! max-w-none shrink-0 whitespace-nowrap! border-current/20 [&_[data-slot=status-badge-label]]:whitespace-nowrap!',
'h-5! max-w-none shrink-0 whitespace-nowrap! [&_[data-slot=status-badge-label]]:whitespace-nowrap!',
colorClassName,
props.className
)}
@@ -166,9 +167,10 @@ function ModelBadgeContent(
<CopyableStatusBadge
value={props.modelName}
variant='neutral'
appearance='soft'
size='sm'
className={cn(
'h-5! max-w-none shrink-0 whitespace-nowrap! border-current/20 [&_[data-slot=status-badge-label]]:whitespace-nowrap!',
'h-5! max-w-none shrink-0 whitespace-nowrap! [&_[data-slot=status-badge-label]]:whitespace-nowrap!',
colorClassName,
props.className
)}
@@ -97,7 +97,7 @@ function UsageLogCard<TData>(props: { cells: Cell<TData, unknown>[] }) {
{(titleCell || badgeCell) && (
<div className='flex min-w-0 items-start justify-between gap-3'>
{titleCell && (
<div className='min-w-0 flex-1 text-[15px] leading-tight font-semibold break-words'>
<div className='min-w-0 flex-1 text-base leading-tight font-semibold break-words'>
{flexRender(
titleCell.column.columnDef.cell,
titleCell.getContext()
@@ -115,31 +115,42 @@ function UsageLogCard<TData>(props: { cells: Cell<TData, unknown>[] }) {
</div>
)}
{rowCells.length > 0 && (
<div className='mt-3 space-y-0.5 border-t pt-3'>
{rowCells.map((cell) => (
<DataTableCardRow
key={cell.id}
label={getCardLabel(cell)}
contentMode={cell.column.columnDef.meta?.contentMode}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataTableCardRow>
))}
</div>
)}
{bodyCells.length > 0 && (
<div
className={cn(
'flex flex-col gap-3',
(titleCell || badgeCell) && 'mt-3'
)}
>
{rowCells.length > 0 && (
<div className='flex flex-col gap-0.5'>
{rowCells.map((cell) => (
<DataTableCardRow
key={cell.id}
label={getCardLabel(cell)}
contentMode={cell.column.columnDef.meta?.contentMode}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataTableCardRow>
))}
</div>
)}
{wideCells.length > 0 && (
<div className='mt-3 space-y-3 border-t pt-3'>
{wideCells.map((cell) => (
<DataTableCardField
key={cell.id}
label={getCardLabel(cell)}
contentMode={cell.column.columnDef.meta?.contentMode ?? 'full'}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataTableCardField>
))}
{wideCells.length > 0 && (
<div className='bg-muted/30 flex flex-col gap-3 rounded-md px-2.5 py-2'>
{wideCells.map((cell) => (
<DataTableCardField
key={cell.id}
label={getCardLabel(cell)}
contentMode={
cell.column.columnDef.meta?.contentMode ?? 'full'
}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataTableCardField>
))}
</div>
)}
</div>
)}
</div>
+19
View File
@@ -20,6 +20,8 @@ For commercial licensing, please contact support@quantumnous.com
* Status mappers for different log types
* Centralized mapper instances for consistent usage across components
*/
import { CHANNEL_TYPES } from '@/features/channels/constants'
import {
MJ_TASK_TYPE_MAPPINGS,
MJ_STATUS_MAPPINGS,
@@ -69,3 +71,20 @@ export const taskStatusMapper = createStatusMapper(TASK_STATUS_MAPPINGS)
* Task platform mapper
*/
export const taskPlatformMapper = createStatusMapper(TASK_PLATFORM_MAPPINGS)
/**
* Resolve a task platform value to a human-readable name.
*
* Video tasks store the platform as a numeric channel type (e.g. "54"), which
* maps to a channel name like "DoubaoVideo". Suno-style tasks store a slug
* (e.g. "suno"). Falls back to the raw value when nothing matches.
*/
export function getTaskPlatformName(platform: string): string {
if (!platform) return ''
const num = Number(platform)
if (Number.isInteger(num) && num > 0) {
const name = CHANNEL_TYPES[num as keyof typeof CHANNEL_TYPES]
if (name) return name
}
return platform
}
+7
View File
@@ -269,12 +269,19 @@ export interface TaskLog {
task_id: string
action: string // MUSIC, LYRICS, GENERATE, TEXT_GENERATE, etc.
channel_id: number
group?: string
quota?: number
submit_time: number // seconds
start_time?: number // seconds
finish_time?: number // seconds
progress?: string
progress_message_en?: string
data?: string // JSON string
fail_reason?: string
// Task result URL (video address, etc.). Backend falls back to fail_reason
// when no dedicated result URL is stored, so always validate before use.
result_url?: string
properties?: unknown
status: string // NOT_START, SUBMITTED, IN_PROGRESS, SUCCESS, FAILURE, QUEUED, UNKNOWN
other?: string
created_at?: number
@@ -115,9 +115,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
{remark && (
<Tooltip>
<TooltipTrigger render={<StatusBadge variant='success' />}>
<LongText className='max-w-full sm:max-w-[80px]'>
{remark}
</LongText>
{remark}
</TooltipTrigger>
<TooltipContent>
<p className='text-xs'>{remark}</p>
@@ -116,7 +116,7 @@ export function PaymentConfirmDialog({
<div className='bg-muted/50 rounded-lg p-3'>
<div className='flex items-center justify-between text-sm'>
<span className='text-muted-foreground'>{t('You save')}</span>
<span className='font-semibold text-green-600'>
<span className='text-status-success font-semibold'>
{formatCurrency(discountAmount)}
</span>
</div>
+11 -1
View File
@@ -1428,7 +1428,7 @@
"Don't have an account?": "Don't have an account?",
"Done": "Done",
"Doubao custom API address editing unlocked": "Doubao custom API address editing unlocked",
"DoubaoVideo": "DoubaoVideo",
"DoubaoVideo": "Doubao Video",
"Double check the configuration below. Your system will be locked until initialization is complete.": "Double check the configuration below. Your system will be locked until initialization is complete.",
"Downgrade Group": "Downgrade Group",
"Downgrade to pre-purchase group": "Downgrade to pre-purchase group",
@@ -1845,6 +1845,7 @@
"Failed to load setup status": "Failed to load setup status",
"Failed to load tag data": "Failed to load tag data",
"Failed to load users": "Failed to load users",
"Failed to load video": "Failed to load video",
"Failed to parse group items": "Failed to parse group items",
"Failed to parse JSON file: {{name}}": "Failed to parse JSON file: {{name}}",
"Failed to query balance": "Failed to query balance",
@@ -3113,6 +3114,7 @@
"Order Payment Method": "Order Payment Method",
"org-...": "org-...",
"Original Model": "Original Model",
"Original Model Name": "Original Model Name",
"Original value": "Original value",
"Other": "Other",
"Other channels": "Other channels",
@@ -3558,6 +3560,7 @@
"Ratio Type": "Ratio Type",
"Ratio: {{value}}": "Ratio: {{value}}",
"Ratios synced successfully": "Ratios synced successfully",
"Raw Data": "Raw Data",
"Raw expression": "Raw expression",
"Raw JSON": "Raw JSON",
"Raw Quota": "Raw Quota",
@@ -3715,6 +3718,7 @@
"Request Model": "Request Model",
"Request Model:": "Request Model:",
"Request overrides, routing behavior, and upstream model automation": "Request overrides, routing behavior, and upstream model automation",
"Request Properties": "Request Properties",
"Request retry": "Request retry",
"Request rule pricing": "Request rule pricing",
"Request success rate sampled over the last 24 hours": "Request success rate sampled over the last 24 hours",
@@ -3791,6 +3795,7 @@
"Restore defaults": "Restore defaults",
"Restrict user model request frequency (may impact high concurrency performance)": "Restrict user model request frequency (may impact high concurrency performance)",
"Result": "Result",
"Result URL": "Result URL",
"Retain last N days": "Retain last N days",
"Retain last N files": "Retain last N files",
"Retention days": "Retention days",
@@ -4337,6 +4342,7 @@
"Target Path (optional)": "Target Path (optional)",
"Target User": "Target User",
"Task": "Task",
"Task Details": "Task Details",
"Task History": "Task History",
"Task ID": "Task ID",
"Task ID:": "Task ID:",
@@ -4734,6 +4740,7 @@
"Updated a redemption code": "Updated a redemption code",
"Updated a subscription plan": "Updated a subscription plan",
"Updated a vendor": "Updated a vendor",
"Updated At": "Updated At",
"Updated channel {{name}} (ID: {{id}})": "Updated channel {{name}} (ID: {{id}})",
"Updated daily": "Updated daily",
"Updated successfully": "Updated successfully",
@@ -4755,6 +4762,7 @@
"Upstream did not return reset credit details.": "Upstream did not return reset credit details.",
"Upstream Model Detection Settings": "Upstream Model Detection Settings",
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.",
"Upstream Model Name": "Upstream Model Name",
"Upstream Model Update Check": "Upstream Model Update Check",
"Upstream Model Updates": "Upstream Model Updates",
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models",
@@ -4914,6 +4922,7 @@
"Vertex AI service account key must be valid JSON": "Vertex AI service account key must be valid JSON",
"Video": "Video",
"Video length in seconds": "Video length in seconds",
"Video Preview": "Video Preview",
"Video Remix": "Video Remix",
"Vidu": "Vidu",
"View": "View",
@@ -4930,6 +4939,7 @@
"View Pricing": "View Pricing",
"View the complete details for this": "View the complete details for this",
"View the complete details for this log entry": "View the complete details for this log entry",
"View the complete details for this task": "View the complete details for this task",
"View the complete error message and details": "View the complete error message and details",
"View the complete prompt and its English translation": "View the complete prompt and its English translation",
"View the generated image": "View the generated image",
+11 -1
View File
@@ -1428,7 +1428,7 @@
"Don't have an account?": "Vous n'avez pas de compte ?",
"Done": "Terminé",
"Doubao custom API address editing unlocked": "Édition d'adresse API personnalisée Doubao déverrouillée",
"DoubaoVideo": "DoubaoVideo",
"DoubaoVideo": "Vidéo Doubao",
"Double check the configuration below. Your system will be locked until initialization is complete.": "Vérifiez la configuration ci-dessous. Votre système sera verrouillé jusqu'à ce que l'initialisation soit terminée.",
"Downgrade Group": "Groupe de rétrogradation",
"Downgrade to pre-purchase group": "Rétrograder vers le groupe d'avant l'achat",
@@ -1845,6 +1845,7 @@
"Failed to load setup status": "Échec du chargement de l'état de configuration",
"Failed to load tag data": "Échec du chargement des données de tags",
"Failed to load users": "Échec du chargement des utilisateurs",
"Failed to load video": "Échec du chargement de la vidéo",
"Failed to parse group items": "Échec de l'analyse des éléments de groupe",
"Failed to parse JSON file: {{name}}": "Échec de l'analyse du fichier JSON : {{name}}",
"Failed to query balance": "Échec de la requête de solde",
@@ -3113,6 +3114,7 @@
"Order Payment Method": "Moyen de paiement (commande)",
"org-...": "org-...",
"Original Model": "Modèle Original",
"Original Model Name": "Nom du modèle d'origine",
"Original value": "Valeur d'origine",
"Other": "Autre",
"Other channels": "Autres canaux",
@@ -3558,6 +3560,7 @@
"Ratio Type": "Type de ratio",
"Ratio: {{value}}": "Ratio : {{value}}",
"Ratios synced successfully": "Ratios synchronisés avec succès",
"Raw Data": "Données brutes",
"Raw expression": "Expression brute",
"Raw JSON": "JSON brut",
"Raw Quota": "Quota brut",
@@ -3715,6 +3718,7 @@
"Request Model": "Modèle demandé",
"Request Model:": "Modèle demandé :",
"Request overrides, routing behavior, and upstream model automation": "Surcharges de requête, comportement de routage et automatisation des modèles amont",
"Request Properties": "Propriétés de la requête",
"Request retry": "Relance des requêtes",
"Request rule pricing": "Règles de tarification de requête",
"Request success rate sampled over the last 24 hours": "Taux de réussite des requêtes échantillonné sur les dernières 24 heures",
@@ -3791,6 +3795,7 @@
"Restore defaults": "Restaurer les paramètres par défaut",
"Restrict user model request frequency (may impact high concurrency performance)": "Restreindre la fréquence des requêtes du modèle utilisateur (peut impacter les performances en cas de forte concurrence)",
"Result": "Résultat",
"Result URL": "URL du résultat",
"Retain last N days": "Conserver les N derniers jours",
"Retain last N files": "Conserver les N derniers fichiers",
"Retention days": "Jours de rétention",
@@ -4337,6 +4342,7 @@
"Target Path (optional)": "Chemin cible (optionnel)",
"Target User": "Utilisateur cible",
"Task": "Tâche",
"Task Details": "Détails de la tâche",
"Task History": "Historique des tâches",
"Task ID": "ID de la tâche",
"Task ID:": "ID de tâche :",
@@ -4734,6 +4740,7 @@
"Updated a redemption code": "Code de réduction mis à jour",
"Updated a subscription plan": "Forfait d'abonnement mis à jour",
"Updated a vendor": "Fournisseur mis à jour",
"Updated At": "Mis à jour le",
"Updated channel {{name}} (ID: {{id}})": "Canal {{name}} mis à jour (ID : {{id}})",
"Updated daily": "Mis à jour quotidiennement",
"Updated successfully": "Mise à jour réussie",
@@ -4755,6 +4762,7 @@
"Upstream did not return reset credit details.": "L'amont n'a renvoyé aucun détail de crédit de réinitialisation.",
"Upstream Model Detection Settings": "Paramètres de détection des modèles en amont",
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "Tâche de détection des modèles en amont démarrée. Suivez la progression dans Infos système, puis actualisez pour examiner les mises à jour en attente.",
"Upstream Model Name": "Nom du modèle amont",
"Upstream Model Update Check": "Vérification des mises à jour des modèles en amont",
"Upstream Model Updates": "Mises à jour des modèles en amont",
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "Mises à jour des modèles en amont appliquées : {{added}} ajoutés, {{removed}} supprimés, {{ignored}} ignorés cette fois, {{totalIgnored}} modèles ignorés au total",
@@ -4914,6 +4922,7 @@
"Vertex AI service account key must be valid JSON": "La clé de compte de service Vertex AI doit être un JSON valide",
"Video": "Vidéo",
"Video length in seconds": "Durée de la vidéo en secondes",
"Video Preview": "Aperçu vidéo",
"Video Remix": "Remix vidéo",
"Vidu": "Vidu",
"View": "Afficher",
@@ -4930,6 +4939,7 @@
"View Pricing": "Voir les tarifs",
"View the complete details for this": "Voir les détails complets de ce",
"View the complete details for this log entry": "Voir les détails complets de cette entrée de journal",
"View the complete details for this task": "Voir les détails complets de cette tâche",
"View the complete error message and details": "Voir le message d'erreur et les détails complets",
"View the complete prompt and its English translation": "Voir l'invite complète et sa traduction anglaise",
"View the generated image": "Voir l'image générée",
+11 -1
View File
@@ -1428,7 +1428,7 @@
"Don't have an account?": "アカウントをお持ちでないですか?",
"Done": "完了",
"Doubao custom API address editing unlocked": "豆包カスタムAPI アドレス編集がアンロックされました",
"DoubaoVideo": "DoubaoVideo",
"DoubaoVideo": "Doubao動画",
"Double check the configuration below. Your system will be locked until initialization is complete.": "下記の設定を再確認してください。初期化が完了するまでシステムはロックされます。",
"Downgrade Group": "ダウングレードグループ",
"Downgrade to pre-purchase group": "購入前のグループにダウングレード",
@@ -1845,6 +1845,7 @@
"Failed to load setup status": "セットアップ状態の読み込みに失敗しました",
"Failed to load tag data": "タグデータの読み込みに失敗しました",
"Failed to load users": "ユーザーの読み込みに失敗しました",
"Failed to load video": "動画の読み込みに失敗しました",
"Failed to parse group items": "グループアイテムの解析に失敗しました",
"Failed to parse JSON file: {{name}}": "JSONファイルの解析に失敗しました: __ PH_0 __",
"Failed to query balance": "残高の取得に失敗しました",
@@ -3113,6 +3114,7 @@
"Order Payment Method": "注文の支払い方法",
"org-...": "org-...",
"Original Model": "オリジナルモデル",
"Original Model Name": "元のモデル名",
"Original value": "元の値",
"Other": "その他",
"Other channels": "その他のチャネル",
@@ -3558,6 +3560,7 @@
"Ratio Type": "比率タイプ",
"Ratio: {{value}}": "倍率:{{value}}",
"Ratios synced successfully": "比率が正常に同期されました",
"Raw Data": "生データ",
"Raw expression": "元の式",
"Raw JSON": "生 JSON",
"Raw Quota": "元のクォータ",
@@ -3715,6 +3718,7 @@
"Request Model": "リクエストモデル",
"Request Model:": "リクエストモデル:",
"Request overrides, routing behavior, and upstream model automation": "リクエスト上書き、ルーティング動作、上流モデル自動化",
"Request Properties": "リクエスト属性",
"Request retry": "リクエスト再試行",
"Request rule pricing": "リクエストルールの課金",
"Request success rate sampled over the last 24 hours": "過去 24 時間にサンプリングされたリクエスト成功率",
@@ -3791,6 +3795,7 @@
"Restore defaults": "既定に戻す",
"Restrict user model request frequency (may impact high concurrency performance)": "ユーザーモデルのリクエスト頻度を制限する(高並行性パフォーマンスに影響を与える可能性があります)",
"Result": "結果",
"Result URL": "結果 URL",
"Retain last N days": "最新N日間を保持",
"Retain last N files": "最新N個のファイルを保持",
"Retention days": "保持日数",
@@ -4337,6 +4342,7 @@
"Target Path (optional)": "ターゲットパス(任意)",
"Target User": "対象ユーザー",
"Task": "タスク",
"Task Details": "タスクの詳細",
"Task History": "タスク履歴",
"Task ID": "タスクID",
"Task ID:": "タスクID",
@@ -4734,6 +4740,7 @@
"Updated a redemption code": "引換コードを更新しました",
"Updated a subscription plan": "サブスクリプションプランを更新しました",
"Updated a vendor": "ベンダーを更新しました",
"Updated At": "更新日時",
"Updated channel {{name}} (ID: {{id}})": "チャネル {{name}} を更新しました(ID: {{id}}",
"Updated daily": "毎日更新",
"Updated successfully": "正常に更新されました",
@@ -4755,6 +4762,7 @@
"Upstream did not return reset credit details.": "上流からリセット回数の詳細が返されませんでした。",
"Upstream Model Detection Settings": "アップストリームモデル検出設定",
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "上流モデル検出タスクを開始しました。システム情報で進捗を確認し、完了後に更新してステージングされた変更をご確認ください。",
"Upstream Model Name": "アップストリームのモデル名",
"Upstream Model Update Check": "アップストリームモデル更新チェック",
"Upstream Model Updates": "上流モデルの更新",
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "上流モデル更新を処理しました:{{added}} 個追加、{{removed}} 個削除、今回 {{ignored}} 個無視、合計 {{totalIgnored}} 個の無視モデル",
@@ -4914,6 +4922,7 @@
"Vertex AI service account key must be valid JSON": "Vertex AI サービスアカウントキーは有効な JSON である必要があります",
"Video": "動画",
"Video length in seconds": "動画の長さ(秒)",
"Video Preview": "動画プレビュー",
"Video Remix": "動画 Remix",
"Vidu": "Vidu",
"View": "表示",
@@ -4930,6 +4939,7 @@
"View Pricing": "価格を見る",
"View the complete details for this": "この",
"View the complete details for this log entry": "このログエントリの完全な詳細を表示",
"View the complete details for this task": "このタスクの詳細を表示",
"View the complete error message and details": "エラーメッセージと詳細を表示",
"View the complete prompt and its English translation": "プロンプト全文と英語訳を表示",
"View the generated image": "生成された画像を表示",
+11 -1
View File
@@ -1428,7 +1428,7 @@
"Don't have an account?": "У вас нет аккаунта?",
"Done": "Готово",
"Doubao custom API address editing unlocked": "Редактирование пользовательского адреса API Doubao разблокировано",
"DoubaoVideo": "DoubaoVideo",
"DoubaoVideo": "Видео Doubao",
"Double check the configuration below. Your system will be locked until initialization is complete.": "Дважды проверьте конфигурацию ниже. Ваша система будет заблокирована до завершения инициализации.",
"Downgrade Group": "Понизить группу",
"Downgrade to pre-purchase group": "Понизить до группы до покупки",
@@ -1845,6 +1845,7 @@
"Failed to load setup status": "Не удалось загрузить статус настройки",
"Failed to load tag data": "Не удалось загрузить данные тегов",
"Failed to load users": "Не удалось загрузить пользователей",
"Failed to load video": "Не удалось загрузить видео",
"Failed to parse group items": "Не удалось разобрать элементы группы",
"Failed to parse JSON file: {{name}}": "Не удалось проанализировать файл JSON: {{name}}",
"Failed to query balance": "Не удалось запросить баланс",
@@ -3113,6 +3114,7 @@
"Order Payment Method": "Способ оплаты (заказа)",
"org-...": "орг-...",
"Original Model": "Оригинальная модель",
"Original Model Name": "Исходное имя модели",
"Original value": "Исходное значение",
"Other": "Другое",
"Other channels": "Другие каналы",
@@ -3558,6 +3560,7 @@
"Ratio Type": "Тип соотношения",
"Ratio: {{value}}": "Коэффициент: {{value}}",
"Ratios synced successfully": "Соотношения успешно синхронизированы",
"Raw Data": "Необработанные данные",
"Raw expression": "Исходное выражение",
"Raw JSON": "Сырой JSON",
"Raw Quota": "Исходная квота",
@@ -3715,6 +3718,7 @@
"Request Model": "Запрошенная модель",
"Request Model:": "Модель запроса:",
"Request overrides, routing behavior, and upstream model automation": "Переопределения запросов, маршрутизация и автоматизация upstream-моделей",
"Request Properties": "Свойства запроса",
"Request retry": "Повтор запросов",
"Request rule pricing": "Правила ценообразования по запросу",
"Request success rate sampled over the last 24 hours": "Доля успешных запросов по выборкам за последние 24 часа",
@@ -3791,6 +3795,7 @@
"Restore defaults": "Сбросить к значениям по умолчанию",
"Restrict user model request frequency (may impact high concurrency performance)": "Ограничить частоту запросов пользовательских моделей (может повлиять на производительность при высокой конкуренции)",
"Result": "Результат",
"Result URL": "URL результата",
"Retain last N days": "Хранить последние N дней",
"Retain last N files": "Хранить последние N файлов",
"Retention days": "Дней хранения",
@@ -4337,6 +4342,7 @@
"Target Path (optional)": "Целевой путь (необязательно)",
"Target User": "Целевой пользователь",
"Task": "Задача",
"Task Details": "Сведения о задаче",
"Task History": "История задач",
"Task ID": "ID задачи",
"Task ID:": "ID задачи:",
@@ -4734,6 +4740,7 @@
"Updated a redemption code": "Код погашения обновлён",
"Updated a subscription plan": "Обновлён тарифный план подписки",
"Updated a vendor": "Обновлён поставщик",
"Updated At": "Обновлено",
"Updated channel {{name}} (ID: {{id}})": "Обновлён канал {{name}} (ID: {{id}})",
"Updated daily": "Обновляется ежедневно",
"Updated successfully": "Обновлено успешно",
@@ -4755,6 +4762,7 @@
"Upstream did not return reset credit details.": "Вышестоящий сервис не вернул сведения о сбросах лимита.",
"Upstream Model Detection Settings": "Настройки обнаружения моделей провайдера",
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "Задача обнаружения моделей вышестоящего источника запущена. Следите за ходом в разделе «Информация о системе», затем обновите, чтобы просмотреть подготовленные изменения.",
"Upstream Model Name": "Имя вышестоящей модели",
"Upstream Model Update Check": "Проверка обновлений моделей провайдера",
"Upstream Model Updates": "Обновления моделей источника",
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "Обновления моделей применены: {{added}} добавлено, {{removed}} удалено, {{ignored}} проигнорировано, всего {{totalIgnored}} проигнорированных моделей",
@@ -4914,6 +4922,7 @@
"Vertex AI service account key must be valid JSON": "Ключ сервисного аккаунта Vertex AI должен быть допустимым JSON",
"Video": "Видео",
"Video length in seconds": "Длительность видео в секундах",
"Video Preview": "Предпросмотр видео",
"Video Remix": "Ремикс видео",
"Vidu": "Vidu",
"View": "Просмотр",
@@ -4930,6 +4939,7 @@
"View Pricing": "Посмотреть цены",
"View the complete details for this": "Просмотр полных деталей этой",
"View the complete details for this log entry": "Просмотр полной информации об этой записи журнала",
"View the complete details for this task": "Посмотреть полные сведения о задаче",
"View the complete error message and details": "Просмотр полного сообщения об ошибке и деталей",
"View the complete prompt and its English translation": "Просмотр полного промпта и его перевода на английский",
"View the generated image": "Просмотр сгенерированного изображения",
+11 -1
View File
@@ -1428,7 +1428,7 @@
"Don't have an account?": "Chưa có tài khoản?",
"Done": "Xong",
"Doubao custom API address editing unlocked": "Đã mở khóa chỉnh sửa địa chỉ API tùy chỉnh Doubao",
"DoubaoVideo": "DoubaoVideo",
"DoubaoVideo": "Video Doubao",
"Double check the configuration below. Your system will be locked until initialization is complete.": "Kiểm tra kỹ lại cấu hình bên dưới. Hệ thống của bạn sẽ bị khóa cho đến khi quá trình khởi tạo hoàn tất.",
"Downgrade Group": "Nhóm hạ cấp",
"Downgrade to pre-purchase group": "Hạ xuống nhóm trước khi mua",
@@ -1845,6 +1845,7 @@
"Failed to load setup status": "Không thể tải trạng thái thiết lập",
"Failed to load tag data": "Không thể tải dữ liệu thẻ",
"Failed to load users": "Không thể tải người dùng",
"Failed to load video": "Không tải được video",
"Failed to parse group items": "Thất bại khi phân tích các mục nhóm",
"Failed to parse JSON file: {{name}}": "Không thể phân tích cú pháp tệp JSON: {{name}}",
"Failed to query balance": "Không thể truy vấn số dư",
@@ -3113,6 +3114,7 @@
"Order Payment Method": "Phương thức thanh toán đơn hàng",
"org-...": "org-...",
"Original Model": "Nguyên mẫu",
"Original Model Name": "Tên mô hình gốc",
"Original value": "Giá trị gốc",
"Other": "Khác",
"Other channels": "Kênh khác",
@@ -3558,6 +3560,7 @@
"Ratio Type": "Rate type",
"Ratio: {{value}}": "Tỷ lệ: {{value}}",
"Ratios synced successfully": "Tỷ lệ đã đồng bộ thành công",
"Raw Data": "Dữ liệu thô",
"Raw expression": "Biểu thức gốc",
"Raw JSON": "JSON thô",
"Raw Quota": "Hạn mức gốc",
@@ -3715,6 +3718,7 @@
"Request Model": "Mô hình yêu cầu",
"Request Model:": "Mô hình yêu cầu:",
"Request overrides, routing behavior, and upstream model automation": "Ghi đè yêu cầu, hành vi định tuyến và tự động hóa mô hình upstream",
"Request Properties": "Thuộc tính yêu cầu",
"Request retry": "Thử lại yêu cầu",
"Request rule pricing": "Quy tắc tính giá theo request",
"Request success rate sampled over the last 24 hours": "Tỷ lệ yêu cầu thành công được lấy mẫu trong 24 giờ qua",
@@ -3791,6 +3795,7 @@
"Restore defaults": "Khôi phục mặc định",
"Restrict user model request frequency (may impact high concurrency performance)": "Hạn chế tần suất yêu cầu mô hình người dùng (có thể ảnh hưởng đến hiệu suất khi có độ đồng thời cao)",
"Result": "Kết quả",
"Result URL": "URL kết quả",
"Retain last N days": "Giữ lại N ngày gần nhất",
"Retain last N files": "Giữ lại N tệp gần nhất",
"Retention days": "Số ngày lưu giữ",
@@ -4337,6 +4342,7 @@
"Target Path (optional)": "Đường dẫn đích (tùy chọn)",
"Target User": "Người dùng mục tiêu",
"Task": "Nhiệm vụ",
"Task Details": "Chi tiết tác vụ",
"Task History": "Lịch sử tác vụ",
"Task ID": "Mã nhiệm vụ",
"Task ID:": "ID nhiệm vụ:",
@@ -4734,6 +4740,7 @@
"Updated a redemption code": "Đã cập nhật một mã đổi thưởng",
"Updated a subscription plan": "Đã cập nhật một gói đăng ký",
"Updated a vendor": "Đã cập nhật một nhà cung cấp",
"Updated At": "Cập nhật lúc",
"Updated channel {{name}} (ID: {{id}})": "Đã cập nhật kênh {{name}} (ID: {{id}})",
"Updated daily": "Cập nhật hàng ngày",
"Updated successfully": "Cập nhật thành công",
@@ -4755,6 +4762,7 @@
"Upstream did not return reset credit details.": "Upstream không trả về chi tiết lượt đặt lại.",
"Upstream Model Detection Settings": "Cài đặt phát hiện mô hình nguồn",
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "Đã bắt đầu tác vụ phát hiện mô hình thượng nguồn. Theo dõi tiến trình trong Thông tin hệ thống, sau đó làm mới để xem các cập nhật đang chờ.",
"Upstream Model Name": "Tên mô hình thượng nguồn",
"Upstream Model Update Check": "Kiểm tra cập nhật mô hình nguồn",
"Upstream Model Updates": "Cập nhật mô hình upstream",
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "Đã áp dụng cập nhật mô hình upstream: {{added}} đã thêm, {{removed}} đã xóa, {{ignored}} bỏ qua lần này, {{totalIgnored}} tổng mô hình đã bỏ qua",
@@ -4914,6 +4922,7 @@
"Vertex AI service account key must be valid JSON": "Khóa tài khoản dịch vụ Vertex AI phải là JSON hợp lệ",
"Video": "Video",
"Video length in seconds": "Độ dài video (giây)",
"Video Preview": "Xem trước video",
"Video Remix": "Remix video",
"Vidu": "Vidu",
"View": "Xem",
@@ -4930,6 +4939,7 @@
"View Pricing": "View price",
"View the complete details for this": "Xem chi tiết đầy đủ của",
"View the complete details for this log entry": "Xem chi tiết đầy đủ cho mục nhật ký này",
"View the complete details for this task": "Xem chi tiết đầy đủ của tác vụ này",
"View the complete error message and details": "Xem toàn bộ thông báo lỗi và chi tiết",
"View the complete prompt and its English translation": "Xem toàn bộ lời nhắc và bản dịch tiếng Anh",
"View the generated image": "Xem ảnh đã tạo",
+12 -2
View File
@@ -1428,7 +1428,7 @@
"Don't have an account?": "沒有賬號?",
"Done": "完成",
"Doubao custom API address editing unlocked": "已解鎖豆包自訂 API 地址編輯",
"DoubaoVideo": "DoubaoVideo",
"DoubaoVideo": "豆包影片",
"Double check the configuration below. Your system will be locked until initialization is complete.": "仔細檢查以下設定。您的系統將在初始化完成前保持鎖定狀態。",
"Downgrade Group": "降級分組",
"Downgrade to pre-purchase group": "降級到購買前分組",
@@ -1845,6 +1845,7 @@
"Failed to load setup status": "無法載入設定狀態",
"Failed to load tag data": "載入標籤數據失敗",
"Failed to load users": "載入用戶失敗",
"Failed to load video": "影片載入失敗",
"Failed to parse group items": "解析組項目失敗",
"Failed to parse JSON file: {{name}}": "解析 JSON 檔案失敗:{{name}}",
"Failed to query balance": "查詢餘額失敗",
@@ -2003,7 +2004,7 @@
"footer.columns.related.links.oneApi": "One API",
"footer.columns.related.title": "相關項目",
"footer.defaultCopyright": "版權所有。",
"footer.newapi.projectAttributionSuffix": "版權所有,由項目貢獻者設計與開發。",
"footer.new\u0061pi.projectAttributionSuffix": "版權所有,由項目貢獻者設計與開發。",
"For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "對於 2025 年 5 月 10 日之後新增的渠道,在部署時無需從模型名稱中移除 \".\"",
"For private deployments, format: https://fastgpt.run/api/openapi": "對於私有部署,格式為:https://fastgpt.run/api/openapi",
"Force a syntactically valid JSON response": "強制返回語法合法的 JSON",
@@ -3113,6 +3114,7 @@
"Order Payment Method": "訂單支付方式",
"org-...": "org-...",
"Original Model": "原始模型",
"Original Model Name": "原始模型名稱",
"Original value": "原始值",
"Other": "其他",
"Other channels": "其他渠道",
@@ -3558,6 +3560,7 @@
"Ratio Type": "比率類型",
"Ratio: {{value}}": "倍率:{{value}}",
"Ratios synced successfully": "比率同步成功",
"Raw Data": "原始資料",
"Raw expression": "原始表達式",
"Raw JSON": "原始 JSON",
"Raw Quota": "原生額度",
@@ -3715,6 +3718,7 @@
"Request Model": "請求模型",
"Request Model:": "請求模型:",
"Request overrides, routing behavior, and upstream model automation": "請求覆蓋、路由行為和上游模型自動化",
"Request Properties": "請求屬性",
"Request retry": "請求重試",
"Request rule pricing": "請求規則收費",
"Request success rate sampled over the last 24 hours": "最近 24 小時按時間桶採樣的請求成功率",
@@ -3791,6 +3795,7 @@
"Restore defaults": "恢復預設",
"Restrict user model request frequency (may impact high concurrency performance)": "限制用戶模型請求頻率(可能會影響高並發效能)",
"Result": "結果",
"Result URL": "結果 URL",
"Retain last N days": "保留最近N天",
"Retain last N files": "保留最近 N 個檔案",
"Retention days": "保留天數",
@@ -4337,6 +4342,7 @@
"Target Path (optional)": "目標路徑(可選)",
"Target User": "目標用戶",
"Task": "任務",
"Task Details": "任務詳情",
"Task History": "歷史任務",
"Task ID": "任務 ID",
"Task ID:": "任務 ID",
@@ -4734,6 +4740,7 @@
"Updated a redemption code": "更新了一個兌換碼",
"Updated a subscription plan": "更新了一個訂閱計劃",
"Updated a vendor": "更新了一個供應商",
"Updated At": "更新時間",
"Updated channel {{name}} (ID: {{id}})": "更新渠道 {{name}}ID: {{id}}",
"Updated daily": "每日更新",
"Updated successfully": "更新成功",
@@ -4755,6 +4762,7 @@
"Upstream did not return reset credit details.": "上游未返回重置次數詳情。",
"Upstream Model Detection Settings": "偵測上游模型設定",
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "上游模型檢測任務已開始。可在「系統資訊」中查看進度,完成後重新整理以查看待處理的更新。",
"Upstream Model Name": "上游模型名稱",
"Upstream Model Update Check": "上游模型更新檢查",
"Upstream Model Updates": "上游模型更新",
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "已處理上游模型更新:加入 {{added}} 個,刪除 {{removed}} 個,本次忽略 {{ignored}} 個,目前已忽略模型 {{totalIgnored}} 個",
@@ -4914,6 +4922,7 @@
"Vertex AI service account key must be valid JSON": "Vertex AI 服務賬號金鑰必須是有效 JSON",
"Video": "影片",
"Video length in seconds": "影片時長(秒)",
"Video Preview": "影片預覽",
"Video Remix": "影片 Remix",
"Vidu": "Vidu",
"View": "查看",
@@ -4930,6 +4939,7 @@
"View Pricing": "查看定價",
"View the complete details for this": "查看此條",
"View the complete details for this log entry": "查看此日誌條目的完整詳情",
"View the complete details for this task": "檢視該任務的完整詳情",
"View the complete error message and details": "查看完整錯誤資訊與詳情",
"View the complete prompt and its English translation": "查看完整提示詞及其英文翻譯",
"View the generated image": "查看生成的圖片",
+11 -1
View File
@@ -1428,7 +1428,7 @@
"Don't have an account?": "没有账号?",
"Done": "完成",
"Doubao custom API address editing unlocked": "已解锁豆包自定义 API 地址编辑",
"DoubaoVideo": "DoubaoVideo",
"DoubaoVideo": "豆包视频",
"Double check the configuration below. Your system will be locked until initialization is complete.": "仔细检查以下配置。您的系统将在初始化完成前保持锁定状态。",
"Downgrade Group": "降级分组",
"Downgrade to pre-purchase group": "降级到购买前分组",
@@ -1845,6 +1845,7 @@
"Failed to load setup status": "无法加载设置状态",
"Failed to load tag data": "加载标签数据失败",
"Failed to load users": "加载用户失败",
"Failed to load video": "视频加载失败",
"Failed to parse group items": "解析组项目失败",
"Failed to parse JSON file: {{name}}": "解析 JSON 文件失败:{{name}}",
"Failed to query balance": "查询余额失败",
@@ -3113,6 +3114,7 @@
"Order Payment Method": "订单支付方式",
"org-...": "org-...",
"Original Model": "原始模型",
"Original Model Name": "原始模型名",
"Original value": "原始值",
"Other": "其他",
"Other channels": "其他渠道",
@@ -3558,6 +3560,7 @@
"Ratio Type": "比率类型",
"Ratio: {{value}}": "倍率:{{value}}",
"Ratios synced successfully": "比率同步成功",
"Raw Data": "原始数据",
"Raw expression": "原始表达式",
"Raw JSON": "原始 JSON",
"Raw Quota": "原生额度",
@@ -3715,6 +3718,7 @@
"Request Model": "请求模型",
"Request Model:": "请求模型:",
"Request overrides, routing behavior, and upstream model automation": "请求覆盖、路由行为和上游模型自动化",
"Request Properties": "请求属性",
"Request retry": "请求重试",
"Request rule pricing": "请求规则计费",
"Request success rate sampled over the last 24 hours": "最近 24 小时按时间桶采样的请求成功率",
@@ -3791,6 +3795,7 @@
"Restore defaults": "恢复默认",
"Restrict user model request frequency (may impact high concurrency performance)": "限制用户模型请求频率(可能会影响高并发性能)",
"Result": "结果",
"Result URL": "结果 URL",
"Retain last N days": "保留最近N天",
"Retain last N files": "保留最近 N 个文件",
"Retention days": "保留天数",
@@ -4337,6 +4342,7 @@
"Target Path (optional)": "目标路径(可选)",
"Target User": "目标用户",
"Task": "任务",
"Task Details": "任务详情",
"Task History": "历史任务",
"Task ID": "任务 ID",
"Task ID:": "任务 ID",
@@ -4734,6 +4740,7 @@
"Updated a redemption code": "更新了一个兑换码",
"Updated a subscription plan": "更新了一个订阅计划",
"Updated a vendor": "更新了一个供应商",
"Updated At": "更新时间",
"Updated channel {{name}} (ID: {{id}})": "更新渠道 {{name}}ID: {{id}}",
"Updated daily": "每日更新",
"Updated successfully": "更新成功",
@@ -4755,6 +4762,7 @@
"Upstream did not return reset credit details.": "上游未返回重置次数详情。",
"Upstream Model Detection Settings": "检测上游模型设置",
"Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.": "上游模型检测任务已开始。可在「系统信息」中查看进度,完成后刷新以查看待处理的更新。",
"Upstream Model Name": "上游模型名",
"Upstream Model Update Check": "上游模型更新检查",
"Upstream Model Updates": "上游模型更新",
"Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models": "已处理上游模型更新:加入 {{added}} 个,删除 {{removed}} 个,本次忽略 {{ignored}} 个,当前已忽略模型 {{totalIgnored}} 个",
@@ -4914,6 +4922,7 @@
"Vertex AI service account key must be valid JSON": "Vertex AI 服务账号密钥必须是有效 JSON",
"Video": "视频",
"Video length in seconds": "视频时长(秒)",
"Video Preview": "视频预览",
"Video Remix": "视频 Remix",
"Vidu": "Vidu",
"View": "查看",
@@ -4930,6 +4939,7 @@
"View Pricing": "查看定价",
"View the complete details for this": "查看此条",
"View the complete details for this log entry": "查看此日志条目的完整详情",
"View the complete details for this task": "查看该任务的完整详情",
"View the complete error message and details": "查看完整错误信息与详情",
"View the complete prompt and its English translation": "查看完整提示词及其英文翻译",
"View the generated image": "查看生成的图片",
+25 -18
View File
@@ -55,28 +55,35 @@ export const colorToBgClass: Record<SemanticColor, string> = {
slate: 'bg-slate-500',
}
const identitySurfaceClassName =
'[background-color:color-mix(in_oklch,currentColor_var(--identity-surface-mix),transparent)] [border-color:color-mix(in_oklch,currentColor_var(--identity-border-mix),transparent)]'
const identityColorClassMap: Record<SemanticColor, string> = {
amber: 'bg-identity-amber/10 text-identity-amber',
blue: 'bg-identity-blue/10 text-identity-blue',
cyan: 'bg-identity-cyan/10 text-identity-cyan',
green: 'bg-identity-green/10 text-identity-green',
grey: 'bg-identity-grey/10 text-identity-grey',
indigo: 'bg-identity-indigo/10 text-identity-indigo',
'light-blue': 'bg-identity-light-blue/10 text-identity-light-blue',
'light-green': 'bg-identity-green/10 text-identity-green',
lime: 'bg-identity-lime/10 text-identity-lime',
orange: 'bg-identity-orange/10 text-identity-orange',
pink: 'bg-identity-pink/10 text-identity-pink',
purple: 'bg-identity-purple/10 text-identity-purple',
red: 'bg-identity-red/10 text-identity-red',
slate: 'bg-identity-grey/10 text-identity-grey',
teal: 'bg-identity-teal/10 text-identity-teal',
violet: 'bg-identity-violet/10 text-identity-violet',
yellow: 'bg-identity-yellow/10 text-identity-yellow',
amber: 'text-identity-amber',
blue: 'text-identity-blue',
cyan: 'text-identity-cyan',
green: 'text-identity-green',
grey: 'text-identity-grey',
indigo: 'text-identity-indigo',
'light-blue': 'text-identity-light-blue',
'light-green': 'text-identity-green',
lime: 'text-identity-lime',
orange: 'text-identity-orange',
pink: 'text-identity-pink',
purple: 'text-identity-purple',
red: 'text-identity-red',
slate: 'text-identity-grey',
teal: 'text-identity-teal',
violet: 'text-identity-violet',
yellow: 'text-identity-yellow',
}
export function getIdentityTextColorClass(name: string): string {
return identityColorClassMap[stringToColor(name)]
}
export function getIdentityColorClass(name: string): string {
return identityColorClassMap[stringToColor(name)]
return `${identitySurfaceClassName} ${getIdentityTextColorClass(name)}`
}
export function getBgColorClass(color?: string): string {
+9 -2
View File
@@ -28,6 +28,7 @@ import { StrictMode } from 'react'
import ReactDOM from 'react-dom/client'
import { toast } from 'sonner'
import { TooltipProvider } from '@/components/ui/tooltip'
import { getStatus } from '@/lib/api'
import { installBuildMetadata } from '@/lib/build-metadata'
import { applyFaviconToDom } from '@/lib/dom-utils'
@@ -115,7 +116,11 @@ declare module '@tanstack/react-router' {
}
// Render the app
const rootElement = document.getElementById('root')!
const rootElement = document.querySelector<HTMLDivElement>('#root')
if (!rootElement) {
throw new Error('Root element not found')
}
// Set document.title and favicon from cached status, then refresh from network
;(function initSystemBranding() {
try {
@@ -165,7 +170,9 @@ if (!rootElement.innerHTML) {
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<DirectionProvider>
<RouterProvider router={router} />
<TooltipProvider>
<RouterProvider router={router} />
</TooltipProvider>
</DirectionProvider>
</ThemeProvider>
</QueryClientProvider>
+10 -10
View File
@@ -421,22 +421,22 @@ For commercial licensing, please contact support@quantumnous.com
[data-theme-preset]:not([data-theme-preset='default']):not(
[data-theme-preset='anthropic']
):not([data-theme-preset='simple-large']) {
--card: color-mix(in oklch, var(--primary) 3%, var(--background));
--popover: color-mix(in oklch, var(--primary) 5%, var(--background));
--muted: color-mix(in oklch, var(--primary) 7%, var(--background));
--card: color-mix(in oklch, var(--primary) 6%, var(--background));
--popover: color-mix(in oklch, var(--primary) 8%, var(--background));
--muted: color-mix(in oklch, var(--primary) 10%, var(--background));
--muted-foreground: color-mix(
in oklch,
var(--foreground) 68%,
var(--foreground) 74%,
var(--primary)
);
--accent: color-mix(in oklch, var(--primary) 14%, var(--background));
--accent: color-mix(in oklch, var(--primary) 18%, var(--background));
--accent-foreground: var(--foreground);
--border: color-mix(in oklch, var(--primary) 20%, var(--background));
--input: color-mix(in oklch, var(--primary) 22%, var(--background));
--sidebar: color-mix(in oklch, var(--primary) 4%, var(--background));
--sidebar-accent: color-mix(in oklch, var(--primary) 14%, var(--background));
--border: color-mix(in oklch, var(--primary) 28%, var(--background));
--input: color-mix(in oklch, var(--primary) 30%, var(--background));
--sidebar: color-mix(in oklch, var(--primary) 7%, var(--background));
--sidebar-accent: color-mix(in oklch, var(--primary) 20%, var(--background));
--sidebar-accent-foreground: var(--foreground);
--sidebar-border: color-mix(in oklch, var(--primary) 18%, var(--background));
--sidebar-border: color-mix(in oklch, var(--primary) 26%, var(--background));
--success: var(--chart-2);
--warning: var(--chart-4);
--info: var(--chart-1);
+87 -39
View File
@@ -56,6 +56,8 @@ For commercial licensing, please contact support@quantumnous.com
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-subtle-foreground: var(--subtle-foreground);
--color-faint-foreground: var(--faint-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
@@ -73,92 +75,113 @@ For commercial licensing, please contact support@quantumnous.com
* keeping compact badge text legible across light, dark, and preset themes. */
--color-status-destructive: color-mix(
in oklch,
var(--destructive) 55%,
var(--destructive) var(--status-color-mix),
var(--foreground)
);
--color-status-success: color-mix(
in oklch,
var(--success) 55%,
var(--success) var(--status-color-mix),
var(--foreground)
);
--color-status-warning: color-mix(
in oklch,
var(--warning) 55%,
var(--warning) var(--status-color-mix),
var(--foreground)
);
--color-status-info: color-mix(
in oklch,
var(--info) var(--status-color-mix),
var(--foreground)
);
--color-status-info: color-mix(in oklch, var(--info) 55%, var(--foreground));
/* Metric identity accent; intentionally distinct from status semantics. */
--color-metric-rpm: color-mix(
in oklch,
oklch(0.68 0.2 350) 55%,
oklch(0.68 calc(0.2 * var(--identity-chroma-scale)) 350)
var(--identity-color-mix),
var(--foreground)
);
/* Stable entity hues used by deterministic string-to-color identity tints. */
/* Stable entity hues used by deterministic string-to-color identity tints.
* Mixing toward the achromatic foreground dilutes chroma along with
* lightness, so each hue pre-scales its chroma by the per-mode
* `--identity-chroma-scale` (see :root / .dark). */
--color-identity-amber: color-mix(
in oklch,
oklch(0.72 0.17 75) 55%,
oklch(0.72 calc(0.17 * var(--identity-chroma-scale)) 75)
var(--identity-color-mix),
var(--foreground)
);
--color-identity-blue: color-mix(
in oklch,
oklch(0.62 0.18 250) 55%,
oklch(0.62 calc(0.18 * var(--identity-chroma-scale)) 250)
var(--identity-color-mix),
var(--foreground)
);
--color-identity-cyan: color-mix(
in oklch,
oklch(0.7 0.14 205) 55%,
oklch(0.7 calc(0.14 * var(--identity-chroma-scale)) 205)
var(--identity-color-mix),
var(--foreground)
);
--color-identity-green: color-mix(
in oklch,
oklch(0.64 0.16 145) 55%,
oklch(0.64 calc(0.16 * var(--identity-chroma-scale)) 145)
var(--identity-color-mix),
var(--foreground)
);
--color-identity-grey: var(--muted-foreground);
--color-identity-indigo: color-mix(
in oklch,
oklch(0.6 0.2 275) 55%,
oklch(0.6 calc(0.2 * var(--identity-chroma-scale)) 275)
var(--identity-color-mix),
var(--foreground)
);
--color-identity-light-blue: color-mix(
in oklch,
oklch(0.74 0.12 225) 55%,
oklch(0.74 calc(0.12 * var(--identity-chroma-scale)) 225)
var(--identity-color-mix),
var(--foreground)
);
--color-identity-lime: color-mix(
in oklch,
oklch(0.75 0.17 125) 55%,
oklch(0.75 calc(0.17 * var(--identity-chroma-scale)) 125)
var(--identity-color-mix),
var(--foreground)
);
--color-identity-orange: color-mix(
in oklch,
oklch(0.7 0.19 50) 55%,
oklch(0.7 calc(0.19 * var(--identity-chroma-scale)) 50)
var(--identity-color-mix),
var(--foreground)
);
--color-identity-pink: var(--color-metric-rpm);
--color-identity-purple: color-mix(
in oklch,
oklch(0.62 0.2 305) 55%,
oklch(0.62 calc(0.2 * var(--identity-chroma-scale)) 305)
var(--identity-color-mix),
var(--foreground)
);
--color-identity-red: color-mix(
in oklch,
oklch(0.64 0.21 25) 55%,
oklch(0.64 calc(0.21 * var(--identity-chroma-scale)) 25)
var(--identity-color-mix),
var(--foreground)
);
--color-identity-teal: color-mix(
in oklch,
oklch(0.66 0.14 175) 55%,
oklch(0.66 calc(0.14 * var(--identity-chroma-scale)) 175)
var(--identity-color-mix),
var(--foreground)
);
--color-identity-violet: color-mix(
in oklch,
oklch(0.65 0.2 325) 55%,
oklch(0.65 calc(0.2 * var(--identity-chroma-scale)) 325)
var(--identity-color-mix),
var(--foreground)
);
--color-identity-yellow: color-mix(
in oklch,
oklch(0.78 0.16 95) 55%,
oklch(0.78 calc(0.16 * var(--identity-chroma-scale)) 95)
var(--identity-color-mix),
var(--foreground)
);
--color-border: var(--border);
@@ -201,7 +224,10 @@ For commercial licensing, please contact support@quantumnous.com
* in theme-presets.css. Default mirrors `--font-sans` so behavior is
* identical when no font preference is set. */
--font-body: var(--font-sans);
--background: oklch(1 0 0);
/* Light mode uses a quiet neutral canvas with white content surfaces.
* Keeping the layers distinct prevents dense admin pages from collapsing
* into one flat white plane while preserving the macaron accents. */
--background: oklch(0.985 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
@@ -211,9 +237,11 @@ For commercial licensing, please contact support@quantumnous.com
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.95 0 0);
--secondary-foreground: oklch(0.145 0 0);
--muted: oklch(0.97 0 0);
--muted: oklch(0.955 0 0);
--muted-foreground: oklch(0.49 0 0);
--accent: oklch(0.95 0 0);
--subtle-foreground: var(--muted-foreground);
--faint-foreground: var(--muted-foreground);
--accent: oklch(0.935 0 0);
--accent-foreground: oklch(0.145 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
@@ -225,49 +253,53 @@ For commercial licensing, please contact support@quantumnous.com
--info-foreground: oklch(0.985 0 0);
--neutral: oklch(0.708 0 0);
--neutral-foreground: oklch(0.145 0 0);
--border: oklch(0.93 0 0);
--input: oklch(0.93 0 0);
--border: oklch(0.895 0 0);
--input: oklch(0.875 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.975 0 0);
--sidebar: oklch(0.96 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.13 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.92 0 0);
--sidebar-accent: oklch(0.9 0 0);
--sidebar-accent-foreground: oklch(0.145 0 0);
--sidebar-border: oklch(0.93 0 0);
--sidebar-border: oklch(0.895 0 0);
--sidebar-ring: oklch(0.708 0 0);
--skeleton-base: oklch(0.97 0 0);
--skeleton-base: oklch(0.955 0 0);
--skeleton-highlight: oklch(0.985 0 0);
--table-row: var(--background);
--table-header: color-mix(
in oklch,
var(--foreground) 1.5%,
var(--background)
);
--status-color-mix: 65%;
--identity-color-mix: 60%;
/* Cancels the chroma diluted by the 60% mix toward the near-black
* foreground (1 / 0.6), so light mode keeps the darkened lightness for
* contrast but stays vivid instead of fading into pastel. */
--identity-chroma-scale: 1.6667;
--identity-surface-mix: 13%;
--identity-border-mix: 28%;
--table-row: var(--card);
--table-header: color-mix(in oklch, var(--foreground) 3%, var(--table-row));
--table-header-hover: color-mix(
in oklch,
var(--foreground) 3%,
var(--background)
var(--foreground) 5%,
var(--table-row)
);
--table-disabled: color-mix(
in oklch,
var(--foreground) 5.5%,
var(--background)
var(--table-row)
);
--table-disabled-hover: color-mix(
in oklch,
var(--foreground) 7%,
var(--background)
var(--table-row)
);
--table-disabled-border: color-mix(
in oklch,
var(--foreground) 24%,
var(--background)
var(--table-row)
);
}
@@ -285,6 +317,16 @@ For commercial licensing, please contact support@quantumnous.com
--secondary-foreground: oklch(0.965 0 0);
--muted: oklch(0.305 0 0);
--muted-foreground: oklch(0.78 0 0);
--subtle-foreground: color-mix(
in oklch,
var(--muted-foreground) 60%,
transparent
);
--faint-foreground: color-mix(
in oklch,
var(--muted-foreground) 40%,
transparent
);
--accent: oklch(0.365 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
@@ -315,6 +357,12 @@ For commercial licensing, please contact support@quantumnous.com
--sidebar-ring: oklch(0.68 0 0);
--skeleton-base: oklch(0.335 0 0);
--skeleton-highlight: oklch(0.44 0 0);
--status-color-mix: 55%;
--identity-color-mix: 55%;
/* Dark mode keeps the soft macaron pastels unchanged. */
--identity-chroma-scale: 1;
--identity-surface-mix: 10%;
--identity-border-mix: 20%;
--table-row: var(--background);
--table-header: color-mix(in oklch, var(--foreground) 6%, var(--background));
--table-header-hover: color-mix(