首次提交:脑晴测 H5 项目

包含前端源码、预构建产物(dist/)、零依赖 Node.js 服务器(server.mjs)、
Cloudflare Worker 版本(worker/)、部署文档(DEPLOY.md)和交接文档。

技术总监可直接 clone 后运行 node server.mjs 启动,无需安装 React 或 build。
DEEPSEEK_API_KEY 通过环境变量注入,未纳入仓库。
This commit is contained in:
MingNian
2026-07-28 10:24:47 +08:00
commit b0fce840a3
152 changed files with 8536 additions and 0 deletions

View File

@@ -0,0 +1,242 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
async function drag(page: Page, locator: Locator, deltaX: number, deltaY: number, steps = 8) {
const box = await locator.boundingBox();
if (!box) throw new Error("Drag target has no bounding box");
const startX = box.x + box.width / 2;
const startY = box.y + box.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
for (let step = 1; step <= steps; step += 1) {
await page.mouse.move(
startX + (deltaX * step) / steps,
startY + (deltaY * step) / steps,
);
await page.waitForTimeout(8);
}
await page.mouse.up();
}
test.beforeEach(async ({ page }) => {
await page.goto("/tests/runtime-fixture.html");
});
test("horizontal intent stays in Carousel and cannot create parent momentum", async ({ page }) => {
const carousel = page.locator(".fixture-carousel");
const card = page.locator(".carousel-card").nth(1);
const parent = page.getByTestId("mobile-scroll");
await expect(carousel).not.toHaveAttribute("data-scroll-drag", "ignore");
await drag(page, card, -130, 14, 5);
const afterRelease = await carousel.evaluate((element) => element.scrollLeft);
expect(afterRelease).toBeGreaterThan(40);
expect(await parent.evaluate((element) => element.scrollTop)).toBe(0);
await page.waitForTimeout(250);
expect(await parent.evaluate((element) => element.scrollTop)).toBe(0);
expect(await page.getByTestId("tap-count").textContent()).toBe("0");
});
test("vertical intent over a carousel is handed to MobileScroll in both directions", async ({ page }) => {
const card = page.locator(".carousel-card").nth(1);
const carousel = page.locator(".fixture-carousel");
const parent = page.getByTestId("mobile-scroll");
await drag(page, card, 4, -150);
expect(await parent.evaluate((element) => element.scrollTop)).toBeGreaterThan(60);
expect(await carousel.evaluate((element) => element.scrollLeft)).toBe(0);
await parent.evaluate((element) => {
element.scrollTop = 80;
});
await drag(page, card, -3, 110);
expect(await parent.evaluate((element) => element.scrollTop)).toBeLessThan(80);
});
test("tap activates a card but a completed drag does not", async ({ page }) => {
const firstCard = page.locator(".carousel-card").first();
await firstCard.click();
await expect(page.getByTestId("tap-count")).toHaveText("1");
await drag(page, firstCard, -100, 6);
await expect(page.getByTestId("tap-count")).toHaveText("1");
});
test("Carousel preserves momentum and edge rubber-banding", async ({ page }) => {
const carousel = page.locator(".fixture-carousel");
const card = page.locator(".carousel-card").nth(1);
await drag(page, card, -100, 5, 3);
const releasedOffset = await carousel.evaluate((element) => element.scrollLeft);
await page.waitForTimeout(120);
expect(await carousel.evaluate((element) => element.scrollLeft)).toBeGreaterThan(releasedOffset);
await carousel.evaluate((element) => {
element.scrollLeft = 0;
});
const box = await card.boundingBox();
if (!box) throw new Error("Card has no bounding box");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 90, box.y + box.height / 2, { steps: 4 });
expect(Number(await carousel.getAttribute("data-overscroll"))).toBeGreaterThan(0);
await page.mouse.up();
await page.waitForTimeout(900);
expect(Math.abs(Number(await carousel.getAttribute("data-overscroll")))).toBeLessThan(1);
});
test("BottomSheet remains mounted while its default exit animation plays", async ({ page }) => {
await page.locator(".sheet-trigger").click();
await expect(page.getByTestId("bottom-sheet")).toBeVisible();
await page.getByTestId("sheet-overlay").click({ position: { x: 8, y: 8 } });
await expect(page.getByTestId("bottom-sheet")).toHaveCount(1);
await page.waitForTimeout(500);
await expect(page.getByTestId("bottom-sheet")).toHaveCount(0);
});
test("keyboard and its attached footer dismiss on the same transition", async ({ page }) => {
await page.goto("/tests/runtime-fixture.html?fixture=keyboard");
const input = page.getByLabel("Message");
const footer = page.getByTestId("flow-fixed-footer");
const keyboard = page.getByTestId("keyboard-dock");
await input.click();
await expect(keyboard).toHaveAttribute("data-visible", "true");
await drag(page, footer, 0, 120, 5);
await expect(keyboard).toHaveAttribute("data-visible", "false");
await page.waitForTimeout(100);
const progress = await page.evaluate(() => {
const footerElement = document.querySelector<HTMLElement>('[data-testid="flow-fixed-footer"]')!;
const keyboardElement = document.querySelector<HTMLElement>('[data-testid="keyboard-dock"]')!;
const fullHeight = Number.parseFloat(keyboardElement.style.height);
const footerRemaining = Number.parseFloat(getComputedStyle(footerElement).bottom);
const matrix = new DOMMatrixReadOnly(getComputedStyle(keyboardElement).transform);
return {
footer: footerRemaining / fullHeight,
keyboard: 1 - matrix.m42 / fullHeight,
};
});
expect(Math.abs(progress.footer - progress.keyboard)).toBeLessThan(0.18);
await page.waitForTimeout(300);
expect(await footer.evaluate((element) => getComputedStyle(element).bottom)).toBe("34px");
});
test("switching to Pixel keeps the composer above Android navigation", async ({ page }) => {
await page.goto("/tests/runtime-fixture.html?fixture=keyboard");
const input = page.getByLabel("Message");
await input.evaluate((element: HTMLInputElement) => {
element.value = "Draft message";
});
await page.getByTestId("device-picker").click();
await page.getByTestId("device-option-pixel-10").click();
const frame = page.getByTestId("phone-frame");
const screen = page.getByTestId("device-screen");
const statusIndicators = page.getByTestId("status-indicators");
const navigation = page.getByTestId("android-navigation-bar");
const footer = page.getByTestId("flow-fixed-footer");
await expect(frame).toHaveAttribute("data-device", "pixel-10");
await expect(screen).toHaveAttribute("data-device", "pixel-10");
await expect(page.locator(".phone-bezel")).toHaveAttribute(
"src",
"/assets/android/Pixel10.png",
);
await expect(statusIndicators).toHaveAttribute("data-platform", "android");
await expect(statusIndicators).toHaveAttribute(
"src",
"/assets/status/status-icons.svg",
);
await expect(navigation).toBeVisible();
await expect(page.getByTestId("home-indicator")).toHaveCount(0);
await expect(input).toHaveValue("Draft message");
await page.waitForTimeout(300);
const layout = await page.evaluate(() => {
const footerElement = document.querySelector<HTMLElement>(
'[data-testid="flow-fixed-footer"]',
)!;
const navigationElement = document.querySelector<HTMLElement>(
'[data-testid="android-navigation-bar"]',
)!;
const appViewportElement = document.querySelector<HTMLElement>(
'[data-testid="mobile-app-viewport"]',
)!;
return {
footerBottom: footerElement.getBoundingClientRect().bottom,
appViewportBottom: appViewportElement.getBoundingClientRect().bottom,
navigationTop: navigationElement.getBoundingClientRect().top,
navigationHeight: Number.parseFloat(getComputedStyle(navigationElement).height),
safeAreaBottom: Number.parseFloat(
getComputedStyle(document.querySelector<HTMLElement>('[data-testid="device-screen"]')!).getPropertyValue(
"--device-safe-area-bottom",
),
),
};
});
expect(layout.safeAreaBottom).toBe(layout.navigationHeight);
expect(Math.abs(layout.appViewportBottom - layout.navigationTop)).toBeLessThanOrEqual(1);
expect(Math.abs(layout.footerBottom - layout.navigationTop)).toBeLessThanOrEqual(1);
await input.click();
await expect(page.getByTestId("keyboard-dock")).toHaveAttribute("data-visible", "true");
await expect(navigation).toHaveCount(0);
await page.waitForTimeout(300);
const keyboardLayout = await page.evaluate(() => {
const screen = document.querySelector<HTMLElement>('[data-testid="device-screen"]')!;
const viewport = document.querySelector<HTMLElement>('[data-testid="mobile-app-viewport"]')!;
const scroll = document.querySelector<HTMLElement>('[data-testid="mobile-scroll"]')!;
const footerElement = document.querySelector<HTMLElement>('[data-testid="flow-fixed-footer"]')!;
const keyboard = document.querySelector<HTMLElement>('[data-testid="keyboard-dock"]')!;
return {
screenBottom: screen.getBoundingClientRect().bottom,
viewportBottom: viewport.getBoundingClientRect().bottom,
scrollBottom: scroll.getBoundingClientRect().bottom,
footerBottom: footerElement.getBoundingClientRect().bottom,
keyboardTop: keyboard.getBoundingClientRect().top,
keyboardBottom: keyboard.getBoundingClientRect().bottom,
};
});
expect(keyboardLayout.viewportBottom).toBeCloseTo(keyboardLayout.screenBottom, 0);
expect(Math.abs(keyboardLayout.keyboardBottom - keyboardLayout.screenBottom)).toBeLessThanOrEqual(1);
expect(Math.abs(keyboardLayout.scrollBottom - keyboardLayout.keyboardTop)).toBeLessThanOrEqual(1);
expect(Math.abs(keyboardLayout.footerBottom - keyboardLayout.keyboardTop)).toBeLessThanOrEqual(1);
});
test("FlowStack pushes and pops screens while dismissing the keyboard", async ({ page }) => {
await page.goto("/tests/runtime-fixture.html?fixture=flow");
await page.getByLabel("Flow message").click();
await expect(page.getByTestId("keyboard-dock")).toHaveAttribute("data-visible", "true");
await page.getByRole("button", { name: "Push level 2" }).click();
await expect(page.getByRole("heading", { name: "Screen stacking works" })).toBeVisible();
await expect(page.getByTestId("keyboard-dock")).toHaveAttribute("data-visible", "false");
const safeHeaderPlacement = await page.evaluate(() => {
const screen = document.querySelector<HTMLElement>('[data-testid="device-screen"]')!;
const toolbar = document.querySelector<HTMLElement>(".flow-fixture-header")!;
return toolbar.getBoundingClientRect().top - screen.getBoundingClientRect().top;
});
expect(safeHeaderPlacement).toBeGreaterThanOrEqual(54);
await page.getByRole("button", { name: "Push level 3" }).click();
await expect(page.getByRole("heading", { name: "Nested view level 3" })).toBeVisible();
await page.getByRole("button", { name: "Push level 4" }).click();
await expect(page.getByRole("heading", { name: "Nested view level 4" })).toBeVisible();
await page.getByRole("button", { name: "Done" }).click();
await expect(page.getByRole("heading", { name: "Nested view level 3" })).toBeVisible();
await page.getByRole("button", { name: " Back" }).click();
await expect(page.getByRole("heading", { name: "Screen stacking works" })).toBeVisible();
await page.getByRole("button", { name: "Done" }).click();
await expect(page.getByRole("heading", { name: "Flow root" })).toBeVisible();
});

