landing.js 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948
  1. /* =========================================================================
  2. Fmode 落地页 · 交互层
  3. 结构:工具函数 → 主题 / 导航 → 滚动揭示 → 标签墙 → Hero 协作演示
  4. → 实验室四面板 → 产品预览 → 数字 → 页脚提示
  5. 全部动画由「可中断的状态机」驱动,支持暂停 / 重播 / 离开视口即停,
  6. 并统一服从 prefers-reduced-motion(降级为直接呈现终态)。
  7. ========================================================================= */
  8. (() => {
  9. 'use strict';
  10. const reduced = matchMedia('(prefers-reduced-motion: reduce)');
  11. const $ = (s, r = document) => r.querySelector(s);
  12. const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
  13. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  14. const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
  15. /** 可取消的等待:每次重播都会 bump 掉上一轮的所有定时器 */
  16. class Runner {
  17. constructor() { this.gen = 0; }
  18. stop() { this.gen++; }
  19. alive(gen) { return gen === this.gen; }
  20. wait(ms, gen) {
  21. return new Promise((resolve) => {
  22. setTimeout(() => resolve(this.alive(gen)), reduced.matches ? Math.min(ms, 30) : ms);
  23. });
  24. }
  25. }
  26. /* ---------------------------------------------------------------- 主题 */
  27. const root = document.documentElement;
  28. const themeBtn = $('#themeBtn');
  29. const THEME_KEY = 'fmode-landing-theme';
  30. function applyTheme(t) {
  31. root.dataset.theme = t;
  32. const meta = $('meta[name="theme-color"]');
  33. if (meta) meta.setAttribute('content', t === 'light' ? '#ffffff' : '#000000');
  34. themeBtn.setAttribute('aria-pressed', String(t === 'light'));
  35. themeBtn.setAttribute('aria-label', t === 'light' ? '切换到深色主题' : '切换到浅色主题');
  36. }
  37. applyTheme(root.dataset.theme || 'dark');
  38. themeBtn.addEventListener('click', () => {
  39. const next = root.dataset.theme === 'light' ? 'dark' : 'light';
  40. applyTheme(next);
  41. try { localStorage.setItem(THEME_KEY, next); } catch (e) { /* 忽略 */ }
  42. });
  43. /* ---------------------------------------------------------------- 提示 */
  44. const toastArea = $('#toastArea');
  45. function toast(text) {
  46. const el = document.createElement('div');
  47. el.className = 'toast';
  48. el.innerHTML = `<i class="ph-fill ph-check-circle" aria-hidden="true"></i><span>${esc(text)}</span>`;
  49. toastArea.append(el);
  50. setTimeout(() => el.remove(), 2600);
  51. }
  52. /* ------------------------------------------------------- 导航:滚动状态 */
  53. const nav = $('#nav');
  54. const onScroll = () => { nav.dataset.scrolled = String(scrollY > 8); };
  55. addEventListener('scroll', onScroll, { passive: true });
  56. onScroll();
  57. // 当前所在区块高亮
  58. const anchorLinks = $$('.nav__link');
  59. const sections = anchorLinks
  60. .map((a) => document.getElementById(a.getAttribute('href').slice(1)))
  61. .filter(Boolean);
  62. const navObserver = new IntersectionObserver((entries) => {
  63. entries.forEach((e) => {
  64. if (!e.isIntersecting) return;
  65. anchorLinks.forEach((a) => a.setAttribute('aria-current', String(a.getAttribute('href') === `#${e.target.id}`)));
  66. });
  67. }, { rootMargin: '-45% 0px -50% 0px' });
  68. sections.forEach((s) => navObserver.observe(s));
  69. /* ------------------------------------------------------- 移动端菜单 */
  70. const menuBtn = $('#menuBtn');
  71. const menu = $('#menu');
  72. const setMenu = (open) => {
  73. menu.dataset.open = String(open);
  74. menu.setAttribute('aria-hidden', String(!open));
  75. menuBtn.setAttribute('aria-expanded', String(open));
  76. menuBtn.setAttribute('aria-label', open ? '关闭菜单' : '打开菜单');
  77. menuBtn.querySelector('i').className = open ? 'ph ph-x' : 'ph ph-list';
  78. document.body.style.overflow = open ? 'hidden' : '';
  79. };
  80. menuBtn.addEventListener('click', () => setMenu(menu.dataset.open !== 'true'));
  81. $$('a', menu).forEach((a) => a.addEventListener('click', () => setMenu(false)));
  82. addEventListener('keydown', (e) => { if (e.key === 'Escape' && menu.dataset.open === 'true') setMenu(false); });
  83. /* ------------------------------------------------------- 通用滚动揭示 */
  84. const revealObserver = new IntersectionObserver((entries) => {
  85. entries.forEach((e) => {
  86. if (!e.isIntersecting) return;
  87. e.target.classList.add('is-in');
  88. revealObserver.unobserve(e.target);
  89. });
  90. }, { rootMargin: '0px 0px -12% 0px', threshold: 0.12 });
  91. $$('.reveal').forEach((el) => revealObserver.observe(el));
  92. /* ------------------------------------------------------- 标签墙:逐词点亮 */
  93. (() => {
  94. const host = $('#manifestoText');
  95. if (!host) return;
  96. const text = host.dataset.text;
  97. host.textContent = '';
  98. const chars = Array.from(text);
  99. chars.forEach((ch) => {
  100. if (ch === '。') return; // 句末标点不单独成词
  101. const span = document.createElement('span');
  102. span.className = 'w';
  103. span.textContent = ch;
  104. host.append(span);
  105. });
  106. const words = $$('.w', host);
  107. let raf = 0;
  108. const update = () => {
  109. raf = 0;
  110. const rect = host.getBoundingClientRect();
  111. // 触发线:视口高度的 62% 位置
  112. const line = innerHeight * 0.62;
  113. const start = rect.top;
  114. const total = rect.height;
  115. if (total <= 0) return;
  116. const progress = Math.min(1, Math.max(0, (line - start) / total));
  117. const upto = reduced.matches ? words.length : Math.floor(progress * words.length * 1.06);
  118. words.forEach((w, i) => {
  119. const on = i < upto ? 'true' : 'false';
  120. if (w.dataset.on !== on) w.dataset.on = on;
  121. });
  122. };
  123. addEventListener('scroll', () => { if (!raf) raf = requestAnimationFrame(update); }, { passive: true });
  124. addEventListener('resize', update);
  125. update();
  126. })();
  127. /* ------------------------------------------------------- Hero 协作演示 */
  128. const heroTimeline = $('#heroTimeline');
  129. const heroRunner = new Runner();
  130. let heroPaused = false;
  131. const HERO_SCRIPT = [
  132. { kind: 'me', name: '我', avatar: '我', time: '12:30', text: '@lj @lj-test 登录页改成手机号 + 验证码' },
  133. {
  134. kind: 'lead', name: 'lj', role: '组长', avatar: 'lj', think: 1800, time: '12:31',
  135. thinkText: '这条需求有三个歧义点:登录方式、是否记住登录、给谁用。先一次问清,再拆分工,避免组员各做一套。',
  136. type: (h) => {
  137. h.innerHTML =
  138. '我是组长 lj。先确认三件事(选一下就行):<br>' +
  139. '1. 要记住登录状态吗? ' + picker(['要', '不用'], 'A') + '<br>' +
  140. '2. 验证码有效期? ' + picker(['5 分钟', '10 分钟'], 'A') + '<br>' +
  141. '3. 给谁用? ' + picker(['内部员工', '外部客户'], 'A');
  142. },
  143. },
  144. { kind: 'me', name: '我', avatar: '我', time: '12:32', text: 'A、A、A' },
  145. {
  146. kind: 'lead', name: 'lj', role: '组长', avatar: 'lj', time: '12:33',
  147. type: (h) => {
  148. h.innerHTML =
  149. '<b>【方案】</b>组长 lj / 组员 lj-test<br>' +
  150. '分工:lj 做前端,lj-test 做验证码接口<br>' +
  151. '交付:<span class="hl">可运行的登录页</span> + 接口文档<br>' +
  152. '先定契约再各自开工。';
  153. },
  154. tools: [
  155. { icon: 'file-text', verb: '写入', target: 'CONTRACT.md', meta: '1.2 KB', ms: 900 },
  156. ],
  157. },
  158. {
  159. kind: 'dev', name: 'lj-test', role: '组员', avatar: 'lt', time: '12:36',
  160. type: (h) => { h.textContent = '后端开工,先按 CONTRACT.md 出接口。'; },
  161. tools: [
  162. { icon: 'magnifying-glass', verb: '搜索', target: 'sms_code', meta: '3 处', ms: 700 },
  163. { icon: 'pencil-simple', verb: '改写', target: 'src/api/sms.ts', meta: '+42', ms: 1400 },
  164. { icon: 'play-circle', verb: '运行', target: 'npm test', meta: '6 通过', ms: 1600, state: 'ok' },
  165. ],
  166. diff: [
  167. { k: 'ctx', n: 14, t: 'export async function sendCode(phone: string) {' },
  168. { k: 'del', n: 15, t: ' await sms.send(phone)' },
  169. { k: 'add', n: 15, t: ' if (!/^1\\d{10}$/.test(phone)) throw new Error(\'手机号格式错误\')' },
  170. { k: 'add', n: 16, t: ' const code = randomCode(6)' },
  171. { k: 'add', n: 17, t: ' await sms.send(phone, code, { ttl: 600 })' },
  172. { k: 'ctx', n: 18, t: ' return { ok: true }' },
  173. { k: 'ctx', n: 19, t: '}' },
  174. ],
  175. },
  176. {
  177. kind: 'lead', name: 'lj', role: '组长', avatar: 'lj', time: '12:39',
  178. type: (h) => {
  179. h.innerHTML = '前端这边验证码有效期按 10 分钟做,和契约对齐了。页面已经能跑。';
  180. },
  181. tools: [
  182. { icon: 'pencil-simple', verb: '改写', target: 'src/login/Login.tsx', meta: '+18 −4', ms: 1300 },
  183. { icon: 'package', verb: '归档', target: '成果库 / 登录页改造', meta: '3 份', ms: 1100 },
  184. ],
  185. deliverable: true,
  186. },
  187. {
  188. kind: 'lead', name: 'lj', role: '组长', avatar: 'lj', time: '12:42',
  189. type: (h) => { h.innerHTML = '<b>【交付】</b>登录页已完成,文件在 <span class="hl">/opt/data/work/login/</span>'; },
  190. },
  191. ];
  192. function picker(options, pick) {
  193. return options.map((o, i) => `<b data-pick="${i === 0 && pick === 'A'}">${String.fromCharCode(65 + i)}. ${esc(o)}</b>`).join(' ');
  194. }
  195. function msgShell(step) {
  196. const role = step.role ? `<span class="msg__role">${esc(step.role)}</span>` : '';
  197. return `<div class="msg msg__enter">
  198. <div class="msg__avatar" data-a="${step.kind}">${esc(step.avatar)}</div>
  199. <div>
  200. <div class="msg__head"><span class="msg__name">${esc(step.name)}</span>${role}<span class="msg__time">${esc(step.time || '')}</span></div>
  201. <div class="msg__body"></div>
  202. </div>
  203. </div>`;
  204. }
  205. async function runHero() {
  206. heroRunner.stop();
  207. const gen = heroRunner.gen;
  208. heroTimeline.innerHTML = '';
  209. const status = $('#heroStatus');
  210. const progress = $('#heroProgress');
  211. progress.style.width = '0%';
  212. for (let i = 0; i < HERO_SCRIPT.length; i++) {
  213. const step = HERO_SCRIPT[i];
  214. if (!heroRunner.alive(gen)) return;
  215. const wrap = document.createElement('div');
  216. wrap.innerHTML = msgShell(step);
  217. const msg = wrap.firstElementChild;
  218. const body = $('.msg__body', msg);
  219. heroTimeline.append(msg);
  220. status.textContent = step.think ? `${step.name} 正在思考…` : `${step.name} 正在回复`;
  221. if (step.think) {
  222. const panel = document.createElement('div');
  223. panel.className = 'cx cx-think';
  224. panel.dataset.open = 'false';
  225. panel.innerHTML =
  226. '<button class="cx-think__head" type="button" aria-expanded="false"><i class="ph-bold ph-caret-right" aria-hidden="true"></i>' +
  227. '<span class="label">思考中</span><span class="secs">0.0s</span></button>' +
  228. '<div class="cx-think__body"><div class="cx-think__inner"><div class="cx-think__text"></div></div></div>';
  229. body.append(panel);
  230. const head = $('.cx-think__head', panel);
  231. const label = $('.label', panel);
  232. const secs = $('.secs', panel);
  233. const txt = $('.cx-think__text', panel);
  234. head.addEventListener('click', () => {
  235. const open = panel.dataset.open === 'true';
  236. panel.dataset.open = String(!open);
  237. head.setAttribute('aria-expanded', String(!open));
  238. });
  239. const total = step.think;
  240. const started = performance.now();
  241. while (performance.now() - started < total) {
  242. if (!heroRunner.alive(gen)) return;
  243. if (heroPaused) { await sleep(120); continue; }
  244. const el = performance.now() - started;
  245. secs.textContent = `${(el / 1000).toFixed(1)}s`;
  246. const shown = Math.floor((el / total) * step.thinkText.length);
  247. txt.textContent = step.thinkText.slice(0, shown);
  248. await sleep(60);
  249. }
  250. if (!heroRunner.alive(gen)) return;
  251. secs.textContent = `${(total / 1000).toFixed(1)}s`;
  252. label.textContent = `已思考 ${(total / 1000).toFixed(1)}s`;
  253. txt.textContent = step.thinkText;
  254. }
  255. // 普通文本:逐字输出 + 光标
  256. if (step.type) {
  257. const slot = document.createElement('div');
  258. body.append(slot);
  259. await streamType(slot, step.type, gen);
  260. } else {
  261. await streamText(body, step.text, gen);
  262. }
  263. if (!heroRunner.alive(gen)) return;
  264. if (step.tools) {
  265. const flow = document.createElement('div');
  266. flow.className = 'cx cx-tools';
  267. body.append(flow);
  268. for (const t of step.tools) {
  269. if (!heroRunner.alive(gen)) return;
  270. await runTool(flow, t, gen);
  271. }
  272. }
  273. if (step.diff) {
  274. const d = renderDiff(body, step.diff, gen, heroRunner, () => heroPaused);
  275. await d;
  276. }
  277. if (step.deliverable) {
  278. const card = document.createElement('div');
  279. card.className = 'cx card';
  280. card.style.padding = '12px';
  281. card.innerHTML =
  282. '<div style="display:flex;align-items:center;gap:8px;font-size:13px;margin-bottom:8px">' +
  283. '<i class="ph-fill ph-package" aria-hidden="true" style="color:var(--accent)"></i>' +
  284. '<b style="font-weight:600">成果已归档</b><span class="pill pill--mute" style="margin-inline-start:auto">3 份</span></div>' +
  285. '<div class="cx-tool" style="padding:4px 0"><i class="ph ph-file-ts"></i><span class="target">Login.tsx</span><span class="meta">v3</span></div>' +
  286. '<div class="cx-tool" style="padding:4px 0"><i class="ph ph-file-html"></i><span class="target">auth-api.md</span><span class="meta">v2</span></div>' +
  287. '<div class="cx-tool" style="padding:4px 0"><i class="ph ph-file-py"></i><span class="target">test_login.py</span><span class="meta">v1</span></div>';
  288. body.append(card);
  289. card.classList.add('msg__enter');
  290. }
  291. progress.style.width = `${Math.round(((i + 1) / HERO_SCRIPT.length) * 100)}%`;
  292. scrollTimeline();
  293. const pause = await heroRunner.wait(1100, gen);
  294. if (!pause) return;
  295. }
  296. status.textContent = '演示完成 · 可重播';
  297. }
  298. function scrollTimeline() {
  299. heroTimeline.scrollTo({ top: heroTimeline.scrollHeight, behavior: reduced.matches ? 'auto' : 'smooth' });
  300. }
  301. /** 逐字流式输出纯文本 */
  302. async function streamText(host, text, gen) {
  303. const out = document.createElement('span');
  304. const caret = document.createElement('span');
  305. caret.className = 'caret';
  306. host.append(out, caret);
  307. if (reduced.matches) { out.textContent = text; caret.remove(); return; }
  308. let i = 0;
  309. while (i < text.length) {
  310. if (!heroRunner.alive(gen)) return;
  311. if (heroPaused) { await sleep(120); continue; }
  312. const ch = text[i];
  313. out.textContent += ch;
  314. i++;
  315. // 标点后停顿更久,模拟真实模型的节奏
  316. const delay = /[,。:;!?]/.test(ch) ? 190 : /[、,]/.test(ch) ? 120 : 28 + Math.random() * 22;
  317. await sleep(delay);
  318. }
  319. caret.remove();
  320. }
  321. /** 富文本:先构建真实结构,再保留布局地逐字揭示(未揭示的字保持占位宽,避免行高跳动) */
  322. async function streamType(host, builder, gen) {
  323. const full = document.createElement('div');
  324. builder(full);
  325. host.append(full);
  326. if (reduced.matches) return;
  327. const tokens = [];
  328. (function walk(node) {
  329. Array.from(node.childNodes).forEach((child) => {
  330. if (child.nodeType === 3) {
  331. const frag = document.createDocumentFragment();
  332. Array.from(child.textContent).forEach((ch) => {
  333. const t = document.createElement('span');
  334. t.textContent = ch;
  335. t.style.color = 'transparent';
  336. frag.append(t);
  337. tokens.push(t);
  338. });
  339. child.replaceWith(frag);
  340. } else if (child.nodeType === 1 && child.tagName !== 'BR') {
  341. walk(child);
  342. }
  343. });
  344. })(full);
  345. const caret = document.createElement('span');
  346. caret.className = 'caret';
  347. for (const t of tokens) {
  348. if (!heroRunner.alive(gen)) { caret.remove(); return; }
  349. if (heroPaused) { await sleep(120); continue; }
  350. // 光标跟随到当前字之后
  351. t.after(caret);
  352. t.style.color = '';
  353. await sleep(/[,。:;!?]/.test(t.textContent) ? 150 : 22 + Math.random() * 16);
  354. }
  355. caret.remove();
  356. }
  357. /** 单条工具调用:运行中 → 完成 */
  358. async function runTool(host, tool, gen) {
  359. const row = document.createElement('div');
  360. row.className = 'cx-tool msg__enter';
  361. row.dataset.state = 'run';
  362. row.innerHTML =
  363. `<i class="ph ph-${esc(tool.icon)}" aria-hidden="true"></i>` +
  364. `<div><span class="verb">${esc(tool.verb)}</span> <span class="target">${esc(tool.target)}</span></div>` +
  365. `<span class="status"><span class="spinner"></span></span>`;
  366. host.append(row);
  367. scrollTimeline();
  368. await heroRunner.wait(tool.ms, gen);
  369. if (!heroRunner.alive(gen)) return;
  370. row.dataset.state = tool.state || 'ok';
  371. $('.status', row).innerHTML = '<i class="ph-bold ph-check" aria-hidden="true"></i>';
  372. const meta = document.createElement('span');
  373. meta.className = 'meta';
  374. meta.style.marginInlineStart = '8px';
  375. meta.textContent = tool.meta;
  376. $('div', row).append(meta);
  377. }
  378. /** 代码 diff:逐行插入 */
  379. function renderDiff(host, lines, gen, runner, pausedFn) {
  380. const box = document.createElement('div');
  381. box.className = 'cx cx-diff msg__enter';
  382. const add = lines.filter((l) => l.k === 'add').length;
  383. const del = lines.filter((l) => l.k === 'del').length;
  384. box.innerHTML =
  385. '<div class="cx-diff__bar"><i class="ph ph-file-ts" aria-hidden="true"></i>src/api/sms.ts' +
  386. `<span class="stat"><span class="add">+${add}</span><span class="del">−${del}</span></span></div>` +
  387. '<div class="cx-diff__body"></div>';
  388. const body = $('.cx-diff__body', box);
  389. host.append(box);
  390. return (async () => {
  391. for (const l of lines) {
  392. if (!runner.alive(gen)) return;
  393. while (pausedFn()) await runner.wait(120, gen);
  394. const sign = l.k === 'add' ? '+' : l.k === 'del' ? '−' : ' ';
  395. const row = document.createElement('div');
  396. row.className = 'dl';
  397. row.dataset.k = l.k;
  398. row.innerHTML = `<span class="n">${l.n}</span><span class="s">${sign}</span><span class="t">${colorize(esc(l.t))}</span>`;
  399. body.append(row);
  400. scrollTimeline();
  401. await runner.wait(110, gen);
  402. }
  403. })();
  404. }
  405. /** 极简语法着色:只处理字符串与关键字,够用且不引入依赖 */
  406. function colorize(t) {
  407. return t
  408. .replace(/(&#39;.*?&#39;|&quot;.*?&quot;)/g, '<span class="str">$1</span>')
  409. .replace(/\b(export|async|function|const|await|return|if|throw|new)\b/g, '<span class="k">$1</span>');
  410. }
  411. // 播放控制
  412. $('#heroReplay').addEventListener('click', runHero);
  413. $('#heroPlay').addEventListener('click', (e) => {
  414. heroPaused = !heroPaused;
  415. e.currentTarget.innerHTML = heroPaused
  416. ? '<i class="ph ph-play" aria-hidden="true"></i>'
  417. : '<i class="ph ph-pause" aria-hidden="true"></i>';
  418. e.currentTarget.setAttribute('aria-label', heroPaused ? '继续演示' : '暂停演示');
  419. $('#heroStatus').textContent = heroPaused ? '已暂停' : '正在演示工作组协作';
  420. });
  421. // 进入视口才播放,离开视口暂停
  422. const heroObserver = new IntersectionObserver((entries) => {
  423. entries.forEach((e) => {
  424. if (e.isIntersecting) { if (!heroStarted) { heroStarted = true; runHero(); } }
  425. });
  426. }, { threshold: 0.25 });
  427. let heroStarted = false;
  428. heroObserver.observe($('#stage'));
  429. /* ------------------------------------------------------- 实验室 1:流式打字 */
  430. const STREAM_LINES = [
  431. '登录页已经改完,三处需要你知道:\n',
  432. '1. 手机号用 /^1\\d{10}$/ 校验,失败会直接提示「手机号格式错误」;\n',
  433. '2. 验证码有效期按你选的 10 分钟写入 CONTRACT.md;\n',
  434. '3. 记住登录用 HttpOnly Cookie,前端不存 Token。\n',
  435. '接口文档在成果库的 auth-api.md,可以直接对照联调。',
  436. ];
  437. (function initStreamLab() {
  438. const out = $('#streamOut');
  439. const runner = new Runner();
  440. const panel = $('#labStream');
  441. let visible = false;
  442. async function play() {
  443. runner.stop();
  444. const gen = runner.gen;
  445. out.textContent = '';
  446. if (reduced.matches) {
  447. out.textContent = STREAM_LINES.join('');
  448. await runner.wait(600, gen);
  449. if (runner.alive(gen)) out.textContent = '';
  450. return;
  451. }
  452. const caret = document.createElement('span');
  453. caret.className = 'caret';
  454. for (const line of STREAM_LINES) {
  455. const span = document.createElement('span');
  456. out.append(span);
  457. if (!out.contains(caret)) out.append(caret);
  458. for (const ch of line) {
  459. if (!runner.alive(gen)) return;
  460. span.textContent += ch;
  461. await sleep(/[,。:;!?\n]/.test(ch) ? 160 : 24 + Math.random() * 20);
  462. }
  463. }
  464. if (!runner.alive(gen)) return;
  465. caret.remove();
  466. await sleep(2600);
  467. if (visible) play();
  468. }
  469. $('.replay', panel).addEventListener('click', play);
  470. new IntersectionObserver(([e]) => {
  471. visible = e.isIntersecting;
  472. if (visible) play(); else runner.stop();
  473. }, { threshold: 0.4 }).observe(panel);
  474. })();
  475. /* ------------------------------------------------------- 实验室 2:工具流水 */
  476. const TOOL_SCRIPT = [
  477. { icon: 'magnifying-glass', verb: '搜索', target: 'src/**/*.tsx', meta: '12 个文件', ms: 900 },
  478. { icon: 'file-text', verb: '读取', target: 'src/login/Login.tsx', meta: '184 行', ms: 700 },
  479. { icon: 'pencil-simple', verb: '改写', target: 'src/login/Login.tsx', meta: '+18 −4', ms: 1500 },
  480. { icon: 'terminal-window', verb: '执行', target: 'npm run test:login', meta: '6 通过', ms: 1900 },
  481. { icon: 'play-circle', verb: '构建', target: 'npm run build', meta: '4.2s', ms: 1400 },
  482. { icon: 'package', verb: '归档', target: '成果库 / 登录页改造', meta: '3 份', ms: 900 },
  483. ];
  484. (function initToolLab() {
  485. const host = $('#toolFlow');
  486. const panel = $('#labTools');
  487. const runner = new Runner();
  488. let visible = false;
  489. async function play() {
  490. runner.stop();
  491. const gen = runner.gen;
  492. host.innerHTML = '';
  493. for (const t of TOOL_SCRIPT) {
  494. if (!runner.alive(gen)) return;
  495. const row = document.createElement('div');
  496. row.className = 'cx-tool msg__enter';
  497. row.dataset.state = 'run';
  498. row.innerHTML =
  499. `<i class="ph ph-${t.icon}" aria-hidden="true"></i>` +
  500. `<div><span class="verb">${esc(t.verb)}</span> <span class="target">${esc(t.target)}</span></div>` +
  501. '<span class="status"><span class="spinner"></span></span>';
  502. host.append(row);
  503. await runner.wait(t.ms, gen);
  504. if (!runner.alive(gen)) return;
  505. row.dataset.state = 'ok';
  506. $('.status', row).innerHTML = '<i class="ph-bold ph-check" aria-hidden="true"></i>';
  507. const meta = document.createElement('span');
  508. meta.className = 'meta';
  509. meta.style.marginInlineStart = '8px';
  510. meta.textContent = t.meta;
  511. $('div', row).append(meta);
  512. }
  513. if (!runner.alive(gen)) return;
  514. await runner.wait(2800, gen);
  515. if (visible) play();
  516. }
  517. $('.replay', panel).addEventListener('click', play);
  518. new IntersectionObserver(([e]) => {
  519. visible = e.isIntersecting;
  520. if (visible) play(); else runner.stop();
  521. }, { threshold: 0.4 }).observe(panel);
  522. })();
  523. /* ------------------------------------------------------- 实验室 3:推理折叠 */
  524. const THINK_TEXT =
  525. '需求是改登录页,但没说登录方式和记住登录的规则。\n' +
  526. '先看现有实现:Login.tsx 里用的是账号密码,sms.ts 里已经有发码函数,可以复用。\n' +
  527. '风险点:如果只改前端,后端仍然按账号密码校验,会直接 401。\n' +
  528. '所以先定契约:手机号 → 发码 → 校验码换 Token,三处同时改。\n' +
  529. '验证方式:打开手机号格式错误、验证码过期、重复发送三个用例,确保三条分支都有提示。';
  530. (function initThinkLab() {
  531. const panel = $('#labThink');
  532. const btn = $('#thinkPanel .cx-think__head');
  533. const body = $('#thinkPanel');
  534. const label = $('#thinkLabel');
  535. const secs = $('#thinkSecs');
  536. const text = $('#thinkText');
  537. const spinner = $('.spinner', panel);
  538. const runner = new Runner();
  539. let visible = false;
  540. btn.addEventListener('click', () => {
  541. const open = body.dataset.open !== 'true';
  542. body.dataset.open = String(open);
  543. btn.setAttribute('aria-expanded', String(open));
  544. });
  545. async function play() {
  546. runner.stop();
  547. const gen = runner.gen;
  548. text.textContent = '';
  549. body.dataset.open = 'false';
  550. btn.setAttribute('aria-expanded', 'false');
  551. spinner.style.display = '';
  552. label.textContent = '思考中…';
  553. secs.textContent = '0.0s';
  554. if (reduced.matches) {
  555. text.textContent = THINK_TEXT;
  556. spinner.style.display = 'none';
  557. label.textContent = '已思考 4.2s';
  558. secs.textContent = '4.2s';
  559. body.dataset.open = 'true';
  560. btn.setAttribute('aria-expanded', 'true');
  561. return;
  562. }
  563. const total = 4200;
  564. const start = performance.now();
  565. while (performance.now() - start < total) {
  566. if (!runner.alive(gen)) return;
  567. const el = performance.now() - start;
  568. secs.textContent = `${(el / 1000).toFixed(1)}s`;
  569. const shown = Math.floor((el / total) * THINK_TEXT.length);
  570. text.textContent = THINK_TEXT.slice(0, shown);
  571. await sleep(48);
  572. }
  573. if (!runner.alive(gen)) return;
  574. text.textContent = THINK_TEXT;
  575. spinner.style.display = 'none';
  576. label.textContent = '已思考 4.2s';
  577. secs.textContent = '4.2s';
  578. // 自动展开一小段时间,让观众看到内容,再自动收起成折叠态
  579. body.dataset.open = 'true';
  580. btn.setAttribute('aria-expanded', 'true');
  581. await sleep(4200);
  582. if (!runner.alive(gen)) return;
  583. body.dataset.open = 'false';
  584. btn.setAttribute('aria-expanded', 'false');
  585. await sleep(2200);
  586. if (visible) play();
  587. }
  588. $('.replay', panel).addEventListener('click', play);
  589. new IntersectionObserver(([e]) => {
  590. visible = e.isIntersecting;
  591. if (visible) play(); else runner.stop();
  592. }, { threshold: 0.4 }).observe(panel);
  593. })();
  594. /* ------------------------------------------------------- 实验室 4:代码 diff */
  595. const DIFF_LINES = [
  596. { k: 'ctx', n: 12, t: 'export function Login() {' },
  597. { k: 'ctx', n: 13, t: " const [account, setAccount] = useState('')" },
  598. { k: 'del', n: 14, t: " const [password, setPassword] = useState('')" },
  599. { k: 'add', n: 14, t: " const [phone, setPhone] = useState('')" },
  600. { k: 'add', n: 15, t: " const [code, setCode] = useState('')" },
  601. { k: 'ctx', n: 16, t: '' },
  602. { k: 'del', n: 17, t: " await api.login(account, password)" },
  603. { k: 'add', n: 17, t: " if (!/^1\\d{10}$/.test(phone)) return setError('手机号格式错误')" },
  604. { k: 'add', n: 18, t: " await api.verifyCode({ phone, code, ttl: 600 })" },
  605. { k: 'add', n: 19, t: " await session.remember('httpOnly', { days: 30 })" },
  606. { k: 'ctx', n: 20, t: " go('/desktop/chat')" },
  607. { k: 'ctx', n: 21, t: '}' },
  608. ];
  609. (function initDiffLab() {
  610. const panel = $('#labDiff');
  611. const bodyEl = $('#diffBody');
  612. const addEl = $('#diffAdd');
  613. const delEl = $('#diffDel');
  614. const runner = new Runner();
  615. let visible = false;
  616. async function play() {
  617. runner.stop();
  618. const gen = runner.gen;
  619. bodyEl.innerHTML = '';
  620. let add = 0;
  621. let del = 0;
  622. addEl.textContent = '+0';
  623. delEl.textContent = '−0';
  624. for (const l of DIFF_LINES) {
  625. if (!runner.alive(gen)) return;
  626. const sign = l.k === 'add' ? '+' : l.k === 'del' ? '−' : ' ';
  627. const row = document.createElement('div');
  628. row.className = 'dl';
  629. row.dataset.k = l.k;
  630. row.innerHTML = `<span class="n">${l.n}</span><span class="s">${sign}</span><span class="t">${colorize(esc(l.t))}</span>`;
  631. bodyEl.append(row);
  632. if (l.k === 'add') addEl.textContent = `+${++add}`;
  633. if (l.k === 'del') delEl.textContent = `−${++del}`;
  634. await runner.wait(reduced.matches ? 20 : 260, gen);
  635. }
  636. if (!runner.alive(gen)) return;
  637. await runner.wait(3000, gen);
  638. if (visible) play();
  639. }
  640. $('.replay', panel).addEventListener('click', play);
  641. new IntersectionObserver(([e]) => {
  642. visible = e.isIntersecting;
  643. if (visible) play(); else runner.stop();
  644. }, { threshold: 0.4 }).observe(panel);
  645. })();
  646. /* ------------------------------------------------------- 产品预览 */
  647. const LIST = {
  648. chat: [
  649. { id: 'g1', name: '登录页改造', sub: 'lj:交付完成,文件在成果库', time: '12:41', unread: 2, lead: true },
  650. { id: 'lj', name: 'lj', sub: '组长 · 在线,可派活', time: '12:30', online: true },
  651. { id: 'lt', name: 'lj-test', sub: '组员 · 正在写接口', time: '12:28', online: true },
  652. { id: 'fe', name: 'fe-2', sub: '组员 · 空闲', time: '昨天' },
  653. ],
  654. groups: [
  655. { id: 'g1', name: '登录页改造', sub: '3 位成员 · 契约 v3', time: '12:41', unread: 2, lead: true },
  656. { id: 'g2', name: '成果库检索', sub: '2 位成员 · 契约 v1', time: '10:12' },
  657. { id: 'g3', name: '移动端整改', sub: '2 位成员 · 已完成', time: '昨天' },
  658. ],
  659. workbench: [
  660. { id: 'w1', name: '运行中 2', sub: '工作组 realtime', time: '' },
  661. { id: 'w2', name: '异常 1', sub: 'fe-2 · 构建失败', time: '' },
  662. { id: 'w3', name: '今日交付 3', sub: '自动归档', time: '' },
  663. ],
  664. deliverables: [
  665. { id: 'd1', name: 'Login.tsx', sub: '登录页改造 · v3', time: '12:41' },
  666. { id: 'd2', name: 'auth-api.md', sub: '登录页改造 · v2', time: '12:38' },
  667. { id: 'd3', name: 'test_login.py', sub: '登录页改造 · v1', time: '12:35' },
  668. { id: 'd4', name: 'search.md', sub: '成果库检索 · v1', time: '10:20' },
  669. ],
  670. tasks: [
  671. { id: 't1', name: '验证码接口', sub: '进行中 · lj-test', time: '' },
  672. { id: 't2', name: '登录页前端', sub: '进行中 · lj', time: '' },
  673. { id: 't3', name: '联调契约', sub: '已完成 · lj', time: '' },
  674. ],
  675. };
  676. const HEAD = {
  677. chat: '<b>登录页改造</b><span class="pill pill--live">3 位成员</span>',
  678. groups: '<b>登录页改造</b><span class="pill pill--live">组长 lj</span><span class="pill pill--mute">契约 v3</span>',
  679. workbench: '<b>工作台</b><span class="pill pill--mute">今日</span>',
  680. deliverables: '<b>成果库</b><span class="pill pill--mute">按项目</span>',
  681. tasks: '<b>任务看板</b><span class="pill pill--mute">本组</span>',
  682. };
  683. const AVATAR = { g1: 'lj', g2: 'lj', g3: 'fe', lj: 'lj', lt: 'lt', fe: 'fe', d1: 'ts', d2: 'md', d3: 'py', d4: 'md', t1: 'lt', t2: 'lj', t3: 'lj', w1: '≡', w2: '!', w3: '✓' };
  684. let currentView = 'chat';
  685. let currentItem = 'g1';
  686. const chatLog = new Map();
  687. chatLog.set('g1', [
  688. { from: 'me', text: '@lj @lj-test 登录页改成手机号 + 验证码' },
  689. { from: 'lj', lead: true, text: '先确认三件事:记住登录状态、验证码有效期、给谁用。' },
  690. { from: 'lt', name: 'lj-test', text: '后端开工,先按 CONTRACT.md 出接口。' },
  691. { from: 'lj', lead: true, text: '【交付】登录页已完成,文件在 /opt/data/work/登录页改造/' },
  692. ]);
  693. function renderList() {
  694. const items = LIST[currentView];
  695. const titles = { chat: '会话', groups: '工作组', workbench: '概览', deliverables: '成果', tasks: '任务' };
  696. $('#listTitle').innerHTML =
  697. `${titles[currentView]} <span id="listCount">${items.length}</span>` +
  698. '<span class="acts"><button type="button" aria-label="新建" data-act="new"><i class="ph ph-pencil-simple"></i></button></span>';
  699. $('#listTitle [data-act="new"]').addEventListener('click', () => toast('新建入口在完整版中可用'));
  700. $('#listItems').innerHTML = items.map((it) => {
  701. const av = AVATAR[it.id] || it.name.slice(0, 2);
  702. return `<button class="li" type="button" data-id="${it.id}" aria-current="${it.id === currentItem}">
  703. <span class="li__av" ${it.lead ? 'data-lead="true"' : ''} ${it.online ? 'data-on="true"' : ''}>${esc(av)}</span>
  704. <span class="li__t"><b>${esc(it.name)}</b><span>${esc(it.sub)}</span></span>
  705. <span class="li__m"><time>${esc(it.time)}</time>${it.unread ? `<em>${it.unread}</em>` : ''}</span>
  706. </button>`;
  707. }).join('');
  708. $$('.li', $('#listItems')).forEach((b) => b.addEventListener('click', () => {
  709. currentItem = b.dataset.id;
  710. renderList();
  711. renderMain();
  712. }));
  713. }
  714. function renderMain() {
  715. const head = $('#mainHead');
  716. head.innerHTML = HEAD[currentView] + '<span class="acts"><button type="button" aria-label="搜索" data-act="search"><i class="ph ph-magnifying-glass"></i></button><button type="button" aria-label="更多" data-act="more"><i class="ph ph-dots-three"></i></button></span>';
  717. const body = $('#mainBody');
  718. const composer = $('#composer');
  719. composer.style.display = currentView === 'chat' || currentView === 'groups' ? '' : 'none';
  720. if (currentView === 'chat' || currentView === 'groups') {
  721. const log = chatLog.get(currentItem) || [];
  722. body.innerHTML = log.map((m) => {
  723. const isMe = m.from === 'me';
  724. const name = isMe ? '我' : m.from === 'lj' ? 'lj' : (m.name || m.from);
  725. const role = m.lead ? '组长' : '';
  726. const av = isMe ? '我' : m.from;
  727. return `<div class="msg" style="border-bottom:0;padding-block:4px">
  728. <div class="msg__avatar" data-a="${isMe ? 'me' : m.lead ? 'lead' : 'dev'}">${esc(av)}</div>
  729. <div><div class="msg__head"><span class="msg__name">${esc(name)}</span>${role ? `<span class="msg__role">${role}</span>` : ''}</div>
  730. <div class="msg__body" style="font-size:12.5px">${esc(m.text)}</div></div>
  731. </div>`;
  732. }).join('') + (currentItem === 'lt' ? '<div class="msg" style="border-bottom:0"><div class="msg__avatar" data-a="dev">lt</div><div><div class="msg__head"><span class="msg__name">lj-test</span><span class="msg__role">组员</span></div><div class="msg__body" style="font-size:12.5px;color:var(--muted)">我在写验证码接口,稍等。</div></div></div>' : '');
  733. body.scrollTop = body.scrollHeight;
  734. return;
  735. }
  736. if (currentView === 'workbench') {
  737. body.innerHTML = `
  738. <div class="metric-grid">
  739. <div class="metric"><span>运行中工作组</span><b>2</b><em>+1 今日</em></div>
  740. <div class="metric"><span>今日交付</span><b>3</b><em>+2 较昨日</em></div>
  741. <div class="metric"><span>平均首次响应</span><b>3.2s</b><em>−0.4s</em></div>
  742. <div class="metric"><span>异常</span><b>1</b><em data-down="true">fe-2 构建失败</em></div>
  743. </div>
  744. <div class="view"><div class="view__title">实时活动<span class="pill pill--live">live</span></div>
  745. <div class="cx-tool"><i class="ph ph-pencil-simple"></i><div><span class="verb">lj 改写</span> <span class="target">Login.tsx</span></div><span class="status"><i class="ph-bold ph-check" style="color:var(--success)"></i></span></div>
  746. <div class="cx-tool"><i class="ph ph-terminal-window"></i><div><span class="verb">lj-test 执行</span> <span class="target">npm test</span></div><span class="status"><i class="ph-bold ph-check" style="color:var(--success)"></i></span></div>
  747. <div class="cx-tool"><i class="ph ph-warning"></i><div><span class="verb">fe-2 构建失败</span> <span class="target">缺少依赖 zod</span></div><span class="status"><i class="ph-bold ph-x" style="color:var(--danger)"></i></span></div>
  748. </div>`;
  749. return;
  750. }
  751. if (currentView === 'deliverables') {
  752. body.innerHTML = `<div class="view"><div class="view__title">登录页改造<span class="pill pill--mute">3 份</span></div>` +
  753. LIST.deliverables.slice(0, 3).map((d) => {
  754. const icon = d.name.endsWith('.py') ? 'file-py' : d.name.endsWith('.md') ? 'file-md' : 'file-ts';
  755. return `<div class="file-row"><i class="ph ph-${icon}"></i><div><b>${esc(d.name)}</b><div><span>${esc(d.sub)} · ${esc(d.time)}</span></div></div><button type="button" aria-label="下载"><i class="ph ph-download-simple"></i></button></div>`;
  756. }).join('') +
  757. `<div class="view__title" style="margin-top:8px">成果库检索<span class="pill pill--mute">1 份</span></div>
  758. <div class="file-row"><i class="ph ph-file-md"></i><div><b>search.md</b><div><span>成果库检索 · v1 · 10:20</span></div></div><button type="button" aria-label="下载"><i class="ph ph-download-simple"></i></button></div>
  759. </div>`;
  760. $$('.file-row button', body).forEach((b) => b.addEventListener('click', () => toast('已开始下载(演示)')));
  761. return;
  762. }
  763. // 任务看板
  764. body.innerHTML = `<div class="board">
  765. <div class="col"><div class="col__h"><span>进行中</span><span>2</span></div>
  766. <div class="tcard"><b>验证码接口</b><span>lj-test</span><span class="tag">后端</span></div>
  767. <div class="tcard"><b>登录页前端</b><span>lj</span><span class="tag">前端</span></div>
  768. </div>
  769. <div class="col"><div class="col__h"><span>待验收</span><span>1</span></div>
  770. <div class="tcard"><b>联调契约</b><span>lj → 我</span><span class="tag">文档</span></div>
  771. </div>
  772. <div class="col"><div class="col__h"><span>已完成</span><span>1</span></div>
  773. <div class="tcard" style="opacity:.6"><b>需求确认</b><span>12:31</span><span class="tag">口径</span></div>
  774. </div>
  775. </div>`;
  776. }
  777. $$('.appnav button').forEach((b) => b.addEventListener('click', () => {
  778. $$('.appnav button').forEach((x) => x.setAttribute('aria-selected', String(x === b)));
  779. currentView = b.dataset.view;
  780. currentItem = LIST[currentView][0].id;
  781. renderList();
  782. renderMain();
  783. }));
  784. const input = $('#composerInput');
  785. const sendBtn = $('#sendBtn');
  786. input.addEventListener('input', () => {
  787. sendBtn.disabled = !input.value.trim();
  788. input.style.height = 'auto';
  789. input.style.height = `${Math.min(input.scrollHeight, 96)}px`;
  790. });
  791. input.addEventListener('keydown', (e) => {
  792. if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); $('#composer').requestSubmit(); }
  793. });
  794. $('#composer').addEventListener('submit', async (e) => {
  795. e.preventDefault();
  796. const text = input.value.trim();
  797. if (!text) return;
  798. const key = currentItem === 'lt' ? 'lt' : currentItem;
  799. const log = chatLog.get(currentItem) || chatLog.get('g1');
  800. const list = chatLog.get(key) || log;
  801. list.push({ from: 'me', text });
  802. chatLog.set(key, list);
  803. input.value = '';
  804. input.style.height = 'auto';
  805. sendBtn.disabled = true;
  806. renderMain();
  807. // 演示用的自动回复:先思考,后逐字输出
  808. if (currentView === 'chat' || currentView === 'groups') {
  809. const body = $('#mainBody');
  810. const typing = document.createElement('div');
  811. typing.className = 'msg';
  812. typing.style.borderBottom = '0';
  813. typing.style.paddingBlock = '4px';
  814. typing.innerHTML = `<div class="msg__avatar" data-a="lead">lj</div><div><div class="msg__head"><span class="msg__name">lj</span><span class="msg__role">组长</span></div><div class="msg__body" style="font-size:12.5px;color:var(--muted)"><span class="spinner" style="width:11px;height:11px;vertical-align:-1px"></span> 正在思考…</div></div>`;
  815. body.append(typing);
  816. body.scrollTop = body.scrollHeight;
  817. const reply = text.includes('@')
  818. ? '收到,我先把口径确认下来,再叫组员分头开工。'
  819. : '收到。这个我来安排,有需要你拍板的地方会先问你。';
  820. await sleep(reduced.matches ? 200 : 1400);
  821. const target = typing.querySelector('.msg__body');
  822. target.style.color = '';
  823. target.textContent = '';
  824. const caret = document.createElement('span');
  825. caret.className = 'caret';
  826. target.append(caret);
  827. for (const ch of reply) {
  828. caret.before(document.createTextNode(ch));
  829. body.scrollTop = body.scrollHeight;
  830. await sleep(reduced.matches ? 0 : 26);
  831. }
  832. caret.remove();
  833. list.push({ from: 'lj', lead: true, text: reply });
  834. chatLog.set(key, list);
  835. }
  836. });
  837. renderList();
  838. renderMain();
  839. $$('#mainBody, #listItems').forEach((el) => el.style.touchAction = 'manipulation');
  840. /* ------------------------------------------------------- 桌面样机自适应缩放
  841. 手机宽度下整台样机按比例缩小展示,而不是把三栏挤扁(内部保持桌面布局)。 */
  842. (() => {
  843. const el = $('[data-fit]');
  844. if (!el) return;
  845. const fit = () => {
  846. const w = el.parentElement.clientWidth;
  847. const k = Math.min(1, w / 760);
  848. el.style.zoom = k < 0.999 ? String(k) : '';
  849. el.style.width = k < 0.999 ? '760px' : '';
  850. };
  851. new ResizeObserver(fit).observe(el.parentElement);
  852. fit();
  853. })();
  854. /* ------------------------------------------------------- 数字滚动 */
  855. const statObserver = new IntersectionObserver((entries) => {
  856. entries.forEach((e) => {
  857. if (!e.isIntersecting) return;
  858. statObserver.unobserve(e.target);
  859. $$('[data-count]', e.target).forEach((el) => {
  860. const target = Number(el.dataset.count);
  861. const suffix = el.dataset.suffix || '';
  862. if (reduced.matches) { el.textContent = `${target}${suffix}`; return; }
  863. const start = performance.now();
  864. const dur = 1100;
  865. const tick = (now) => {
  866. const p = Math.min(1, (now - start) / dur);
  867. const eased = 1 - Math.pow(1 - p, 3);
  868. el.textContent = `${Math.round(target * eased)}${suffix}`;
  869. if (p < 1) requestAnimationFrame(tick);
  870. };
  871. requestAnimationFrame(tick);
  872. });
  873. });
  874. }, { threshold: 0.4 });
  875. $$('.stats').forEach((el) => statObserver.observe(el));
  876. /* ------------------------------------------------------- 页脚占位链接 */
  877. $$('[data-demo]').forEach((a) => a.addEventListener('click', (e) => {
  878. e.preventDefault();
  879. toast(`「${a.dataset.demo}」在演示版中未接入,请联系邀请你的人。`);
  880. }));
  881. })();