mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
Add configurable minimum versions for Codex Desktop and CLI, return 426 for unsupported recognized clients, and provide install and Windows offline download guidance. Co-authored-by: Codex <noreply@openai.com>
331 lines
8.4 KiB
Vue
331 lines
8.4 KiB
Vue
<script setup lang="ts">
|
|
import { AlertCircle, AlertTriangle, CheckCircle2, Info, X } from '@lucide/vue'
|
|
import { useResizeObserver } from '@vueuse/core'
|
|
import { nextTick, onBeforeUnmount, useId, useTemplateRef, watch } from 'vue'
|
|
|
|
import BaseIconButton from '../BaseIconButton.vue'
|
|
import BaseScrollbar from '../BaseScrollbar.vue'
|
|
import { lockBodyScroll, unlockBodyScroll } from './bodyScrollLock'
|
|
import { useModalDrag } from './useModalDrag'
|
|
|
|
type ModalSize = 'sm' | 'md' | 'lg' | 'xl'
|
|
type ModalTone = 'neutral' | 'info' | 'warning' | 'danger' | 'success'
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
title: string
|
|
description?: string
|
|
size?: ModalSize
|
|
tone?: ModalTone
|
|
dismissible?: boolean
|
|
draggable?: boolean
|
|
role?: 'dialog' | 'alertdialog'
|
|
}>(),
|
|
{
|
|
description: undefined,
|
|
size: 'md',
|
|
tone: 'neutral',
|
|
dismissible: true,
|
|
draggable: true,
|
|
role: 'dialog',
|
|
},
|
|
)
|
|
|
|
const open = defineModel<boolean>({ default: false })
|
|
const panel = useTemplateRef<HTMLElement>('panel')
|
|
const titleId = useId()
|
|
const descriptionId = useId()
|
|
const { cancelDrag, constrainPosition, handlePointerDown, isDragging, resetPosition } = useModalDrag(
|
|
panel,
|
|
() => props.draggable,
|
|
)
|
|
let previouslyFocused: HTMLElement | null = null
|
|
let ownsScrollLock = false
|
|
|
|
// 内容分段可能在弹窗打开期间改变面板高度;拖拽偏移必须随尺寸重新约束到视口内。
|
|
useResizeObserver(panel, () => {
|
|
if (open.value)
|
|
constrainPosition()
|
|
})
|
|
|
|
const focusableSelector = [
|
|
'a[href]',
|
|
'button:not([disabled])',
|
|
'input:not([disabled])',
|
|
'select:not([disabled])',
|
|
'textarea:not([disabled])',
|
|
'[tabindex]:not([tabindex="-1"])',
|
|
].join(',')
|
|
|
|
const sizeClasses: Record<ModalSize, string> = {
|
|
sm: 'max-w-md',
|
|
md: 'max-w-xl',
|
|
lg: 'max-w-3xl',
|
|
xl: 'max-w-5xl',
|
|
}
|
|
|
|
const iconMap = {
|
|
neutral: Info,
|
|
info: Info,
|
|
warning: AlertTriangle,
|
|
danger: AlertCircle,
|
|
success: CheckCircle2,
|
|
}
|
|
|
|
const toneClasses: Record<ModalTone, { iconBg: string, icon: string }> = {
|
|
neutral: {
|
|
iconBg: 'bg-cp-fill-quaternary',
|
|
icon: 'text-cp-text-secondary',
|
|
},
|
|
info: {
|
|
iconBg: 'bg-cp-info-bg',
|
|
icon: 'text-cp-info',
|
|
},
|
|
warning: {
|
|
iconBg: 'bg-cp-warning-bg',
|
|
icon: 'text-cp-warning',
|
|
},
|
|
danger: {
|
|
iconBg: 'bg-cp-error-bg',
|
|
icon: 'text-cp-error',
|
|
},
|
|
success: {
|
|
iconBg: 'bg-cp-success-bg',
|
|
icon: 'text-cp-success',
|
|
},
|
|
}
|
|
|
|
function closeModal() {
|
|
if (!props.dismissible)
|
|
return
|
|
open.value = false
|
|
}
|
|
|
|
function focusableElements() {
|
|
return Array.from(panel.value?.querySelectorAll<HTMLElement>(focusableSelector) ?? []).filter(
|
|
element => !element.hidden,
|
|
)
|
|
}
|
|
|
|
function handleKeydown(event: KeyboardEvent) {
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault()
|
|
closeModal()
|
|
return
|
|
}
|
|
if (event.key !== 'Tab')
|
|
return
|
|
|
|
const focusable = focusableElements()
|
|
if (focusable.length === 0) {
|
|
event.preventDefault()
|
|
panel.value?.focus()
|
|
return
|
|
}
|
|
const first = focusable[0]
|
|
const last = focusable[focusable.length - 1]
|
|
if (!panel.value?.contains(document.activeElement)) {
|
|
event.preventDefault()
|
|
if (event.shiftKey)
|
|
last?.focus()
|
|
else first?.focus()
|
|
}
|
|
else if (event.shiftKey && document.activeElement === first) {
|
|
event.preventDefault()
|
|
last?.focus()
|
|
}
|
|
else if (!event.shiftKey && document.activeElement === last) {
|
|
event.preventDefault()
|
|
first?.focus()
|
|
}
|
|
}
|
|
|
|
function acquireScrollLock() {
|
|
if (ownsScrollLock)
|
|
return
|
|
ownsScrollLock = true
|
|
lockBodyScroll()
|
|
}
|
|
|
|
function releaseScrollLock() {
|
|
if (!ownsScrollLock)
|
|
return
|
|
ownsScrollLock = false
|
|
unlockBodyScroll()
|
|
}
|
|
|
|
function restorePreviousFocus() {
|
|
if (previouslyFocused?.isConnected)
|
|
previouslyFocused.focus()
|
|
previouslyFocused = null
|
|
}
|
|
|
|
watch(
|
|
open,
|
|
async (isOpen) => {
|
|
if (isOpen) {
|
|
previouslyFocused
|
|
= document.activeElement instanceof HTMLElement ? document.activeElement : null
|
|
acquireScrollLock()
|
|
await nextTick()
|
|
const first = focusableElements()[0]
|
|
if (first)
|
|
first.focus()
|
|
else panel.value?.focus()
|
|
return
|
|
}
|
|
|
|
cancelDrag()
|
|
releaseScrollLock()
|
|
restorePreviousFocus()
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
onBeforeUnmount(() => {
|
|
releaseScrollLock()
|
|
restorePreviousFocus()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<Teleport to="body">
|
|
<Transition name="cp-modal" @after-leave="resetPosition">
|
|
<div
|
|
v-if="open"
|
|
class="fixed inset-0 z-50 grid place-items-center overflow-hidden p-3 sm:p-6"
|
|
role="presentation"
|
|
@keydown="handleKeydown"
|
|
>
|
|
<button
|
|
type="button"
|
|
tabindex="-1"
|
|
aria-label="关闭弹窗"
|
|
class="absolute inset-0 cursor-default border-0 bg-cp-bg-mask p-0"
|
|
@click="closeModal"
|
|
/>
|
|
<section
|
|
ref="panel"
|
|
class="cp-modal-panel relative grid max-h-[calc(100dvh-1.5rem)] w-full min-w-0 overflow-hidden rounded-cp-card bg-cp-modal-bg shadow-cp sm:max-h-[calc(100dvh-3rem)]"
|
|
:class="[
|
|
sizeClasses[size],
|
|
$slots.default ? 'grid-rows-[auto_minmax(0,1fr)_auto]' : 'grid-rows-[auto_auto]',
|
|
isDragging ? 'cp-modal-panel--dragging' : undefined,
|
|
]"
|
|
:role="role"
|
|
aria-modal="true"
|
|
:aria-labelledby="titleId"
|
|
:aria-describedby="description ? descriptionId : undefined"
|
|
tabindex="-1"
|
|
>
|
|
<header
|
|
class="cp-modal-header grid shrink-0 gap-3 p-4 pb-0 sm:gap-4 sm:p-6 sm:pb-0"
|
|
:class="[
|
|
tone === 'neutral'
|
|
? 'grid-cols-[minmax(0,1fr)_28px]'
|
|
: 'grid-cols-[auto_minmax(0,1fr)_28px]',
|
|
description ? 'items-start' : 'items-center',
|
|
draggable ? 'cp-modal-header--draggable' : undefined,
|
|
]"
|
|
@pointerdown="handlePointerDown"
|
|
>
|
|
<span
|
|
v-if="tone !== 'neutral'"
|
|
class="inline-flex size-11 items-center justify-center rounded-cp"
|
|
:class="$slots.icon ? 'bg-cp-fill-quaternary' : toneClasses[tone].iconBg"
|
|
>
|
|
<slot name="icon">
|
|
<component :is="iconMap[tone]" :size="18" :class="toneClasses[tone].icon" />
|
|
</slot>
|
|
</span>
|
|
<div class="min-w-0">
|
|
<h2 :id="titleId" class="m-0 text-lg leading-[1.15] font-heavy text-cp-text">
|
|
{{ title }}
|
|
</h2>
|
|
<p
|
|
v-if="description"
|
|
:id="descriptionId"
|
|
class="mt-1 mb-0 text-cp leading-[1.45] font-semibold text-cp-text-secondary"
|
|
>
|
|
{{ description }}
|
|
</p>
|
|
</div>
|
|
<BaseIconButton
|
|
label="关闭"
|
|
size="sm"
|
|
variant="ghost"
|
|
:disabled="!dismissible"
|
|
@click="closeModal"
|
|
>
|
|
<X :size="16" />
|
|
</BaseIconButton>
|
|
</header>
|
|
<div v-if="$slots.default" class="min-h-0 overflow-hidden px-3.25 py-3.25 sm:px-5.25 sm:py-5.25">
|
|
<BaseScrollbar
|
|
class="h-full -mr-2 pr-2 sm:-mr-3 sm:pr-3"
|
|
>
|
|
<div class="p-0.75">
|
|
<slot />
|
|
</div>
|
|
</BaseScrollbar>
|
|
</div>
|
|
<footer
|
|
v-if="$slots.footer"
|
|
class="flex shrink-0 flex-wrap justify-end gap-2 px-4 pb-4 sm:gap-3 sm:px-6 sm:pb-6"
|
|
>
|
|
<slot name="footer" />
|
|
</footer>
|
|
</section>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.cp-modal-enter-active,
|
|
.cp-modal-leave-active {
|
|
transition: opacity 180ms ease;
|
|
}
|
|
|
|
.cp-modal-enter-active .cp-modal-panel,
|
|
.cp-modal-leave-active .cp-modal-panel {
|
|
transition:
|
|
opacity 180ms ease,
|
|
transform 180ms ease;
|
|
}
|
|
|
|
.cp-modal-enter-from,
|
|
.cp-modal-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
.cp-modal-enter-from .cp-modal-panel {
|
|
opacity: 0;
|
|
transform: translate3d(0, 8px, 0) scale(0.985);
|
|
}
|
|
|
|
.cp-modal-leave-to .cp-modal-panel {
|
|
opacity: 0;
|
|
transform: translate3d(0, 4px, 0) scale(0.99);
|
|
}
|
|
|
|
.cp-modal-header--draggable {
|
|
cursor: move;
|
|
touch-action: none;
|
|
user-select: none;
|
|
}
|
|
|
|
.cp-modal-panel--dragging .cp-modal-header {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.cp-modal-enter-active,
|
|
.cp-modal-leave-active,
|
|
.cp-modal-enter-active .cp-modal-panel,
|
|
.cp-modal-leave-active .cp-modal-panel {
|
|
transition: none;
|
|
}
|
|
}
|
|
</style>
|