a642bf16a2
design/ 同步自最新设计导出,新增 ui_kits/tablet/ 平板分栏布局;todo/ 录入 18 个并行实施任务。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
129 lines
7.0 KiB
HTML
129 lines
7.0 KiB
HTML
<!-- @dsCard group="UI Kit — Mobile" name="UI Kit · 移动 App" subtitle="iOS 连接界面 · 节点 · 账户 · 中英切换 · 双主题" viewport="390x760" -->
|
|
<!doctype html>
|
|
<html lang="zh">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>穿山甲 · 移动 App</title>
|
|
<link rel="stylesheet" href="../../colors_and_type.css">
|
|
<style>
|
|
html, body { margin: 0; height: 100%; }
|
|
body { background: var(--sand-100); display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 32px 0; box-sizing: border-box; font-family: var(--font-sans); }
|
|
[data-theme="dark"] ~ * , body:has(#root [data-theme="dark"]) { }
|
|
@keyframes pg-spin { to { transform: rotate(360deg); } }
|
|
@keyframes pg-in-l { from { opacity: 0; transform: translateX(18px); } to { opacity: 1; transform: translateX(0); } }
|
|
@keyframes pg-in-r { from { opacity: 0; transform: translateX(-18px); } to { opacity: 1; transform: translateX(0); } }
|
|
* { -webkit-font-smoothing: antialiased; }
|
|
button { font-family: inherit; }
|
|
</style>
|
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
<script type="text/babel" src="ios-frame.jsx?v8"></script>
|
|
<script type="text/babel" src="parts.jsx?v8"></script>
|
|
<script type="text/babel" src="screens.jsx?v8"></script>
|
|
<script type="text/babel" src="flows.jsx?v8"></script>
|
|
<script type="text/babel">
|
|
const { useState, useEffect, useRef } = React;
|
|
|
|
function App() {
|
|
const [stage, setStage] = useState('app'); // app | auth | onboarding — UI kit opens on the main app
|
|
const [tab, setTab] = useState('connect');
|
|
const [status, setStatus] = useState('off'); // off | connecting | on
|
|
const [code, setCode] = useState('AUTO'); // 'AUTO' = 智能选择(默认) | 节点 code
|
|
const [dark, setDark] = useState(false);
|
|
const [lang, setLang] = useState('zh'); // zh | en
|
|
const [free, setFree] = useState(true); // 演示:免费版视角(额度 UI)
|
|
const [mins, setMins] = useState(6); // 今日剩余分钟(演示值,每日 10)
|
|
const [adDone, setAdDone] = useState(false); // 今日是否已看广告解锁
|
|
const [elapsed, setElapsed] = useState(0);
|
|
const [q, setQ] = useState('');
|
|
const [dir, setDir] = useState(0); // 滑动方向:1 左滑进 / -1 右滑进 / 0 无动画
|
|
const timer = useRef(null);
|
|
const touchRef = useRef({ x: 0, y: 0 });
|
|
const t = makeT(lang);
|
|
const SWIPE_ORDER = ['connect', 'servers', 'stats', 'me'];
|
|
|
|
// 统一导航:主 Tab 间带方向动画,子页(兑换/套餐/联系)无动画
|
|
function goTab(id) {
|
|
const a = SWIPE_ORDER.indexOf(tab), b = SWIPE_ORDER.indexOf(id);
|
|
setDir(a >= 0 && b >= 0 ? (b > a ? 1 : b < a ? -1 : 0) : 0);
|
|
setTab(id);
|
|
setTimeout(() => setDir(0), 300); // 动画结束后清除(隐藏环境下 animationend 不可靠)
|
|
}
|
|
function onSwipeStart(e) { const p = e.touches[0]; touchRef.current = { x: p.clientX, y: p.clientY }; }
|
|
function onSwipeEnd(e) {
|
|
if (!SWIPE_ORDER.includes(tab)) return; // 子页不响应滑动
|
|
const p = e.changedTouches[0];
|
|
const dx = p.clientX - touchRef.current.x, dy = p.clientY - touchRef.current.y;
|
|
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) {
|
|
const i = SWIPE_ORDER.indexOf(tab);
|
|
const ni = dx < 0 ? Math.min(SWIPE_ORDER.length - 1, i + 1) : Math.max(0, i - 1);
|
|
if (ni !== i) goTab(SWIPE_ORDER[ni]);
|
|
}
|
|
}
|
|
|
|
const smart = code === 'AUTO';
|
|
const server = smart ? SERVERS.reduce((a, b) => (a.ping < b.ping ? a : b)) : SERVERS.find(s => s.code === code);
|
|
|
|
function toggle() {
|
|
if (status === 'off') {
|
|
setStatus('connecting');
|
|
setTimeout(() => { setStatus('on'); setElapsed(0); }, 1500);
|
|
} else {
|
|
setStatus('off');
|
|
}
|
|
}
|
|
// pick a server: if connected, brief reconnect
|
|
function pick(c) {
|
|
setCode(c);
|
|
setTab('connect');
|
|
if (status === 'on') { setStatus('connecting'); setTimeout(() => { setStatus('on'); setElapsed(0); }, 1200); }
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (status === 'on') { timer.current = setInterval(() => setElapsed(e => e + 1), 1000); }
|
|
return () => clearInterval(timer.current);
|
|
}, [status]);
|
|
|
|
let body;
|
|
if (stage === 'auth') {
|
|
body = <AuthFlow t={t} lang={lang} onDone={() => setStage('onboarding')} />;
|
|
} else if (stage === 'onboarding') {
|
|
body = <Onboarding t={t} lang={lang} onDone={() => setStage('app')} />;
|
|
} else {
|
|
let screen;
|
|
if (tab === 'connect') screen = <ConnectScreen t={t} lang={lang} status={status} onToggle={toggle} server={server} smart={smart} free={free} mins={mins} adDone={adDone} onAd={() => setAdDone(true)} goServers={() => goTab('servers')} elapsed={elapsed} />;
|
|
else if (tab === 'servers') screen = <ServersScreen t={t} lang={lang} current={code} onPick={pick} q={q} setQ={setQ} />;
|
|
else if (tab === 'stats') screen = <StatsScreen t={t} lang={lang} />;
|
|
else if (tab === 'redeem') screen = <RedeemScreen t={t} lang={lang} onBack={() => goTab('plans')} />;
|
|
else if (tab === 'plans') screen = <PlansScreen t={t} lang={lang} onBack={() => goTab('me')} onChoose={() => goTab('redeem')} />;
|
|
else if (tab === 'contact') screen = <ContactScreen t={t} lang={lang} onBack={() => goTab('me')} />;
|
|
else screen = <SettingsScreen t={t} lang={lang} setLang={setLang} dark={dark} setDark={setDark} free={free} setFree={setFree} onRedeem={() => goTab('redeem')} onUpgrade={() => goTab('plans')} onContact={() => goTab('contact')} onSignOut={() => setStage('auth')} onReplayAuth={() => setStage('auth')} onReplayOnboarding={() => setStage('onboarding')} />;
|
|
body = (<>
|
|
<div onTouchStart={onSwipeStart} onTouchEnd={onSwipeEnd} style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
|
<div key={tab} onAnimationEnd={() => setDir(0)} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', animation: dir !== 0 ? `pg-in-${dir === 1 ? 'l' : 'r'} 200ms var(--ease-out)` : 'none' }}>
|
|
{screen}
|
|
</div>
|
|
</div>
|
|
<BottomTab t={t} tab={(tab === 'redeem' || tab === 'plans' || tab === 'contact') ? 'me' : tab} setTab={goTab} />
|
|
</>);
|
|
}
|
|
|
|
return (
|
|
<IOSDevice dark={dark}>
|
|
<div data-theme={dark ? 'dark' : 'light'} style={{ position: 'absolute', inset: 0, background: 'var(--bg)', display: 'flex', flexDirection: 'column', paddingTop: 56 }}>
|
|
{body}
|
|
</div>
|
|
</IOSDevice>
|
|
);
|
|
}
|
|
|
|
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
|
</script>
|
|
</body>
|
|
</html>
|