fix(web): 统一首页与登录页视觉规范
This commit is contained in:
31 files changed
+1153
-565
No files matched your search
+12
-11
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user