File size: 6,388 Bytes
68722e4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 | import { useEffect, useRef, useState } from 'react'
import { useI18n } from '../../shared/i18n/index'
import type { ConnectionMode, ConnectionSettings } from './connection-types'
import { saveConnection, useConnection } from './connection-storage'
import { ConnectionChoice } from './ConnectionChoice'
import { CatKeyForm } from './CatKeyForm'
import { ByokForm } from './ByokForm'
type GuideStep = 'choice' | ConnectionMode
/** 主界面先自己待一会儿,这一屏再浮上来。 */
const GUIDE_APPEAR_DELAY_MS = 2_000
const GUIDE_EXIT_MS = 320
function readPrefersReducedMotion(): boolean {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return false
}
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
interface ConnectionGuideProps {
/** 叉掉、按 Esc、点背景,或者选「先不选」:这一屏沉下去,主界面照常可用,只是还生成不了。 */
onDismiss: () => void
/** 验证成功后由 App 负责保存;连接要等退场动画结束才落盘,避免弹窗被立刻卸载。 */
onSaved?: (connection: ConnectionSettings) => void
}
/**
* The guide that floats over the app when there is no connection yet.
*
* It is an overlay, not a gate: the screen underneath never goes away, so "not now" is a real way
* out. (As a gate it replaced the app entirely — with no connection you could not reach anything,
* not even the settings page that configures one.)
*
* It also arrives late, on purpose: the home screen gets two seconds to itself first, then this
* screen washes in over it. App decides *whether* to mount this at all — only App can see that a
* closing settings page has finished sliding away.
*/
export function ConnectionGuide({ onDismiss, onSaved }: ConnectionGuideProps) {
const { t } = useI18n()
const connection = useConnection()
const [step, setStep] = useState<GuideStep>('choice')
const [shown, setShown] = useState(false)
const [closing, setClosing] = useState(false)
const closeTimerRef = useRef<number | null>(null)
useEffect(() => {
const timer = window.setTimeout(() => setShown(true), GUIDE_APPEAR_DELAY_MS)
return () => window.clearTimeout(timer)
}, [])
useEffect(() => {
return () => {
if (closeTimerRef.current) window.clearTimeout(closeTimerRef.current)
}
}, [])
const closeWithAnimation = (after: () => void) => {
if (readPrefersReducedMotion()) {
after()
return
}
setClosing(true)
closeTimerRef.current = window.setTimeout(() => {
closeTimerRef.current = null
after()
}, GUIDE_EXIT_MS)
}
const requestClose = () => {
if (closing) return
closeWithAnimation(onDismiss)
}
// It is a real dialog, so Esc has to close it.
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') requestClose()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [closing])
if (!shown) return null
const handleSaved = (next: ConnectionSettings) => {
if (onSaved) {
closeWithAnimation(() => onSaved(next))
return
}
saveConnection(next)
setStep('choice')
closeWithAnimation(onDismiss)
}
return (
<div role="dialog" aria-modal="true" className="fixed inset-0 z-[200] flex items-center justify-center p-4">
{/* 剩下的一切沉到玻璃底下——玻璃要薄,底下的主界面还看得见。 */}
<div
className={`${closing ? 'animate-overlay-wash-out' : 'animate-overlay-wash-in'} absolute inset-0 bg-bg-primary/40 backdrop-blur-sm`}
onClick={requestClose}
/>
<div className={`${closing ? 'animate-modal-pop-out' : 'animate-modal-pop-in'} relative w-full max-w-2xl rounded-[2.5rem] border border-border/5 bg-bg-secondary p-10 shadow-2xl`}>
<button
type="button"
onClick={requestClose}
aria-label={t('common.close')}
title={t('common.close')}
className="absolute right-6 top-6 rounded-2xl p-2.5 text-text-secondary/50 transition-colors duration-200 ease-ui hover:bg-bg-primary/50 hover:text-text-primary active:scale-90"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* 表单里只剩一个动作按钮,所以退路提到卡片自己头上——否则进了 BYOK 就出不去。 */}
{step !== 'choice' && (
<div className="-ml-2 mb-6 flex items-center">
<button
type="button"
onClick={() => setStep('choice')}
aria-label={t('connection.back')}
title={t('connection.back')}
className="rounded-2xl p-2.5 text-text-secondary/60 transition-colors duration-200 ease-ui hover:bg-bg-primary/60 hover:text-text-primary active:scale-90"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
</div>
)}
{/* 选择 → 表单也是一次换内容,方向和设置页一致:新的一屏从右边进来。 */}
<div key={step} className="panel-in-right">
{step === 'choice' && <ConnectionChoice onSelect={setStep} />}
{step === 'catkey' && (
<CatKeyForm
connection={connection}
submitLabel={t('connection.catkey.verify')}
onSaved={handleSaved}
/>
)}
{step === 'byok' && <ByokForm connection={connection} onSaved={handleSaved} />}
</div>
{step === 'choice' && (
<div className="mt-7 flex justify-center">
<button
type="button"
onClick={requestClose}
className="rounded-2xl px-4 py-2 text-sm text-text-secondary/70 transition-transform duration-200 ease-ui active:scale-95"
>
{t('connection.guide.dismiss')}
</button>
</div>
)}
</div>
</div>
)
}
|