add tech selection h5 page

This commit is contained in:
renran
2026-08-26 13:36:13 +08:00
parent ee4146b121
commit 15d689296f
+324
View File
@@ -0,0 +1,324 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#0d2438">
<title>技术路线评估 | Flutter vs uni-app x</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600;700;800&display=swap');
:root {
--ink: #102d43;
--muted: #587083;
--paper: #f7f3ea;
--paper-deep: #ebe4d4;
--line: #cfd8d4;
--orange: #ee7041;
--teal: #007d78;
--lime: #b9d551;
--blue: #377fc7;
--shadow: 0 22px 55px rgba(17, 39, 56, .15);
}
* { box-sizing: border-box; }
html { background: var(--ink); }
body {
min-width: 320px;
margin: 0;
color: var(--ink);
font-family: "Noto Sans SC", "Microsoft YaHei", sans-serif;
background:
linear-gradient(90deg, rgba(16, 45, 67, .035) 1px, transparent 1px),
linear-gradient(rgba(16, 45, 67, .035) 1px, transparent 1px),
var(--paper);
background-size: 24px 24px;
}
button { font: inherit; }
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1220px;
min-height: 82px;
padding: 16px 28px;
margin: auto;
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 13px; font-weight: 800; letter-spacing: 0; }
.brand-mark { display: grid; width: 30px; height: 30px; place-items: center; color: white; background: var(--orange); font-family: "DM Mono", monospace; font-size: 13px; }
.meta { color: var(--muted); font-family: "DM Mono", monospace; font-size: 12px; letter-spacing: .04em; }
.shell { width: min(1164px, calc(100% - 48px)); margin: 28px auto 0; }
.chapter-strip { display: flex; gap: 6px; margin-bottom: 18px; overflow-x: auto; scrollbar-width: thin; }
.chapter-tab {
flex: 0 0 auto;
padding: 8px 12px;
border: 1px solid transparent;
color: var(--muted);
background: transparent;
cursor: pointer;
font-size: 13px;
transition: color .2s, background .2s, border-color .2s;
}
.chapter-tab:hover, .chapter-tab[aria-selected="true"] { color: var(--ink); border-color: var(--ink); background: #fffdf8; }
.chapter-tab[aria-selected="true"] { font-weight: 700; }
.deck { position: relative; min-height: 602px; }
.page {
display: none;
min-height: 602px;
padding: 42px clamp(25px, 5vw, 64px) 36px;
border-top: 7px solid var(--teal);
background: #fffdf8;
box-shadow: var(--shadow);
animation: enter .35s ease both;
}
.page.active { display: block; }
.page.orange { border-color: var(--orange); }
.page.blue { border-color: var(--blue); }
.page.lime { border-color: var(--lime); }
@keyframes enter { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.eyebrow { margin: 0 0 11px; color: var(--teal); font-family: "DM Mono", monospace; font-size: 12px; font-weight: 500; letter-spacing: .1em; text-transform: uppercase; }
.orange .eyebrow { color: #bc4d25; }
.blue .eyebrow { color: #2769aa; }
h1, h2, h3, p { margin-top: 0; }
h1 { max-width: 920px; margin-bottom: 18px; font-size: clamp(34px, 5vw, 62px); line-height: 1.12; letter-spacing: 0; }
h2 { max-width: 760px; margin-bottom: 26px; font-size: clamp(28px, 4vw, 46px); line-height: 1.18; letter-spacing: 0; }
h3 { margin-bottom: 10px; font-size: 17px; }
.lede { max-width: 760px; color: #385266; font-size: 17px; line-height: 1.8; }
.rule { height: 1px; margin: 27px 0; background: var(--line); }
.key-decision { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(240px, .65fr); gap: 24px; align-items: stretch; }
.decision { display: flex; flex-direction: column; justify-content: space-between; padding: 26px; color: #fffdf8; background: var(--ink); }
.decision-label { color: var(--lime); font-family: "DM Mono", monospace; font-size: 12px; letter-spacing: .08em; }
.decision strong { display: block; margin: 26px 0 12px; font-size: clamp(32px, 4.4vw, 55px); line-height: 1; }
.decision p { max-width: 500px; margin-bottom: 0; color: #d6e2e8; line-height: 1.7; }
.facts { display: grid; gap: 1px; background: var(--line); }
.fact { padding: 17px 20px; background: #f1f4ec; }
.fact span { display: block; color: var(--muted); font-size: 12px; }
.fact b { display: block; margin-top: 5px; font-family: "DM Mono", monospace; font-size: 20px; }
.architecture { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-top: 1px solid var(--line); border-left: 1px solid var(--line); }
.route { min-height: 203px; padding: 19px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.route-no { color: var(--orange); font-family: "DM Mono", monospace; font-size: 13px; }
.route h3 { margin: 16px 0 8px; }
.route p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.65; }
.route.highlight { color: #fffdf8; background: var(--teal); }
.route.highlight .route-no { color: var(--lime); }
.route.highlight p { color: #d4f0e7; }
.compare { width: 100%; border-collapse: collapse; font-size: 14px; }
.compare th, .compare td { padding: 13px 12px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; line-height: 1.55; }
.compare th { color: var(--muted); font-size: 12px; font-weight: 500; }
.compare th:last-child, .compare td:last-child { color: var(--teal); font-weight: 700; }
.callout { margin-top: 24px; padding: 18px 21px; border-left: 4px solid var(--orange); color: #3d5263; background: #fff0e8; line-height: 1.7; }
.bridge { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.bridge-way { padding: 21px; border: 1px solid var(--line); }
.bridge-way.good { border-color: var(--teal); background: #eef8f4; }
.bridge-way.risk { border-color: #edb49f; background: #fff5ef; }
.bridge-way h3 { display: flex; align-items: center; gap: 8px; }
.tag { display: inline-block; padding: 2px 6px; color: white; background: var(--teal); font-family: "DM Mono", monospace; font-size: 11px; font-weight: 400; }
.risk .tag { background: var(--orange); }
.bridge-way p { margin-bottom: 0; color: #425b6c; font-size: 14px; line-height: 1.7; }
.capabilities { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; }
.capability { min-height: 84px; padding: 14px; border-top: 3px solid var(--blue); background: #eef4fb; font-size: 14px; font-weight: 700; }
.capability:nth-child(2n) { border-color: var(--orange); background: #fff0e8; }
.capability:nth-child(3n) { border-color: var(--teal); background: #eaf6f1; }
.numbers { display: grid; grid-template-columns: 1.1fr .9fr .9fr; gap: 1px; background: var(--line); }
.number { min-height: 175px; padding: 24px; background: #fffdf8; }
.number b { display: block; color: var(--teal); font-family: "DM Mono", monospace; font-size: clamp(34px, 5vw, 61px); line-height: 1; }
.number p { margin: 14px 0 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
.split-note { display: grid; grid-template-columns: .9fr 1.1fr; gap: 26px; margin-top: 24px; }
.split-note p { margin: 0; color: #3c5566; line-height: 1.75; }
.case-studies { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-top: 24px; }
.case-study { display: grid; grid-template-columns: 152px 1fr; min-height: 168px; border: 1px solid var(--line); background: #fffdf8; overflow: hidden; }
.case-study img { width: 100%; height: 100%; min-height: 168px; object-fit: cover; }
.case-copy { padding: 18px; }
.case-copy small { color: var(--teal); font-family: "DM Mono", monospace; font-size: 11px; letter-spacing: .06em; }
.case-copy h3 { margin: 9px 0 7px; }
.case-copy p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.65; }
.pipeline { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 28px; }
.pipeline-step { position: relative; min-height: 155px; padding: 20px; border: 1px solid var(--line); }
.pipeline-step b { display: block; margin-bottom: 12px; font-family: "DM Mono", monospace; font-size: 12px; }
.pipeline-step p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
.pipeline-step.ok { border-top: 5px solid var(--teal); }
.pipeline-step.block { border-top: 5px solid var(--orange); background: #fff7f2; }
.cost-bars { display: grid; gap: 13px; margin: 27px 0; }
.cost-row { display: grid; grid-template-columns: 150px 1fr 160px; gap: 14px; align-items: center; font-size: 14px; }
.bar { height: 14px; background: #e1e7e4; }
.bar span { display: block; height: 100%; background: var(--teal); }
.cost-row:nth-child(2) .bar span { background: var(--blue); }
.cost-row:nth-child(3) .bar span, .cost-row:nth-child(4) .bar span { background: var(--orange); }
.cost-impact { color: var(--muted); font-size: 13px; }
.finale { display: grid; grid-template-columns: 1.1fr .9fr; gap: 32px; align-items: end; }
.reasons { padding: 0; margin: 0; list-style: none; counter-reset: reason; }
.reasons li { position: relative; min-height: 55px; padding: 12px 0 12px 49px; border-top: 1px solid var(--line); line-height: 1.6; }
.reasons li::before { position: absolute; left: 0; top: 12px; color: var(--orange); content: "0" counter(reason); counter-increment: reason; font-family: "DM Mono", monospace; font-weight: 500; }
.stamp { padding: 27px; color: #fffdf8; background: var(--ink); }
.stamp small { color: var(--lime); font-family: "DM Mono", monospace; letter-spacing: .08em; }
.stamp b { display: block; margin: 17px 0 9px; font-size: 31px; }
.stamp p { margin: 0; color: #d6e2e8; font-size: 14px; line-height: 1.7; }
.controls { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 20px; padding: 23px 0 30px; }
.pager-button { display: inline-grid; width: 43px; height: 43px; place-items: center; border: 1px solid var(--ink); color: var(--ink); background: transparent; cursor: pointer; font-size: 24px; transition: background .2s, color .2s; }
.pager-button:hover:not(:disabled) { color: #fffdf8; background: var(--ink); }
.pager-button:disabled { opacity: .25; cursor: not-allowed; }
#next { justify-self: end; }
.page-count { color: var(--muted); font-family: "DM Mono", monospace; font-size: 13px; }
.page-dots { display: flex; justify-content: center; gap: 7px; }
.dot { width: 8px; height: 8px; padding: 0; border: 0; background: #aebeba; cursor: pointer; }
.dot.active { width: 26px; background: var(--orange); }
.source { padding: 0 28px 28px; color: #c1d0d6; font-size: 12px; text-align: center; }
.source a { color: #d9ed8f; }
@media (max-width: 760px) {
.topbar { min-height: 64px; padding: 13px 18px; }
.meta { display: none; }
.shell { width: min(100% - 28px, 680px); margin-top: 14px; }
.page { min-height: 0; padding: 30px 20px; }
.key-decision, .bridge, .split-note, .finale, .case-studies { grid-template-columns: 1fr; }
.case-study { grid-template-columns: 128px 1fr; }
.architecture { grid-template-columns: repeat(2, 1fr); }
.capabilities { grid-template-columns: repeat(2, 1fr); }
.numbers { grid-template-columns: 1fr; }
.pipeline { grid-template-columns: repeat(2, 1fr); }
.cost-row { grid-template-columns: 92px 1fr; }
.cost-impact { grid-column: 2; }
.compare { min-width: 610px; }
.table-scroll { overflow-x: auto; }
}
@media (max-width: 420px) { .architecture, .pipeline { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><span class="brand-mark">CR</span><span>Continental Retail APP</span></div>
<span class="meta">TECHNOLOGY ROUTE REVIEW / 2026.08</span>
</header>
<main class="shell">
<div class="chapter-strip" aria-label="章节导航" role="tablist">
<button class="chapter-tab" role="tab" aria-selected="true" aria-controls="chapter-1" data-page="0">01 概览</button>
<button class="chapter-tab" role="tab" aria-selected="false" aria-controls="chapter-2" data-page="1">02 架构</button>
<button class="chapter-tab" role="tab" aria-selected="false" aria-controls="chapter-3" data-page="2">03 业务适配</button>
<button class="chapter-tab" role="tab" aria-selected="false" aria-controls="chapter-4" data-page="3">04 生态治理</button>
<button class="chapter-tab" role="tab" aria-selected="false" aria-controls="chapter-5" data-page="4">05 工程链路</button>
<button class="chapter-tab" role="tab" aria-selected="false" aria-controls="chapter-6" data-page="5">06 结论</button>
</div>
<section class="deck" aria-live="polite">
<article class="page active orange" id="chapter-1" role="tabpanel">
<p class="eyebrow">Chapter 01 / Executive Summary</p>
<h1>面向五年周期的<br>独立 App 技术路线选择。</h1>
<p class="lede">Continental Retail APP 将整合并关停六套小程序。首版只面向 Android 与 iOS;在十一种候选技术栈中,核心挑战仍是系统级能力、第三方网页集成与长期工程可控性。</p>
<div class="rule"></div>
<div class="key-decision">
<section class="decision"><span class="decision-label">ASSESSMENT DECISION</span><strong>推荐 Flutter</strong><p>首版投入与 uni-app x 基本持平;真正的差异将出现在未来五年新增系统能力、自动化交付和底层问题的处置能力上。</p></section>
<aside class="facts" aria-label="评估信息"><div class="fact"><span>评估对象</span><b>Flutter / uni-app / uni-app x</b></div><div class="fact"><span>首版平台</span><b>Android + iOS</b></div><div class="fact"><span>核验日期</span><b>2026.08.25</b></div></aside>
</div>
</article>
<article class="page" id="chapter-2" role="tabpanel" hidden>
<p class="eyebrow">Chapter 02 / Architecture</p>
<h2>十一种技术栈,底层仍然只有四条路线。</h2>
<div class="architecture">
<section class="route"><span class="route-no">01 / NATIVE</span><h3>原生双端</h3><p>Kotlin 与 Swift 分别开发。能力没有上限,但维护两套代码与团队。</p></section>
<section class="route"><span class="route-no">02 / WEBVIEW</span><h3>网页套壳</h3><p>uni-app 的路径。系统能力依赖插件,受 WebView 内核与插件生态制约。</p></section>
<section class="route"><span class="route-no">03 / COMPILE</span><h3>编译到系统控件</h3><p>uni-app x、React Native、Kuikly、.NET MAUI 的路径。能力上限取决于“翻译词典”和插件覆盖度。</p></section>
<section class="route highlight"><span class="route-no">04 / RENDER</span><h3>自绘引擎</h3><p>Flutter 自己绘制像素,原生能力通过标准插件直接对接,边界由工程能力决定。</p></section>
</div>
<div class="callout"><b>Kuikly 的位置:</b>腾讯于 2025 年开源、内部落地规模可观,但对外生态仅一年多,且更贴合 Kotlin/KMP 团队与动态化需求。本项目不具备这两个前提,应在未来三年后重新评估。</div>
<div class="table-scroll"><table class="compare"><thead><tr><th>维度</th><th>uni-app</th><th>uni-app x</th><th>Flutter</th></tr></thead><tbody><tr><td>路线</td><td>网页套壳</td><td>系统控件编译</td><td>自绘引擎</td></tr><tr><td>界面一致性</td><td>受 WebView 影响</td><td>两端系统控件存在差异</td><td>逐像素一致</td></tr><tr><td>能力天花板</td><td>插件生态</td><td>词典 + 插件生态</td><td>自己的工程能力</td></tr></tbody></table></div>
</article>
<article class="page blue" id="chapter-3" role="tabpanel" hidden>
<p class="eyebrow">Chapter 03 / Product Fit</p>
<h2>原生能力密度,才是本项目的决定性变量。</h2>
<div class="bridge">
<section class="bridge-way good"><h3><span class="tag">Flutter</span> F6 网页零改动</h3><p>App 在页面加载后注入胶水代码,单一双向 JavaScript Channel 承担请求与回调。F6 无需引入额外脚本。</p></section>
<section class="bridge-way risk"><h3><span class="tag">uni-app x</span> 需要外部厂商配合</h3><p>网页须引入 DCloud 的 <code>uni.webview.js</code>;网页到 App 与 App 到网页是两条不对称单向通道,需自行封装协议。</p></section>
</div>
<div class="rule"></div>
<div class="capabilities"><div class="capability">扫码 / VIN 识别</div><div class="capability">相机 / 相册多选</div><div class="capability">弱网离线与重传</div><div class="capability">带进度的大文件上传</div><div class="capability">定位与运行时权限</div><div class="capability">Bugly 崩溃上报</div><div class="capability">神策埋点 SDK</div><div class="capability">物理返回键拦截</div><div class="capability">WebView 文件选择</div><div class="capability">iOS 隐私清单</div></div>
<div class="callout"><b>结论:</b>这些能力无论选择哪条路线都需要实现与双端验证。Flutter 的原生实现可同时服务原生页面和 F6 网页通道;uni-app x 则需在 UTS 插件与 HBuilderX 约束下处理。</div>
</article>
<article class="page lime" id="chapter-4" role="tabpanel" hidden>
<p class="eyebrow">Chapter 04 / Ecosystem & Governance</p>
<h2>生态的价值,在规模化交付与问题发生时才真正显现。</h2>
<div class="numbers"><section class="number"><b>91,171</b><p>Flutter 主仓库公开提交数,包含渲染引擎到框架层。</p></section><section class="number"><b>31,002</b><p>Flutter fork 数。公开协作与问题排查的广度。</p></section><section class="number"><b>~8x</b><p>相对 uni-app 主仓库提交数与 fork 数的量级差异。</p></section></div>
<div class="split-note"><p><b>开源的层次不同。</b><br>Flutter 从渲染引擎到框架均以 BSD 协议开源。uni-app x 的组件与 API 开源,但 Runtime、SDK 和 HBuilderX 为 DCloud 自有闭源能力。</p><p><b>这不是偏好问题。</b><br>当问题落在渲染层或运行时,Flutter 可以阅读源码、提交修复或自行维护;uni-app x 需要依赖厂商支持。对于五年周期的核心业务系统,这是不同的风险敞口。</p></div>
<div class="case-studies">
<article class="case-study"><img src="assets/bmw1.png" alt="My BMW App 的车辆状态和车控界面"><div class="case-copy"><small>BMW / 甲档案例</small><h3>96 个变体,自动化交付</h3><p>My BMW App 覆盖 47 国。其 Mobile 2.0 Platform 可在每次构建中自动生成、测试并部署 96 个变体。</p></div></article>
<article class="case-study"><img src="assets/vw4.png" alt="ID. UNYX App 的车辆状态和远程车控界面"><div class="case-copy"><small>ID. UNYX / 乙档案例</small><h3>主干页面承载车控场景</h3><p>大众安徽的法定 SDK 披露将 Flutter 列为 App 全部 L1、L2、L3 页面开发语言,展示真实混合架构的主干选择。</p></div></article>
</div>
<div class="callout"><b>落地证据:</b>字节 90+ 款、闲鱼、美团商家端、京东、携程、BMW 与 ID. UNYX 的公开材料,指向“原生 / 混合底座 + Flutter 承载业务主干”的成熟形态,正是本项目规划的路径。AI 工具链方面,Flutter / Dart 的 skills 和 MCP 服务也是独立开源资产,可接入多种 agent 工具。</div>
</article>
<article class="page orange" id="chapter-5" role="tabpanel" hidden>
<p class="eyebrow">Chapter 05 / Delivery & Extensibility</p>
<h2>自动化交付,不能依赖某台机器上的一个 IDE。</h2>
<p class="lede">既定链路包含 GitLab CI/CD、三套环境、远程 Mac 构建节点、Android OTA 与 iOS TestFlight。目标是每次发布都可追溯、可复现。</p>
<div class="pipeline"><section class="pipeline-step ok"><b>01 / COMMIT</b><p>代码提交后触发 lint、格式化与测试卡点。</p></section><section class="pipeline-step ok"><b>02 / BUILD</b><p>Android / iOS 在自有流水线和构建节点中出包。</p></section><section class="pipeline-step block"><b>03 / UTS PLUGIN</b><p>官方限制:UTS 插件只能经 HBuilderX 创建和使用,不支持 CLI。</p></section><section class="pipeline-step block"><b>04 / PACKAGE</b><p>加密付费插件仅支持云端打包;部分系统扩展官方建议离线打包,形成取舍。</p></section></div>
<div class="split-note"><p><b>离线打包不是完整原生工程。</b><br>拿到的是围绕闭源 Runtime / AAR 搭建的壳工程:可在外层添加模块,不能修改运行时内部行为。</p><p><b>扩展能力会回到原生。</b><br>小组件、Siri 快捷指令、分享扩展等最终仍需要 Xcode / Android Studio 与 Swift / KotlinFlutter 侧可直接遵循标准原生工程规则。</p></div>
</article>
<article class="page blue" id="chapter-6" role="tabpanel" hidden>
<p class="eyebrow">Chapter 06 / Recommendation</p>
<h2>首版成本持平,五年后的主动权不一样。</h2>
<div class="cost-bars"><div class="cost-row"><b>业务集成 45-55%</b><div class="bar"><span style="width: 88%"></span></div><span class="cost-impact">框架基本无差异</span></div><div class="cost-row"><b>UI 实现 20-25%</b><div class="bar"><span style="width: 40%"></span></div><span class="cost-impact">uni-app x 略省</span></div><div class="cost-row"><b>原生桥接 10-15%</b><div class="bar"><span style="width: 27%"></span></div><span class="cost-impact">uni-app x 更贵</span></div><div class="cost-row"><b>工程与可观测 10-15%</b><div class="bar"><span style="width: 27%"></span></div><span class="cost-impact">uni-app x 更贵</span></div></div>
<div class="finale"><ol class="reasons"><li><b>架构适配:</b>项目是独立 App,无法获得多端小程序复用收益。</li><li><b>业务风险:</b>F6 集成和十余项原生能力需要更直接、可控的通道。</li><li><b>长期治理:</b>开放生态与完整原生工程为问题处置保留主动权。</li><li><b>交付能力:</b>Flutter 更匹配现有 GitLab CI/CD 与标准原生扩展方式。</li></ol><aside class="stamp" aria-label="最终技术路线建议"><small>FINAL DECISION</small><b>推荐 Flutter</b><p>uni-app x 在鸿蒙支持、小程序/H5 复用、包体和内存上具有真实优势;但它们不是本项目首版的敏感维度。</p></aside></div>
</article>
</section>
<footer class="controls">
<button class="pager-button" id="previous" type="button" aria-label="上一章" title="上一章">&#8592;</button>
<div><div class="page-dots" aria-label="分页"></div><div class="page-count">01 / 06</div></div>
<button class="pager-button" id="next" type="button" aria-label="下一章" title="下一章">&#8594;</button>
</footer>
</main>
<script>
const pages = Array.from(document.querySelectorAll('.page'));
const tabs = Array.from(document.querySelectorAll('.chapter-tab'));
const dotsHost = document.querySelector('.page-dots');
const count = document.querySelector('.page-count');
const previous = document.querySelector('#previous');
const next = document.querySelector('#next');
let currentPage = 0;
let lastPagerAction = 0;
pages.forEach((page, index) => {
const dot = document.createElement('button');
dot.className = 'dot';
dot.type = 'button';
dot.setAttribute('aria-label', `跳转至第 ${index + 1}`);
dot.addEventListener('click', () => showPage(index));
dotsHost.append(dot);
});
const dots = Array.from(document.querySelectorAll('.dot'));
function showPage(index) {
currentPage = Math.max(0, Math.min(index, pages.length - 1));
pages.forEach((page, itemIndex) => {
const active = itemIndex === currentPage;
page.classList.toggle('active', active);
page.hidden = !active;
tabs[itemIndex].setAttribute('aria-selected', String(active));
dots[itemIndex].classList.toggle('active', active);
});
count.textContent = `${String(currentPage + 1).padStart(2, '0')} / ${String(pages.length).padStart(2, '0')}`;
previous.disabled = currentPage === 0;
next.disabled = currentPage === pages.length - 1;
}
tabs.forEach((tab, index) => tab.addEventListener('click', () => showPage(index)));
function movePage(direction) {
const now = Date.now();
if (now - lastPagerAction < 250) return;
lastPagerAction = now;
showPage(currentPage + direction);
}
previous.addEventListener('click', () => movePage(-1));
next.addEventListener('click', () => movePage(1));
document.addEventListener('keydown', (event) => {
if (event.key === 'ArrowLeft') showPage(currentPage - 1);
if (event.key === 'ArrowRight') showPage(currentPage + 1);
});
showPage(0);
</script>
</body>
</html>