View File

@@ -0,0 +1,82 @@
.fixture-screen {
background: #f6f6f6;
}
.fixture-content {
min-height: 1200px;
padding: 76px 24px 120px;
}
.fixture-content h1 {
margin: 0 0 20px;
font-size: 24px;
}
.sheet-trigger {
margin-bottom: 24px;
}
.fixture-carousel {
width: 100%;
}
.fixture-carousel-track {
gap: 12px;
}
.carousel-card {
width: 180px;
height: 132px;
flex: 0 0 auto;
border: 0;
border-radius: 18px;
background: #d9e7ff;
}
[data-testid="tap-count"] {
display: block;
margin-top: 12px;
}
.fixture-tall-content {
min-height: 900px;
padding-top: 160px;
}
.keyboard-footer {
height: 84px;
padding: 14px 20px;
background: white;
border-top: 1px solid #ddd;
}
.keyboard-footer input {
width: 100%;
height: 48px;
padding: 0 16px;
border: 1px solid #bbb;
border-radius: 24px;
}
.flow-fixture-header {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
height: var(--flow-header-content-height);
padding: 0 18px;
background: white;
border-bottom: 1px solid #ddd;
}
.flow-fixture-header button {
justify-self: start;
}
.flow-fixture-footer {
display: flex;
justify-content: flex-end;
height: 64px;
padding: 10px 18px;
background: white;
border-top: 1px solid #ddd;
}

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mobile runtime interaction fixture</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/tests/runtime-fixture.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,152 @@
import { StrictMode, useState } from "react";
import { createRoot } from "react-dom/client";
import {
BottomSheet,
Carousel,
FlowStack,
KeyboardInput,
MobileRuntime,
MobileScroll,
type FlowScreen,
} from "../src/mobile";
import "../src/styles.css";
import "./runtime-fixture.css";
function CarouselFixture() {
const [tapCount, setTapCount] = useState(0);
const [sheetOpen, setSheetOpen] = useState(false);
return (
<MobileRuntime>
<MobileScroll className="fixture-screen">
<main className="fixture-content">
<h1>Runtime fixture</h1>
<button className="sheet-trigger" type="button" onClick={() => setSheetOpen(true)}>
Open sheet
</button>
<Carousel
ariaLabel="Featured cards"
className="fixture-carousel"
contentClassName="fixture-carousel-track"
>
{Array.from({ length: 7 }, (_, index) => (
<button
className="carousel-card"
type="button"
onClick={() => setTapCount((count) => count + 1)}
key={index}
>
Card {index + 1}
</button>
))}
</Carousel>
<output data-testid="tap-count">{tapCount}</output>
<div className="fixture-tall-content">Scrollable parent content</div>
</main>
</MobileScroll>
<BottomSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
title="Animated sheet"
description="Exit motion regression fixture."
>
<p>Sheet content</p>
</BottomSheet>
</MobileRuntime>
);
}
function KeyboardFixture() {
const screen: FlowScreen = {
id: "keyboard",
footerHeight: 84,
footer: () => (
<div className="keyboard-footer">
<KeyboardInput aria-label="Message" placeholder="Message" />
</div>
),
render: () => (
<MobileScroll className="fixture-screen">
<main className="fixture-content fixture-tall-content">Keyboard fixture</main>
</MobileScroll>
),
};
return (
<MobileRuntime>
<FlowStack initial={screen} />
</MobileRuntime>
);
}
function stackedScreen(level: number): FlowScreen {
return {
id: `flow-level-${level}`,
headerHeight: 56,
header: (flow) => (
<div className="flow-fixture-header">
<button type="button" onClick={flow.pop}> Back</button>
<strong>Level {level}</strong>
<span />
</div>
),
footerHeight: 64,
footer: (flow) => (
<div className="flow-fixture-footer">
<button type="button" onClick={flow.pop}>Done</button>
</div>
),
render: (flow) => (
<MobileScroll className="fixture-screen">
<main className="fixture-content">
<h1>{level === 2 ? "Screen stacking works" : `Nested view level ${level}`}</h1>
{level < 4 ? (
<button type="button" onClick={() => flow.push(stackedScreen(level + 1))}>
Push level {level + 1}
</button>
) : null}
</main>
</MobileScroll>
),
};
}
function FlowFixture() {
const screen: FlowScreen = {
id: "flow-root",
footerHeight: 84,
footer: () => (
<div className="keyboard-footer">
<KeyboardInput aria-label="Flow message" placeholder="Message" />
</div>
),
render: (flow) => (
<MobileScroll className="fixture-screen">
<main className="fixture-content">
<h1>Flow root</h1>
<button type="button" onClick={() => flow.push(stackedScreen(2))}>
Push level 2
</button>
</main>
</MobileScroll>
),
};
return (
<MobileRuntime>
<FlowStack initial={screen} />
</MobileRuntime>
);
}
const fixture = new URLSearchParams(window.location.search).get("fixture");
const fixtureElement =
fixture === "keyboard"
? <KeyboardFixture />
: fixture === "flow"
? <FlowFixture />
: <CarouselFixture />;
createRoot(document.getElementById("root")!).render(
<StrictMode>{fixtureElement}</StrictMode>,
);

