Files
conti-docs/tech-selection/index.html
T
2026-08-26 13:36:13 +08:00

324 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>