Files
codex-proxy-rs/frontend/src/components/base/BaseModal/index.vue
T
ZyyandCodex bd2454a5b6 feat(settings): enforce minimum client versions
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>
2026-09-01 13:37:04 +08:00

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>