fix(web): 统一首页与登录页视觉规范

This commit is contained in:
tbphp committed 2026-07-31 13:09:49 +08:00
1 parent 5933bbbc6f
commit 08df0a36f0
31 files changed
+1153 -565

No files matched your search

+12 -11
View File
@@ -142,8 +142,13 @@ watch(
:close-label="t('shell.closeNavigation')"
>
<template #trigger>
<IconButton class="mobile-menu-trigger" :label="t('shell.openNavigation')">
<Menu :size="20" aria-hidden="true" />
<IconButton
class="mobile-menu-trigger"
:label="t('shell.openNavigation')"
variant="surface"
size="compact"
>
<Menu :size="15" aria-hidden="true" />
</IconButton>
</template>
<nav class="mobile-nav" :aria-label="t('shell.primaryNavigation')">
@@ -205,13 +210,13 @@ watch(
}
.brand {
display: inline-flex;
min-height: 44px;
align-items: center;
gap: 9px;
font-family: var(--font-serif);
font-size: var(--title-section);
font-weight: 400;
letter-spacing: -0.01em;
white-space: nowrap;
}
.brand-mark {
width: 7px;
@@ -229,12 +234,11 @@ watch(
}
.nav-link {
display: inline-flex;
min-height: 44px;
align-items: center;
gap: var(--space-2);
border-bottom: 1.5px solid transparent;
color: var(--color-text-muted);
padding: var(--space-2) 2px;
padding: 3px 0;
font-size: 13px;
font-weight: 400;
transition:
@@ -261,6 +265,7 @@ watch(
gap: var(--space-2);
}
.import-action {
height: var(--control-compact);
min-height: var(--control-compact);
gap: 6px;
border: 1px solid var(--color-action);
@@ -270,12 +275,8 @@ watch(
font-size: var(--text-meta);
font-weight: 560;
}
.shell-actions :deep(.icon-button) {
width: var(--control-compact);
height: var(--control-compact);
}
.shell-actions :deep(.icon-button:hover:not(:disabled)) {
background: var(--color-surface-sunken);
.import-action__label {
letter-spacing: 0.01em;
}
.mobile-menu-trigger {
display: none;
+99 -46
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
import { formatISOInstant, formatInteger, formatLocalTimeRange } from '@/lib/format'
import { formatISOInstant, formatInteger, formatLocalInstant } from '@/lib/format'
import { buildTrendGeometry, isTrendSeriesUsable, type TrendDatum } from './trend-chart'
@@ -60,9 +60,15 @@ const activePoint = computed(() => {
const tooltipStyle = computed(() => {
const point = activePoint.value?.point
if (!point) return undefined
const left =
point.x < width * 0.16
? '2px'
: point.x > width * 0.84
? 'calc(100% - 2px)'
: `${(point.x / width) * 100}%`
return {
left: `${(point.x / width) * 100}%`,
top: `${(point.y / chartHeight) * 100}%`,
left,
top: `max(72px, calc(${(point.y / chartHeight) * 100}% - 12px))`,
}
})
const tooltipAlignment = computed(() => {
@@ -78,8 +84,8 @@ watch(seriesKey, () => {
clearAnnouncement()
})
function formatBucketRange(datum: TrendDatum): string {
return formatLocalTimeRange(datum.bucket_start_ms, datum.bucket_end_ms, props.locale)
function formatBucketTime(datum: TrendDatum): string {
return formatLocalInstant(datum.bucket_start_ms, props.locale)
}
function tooltipValue(value: number): string {
@@ -89,7 +95,7 @@ function tooltipValue(value: number): string {
function pointAnnouncement(index: number): string {
const datum = chartSeries.value[index]
if (!datum) return ''
return `${formatBucketRange(datum)} · ${props.requestLabel} ${tooltipValue(datum.request_count)} · ${props.failureLabel} ${tooltipValue(datum.failure_count)}`
return `${formatBucketTime(datum)} · ${props.requestLabel} ${tooltipValue(datum.request_count)} · ${props.failureLabel} ${tooltipValue(datum.failure_count)}`
}
function clearAnnouncement(): void {
@@ -177,11 +183,7 @@ onBeforeUnmount(() => document.removeEventListener('pointerdown', closeOnExterna
</script>
<template>
<p v-if="chartSeries.length === 0" class="trend-chart__empty" role="status">
{{ emptyLabel }}
</p>
<figure
v-else
ref="chartElement"
class="trend-chart"
tabindex="0"
@@ -194,7 +196,9 @@ onBeforeUnmount(() => document.removeEventListener('pointerdown', closeOnExterna
@pointerleave="onPointerLeave"
@pointermove="onPointerMove"
>
<span :id="descriptionID" class="trend-chart__visually-hidden">{{ description }}</span>
<span :id="descriptionID" class="trend-chart__visually-hidden">
{{ chartSeries.length === 0 ? emptyLabel : description }}
</span>
<span class="trend-chart__visually-hidden" aria-live="polite" aria-atomic="true">
{{ selectionAnnouncement }}
</span>
@@ -205,7 +209,6 @@ onBeforeUnmount(() => document.removeEventListener('pointerdown', closeOnExterna
<svg
class="trend-chart__graphic"
:viewBox="`0 0 ${width} ${chartHeight}`"
preserveAspectRatio="none"
aria-hidden="true"
>
<g class="trend-chart__grid">
@@ -261,11 +264,30 @@ onBeforeUnmount(() => document.removeEventListener('pointerdown', closeOnExterna
:style="tooltipStyle"
role="tooltip"
>
<time :datetime="formatISOInstant(activePoint.datum.bucket_start_ms)">
{{ formatBucketRange(activePoint.datum) }}
<time
class="trend-chart__tooltip-time"
:datetime="formatISOInstant(activePoint.datum.bucket_start_ms)"
>
{{ formatBucketTime(activePoint.datum) }}
</time>
<span>{{ requestLabel }} {{ tooltipValue(activePoint.datum.request_count) }}</span>
<span>{{ failureLabel }} {{ tooltipValue(activePoint.datum.failure_count) }}</span>
<span class="trend-chart__tooltip-row">
<span class="trend-chart__tooltip-key">
<i class="trend-chart__tooltip-swatch trend-chart__tooltip-swatch--request"></i>
{{ requestLabel }}
</span>
<strong class="trend-chart__tooltip-value">
{{ tooltipValue(activePoint.datum.request_count) }}
</strong>
</span>
<span class="trend-chart__tooltip-row">
<span class="trend-chart__tooltip-key">
<i class="trend-chart__tooltip-swatch trend-chart__tooltip-swatch--failure"></i>
{{ failureLabel }}
</span>
<strong class="trend-chart__tooltip-value">
{{ tooltipValue(activePoint.datum.failure_count) }}
</strong>
</span>
</div>
</Transition>
</div>
@@ -301,28 +323,28 @@ onBeforeUnmount(() => document.removeEventListener('pointerdown', closeOnExterna
.trend-chart__request-frame {
position: relative;
height: 100%;
}
.trend-chart__plot-stack {
position: relative;
height: 158px;
aspect-ratio: var(--chart-aspect-ratio);
}
.trend-chart__data-layer {
height: 158px;
height: 100%;
}
.trend-chart__graphic {
display: block;
width: 100%;
height: 158px;
height: auto;
}
.trend-chart__grid line {
stroke: var(--color-border-subtle);
stroke-width: 1;
stroke-dasharray: 3 4;
vector-effect: non-scaling-stroke;
stroke-dasharray: var(--chart-grid-dash);
}
.trend-chart__area {
@@ -335,21 +357,17 @@ onBeforeUnmount(() => document.removeEventListener('pointerdown', closeOnExterna
stroke: var(--color-action);
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
vector-effect: non-scaling-stroke;
stroke-width: var(--chart-line-width);
}
.trend-chart__guide {
stroke: var(--color-border-strong);
stroke-dasharray: 3 3;
vector-effect: non-scaling-stroke;
}
.trend-chart__dot {
fill: var(--color-action);
stroke: var(--color-surface-raised);
stroke-width: 2;
vector-effect: non-scaling-stroke;
stroke-width: 2.5;
}
.trend-chart__failure-bars rect {
@@ -360,38 +378,73 @@ onBeforeUnmount(() => document.removeEventListener('pointerdown', closeOnExterna
.trend-chart__tooltip {
position: absolute;
z-index: 2;
display: grid;
min-width: 148px;
gap: 2px;
transform: translate(-50%, calc(-100% - 12px));
min-width: 134px;
transform: translate(-50%, -100%);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-control);
border-radius: 8px;
background: var(--color-surface-raised);
box-shadow: var(--shadow-card);
color: var(--color-text-muted);
padding: var(--space-2) var(--space-3);
font-family: var(--font-mono);
font-size: var(--text-xs);
line-height: 1.45;
box-shadow: var(--shadow-chart-tooltip);
padding: 8px 11px;
pointer-events: none;
white-space: nowrap;
}
.trend-chart__tooltip time {
.trend-chart__tooltip-time {
display: block;
margin-bottom: 5px;
color: var(--color-text-faint);
font-family: var(--font-mono);
font-size: 11px;
}
.trend-chart__tooltip-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 14px;
font-size: 12px;
}
.trend-chart__tooltip-row + .trend-chart__tooltip-row {
margin-top: 2px;
}
.trend-chart__tooltip-key {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--color-text-muted);
}
.trend-chart__tooltip-swatch {
display: block;
width: 7px;
height: 7px;
flex: none;
border-radius: 2px;
}
.trend-chart__tooltip-swatch--request {
background: var(--color-action);
}
.trend-chart__tooltip-swatch--failure {
background: var(--color-danger);
}
.trend-chart__tooltip-value {
color: var(--color-text);
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.trend-chart__tooltip--start {
transform: translate(0, calc(-100% - 12px));
transform: translate(0, -100%);
}
.trend-chart__tooltip--end {
transform: translate(-100%, calc(-100% - 12px));
}
.trend-chart__empty {
margin: 0;
color: var(--color-text-muted);
transform: translate(-100%, -100%);
}
@media (prefers-reduced-motion: no-preference) {
+13 -8
View File
@@ -114,13 +114,16 @@ export function buildTrendGeometry(
rangeStart: number,
rangeEnd: number,
): TrendGeometry {
const plotTop = validDimension(chartHeight) ? Math.min(14, chartHeight * 0.1) : 0
const plotBottomInset = validDimension(chartHeight) ? Math.min(8, chartHeight * 0.1) : 0
const baseline = validDimension(chartHeight) ? chartHeight - plotBottomInset : 0
const empty: TrendGeometry = {
requestPath: '',
requestAreaPath: '',
requestPoints: [],
failureBars: [],
plotTop: 0,
baseline: 0,
plotTop,
baseline,
}
if (
!validDimension(width) ||
@@ -134,16 +137,18 @@ export function buildTrendGeometry(
const parsed = series.map(parseDatum) as ParsedDatum[]
const rangeDuration = rangeEnd - rangeStart
const maximumRequests = Math.max(...parsed.map((datum) => datum.requestCount))
const maximumRequests = Math.max(...parsed.map((datum) => datum.requestCount)) * 1.08
const maximumFailures = Math.max(...parsed.map((datum) => datum.failureCount))
const plotTop = Math.min(14, chartHeight * 0.1)
const plotBottomInset = Math.min(8, chartHeight * 0.1)
const baseline = chartHeight - plotBottomInset
const failureHeight = Math.min(maximumFailureHeight, baseline - plotTop)
const first = parsed[0]!
const last = parsed.at(-1)!
const firstMidpoint = first.start + (first.end - first.start) / 2
const lastMidpoint = last.start + (last.end - last.start) / 2
const pointDuration = lastMidpoint - firstMidpoint
const points = parsed.map<TrendPoint>((datum) => {
const midpoint = datum.start + (datum.end - datum.start) / 2
return {
x: round(((midpoint - rangeStart) / rangeDuration) * width),
x: round(pointDuration === 0 ? 0 : ((midpoint - firstMidpoint) / pointDuration) * width),
y: round(
maximumRequests === 0
? baseline
@@ -174,7 +179,7 @@ export function buildTrendGeometry(
const barWidth = Math.min(rawWidth, width, Math.max(3, Math.min(6, rawWidth * 0.26)))
const point = points[index]!
return {
x: round(Math.max(0, Math.min(width - barWidth, point.x - barWidth / 2))),
x: round(point.x - barWidth / 2),
y: round(baseline - height),
width: round(barWidth),
height,
@@ -52,7 +52,6 @@ withDefaults(
font-family: var(--font-serif);
font-size: var(--title-section);
font-weight: 500;
line-height: var(--line-compact);
}
.page-section__content {
+9 -77
View File
@@ -1,12 +1,10 @@
<script setup lang="ts">
import { Moon, Sun } from '@lucide/vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink, useRoute } from 'vue-router'
import { homeLocation } from '@/app/route-locations'
import AppSelect from '@/components/ui/AppSelect.vue'
import IconButton from '@/components/ui/IconButton.vue'
import PreferencesControl from '@/features/preferences/PreferencesControl.vue'
import { useTheme } from '@/features/preferences/theme'
import { supportedLocales, type AppLocale } from '@/i18n'
import { useAppI18n } from '@/i18n/context'
@@ -16,19 +14,6 @@ const theme = useTheme()
const route = useRoute()
const { locale, t } = useI18n()
const currentLocale = computed(() => locale.value as AppLocale)
const systemDark = ref(false)
let colorScheme: MediaQueryList | undefined
const languageOptions = computed(() => [
{ value: 'zh-CN', label: t('shell.localeZh') },
{ value: 'en-US', label: t('shell.localeEn') },
{ value: 'ja-JP', label: t('shell.localeJa') },
])
const darkTheme = computed(
() => theme.theme.value === 'dark' || (theme.theme.value === 'system' && systemDark.value),
)
const themeActionLabel = computed(() =>
darkTheme.value ? t('shell.useLightTheme') : t('shell.useDarkTheme'),
)
function setLocale(value: string): void {
if (supportedLocales.includes(value as AppLocale)) {
@@ -36,24 +21,6 @@ function setLocale(value: string): void {
}
}
function toggleTheme(): void {
theme.setTheme(darkTheme.value ? 'light' : 'dark')
}
function syncSystemTheme(event: MediaQueryListEvent | MediaQueryList): void {
systemDark.value = event.matches
}
onMounted(() => {
colorScheme = window.matchMedia('(prefers-color-scheme: dark)')
syncSystemTheme(colorScheme)
colorScheme.addEventListener('change', syncSystemTheme)
})
onBeforeUnmount(() => {
colorScheme?.removeEventListener('change', syncSystemTheme)
})
watch(
[() => route.meta.titleKey, locale],
() => {
@@ -77,24 +44,13 @@ watch(
<span>{{ t('common.appName') }}</span>
</RouterLink>
<div class="public-tools">
<AppSelect
class="public-language"
:model-value="currentLocale"
:label="t('shell.language')"
:options="languageOptions"
@update:model-value="setLocale"
/>
<IconButton
class="public-theme-action"
:label="themeActionLabel"
size="compact"
@click="toggleTheme"
>
<Sun v-if="darkTheme" :size="15" aria-hidden="true" />
<Moon v-else :size="15" aria-hidden="true" />
</IconButton>
</div>
<PreferencesControl
compact
:locale="currentLocale"
:theme="theme.theme.value"
@update:locale="setLocale"
@update:theme="theme.setTheme"
/>
</header>
<div id="main-content" class="public-content" tabindex="-1">
<slot />
@@ -120,7 +76,6 @@ watch(
.public-brand {
display: inline-flex;
min-height: var(--touch-target);
align-items: center;
gap: 9px;
font-family: var(--font-serif);
@@ -137,23 +92,6 @@ watch(
background: var(--color-action);
}
.public-tools {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
}
.public-tools :deep(.public-language.app-select__trigger) {
width: auto;
min-width: 126px;
min-height: var(--control-compact);
height: var(--control-compact);
padding: 0 var(--space-2);
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.public-content {
min-height: calc(100vh - var(--topbar-height));
}
@@ -164,10 +102,4 @@ watch(
}
}
@media (pointer: coarse) {
.public-tools :deep(.public-language.app-select__trigger) {
min-height: var(--touch-target);
height: var(--touch-target);
}
}
</style>
+34 -7
View File
@@ -4,8 +4,8 @@ withDefaults(
type?: 'button' | 'submit' | 'reset'
disabled?: boolean
busy?: boolean
variant?: 'primary' | 'secondary' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'link'
size?: 'inline' | 'compact' | 'sm' | 'md' | 'cta' | 'lg'
}>(),
{
type: 'button',
@@ -55,6 +55,25 @@ withDefaults(
font-size: var(--text-sm);
}
.app-button--compact {
min-height: var(--control-compact);
padding-inline: 12px;
font-size: var(--text-meta);
font-weight: 560;
}
.app-button--cta {
min-height: var(--control-md);
gap: var(--space-2);
padding-inline: 15px;
}
.app-button--inline {
min-height: 0;
padding: 0;
font-size: inherit;
}
.app-button--lg {
min-height: var(--control-lg);
padding-inline: 18px;
@@ -83,6 +102,15 @@ withDefaults(
color: var(--color-action-ink);
}
.app-button--link {
border: 0;
background: transparent;
color: var(--color-action);
font-weight: inherit;
line-height: inherit;
vertical-align: baseline;
}
.app-button--primary:hover:not(:disabled) {
border-color: var(--color-action-hover);
background: var(--color-action-hover);
@@ -102,6 +130,10 @@ withDefaults(
opacity: 0.88;
}
.app-button--link:hover:not(:disabled) {
color: var(--color-action-hover);
text-decoration: underline;
}
.app-button:disabled {
cursor: not-allowed;
opacity: 0.55;
@@ -111,9 +143,4 @@ withDefaults(
cursor: wait;
}
@media (pointer: coarse) {
.app-button {
min-height: var(--touch-target);
}
}
</style>
+3 -1
View File
@@ -7,6 +7,7 @@ withDefaults(
defineProps<{
align?: 'start' | 'center' | 'end'
side?: 'top' | 'right' | 'bottom' | 'left'
contentClass?: string
}>(),
{
align: 'end',
@@ -24,6 +25,7 @@ withDefaults(
<PopoverPortal>
<PopoverContent
class="app-popover__content"
:class="contentClass"
:align="align"
:side="side"
:side-offset="8"
@@ -62,7 +64,7 @@ withDefaults(
@keyframes app-popover-in {
from {
opacity: 0;
transform: translateY(-4px);
transform: translateY(-4px) scale(0.98);
}
}
+24 -6
View File
@@ -26,11 +26,13 @@ const props = withDefaults(
options: SelectOption[]
disabled?: boolean
variant?: 'default' | 'embedded'
size?: 'md' | 'compact'
}>(),
{
modelValue: undefined,
disabled: false,
variant: 'default',
size: 'md',
},
)
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
@@ -46,7 +48,10 @@ const attrs = useAttrs()
<SelectTrigger
v-bind="attrs"
class="app-select__trigger"
:class="`app-select__trigger--${props.variant}`"
:class="[
`app-select__trigger--${props.variant}`,
`app-select__trigger--${props.size}`,
]"
:aria-label="label"
:disabled="props.disabled"
>
@@ -103,8 +108,26 @@ const attrs = useAttrs()
.app-select__trigger--embedded {
width: 100%;
min-width: 0;
min-height: 0;
flex: 1;
border: 0;
border-radius: 0;
padding: 7px 10px;
font-size: var(--text-sm);
}
.app-select__trigger--compact {
width: auto;
min-width: 0;
min-height: var(--control-compact);
height: var(--control-compact);
padding: 0 var(--space-2);
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.app-select__trigger--compact .app-select__chevron,
.app-select__trigger--embedded .app-select__chevron {
width: 14px;
height: 14px;
}
.app-select__value {
min-width: 0;
@@ -161,9 +184,4 @@ const attrs = useAttrs()
color: var(--color-action);
}
@media (pointer: coarse) {
.app-select__trigger {
min-height: var(--touch-target);
}
}
</style>
+60 -8
View File
@@ -1,17 +1,23 @@
<script setup lang="ts">
import CopyButton from './CopyButton.vue'
defineProps<{
code: string
language: string
copyLabel?: string
copySuccessLabel?: string
copyFailureLabel?: string
}>()
withDefaults(
defineProps<{
code: string
language: string
copyLabel?: string
copySuccessLabel?: string
copyFailureLabel?: string
appearance?: 'default' | 'snippet'
}>(),
{
appearance: 'default',
},
)
</script>
<template>
<div class="code-block">
<div class="code-block" :class="`code-block--${appearance}`">
<div class="code-block__toolbar">
<span data-code-language>{{ language }}</span>
<slot name="action">
@@ -63,4 +69,50 @@ defineProps<{
font-size: var(--text-sm);
overflow-wrap: anywhere;
}
.code-block--snippet {
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
}
.code-block--snippet .code-block__toolbar {
min-height: 0;
margin-bottom: 6px;
border: 0;
padding: 0;
color: var(--color-text-faint);
font-family: var(--font-sans);
font-size: var(--text-sm);
}
.code-block--snippet .code-block__toolbar :deep(.copy-action) {
width: 28px;
min-width: 28px;
height: 24px;
border: 0;
background: transparent;
}
.code-block--snippet .code-block__toolbar :deep(.copy-action svg) {
width: 14px;
height: 14px;
}
.code-block--snippet pre {
margin: 0;
border: 1px solid var(--color-border-subtle);
border-radius: 8px;
background: var(--color-surface-sunken);
padding: 11px 13px;
line-height: 1.7;
white-space: pre;
}
.code-block--snippet code {
color: var(--color-text-muted);
font-size: var(--text-sm);
overflow-wrap: normal;
}
</style>
+7 -11
View File
@@ -62,12 +62,19 @@ defineEmits<{ copy: [] }>()
}
.copy-action--embedded {
width: 34px;
min-width: 34px;
height: auto;
align-self: stretch;
border-width: 0 0 0 1px;
border-radius: 0;
}
.copy-action--embedded :deep(svg) {
width: 14px;
height: 14px;
}
.copy-action:disabled {
cursor: not-allowed;
opacity: 0.55;
@@ -81,15 +88,4 @@ defineEmits<{ copy: [] }>()
color: var(--color-success);
}
@media (pointer: coarse) {
.copy-action {
width: var(--touch-target);
min-width: var(--touch-target);
height: var(--touch-target);
}
.copy-action--embedded {
height: auto;
}
}
</style>
+28 -5
View File
@@ -7,9 +7,11 @@ withDefaults(
dense?: boolean
scrollHint?: string
appearance?: 'card' | 'editorial'
columnCollapse?: 'standard' | 'narrow'
}>(),
{
appearance: 'card',
columnCollapse: 'standard',
scrollHint: undefined,
},
)
@@ -50,6 +52,7 @@ onBeforeUnmount(() => {
:class="{
'data-table__container--dense': dense,
'data-table__container--editorial': appearance === 'editorial',
'data-table__container--collapse-narrow': columnCollapse === 'narrow',
}"
:tabindex="overflowing ? 0 : undefined"
:aria-label="overflowing ? caption : undefined"
@@ -112,14 +115,26 @@ onBeforeUnmount(() => {
.data-table__container--editorial .data-table :deep(th) {
background: transparent;
color: var(--color-text-faint);
padding-block: var(--space-2);
font-size: var(--text-xs);
padding: 0 12px 8px 0;
font-size: var(--text-label-xs);
font-weight: 500;
letter-spacing: 0.04em;
text-transform: none;
letter-spacing: 0.07em;
line-height: 1.3;
text-transform: uppercase;
white-space: nowrap;
}
.data-table__container--editorial .data-table :deep(td) {
padding-block: var(--space-3);
padding: 10px 12px 10px 0;
line-height: 1.3;
white-space: nowrap;
}
.data-table__container--editorial .data-table {
width: 100%;
min-width: var(--table-editorial-min-width);
font-size: 12.5px;
}
.data-table__container--editorial .data-table :deep(tbody tr:hover td) {
background: var(--color-surface-sunken);
}
.data-table :deep(:is(th, td):first-child) {
padding-left: var(--space-4);
@@ -142,4 +157,12 @@ onBeforeUnmount(() => {
display: none;
}
}
@media (min-width: 701px) and (max-width: 759px) {
.data-table__container--collapse-narrow
.data-table
:deep([data-column-priority='low']) {
display: table-cell;
}
}
</style>
+50
View File
@@ -0,0 +1,50 @@
<script setup lang="ts">
defineProps<{
summary: string
}>()
</script>
<template>
<details class="disclosure-panel">
<summary>{{ summary }}</summary>
<div class="disclosure-panel__body">
<slot />
</div>
</details>
</template>
<style scoped>
.disclosure-panel {
border-top: 1px solid var(--color-border-subtle);
padding-top: 14px;
}
.disclosure-panel summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
color: var(--color-text-muted);
cursor: pointer;
font-size: var(--text-sm);
font-weight: 600;
list-style: none;
}
.disclosure-panel summary::-webkit-details-marker {
display: none;
}
.disclosure-panel summary::after {
color: var(--color-text-faint);
content: '+';
}
.disclosure-panel[open] summary::after {
content: '−';
}
.disclosure-panel__body {
margin-top: 11px;
}
</style>
+17 -8
View File
@@ -5,8 +5,8 @@ withDefaults(
disabled?: boolean
busy?: boolean
pressed?: boolean
variant?: 'default' | 'ghost' | 'danger'
size?: 'md' | 'compact'
variant?: 'default' | 'surface' | 'ghost' | 'danger'
size?: 'md' | 'compact' | 'xs'
}>(),
{
disabled: false,
@@ -57,11 +57,26 @@ withDefaults(
color: var(--color-text);
}
.icon-button[aria-pressed='true'] {
border-color: var(--color-text-faint);
background: var(--color-surface-sunken);
color: var(--color-text);
}
.icon-button--compact {
width: var(--control-compact);
height: var(--control-compact);
}
.icon-button--xs {
width: 28px;
height: 28px;
}
.icon-button--surface:hover:not(:disabled):not([aria-pressed='true']) {
background: var(--color-surface);
}
.icon-button--ghost {
border-color: transparent;
background: transparent;
@@ -88,10 +103,4 @@ withDefaults(
cursor: wait;
}
@media (pointer: coarse) {
.icon-button {
width: var(--touch-target);
height: var(--touch-target);
}
}
</style>
+59 -1
View File
@@ -4,9 +4,11 @@ import { computed } from 'vue'
const props = withDefaults(
defineProps<{
tone?: 'info' | 'success' | 'warning' | 'danger'
appearance?: 'default' | 'hint' | 'auth' | 'toast'
}>(),
{
tone: 'info',
appearance: 'default',
},
)
@@ -24,7 +26,7 @@ const glyph = computed(() => {
<template>
<div
class="inline-feedback"
:class="`inline-feedback--${tone}`"
:class="[`inline-feedback--${tone}`, `inline-feedback--${appearance}`]"
:role="role"
:aria-live="live"
aria-atomic="true"
@@ -79,4 +81,60 @@ const glyph = computed(() => {
background: var(--color-danger-bg);
color: var(--color-danger);
}
.inline-feedback--hint {
gap: 6px;
border: 0;
background: transparent;
padding: 0;
font-size: var(--text-sm);
}
.inline-feedback--hint .inline-feedback__glyph {
width: 14px;
height: 14px;
}
.inline-feedback--hint.inline-feedback--info {
color: var(--color-action);
}
.inline-feedback--auth {
gap: 0;
border-color: color-mix(
in srgb,
var(--color-danger) 34%,
var(--color-border-subtle)
);
background: var(--color-danger-bg);
color: var(--color-danger);
padding: 8px 10px;
font-size: var(--text-label-xs);
line-height: 1.55;
}
.inline-feedback--auth .inline-feedback__glyph {
display: none;
}
.inline-feedback--toast {
position: fixed;
z-index: var(--z-drawer);
bottom: 26px;
left: 50%;
width: max-content;
max-width: calc(100vw - 32px);
transform: translateX(-50%);
border: 0;
border-radius: 8px;
background: var(--color-text);
color: var(--color-surface);
padding: 8px 14px;
box-shadow: var(--shadow-overlay);
font-size: 12.5px;
}
.inline-feedback--toast .inline-feedback__glyph {
display: none;
}
</style>
+43 -18
View File
@@ -7,14 +7,20 @@ export interface SegmentedControlOption {
disabled?: boolean
}
defineProps<{
modelValue: string
label: string
options: SegmentedControlOption[]
controlsId?: string
idPrefix?: string
scrollable?: boolean
}>()
withDefaults(
defineProps<{
modelValue: string
label: string
options: SegmentedControlOption[]
controlsId?: string
idPrefix?: string
scrollable?: boolean
appearance?: 'joined' | 'pills'
}>(),
{
appearance: 'joined',
},
)
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
@@ -57,7 +63,10 @@ function handleSegmentKeydown(event: KeyboardEvent): void {
>
<TabsList
class="segmented-control__list"
:class="{ 'segmented-control__list--scrollable': scrollable }"
:class="[
`segmented-control__list--${appearance}`,
{ 'segmented-control__list--scrollable': scrollable },
]"
:aria-label="label"
>
<TabsTrigger
@@ -83,9 +92,7 @@ function handleSegmentKeydown(event: KeyboardEvent): void {
display: inline-flex;
}
.segmented-control__list {
display: inline-grid;
grid-auto-columns: minmax(48px, 1fr);
grid-auto-flow: column;
display: inline-flex;
overflow: hidden;
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-control);
@@ -96,8 +103,15 @@ function handleSegmentKeydown(event: KeyboardEvent): void {
overflow-x: auto;
overscroll-behavior-inline: contain;
}
.segmented-control__list--pills {
gap: 6px;
border: 0;
border-radius: 0;
background: transparent;
}
.segmented-control__trigger {
min-height: var(--control-compact);
flex: none;
min-height: 0;
border: 0;
border-right: 1px solid var(--color-border-strong);
background: transparent;
@@ -115,19 +129,30 @@ function handleSegmentKeydown(event: KeyboardEvent): void {
.segmented-control__trigger:last-child {
border-right: 0;
}
.segmented-control__list--pills .segmented-control__trigger {
border: 1px solid var(--color-border-control);
border-radius: var(--radius-control);
background: var(--color-surface);
color: var(--color-text-muted);
padding: 6px 12px;
font-size: 12.5px;
}
.segmented-control__list--pills
.segmented-control__trigger:hover:not(:disabled):not([data-state='active']) {
border-color: var(--color-text-faint);
color: var(--color-text);
}
.segmented-control__trigger[data-state='active'] {
background: var(--color-text);
color: var(--color-surface);
font-weight: 560;
}
.segmented-control__list--pills .segmented-control__trigger[data-state='active'] {
border-color: var(--color-text);
}
.segmented-control__trigger:disabled {
cursor: not-allowed;
opacity: 0.55;
}
@media (pointer: coarse) {
.segmented-control__trigger {
min-height: var(--touch-target);
}
}
</style>
+6 -3
View File
@@ -2,10 +2,12 @@
withDefaults(
defineProps<{
height?: string
width?: string
rounded?: boolean
}>(),
{
height: '1rem',
width: '100%',
rounded: true,
},
)
@@ -15,7 +17,7 @@ withDefaults(
<span
class="skeleton-block"
:class="{ 'skeleton-block--rounded': rounded }"
:style="{ '--skeleton-height': height }"
:style="{ '--skeleton-height': height, '--skeleton-width': width }"
aria-hidden="true"
/>
</template>
@@ -23,7 +25,8 @@ withDefaults(
<style scoped>
.skeleton-block {
display: block;
width: 100%;
width: var(--skeleton-width);
max-width: 100%;
height: var(--skeleton-height);
background: linear-gradient(
90deg,
@@ -32,7 +35,7 @@ withDefaults(
var(--color-surface-sunken)
);
background-size: 200% 100%;
animation: skeleton-shift 1.4s var(--easing-standard) infinite;
animation: skeleton-shift var(--duration-skeleton) var(--easing-standard) infinite;
}
.skeleton-block--rounded {
+1 -1
View File
@@ -28,7 +28,7 @@ defineProps<{
display: grid;
min-width: 0;
align-content: start;
gap: var(--space-2);
gap: 6px;
}
.stat-figure__label {
color: var(--color-text-faint);
+118 -18
View File
@@ -10,6 +10,7 @@ import { safeRedirect } from '@/app/router'
import LedgerSheet from '@/components/layout/LedgerSheet.vue'
import PageFrame from '@/components/layout/PageFrame.vue'
import AppButton from '@/components/ui/AppButton.vue'
import DisclosurePanel from '@/components/ui/DisclosurePanel.vue'
import FormField from '@/components/ui/FormField.vue'
import IconButton from '@/components/ui/IconButton.vue'
import InlineFeedback from '@/components/ui/InlineFeedback.vue'
@@ -74,6 +75,14 @@ function handleInput(): void {
}
}
async function retryFromHelp(): Promise<void> {
if (candidate.value === '') {
await focusInput()
return
}
await submit()
}
async function submit(): Promise<void> {
if (candidate.value === '') {
fieldError.value = t('auth.required')
@@ -171,7 +180,7 @@ async function submit(): Promise<void> {
:pressed="visible"
:disabled="controlsDisabled"
variant="ghost"
size="compact"
size="xs"
@click="visible = !visible"
>
<EyeOff v-if="visible" :size="15" aria-hidden="true" />
@@ -180,8 +189,12 @@ async function submit(): Promise<void> {
</div>
</FormField>
<div id="auth-feedback" class="ledger-login__feedback">
<InlineFeedback v-if="feedbackMessage" :tone="feedbackTone">
<div
v-if="feedbackMessage"
id="auth-feedback"
class="ledger-login__feedback"
>
<InlineFeedback :tone="feedbackTone" appearance="auth">
{{ feedbackMessage }}
</InlineFeedback>
</div>
@@ -196,10 +209,52 @@ async function submit(): Promise<void> {
</AppButton>
<p id="auth-session-note" class="ledger-login__session-note">
{{ t('auth.sessionNote') }}
{{ t('auth.sessionNotePrefix') }}<code>sessionStorage</code>{{ t(
'auth.sessionNoteSuffix',
) }}
</p>
</form>
<DisclosurePanel
class="ledger-login__auth-help"
:summary="t('auth.help.title')"
>
<div class="ledger-login__auth-sources">
<div class="ledger-login__auth-source">
<strong>{{ t('auth.help.environmentTitle') }}</strong>
<i18n-t keypath="auth.help.environmentDescription" tag="p">
<template #key><code>AUTH_KEY</code></template>
</i18n-t>
</div>
<div class="ledger-login__auth-source">
<strong>{{ t('auth.help.fileTitle') }}</strong>
<i18n-t keypath="auth.help.fileDescription" tag="p">
<template #path><code>${DATA_DIR}/auth.key</code></template>
<template #containerPath><code>/app/data/auth.key</code></template>
</i18n-t>
</div>
<div class="ledger-login__auth-source">
<strong>{{ t('auth.help.dockerTitle') }}</strong>
<i18n-t keypath="auth.help.dockerDescription" tag="p">
<template #command><code>docker exec -it gpt-load sh</code></template>
</i18n-t>
</div>
</div>
</DisclosurePanel>
<p class="ledger-login__recovery">
{{ t('auth.recoveryPrefix') }}
<AppButton
variant="link"
size="inline"
:disabled="controlsDisabled"
@click="retryFromHelp"
>
{{ t('auth.recoveryAction') }}
</AppButton>
{{ t('auth.recoverySuffix') }}
</p>
<p class="ledger-login__instance">
<span aria-hidden="true"></span>
{{ t('auth.instance', { origin: instanceOrigin }) }}
@@ -220,7 +275,8 @@ async function submit(): Promise<void> {
.ledger-login__intro {
display: flex;
min-width: 0;
align-items: center;
flex-direction: column;
justify-content: center;
border-right: 1px solid var(--color-border-subtle);
padding: 50px 56px 44px;
}
@@ -285,13 +341,14 @@ async function submit(): Promise<void> {
margin: 2px 0 0;
color: var(--color-text-faint);
font-size: var(--text-sm);
line-height: var(--line-normal);
line-height: normal;
}
.ledger-login__auth {
display: flex;
min-width: 0;
align-items: center;
flex-direction: column;
justify-content: center;
background: var(--color-surface-sunken);
padding: 44px 42px;
}
@@ -307,7 +364,6 @@ async function submit(): Promise<void> {
font-size: var(--title-panel);
font-weight: 500;
letter-spacing: -0.015em;
line-height: var(--line-compact);
}
.ledger-login__description {
@@ -333,7 +389,7 @@ async function submit(): Promise<void> {
font-size: 12.5px;
}
.ledger-login__input-wrap :deep(.ledger-login__reveal.icon-button) {
.ledger-login__reveal {
position: absolute;
top: 50%;
right: 7px;
@@ -341,11 +397,7 @@ async function submit(): Promise<void> {
}
.ledger-login__feedback {
min-height: 72px;
}
.ledger-login__feedback :deep(.inline-feedback) {
font-size: var(--text-label-xs);
min-width: 0;
}
.ledger-login__submit {
@@ -359,6 +411,56 @@ async function submit(): Promise<void> {
line-height: 1.6;
}
.ledger-login__session-note code {
color: var(--color-text-muted);
font-family: var(--font-mono);
}
.ledger-login__auth-help {
margin-top: 17px;
}
.ledger-login__auth-sources {
display: grid;
gap: var(--space-2);
}
.ledger-login__auth-source {
border-left: 2px solid var(--color-border-control);
padding-left: 9px;
}
.ledger-login__auth-source strong {
display: block;
color: var(--color-text-muted);
font-size: var(--text-label-xs);
}
.ledger-login__auth-source p {
margin: 2px 0 0;
color: var(--color-text-faint);
font-size: 10px;
line-height: 1.55;
}
.ledger-login__auth-source code {
display: inline-block;
max-width: 100%;
overflow: hidden;
color: var(--color-text-muted);
font-family: var(--font-mono);
text-overflow: ellipsis;
vertical-align: bottom;
white-space: nowrap;
}
.ledger-login__recovery {
margin: var(--space-3) 0 0;
color: var(--color-text-faint);
font-size: 10px;
line-height: 1.55;
}
.ledger-login__instance {
display: flex;
min-width: 0;
@@ -415,11 +517,9 @@ async function submit(): Promise<void> {
.ledger-login__headline {
font-size: var(--title-lede);
}
}
@media (pointer: coarse) {
.ledger-login__input-wrap input {
padding-right: 50px;
.ledger-login__capability p {
line-height: var(--line-normal);
}
}
</style>
+40 -11
View File
@@ -5,14 +5,21 @@ import { useI18n } from 'vue-i18n'
import type { HomeRankings, HomeStatisticsRef } from '@/app/resources/home'
import DataTable from '@/components/ui/DataTable.vue'
import SegmentedControl from '@/components/ui/SegmentedControl.vue'
import SkeletonBlock from '@/components/ui/SkeletonBlock.vue'
import { formatEstimatedCost, formatInteger, formatTokens } from '@/lib/format'
type RankingDimension = 'models' | 'groups' | 'accessKeys'
const props = defineProps<{
rankings: HomeRankings
range: string
}>()
const props = withDefaults(
defineProps<{
rankings: HomeRankings
range: string
loading?: boolean
}>(),
{
loading: false,
},
)
const { locale, t } = useI18n()
const dimension = ref<RankingDimension>('models')
@@ -65,7 +72,9 @@ function tokenCellAttributes(totalTokens: number): { title: string; 'aria-label'
<DataTable
appearance="editorial"
column-collapse="narrow"
dense
:aria-busy="loading ? 'true' : undefined"
:caption="t('home.ledger.ranking.caption')"
:scroll-hint="t('home.ledger.ranking.scrollHint')"
>
@@ -103,14 +112,24 @@ function tokenCellAttributes(totalTokens: number): { title: string; 'aria-label'
</tr>
</thead>
<tbody>
<tr v-if="rows.length === 0">
<template v-if="loading">
<tr v-for="row in 5" :key="`loading-${row}`">
<td v-for="column in dimension === 'models' ? 5 : 4" :key="column">
<SkeletonBlock
height="0.72rem"
:width="column === 1 ? '72%' : column === 2 ? '58%' : '44%'"
/>
</td>
</tr>
</template>
<tr v-else-if="rows.length === 0">
<td class="consumption-ranking__empty" :colspan="dimension === 'models' ? 5 : 4">
{{ t('home.ledger.ranking.empty') }}
</td>
</tr>
<template v-else-if="dimension === 'models'">
<tr v-for="row in rankings.models.slice(0, 5)" :key="`${row.group.id}:${row.model}`">
<td class="consumption-ranking__mono">{{ modelName(row.model) }}</td>
<td class="consumption-ranking__model">{{ modelName(row.model) }}</td>
<td>{{ referenceName(row.group, 'group') }}</td>
<td class="consumption-ranking__number consumption-ranking__mono">
{{ formatInteger(row.request_count, locale) }}
@@ -129,7 +148,9 @@ function tokenCellAttributes(totalTokens: number): { title: string; 'aria-label'
</template>
<template v-else-if="dimension === 'groups'">
<tr v-for="row in rankings.groups.slice(0, 5)" :key="row.group.id">
<td>{{ referenceName(row.group, 'group') }}</td>
<td>
{{ referenceName(row.group, 'group') }}
</td>
<td class="consumption-ranking__number consumption-ranking__mono">
{{ formatInteger(row.request_count, locale) }}
</td>
@@ -147,7 +168,9 @@ function tokenCellAttributes(totalTokens: number): { title: string; 'aria-label'
</template>
<template v-else>
<tr v-for="row in rankings.access_keys.slice(0, 5)" :key="row.access_key.id">
<td>{{ referenceName(row.access_key, 'accessKey') }}</td>
<td>
{{ referenceName(row.access_key, 'accessKey') }}
</td>
<td class="consumption-ranking__number consumption-ranking__mono">
{{ formatInteger(row.request_count, locale) }}
</td>
@@ -171,15 +194,15 @@ function tokenCellAttributes(totalTokens: number): { title: string; 'aria-label'
<style scoped>
.consumption-ranking {
border-bottom: 1px solid var(--color-border-subtle);
padding: var(--space-6) 0 var(--space-5);
padding: 22px 0 20px;
}
.consumption-ranking__header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-3);
gap: 14px;
margin-bottom: 12px;
}
.consumption-ranking__header h2 {
margin: 0;
@@ -194,9 +217,15 @@ function tokenCellAttributes(totalTokens: number): { title: string; 'aria-label'
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.consumption-ranking__model {
color: var(--color-text-muted);
font-family: var(--font-mono);
font-size: var(--text-sm);
}
.consumption-ranking__empty {
color: var(--color-text-faint);
font-size: var(--text-sm);
padding-block: var(--space-3) !important;
}
</style>
+62 -23
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ExternalLink, KeyRound } from '@lucide/vue'
import { Layers3, Zap } from '@lucide/vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
@@ -131,6 +131,14 @@ function selectedClientLabel(clientID: GatewayClientID): string {
return t(`home.ledger.connection.clients.${clientID}`)
}
function selectedClientKind(clientID: GatewayClientID): string {
if (clientID === 'nextchat') return t('home.ledger.connection.clientKinds.desktopWeb')
if (clientID === 'cherry-studio') return t('home.ledger.connection.clientKinds.desktop')
if (clientID === 'claude-code') return t('home.ledger.connection.clientKinds.commandLine')
if (clientID === 'curl') return t('home.ledger.connection.clientKinds.general')
return ''
}
function identityMatches(identity: OperationIdentity): boolean {
return (
!unmounted &&
@@ -319,7 +327,7 @@ onBeforeUnmount(() => {
<template>
<section class="gateway-connection" aria-labelledby="gateway-connection-title">
<div class="gateway-connection__heading">
<KeyRound :size="16" aria-hidden="true" />
<Layers3 :size="15" aria-hidden="true" />
<h2 id="gateway-connection-title">{{ t('home.ledger.connection.title') }}</h2>
</div>
@@ -364,6 +372,7 @@ onBeforeUnmount(() => {
:options="clientOptions"
:controls-id="gatewayClientPanelID"
:id-prefix="gatewayClientTabPrefix"
appearance="pills"
scrollable
@update:model-value="selectClient"
/>
@@ -377,15 +386,20 @@ onBeforeUnmount(() => {
:aria-labelledby="`${gatewayClientTabPrefix}-${activeClient}`"
>
<header class="gateway-connection__panel-header">
<strong>{{ selectedClientLabel(activeClient) }}</strong>
<strong>
{{ selectedClientLabel(activeClient) }}
<span v-if="selectedClientKind(activeClient)">
{{ selectedClientKind(activeClient) }}
</span>
</strong>
<AppButton
v-if="activeClient === 'nextchat'"
size="sm"
size="compact"
:disabled="!selectedKeySupportsClient || actionBusy"
:busy="actionBusy"
@click="nextChatConfirmationOpen = true"
>
<ExternalLink :size="16" aria-hidden="true" />
<Zap :size="14" aria-hidden="true" />
{{ t('home.ledger.connection.openNextChat') }}
</AppButton>
</header>
@@ -397,7 +411,11 @@ onBeforeUnmount(() => {
</p>
</template>
<template v-else>
<InlineFeedback v-if="!selectedKeySupportsClient" tone="warning">
<InlineFeedback
v-if="!selectedKeySupportsClient"
tone="warning"
appearance="hint"
>
{{
t('home.ledger.connection.protocolUnavailable', {
client: selectedClientLabel(activeClient),
@@ -408,7 +426,11 @@ onBeforeUnmount(() => {
}}
</InlineFeedback>
<CodeBlock :code="maskedSnippet" :language="selectedClientLabel(activeClient)">
<CodeBlock
:code="maskedSnippet"
:language="t('home.ledger.connection.configuration')"
appearance="snippet"
>
<template #action>
<CopyAction
:label="t('home.ledger.connection.copyConfiguration')"
@@ -420,7 +442,11 @@ onBeforeUnmount(() => {
</template>
</CodeBlock>
<InlineFeedback v-if="activeClient === 'nextchat'" tone="info">
<InlineFeedback
v-if="activeClient === 'nextchat'"
tone="info"
appearance="hint"
>
{{ t('home.ledger.connection.disableFastLink') }}
</InlineFeedback>
</template>
@@ -432,6 +458,7 @@ onBeforeUnmount(() => {
v-if="visibleFeedback"
class="gateway-connection__feedback"
:tone="feedbackTone"
appearance="toast"
>
{{ feedbackMessage }}
</InlineFeedback>
@@ -461,13 +488,14 @@ onBeforeUnmount(() => {
<style scoped>
.gateway-connection {
padding-top: var(--space-6);
padding-top: 22px;
}
.gateway-connection__heading {
display: flex;
align-items: center;
gap: var(--space-2);
gap: 10px;
margin-bottom: 4px;
}
.gateway-connection__heading h2 {
@@ -485,8 +513,8 @@ onBeforeUnmount(() => {
display: grid;
grid-template-columns: minmax(220px, 300px) minmax(0, 1fr);
align-items: end;
gap: var(--space-5);
margin-top: var(--space-5);
gap: 20px;
margin-top: 18px;
}
.gateway-connection__key {
@@ -503,17 +531,12 @@ onBeforeUnmount(() => {
.gateway-connection__key-control {
display: flex;
min-width: 0;
min-height: var(--control-md);
overflow: hidden;
border: 1px solid var(--color-border-control);
border-radius: var(--radius-control);
background: var(--color-surface);
}
.gateway-connection__key-control :deep(.app-select__trigger) {
flex: 1;
}
.gateway-connection__clients {
display: flex;
min-width: 0;
@@ -530,33 +553,49 @@ onBeforeUnmount(() => {
.gateway-connection__panel-header {
display: flex;
min-height: var(--touch-target);
min-height: var(--surface-header-min-height);
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
border-bottom: 1px solid var(--color-border-subtle);
background: var(--color-surface-sunken);
padding: 10px 14px;
padding: 12px 14px;
}
.gateway-connection__panel-header strong {
display: inline-flex;
min-width: 0;
align-items: baseline;
gap: 8px;
font-size: var(--text-body);
font-weight: 600;
}
.gateway-connection__panel-header strong span {
color: var(--color-text-faint);
font-size: var(--text-sm);
font-weight: 400;
}
.gateway-connection__panel-body {
display: grid;
gap: var(--space-3);
gap: 10px;
padding: 14px;
}
.gateway-connection__more {
margin: 0;
color: var(--color-text-muted);
margin: 4px;
border: 1px dashed var(--color-border-control);
border-radius: 8px;
color: var(--color-text-faint);
padding: 26px 18px;
font-size: 12.5px;
text-align: center;
}
.gateway-connection__feedback {
margin-top: var(--space-3);
margin: 0;
}
.gateway-connection__empty {
+63 -40
View File
@@ -18,14 +18,19 @@ import {
import type { HomeStatisticsState } from './home-presenter'
const props = defineProps<{
base: HomeBaseDto
statisticsState: HomeStatisticsState
selectedRange: HomeRange
targetRange: HomeRange | null
observedAtMs: number | null
uptimeNowMs: number
}>()
const props = withDefaults(
defineProps<{
base: HomeBaseDto
statisticsState: HomeStatisticsState
selectedRange: HomeRange
observedAtMs: number | null
uptimeNowMs: number
loading?: boolean
}>(),
{
loading: false,
},
)
const emit = defineEmits<{ selectRange: [range: HomeRange] }>()
const { locale, t } = useI18n()
@@ -34,10 +39,9 @@ const snapshot = computed(() => {
const state = props.statisticsState
return state.kind === 'initial' ? null : state.snapshot
})
const isSwitching = computed(() => props.statisticsState.kind === 'switching')
const rangeOptions = computed(() => [
{ value: '24h', label: t('home.range.last24Hours'), disabled: props.targetRange === '24h' },
{ value: '30d', label: t('home.range.last30Days'), disabled: props.targetRange === '30d' },
{ value: '24h', label: t('home.range.last24Hours') },
{ value: '30d', label: t('home.range.last30Days') },
])
const updated = computed(() =>
props.observedAtMs === null ? '—' : formatLocalTime(props.observedAtMs, locale.value),
@@ -84,6 +88,9 @@ const costDetailExact = computed(() => {
unpriced: formatInteger(summary.unpriced_request_count, locale.value),
})
})
function rangeLabel(range: HomeRange): string {
return t(range === '24h' ? 'home.range.display24Hours' : 'home.range.display30Days')
}
function selectRange(value: string): void {
if (value === '24h' || value === '30d') emit('selectRange', value)
}
@@ -129,23 +136,25 @@ function selectRange(value: string): void {
</dl>
</header>
<section class="home-summary__figures">
<template v-if="isSwitching || !snapshot">
<SkeletonBlock
class="home-summary__figure"
height="5.5rem"
:aria-label="t('home.ledger.statisticsLoading')"
/>
<SkeletonBlock
class="home-summary__figure home-summary__figure--secondary"
height="5.5rem"
:aria-label="t('home.ledger.statisticsLoading')"
/>
<section class="home-summary__figures" :aria-busy="loading ? 'true' : undefined">
<template v-if="loading || !snapshot">
<div class="home-summary__figure home-summary__figure--loading">
<SkeletonBlock width="48%" height="0.72rem" />
<SkeletonBlock width="62%" height="2.25rem" />
<SkeletonBlock width="54%" height="0.75rem" />
</div>
<div
class="home-summary__figure home-summary__figure--secondary home-summary__figure--loading"
>
<SkeletonBlock width="52%" height="0.72rem" />
<SkeletonBlock width="68%" height="2.25rem" />
<SkeletonBlock width="58%" height="0.75rem" />
</div>
</template>
<template v-else>
<StatFigure
class="home-summary__figure"
:label="t('home.ledger.successRate', { range: snapshot.range })"
:label="t('home.ledger.successRate', { range: rangeLabel(snapshot.range) })"
:value="
formatPercent(snapshot.summary.success_count, snapshot.summary.request_count, locale)
"
@@ -153,7 +162,7 @@ function selectRange(value: string): void {
/>
<StatFigure
class="home-summary__figure home-summary__figure--secondary"
:label="t('home.ledger.estimatedCost', { range: snapshot.range })"
:label="t('home.ledger.estimatedCost', { range: rangeLabel(snapshot.range) })"
:value="formatEstimatedCost(snapshot.summary.estimated_cost_nano_usd, locale)"
:detail="costDetail"
:detail-title="costDetailExact"
@@ -173,11 +182,12 @@ function selectRange(value: string): void {
<style scoped>
.home-summary__header {
display: flex;
align-items: start;
align-items: center;
justify-content: space-between;
gap: var(--space-5);
gap: 22px;
flex-wrap: wrap;
border-bottom: 1px solid var(--color-border-control);
padding-bottom: var(--space-5);
padding-bottom: 20px;
}
.home-summary__facts {
@@ -188,10 +198,11 @@ function selectRange(value: string): void {
font-size: var(--title-lede);
font-weight: 500;
line-height: var(--line-compact);
letter-spacing: -0.015em;
}
.home-summary__fact {
white-space: nowrap;
color: var(--color-text-muted);
}
.home-summary__fact strong {
@@ -207,11 +218,11 @@ function selectRange(value: string): void {
.home-summary__stamp {
display: grid;
gap: var(--space-1);
gap: 5px;
margin: 0;
color: var(--color-text-faint);
font-family: var(--font-mono);
font-size: var(--text-meta);
font-size: var(--text-sm);
text-align: right;
white-space: nowrap;
}
@@ -219,7 +230,7 @@ function selectRange(value: string): void {
.home-summary__stamp div {
display: flex;
justify-content: end;
gap: var(--space-2);
gap: 1ch;
}
.home-summary__stamp dt,
.home-summary__stamp dd {
@@ -227,32 +238,41 @@ function selectRange(value: string): void {
}
.home-summary__stamp dd {
color: var(--color-text-muted);
font-weight: 500;
}
.home-summary__figures {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)) auto;
align-items: start;
gap: var(--space-7);
border-bottom: 1px solid var(--color-border-subtle);
padding: var(--space-6) 0;
padding: 22px 0 24px;
}
.home-summary__range {
flex: none;
grid-column: 3;
}
.home-summary__figure {
min-height: 5.5rem;
border-left: 1px solid var(--color-border-subtle);
padding: 0 28px;
}
.home-summary__figure:first-child {
border-left: 0;
padding-left: 0;
}
.home-summary__figure--secondary {
border-left: 1px solid var(--color-border-subtle);
padding-left: var(--space-7);
}
.home-summary__figure--loading {
display: grid;
min-height: 5.5rem;
align-content: start;
gap: 6px;
}
@media (max-width: 860px) {
.home-summary__header {
align-items: start;
flex-direction: column;
}
.home-summary__stamp {
text-align: left;
@@ -262,18 +282,21 @@ function selectRange(value: string): void {
}
.home-summary__figures {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.home-summary__range {
grid-column: 1 / -1;
grid-row: 1;
justify-self: end;
width: max-content;
max-width: 100%;
margin-bottom: 14px;
}
.home-summary__figure {
grid-row: 2;
padding: 0 18px;
}
.home-summary__figure--secondary {
padding-left: var(--space-4);
.home-summary__figure:first-child {
padding-left: 0;
}
}
</style>
+33 -34
View File
@@ -4,7 +4,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useApiClient } from '@/api/client-context'
import { homeBaseQueryOptions } from '@/app/resources/home'
import { homeBaseQueryOptions, type HomeRange } from '@/app/resources/home'
import TrendChart from '@/components/charts/TrendChart.vue'
import LedgerSheet from '@/components/layout/LedgerSheet.vue'
import PageFrame from '@/components/layout/PageFrame.vue'
@@ -36,16 +36,14 @@ watch(
const uptimeNowMS = computed(() => nowMS.value + serverClockOffsetMS.value)
const snapshot = computed(() => {
const state = statistics.state.value
return state.kind === 'initial' || state.kind === 'switching' ? null : state.snapshot
return state.kind === 'initial' ? null : state.snapshot
})
const statisticsLoading = computed(() => {
const kind = statistics.state.value.kind
return kind === 'initial' || kind === 'switching'
return statistics.state.value.kind === 'initial'
})
const statisticsFeedbackMessage = computed(() =>
statistics.lastSuccessfulObservedAtMS.value === null
? t('home.ledger.statisticsInitialError')
: t('home.ledger.statisticsError'),
const statisticsSwitching = computed(() => statistics.state.value.kind === 'switching')
const displayedRange = computed(
() => statistics.targetRange.value ?? snapshot.value?.range ?? statistics.selectedRange.value,
)
const isEmpty = computed(() => {
const inventory = baseQuery.data.value?.inventory
@@ -53,6 +51,9 @@ const isEmpty = computed(() => {
inventory !== undefined && inventory.group_count === 0 && inventory.upstream_key_count === 0
)
})
function rangeLabel(range: HomeRange): string {
return t(range === '24h' ? 'home.range.display24Hours' : 'home.range.display30Days')
}
const uptimeTimer = window.setInterval(() => {
nowMS.value = Date.now()
@@ -63,7 +64,10 @@ onBeforeUnmount(() => window.clearInterval(uptimeTimer))
<template>
<PageFrame aria-labelledby="home-title">
<LedgerSheet :class="{ 'home-view__sheet--welcome': isEmpty }">
<LedgerSheet
class="home-view__sheet"
:class="{ 'home-view__sheet--welcome': isEmpty }"
>
<div
v-if="baseQuery.isPending.value"
class="home-view__loading"
@@ -95,29 +99,27 @@ onBeforeUnmount(() => window.clearInterval(uptimeTimer))
:base="baseQuery.data.value"
:statistics-state="statistics.state.value"
:selected-range="statistics.selectedRange.value"
:target-range="statistics.targetRange.value"
:observed-at-ms="statistics.lastSuccessfulObservedAtMS.value"
:uptime-now-ms="uptimeNowMS"
:loading="statisticsSwitching"
@select-range="statistics.selectRange"
/>
<div v-if="statistics.state.value.kind === 'stale'" class="home-view__statistics-feedback">
<QueryFeedback
state="stale"
:message="statisticsFeedbackMessage"
:retry-label="t('common.retry')"
@retry="statistics.retry()"
/>
</div>
<section class="home-view__statistics-region home-view__statistics-region--trend">
<PageSection
v-if="snapshot"
:title="t('home.ledger.trendTitle', { range: snapshot.range })"
:title="t('home.ledger.trendTitle', { range: rangeLabel(displayedRange) })"
>
<SkeletonBlock
v-if="statisticsSwitching"
class="home-view__trend-skeleton"
height="auto"
:aria-label="t('home.ledger.statisticsLoading')"
/>
<TrendChart
v-else
:series="snapshot.series"
:title="t('home.ledger.trendTitle', { range: snapshot.range })"
:title="t('home.ledger.trendTitle', { range: rangeLabel(snapshot.range) })"
:description="t('home.ledger.trendDescription')"
:empty-label="t('home.ledger.trendEmpty')"
:request-label="t('home.ledger.requestsLabel')"
@@ -140,7 +142,8 @@ onBeforeUnmount(() => window.clearInterval(uptimeTimer))
<ConsumptionRanking
v-if="snapshot"
:rankings="snapshot.rankings"
:range="snapshot.range"
:range="rangeLabel(displayedRange)"
:loading="statisticsSwitching"
/>
<PageSection
v-else-if="statisticsLoading"
@@ -177,24 +180,16 @@ onBeforeUnmount(() => window.clearInterval(uptimeTimer))
font-weight: 500;
}
.home-view__statistics-feedback {
padding-top: var(--space-4);
}
.home-view__statistics-section :deep(.page-section__content) {
min-height: 12rem;
}
.home-view__statistics-region {
min-height: 17rem;
.home-view__statistics-region--trend :deep(.page-section__header) {
margin-bottom: 10px;
}
.home-view__statistics-region--trend {
min-height: 16rem;
}
.home-view__statistics-region--ranking {
min-height: 21rem;
.home-view__trend-skeleton {
aspect-ratio: var(--chart-aspect-ratio);
}
.home-view__sheet--welcome {
@@ -206,5 +201,9 @@ onBeforeUnmount(() => window.clearInterval(uptimeTimer))
.home-view__sheet--welcome {
min-height: 0;
}
.home-view__sheet {
border-radius: 9px;
}
}
</style>
+39 -22
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ArrowRight, KeyRound, ShieldCheck } from '@lucide/vue'
import { ArrowRight, CircleCheck, KeyRound } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
@@ -14,7 +14,12 @@ const router = useRouter()
<section class="home-welcome" aria-labelledby="home-title">
<header class="home-welcome__header">
<h1 id="home-title">{{ t('home.ledger.welcomeTitle') }}</h1>
<AppButton type="button" @click="router.push(importLocation())">
<AppButton
class="home-welcome__action"
type="button"
size="cta"
@click="router.push(importLocation())"
>
<KeyRound :size="16" aria-hidden="true" />
{{ t('home.ledger.importKeys') }}
</AppButton>
@@ -37,7 +42,7 @@ const router = useRouter()
</li>
</ol>
<p class="home-welcome__note">
<ShieldCheck :size="15" aria-hidden="true" />
<CircleCheck :size="15" aria-hidden="true" />
{{ t('home.ledger.welcomeSecurityNote') }}
</p>
</section>
@@ -53,7 +58,7 @@ const router = useRouter()
grid-template-columns: minmax(0, 1fr) auto;
min-height: 72px;
align-items: center;
gap: var(--space-8);
gap: 36px;
border-bottom: 1px solid var(--color-border-control);
padding-bottom: var(--space-5);
}
@@ -63,15 +68,22 @@ const router = useRouter()
font-family: var(--font-serif);
font-size: var(--title-lede);
font-weight: 500;
letter-spacing: -0.015em;
line-height: var(--line-compact);
}
.home-welcome__action {
white-space: nowrap;
}
.home-welcome__guide {
padding-top: var(--space-7);
padding-top: 28px;
}
.home-welcome__description {
max-width: 45rem;
margin: 0 0 var(--space-6);
margin: 0 0 24px;
color: var(--color-text-muted);
font-size: 14px;
line-height: var(--line-relaxed);
}
.home-welcome__guide-header {
@@ -79,7 +91,7 @@ const router = useRouter()
align-items: baseline;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-3);
margin-bottom: 10px;
}
.home-welcome__guide-header h2 {
margin: 0;
@@ -90,7 +102,7 @@ const router = useRouter()
.home-welcome__guide-header span {
color: var(--color-text-faint);
font-family: var(--font-mono);
font-size: var(--text-meta);
font-size: 11px;
}
.home-welcome__steps {
display: grid;
@@ -102,15 +114,15 @@ const router = useRouter()
.home-welcome__steps li {
display: grid;
grid-template-columns: 46px minmax(0, 1fr) auto;
gap: var(--space-3);
gap: 14px;
align-items: center;
border-bottom: 1px solid var(--color-border-subtle);
padding: var(--space-4) var(--space-1);
padding: 17px 4px;
}
.home-welcome__step-number {
color: var(--color-text-faint);
font-family: var(--font-mono);
font-size: var(--text-meta);
font-size: 11px;
}
.home-welcome__steps h3,
.home-welcome__steps p {
@@ -121,10 +133,10 @@ const router = useRouter()
font-weight: 600;
}
.home-welcome__steps p {
margin-top: var(--space-1);
color: var(--color-text-muted);
font-size: var(--text-sm);
line-height: var(--line-relaxed);
margin-top: 3px;
color: var(--color-text-faint);
font-size: var(--text-meta);
line-height: 1.6;
}
.home-welcome__steps svg {
color: var(--color-text-faint);
@@ -133,14 +145,14 @@ const router = useRouter()
display: flex;
align-items: flex-start;
gap: var(--space-2);
margin: var(--space-5) 0 0;
margin: 18px 0 0;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-control);
border-radius: 8px;
background: var(--color-surface-sunken);
color: var(--color-text-muted);
padding: var(--space-3);
font-size: var(--text-meta);
line-height: var(--line-relaxed);
padding: 11px 13px;
font-size: var(--text-sm);
line-height: 1.6;
}
.home-welcome__note svg {
flex: none;
@@ -151,12 +163,17 @@ const router = useRouter()
.home-welcome__header {
grid-template-columns: 1fr;
align-items: start;
gap: var(--space-4);
}
.home-welcome__action {
justify-self: start;
}
}
@media (max-width: 560px) {
.home-welcome__guide-header {
align-items: start;
flex-direction: column;
gap: var(--space-1);
gap: 3px;
}
.home-welcome__steps li {
grid-template-columns: 34px minmax(0, 1fr);
+3 -1
View File
@@ -225,7 +225,9 @@ export function useHomeStatisticsPresenter(
state,
selectedRange: computed(() => {
const current = state.value
return current.kind === 'initial' ? current.requestedRange : current.selectedRange
if (current.kind === 'initial') return current.requestedRange
if (current.kind === 'switching') return current.targetRange
return current.selectedRange
}),
targetRange: computed(() =>
state.value.kind === 'switching' ? state.value.targetRange : null,
@@ -31,10 +31,10 @@ const emit = defineEmits<{
const { t } = useI18n()
const open = ref(false)
const identity = useId()
const localeOptions: Array<{ value: AppLocale; labelKey: string }> = [
{ value: 'zh-CN', labelKey: 'shell.localeZh' },
{ value: 'en-US', labelKey: 'shell.localeEn' },
{ value: 'ja-JP', labelKey: 'shell.localeJa' },
const localeOptions: Array<{ value: AppLocale; labelKey: string; compactLabelKey: string }> = [
{ value: 'zh-CN', labelKey: 'shell.localeZh', compactLabelKey: 'shell.localeZhShort' },
{ value: 'en-US', labelKey: 'shell.localeEn', compactLabelKey: 'shell.localeEnShort' },
{ value: 'ja-JP', labelKey: 'shell.localeJa', compactLabelKey: 'shell.localeJaShort' },
]
const themeOptions: Array<{
value: AppTheme
@@ -62,40 +62,50 @@ function updateTheme(event: Event): void {
v-if="compact"
v-model:open="open"
class="preferences-control preferences-control--compact"
content-class="app-popover__content--preferences"
>
<template #trigger>
<IconButton :label="t('shell.preferences')" :pressed="open">
<Menu :size="16" aria-hidden="true" />
<IconButton
:label="t('shell.preferences')"
:pressed="open"
variant="surface"
size="compact"
>
<Menu :size="15" aria-hidden="true" />
</IconButton>
</template>
<div class="preferences-panel">
<fieldset>
<legend>{{ t('shell.theme') }}</legend>
<label v-for="option in themeOptions" :key="option.value">
<input
type="radio"
:name="`${identity}-theme`"
:value="option.value"
:checked="props.theme === option.value"
@change="updateTheme"
/>
<component :is="option.icon" :size="14" aria-hidden="true" />
<span class="sr-only">{{ t(option.labelKey) }}</span>
</label>
</fieldset>
<fieldset>
<legend>{{ t('shell.language') }}</legend>
<label v-for="option in localeOptions" :key="option.value">
<input
type="radio"
:name="`${identity}-locale`"
:value="option.value"
:checked="props.locale === option.value"
@change="updateLocale"
/>
<span>{{ t(option.labelKey) }}</span>
</label>
</fieldset>
<div class="preferences-panel__group">
<span class="preferences-panel__label">{{ t('shell.theme') }}</span>
<div class="preferences-panel__segments">
<label v-for="option in themeOptions" :key="option.value">
<input
type="radio"
:name="`${identity}-theme`"
:value="option.value"
:checked="props.theme === option.value"
@change="updateTheme"
/>
<component :is="option.icon" :size="14" aria-hidden="true" />
<span class="sr-only">{{ t(option.labelKey) }}</span>
</label>
</div>
</div>
<div class="preferences-panel__group">
<span class="preferences-panel__label">{{ t('shell.language') }}</span>
<div class="preferences-panel__segments">
<label v-for="option in localeOptions" :key="option.value">
<input
type="radio"
:name="`${identity}-locale`"
:value="option.value"
:checked="props.locale === option.value"
@change="updateLocale"
/>
<span>{{ t(option.compactLabelKey) }}</span>
</label>
</div>
</div>
<div v-if="showSignOut" class="preferences-panel__divider"></div>
<button
v-if="showSignOut"
@@ -110,33 +120,41 @@ function updateTheme(event: Event): void {
</AppPopover>
<div v-else class="preferences-control preferences-panel">
<fieldset>
<legend><Languages :size="15" aria-hidden="true" />{{ t('shell.language') }}</legend>
<label v-for="option in localeOptions" :key="option.value">
<input
type="radio"
:name="`${identity}-locale`"
:value="option.value"
:checked="props.locale === option.value"
@change="updateLocale"
/>
<span>{{ t(option.labelKey) }}</span>
</label>
</fieldset>
<fieldset>
<legend><Monitor :size="15" aria-hidden="true" />{{ t('shell.theme') }}</legend>
<label v-for="option in themeOptions" :key="option.value">
<input
type="radio"
:name="`${identity}-theme`"
:value="option.value"
:checked="props.theme === option.value"
@change="updateTheme"
/>
<component :is="option.icon" :size="15" aria-hidden="true" />
<span>{{ t(option.labelKey) }}</span>
</label>
</fieldset>
<div class="preferences-panel__group">
<span class="preferences-panel__label">
<Languages :size="15" aria-hidden="true" />{{ t('shell.language') }}
</span>
<div class="preferences-panel__segments">
<label v-for="option in localeOptions" :key="option.value">
<input
type="radio"
:name="`${identity}-locale`"
:value="option.value"
:checked="props.locale === option.value"
@change="updateLocale"
/>
<span>{{ t(option.labelKey) }}</span>
</label>
</div>
</div>
<div class="preferences-panel__group">
<span class="preferences-panel__label">
<Monitor :size="15" aria-hidden="true" />{{ t('shell.theme') }}
</span>
<div class="preferences-panel__segments">
<label v-for="option in themeOptions" :key="option.value">
<input
type="radio"
:name="`${identity}-theme`"
:value="option.value"
:checked="props.theme === option.value"
@change="updateTheme"
/>
<component :is="option.icon" :size="15" aria-hidden="true" />
<span>{{ t(option.labelKey) }}</span>
</label>
</div>
</div>
<div v-if="showSignOut" class="preferences-panel__divider"></div>
<button
v-if="showSignOut"
@@ -153,38 +171,40 @@ function updateTheme(event: Event): void {
<style>
.preferences-panel {
display: grid;
min-width: 216px;
width: 100%;
gap: 10px;
}
.preferences-panel fieldset {
.preferences-panel__group {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
gap: 6px;
min-width: 0;
margin: 0;
overflow: hidden;
border: 1px solid var(--color-border-control);
border-radius: var(--radius-control);
padding: 0;
}
.preferences-panel legend {
grid-column: 1 / -1;
width: 100%;
margin-bottom: 6px;
.preferences-panel__label {
display: flex;
align-items: center;
gap: 4px;
color: var(--color-text-faint);
padding: 0 2px;
font-size: var(--text-label-xs);
font-weight: 400;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.preferences-panel__segments {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
overflow: hidden;
border: 1px solid var(--color-border-control);
border-radius: var(--radius-control);
}
.preferences-panel label {
position: relative;
display: flex;
min-width: 0;
min-height: var(--control-compact);
align-items: center;
justify-content: center;
gap: 4px;
@@ -226,13 +246,13 @@ function updateTheme(event: Event): void {
.preferences-panel__divider {
height: 1px;
margin: 0 -10px;
background: var(--color-border-subtle);
}
.preferences-panel__action {
display: flex;
width: 100%;
min-height: var(--control-sm);
align-items: center;
gap: var(--space-2);
border: 0;
@@ -241,7 +261,7 @@ function updateTheme(event: Event): void {
color: var(--color-text);
padding: 7px 6px;
font: inherit;
font-size: var(--text-meta);
font-size: 12.5px;
cursor: pointer;
}
@@ -249,7 +269,7 @@ function updateTheme(event: Event): void {
background: var(--color-surface-sunken);
}
.mobile-preferences .preferences-panel fieldset {
.mobile-preferences .preferences-panel__segments {
grid-template-columns: 1fr;
}
@@ -262,4 +282,12 @@ function updateTheme(event: Event): void {
.mobile-preferences .preferences-panel label:first-of-type {
border-top: 0;
}
.app-popover__content.app-popover__content--preferences {
width: auto;
min-width: 216px;
border-color: var(--color-border-control);
border-radius: 10px;
padding: 10px;
}
</style>
+53 -24
View File
@@ -22,24 +22,24 @@ export default {
},
auth: {
eyebrow: 'GPT-Load 2.0',
headline: 'Keep your model gateway clear and in control.',
lead: 'A self-hosted AI API key aggregator and native-protocol gateway. Sign in to manage upstream keys, routing, and usage.',
headline: 'Keep your model gateway clear and in control',
lead: 'A self-hosted AI API key aggregator and native-protocol gateway. Sign in to manage upstream keys, routing, and usage',
capabilities: {
1: {
title: 'Manage upstream keys centrally',
description: 'Maintain Groups, models, and availability in one place.',
description: 'Maintain Groups, models, and availability in one place',
},
2: {
title: 'Keep provider-native protocols',
description: 'Use clear OpenAI, Anthropic, and Gemini paths.',
description: 'Use clear OpenAI, Anthropic, and Gemini paths',
},
3: {
title: 'Review usage and estimated cost',
description: 'See requests, tokens, and cost estimates together.',
description: 'See requests, tokens, and cost estimates together',
},
},
loginTitle: 'Sign in to the admin console',
loginDescription: 'Enter the management key for this GPT-Load instance.',
loginDescription: 'Enter the management key for this GPT-Load instance',
keyLabel: 'AUTH_KEY',
keyPlaceholder: 'Enter the management key',
reveal: 'Show management key',
@@ -53,7 +53,24 @@ export default {
network: 'Unable to reach the management API. Check the service and try again.',
invalidResponse: 'The management API returned an unrecognized response.',
checking: 'Verifying the current session…',
sessionNote: 'The management key is stored only in sessionStorage for this browser session.',
sessionNotePrefix: 'The management key is stored only in ',
sessionNoteSuffix: ' for this browser session',
help: {
title: 'Where is AUTH_KEY?',
environmentTitle: 'Environment variable first',
environmentDescription:
'If the instance was started with {key}, use the protected value from your deployment system',
fileTitle: 'Managed key file',
fileDescription:
'Without the environment variable, the instance reads or creates {path}; containers use {containerPath} by default',
dockerTitle: 'Docker instance',
dockerDescription:
'Run {command} in a controlled terminal before reading the managed file. Never paste its output into logs, tickets, or chats',
},
recoveryPrefix:
'If the service returns an invalid response, confirm the instance address and health, then',
recoveryAction: 'verify the session again',
recoverySuffix: 'or use another management key',
instance: 'Current instance · {origin}',
},
shell: {
@@ -73,6 +90,9 @@ export default {
localeZh: '简体中文',
localeEn: 'English',
localeJa: '日本語',
localeZhShort: '中文',
localeEnShort: 'EN',
localeJaShort: '日本語',
themeSystem: 'Use system theme',
themeLight: 'Light',
themeDark: 'Dark',
@@ -120,8 +140,8 @@ export default {
statisticsInitialError: 'Statistics are unavailable; zero-value placeholders are shown.',
statisticsLoading: 'Loading statistics…',
trendTitle: 'Request trend · last {range}',
trendDescription: 'Request and failure counts grouped into time buckets.',
trendEmpty: 'There is no request trend to chart for this range.',
trendDescription: 'Request and failure counts grouped into time buckets',
trendEmpty: 'There is no request trend to chart for this range',
requestsLabel: 'Requests',
failuresLabel: 'Failures',
factGroups: 'Groups',
@@ -137,26 +157,26 @@ export default {
tokens: '{count} tokens',
tokensWithUnpriced: '{tokens} · {unpriced} unpriced',
welcomeTitle: 'Welcome to GPT-Load',
importKeys: 'Import keys',
importKeys: 'Import upstream keys',
welcomeDescription:
'Import your first upstream key to create Groups and start forwarding native API requests.',
'Import your first upstream key to create Groups and start forwarding native API requests',
welcomeGuideTitle: 'Complete your first setup in three steps',
welcomeEstimatedTime: 'Estimated 1–2 minutes',
welcomeStep1Title: 'Choose an upstream service',
welcomeStep1Description:
'OpenAI, Anthropic, Gemini, and compatible protocol services are supported.',
'OpenAI, Anthropic, Gemini, and compatible protocol services are supported',
welcomeStep2Title: 'Configure Groups and models',
welcomeStep2Description:
'Set the upstream address, protocols, and available models for related keys.',
'Set the upstream address, protocols, and available models for related keys',
welcomeStep3Title: 'Import one or more keys',
welcomeStep3Description:
'Paste upstream keys and confirm the import to begin managing availability immediately.',
'Paste upstream keys and confirm the import to begin managing availability immediately',
welcomeSecurityNote:
'Upstream keys are stored encrypted; empty charts stay hidden until import is complete.',
'Upstream keys are stored encrypted; empty charts stay hidden until import is complete',
ranking: {
title: 'Consumption ranking · {range}',
title: 'Consumption ranking · Last {range}',
caption: 'Top consumption ranking',
scrollHint: 'Scroll horizontally to inspect every ranking column.',
scrollHint: 'Scroll horizontally to inspect every ranking column',
tabs: {
label: 'Ranking dimension',
models: 'Models',
@@ -174,10 +194,11 @@ export default {
deletedGroup: 'Deleted Group #{id}',
deletedAccessKey: 'Deleted AccessKey #{id}',
unknownModel: 'Unknown model',
empty: 'No ranking data is available for this range.',
empty: 'No ranking data is available for this range',
},
connection: {
title: 'Connect to the gateway',
configuration: 'Configuration',
accessKey: 'Access key',
copyAccessKey: 'Copy access key',
copyConfiguration: 'Copy configuration',
@@ -187,18 +208,18 @@ export default {
keyCopyFailed: 'Unable to copy the access key.',
configurationCopied: 'Configuration copied.',
configurationCopyFailed: 'Unable to copy the configuration.',
noAccessKey: 'Create an AccessKey before connecting a client.',
noAccessKey: 'Create an AccessKey before connecting a client',
createAccessKey: 'Create AccessKey',
openNextChat: 'Open NextChat',
openNextChat: 'Import in one click',
nextChatOpened: 'Opened NextChat',
nextChatFailed: 'Unable to open NextChat.',
popupBlocked: 'The browser blocked the NextChat window.',
nextChatConfirmTitle: 'Open NextChat',
nextChatConfirmDescription:
'Your gateway address and AccessKey will be sent to NextChat in this browser.',
'Your gateway address and AccessKey will be sent to NextChat in this browser',
disableFastLink:
'Known limitation: NextChat silently ignores this setting when disableFastLink is enabled.',
protocolUnavailable: '{client} requires {protocol} for this AccessKey.',
'Known limitation: NextChat silently ignores this setting when disableFastLink is enabled',
protocolUnavailable: '{client} requires {protocol} for this AccessKey',
requiredProtocols: {
'openai-completions': 'OpenAI Completions',
anthropic: 'Anthropic',
@@ -211,13 +232,21 @@ export default {
curl: 'curl',
more: 'More',
},
moreDescription: 'More client presets are coming soon. Use the curl example for now.',
clientKinds: {
desktopWeb: 'Desktop / Web',
desktop: 'Desktop',
commandLine: 'Command line',
general: 'General',
},
moreDescription: 'More client presets are coming soon. Use the curl example for now',
},
},
range: {
label: 'Home statistics range',
last24Hours: '24h',
last30Days: '30d',
display24Hours: '24 hours',
display30Days: '30 days',
},
},
} as const
+53 -24
View File
@@ -22,24 +22,24 @@ export default {
},
auth: {
eyebrow: 'GPT-Load 2.0',
headline: 'モデルゲートウェイを明快に、思いどおりに管理。',
lead: 'セルフホスト型の AI API キー集約・ネイティブプロトコルゲートウェイです。ログインするとアップストリームキー、ルーティング、使用量を管理できます。',
headline: 'モデルゲートウェイを明快に、思いどおりに管理',
lead: 'セルフホスト型の AI API キー集約・ネイティブプロトコルゲートウェイです。ログインするとアップストリームキー、ルーティング、使用量を管理できます',
capabilities: {
1: {
title: 'アップストリームキーを一元管理',
description: 'Group、モデル、利用可能状況をまとめて管理します。',
description: 'Group、モデル、利用可能状況をまとめて管理します',
},
2: {
title: 'ネイティブプロトコルを維持',
description: 'OpenAI、Anthropic、Gemini のパスを明確に保ちます。',
description: 'OpenAI、Anthropic、Gemini のパスを明確に保ちます',
},
3: {
title: '使用量と推定コストを確認',
description: 'リクエスト、トークン、費用をまとめて表示します。',
description: 'リクエスト、トークン、費用をまとめて表示します',
},
},
loginTitle: '管理コンソールにログイン',
loginDescription: 'この GPT-Load インスタンスの管理キーを入力してください。',
loginDescription: 'この GPT-Load インスタンスの管理キーを入力してください',
keyLabel: 'AUTH_KEY',
keyPlaceholder: '管理キーを入力',
reveal: '管理キーを表示',
@@ -53,7 +53,24 @@ export default {
network: '管理 API に接続できません。サービスを確認して再試行してください。',
invalidResponse: '管理 API から認識できない応答が返されました。',
checking: '現在のセッションを確認しています…',
sessionNote: '管理キーは、このブラウザーセッションの sessionStorage にのみ保存されます。',
sessionNotePrefix: '管理キーは、このブラウザーセッションの ',
sessionNoteSuffix: ' にのみ保存されます',
help: {
title: 'AUTH_KEY はどこにありますか?',
environmentTitle: '環境変数を優先',
environmentDescription:
'インスタンス起動時に {key} を設定した場合は、デプロイ環境で保護された値を使用してください',
fileTitle: '管理キーのファイル',
fileDescription:
'環境変数がない場合、インスタンスは {path} を読み込むか生成します。コンテナの既定値は {containerPath} です',
dockerTitle: 'Docker インスタンス',
dockerDescription:
'管理された端末で {command} を実行してからキーのファイルを確認してください。出力をログ、チケット、チャットに貼り付けないでください',
},
recoveryPrefix:
'サービスが無効な応答を返す場合は、インスタンスのアドレスと状態を確認してから',
recoveryAction: 'セッションを再確認',
recoverySuffix: 'するか、別の管理キーを使用してください',
instance: '現在のインスタンス · {origin}',
},
shell: {
@@ -73,6 +90,9 @@ export default {
localeZh: '简体中文',
localeEn: 'English',
localeJa: '日本語',
localeZhShort: '中文',
localeEnShort: 'EN',
localeJaShort: '日本語',
themeSystem: 'システム設定を使用',
themeLight: 'ライト',
themeDark: 'ダーク',
@@ -120,8 +140,8 @@ export default {
statisticsInitialError: '統計を取得できないため、ゼロ値のプレースホルダーを表示しています。',
statisticsLoading: '統計を読み込み中…',
trendTitle: 'リクエスト量の推移 · 過去 {range}',
trendDescription: '時間バケットごとのリクエスト数と失敗数。',
trendEmpty: 'この期間に描画できるリクエスト推移はありません。',
trendDescription: '時間バケットごとのリクエスト数と失敗数',
trendEmpty: 'この期間に描画できるリクエスト推移はありません',
requestsLabel: 'リクエスト',
failuresLabel: '失敗',
factGroups: '個の Group',
@@ -137,26 +157,26 @@ export default {
tokens: '{count} tokens',
tokensWithUnpriced: '{tokens} · 価格未設定 {unpriced} 件',
welcomeTitle: 'GPT-Load へようこそ',
importKeys: 'キーをインポート',
importKeys: 'アップストリームキーをインポート',
welcomeDescription:
'最初のアップストリームキーをインポートすると、Group を作成してネイティブ API リクエストを転送できます。',
'最初のアップストリームキーをインポートすると、Group を作成してネイティブ API リクエストを転送できます',
welcomeGuideTitle: '初回接続を 3 ステップで完了',
welcomeEstimatedTime: '所要 1~2 分',
welcomeStep1Title: 'アップストリームサービスを選択',
welcomeStep1Description:
'OpenAI、Anthropic、Gemini、および互換プロトコルのサービスに対応します。',
'OpenAI、Anthropic、Gemini、および互換プロトコルのサービスに対応します',
welcomeStep2Title: 'Group とモデルを設定',
welcomeStep2Description:
'用途が同じキーに対して、アップストリームアドレス、プロトコル、利用可能なモデルを設定します。',
'用途が同じキーに対して、アップストリームアドレス、プロトコル、利用可能なモデルを設定します',
welcomeStep3Title: 'キーを 1 つ以上インポート',
welcomeStep3Description:
'アップストリームキーを貼り付けてインポートを確認すると、可用性管理がすぐに始まります。',
'アップストリームキーを貼り付けてインポートを確認すると、可用性管理がすぐに始まります',
welcomeSecurityNote:
'アップストリームキーは暗号化して保存され、インポート完了前に意味のない空グラフは表示しません。',
'アップストリームキーは暗号化して保存され、インポート完了前に意味のない空グラフは表示しません',
ranking: {
title: '消費ランキング · {range}',
title: '消費ランキング · 直近 {range}',
caption: '消費ランキング上位',
scrollHint: '横にスクロールするとすべての順位列を確認できます。',
scrollHint: '横にスクロールするとすべての順位列を確認できます',
tabs: {
label: 'ランキングの軸',
models: 'モデル',
@@ -174,10 +194,11 @@ export default {
deletedGroup: '削除済み Group #{id}',
deletedAccessKey: '削除済みアクセスキー #{id}',
unknownModel: '不明なモデル',
empty: 'この期間に表示できるランキングデータはありません。',
empty: 'この期間に表示できるランキングデータはありません',
},
connection: {
title: 'ゲートウェイへ接続',
configuration: '設定',
accessKey: 'アクセスキー',
copyAccessKey: 'アクセスキーをコピー',
copyConfiguration: '設定をコピー',
@@ -187,19 +208,19 @@ export default {
keyCopyFailed: 'アクセスキーをコピーできません。',
configurationCopied: '設定をコピーしました。',
configurationCopyFailed: '設定をコピーできません。',
noAccessKey: 'クライアント接続の前にアクセスキーを作成してください。',
noAccessKey: 'クライアント接続の前にアクセスキーを作成してください',
createAccessKey: 'アクセスキーを作成',
openNextChat: 'NextChat を開く',
openNextChat: 'ワンクリックでインポート',
nextChatOpened: 'NextChat を開きました',
nextChatFailed: 'NextChat を開けません。',
popupBlocked: 'ブラウザーが NextChat のウィンドウをブロックしました。',
nextChatConfirmTitle: 'NextChat を開く',
nextChatConfirmDescription:
'このブラウザーからゲートウェイアドレスとアクセスキーが NextChat に送信されます。',
'このブラウザーからゲートウェイアドレスとアクセスキーが NextChat に送信されます',
disableFastLink:
'既知の制限:disableFastLink が有効な場合、NextChat はこの設定を黙って無視します。',
'既知の制限:disableFastLink が有効な場合、NextChat はこの設定を黙って無視します',
protocolUnavailable:
'このアクセスキーでは {client} に必要な {protocol} が有効ではありません。',
'このアクセスキーでは {client} に必要な {protocol} が有効ではありません',
requiredProtocols: {
'openai-completions': 'OpenAI Completions',
anthropic: 'Anthropic',
@@ -212,14 +233,22 @@ export default {
curl: 'curl',
more: 'その他',
},
clientKinds: {
desktopWeb: 'デスクトップ / Web',
desktop: 'デスクトップ',
commandLine: 'コマンドライン',
general: '汎用',
},
moreDescription:
'その他のクライアントプリセットは順次追加予定です。現在は curl の例を利用できます。',
'その他のクライアントプリセットは順次追加予定です。現在は curl の例を利用できます',
},
},
range: {
label: 'ホーム統計の期間',
last24Hours: '24h',
last30Days: '30d',
display24Hours: '24時間',
display30Days: '30日',
},
},
} as const
+51 -24
View File
@@ -22,24 +22,24 @@ export default {
},
auth: {
eyebrow: 'GPT-Load 2.0',
headline: '管理你的模型网关,保持清晰、可控。',
lead: '一个自托管的 AI API 密钥聚合与原生协议网关。登录后即可管理上游密钥、路由和用量。',
headline: '管理你的模型网关,保持清晰、可控',
lead: '一个自托管的 AI API 密钥聚合与原生协议网关。登录后即可管理上游密钥、路由和用量',
capabilities: {
1: {
title: '集中管理上游密钥',
description: '统一维护分组、模型与可用状态。',
description: '统一维护分组、模型与可用状态',
},
2: {
title: '保持原生协议调用',
description: 'OpenAI、Anthropic 与 Gemini 路径保持清晰。',
description: 'OpenAI、Anthropic 与 Gemini 路径保持清晰',
},
3: {
title: '查看用量与估算成本',
description: '请求、Tokens 和费用集中呈现。',
description: '请求、Tokens 和费用集中呈现',
},
},
loginTitle: '登录管理控制台',
loginDescription: '输入当前 GPT-Load 实例的管理密钥。',
loginDescription: '输入当前 GPT-Load 实例的管理密钥',
keyLabel: 'AUTH_KEY',
keyPlaceholder: '请输入管理密钥',
reveal: '显示管理密钥',
@@ -53,7 +53,21 @@ export default {
network: '无法连接到管理 API,请检查服务后重试。',
invalidResponse: '管理 API 返回了无法识别的响应。',
checking: '正在验证当前会话…',
sessionNote: '管理密钥仅保存在当前浏览器会话的 sessionStorage 中。',
sessionNotePrefix: '管理密钥仅保存在当前浏览器会话的 ',
sessionNoteSuffix: ' 中',
help: {
title: 'AUTH_KEY 在哪里?',
environmentTitle: '环境变量优先',
environmentDescription: '如果启动实例时设置了 {key},请使用部署系统中受保护的值',
fileTitle: '受管密钥文件',
fileDescription: '未设置环境变量时,实例读取或生成 {path};容器默认位于 {containerPath}',
dockerTitle: 'Docker 实例',
dockerDescription:
'可在受控终端执行 {command} 后读取受管文件。不要把输出粘贴到日志、工单或聊天中',
},
recoveryPrefix: '如果服务返回无效响应,请先确认实例地址与健康状态,再',
recoveryAction: '重新验证会话',
recoverySuffix: '或更换管理密钥',
instance: '当前实例 · {origin}',
},
shell: {
@@ -73,6 +87,9 @@ export default {
localeZh: '简体中文',
localeEn: 'English',
localeJa: '日本語',
localeZhShort: '中文',
localeEnShort: 'EN',
localeJaShort: '日本語',
themeSystem: '跟随系统',
themeLight: '浅色',
themeDark: '深色',
@@ -120,8 +137,8 @@ export default {
statisticsInitialError: '统计暂时不可用,当前显示零值占位。',
statisticsLoading: '正在加载统计数据…',
trendTitle: '请求量趋势 · 近 {range}',
trendDescription: '按时间桶展示请求总数和失败请求数。',
trendEmpty: '当前范围没有可绘制的请求趋势。',
trendDescription: '按时间桶展示请求总数和失败请求数',
trendEmpty: '当前范围没有可绘制的请求趋势',
requestsLabel: '请求',
failuresLabel: '失败',
factGroups: '个分组',
@@ -137,21 +154,22 @@ export default {
tokens: '{count} tokens',
tokensWithUnpriced: '{tokens} · {unpriced} 条未定价',
welcomeTitle: '欢迎使用 GPT-Load',
importKeys: '导入密钥',
welcomeDescription: '导入第一个上游密钥后,即可建立分组并通过原生 API 开始转发请求。',
importKeys: '导入上游密钥',
welcomeDescription:
'导入第一批上游密钥,建立你的模型分组。完成后,首页会自动展示请求、Tokens 与估算成本',
welcomeGuideTitle: '三步完成首次接入',
welcomeEstimatedTime: '预计 1–2 分钟',
welcomeStep1Title: '选择上游服务',
welcomeStep1Description: '支持 OpenAI、Anthropic、Gemini 及兼容协议服务。',
welcomeStep1Description: '支持 OpenAI、Anthropic、Gemini 及兼容协议服务',
welcomeStep2Title: '配置分组与模型',
welcomeStep2Description: '为一组相同用途的密钥设置上游地址、协议和可用模型。',
welcomeStep2Description: '为一组相同用途的密钥设置上游地址、协议和可用模型',
welcomeStep3Title: '导入一个或多个密钥',
welcomeStep3Description: '粘贴上游密钥并确认导入,系统会立即开始管理可用性。',
welcomeSecurityNote: '上游密钥将加密保存;导入完成前,首页不会显示无意义的空图表。',
welcomeStep3Description: '粘贴上游密钥并确认导入,系统会立即开始管理可用性',
welcomeSecurityNote: '上游密钥将加密保存;导入完成前,首页不会显示无意义的空图表',
ranking: {
title: '消耗排行 · {range}',
title: '消耗排行 · 近 {range}',
caption: '消耗排行前五',
scrollHint: '横向滚动可查看全部排行列。',
scrollHint: '横向滚动可查看全部排行列',
tabs: {
label: '排行维度',
models: '模型',
@@ -169,10 +187,11 @@ export default {
deletedGroup: '已删除分组 #{id}',
deletedAccessKey: '已删除访问密钥 #{id}',
unknownModel: '未知模型',
empty: '当前范围没有可展示的排行数据。',
empty: '当前范围没有可展示的排行数据',
},
connection: {
title: '连接到网关',
configuration: '配置',
accessKey: '访问密钥',
copyAccessKey: '复制访问密钥',
copyConfiguration: '复制配置',
@@ -182,16 +201,16 @@ export default {
keyCopyFailed: '无法复制访问密钥。',
configurationCopied: '已复制配置。',
configurationCopyFailed: '无法复制配置。',
noAccessKey: '请先创建访问密钥,再连接客户端。',
noAccessKey: '请先创建访问密钥,再连接客户端',
createAccessKey: '创建访问密钥',
openNextChat: '打开 NextChat',
openNextChat: '一键导入',
nextChatOpened: '已打开 NextChat',
nextChatFailed: '无法打开 NextChat。',
popupBlocked: '浏览器拦截了 NextChat 窗口。',
nextChatConfirmTitle: '打开 NextChat',
nextChatConfirmDescription: '当前浏览器会将网关地址和访问密钥发送给 NextChat。',
disableFastLink: '已知限制:启用 disableFastLink 时,NextChat 会静默忽略此设置。',
protocolUnavailable: '此访问密钥未开通 {client} 所需的 {protocol}。',
nextChatConfirmDescription: '当前浏览器会将网关地址和访问密钥发送给 NextChat',
disableFastLink: '已知限制:启用 disableFastLink 时,NextChat 会静默忽略此设置',
protocolUnavailable: '此访问密钥未开通 {client} 所需的 {protocol}',
requiredProtocols: {
'openai-completions': 'OpenAI Completions',
anthropic: 'Anthropic',
@@ -204,13 +223,21 @@ export default {
curl: 'curl',
more: '更多',
},
moreDescription: '更多客户端预设正在陆续增加;目前可使用 curl 示例接入。',
clientKinds: {
desktopWeb: '桌面 / 网页',
desktop: '桌面',
commandLine: '命令行',
general: '通用',
},
moreDescription: '更多客户端预设正在陆续增加;目前可使用 curl 示例接入',
},
},
range: {
label: '首页统计范围',
last24Hours: '24h',
last30Days: '30d',
display24Hours: '24h',
display30Days: '30 天',
},
},
} as const
+54 -41
View File
@@ -12,6 +12,11 @@ export function formatLocalInstant(
): string {
const date = validDate(ms)
if (!date) return '—'
const timeZone = options.timeZone ?? currentTimeZone()
const hasCustomShape = Object.keys(options).some((key) => key !== 'timeZone')
if (!hasCustomShape) {
return formatZonedDateTime(date, timeZone, false)
}
try {
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
@@ -20,7 +25,7 @@ export function formatLocalInstant(
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZone: currentTimeZone(),
timeZone,
timeZoneName: 'short',
hourCycle: 'h23',
...options,
@@ -35,43 +40,25 @@ export function formatISOInstant(ms: number): string | undefined {
return validDate(ms)?.toISOString()
}
export function formatLocalTime(ms: number, locale: string): string {
return formatLocalInstant(ms, locale, {
year: undefined,
month: undefined,
day: undefined,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: undefined,
hourCycle: 'h23',
})
export function formatLocalTime(ms: number, _locale: string): string {
const date = validDate(ms)
if (!date) return '—'
return formatZonedDateTime(date, currentTimeZone(), true).slice(11)
}
export function formatLocalTimeRange(startMs: number, endMs: number, locale: string): string {
export function formatLocalTimeRange(startMs: number, endMs: number, _locale: string): string {
const start = validDate(startMs)
const end = validDate(endMs)
if (!start || !end || end.getTime() <= start.getTime()) return '—'
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: currentTimeZone(),
timeZoneName: 'short',
hourCycle: 'h23',
}
try {
const formatter = new Intl.DateTimeFormat(locale, options)
return formatter.formatRange(start, end)
} catch {
return `${formatLocalInstant(startMs, locale, options)} – ${formatLocalInstant(endMs, locale, options)}`
}
const timeZone = currentTimeZone()
return `${formatZonedDateTime(start, timeZone, false)} – ${formatZonedDateTime(
end,
timeZone,
false,
)}`
}
export function formatDuration(startedAtMs: number, nowMs: number, locale: string): string {
export function formatDuration(startedAtMs: number, nowMs: number, _locale: string): string {
if (!Number.isSafeInteger(startedAtMs) || !Number.isSafeInteger(nowMs)) {
return '—'
}
@@ -82,12 +69,12 @@ export function formatDuration(startedAtMs: number, nowMs: number, locale: strin
const minutes = elapsedMinutes % 60
if (days > 0) {
return [formatUnit(days, 'day', locale), formatUnit(hours, 'hour', locale)].join(' ')
return `${days}d ${String(hours).padStart(2, '0')}h`
}
if (hours > 0) {
return [formatUnit(hours, 'hour', locale), formatUnit(minutes, 'minute', locale)].join(' ')
return `${hours}h ${String(minutes).padStart(2, '0')}m`
}
return formatUnit(minutes, 'minute', locale)
return `${minutes}m`
}
export function formatInteger(value: number, locale: string): string {
@@ -151,13 +138,39 @@ function validDate(ms: number): Date | null {
return Number.isNaN(date.getTime()) ? null : date
}
function formatUnit(value: number, unit: 'day' | 'hour' | 'minute', locale: string): string {
return new Intl.NumberFormat(locale, {
style: 'unit',
unit,
unitDisplay: 'narrow',
maximumFractionDigits: 0,
}).format(value)
function formatZonedDateTime(date: Date, timeZone: string, includeSeconds: boolean): string {
try {
const parts = new Intl.DateTimeFormat('en-CA-u-nu-latn', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: includeSeconds ? '2-digit' : undefined,
timeZone,
hourCycle: 'h23',
}).formatToParts(date)
const values = new Map(parts.map((part) => [part.type, part.value]))
const year = values.get('year')
const month = values.get('month')
const day = values.get('day')
const hour = values.get('hour')
const minute = values.get('minute')
const second = values.get('second')
if (!year || !month || !day || !hour || !minute || (includeSeconds && !second)) {
return fallbackDateTime(date, includeSeconds)
}
return `${year}-${month}-${day} ${hour}:${minute}${includeSeconds ? `:${second}` : ''}`
} catch {
return fallbackDateTime(date, includeSeconds)
}
}
function fallbackDateTime(date: Date, includeSeconds: boolean): string {
return date
.toISOString()
.replace('T', ' ')
.slice(0, includeSeconds ? 19 : 16)
}
function roundNanoUSD(value: bigint, scale: number): bigint {
+2 -14
View File
@@ -19,8 +19,8 @@ body {
color: var(--color-text);
font-family: var(--font-sans);
font-size: var(--text-body);
line-height: var(--line-relaxed);
text-rendering: optimizeLegibility;
line-height: normal;
-webkit-font-smoothing: antialiased;
}
button,
@@ -177,18 +177,6 @@ h1 {
}
}
@media (max-width: 640px) {
.form-field input {
font-size: 16px;
}
input:not([type='checkbox']):not([type='radio']),
select,
textarea {
font-size: 1rem;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
+12
View File
@@ -79,16 +79,24 @@
--touch-target: 44px;
--section-gap: 24px;
--collection-row-height: 48px;
--surface-header-min-height: 55px;
--table-editorial-min-width: 560px;
--chart-aspect-ratio: 1000 / 158;
--chart-line-width: 2;
--chart-grid-dash: 3 4;
--shadow-sheet: 0 1px 2px rgba(28, 26, 20, 0.05), 0 12px 32px rgba(28, 26, 20, 0.06);
--shadow-card: 0 1px 2px rgba(28, 26, 20, 0.05);
--shadow-overlay: 0 2px 8px rgba(0, 0, 0, 0.08), 0 14px 30px rgba(0, 0, 0, 0.09);
--shadow-chart-tooltip:
0 2px 6px rgba(0, 0, 0, 0.07), 0 10px 24px rgba(0, 0, 0, 0.08);
--focus-ring: 0 0 0 3px color-mix(in srgb, var(--color-focus) 16%, transparent);
--duration-instant: 120ms;
--duration-fast: 140ms;
--duration-normal: 180ms;
--duration-data: 220ms;
--duration-skeleton: 1.4s;
--easing-standard: ease;
--easing-data: ease-out;
@@ -142,6 +150,8 @@
--shadow-sheet: 0 1px 2px rgba(0, 0, 0, 0.45), 0 12px 32px rgba(0, 0, 0, 0.3);
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.45);
--shadow-overlay: 0 2px 8px rgba(0, 0, 0, 0.5), 0 14px 30px rgba(0, 0, 0, 0.4);
--shadow-chart-tooltip:
0 2px 6px rgba(0, 0, 0, 0.45), 0 10px 24px rgba(0, 0, 0, 0.35);
}
@media (prefers-color-scheme: dark) {
@@ -178,6 +188,8 @@
--shadow-sheet: 0 1px 2px rgba(0, 0, 0, 0.45), 0 12px 32px rgba(0, 0, 0, 0.3);
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.45);
--shadow-overlay: 0 2px 8px rgba(0, 0, 0, 0.5), 0 14px 30px rgba(0, 0, 0, 0.4);
--shadow-chart-tooltip:
0 2px 6px rgba(0, 0, 0, 0.45), 0 10px 24px rgba(0, 0, 0, 0.35);
}
}