View File

@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { access } from "node:fs/promises";
import test from "node:test";
import worker from "../worker/index.js";
test("serves existing static assets without a fallback", async () => {
const calls = [];
const response = await worker.fetch(new Request("https://example.test/assets/app.js"), {
ASSETS: {
fetch: async (request) => {
calls.push(new URL(request.url).pathname);
return new Response("asset", { status: 200 });
},
},
});
assert.equal(response.status, 200);
assert.deepEqual(calls, ["/assets/app.js"]);
});
test("falls back to index.html for an unknown app route", async () => {
const calls = [];
const response = await worker.fetch(
new Request("https://example.test/flow/step-two?source=share", {
headers: { accept: "text/html" },
}),
{
ASSETS: {
fetch: async (request) => {
const url = new URL(request.url);
calls.push(url.pathname + url.search);
return new Response(url.pathname === "/index.html" ? "app" : "missing", {
status: url.pathname === "/index.html" ? 200 : 404,
});
},
},
},
);
assert.equal(response.status, 200);
assert.deepEqual(calls, ["/flow/step-two?source=share", "/index.html"]);
});
test("does not turn missing API or write requests into the app shell", async () => {
for (const request of [
new Request("https://example.test/api/missing", { headers: { accept: "application/json" } }),
new Request("https://example.test/flow", { method: "POST", headers: { accept: "text/html" } }),
]) {
let calls = 0;
const response = await worker.fetch(request, {
ASSETS: {
fetch: async () => {
calls += 1;
return new Response("missing", { status: 404 });
},
},
});
assert.equal(response.status, 404);
assert.equal(calls, 1);
}
});
test("emits the files required by Sites packaging", async () => {
await access(new URL("../dist/client/index.html", import.meta.url));
await access(new URL("../dist/server/index.js", import.meta.url));
await access(new URL("../dist/.openai/hosting.json", import.meta.url));
});