mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(ui): restore dashboard and mobile interactions
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -23,7 +23,8 @@ const MAX_PAGE_SIZE: u16 = 100;
|
||||
const MAX_FILTER_BYTES: usize = 256;
|
||||
const MAX_SEARCH_BYTES: usize = 512;
|
||||
const MAX_ACCOUNT_IDS: usize = 200;
|
||||
const DASHBOARD_ACCOUNT_LIMIT: u16 = 50;
|
||||
/// 概览卡只展示最近使用的四个账号;完整账号用量由账号管理页单独查询。
|
||||
const DASHBOARD_ACCOUNT_LIMIT: u16 = 4;
|
||||
const DIAGNOSTIC_LIMIT: i64 = 100;
|
||||
const ACCOUNT_USAGE_TIMELINE_HOURS: i64 = 24;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="flex min-h-17 shrink-0 items-start justify-between gap-4">
|
||||
<header class="flex min-h-17 shrink-0 flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h1 class="m-0 text-[34px] leading-[1.15] font-extrabold text-(--cp-text-primary)">
|
||||
{{ title }}
|
||||
@@ -20,7 +20,10 @@ defineProps<{
|
||||
</slot>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="mt-0.5 flex shrink-0 items-center gap-2">
|
||||
<div
|
||||
v-if="$slots.actions"
|
||||
class="mt-0.5 flex shrink-0 items-center gap-2 max-sm:w-full max-sm:justify-end"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { Menu } from '@lucide/vue'
|
||||
import {
|
||||
useDraggable,
|
||||
useElementSize,
|
||||
useScreenSafeArea,
|
||||
useStorage,
|
||||
useWindowSize,
|
||||
} from '@vueuse/core'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onScopeDispose,
|
||||
shallowRef,
|
||||
useTemplateRef,
|
||||
watch,
|
||||
} from 'vue'
|
||||
|
||||
interface StoredPosition {
|
||||
initialized: boolean
|
||||
side: 'left' | 'right'
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: []
|
||||
}>()
|
||||
|
||||
const edgeGap = 12
|
||||
const defaultButtonSize = 44
|
||||
const dragClickThreshold = 6
|
||||
const storageKey = 'codex-proxy:mobile-sidebar-toggle-position'
|
||||
|
||||
const toggleEl = useTemplateRef<HTMLElement>('toggleEl')
|
||||
const { width: viewportWidth, height: viewportHeight } = useWindowSize({
|
||||
initialWidth: 390,
|
||||
initialHeight: 760,
|
||||
type: 'visual',
|
||||
})
|
||||
const { width: toggleWidth, height: toggleHeight } = useElementSize(toggleEl, {
|
||||
width: defaultButtonSize,
|
||||
height: defaultButtonSize,
|
||||
})
|
||||
const safeArea = useScreenSafeArea()
|
||||
const storedPosition = useStorage<StoredPosition>(
|
||||
storageKey,
|
||||
{
|
||||
initialized: false,
|
||||
side: 'left',
|
||||
x: edgeGap,
|
||||
y: 136,
|
||||
},
|
||||
undefined,
|
||||
{ mergeDefaults: true, shallow: true },
|
||||
)
|
||||
const pointerStart = shallowRef({ x: edgeGap, y: 136 })
|
||||
const ignoreNextClick = shallowRef(false)
|
||||
const positionReady = shallowRef(false)
|
||||
let ignoreClickTimer: number | undefined
|
||||
|
||||
const bounds = computed(() => {
|
||||
const buttonWidth = positiveNumber(toggleWidth.value, defaultButtonSize)
|
||||
const buttonHeight = positiveNumber(toggleHeight.value, defaultButtonSize)
|
||||
const windowWidth = positiveNumber(viewportWidth.value, 390)
|
||||
const windowHeight = positiveNumber(viewportHeight.value, 760)
|
||||
const minX = parseCssPixel(safeArea.left.value) + edgeGap
|
||||
const maxX = Math.max(
|
||||
minX,
|
||||
windowWidth - parseCssPixel(safeArea.right.value) - edgeGap - buttonWidth,
|
||||
)
|
||||
const minY = parseCssPixel(safeArea.top.value) + edgeGap
|
||||
const maxY = Math.max(
|
||||
minY,
|
||||
windowHeight - parseCssPixel(safeArea.bottom.value) - edgeGap - buttonHeight,
|
||||
)
|
||||
|
||||
return { minX, maxX, minY, maxY }
|
||||
})
|
||||
|
||||
const { x, y, isDragging } = useDraggable(toggleEl, {
|
||||
initialValue: {
|
||||
x: storedPosition.value.x,
|
||||
y: storedPosition.value.y,
|
||||
},
|
||||
preventDefault: false,
|
||||
stopPropagation: false,
|
||||
onEnd(position) {
|
||||
applyPosition(snapPosition(position.x, position.y))
|
||||
},
|
||||
})
|
||||
|
||||
const toggleStyle = computed<CSSProperties>(() => ({
|
||||
transform: `translate3d(${x.value}px, ${y.value}px, 0)`,
|
||||
touchAction: 'none',
|
||||
}))
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
pointerStart.value = { x: event.clientX, y: event.clientY }
|
||||
const target = event.currentTarget
|
||||
if (target instanceof HTMLElement)
|
||||
target.setPointerCapture?.(event.pointerId)
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent) {
|
||||
const target = event.currentTarget
|
||||
if (target instanceof HTMLElement)
|
||||
target.releasePointerCapture?.(event.pointerId)
|
||||
|
||||
const moved = Math.hypot(
|
||||
event.clientX - pointerStart.value.x,
|
||||
event.clientY - pointerStart.value.y,
|
||||
)
|
||||
ignoreNextClick.value = true
|
||||
window.clearTimeout(ignoreClickTimer)
|
||||
ignoreClickTimer = window.setTimeout(() => {
|
||||
ignoreNextClick.value = false
|
||||
}, 320)
|
||||
|
||||
if (moved <= dragClickThreshold) {
|
||||
emit('open')
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (isDragging.value || ignoreNextClick.value) {
|
||||
ignoreNextClick.value = false
|
||||
return
|
||||
}
|
||||
|
||||
emit('open')
|
||||
}
|
||||
|
||||
function defaultPosition() {
|
||||
const targetY = Math.round(positiveNumber(viewportHeight.value, 760) * 0.36)
|
||||
return snapPosition(bounds.value.minX, targetY)
|
||||
}
|
||||
|
||||
function restorePosition() {
|
||||
if (!storedPosition.value.initialized) {
|
||||
applyPosition(defaultPosition())
|
||||
return
|
||||
}
|
||||
|
||||
const sideX = storedPosition.value.side === 'right' ? bounds.value.maxX : bounds.value.minX
|
||||
applyPosition({
|
||||
initialized: true,
|
||||
side: storedPosition.value.side,
|
||||
x: sideX,
|
||||
y: storedPosition.value.y,
|
||||
})
|
||||
}
|
||||
|
||||
function snapPosition(rawX: number, rawY: number) {
|
||||
const { minX, maxX, minY, maxY } = bounds.value
|
||||
const side = rawX <= (minX + maxX) / 2 ? 'left' : 'right'
|
||||
|
||||
return {
|
||||
initialized: true,
|
||||
side,
|
||||
x: side === 'left' ? minX : maxX,
|
||||
y: clamp(rawY, minY, maxY),
|
||||
} satisfies StoredPosition
|
||||
}
|
||||
|
||||
function applyPosition(position: StoredPosition) {
|
||||
const { minX, maxX, minY, maxY } = bounds.value
|
||||
const next = {
|
||||
initialized: true,
|
||||
side: position.side,
|
||||
x: clamp(position.x, minX, maxX),
|
||||
y: clamp(position.y, minY, maxY),
|
||||
} satisfies StoredPosition
|
||||
|
||||
x.value = next.x
|
||||
y.value = next.y
|
||||
storedPosition.value = next
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
function positiveNumber(value: number, fallback: number) {
|
||||
return Number.isFinite(value) && value > 0 ? value : fallback
|
||||
}
|
||||
|
||||
function parseCssPixel(value: string) {
|
||||
const parsed = Number.parseFloat(value)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
safeArea.update()
|
||||
restorePosition()
|
||||
positionReady.value = true
|
||||
})
|
||||
|
||||
watch(
|
||||
bounds,
|
||||
() => {
|
||||
if (!positionReady.value)
|
||||
return
|
||||
restorePosition()
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
onScopeDispose(() => {
|
||||
window.clearTimeout(ignoreClickTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="toggleEl"
|
||||
class="fixed top-0 left-0 z-40 min-[961px]:hidden"
|
||||
:class="positionReady ? 'opacity-100' : 'opacity-0'"
|
||||
:style="toggleStyle"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-11.5 items-center justify-center rounded-(--cp-icon-button-radius) border-0 bg-(--cp-bg-surface) text-(--cp-text-primary) shadow-[0_18px_34px_-18px_var(--cp-shadow-sticky)] outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 hover:bg-(--cp-default-bg-hover) hover:text-(--cp-normal) active:scale-95 focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-page)"
|
||||
:class="
|
||||
isDragging
|
||||
? 'scale-95 cursor-grabbing shadow-[0_14px_28px_-20px_var(--cp-shadow-sticky)]'
|
||||
: 'cursor-grab'
|
||||
"
|
||||
aria-label="打开侧边栏"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointerup="handlePointerUp"
|
||||
@click="handleClick"
|
||||
>
|
||||
<Menu class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,26 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Menu } from '@lucide/vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="sticky top-0 z-40 -mx-4 -mt-4 mb-4 flex h-15 items-center bg-(--cp-bg-page)/96 px-4 backdrop-blur-sm min-[961px]:hidden"
|
||||
>
|
||||
<BaseButton
|
||||
icon-only
|
||||
size="md"
|
||||
variant="default"
|
||||
label="打开侧边栏"
|
||||
class="shadow-[0_12px_28px_-20px_var(--cp-shadow-sticky)]"
|
||||
@click="emit('open')"
|
||||
>
|
||||
<Menu class="size-5" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,7 +9,7 @@ import { useUiStore } from '@/stores/modules/ui'
|
||||
|
||||
import AppAboutModal from './components/AppAboutModal.vue'
|
||||
import AppSidebar from './components/AppSidebar.vue'
|
||||
import MobileSidebarToolbar from './components/MobileSidebarToolbar.vue'
|
||||
import FloatingSidebarToggle from './components/FloatingSidebarToggle.vue'
|
||||
import SystemUpdateModal from './components/SystemUpdateModal.vue'
|
||||
|
||||
const uiStore = useUiStore()
|
||||
@@ -77,12 +77,12 @@ watch(
|
||||
@open-about="aboutOpen = true"
|
||||
@open-system-update="openSystemUpdate"
|
||||
/>
|
||||
<FloatingSidebarToggle v-if="!mobileSidebarOpen" @open="openMobileSidebar" />
|
||||
<main class="h-dvh min-w-0 flex-1 overflow-hidden">
|
||||
<BaseScrollbar
|
||||
ref="pageScrollbarRef"
|
||||
view-class="flex min-h-full min-w-0 flex-col p-4 min-[961px]:p-6"
|
||||
>
|
||||
<MobileSidebarToolbar v-if="!mobileSidebarOpen" @open="openMobileSidebar" />
|
||||
<RouterView v-slot="{ Component }">
|
||||
<component :is="Component" class="min-h-0 flex-1" />
|
||||
</RouterView>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { useAccountOnboarding } from '../composables/useAccountOnboarding'
|
||||
import type { AccountRow } from '../constants'
|
||||
import { Openai, Xai } from '@boxicons/vue'
|
||||
import { Copy, Grid2X2, KeyRound, Upload } from '@lucide/vue'
|
||||
import { Copy, KeyRound, LayoutGrid, Upload } from '@lucide/vue'
|
||||
|
||||
import { useClipboard, useFileDialog } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
@@ -207,7 +207,7 @@ async function copyText(value: string, successText: string) {
|
||||
:hide-footer="isChoosingProvider"
|
||||
>
|
||||
<template #icon>
|
||||
<Grid2X2 v-if="isBatch" class="text-(--cp-text-primary)" aria-hidden="true" :width="20" :height="20" />
|
||||
<LayoutGrid v-if="isBatch" class="text-(--cp-text-primary)" aria-hidden="true" :width="20" :height="20" />
|
||||
<Xai v-else-if="isXai" class="text-(--cp-text-primary)" aria-hidden="true" :width="20" :height="20" />
|
||||
<Openai v-else class="text-(--cp-text-primary)" aria-hidden="true" :width="20" :height="20" />
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Openai, Xai } from '@boxicons/vue'
|
||||
import { Grid2X2 } from '@lucide/vue'
|
||||
import { LayoutGrid } from '@lucide/vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
@@ -16,6 +16,11 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const providers = [
|
||||
{
|
||||
value: 'batch' as const,
|
||||
label: '批量导入',
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{
|
||||
value: 'openai' as const,
|
||||
label: 'OpenAI',
|
||||
@@ -26,11 +31,6 @@ const providers = [
|
||||
label: 'xAI',
|
||||
icon: Xai,
|
||||
},
|
||||
{
|
||||
value: 'batch' as const,
|
||||
label: '批量导入',
|
||||
icon: Grid2X2,
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
|
||||
@@ -136,6 +136,7 @@ const requestBars = computed(() => {
|
||||
}
|
||||
})
|
||||
})
|
||||
const hasDailyRequests = computed(() => requestBars.value.some(bar => bar.requestCount > 0))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -197,6 +198,7 @@ const requestBars = computed(() => {
|
||||
</strong>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasDailyRequests"
|
||||
class="flex items-stretch gap-px"
|
||||
:class="[trackShapeClass, !isCompact ? 'mt-2' : 'mt-auto']"
|
||||
role="img"
|
||||
@@ -215,6 +217,11 @@ const requestBars = computed(() => {
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="block font-mono tabular-nums text-(--cp-text-muted)"
|
||||
:class="[valueClass, !isCompact ? 'mt-2' : 'mt-auto']"
|
||||
>—</span>
|
||||
</template>
|
||||
|
||||
<div v-else :class="headerClass">
|
||||
|
||||
Reference in New Issue
Block a user