| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948 |
- /* =========================================================================
- Fmode 落地页 · 交互层
- 结构:工具函数 → 主题 / 导航 → 滚动揭示 → 标签墙 → Hero 协作演示
- → 实验室四面板 → 产品预览 → 数字 → 页脚提示
- 全部动画由「可中断的状态机」驱动,支持暂停 / 重播 / 离开视口即停,
- 并统一服从 prefers-reduced-motion(降级为直接呈现终态)。
- ========================================================================= */
- (() => {
- 'use strict';
- const reduced = matchMedia('(prefers-reduced-motion: reduce)');
- const $ = (s, r = document) => r.querySelector(s);
- const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
- const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
- /** 可取消的等待:每次重播都会 bump 掉上一轮的所有定时器 */
- class Runner {
- constructor() { this.gen = 0; }
- stop() { this.gen++; }
- alive(gen) { return gen === this.gen; }
- wait(ms, gen) {
- return new Promise((resolve) => {
- setTimeout(() => resolve(this.alive(gen)), reduced.matches ? Math.min(ms, 30) : ms);
- });
- }
- }
- /* ---------------------------------------------------------------- 主题 */
- const root = document.documentElement;
- const themeBtn = $('#themeBtn');
- const THEME_KEY = 'fmode-landing-theme';
- function applyTheme(t) {
- root.dataset.theme = t;
- const meta = $('meta[name="theme-color"]');
- if (meta) meta.setAttribute('content', t === 'light' ? '#ffffff' : '#000000');
- themeBtn.setAttribute('aria-pressed', String(t === 'light'));
- themeBtn.setAttribute('aria-label', t === 'light' ? '切换到深色主题' : '切换到浅色主题');
- }
- applyTheme(root.dataset.theme || 'dark');
- themeBtn.addEventListener('click', () => {
- const next = root.dataset.theme === 'light' ? 'dark' : 'light';
- applyTheme(next);
- try { localStorage.setItem(THEME_KEY, next); } catch (e) { /* 忽略 */ }
- });
- /* ---------------------------------------------------------------- 提示 */
- const toastArea = $('#toastArea');
- function toast(text) {
- const el = document.createElement('div');
- el.className = 'toast';
- el.innerHTML = `<i class="ph-fill ph-check-circle" aria-hidden="true"></i><span>${esc(text)}</span>`;
- toastArea.append(el);
- setTimeout(() => el.remove(), 2600);
- }
- /* ------------------------------------------------------- 导航:滚动状态 */
- const nav = $('#nav');
- const onScroll = () => { nav.dataset.scrolled = String(scrollY > 8); };
- addEventListener('scroll', onScroll, { passive: true });
- onScroll();
- // 当前所在区块高亮
- const anchorLinks = $$('.nav__link');
- const sections = anchorLinks
- .map((a) => document.getElementById(a.getAttribute('href').slice(1)))
- .filter(Boolean);
- const navObserver = new IntersectionObserver((entries) => {
- entries.forEach((e) => {
- if (!e.isIntersecting) return;
- anchorLinks.forEach((a) => a.setAttribute('aria-current', String(a.getAttribute('href') === `#${e.target.id}`)));
- });
- }, { rootMargin: '-45% 0px -50% 0px' });
- sections.forEach((s) => navObserver.observe(s));
- /* ------------------------------------------------------- 移动端菜单 */
- const menuBtn = $('#menuBtn');
- const menu = $('#menu');
- const setMenu = (open) => {
- menu.dataset.open = String(open);
- menu.setAttribute('aria-hidden', String(!open));
- menuBtn.setAttribute('aria-expanded', String(open));
- menuBtn.setAttribute('aria-label', open ? '关闭菜单' : '打开菜单');
- menuBtn.querySelector('i').className = open ? 'ph ph-x' : 'ph ph-list';
- document.body.style.overflow = open ? 'hidden' : '';
- };
- menuBtn.addEventListener('click', () => setMenu(menu.dataset.open !== 'true'));
- $$('a', menu).forEach((a) => a.addEventListener('click', () => setMenu(false)));
- addEventListener('keydown', (e) => { if (e.key === 'Escape' && menu.dataset.open === 'true') setMenu(false); });
- /* ------------------------------------------------------- 通用滚动揭示 */
- const revealObserver = new IntersectionObserver((entries) => {
- entries.forEach((e) => {
- if (!e.isIntersecting) return;
- e.target.classList.add('is-in');
- revealObserver.unobserve(e.target);
- });
- }, { rootMargin: '0px 0px -12% 0px', threshold: 0.12 });
- $$('.reveal').forEach((el) => revealObserver.observe(el));
- /* ------------------------------------------------------- 标签墙:逐词点亮 */
- (() => {
- const host = $('#manifestoText');
- if (!host) return;
- const text = host.dataset.text;
- host.textContent = '';
- const chars = Array.from(text);
- chars.forEach((ch) => {
- if (ch === '。') return; // 句末标点不单独成词
- const span = document.createElement('span');
- span.className = 'w';
- span.textContent = ch;
- host.append(span);
- });
- const words = $$('.w', host);
- let raf = 0;
- const update = () => {
- raf = 0;
- const rect = host.getBoundingClientRect();
- // 触发线:视口高度的 62% 位置
- const line = innerHeight * 0.62;
- const start = rect.top;
- const total = rect.height;
- if (total <= 0) return;
- const progress = Math.min(1, Math.max(0, (line - start) / total));
- const upto = reduced.matches ? words.length : Math.floor(progress * words.length * 1.06);
- words.forEach((w, i) => {
- const on = i < upto ? 'true' : 'false';
- if (w.dataset.on !== on) w.dataset.on = on;
- });
- };
- addEventListener('scroll', () => { if (!raf) raf = requestAnimationFrame(update); }, { passive: true });
- addEventListener('resize', update);
- update();
- })();
- /* ------------------------------------------------------- Hero 协作演示 */
- const heroTimeline = $('#heroTimeline');
- const heroRunner = new Runner();
- let heroPaused = false;
- const HERO_SCRIPT = [
- { kind: 'me', name: '我', avatar: '我', time: '12:30', text: '@lj @lj-test 登录页改成手机号 + 验证码' },
- {
- kind: 'lead', name: 'lj', role: '组长', avatar: 'lj', think: 1800, time: '12:31',
- thinkText: '这条需求有三个歧义点:登录方式、是否记住登录、给谁用。先一次问清,再拆分工,避免组员各做一套。',
- type: (h) => {
- h.innerHTML =
- '我是组长 lj。先确认三件事(选一下就行):<br>' +
- '1. 要记住登录状态吗? ' + picker(['要', '不用'], 'A') + '<br>' +
- '2. 验证码有效期? ' + picker(['5 分钟', '10 分钟'], 'A') + '<br>' +
- '3. 给谁用? ' + picker(['内部员工', '外部客户'], 'A');
- },
- },
- { kind: 'me', name: '我', avatar: '我', time: '12:32', text: 'A、A、A' },
- {
- kind: 'lead', name: 'lj', role: '组长', avatar: 'lj', time: '12:33',
- type: (h) => {
- h.innerHTML =
- '<b>【方案】</b>组长 lj / 组员 lj-test<br>' +
- '分工:lj 做前端,lj-test 做验证码接口<br>' +
- '交付:<span class="hl">可运行的登录页</span> + 接口文档<br>' +
- '先定契约再各自开工。';
- },
- tools: [
- { icon: 'file-text', verb: '写入', target: 'CONTRACT.md', meta: '1.2 KB', ms: 900 },
- ],
- },
- {
- kind: 'dev', name: 'lj-test', role: '组员', avatar: 'lt', time: '12:36',
- type: (h) => { h.textContent = '后端开工,先按 CONTRACT.md 出接口。'; },
- tools: [
- { icon: 'magnifying-glass', verb: '搜索', target: 'sms_code', meta: '3 处', ms: 700 },
- { icon: 'pencil-simple', verb: '改写', target: 'src/api/sms.ts', meta: '+42', ms: 1400 },
- { icon: 'play-circle', verb: '运行', target: 'npm test', meta: '6 通过', ms: 1600, state: 'ok' },
- ],
- diff: [
- { k: 'ctx', n: 14, t: 'export async function sendCode(phone: string) {' },
- { k: 'del', n: 15, t: ' await sms.send(phone)' },
- { k: 'add', n: 15, t: ' if (!/^1\\d{10}$/.test(phone)) throw new Error(\'手机号格式错误\')' },
- { k: 'add', n: 16, t: ' const code = randomCode(6)' },
- { k: 'add', n: 17, t: ' await sms.send(phone, code, { ttl: 600 })' },
- { k: 'ctx', n: 18, t: ' return { ok: true }' },
- { k: 'ctx', n: 19, t: '}' },
- ],
- },
- {
- kind: 'lead', name: 'lj', role: '组长', avatar: 'lj', time: '12:39',
- type: (h) => {
- h.innerHTML = '前端这边验证码有效期按 10 分钟做,和契约对齐了。页面已经能跑。';
- },
- tools: [
- { icon: 'pencil-simple', verb: '改写', target: 'src/login/Login.tsx', meta: '+18 −4', ms: 1300 },
- { icon: 'package', verb: '归档', target: '成果库 / 登录页改造', meta: '3 份', ms: 1100 },
- ],
- deliverable: true,
- },
- {
- kind: 'lead', name: 'lj', role: '组长', avatar: 'lj', time: '12:42',
- type: (h) => { h.innerHTML = '<b>【交付】</b>登录页已完成,文件在 <span class="hl">/opt/data/work/login/</span>'; },
- },
- ];
- function picker(options, pick) {
- return options.map((o, i) => `<b data-pick="${i === 0 && pick === 'A'}">${String.fromCharCode(65 + i)}. ${esc(o)}</b>`).join(' ');
- }
- function msgShell(step) {
- const role = step.role ? `<span class="msg__role">${esc(step.role)}</span>` : '';
- return `<div class="msg msg__enter">
- <div class="msg__avatar" data-a="${step.kind}">${esc(step.avatar)}</div>
- <div>
- <div class="msg__head"><span class="msg__name">${esc(step.name)}</span>${role}<span class="msg__time">${esc(step.time || '')}</span></div>
- <div class="msg__body"></div>
- </div>
- </div>`;
- }
- async function runHero() {
- heroRunner.stop();
- const gen = heroRunner.gen;
- heroTimeline.innerHTML = '';
- const status = $('#heroStatus');
- const progress = $('#heroProgress');
- progress.style.width = '0%';
- for (let i = 0; i < HERO_SCRIPT.length; i++) {
- const step = HERO_SCRIPT[i];
- if (!heroRunner.alive(gen)) return;
- const wrap = document.createElement('div');
- wrap.innerHTML = msgShell(step);
- const msg = wrap.firstElementChild;
- const body = $('.msg__body', msg);
- heroTimeline.append(msg);
- status.textContent = step.think ? `${step.name} 正在思考…` : `${step.name} 正在回复`;
- if (step.think) {
- const panel = document.createElement('div');
- panel.className = 'cx cx-think';
- panel.dataset.open = 'false';
- panel.innerHTML =
- '<button class="cx-think__head" type="button" aria-expanded="false"><i class="ph-bold ph-caret-right" aria-hidden="true"></i>' +
- '<span class="label">思考中</span><span class="secs">0.0s</span></button>' +
- '<div class="cx-think__body"><div class="cx-think__inner"><div class="cx-think__text"></div></div></div>';
- body.append(panel);
- const head = $('.cx-think__head', panel);
- const label = $('.label', panel);
- const secs = $('.secs', panel);
- const txt = $('.cx-think__text', panel);
- head.addEventListener('click', () => {
- const open = panel.dataset.open === 'true';
- panel.dataset.open = String(!open);
- head.setAttribute('aria-expanded', String(!open));
- });
- const total = step.think;
- const started = performance.now();
- while (performance.now() - started < total) {
- if (!heroRunner.alive(gen)) return;
- if (heroPaused) { await sleep(120); continue; }
- const el = performance.now() - started;
- secs.textContent = `${(el / 1000).toFixed(1)}s`;
- const shown = Math.floor((el / total) * step.thinkText.length);
- txt.textContent = step.thinkText.slice(0, shown);
- await sleep(60);
- }
- if (!heroRunner.alive(gen)) return;
- secs.textContent = `${(total / 1000).toFixed(1)}s`;
- label.textContent = `已思考 ${(total / 1000).toFixed(1)}s`;
- txt.textContent = step.thinkText;
- }
- // 普通文本:逐字输出 + 光标
- if (step.type) {
- const slot = document.createElement('div');
- body.append(slot);
- await streamType(slot, step.type, gen);
- } else {
- await streamText(body, step.text, gen);
- }
- if (!heroRunner.alive(gen)) return;
- if (step.tools) {
- const flow = document.createElement('div');
- flow.className = 'cx cx-tools';
- body.append(flow);
- for (const t of step.tools) {
- if (!heroRunner.alive(gen)) return;
- await runTool(flow, t, gen);
- }
- }
- if (step.diff) {
- const d = renderDiff(body, step.diff, gen, heroRunner, () => heroPaused);
- await d;
- }
- if (step.deliverable) {
- const card = document.createElement('div');
- card.className = 'cx card';
- card.style.padding = '12px';
- card.innerHTML =
- '<div style="display:flex;align-items:center;gap:8px;font-size:13px;margin-bottom:8px">' +
- '<i class="ph-fill ph-package" aria-hidden="true" style="color:var(--accent)"></i>' +
- '<b style="font-weight:600">成果已归档</b><span class="pill pill--mute" style="margin-inline-start:auto">3 份</span></div>' +
- '<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>' +
- '<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>' +
- '<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>';
- body.append(card);
- card.classList.add('msg__enter');
- }
- progress.style.width = `${Math.round(((i + 1) / HERO_SCRIPT.length) * 100)}%`;
- scrollTimeline();
- const pause = await heroRunner.wait(1100, gen);
- if (!pause) return;
- }
- status.textContent = '演示完成 · 可重播';
- }
- function scrollTimeline() {
- heroTimeline.scrollTo({ top: heroTimeline.scrollHeight, behavior: reduced.matches ? 'auto' : 'smooth' });
- }
- /** 逐字流式输出纯文本 */
- async function streamText(host, text, gen) {
- const out = document.createElement('span');
- const caret = document.createElement('span');
- caret.className = 'caret';
- host.append(out, caret);
- if (reduced.matches) { out.textContent = text; caret.remove(); return; }
- let i = 0;
- while (i < text.length) {
- if (!heroRunner.alive(gen)) return;
- if (heroPaused) { await sleep(120); continue; }
- const ch = text[i];
- out.textContent += ch;
- i++;
- // 标点后停顿更久,模拟真实模型的节奏
- const delay = /[,。:;!?]/.test(ch) ? 190 : /[、,]/.test(ch) ? 120 : 28 + Math.random() * 22;
- await sleep(delay);
- }
- caret.remove();
- }
- /** 富文本:先构建真实结构,再保留布局地逐字揭示(未揭示的字保持占位宽,避免行高跳动) */
- async function streamType(host, builder, gen) {
- const full = document.createElement('div');
- builder(full);
- host.append(full);
- if (reduced.matches) return;
- const tokens = [];
- (function walk(node) {
- Array.from(node.childNodes).forEach((child) => {
- if (child.nodeType === 3) {
- const frag = document.createDocumentFragment();
- Array.from(child.textContent).forEach((ch) => {
- const t = document.createElement('span');
- t.textContent = ch;
- t.style.color = 'transparent';
- frag.append(t);
- tokens.push(t);
- });
- child.replaceWith(frag);
- } else if (child.nodeType === 1 && child.tagName !== 'BR') {
- walk(child);
- }
- });
- })(full);
- const caret = document.createElement('span');
- caret.className = 'caret';
- for (const t of tokens) {
- if (!heroRunner.alive(gen)) { caret.remove(); return; }
- if (heroPaused) { await sleep(120); continue; }
- // 光标跟随到当前字之后
- t.after(caret);
- t.style.color = '';
- await sleep(/[,。:;!?]/.test(t.textContent) ? 150 : 22 + Math.random() * 16);
- }
- caret.remove();
- }
- /** 单条工具调用:运行中 → 完成 */
- async function runTool(host, tool, gen) {
- const row = document.createElement('div');
- row.className = 'cx-tool msg__enter';
- row.dataset.state = 'run';
- row.innerHTML =
- `<i class="ph ph-${esc(tool.icon)}" aria-hidden="true"></i>` +
- `<div><span class="verb">${esc(tool.verb)}</span> <span class="target">${esc(tool.target)}</span></div>` +
- `<span class="status"><span class="spinner"></span></span>`;
- host.append(row);
- scrollTimeline();
- await heroRunner.wait(tool.ms, gen);
- if (!heroRunner.alive(gen)) return;
- row.dataset.state = tool.state || 'ok';
- $('.status', row).innerHTML = '<i class="ph-bold ph-check" aria-hidden="true"></i>';
- const meta = document.createElement('span');
- meta.className = 'meta';
- meta.style.marginInlineStart = '8px';
- meta.textContent = tool.meta;
- $('div', row).append(meta);
- }
- /** 代码 diff:逐行插入 */
- function renderDiff(host, lines, gen, runner, pausedFn) {
- const box = document.createElement('div');
- box.className = 'cx cx-diff msg__enter';
- const add = lines.filter((l) => l.k === 'add').length;
- const del = lines.filter((l) => l.k === 'del').length;
- box.innerHTML =
- '<div class="cx-diff__bar"><i class="ph ph-file-ts" aria-hidden="true"></i>src/api/sms.ts' +
- `<span class="stat"><span class="add">+${add}</span><span class="del">−${del}</span></span></div>` +
- '<div class="cx-diff__body"></div>';
- const body = $('.cx-diff__body', box);
- host.append(box);
- return (async () => {
- for (const l of lines) {
- if (!runner.alive(gen)) return;
- while (pausedFn()) await runner.wait(120, gen);
- const sign = l.k === 'add' ? '+' : l.k === 'del' ? '−' : ' ';
- const row = document.createElement('div');
- row.className = 'dl';
- row.dataset.k = l.k;
- row.innerHTML = `<span class="n">${l.n}</span><span class="s">${sign}</span><span class="t">${colorize(esc(l.t))}</span>`;
- body.append(row);
- scrollTimeline();
- await runner.wait(110, gen);
- }
- })();
- }
- /** 极简语法着色:只处理字符串与关键字,够用且不引入依赖 */
- function colorize(t) {
- return t
- .replace(/('.*?'|".*?")/g, '<span class="str">$1</span>')
- .replace(/\b(export|async|function|const|await|return|if|throw|new)\b/g, '<span class="k">$1</span>');
- }
- // 播放控制
- $('#heroReplay').addEventListener('click', runHero);
- $('#heroPlay').addEventListener('click', (e) => {
- heroPaused = !heroPaused;
- e.currentTarget.innerHTML = heroPaused
- ? '<i class="ph ph-play" aria-hidden="true"></i>'
- : '<i class="ph ph-pause" aria-hidden="true"></i>';
- e.currentTarget.setAttribute('aria-label', heroPaused ? '继续演示' : '暂停演示');
- $('#heroStatus').textContent = heroPaused ? '已暂停' : '正在演示工作组协作';
- });
- // 进入视口才播放,离开视口暂停
- const heroObserver = new IntersectionObserver((entries) => {
- entries.forEach((e) => {
- if (e.isIntersecting) { if (!heroStarted) { heroStarted = true; runHero(); } }
- });
- }, { threshold: 0.25 });
- let heroStarted = false;
- heroObserver.observe($('#stage'));
- /* ------------------------------------------------------- 实验室 1:流式打字 */
- const STREAM_LINES = [
- '登录页已经改完,三处需要你知道:\n',
- '1. 手机号用 /^1\\d{10}$/ 校验,失败会直接提示「手机号格式错误」;\n',
- '2. 验证码有效期按你选的 10 分钟写入 CONTRACT.md;\n',
- '3. 记住登录用 HttpOnly Cookie,前端不存 Token。\n',
- '接口文档在成果库的 auth-api.md,可以直接对照联调。',
- ];
- (function initStreamLab() {
- const out = $('#streamOut');
- const runner = new Runner();
- const panel = $('#labStream');
- let visible = false;
- async function play() {
- runner.stop();
- const gen = runner.gen;
- out.textContent = '';
- if (reduced.matches) {
- out.textContent = STREAM_LINES.join('');
- await runner.wait(600, gen);
- if (runner.alive(gen)) out.textContent = '';
- return;
- }
- const caret = document.createElement('span');
- caret.className = 'caret';
- for (const line of STREAM_LINES) {
- const span = document.createElement('span');
- out.append(span);
- if (!out.contains(caret)) out.append(caret);
- for (const ch of line) {
- if (!runner.alive(gen)) return;
- span.textContent += ch;
- await sleep(/[,。:;!?\n]/.test(ch) ? 160 : 24 + Math.random() * 20);
- }
- }
- if (!runner.alive(gen)) return;
- caret.remove();
- await sleep(2600);
- if (visible) play();
- }
- $('.replay', panel).addEventListener('click', play);
- new IntersectionObserver(([e]) => {
- visible = e.isIntersecting;
- if (visible) play(); else runner.stop();
- }, { threshold: 0.4 }).observe(panel);
- })();
- /* ------------------------------------------------------- 实验室 2:工具流水 */
- const TOOL_SCRIPT = [
- { icon: 'magnifying-glass', verb: '搜索', target: 'src/**/*.tsx', meta: '12 个文件', ms: 900 },
- { icon: 'file-text', verb: '读取', target: 'src/login/Login.tsx', meta: '184 行', ms: 700 },
- { icon: 'pencil-simple', verb: '改写', target: 'src/login/Login.tsx', meta: '+18 −4', ms: 1500 },
- { icon: 'terminal-window', verb: '执行', target: 'npm run test:login', meta: '6 通过', ms: 1900 },
- { icon: 'play-circle', verb: '构建', target: 'npm run build', meta: '4.2s', ms: 1400 },
- { icon: 'package', verb: '归档', target: '成果库 / 登录页改造', meta: '3 份', ms: 900 },
- ];
- (function initToolLab() {
- const host = $('#toolFlow');
- const panel = $('#labTools');
- const runner = new Runner();
- let visible = false;
- async function play() {
- runner.stop();
- const gen = runner.gen;
- host.innerHTML = '';
- for (const t of TOOL_SCRIPT) {
- if (!runner.alive(gen)) return;
- const row = document.createElement('div');
- row.className = 'cx-tool msg__enter';
- row.dataset.state = 'run';
- row.innerHTML =
- `<i class="ph ph-${t.icon}" aria-hidden="true"></i>` +
- `<div><span class="verb">${esc(t.verb)}</span> <span class="target">${esc(t.target)}</span></div>` +
- '<span class="status"><span class="spinner"></span></span>';
- host.append(row);
- await runner.wait(t.ms, gen);
- if (!runner.alive(gen)) return;
- row.dataset.state = 'ok';
- $('.status', row).innerHTML = '<i class="ph-bold ph-check" aria-hidden="true"></i>';
- const meta = document.createElement('span');
- meta.className = 'meta';
- meta.style.marginInlineStart = '8px';
- meta.textContent = t.meta;
- $('div', row).append(meta);
- }
- if (!runner.alive(gen)) return;
- await runner.wait(2800, gen);
- if (visible) play();
- }
- $('.replay', panel).addEventListener('click', play);
- new IntersectionObserver(([e]) => {
- visible = e.isIntersecting;
- if (visible) play(); else runner.stop();
- }, { threshold: 0.4 }).observe(panel);
- })();
- /* ------------------------------------------------------- 实验室 3:推理折叠 */
- const THINK_TEXT =
- '需求是改登录页,但没说登录方式和记住登录的规则。\n' +
- '先看现有实现:Login.tsx 里用的是账号密码,sms.ts 里已经有发码函数,可以复用。\n' +
- '风险点:如果只改前端,后端仍然按账号密码校验,会直接 401。\n' +
- '所以先定契约:手机号 → 发码 → 校验码换 Token,三处同时改。\n' +
- '验证方式:打开手机号格式错误、验证码过期、重复发送三个用例,确保三条分支都有提示。';
- (function initThinkLab() {
- const panel = $('#labThink');
- const btn = $('#thinkPanel .cx-think__head');
- const body = $('#thinkPanel');
- const label = $('#thinkLabel');
- const secs = $('#thinkSecs');
- const text = $('#thinkText');
- const spinner = $('.spinner', panel);
- const runner = new Runner();
- let visible = false;
- btn.addEventListener('click', () => {
- const open = body.dataset.open !== 'true';
- body.dataset.open = String(open);
- btn.setAttribute('aria-expanded', String(open));
- });
- async function play() {
- runner.stop();
- const gen = runner.gen;
- text.textContent = '';
- body.dataset.open = 'false';
- btn.setAttribute('aria-expanded', 'false');
- spinner.style.display = '';
- label.textContent = '思考中…';
- secs.textContent = '0.0s';
- if (reduced.matches) {
- text.textContent = THINK_TEXT;
- spinner.style.display = 'none';
- label.textContent = '已思考 4.2s';
- secs.textContent = '4.2s';
- body.dataset.open = 'true';
- btn.setAttribute('aria-expanded', 'true');
- return;
- }
- const total = 4200;
- const start = performance.now();
- while (performance.now() - start < total) {
- if (!runner.alive(gen)) return;
- const el = performance.now() - start;
- secs.textContent = `${(el / 1000).toFixed(1)}s`;
- const shown = Math.floor((el / total) * THINK_TEXT.length);
- text.textContent = THINK_TEXT.slice(0, shown);
- await sleep(48);
- }
- if (!runner.alive(gen)) return;
- text.textContent = THINK_TEXT;
- spinner.style.display = 'none';
- label.textContent = '已思考 4.2s';
- secs.textContent = '4.2s';
- // 自动展开一小段时间,让观众看到内容,再自动收起成折叠态
- body.dataset.open = 'true';
- btn.setAttribute('aria-expanded', 'true');
- await sleep(4200);
- if (!runner.alive(gen)) return;
- body.dataset.open = 'false';
- btn.setAttribute('aria-expanded', 'false');
- await sleep(2200);
- if (visible) play();
- }
- $('.replay', panel).addEventListener('click', play);
- new IntersectionObserver(([e]) => {
- visible = e.isIntersecting;
- if (visible) play(); else runner.stop();
- }, { threshold: 0.4 }).observe(panel);
- })();
- /* ------------------------------------------------------- 实验室 4:代码 diff */
- const DIFF_LINES = [
- { k: 'ctx', n: 12, t: 'export function Login() {' },
- { k: 'ctx', n: 13, t: " const [account, setAccount] = useState('')" },
- { k: 'del', n: 14, t: " const [password, setPassword] = useState('')" },
- { k: 'add', n: 14, t: " const [phone, setPhone] = useState('')" },
- { k: 'add', n: 15, t: " const [code, setCode] = useState('')" },
- { k: 'ctx', n: 16, t: '' },
- { k: 'del', n: 17, t: " await api.login(account, password)" },
- { k: 'add', n: 17, t: " if (!/^1\\d{10}$/.test(phone)) return setError('手机号格式错误')" },
- { k: 'add', n: 18, t: " await api.verifyCode({ phone, code, ttl: 600 })" },
- { k: 'add', n: 19, t: " await session.remember('httpOnly', { days: 30 })" },
- { k: 'ctx', n: 20, t: " go('/desktop/chat')" },
- { k: 'ctx', n: 21, t: '}' },
- ];
- (function initDiffLab() {
- const panel = $('#labDiff');
- const bodyEl = $('#diffBody');
- const addEl = $('#diffAdd');
- const delEl = $('#diffDel');
- const runner = new Runner();
- let visible = false;
- async function play() {
- runner.stop();
- const gen = runner.gen;
- bodyEl.innerHTML = '';
- let add = 0;
- let del = 0;
- addEl.textContent = '+0';
- delEl.textContent = '−0';
- for (const l of DIFF_LINES) {
- if (!runner.alive(gen)) return;
- const sign = l.k === 'add' ? '+' : l.k === 'del' ? '−' : ' ';
- const row = document.createElement('div');
- row.className = 'dl';
- row.dataset.k = l.k;
- row.innerHTML = `<span class="n">${l.n}</span><span class="s">${sign}</span><span class="t">${colorize(esc(l.t))}</span>`;
- bodyEl.append(row);
- if (l.k === 'add') addEl.textContent = `+${++add}`;
- if (l.k === 'del') delEl.textContent = `−${++del}`;
- await runner.wait(reduced.matches ? 20 : 260, gen);
- }
- if (!runner.alive(gen)) return;
- await runner.wait(3000, gen);
- if (visible) play();
- }
- $('.replay', panel).addEventListener('click', play);
- new IntersectionObserver(([e]) => {
- visible = e.isIntersecting;
- if (visible) play(); else runner.stop();
- }, { threshold: 0.4 }).observe(panel);
- })();
- /* ------------------------------------------------------- 产品预览 */
- const LIST = {
- chat: [
- { id: 'g1', name: '登录页改造', sub: 'lj:交付完成,文件在成果库', time: '12:41', unread: 2, lead: true },
- { id: 'lj', name: 'lj', sub: '组长 · 在线,可派活', time: '12:30', online: true },
- { id: 'lt', name: 'lj-test', sub: '组员 · 正在写接口', time: '12:28', online: true },
- { id: 'fe', name: 'fe-2', sub: '组员 · 空闲', time: '昨天' },
- ],
- groups: [
- { id: 'g1', name: '登录页改造', sub: '3 位成员 · 契约 v3', time: '12:41', unread: 2, lead: true },
- { id: 'g2', name: '成果库检索', sub: '2 位成员 · 契约 v1', time: '10:12' },
- { id: 'g3', name: '移动端整改', sub: '2 位成员 · 已完成', time: '昨天' },
- ],
- workbench: [
- { id: 'w1', name: '运行中 2', sub: '工作组 realtime', time: '' },
- { id: 'w2', name: '异常 1', sub: 'fe-2 · 构建失败', time: '' },
- { id: 'w3', name: '今日交付 3', sub: '自动归档', time: '' },
- ],
- deliverables: [
- { id: 'd1', name: 'Login.tsx', sub: '登录页改造 · v3', time: '12:41' },
- { id: 'd2', name: 'auth-api.md', sub: '登录页改造 · v2', time: '12:38' },
- { id: 'd3', name: 'test_login.py', sub: '登录页改造 · v1', time: '12:35' },
- { id: 'd4', name: 'search.md', sub: '成果库检索 · v1', time: '10:20' },
- ],
- tasks: [
- { id: 't1', name: '验证码接口', sub: '进行中 · lj-test', time: '' },
- { id: 't2', name: '登录页前端', sub: '进行中 · lj', time: '' },
- { id: 't3', name: '联调契约', sub: '已完成 · lj', time: '' },
- ],
- };
- const HEAD = {
- chat: '<b>登录页改造</b><span class="pill pill--live">3 位成员</span>',
- groups: '<b>登录页改造</b><span class="pill pill--live">组长 lj</span><span class="pill pill--mute">契约 v3</span>',
- workbench: '<b>工作台</b><span class="pill pill--mute">今日</span>',
- deliverables: '<b>成果库</b><span class="pill pill--mute">按项目</span>',
- tasks: '<b>任务看板</b><span class="pill pill--mute">本组</span>',
- };
- 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: '✓' };
- let currentView = 'chat';
- let currentItem = 'g1';
- const chatLog = new Map();
- chatLog.set('g1', [
- { from: 'me', text: '@lj @lj-test 登录页改成手机号 + 验证码' },
- { from: 'lj', lead: true, text: '先确认三件事:记住登录状态、验证码有效期、给谁用。' },
- { from: 'lt', name: 'lj-test', text: '后端开工,先按 CONTRACT.md 出接口。' },
- { from: 'lj', lead: true, text: '【交付】登录页已完成,文件在 /opt/data/work/登录页改造/' },
- ]);
- function renderList() {
- const items = LIST[currentView];
- const titles = { chat: '会话', groups: '工作组', workbench: '概览', deliverables: '成果', tasks: '任务' };
- $('#listTitle').innerHTML =
- `${titles[currentView]} <span id="listCount">${items.length}</span>` +
- '<span class="acts"><button type="button" aria-label="新建" data-act="new"><i class="ph ph-pencil-simple"></i></button></span>';
- $('#listTitle [data-act="new"]').addEventListener('click', () => toast('新建入口在完整版中可用'));
- $('#listItems').innerHTML = items.map((it) => {
- const av = AVATAR[it.id] || it.name.slice(0, 2);
- return `<button class="li" type="button" data-id="${it.id}" aria-current="${it.id === currentItem}">
- <span class="li__av" ${it.lead ? 'data-lead="true"' : ''} ${it.online ? 'data-on="true"' : ''}>${esc(av)}</span>
- <span class="li__t"><b>${esc(it.name)}</b><span>${esc(it.sub)}</span></span>
- <span class="li__m"><time>${esc(it.time)}</time>${it.unread ? `<em>${it.unread}</em>` : ''}</span>
- </button>`;
- }).join('');
- $$('.li', $('#listItems')).forEach((b) => b.addEventListener('click', () => {
- currentItem = b.dataset.id;
- renderList();
- renderMain();
- }));
- }
- function renderMain() {
- const head = $('#mainHead');
- 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>';
- const body = $('#mainBody');
- const composer = $('#composer');
- composer.style.display = currentView === 'chat' || currentView === 'groups' ? '' : 'none';
- if (currentView === 'chat' || currentView === 'groups') {
- const log = chatLog.get(currentItem) || [];
- body.innerHTML = log.map((m) => {
- const isMe = m.from === 'me';
- const name = isMe ? '我' : m.from === 'lj' ? 'lj' : (m.name || m.from);
- const role = m.lead ? '组长' : '';
- const av = isMe ? '我' : m.from;
- return `<div class="msg" style="border-bottom:0;padding-block:4px">
- <div class="msg__avatar" data-a="${isMe ? 'me' : m.lead ? 'lead' : 'dev'}">${esc(av)}</div>
- <div><div class="msg__head"><span class="msg__name">${esc(name)}</span>${role ? `<span class="msg__role">${role}</span>` : ''}</div>
- <div class="msg__body" style="font-size:12.5px">${esc(m.text)}</div></div>
- </div>`;
- }).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>' : '');
- body.scrollTop = body.scrollHeight;
- return;
- }
- if (currentView === 'workbench') {
- body.innerHTML = `
- <div class="metric-grid">
- <div class="metric"><span>运行中工作组</span><b>2</b><em>+1 今日</em></div>
- <div class="metric"><span>今日交付</span><b>3</b><em>+2 较昨日</em></div>
- <div class="metric"><span>平均首次响应</span><b>3.2s</b><em>−0.4s</em></div>
- <div class="metric"><span>异常</span><b>1</b><em data-down="true">fe-2 构建失败</em></div>
- </div>
- <div class="view"><div class="view__title">实时活动<span class="pill pill--live">live</span></div>
- <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>
- <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>
- <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>
- </div>`;
- return;
- }
- if (currentView === 'deliverables') {
- body.innerHTML = `<div class="view"><div class="view__title">登录页改造<span class="pill pill--mute">3 份</span></div>` +
- LIST.deliverables.slice(0, 3).map((d) => {
- const icon = d.name.endsWith('.py') ? 'file-py' : d.name.endsWith('.md') ? 'file-md' : 'file-ts';
- 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>`;
- }).join('') +
- `<div class="view__title" style="margin-top:8px">成果库检索<span class="pill pill--mute">1 份</span></div>
- <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>
- </div>`;
- $$('.file-row button', body).forEach((b) => b.addEventListener('click', () => toast('已开始下载(演示)')));
- return;
- }
- // 任务看板
- body.innerHTML = `<div class="board">
- <div class="col"><div class="col__h"><span>进行中</span><span>2</span></div>
- <div class="tcard"><b>验证码接口</b><span>lj-test</span><span class="tag">后端</span></div>
- <div class="tcard"><b>登录页前端</b><span>lj</span><span class="tag">前端</span></div>
- </div>
- <div class="col"><div class="col__h"><span>待验收</span><span>1</span></div>
- <div class="tcard"><b>联调契约</b><span>lj → 我</span><span class="tag">文档</span></div>
- </div>
- <div class="col"><div class="col__h"><span>已完成</span><span>1</span></div>
- <div class="tcard" style="opacity:.6"><b>需求确认</b><span>12:31</span><span class="tag">口径</span></div>
- </div>
- </div>`;
- }
- $$('.appnav button').forEach((b) => b.addEventListener('click', () => {
- $$('.appnav button').forEach((x) => x.setAttribute('aria-selected', String(x === b)));
- currentView = b.dataset.view;
- currentItem = LIST[currentView][0].id;
- renderList();
- renderMain();
- }));
- const input = $('#composerInput');
- const sendBtn = $('#sendBtn');
- input.addEventListener('input', () => {
- sendBtn.disabled = !input.value.trim();
- input.style.height = 'auto';
- input.style.height = `${Math.min(input.scrollHeight, 96)}px`;
- });
- input.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); $('#composer').requestSubmit(); }
- });
- $('#composer').addEventListener('submit', async (e) => {
- e.preventDefault();
- const text = input.value.trim();
- if (!text) return;
- const key = currentItem === 'lt' ? 'lt' : currentItem;
- const log = chatLog.get(currentItem) || chatLog.get('g1');
- const list = chatLog.get(key) || log;
- list.push({ from: 'me', text });
- chatLog.set(key, list);
- input.value = '';
- input.style.height = 'auto';
- sendBtn.disabled = true;
- renderMain();
- // 演示用的自动回复:先思考,后逐字输出
- if (currentView === 'chat' || currentView === 'groups') {
- const body = $('#mainBody');
- const typing = document.createElement('div');
- typing.className = 'msg';
- typing.style.borderBottom = '0';
- typing.style.paddingBlock = '4px';
- 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>`;
- body.append(typing);
- body.scrollTop = body.scrollHeight;
- const reply = text.includes('@')
- ? '收到,我先把口径确认下来,再叫组员分头开工。'
- : '收到。这个我来安排,有需要你拍板的地方会先问你。';
- await sleep(reduced.matches ? 200 : 1400);
- const target = typing.querySelector('.msg__body');
- target.style.color = '';
- target.textContent = '';
- const caret = document.createElement('span');
- caret.className = 'caret';
- target.append(caret);
- for (const ch of reply) {
- caret.before(document.createTextNode(ch));
- body.scrollTop = body.scrollHeight;
- await sleep(reduced.matches ? 0 : 26);
- }
- caret.remove();
- list.push({ from: 'lj', lead: true, text: reply });
- chatLog.set(key, list);
- }
- });
- renderList();
- renderMain();
- $$('#mainBody, #listItems').forEach((el) => el.style.touchAction = 'manipulation');
- /* ------------------------------------------------------- 桌面样机自适应缩放
- 手机宽度下整台样机按比例缩小展示,而不是把三栏挤扁(内部保持桌面布局)。 */
- (() => {
- const el = $('[data-fit]');
- if (!el) return;
- const fit = () => {
- const w = el.parentElement.clientWidth;
- const k = Math.min(1, w / 760);
- el.style.zoom = k < 0.999 ? String(k) : '';
- el.style.width = k < 0.999 ? '760px' : '';
- };
- new ResizeObserver(fit).observe(el.parentElement);
- fit();
- })();
- /* ------------------------------------------------------- 数字滚动 */
- const statObserver = new IntersectionObserver((entries) => {
- entries.forEach((e) => {
- if (!e.isIntersecting) return;
- statObserver.unobserve(e.target);
- $$('[data-count]', e.target).forEach((el) => {
- const target = Number(el.dataset.count);
- const suffix = el.dataset.suffix || '';
- if (reduced.matches) { el.textContent = `${target}${suffix}`; return; }
- const start = performance.now();
- const dur = 1100;
- const tick = (now) => {
- const p = Math.min(1, (now - start) / dur);
- const eased = 1 - Math.pow(1 - p, 3);
- el.textContent = `${Math.round(target * eased)}${suffix}`;
- if (p < 1) requestAnimationFrame(tick);
- };
- requestAnimationFrame(tick);
- });
- });
- }, { threshold: 0.4 });
- $$('.stats').forEach((el) => statObserver.observe(el));
- /* ------------------------------------------------------- 页脚占位链接 */
- $$('[data-demo]').forEach((a) => a.addEventListener('click', (e) => {
- e.preventDefault();
- toast(`「${a.dataset.demo}」在演示版中未接入,请联系邀请你的人。`);
- }));
- })();
|