Configure board deployment and backend proxy
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
screenshots
|
||||
.git
|
||||
.DS_Store
|
||||
.env
|
||||
server/node_modules
|
||||
@@ -0,0 +1,9 @@
|
||||
APP_PORT=47880
|
||||
VITE_REALTIME_MODE=websocket
|
||||
VITE_REALTIME_WS_URL=/ws
|
||||
VITE_WORDCLOUD_API_BASE_URL=/wordcloud-api
|
||||
VITE_WORDCLOUD_DEFAULT_JOB_ID=bd1f240adcb4458f857c40ac427122c6
|
||||
WORDCLOUD_API_UPSTREAM=http://114.55.99.6:8000
|
||||
HARBOR_REGISTRY=192.168.31.213:10081
|
||||
HARBOR_PROJECT=wordcloud
|
||||
IMAGE_TAG=latest
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Build, Push and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, hotfix/*]
|
||||
|
||||
env:
|
||||
HARBOR: 192.168.31.213:10081
|
||||
HARBOR_REGISTRY: 192.168.31.213:10081
|
||||
HARBOR_PROJECT: wordcloud
|
||||
APP_PORT: 47880
|
||||
WORDCLOUD_API_UPSTREAM: http://192.168.31.213:8000
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
run: |
|
||||
set -euo pipefail
|
||||
GITEA_URL="${GITHUB_SERVER_URL:-http://192.168.31.213:10880}"
|
||||
git init -q .
|
||||
git remote add origin "$GITEA_URL/$GITHUB_REPOSITORY.git"
|
||||
git fetch -q --depth 1 origin "$GITHUB_SHA"
|
||||
git checkout -q "$GITHUB_SHA"
|
||||
|
||||
- name: Login Harbor
|
||||
run: |
|
||||
echo "${{ secrets.HARBOR_PASS }}" | docker login "$HARBOR" \
|
||||
-u "${{ secrets.HARBOR_USER }}" --password-stdin
|
||||
|
||||
- name: Build Docker images
|
||||
run: |
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
COMPOSE="docker compose"
|
||||
else
|
||||
COMPOSE="docker-compose"
|
||||
fi
|
||||
$COMPOSE build
|
||||
|
||||
- name: Tag and push images
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHA="${{ github.sha }}"
|
||||
for name in wordcloud-board-app wordcloud-board-realtime; do
|
||||
image="$HARBOR/$HARBOR_PROJECT/$name"
|
||||
docker tag "$image:latest" "$image:$SHA"
|
||||
docker push "$image:latest"
|
||||
docker push "$image:$SHA"
|
||||
done
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout deploy config
|
||||
run: |
|
||||
set -euo pipefail
|
||||
GITEA_URL="${GITHUB_SERVER_URL:-http://192.168.31.213:10880}"
|
||||
git init -q .
|
||||
git remote add origin "$GITEA_URL/$GITHUB_REPOSITORY.git"
|
||||
git fetch -q --depth 1 origin "$GITHUB_SHA"
|
||||
git checkout -q "$GITHUB_SHA"
|
||||
|
||||
- name: Deploy WordCloud Board
|
||||
run: |
|
||||
chmod +x ./deploy.sh
|
||||
./deploy.sh wordcloud-board production "${{ github.sha }}"
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
screenshots
|
||||
*.local
|
||||
.DS_Store
|
||||
.env
|
||||
@@ -0,0 +1,66 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `Design/` is the current source of truth for the product experience.
|
||||
- `Design/index.html` is the overview/launcher entry point.
|
||||
- `Design/control.html` is the participant control surface.
|
||||
- `Design/screen.html` is the full-screen word-cloud display.
|
||||
- `Design/DESIGN-MANIFEST.json` maps screens, assets, required states, and responsive checks.
|
||||
- `Design/DESIGN-HANDOFF.md` defines the implementation contract and must be read before adding production code.
|
||||
- `Design/drawing-*.png` and future design assets live beside the HTML screens.
|
||||
|
||||
The app is a React + TypeScript + Vite project rooted at the repository root; `Design/` remains the confirmed visual reference.
|
||||
|
||||
## Development Commands
|
||||
|
||||
Use npm for the React app:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Run verification before claiming completion:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run build
|
||||
npm run test:server
|
||||
```
|
||||
|
||||
For production, build and run with `docker compose up -d --build`; the port is configured with `APP_PORT`. Nginx serves the SPA and forwards `/ws` to the realtime container.
|
||||
|
||||
For direct design-file inspection without the app:
|
||||
|
||||
```bash
|
||||
python3 -m http.server 8000 --directory Design
|
||||
```
|
||||
|
||||
Then open `http://localhost:8000/index.html`, `/control.html`, or `/screen.html`.
|
||||
|
||||
## Coding Style
|
||||
|
||||
- Keep pages, reusable components, data, hooks, and realtime adapters separated.
|
||||
- Use two-space indentation for HTML, CSS, TypeScript, and JavaScript.
|
||||
- Use CSS Modules for page/component styles and keep visual values in `src/styles/global.css`.
|
||||
- Preserve existing CSS custom properties for color, typography, spacing, radius, shadow, and motion.
|
||||
- Use descriptive BEM-like class names such as `art-title`, `cloud`, `dock`, and `screen-note`; do not introduce anonymous utility classes for domain-specific layout.
|
||||
- Keep user-facing copy in the existing Chinese-first style and preserve exact labels unless requirements say otherwise.
|
||||
- Prefer semantic elements and visible focus states for any interactive implementation.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Use Vitest and React Testing Library. Keep core search, duplicate-target, queue, and adapter behavior covered by tests. For visual or interaction changes, also verify all three routes in a browser, check mobile and desktop viewports, and ensure no horizontal overflow or hidden controls. The manifest's responsive viewport list is the minimum visual checklist.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
Use short, imperative commit subjects, for example `Update screen word positions` or `Add control success state`.
|
||||
|
||||
Pull requests should include the change reason, affected screen files, browser and viewport checks performed, and screenshots or short recordings for visual changes. Link related implementation or design tickets when available.
|
||||
|
||||
## Agent Guidance
|
||||
|
||||
Before implementing production code, read `DESIGN-HANDOFF.md` and `DESIGN-MANIFEST.json`, preserve the documented visual system, and keep launcher, control, and display surfaces separate.
|
||||
@@ -0,0 +1,89 @@
|
||||
# 65d91830-ed68-4c91-99dc-b18d3b4d3d35 implementation handoff
|
||||
|
||||
This archive is the source of truth for turning the design into production code. Start from `index.html`, then preserve the visual system, responsive behavior, and interactions found in the exported files.
|
||||
|
||||
## Implementation target
|
||||
- Build production UI from the exported design, not a loose reinterpretation.
|
||||
- Preserve typography scale, spacing rhythm, color tokens, border radii, shadows, motion timing, and component states.
|
||||
- Replace static placeholders only when the target app has real data or functional equivalents.
|
||||
- Keep generated product UI free of OpenDesign chrome, preview labels, or design-process annotations.
|
||||
- Treat this handoff as a visual contract: if implementation choices conflict, match the exported pixels and behavior first, then refactor internals.
|
||||
|
||||
## Source map
|
||||
- Primary entry: `index.html`
|
||||
- HTML screens detected: 3
|
||||
- Stylesheets detected: 0
|
||||
- Script/component files detected: 0
|
||||
- Supporting assets detected: 1
|
||||
|
||||
## Responsive contract
|
||||
Validate the implementation across this 2025–2026 viewport matrix:
|
||||
- Mobile compact: 360×800
|
||||
- Mobile standard: 390×844
|
||||
- Mobile large: 430×932
|
||||
- Foldable / small tablet: 600×960
|
||||
- Tablet portrait: 820×1180
|
||||
- Tablet landscape: 1024×768
|
||||
- Laptop: 1366×768
|
||||
- Desktop: 1440×900
|
||||
- Wide desktop: 1920×1080
|
||||
|
||||
For responsive web exports, treat these as a modern breakpoint system for one adaptive web experience, not three fixed screenshots. Do not split responsive web into unrelated native app screens unless the project explicitly includes native targets. Use semantic layout thresholds, fluid `clamp()` type/spacing, and container queries where component width matters more than viewport width. Preserve any CSS media queries, container queries, fluid `clamp()` scales, and layout changes already present in the exported files.
|
||||
|
||||
## Design fidelity contract
|
||||
- Extract reusable tokens before writing components: background, surface, foreground, muted text, border, accent, radius, shadow, spacing, type scale, and motion duration/easing.
|
||||
- Map product screens, in-app modules/components, optional landing page, and optional OS widget surfaces before coding. Keep these surfaces separate in the target architecture.
|
||||
- Match layout geometry: max-widths, gutters, grid columns, card proportions, sticky/fixed elements, and viewport-specific navigation.
|
||||
- Preserve real copy, labels, and data shown in the export. Do not replace specific text with generic marketing filler.
|
||||
- Preserve interactive affordances: hover, focus, pressed, disabled, loading, validation, copy/share, tab/accordion, modal/sheet, and keyboard states where present.
|
||||
- Preserve accessibility semantics when converting: headings stay hierarchical, controls remain buttons/links/inputs, focus states stay visible.
|
||||
- Do not keep prototype-only annotations, frame labels, or OpenDesign chrome in the production UI.
|
||||
|
||||
## CJX-ready UX contract
|
||||
- Use `DESIGN-MANIFEST.json` as the machine-readable map for screens, app modules, OS widgets, landing pages, tokens, interactions, and viewport checks.
|
||||
- Screen-file-first: when multiple user-facing surfaces exist, implement each HTML screen as its own route/file. Treat `index.html` as a launcher/overview when the manifest marks it that way, not as a combined final UI.
|
||||
- If `landing.html`, app screens, platform screens, or OS widget files exist, preserve those boundaries in the target app instead of merging them into one page.
|
||||
- A single self-contained `index.html` is acceptable only when the export truly contains one user-facing screen and its CSS/JS are structured enough to extract tokens, components, states, and behavior.
|
||||
- If separate `css/` or `js/` files exist, treat them as source of truth for token/component/interactions before porting to React, Vue, SwiftUI, Compose, or another target stack.
|
||||
- In-app modules/components are product UI blocks inside the app. OS widgets are home-screen/lock-screen/quick-access surfaces outside the app. Do not merge those concepts.
|
||||
|
||||
## Color and brand contract
|
||||
- Use the exported design tokens and product/domain context as the color source of truth.
|
||||
- Do not introduce warm beige / cream / peach / pink / orange-brown background washes unless they are already explicit brand/reference colors in the export.
|
||||
- No obvious token stylesheet was detected; sample colors from the entry file and convert them into named tokens before coding.
|
||||
|
||||
## Implementation sequence for AI coding tools
|
||||
1. Open `index.html` and `DESIGN-MANIFEST.json`; identify every screen file, launcher/overview file, app module, and interaction before coding.
|
||||
2. If multiple HTML screens exist, map them to separate routes/surfaces first; do not merge `landing.html`, product app screens, platform screens, or OS widgets into one route.
|
||||
3. Extract a token table from CSS/root styles and inline styles before building framework components.
|
||||
4. Build product screens and domain-specific in-app modules from largest layout regions down to controls; avoid starting with isolated atoms that lose spatial intent.
|
||||
5. Port responsive behavior across the modern viewport matrix and test each semantic breakpoint before cleanup.
|
||||
6. Port interactions and states, then replace static placeholders only with real app data or functional equivalents.
|
||||
7. Keep optional landing page and OS widget surfaces as separate surfaces if present.
|
||||
8. Compare final screenshots against the export at 360×800, 390×844, 430×932, 820×1180, 1024×768, 1366×768, 1440×900, and 1920×1080 before declaring done.
|
||||
|
||||
## Entry points
|
||||
- `control.html`
|
||||
- `index.html`
|
||||
- `screen.html`
|
||||
|
||||
## Styles
|
||||
- None detected
|
||||
|
||||
## Scripts/components
|
||||
- None detected
|
||||
|
||||
## Assets and supporting files
|
||||
- `drawing-2026-09-12T12-48-14-971Z.png`
|
||||
|
||||
## Coding checklist for AI tools
|
||||
1. Inspect `index.html` and `DESIGN-MANIFEST.json` first and identify reusable components before coding.
|
||||
2. Implement each user-facing screen file as its own route/surface; keep launcher, landing, app, platform, and OS widget files separate.
|
||||
3. Extract design tokens into the target stack: colors, type scale, spacing, radius, shadows, and motion.
|
||||
4. Implement layout with real 2025–2026 responsive breakpoints, fluid type/spacing, and container-query-aware component behavior; test with no horizontal overflow.
|
||||
5. Preserve interactive controls, hover/focus/pressed states, form behavior, validation, and copy actions where present.
|
||||
6. Implement domain-specific in-app modules with real states; do not flatten them into generic cards.
|
||||
7. Keep landing page, product screens, and OS widget/quick-access surfaces separate when present.
|
||||
8. Confirm the production result visually matches the exported design before refactoring internals.
|
||||
9. Reject implementation shortcuts that flatten the design into generic cards, generic gradients, placeholder stats, or framework-default typography.
|
||||
10. If a detail is ambiguous, keep the exported HTML/CSS/JS behavior rather than inventing a new pattern.
|
||||
@@ -0,0 +1,185 @@
|
||||
{
|
||||
"schema": "open-design.design-manifest.v1",
|
||||
"title": "65d91830-ed68-4c91-99dc-b18d3b4d3d35",
|
||||
"entryFile": "index.html",
|
||||
"sourceFiles": {
|
||||
"all": [
|
||||
"control.html",
|
||||
"drawing-2026-09-12T12-48-14-971Z.png",
|
||||
"index.html",
|
||||
"screen.html"
|
||||
],
|
||||
"html": [
|
||||
"control.html",
|
||||
"index.html",
|
||||
"screen.html"
|
||||
],
|
||||
"css": [],
|
||||
"scriptsAndComponents": [],
|
||||
"assets": [
|
||||
"drawing-2026-09-12T12-48-14-971Z.png"
|
||||
]
|
||||
},
|
||||
"screens": [
|
||||
{
|
||||
"file": "control.html",
|
||||
"role": "screen",
|
||||
"implementationNote": "Preserve visual hierarchy, responsive behavior, and interactive states from this screen."
|
||||
},
|
||||
{
|
||||
"file": "index.html",
|
||||
"role": "launcher-overview",
|
||||
"implementationNote": "Use this as the navigation/overview entry only; implement each linked screen file as its own route/surface."
|
||||
},
|
||||
{
|
||||
"file": "screen.html",
|
||||
"role": "product-screen",
|
||||
"implementationNote": "Preserve visual hierarchy, responsive behavior, and interactive states from this screen."
|
||||
}
|
||||
],
|
||||
"screenFilePolicy": {
|
||||
"mode": "screen-file-first",
|
||||
"entryFileRole": "launcher-overview",
|
||||
"rules": [
|
||||
"Each distinct user-facing screen or surface must be delivered and implemented as its own file/route.",
|
||||
"If a landing page is present or requested, keep it in landing.html and do not merge it into the product app screen.",
|
||||
"When multiple HTML screens exist, index.html is a launcher/overview only; it must not be treated as the combined final UI.",
|
||||
"Keep product app screens, landing pages, platform screens, and OS widget surfaces separate in production code."
|
||||
]
|
||||
},
|
||||
"appModules": [
|
||||
"Identify domain-specific in-app modules from the exported UI; do not reduce them to generic cards.",
|
||||
"For each major module, implement purpose, default/loading/empty/error/success states, and responsive behavior.",
|
||||
"Keep app modules separate from OS home-screen widgets in the production component model."
|
||||
],
|
||||
"osWidgets": [
|
||||
"If the export includes home-screen, lock-screen, Live Activity, tablet glance, or Android widget surfaces, implement them as platform quick-access surfaces outside the app UI.",
|
||||
"If none are present, do not invent OS widgets unless the product requirements request them."
|
||||
],
|
||||
"landingPage": {
|
||||
"detection": "Inspect files and screen names for a marketing/landing page surface. If present, keep it separate from product app screens.",
|
||||
"requiredSections": [
|
||||
"hero",
|
||||
"value props",
|
||||
"product proof/screenshots",
|
||||
"feature proof",
|
||||
"CTA"
|
||||
]
|
||||
},
|
||||
"tokens": {
|
||||
"source": [
|
||||
"index.html"
|
||||
],
|
||||
"required": [
|
||||
"background",
|
||||
"surface",
|
||||
"foreground",
|
||||
"muted text",
|
||||
"border",
|
||||
"accent",
|
||||
"radius",
|
||||
"shadow",
|
||||
"spacing",
|
||||
"type scale",
|
||||
"motion"
|
||||
],
|
||||
"note": "Extract/freeze tokens before framework implementation so coding tools do not substitute default theme colors or typography."
|
||||
},
|
||||
"interactions": {
|
||||
"source": [
|
||||
"index.html"
|
||||
],
|
||||
"requiredStates": [
|
||||
"default",
|
||||
"hover",
|
||||
"focus",
|
||||
"active",
|
||||
"disabled",
|
||||
"loading",
|
||||
"empty",
|
||||
"error",
|
||||
"success"
|
||||
],
|
||||
"requiredBehaviors": [
|
||||
"forms/validation where present",
|
||||
"tabs/filters where present",
|
||||
"dialogs/sheets/drawers where present",
|
||||
"copy/generate/share actions where present",
|
||||
"player or quick controls where present"
|
||||
],
|
||||
"note": "If the prototype is static, derive missing behavior from visible controls and document it before coding."
|
||||
},
|
||||
"responsiveViewports": [
|
||||
{
|
||||
"name": "mobile-compact",
|
||||
"width": 360,
|
||||
"height": 800,
|
||||
"category": "mobile",
|
||||
"mustAvoidHorizontalScroll": true
|
||||
},
|
||||
{
|
||||
"name": "mobile-standard",
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"category": "mobile",
|
||||
"mustAvoidHorizontalScroll": true
|
||||
},
|
||||
{
|
||||
"name": "mobile-large",
|
||||
"width": 430,
|
||||
"height": 932,
|
||||
"category": "mobile",
|
||||
"mustAvoidHorizontalScroll": true
|
||||
},
|
||||
{
|
||||
"name": "foldable-small-tablet",
|
||||
"width": 600,
|
||||
"height": 960,
|
||||
"category": "foldable-tablet",
|
||||
"mustAvoidHorizontalScroll": true
|
||||
},
|
||||
{
|
||||
"name": "tablet-portrait",
|
||||
"width": 820,
|
||||
"height": 1180,
|
||||
"category": "tablet",
|
||||
"mustAvoidHorizontalScroll": true
|
||||
},
|
||||
{
|
||||
"name": "tablet-landscape",
|
||||
"width": 1024,
|
||||
"height": 768,
|
||||
"category": "tablet",
|
||||
"mustAvoidHorizontalScroll": true
|
||||
},
|
||||
{
|
||||
"name": "laptop",
|
||||
"width": 1366,
|
||||
"height": 768,
|
||||
"category": "desktop",
|
||||
"mustAvoidHorizontalScroll": true
|
||||
},
|
||||
{
|
||||
"name": "desktop",
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"category": "desktop",
|
||||
"mustAvoidHorizontalScroll": true
|
||||
},
|
||||
{
|
||||
"name": "wide",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"category": "wide",
|
||||
"mustAvoidHorizontalScroll": true
|
||||
}
|
||||
],
|
||||
"implementationChecklist": [
|
||||
"Open entryFile first and map screens, modules, tokens, and interactions.",
|
||||
"Extract tokens before writing framework components.",
|
||||
"Implement app-specific modules with real states instead of generic card grids.",
|
||||
"Preserve or rebuild JS interactions for meaningful UX actions.",
|
||||
"Validate screenshots at desktop/tablet/mobile viewports with no horizontal overflow.",
|
||||
"Keep landing pages, in-app modules, and OS widgets as separate implementation surfaces."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>遥控器 · 找到你自己</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg:#ffffff; --surface:#f6f6f3; --surface-warm:#e5e5e0;
|
||||
--fg:#211922; --fg-2:#000000; --muted:#62625b; --meta:#91918c;
|
||||
--border-soft:#e0e0d9; --accent:#e60023; --success:#103c25;
|
||||
--font-body:"Pin Sans",-apple-system,system-ui,Arial,sans-serif;
|
||||
--text-xs:12px; --text-sm:14px; --text-base:16px; --text-xl:22px; --text-2xl:28px;
|
||||
--leading-body:1.4; --tracking-display:-0.02em;
|
||||
--space-2:8px; --space-3:12px; --space-4:16px; --space-6:24px; --space-8:32px; --space-12:48px;
|
||||
--radius-md:16px; --radius-pill:9999px;
|
||||
--focus-ring:0 0 0 3px rgba(67,94,229,.35);
|
||||
}
|
||||
*,*::before,*::after { box-sizing:border-box; }
|
||||
html,body { margin:0;padding:0;height:100%; }
|
||||
body { background:var(--bg);color:var(--fg);font-family:var(--font-body);font-size:var(--text-base);line-height:var(--leading-body); }
|
||||
.scroll { min-height:100dvh;display:grid;justify-items:center;padding:48px 16px 32px; }
|
||||
.box { width:min(440px,100%);margin-top:6vh;display:grid;gap:16px;align-content:start; }
|
||||
.eyebrow { font-size:var(--text-xs);font-weight:700;letter-spacing:.14em;color:var(--meta);margin:0; }
|
||||
h1 { margin:0;font-size:var(--text-2xl);letter-spacing:var(--tracking-display); }
|
||||
.lede { margin:0;color:var(--muted); }
|
||||
.field { display:grid;gap:8px;margin-top:24px; }
|
||||
.field label { font-size:var(--text-sm);font-weight:700; }
|
||||
.fake-input { border:1px solid var(--meta);border-radius:var(--radius-md);background:var(--bg);color:var(--meta);padding:12px 15px;min-height:48px;display:flex;align-items:center; }
|
||||
.main-btn { background:var(--accent);color:#000;border-radius:var(--radius-md);padding:14px 20px;font-weight:700;font-size:var(--text-sm);text-align:center; }
|
||||
.foot { font-size:var(--text-xs);color:var(--meta);text-align:center;margin:0; }
|
||||
.success { text-align:center;padding:32px 0;display:grid;gap:12px;justify-items:center;border-top:1px solid var(--border-soft); }
|
||||
.mark { width:64px;height:64px;border-radius:var(--radius-pill);background:var(--surface-warm);color:var(--success);display:grid;place-items:center;font-size:28px;font-weight:700; }
|
||||
.success h2 { margin:0;font-size:var(--text-xl); }
|
||||
.success p { margin:0;color:var(--muted);font-size:var(--text-sm); }
|
||||
.static-note { font-size:var(--text-xs);color:var(--meta);text-align:center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="scroll" aria-label="遥控器静态设计">
|
||||
<div class="box">
|
||||
<p class="eyebrow">LIVE · 现场互动</p>
|
||||
<h1>找到你自己</h1>
|
||||
<p class="lede">输入名字,它会出现在大屏幕上。</p>
|
||||
<div class="field">
|
||||
<label>你的名字</label>
|
||||
<div class="fake-input">输入你的名字</div>
|
||||
</div>
|
||||
<div class="main-btn">在大屏上找到我</div>
|
||||
<div class="success" aria-label="成功状态示意">
|
||||
<div class="mark">✓</div>
|
||||
<h2>找到了 · 抬头看看大屏</h2>
|
||||
<p>几秒后大屏会恢复完整词云 · 再找一个名字</p>
|
||||
</div>
|
||||
<p class="foot">请留意现场大屏</p>
|
||||
<p class="static-note">静态设计示意 · 不可交互</p>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
@@ -0,0 +1,126 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>个人查找 · 词云</title>
|
||||
<style>
|
||||
@layer od-layout {
|
||||
:where(.od-stack,.od-row) > :where(*) { min-width: 0; }
|
||||
.od-stack { display:flex;flex-direction:column;gap:var(--od-gap,8px); }
|
||||
.od-row { display:flex;align-items:center;gap:var(--od-gap,8px); }
|
||||
.od-nowrap { white-space:nowrap; }
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
:root {
|
||||
--bg:#ffffff; --surface:#f6f6f3; --surface-warm:#e5e5e0;
|
||||
--fg:#211922; --fg-2:#000000; --muted:#62625b; --meta:#91918c;
|
||||
--border:#c8c8c1; --border-soft:#e0e0d9;
|
||||
--accent:#e60023;
|
||||
--font-body:"Pin Sans",-apple-system,system-ui,"Segoe UI",Roboto,Arial,sans-serif;
|
||||
--font-art:"Pin Sans",-apple-system,"PingFang SC","Hiragino Sans GB","Noto Sans SC","Microsoft YaHei",system-ui,sans-serif;
|
||||
--text-xs:12px; --text-sm:14px; --text-base:16px; --text-lg:18px; --text-2xl:28px;
|
||||
--leading-body:1.4; --tracking-display:-0.02em;
|
||||
--space-2:8px; --space-3:12px; --space-4:16px; --space-6:24px;
|
||||
--radius-md:16px; --radius-pill:9999px;
|
||||
--elev-raised:0 4px 16px rgba(33,25,34,0.06);
|
||||
}
|
||||
*,*::before,*::after { box-sizing:border-box; }
|
||||
html,body { margin:0;padding:0;height:100%; }
|
||||
body { background:var(--bg);color:var(--fg);font-family:var(--font-body);font-size:var(--text-base);line-height:var(--leading-body); }
|
||||
.page { height:100dvh;position:relative;overflow:hidden; }
|
||||
.cloud { position:absolute;inset:0; }
|
||||
.cloud span { position:absolute;transform:translate(-50%,-50%);white-space:nowrap;font-family:var(--font-art);color:var(--fg); }
|
||||
.t1 { font-size:15px;opacity:.55;font-weight:500; }
|
||||
.t2 { font-size:22px;opacity:.7;font-weight:600; }
|
||||
.t3 { font-size:34px;opacity:.85;font-weight:700; }
|
||||
.t4 { font-size:52px;opacity:.94;font-weight:700;letter-spacing:var(--tracking-display); }
|
||||
.eye { position:absolute;left:16px;top:max(16px,env(safe-area-inset-top));font-size:var(--text-xs);letter-spacing:.14em;color:var(--meta);font-weight:700;margin:0;z-index:3; }
|
||||
.art-title { position:absolute;left:50%;top:max(44px,calc(env(safe-area-inset-top) + 36px));transform:translateX(-50%);text-align:center;pointer-events:none;margin:0;max-width:min(88vw,640px);z-index:2;width:max-content; }
|
||||
.art-title h1 { margin:0;font-size:clamp(22px,6vw,32px);font-weight:700;letter-spacing:var(--tracking-display);line-height:1.2;text-wrap:balance; }
|
||||
.art-title p { margin:6px 0 0;font-size:var(--text-xs);letter-spacing:.2em;color:var(--meta);font-weight:700;white-space:nowrap; }
|
||||
.art-title::after { content:"";display:block;width:32px;height:2px;background:var(--accent);border-radius:2px;margin:10px auto 0;opacity:.9; }
|
||||
.dock-wrap { position:absolute;left:0;right:0;bottom:0;display:flex;justify-content:center;padding:0 16px calc(16px + env(safe-area-inset-bottom)); }
|
||||
.dock { width:min(520px,100%);background:var(--surface);border:1px solid var(--border-soft);border-radius:var(--radius-md);box-shadow:var(--elev-raised);padding:12px;display:grid;gap:8px; }
|
||||
.row { display:flex;gap:8px; }
|
||||
.fake-input { flex:1;border:1px solid var(--meta);border-radius:var(--radius-md);background:var(--bg);color:var(--meta);padding:12px 15px;font-size:var(--text-base); }
|
||||
.fake-btn { background:var(--accent);color:#000;border-radius:var(--radius-md);padding:12px 20px;font-weight:700;font-size:var(--text-sm);display:grid;place-items:center; }
|
||||
.found { background:var(--bg);border:1px dashed var(--border);border-radius:var(--radius-md);padding:10px 12px; }
|
||||
.found strong { display:block;font-size:var(--text-base); }
|
||||
.found small { color:var(--muted);font-size:var(--text-sm); }
|
||||
.found-row { display:flex;gap:8px;align-items:center;margin-top:8px; }
|
||||
.ghost-btn { background:var(--surface-warm);color:var(--fg-2);border-radius:var(--radius-md);padding:10px 16px;font-size:var(--text-sm);font-weight:700; }
|
||||
.dock-note { font-size:var(--text-xs);color:var(--meta);text-align:center;margin:0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="page" aria-label="个人查找静态设计">
|
||||
<p class="eye">LIVE ARCHIVE · 2026</p>
|
||||
<div class="art-title">
|
||||
<h1>2026 毕业典礼 · 全体名单</h1>
|
||||
<p>CLASS OF 2026 · WORD CLOUD</p>
|
||||
</div>
|
||||
<div class="cloud" aria-hidden="true">
|
||||
<span class="t4" style="left:50%;top:44%">王小明</span>
|
||||
<span class="t3" style="left:32%;top:30%">林嘉怡</span>
|
||||
<span class="t3" style="left:68%;top:30%">Sophia</span>
|
||||
<span class="t3" style="left:24%;top:58%">李欣</span>
|
||||
<span class="t3" style="left:76%;top:60%">Kevin</span>
|
||||
<span class="t3" style="left:50%;top:66%">陈晨</span>
|
||||
<span class="t2" style="left:38%;top:52%">Alex</span>
|
||||
<span class="t2" style="left:62%;top:52%">Lily</span>
|
||||
<span class="t2" style="left:44%;top:24%">张雨</span>
|
||||
<span class="t2" style="left:58%;top:76%">Daniel</span>
|
||||
<span class="t2" style="left:18%;top:40%">赵天宇</span>
|
||||
<span class="t2" style="left:82%;top:42%">Emma</span>
|
||||
<span class="t2" style="left:30%;top:76%">苏晚晴</span>
|
||||
<span class="t2" style="left:70%;top:78%">周子墨</span>
|
||||
<span class="t2" style="left:50%;top:14%">Olivia</span>
|
||||
<span class="t2" style="left:50%;top:88%">沈知遥</span>
|
||||
<span class="t1" style="left:14%;top:66%">王梓涵</span>
|
||||
<span class="t1" style="left:86%;top:66%">李雨桐</span>
|
||||
<span class="t1" style="left:22%;top:22%">张浩然</span>
|
||||
<span class="t1" style="left:78%;top:20%">刘亦菲</span>
|
||||
<span class="t1" style="left:12%;top:52%">陈子豪</span>
|
||||
<span class="t1" style="left:88%;top:52%">杨晨曦</span>
|
||||
<span class="t1" style="left:36%;top:84%">黄俊杰</span>
|
||||
<span class="t1" style="left:64%;top:84%">吴思远</span>
|
||||
<span class="t1" style="left:26%;top:44%">徐若瑄</span>
|
||||
<span class="t1" style="left:74%;top:46%">孙浩宇</span>
|
||||
<span class="t1" style="left:42%;top:38%">马晓彤</span>
|
||||
<span class="t1" style="left:58%;top:38%">朱子睿</span>
|
||||
<span class="t1" style="left:34%;top:64%">胡文轩</span>
|
||||
<span class="t1" style="left:66%;top:64%">郭美玲</span>
|
||||
<span class="t1" style="left:46%;top:58%">何佳怡</span>
|
||||
<span class="t1" style="left:55%;top:59%">罗子涵</span>
|
||||
<span class="t1" style="left:20%;top:82%">James</span>
|
||||
<span class="t1" style="left:80%;top:82%">Mary</span>
|
||||
<span class="t1" style="left:16%;top:30%">John</span>
|
||||
<span class="t1" style="left:84%;top:30%">Grace</span>
|
||||
<span class="t1" style="left:40%;top:10%">Noah</span>
|
||||
<span class="t1" style="left:60%;top:10%">Mia</span>
|
||||
<span class="t1" style="left:50%;top:94%">艾</span>
|
||||
<span class="t2" style="left:68%;top:14%">Alexandrina</span>
|
||||
<span class="t2" style="left:30%;top:90%">欧阳雨桐溪</span>
|
||||
</div>
|
||||
<div class="dock-wrap">
|
||||
<div class="dock">
|
||||
<div class="row">
|
||||
<div class="fake-input">输入你的名字</div>
|
||||
<div class="fake-btn">查找</div>
|
||||
</div>
|
||||
<div class="found">
|
||||
<strong>找到你了 · 王小明</strong>
|
||||
<small>你在这里 · 静态示意,不可点击</small>
|
||||
<div class="found-row">
|
||||
<span class="ghost-btn">查看全图</span>
|
||||
<small>找到 2 处 · 1 / 2</small>
|
||||
</div>
|
||||
</div>
|
||||
<p class="dock-note">静态设计示意 · 不可交互</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,98 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>大屏展示 · 词云</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg:#ffffff; --surface:#f6f6f3; --surface-warm:#e5e5e0;
|
||||
--fg:#211922; --fg-2:#000000; --muted:#62625b; --meta:#91918c;
|
||||
--border-soft:#e0e0d9; --accent:#e60023;
|
||||
--font-body:"Pin Sans",-apple-system,system-ui,Arial,sans-serif;
|
||||
--font-art:"Pin Sans",-apple-system,"PingFang SC","Noto Sans SC","Microsoft YaHei",system-ui,sans-serif;
|
||||
--text-xs:12px; --text-sm:14px; --text-lg:18px;
|
||||
--radius-md:16px; --radius-sm:12px;
|
||||
--elev-raised:0 4px 16px rgba(33,25,34,0.06);
|
||||
}
|
||||
*,*::before,*::after { box-sizing:border-box; }
|
||||
html,body { margin:0;padding:0;height:100%; }
|
||||
body { background:var(--bg);color:var(--fg);font-family:var(--font-body); }
|
||||
.page { height:100dvh;position:relative;overflow:hidden; }
|
||||
.cloud { position:absolute;inset:0; }
|
||||
.cloud span { position:absolute;transform:translate(-50%,-50%);white-space:nowrap;font-family:var(--font-art);color:var(--fg); }
|
||||
.t1 { font-size:18px;opacity:.55;font-weight:500; }
|
||||
.t2 { font-size:28px;opacity:.7;font-weight:600; }
|
||||
.t3 { font-size:44px;opacity:.85;font-weight:700; }
|
||||
.t4 { font-size:72px;opacity:.94;font-weight:700; }
|
||||
.eye { position:absolute;left:48px;top:48px;font-size:var(--text-xs);letter-spacing:.14em;color:var(--meta);font-weight:700;margin:0;z-index:3; }
|
||||
.art-title { position:absolute;left:50%;top:48px;transform:translateX(-50%);text-align:center;pointer-events:none;margin:0;max-width:min(80vw,900px);z-index:2;width:max-content; }
|
||||
.art-title h1 { margin:0;font-size:clamp(38px,4.2vw,62px);font-weight:700;letter-spacing:-0.02em;line-height:1.1;text-wrap:balance; }
|
||||
.art-title p { margin:10px 0 0;font-size:var(--text-sm);letter-spacing:.24em;color:var(--meta);font-weight:700;white-space:nowrap; }
|
||||
.art-title::after { content:"";display:block;width:48px;height:3px;background:var(--accent);border-radius:3px;margin:14px auto 0;opacity:.9; }
|
||||
.cloud { position:absolute;inset:0;z-index:1; }
|
||||
.caption { position:absolute;left:48px;bottom:48px;margin:0;font-size:var(--text-sm);letter-spacing:.12em;color:var(--meta); }
|
||||
.qr { position:absolute;right:48px;bottom:48px;background:var(--surface);border:1px solid var(--border-soft);border-radius:var(--radius-md);box-shadow:var(--elev-raised);padding:16px;display:flex;gap:16px;align-items:center;max-width:360px; }
|
||||
.qr-code { width:168px;height:168px;flex:none;background:#fff;border-radius:var(--radius-sm);display:grid;grid-template-columns:repeat(9,1fr);grid-template-rows:repeat(9,1fr);gap:1px;padding:10px;border:1px solid var(--border-soft); }
|
||||
.qr-code i { background:#fff;display:block; }
|
||||
.qr-code i.b { background:var(--fg); }
|
||||
.qr h2 { margin:0 0 4px;font-size:var(--text-lg); }
|
||||
.qr p { margin:0;font-size:var(--text-sm);color:var(--muted); }
|
||||
.screen-note { position:absolute;left:48px;top:76px;font-size:var(--text-xs);color:var(--meta); }
|
||||
@media (max-width:760px) {
|
||||
.eye { left:16px;top:16px; }
|
||||
.caption { display:none; }
|
||||
.screen-note { left:16px;top:40px; }
|
||||
.qr { right:16px;left:16px;bottom:16px;max-width:none; }
|
||||
.qr-code { width:120px;height:120px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="page" aria-label="大屏展示静态设计">
|
||||
<p class="eye">LIVE ARCHIVE · 2026</p>
|
||||
<div class="art-title">
|
||||
<h1>2026 毕业典礼 · 全体名单</h1>
|
||||
<p>CLASS OF 2026 · WORD CLOUD</p>
|
||||
</div>
|
||||
<p class="screen-note">静态设计示意 · 不可交互</p>
|
||||
<div class="cloud" aria-hidden="true">
|
||||
<span class="t4" style="left:50%;top:44%">王小明</span>
|
||||
<span class="t3" style="left:32%;top:28%">林嘉怡</span>
|
||||
<span class="t3" style="left:68%;top:28%">Sophia</span>
|
||||
<span class="t3" style="left:24%;top:56%">李欣</span>
|
||||
<span class="t3" style="left:76%;top:58%">Kevin</span>
|
||||
<span class="t3" style="left:50%;top:66%">陈晨</span>
|
||||
<span class="t2" style="left:38%;top:50%">Alex</span>
|
||||
<span class="t2" style="left:62%;top:50%">Lily</span>
|
||||
<span class="t2" style="left:44%;top:20%">张雨</span>
|
||||
<span class="t2" style="left:58%;top:78%">Daniel</span>
|
||||
<span class="t2" style="left:16%;top:40%">赵天宇</span>
|
||||
<span class="t2" style="left:84%;top:42%">Emma</span>
|
||||
<span class="t2" style="left:30%;top:78%">苏晚晴</span>
|
||||
<span class="t2" style="left:70%;top:80%">周子墨</span>
|
||||
<span class="t1" style="left:14%;top:64%">王梓涵</span>
|
||||
<span class="t1" style="left:86%;top:64%">李雨桐</span>
|
||||
<span class="t1" style="left:22%;top:20%">张浩然</span>
|
||||
<span class="t1" style="left:78%;top:18%">刘亦菲</span>
|
||||
<span class="t1" style="left:36%;top:84%">黄俊杰</span>
|
||||
<span class="t1" style="left:64%;top:84%">吴思远</span>
|
||||
<span class="t1" style="left:42%;top:36%">马晓彤</span>
|
||||
<span class="t1" style="left:58%;top:36%">朱子睿</span>
|
||||
<span class="t1" style="left:20%;top:82%">James</span>
|
||||
<span class="t1" style="left:80%;top:82%">Mary</span>
|
||||
<span class="t1" style="left:50%;top:12%">Olivia</span>
|
||||
<span class="t1" style="left:50%;top:90%">沈知遥</span>
|
||||
</div>
|
||||
<p class="caption">THIS WALL IS MADE OF EVERYONE HERE</p>
|
||||
<aside class="qr" aria-label="扫码互动静态示意">
|
||||
<div class="qr-code" aria-hidden="true"><i class="b"></i><i class="b"></i><i class="b"></i><i></i><i class="b"></i><i></i><i class="b"></i><i class="b"></i><i class="b"></i><i class="b"></i><i></i><i></i><i></i><i></i><i class="b"></i><i></i><i></i><i class="b"></i><i class="b"></i><i></i><i class="b"></i><i></i><i></i><i></i><i class="b"></i><i></i><i class="b"></i><i></i><i></i><i></i><i class="b"></i><i></i><i class="b"></i><i></i><i></i><i></i><i></i><i class="b"></i><i class="b"></i><i></i><i></i><i class="b"></i><i></i><i class="b"></i><i></i><i></i><i class="b"></i><i></i><i></i><i class="b"></i><i></i><i class="b"></i><i></i><i class="b"></i><i class="b"></i><i class="b"></i><i></i><i></i><i class="b"></i><i></i><i></i><i class="b"></i><i></i><i class="b"></i><i class="b"></i><i></i><i class="b"></i><i></i><i></i><i></i><i></i><i class="b"></i><i></i><i></i><i class="b"></i><i></i><i class="b"></i><i class="b"></i><i class="b"></i><i></i><i></i><i class="b"></i></div>
|
||||
<div>
|
||||
<h2>找到你的名字</h2>
|
||||
<p>扫码输入名字</p>
|
||||
<p>静态占位 · 不可扫码</p>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
ARG VITE_REALTIME_MODE=websocket
|
||||
ARG VITE_REALTIME_WS_URL=/ws
|
||||
ARG VITE_WORDCLOUD_API_BASE_URL=/wordcloud-api
|
||||
ARG VITE_WORDCLOUD_DEFAULT_JOB_ID=bd1f240adcb4458f857c40ac427122c6
|
||||
ENV VITE_REALTIME_MODE=$VITE_REALTIME_MODE
|
||||
ENV VITE_REALTIME_WS_URL=$VITE_REALTIME_WS_URL
|
||||
ENV VITE_WORDCLOUD_API_BASE_URL=$VITE_WORDCLOUD_API_BASE_URL
|
||||
ENV VITE_WORDCLOUD_DEFAULT_JOB_ID=$VITE_WORDCLOUD_DEFAULT_JOB_ID
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
ENV WORDCLOUD_API_UPSTREAM=http://192.168.31.213:8000
|
||||
COPY deploy/nginx.conf /etc/nginx/templates/default.conf.template
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1/healthz | grep -q '^ok$'
|
||||
@@ -0,0 +1,76 @@
|
||||
# wc-board
|
||||
|
||||
词云现场互动 Web 应用,包含个人查找、大屏展示和遥控器三个页面。`demo` 可用于离线验收;其他路由会在运行时加载 WordCloud 任务的位置数据与 SVG。
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
打开以下路由验证离线完整流程:
|
||||
|
||||
```text
|
||||
/cloud/demo
|
||||
/screen/demo
|
||||
/control/demo
|
||||
```
|
||||
|
||||
在同一浏览器的两个 tab 中分别打开大屏与遥控器后,遥控器提交名字可以让大屏聚焦;多个连续提交会排队展示。
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
npm run build
|
||||
npm run preview
|
||||
npm test
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
- `src/pages/`:Personal Search、Public Display、Remote Controller。
|
||||
- `src/components/`:词云、搜索 dock、QR panel、状态组件。
|
||||
- `src/data/clouds/`:离线 `demo` 词云数据。
|
||||
- `src/lib/wordcloudApi.ts`:WordCloud API 适配器;运行时读取位置数据与 SVG。
|
||||
- `src/hooks/useCloud.ts`:为三个页面统一加载本地 demo 或远程任务词云。
|
||||
- `src/hooks/`:focus lifecycle、queue 和 realtime integration。
|
||||
- `src/realtime/`:BroadcastChannel adapter;后续 WebSocket/Supabase 等实现替换这一层。
|
||||
- `src/styles/global.css`:从既有设计提取的全局 tokens。
|
||||
- `Design/`:已确认的视觉基准和设计 handoff。
|
||||
|
||||
本地开发默认使用同一浏览器内的 BroadcastChannel;生产构建默认使用同源 WebSocket adapter,适合多设备访问。当前 realtime 服务是内存转发,暂不保存名单或事件。
|
||||
|
||||
## Production Deployment
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
默认端口由 `.env` 的 `APP_PORT` 控制,例如 `APP_PORT=47880` 时通过下面地址访问:
|
||||
|
||||
```text
|
||||
http://<server-host>:47880/cloud/demo
|
||||
http://<server-host>:47880/screen/demo
|
||||
http://<server-host>:47880/control/demo
|
||||
```
|
||||
|
||||
生产构建使用 WebSocket adapter,浏览器连接同源 `/ws`;Nginx 会将该路径转发到 `realtime` 容器。因为二维码按当前浏览器的 origin 生成,通过 A 域名打开大屏时,扫码会进入 A 域名的 Controller,而不会跳去 B 域名。
|
||||
|
||||
如果 A 和 B 是不同域名并都指向同一台服务器,可以在外层网关或 DNS 上都转发到 Compose 暴露的端口;前端与二维码会保留访问者实际使用的域名。
|
||||
|
||||
除 `demo` 外,路由参数即 WordCloud `jobId`。例如:
|
||||
|
||||
```text
|
||||
/cloud/bd1f240adcb4458f857c40ac427122c6
|
||||
/screen/bd1f240adcb4458f857c40ac427122c6
|
||||
/control/bd1f240adcb4458f857c40ac427122c6
|
||||
```
|
||||
|
||||
`/cloud/imported`、`/screen/imported` 与 `/control/imported` 是由 `VITE_WORDCLOUD_DEFAULT_JOB_ID` 配置的默认任务别名。应用默认从同源 `/wordcloud-api` 读取位置与 SVG,Nginx 再将其转发到 `WORDCLOUD_API_UPSTREAM`,默认值为 `http://192.168.31.213:8000`。这样浏览器不需要跨域读取 SVG,且多域名部署时仍保留访问者实际使用的域名。
|
||||
|
||||
本地 `npm run dev` 也会将 `/wordcloud-api` 转发到同一个后端。只有在已具备可靠 CORS 的场景,才需要把 `VITE_WORDCLOUD_API_BASE_URL` 改为完整的远端 URL。
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
APP_NAME="${1:-wordcloud-board}"
|
||||
TARGET_ENV="${2:-production}"
|
||||
IMAGE_TAG="${3:-latest}"
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
COMPOSE="docker compose"
|
||||
else
|
||||
COMPOSE="docker-compose"
|
||||
fi
|
||||
|
||||
export COMPOSE_PROJECT_NAME="${APP_NAME}"
|
||||
export HARBOR_REGISTRY="${HARBOR_REGISTRY:-192.168.31.213:10081}"
|
||||
export HARBOR_PROJECT="${HARBOR_PROJECT:-wordcloud}"
|
||||
export APP_PORT="${APP_PORT:-47880}"
|
||||
export WORDCLOUD_API_UPSTREAM="${WORDCLOUD_API_UPSTREAM:-http://192.168.31.213:8000}"
|
||||
export IMAGE_TAG
|
||||
|
||||
echo "[deploy] project=${APP_NAME} env=${TARGET_ENV} image_tag=${IMAGE_TAG} port=${APP_PORT}"
|
||||
"$COMPOSE" pull
|
||||
"$COMPOSE" up -d --remove-orphans
|
||||
"$COMPOSE" ps
|
||||
@@ -0,0 +1,57 @@
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
|
||||
location = /healthz {
|
||||
access_log off;
|
||||
default_type text/plain;
|
||||
return 200 'ok\n';
|
||||
}
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://realtime:8787;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_read_timeout 1d;
|
||||
proxy_send_timeout 1d;
|
||||
}
|
||||
|
||||
location ^~ /wordcloud-api/ {
|
||||
proxy_pass ${WORDCLOUD_API_UPSTREAM}/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $proxy_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
}
|
||||
|
||||
location ^~ /assets/ {
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-store, must-revalidate";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
services:
|
||||
app:
|
||||
image: ${HARBOR_REGISTRY:-192.168.31.213:10081}/wordcloud/wordcloud-board-app:${IMAGE_TAG:-latest}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
args:
|
||||
VITE_REALTIME_MODE: ${VITE_REALTIME_MODE:-websocket}
|
||||
VITE_REALTIME_WS_URL: ${VITE_REALTIME_WS_URL:-/ws}
|
||||
VITE_WORDCLOUD_API_BASE_URL: ${VITE_WORDCLOUD_API_BASE_URL:-/wordcloud-api}
|
||||
VITE_WORDCLOUD_DEFAULT_JOB_ID: ${VITE_WORDCLOUD_DEFAULT_JOB_ID:-bd1f240adcb4458f857c40ac427122c6}
|
||||
ports:
|
||||
- "${APP_PORT:-47880}:80"
|
||||
depends_on:
|
||||
realtime:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
WORDCLOUD_API_UPSTREAM: ${WORDCLOUD_API_UPSTREAM:-http://192.168.31.213:8000}
|
||||
restart: unless-stopped
|
||||
|
||||
realtime:
|
||||
image: ${HARBOR_REGISTRY:-192.168.31.213:10081}/wordcloud/wordcloud-board-realtime:${IMAGE_TAG:-latest}
|
||||
build:
|
||||
context: ./server
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
REALTIME_PORT: 8787
|
||||
expose:
|
||||
- "8787"
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,26 @@
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'node_modules'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'no-console': 'warn',
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
},
|
||||
},
|
||||
);
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>找到你自己 · 词云</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+5339
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "wc-board",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:realtime": "npm --prefix server run dev",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"test:server": "npm --prefix server test"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.45.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^8.57.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.14.0",
|
||||
"jsdom": "^24.1.3",
|
||||
"typescript": "~5.6.3",
|
||||
"typescript-eslint": "^7.18.0",
|
||||
"vite": "^5.4.19",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.7 MiB |
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
COPY realtime-server.mjs ./
|
||||
|
||||
USER node
|
||||
EXPOSE 8787
|
||||
HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:8787/healthz | grep -q '"status":"ok"'
|
||||
|
||||
CMD ["node", "realtime-server.mjs"]
|
||||
Generated
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "wc-board-realtime",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wc-board-realtime",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "wc-board-realtime",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node realtime-server.mjs",
|
||||
"start": "node realtime-server.mjs",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
function isFocusEvent(value) {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
value.type === 'FOCUS_NAME' &&
|
||||
typeof value.cloudId === 'string' &&
|
||||
typeof value.name === 'string' &&
|
||||
typeof value.messageId === 'string' &&
|
||||
typeof value.sentAt === 'number' &&
|
||||
(value.targetIndex === undefined ||
|
||||
(typeof value.targetIndex === 'number' &&
|
||||
Number.isInteger(value.targetIndex) &&
|
||||
value.targetIndex >= 0))
|
||||
);
|
||||
}
|
||||
|
||||
export function createRealtimeServer(port = Number(process.env.REALTIME_PORT || 8787)) {
|
||||
const server = createServer((request, response) => {
|
||||
if (request.url === '/healthz') {
|
||||
response.writeHead(200, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify({ status: 'ok' }));
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(404, { 'content-type': 'text/plain' });
|
||||
response.end('Not found');
|
||||
});
|
||||
const realtime = new WebSocketServer({
|
||||
server,
|
||||
maxPayload: 16 * 1024,
|
||||
});
|
||||
const heartbeat = setInterval(() => {
|
||||
for (const socket of realtime.clients) {
|
||||
if (socket.isAlive === false) {
|
||||
socket.terminate();
|
||||
continue;
|
||||
}
|
||||
|
||||
socket.isAlive = false;
|
||||
socket.ping();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
heartbeat.unref();
|
||||
|
||||
realtime.on('connection', (socket) => {
|
||||
socket.isAlive = true;
|
||||
socket.on('pong', () => {
|
||||
socket.isAlive = true;
|
||||
});
|
||||
|
||||
socket.on('message', (data) => {
|
||||
let value;
|
||||
|
||||
try {
|
||||
value = JSON.parse(data.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isFocusEvent(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = JSON.stringify(value);
|
||||
|
||||
for (const client of realtime.clients) {
|
||||
if (client !== socket && client.readyState === client.OPEN) {
|
||||
client.send(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
realtime.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
||||
const port = Number(process.env.REALTIME_PORT || 8787);
|
||||
createRealtimeServer(port).listen(port, () => {
|
||||
console.log(`Realtime WebSocket server listening on port ${port}`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRealtimeServer } from './realtime-server.mjs';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
test('broadcasts focus events to other connected clients', async () => {
|
||||
const server = createRealtimeServer(0);
|
||||
await new Promise((resolve) => server.listen(resolve));
|
||||
const { port } = server.address();
|
||||
|
||||
try {
|
||||
const screen = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
const controller = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
|
||||
await Promise.all([
|
||||
new Promise((resolve) => screen.once('open', resolve)),
|
||||
new Promise((resolve) => controller.once('open', resolve)),
|
||||
]);
|
||||
|
||||
const received = new Promise((resolve) => {
|
||||
screen.once('message', (data) => resolve(JSON.parse(data)));
|
||||
});
|
||||
|
||||
controller.send(JSON.stringify({
|
||||
type: 'FOCUS_NAME',
|
||||
cloudId: 'demo',
|
||||
name: 'Sophia',
|
||||
messageId: 'focus-1',
|
||||
sentAt: 1,
|
||||
}));
|
||||
|
||||
assert.deepEqual(await received, {
|
||||
type: 'FOCUS_NAME',
|
||||
cloudId: 'demo',
|
||||
name: 'Sophia',
|
||||
messageId: 'focus-1',
|
||||
sentAt: 1,
|
||||
});
|
||||
|
||||
screen.close();
|
||||
controller.close();
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AppRoutes } from './AppRoutes';
|
||||
|
||||
export default function App() {
|
||||
return <AppRoutes />;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { afterEach, vi } from 'vitest';
|
||||
import { AppRoutes } from './AppRoutes';
|
||||
|
||||
const remoteJobId = 'bd1f240adcb4458f857c40ac427122c6';
|
||||
const remoteLocationResult = {
|
||||
job_id: remoteJobId,
|
||||
query: '',
|
||||
mode: 'exact',
|
||||
total: 2,
|
||||
canvas_width: 4000,
|
||||
canvas_height: 3563,
|
||||
matches: [
|
||||
{
|
||||
id: 1,
|
||||
name: '王小明',
|
||||
x: 100,
|
||||
y: 100,
|
||||
font_size: 80,
|
||||
color: '#000000',
|
||||
orientation: 'horizontal',
|
||||
box_x: 100,
|
||||
box_y: 100,
|
||||
box_width: 240,
|
||||
box_height: 80,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: ' 李欣',
|
||||
x: 400,
|
||||
y: 400,
|
||||
font_size: 48,
|
||||
color: '#000000',
|
||||
orientation: 'vertical',
|
||||
box_x: 400,
|
||||
box_y: 400,
|
||||
box_width: 48,
|
||||
box_height: 96,
|
||||
},
|
||||
],
|
||||
};
|
||||
const remoteSvg = `<svg viewBox="0 0 4000 3563" xmlns="http://www.w3.org/2000/svg">${
|
||||
'<path d="M0 0"/>'.repeat(5)
|
||||
}</svg>`;
|
||||
|
||||
function mockRemoteCloudApi() {
|
||||
return vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
|
||||
if (url.endsWith(`/api/jobs/${remoteJobId}`)) {
|
||||
return new Response('job not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (url.includes(`/api/jobs/${remoteJobId}/locations`)) {
|
||||
return new Response(JSON.stringify(remoteLocationResult), { status: 200 });
|
||||
}
|
||||
|
||||
if (url.endsWith(`/api/jobs/${remoteJobId}/files/svg`)) {
|
||||
return new Response(remoteSvg, { status: 200 });
|
||||
}
|
||||
|
||||
return new Response('not found', { status: 404 });
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('application routes', () => {
|
||||
it('renders the personal search word cloud and input', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/cloud/demo']}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText('王小明')).toHaveLength(2);
|
||||
expect(screen.getByPlaceholderText('输入你的名字')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('enables the localized title layer on both word cloud views', () => {
|
||||
const personal = render(
|
||||
<MemoryRouter initialEntries={['/cloud/demo']}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(personal.container.querySelector('[class*="title"]'))
|
||||
.toHaveAttribute('data-layered', 'true');
|
||||
personal.unmount();
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/screen/demo']}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(document.querySelector('[class*="title"]'))
|
||||
.toHaveAttribute('data-layered', 'true');
|
||||
});
|
||||
|
||||
it('renders the public display with a real QR panel', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/screen/demo']}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { name: '2026 毕业典礼 · 全体名单' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('img', { name: '扫码进入遥控器' })).toBeInTheDocument();
|
||||
expect(screen.getByText('找到你的名字')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('provides a fullscreen control on the public display', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/screen/demo']}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '进入全屏' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads a remote job from locations when the job-status route omits disk-backed jobs', async () => {
|
||||
const observe = vi.fn();
|
||||
class ResizeObserverMock {
|
||||
constructor() {}
|
||||
|
||||
observe = observe;
|
||||
disconnect = vi.fn();
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
top: 0,
|
||||
right: 1200,
|
||||
bottom: 800,
|
||||
left: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
const fetchMock = mockRemoteCloudApi();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const user = userEvent.setup();
|
||||
|
||||
const personal = render(
|
||||
<MemoryRouter initialEntries={[`/cloud/${remoteJobId}`]}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(personal.container.querySelectorAll('g[data-word-id]')).toHaveLength(2);
|
||||
});
|
||||
expect(observe).toHaveBeenCalledWith(expect.any(HTMLDivElement));
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`/wordcloud-api/api/jobs/${remoteJobId}/locations?name=`,
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalledWith(`/wordcloud-api/api/jobs/${remoteJobId}`);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('输入你的名字'), '王小明');
|
||||
await user.click(screen.getByRole('button', { name: '查找' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(personal.container.querySelector('[data-phase="focusing"]')?.getAttribute('style'))
|
||||
.not.toContain('translate3d(0px, 0px, 0px)');
|
||||
});
|
||||
personal.unmount();
|
||||
|
||||
const screenView = render(
|
||||
<MemoryRouter initialEntries={[`/screen/${remoteJobId}`]}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screenView.container.querySelectorAll('g[data-word-id]')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the remote controller form', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/control/demo']}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { name: '找到你自己' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('你的名字')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lets the controller move between duplicate name matches', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/control/demo']}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByLabelText('你的名字'), 'Alex');
|
||||
await user.click(screen.getByRole('button', { name: '在大屏上找到我' }));
|
||||
|
||||
expect(screen.getByText('找到 2 处 · 1 / 2')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '下一个名字' }));
|
||||
expect(screen.getByText('找到 2 处 · 2 / 2')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '上一个名字' }));
|
||||
expect(screen.getByText('找到 2 处 · 1 / 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
|
||||
import { PersonalSearchPage } from './pages/PersonalSearchPage';
|
||||
import { PublicDisplayPage } from './pages/PublicDisplayPage';
|
||||
import { RemoteControllerPage } from './pages/RemoteControllerPage';
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/cloud/imported" replace />} />
|
||||
<Route path="/cloud/:cloudId" element={<PersonalRoute />} />
|
||||
<Route path="/screen/:cloudId" element={<PublicRoute />} />
|
||||
<Route path="/control/:cloudId" element={<ControllerRoute />} />
|
||||
<Route path="*" element={<Navigate to="/cloud/imported" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonalRoute() {
|
||||
const { cloudId = '' } = useParams();
|
||||
|
||||
return <PersonalSearchPage cloudId={cloudId} />;
|
||||
}
|
||||
|
||||
function PublicRoute() {
|
||||
const { cloudId = '' } = useParams();
|
||||
|
||||
return <PublicDisplayPage cloudId={cloudId} />;
|
||||
}
|
||||
|
||||
function ControllerRoute() {
|
||||
const { cloudId = '' } = useParams();
|
||||
|
||||
return <RemoteControllerPage cloudId={cloudId} />;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
.header {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--motion-normal) ease;
|
||||
}
|
||||
|
||||
.header[data-dimmed='true'] .eyebrow,
|
||||
.header[data-dimmed='true'] .title h1,
|
||||
.header[data-dimmed='true'] .title p {
|
||||
opacity: 0.14;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
position: absolute;
|
||||
top: max(16px, env(safe-area-inset-top));
|
||||
left: 16px;
|
||||
z-index: 3;
|
||||
margin: 0;
|
||||
color: var(--text-meta);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: absolute;
|
||||
top: max(44px, calc(env(safe-area-inset-top) + 36px));
|
||||
left: 50%;
|
||||
z-index: 2;
|
||||
width: max-content;
|
||||
max-width: min(88vw, 640px);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.title h1 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: clamp(22px, 6vw, 32px);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--tracking-display);
|
||||
line-height: 1.2;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.title p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--text-meta);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.title::after {
|
||||
width: 32px;
|
||||
height: 2px;
|
||||
margin: 10px auto 0;
|
||||
content: '';
|
||||
display: block;
|
||||
opacity: 0.9;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.eyebrow {
|
||||
top: 48px;
|
||||
left: 48px;
|
||||
}
|
||||
|
||||
.title {
|
||||
top: 48px;
|
||||
max-width: min(80vw, 900px);
|
||||
}
|
||||
|
||||
.title h1 {
|
||||
font-size: clamp(38px, 4.2vw, 62px);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.title p {
|
||||
margin-top: 10px;
|
||||
font-size: var(--text-sm);
|
||||
letter-spacing: 0.24em;
|
||||
}
|
||||
|
||||
.title::after {
|
||||
width: 48px;
|
||||
height: 3px;
|
||||
margin-top: 14px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import styles from './CloudHeader.module.css';
|
||||
|
||||
interface CloudHeaderProps {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
dimmed?: boolean;
|
||||
layered?: boolean;
|
||||
}
|
||||
|
||||
export function CloudHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
subtitle,
|
||||
dimmed = false,
|
||||
layered = false,
|
||||
}: CloudHeaderProps) {
|
||||
return (
|
||||
<header
|
||||
className={styles.header}
|
||||
data-dimmed={dimmed}
|
||||
data-layered={layered}
|
||||
>
|
||||
<p className={styles.eyebrow}>{eyebrow}</p>
|
||||
<div className={styles.title} data-layered={layered}>
|
||||
<h1>{title}</h1>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import styles from './CloudUnavailableState.module.css';
|
||||
|
||||
export function CloudLoadingState() {
|
||||
return (
|
||||
<main className={styles.page} aria-label="正在加载词云">
|
||||
<div className={styles.box}>
|
||||
<h1>正在加载词云</h1>
|
||||
<p>正在读取名单和词云画面。</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.page {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 48px 16px;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.box {
|
||||
max-width: 440px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.box h1 {
|
||||
margin: 0 0 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-2xl);
|
||||
letter-spacing: var(--tracking-display);
|
||||
}
|
||||
|
||||
.box p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import styles from './CloudUnavailableState.module.css';
|
||||
|
||||
export function CloudUnavailableState() {
|
||||
return (
|
||||
<main className={styles.page} aria-label="词云未找到">
|
||||
<div className={styles.box}>
|
||||
<h1>暂时没有找到这个词云</h1>
|
||||
<p>请确认二维码对应的词云。</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
.success {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
justify-items: center;
|
||||
padding: 32px 0;
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: var(--success);
|
||||
background: var(--surface-warm);
|
||||
border-radius: var(--radius-pill);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.success h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
|
||||
.success p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.duplicateNav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ghostButton {
|
||||
min-height: 44px;
|
||||
padding: 10px 16px;
|
||||
color: var(--text-strong);
|
||||
background: var(--surface-warm);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ghostButton:disabled {
|
||||
color: var(--text-meta);
|
||||
background: var(--surface);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import styles from './ControllerSuccessState.module.css';
|
||||
|
||||
interface ControllerSuccessStateProps {
|
||||
targetCount: number;
|
||||
selectedTargetIndex: number;
|
||||
onSelectTarget: (targetIndex: number) => void;
|
||||
}
|
||||
|
||||
export function ControllerSuccessState({
|
||||
targetCount,
|
||||
selectedTargetIndex,
|
||||
onSelectTarget,
|
||||
}: ControllerSuccessStateProps) {
|
||||
const hasDuplicateMatches = targetCount > 1;
|
||||
|
||||
return (
|
||||
<div className={styles.success} aria-live="polite" aria-label="成功状态">
|
||||
<div className={styles.mark} aria-hidden="true">
|
||||
✓
|
||||
</div>
|
||||
<h2>找到了 · 抬头看看大屏</h2>
|
||||
<p>几秒后大屏会恢复完整词云 · 再找一个名字</p>
|
||||
{hasDuplicateMatches ? (
|
||||
<div className={styles.duplicateNav} aria-label="重复名字切换">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ghostButton}
|
||||
onClick={() => onSelectTarget(Math.max(0, selectedTargetIndex - 1))}
|
||||
disabled={selectedTargetIndex === 0}
|
||||
>
|
||||
上一个名字
|
||||
</button>
|
||||
<small>
|
||||
找到 {targetCount} 处 · {selectedTargetIndex + 1} / {targetCount}
|
||||
</small>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ghostButton}
|
||||
onClick={() => onSelectTarget(Math.min(targetCount - 1, selectedTargetIndex + 1))}
|
||||
disabled={selectedTargetIndex === targetCount - 1}
|
||||
>
|
||||
下一个名字
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
.dockWrap {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0 16px calc(16px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.dock {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
width: min(520px, 100%);
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--elev-raised);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
padding: 12px 15px;
|
||||
color: var(--text-primary);
|
||||
background: var(--background);
|
||||
border: 1px solid var(--text-meta);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--text-meta);
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
min-height: 48px;
|
||||
padding: 12px 20px;
|
||||
color: var(--text-strong);
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.notFound {
|
||||
padding: 10px 12px;
|
||||
background: var(--background);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.notFound strong {
|
||||
display: block;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.notFound small {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { FormEvent } from 'react';
|
||||
import { SearchResultState } from './SearchResultState';
|
||||
import styles from './FloatingSearchDock.module.css';
|
||||
|
||||
interface FloatingSearchDockProps {
|
||||
name: string;
|
||||
status: 'idle' | 'not-found' | 'found';
|
||||
targetCount: number;
|
||||
selectedTargetIndex: number;
|
||||
onChange: (name: string) => void;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
onReset: () => void;
|
||||
onSelectTarget: (targetIndex: number) => void;
|
||||
}
|
||||
|
||||
export function FloatingSearchDock({
|
||||
name,
|
||||
status,
|
||||
targetCount,
|
||||
selectedTargetIndex,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onReset,
|
||||
onSelectTarget,
|
||||
}: FloatingSearchDockProps) {
|
||||
return (
|
||||
<div className={styles.dockWrap}>
|
||||
<div className={styles.dock}>
|
||||
<form className={styles.row} onSubmit={onSubmit}>
|
||||
<label className="visually-hidden" htmlFor="personal-search-name">
|
||||
你的名字
|
||||
</label>
|
||||
<input
|
||||
id="personal-search-name"
|
||||
className={styles.searchInput}
|
||||
type="text"
|
||||
name="name"
|
||||
autoComplete="off"
|
||||
placeholder="输入你的名字"
|
||||
value={name}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
<button className={styles.searchButton} type="submit">
|
||||
查找
|
||||
</button>
|
||||
</form>
|
||||
{status === 'not-found' ? (
|
||||
<div className={styles.notFound} aria-live="polite">
|
||||
<strong>暂时没有找到这个名字</strong>
|
||||
<small>试试完整名字或英文大小写写法</small>
|
||||
</div>
|
||||
) : null}
|
||||
{status === 'found' ? (
|
||||
<SearchResultState
|
||||
query={name}
|
||||
targetCount={targetCount}
|
||||
selectedTargetIndex={selectedTargetIndex}
|
||||
onReset={onReset}
|
||||
onSelectTarget={onSelectTarget}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
.button {
|
||||
position: absolute;
|
||||
top: max(16px, env(safe-area-inset-top));
|
||||
right: 16px;
|
||||
z-index: 4;
|
||||
display: grid;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(200, 200, 193, 0.82);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--elev-raised);
|
||||
backdrop-filter: blur(8px);
|
||||
transition:
|
||||
background var(--motion-fast) ease,
|
||||
color var(--motion-fast) ease,
|
||||
transform var(--motion-fast) ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.button {
|
||||
top: 48px;
|
||||
right: 48px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import styles from './FullscreenButton.module.css';
|
||||
|
||||
type FullscreenDocument = Document & {
|
||||
webkitExitFullscreen?: () => Promise<void> | void;
|
||||
webkitFullscreenElement?: Element | null;
|
||||
};
|
||||
|
||||
type FullscreenElement = HTMLElement & {
|
||||
webkitRequestFullscreen?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
function getFullscreenElement() {
|
||||
const fullscreenDocument = document as FullscreenDocument;
|
||||
return document.fullscreenElement ?? fullscreenDocument.webkitFullscreenElement ?? null;
|
||||
}
|
||||
|
||||
export function FullscreenButton() {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const syncFullscreenState = () => {
|
||||
setIsFullscreen(Boolean(getFullscreenElement()));
|
||||
};
|
||||
|
||||
syncFullscreenState();
|
||||
document.addEventListener('fullscreenchange', syncFullscreenState);
|
||||
document.addEventListener('webkitfullscreenchange', syncFullscreenState);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', syncFullscreenState);
|
||||
document.removeEventListener('webkitfullscreenchange', syncFullscreenState);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = async () => {
|
||||
const fullscreenDocument = document as FullscreenDocument;
|
||||
|
||||
try {
|
||||
if (getFullscreenElement()) {
|
||||
const exitFullscreen = document.exitFullscreen ?? fullscreenDocument.webkitExitFullscreen;
|
||||
await exitFullscreen?.call(document);
|
||||
return;
|
||||
}
|
||||
|
||||
const target = document.documentElement as FullscreenElement;
|
||||
const requestFullscreen = target.requestFullscreen ?? target.webkitRequestFullscreen;
|
||||
await requestFullscreen?.call(target);
|
||||
} catch {
|
||||
// The browser can deny a fullscreen request; preserve the current screen state.
|
||||
}
|
||||
};
|
||||
|
||||
const label = isFullscreen ? '退出全屏' : '进入全屏';
|
||||
const Icon = isFullscreen ? Minimize2 : Maximize2;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={styles.button}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-pressed={isFullscreen}
|
||||
title={label}
|
||||
onClick={() => {
|
||||
void toggleFullscreen();
|
||||
}}
|
||||
>
|
||||
<Icon aria-hidden="true" size={20} strokeWidth={1.8} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
.panel {
|
||||
position: absolute;
|
||||
right: 48px;
|
||||
bottom: 48px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
width: min(360px, calc(100vw - 96px));
|
||||
padding: 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--elev-raised);
|
||||
}
|
||||
|
||||
.code {
|
||||
width: 168px;
|
||||
height: 168px;
|
||||
flex: none;
|
||||
display: block;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.panel {
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
bottom: 16px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.code {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toDataURL } from 'qrcode';
|
||||
import styles from './QRPanel.module.css';
|
||||
|
||||
interface QRPanelProps {
|
||||
cloudId: string;
|
||||
}
|
||||
|
||||
export function QRPanel({ cloudId }: QRPanelProps) {
|
||||
const [qrDataUrl, setQRDataUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const url = new URL(`/control/${encodeURIComponent(cloudId)}`, window.location.origin).href;
|
||||
|
||||
toDataURL(url, {
|
||||
errorCorrectionLevel: 'M',
|
||||
margin: 1,
|
||||
width: 336,
|
||||
color: {
|
||||
dark: '#211922',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
.then((dataUrl) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setQRDataUrl(dataUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setQRDataUrl(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cloudId]);
|
||||
|
||||
return (
|
||||
<aside className={styles.panel} aria-label="扫码互动">
|
||||
{qrDataUrl ? (
|
||||
<img
|
||||
className={styles.code}
|
||||
src={qrDataUrl}
|
||||
alt="扫码进入遥控器"
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.code} aria-hidden="true" />
|
||||
)}
|
||||
<div className={styles.content}>
|
||||
<h2>找到你的名字</h2>
|
||||
<p>扫码输入名字</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
.result {
|
||||
padding: 10px 12px;
|
||||
background: var(--background);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.result strong {
|
||||
display: block;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.result small {
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.resultRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ghostButton {
|
||||
min-height: 44px;
|
||||
padding: 10px 16px;
|
||||
color: var(--text-strong);
|
||||
background: var(--surface-warm);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import styles from './SearchResultState.module.css';
|
||||
|
||||
interface SearchResultStateProps {
|
||||
query: string;
|
||||
targetCount: number;
|
||||
selectedTargetIndex: number;
|
||||
onReset: () => void;
|
||||
onSelectTarget: (targetIndex: number) => void;
|
||||
}
|
||||
|
||||
export function SearchResultState({
|
||||
query,
|
||||
targetCount,
|
||||
selectedTargetIndex,
|
||||
onReset,
|
||||
onSelectTarget,
|
||||
}: SearchResultStateProps) {
|
||||
const hasMultipleTargets = targetCount > 1;
|
||||
|
||||
return (
|
||||
<div className={styles.result} aria-live="polite">
|
||||
<strong>找到你了 · {query}</strong>
|
||||
<small>{hasMultipleTargets ? `找到 ${targetCount} 处` : '你在这里'}</small>
|
||||
<div className={styles.resultRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ghostButton}
|
||||
onClick={onReset}
|
||||
>
|
||||
查看全图
|
||||
</button>
|
||||
{hasMultipleTargets ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ghostButton}
|
||||
onClick={() => onSelectTarget(Math.max(0, selectedTargetIndex - 1))}
|
||||
disabled={selectedTargetIndex === 0}
|
||||
>
|
||||
上一处
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ghostButton}
|
||||
onClick={() => onSelectTarget(Math.min(targetCount - 1, selectedTargetIndex + 1))}
|
||||
disabled={selectedTargetIndex === targetCount - 1}
|
||||
>
|
||||
下一处
|
||||
</button>
|
||||
<small className={styles.count}>
|
||||
{selectedTargetIndex + 1} / {targetCount}
|
||||
</small>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
.frost {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
z-index: 1;
|
||||
width: min(88vw, 660px);
|
||||
height: 132px;
|
||||
transform: translateX(-50%);
|
||||
background: radial-gradient(
|
||||
120% 120% at 50% 50%,
|
||||
rgba(255, 255, 255, 0.34) 0%,
|
||||
rgba(255, 255, 255, 0.16) 48%,
|
||||
rgba(255, 255, 255, 0.04) 78%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
backdrop-filter: blur(22px) saturate(180%) brightness(1.12) contrast(1.05);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(180%) brightness(1.12) contrast(1.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 24px;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.5),
|
||||
0 10px 24px rgba(0, 0, 0, 0.06);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.frost {
|
||||
top: 24px;
|
||||
width: min(80vw, 760px);
|
||||
height: 150px;
|
||||
border-radius: 28px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import styles from './TitleFrost.module.css';
|
||||
|
||||
export function TitleFrost() {
|
||||
return <div aria-hidden="true" className={styles.frost} />;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
.canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.stageFrame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.stageBox {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.stageBox[data-fitted='true'] {
|
||||
width: min(100%, calc(100dvh * var(--source-aspect)));
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
aspect-ratio: var(--source-aspect);
|
||||
}
|
||||
|
||||
.stage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform-origin: 0 0;
|
||||
will-change: transform;
|
||||
transition: transform var(--motion-focus) var(--ease-camera);
|
||||
}
|
||||
|
||||
.stage[data-phase='returning'] {
|
||||
transition: transform var(--motion-return) var(--ease-camera);
|
||||
}
|
||||
|
||||
.sourcePreview,
|
||||
.sourceVector {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sourcePreview {
|
||||
display: block;
|
||||
object-fit: fill;
|
||||
}
|
||||
|
||||
.sourceVector svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sourceVector g[data-word-id] {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
transition:
|
||||
opacity var(--motion-normal) ease,
|
||||
filter var(--motion-fast) ease,
|
||||
transform var(--motion-focus) var(--ease-camera);
|
||||
}
|
||||
|
||||
.sourceVector[data-has-active='true'] g[data-word-id] {
|
||||
opacity: var(--dimmed-opacity);
|
||||
}
|
||||
|
||||
.sourceVector[data-has-active='true'] g[data-active='true'] {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 2px 12px rgba(230, 0, 35, 0.22));
|
||||
transform: scale(var(--target-scale));
|
||||
}
|
||||
|
||||
.sourceVector[data-has-active='true'] g[data-active='true'] path {
|
||||
fill: var(--accent);
|
||||
}
|
||||
|
||||
.word {
|
||||
position: absolute;
|
||||
color: var(--cloud-foreground);
|
||||
font-family: var(--font-art);
|
||||
white-space: nowrap;
|
||||
opacity: var(--base-opacity);
|
||||
transition:
|
||||
opacity var(--motion-normal) ease,
|
||||
transform var(--motion-focus) var(--ease-camera),
|
||||
color var(--motion-fast) ease;
|
||||
}
|
||||
|
||||
.word[data-dimmed='true'] {
|
||||
opacity: var(--dimmed-opacity);
|
||||
}
|
||||
|
||||
.word[data-active='true'] {
|
||||
z-index: 5;
|
||||
color: var(--cloud-accent);
|
||||
text-shadow: 0 2px 12px rgba(230, 0, 35, 0.16);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, vi } from 'vitest';
|
||||
import { WordCloudCanvas } from './WordCloudCanvas';
|
||||
import type { WordCloudWord } from '../types/cloud';
|
||||
|
||||
const words: WordCloudWord[] = [
|
||||
{
|
||||
id: 'horizontal',
|
||||
name: '魏大正',
|
||||
x: 50,
|
||||
y: 50,
|
||||
fontSize: 83,
|
||||
tier: 4,
|
||||
},
|
||||
{
|
||||
id: 'vertical',
|
||||
name: '梅瑞麟',
|
||||
x: 25,
|
||||
y: 25,
|
||||
fontSize: 83,
|
||||
rotation: 90,
|
||||
tier: 4,
|
||||
},
|
||||
];
|
||||
|
||||
const measuredWords: Array<WordCloudWord & { sourceBox: { width: number; height: number } }> = [
|
||||
{ ...words[0], sourceBox: { width: 249, height: 75 } },
|
||||
{ ...words[1], sourceBox: { width: 76, height: 249 } },
|
||||
];
|
||||
|
||||
const sourceSvg = [
|
||||
'<svg width="4000" height="3563" viewBox="0 0 4000 3563" xmlns="http://www.w3.org/2000/svg">',
|
||||
'<rect width="100%" height="100%" fill="white"/>',
|
||||
'<path id="path-1" d="M0 0"/>',
|
||||
'<path id="path-2" d="M0 0"/>',
|
||||
'<path id="path-3" d="M0 0"/>',
|
||||
'<path id="path-4" d="M0 0"/>',
|
||||
'<path id="path-5" d="M0 0"/>',
|
||||
'</svg>',
|
||||
].join('');
|
||||
|
||||
const wholeWordSourceSvg = [
|
||||
'<svg width="4000" height="3563" viewBox="0 0 4000 3563" xmlns="http://www.w3.org/2000/svg">',
|
||||
'<rect width="100%" height="100%" fill="white"/>',
|
||||
'<path id="word-1" d="M0 0"/>',
|
||||
'<path id="word-2" d="M0 0"/>',
|
||||
'</svg>',
|
||||
].join('');
|
||||
|
||||
const vectorWords: WordCloudWord[] = [
|
||||
{ ...words[0], id: 'first', name: '王小明' },
|
||||
{ ...words[1], id: 'second', name: ' 曹益' },
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
document.documentElement.style.removeProperty('--accent');
|
||||
});
|
||||
|
||||
describe('WordCloudCanvas', () => {
|
||||
it('anchors every imported word at its measured bounding-box center', () => {
|
||||
render(
|
||||
<WordCloudCanvas
|
||||
words={words}
|
||||
sourceCanvas={{ width: 4000, height: 3563 }}
|
||||
viewport={{ width: 1280, height: 720 }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('魏大正')).toHaveStyle({
|
||||
transform: 'translate(-50%, -50%)',
|
||||
});
|
||||
expect(screen.getByText('梅瑞麟')).toHaveStyle({
|
||||
transform: 'translate(-50%, -50%) rotate(90deg)',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a rotated target centered while applying its focus scale', () => {
|
||||
render(
|
||||
<WordCloudCanvas
|
||||
words={words}
|
||||
activeIds={new Set(['vertical'])}
|
||||
sourceCanvas={{ width: 4000, height: 3563 }}
|
||||
viewport={{ width: 1280, height: 720 }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('梅瑞麟')).toHaveStyle({
|
||||
transform: 'translate(-50%, -50%) rotate(90deg) scale(1.18)',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses source bounding boxes for the text line box', () => {
|
||||
render(
|
||||
<WordCloudCanvas
|
||||
words={measuredWords}
|
||||
sourceCanvas={{ width: 4000, height: 3563 }}
|
||||
viewport={{ width: 1280, height: 720 }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const scale = (720 * (4000 / 3563)) / 4000;
|
||||
|
||||
expect(parseFloat(screen.getByText('魏大正').style.lineHeight)).toBeCloseTo(75 * scale);
|
||||
expect(parseFloat(screen.getByText('梅瑞麟').style.lineHeight)).toBeCloseTo(76 * scale);
|
||||
});
|
||||
|
||||
it('renders source SVG paths as independent word groups', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => sourceSvg,
|
||||
}));
|
||||
|
||||
const vectorProps: React.ComponentProps<typeof WordCloudCanvas> & { sourceSvg: string } = {
|
||||
words: vectorWords,
|
||||
sourceCanvas: { width: 4000, height: 3563 },
|
||||
viewport: { width: 1280, height: 720 },
|
||||
sourceSvg: '/wordclouds/imported.svg',
|
||||
};
|
||||
const { container } = render(<WordCloudCanvas {...vectorProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('g[data-word-id]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(container.querySelector('[data-word-id="first"]')?.querySelectorAll('path')).toHaveLength(3);
|
||||
expect(container.querySelector('[data-word-id="second"]')?.querySelectorAll('path')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders one complete SVG path per imported word when provided by the source', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => wholeWordSourceSvg,
|
||||
}));
|
||||
|
||||
const { container } = render(
|
||||
<WordCloudCanvas
|
||||
words={vectorWords}
|
||||
sourceCanvas={{ width: 4000, height: 3563 }}
|
||||
viewport={{ width: 1280, height: 720 }}
|
||||
sourceSvg="/wordclouds/whole-word.svg"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('g[data-word-id]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(container.querySelector('[data-word-id="first"]')?.querySelectorAll('path')).toHaveLength(1);
|
||||
expect(container.querySelector('[data-word-id="second"]')?.querySelectorAll('path')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('fills active source SVG paths with the interface accent color', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => sourceSvg,
|
||||
}));
|
||||
const { container } = render(
|
||||
<WordCloudCanvas
|
||||
words={vectorWords}
|
||||
activeIds={new Set(['first'])}
|
||||
sourceCanvas={{ width: 4000, height: 3563 }}
|
||||
viewport={{ width: 1280, height: 720 }}
|
||||
sourceSvg="/wordclouds/imported.svg"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-word-id="first"] path')).not.toBeNull();
|
||||
});
|
||||
|
||||
expect(
|
||||
getComputedStyle(container.querySelector('[data-word-id="first"] path')!).fill,
|
||||
).toBe('var(--accent)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type {
|
||||
CSSProperties,
|
||||
KeyboardEventHandler,
|
||||
MouseEventHandler,
|
||||
RefObject,
|
||||
WheelEventHandler,
|
||||
} from 'react';
|
||||
import { FOCUS_CONFIG } from '../config/focus';
|
||||
import type { FocusPhase } from '../hooks/useWordCloudFocus';
|
||||
import type { WordCloudWord } from '../types/cloud';
|
||||
import { groupSvgPathsByWord } from '../lib/sourceSvg';
|
||||
import styles from './WordCloudCanvas.module.css';
|
||||
import type { FocusTransform } from '../lib/wordCloud';
|
||||
|
||||
interface WordCloudCanvasProps {
|
||||
words: WordCloudWord[];
|
||||
phase?: FocusPhase;
|
||||
transform?: FocusTransform;
|
||||
activeIds?: Set<string>;
|
||||
cloudRef?: RefObject<HTMLDivElement>;
|
||||
onPointerDown?: MouseEventHandler<HTMLDivElement>;
|
||||
onPointerMove?: MouseEventHandler<HTMLDivElement>;
|
||||
onPointerUp?: MouseEventHandler<HTMLDivElement>;
|
||||
onWheel?: WheelEventHandler<HTMLDivElement>;
|
||||
onKeyDown?: KeyboardEventHandler<HTMLDivElement>;
|
||||
sourceCanvas?: { width: number; height: number };
|
||||
sourceSvg?: string;
|
||||
viewport?: { width: number; height: number };
|
||||
}
|
||||
|
||||
export function WordCloudCanvas({
|
||||
words,
|
||||
phase = 'idle',
|
||||
transform = { x: 0, y: 0, scale: 1 },
|
||||
activeIds = new Set<string>(),
|
||||
cloudRef,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onWheel,
|
||||
onKeyDown,
|
||||
sourceCanvas,
|
||||
sourceSvg,
|
||||
viewport = { width: 0, height: 0 },
|
||||
}: WordCloudCanvasProps) {
|
||||
const [sourceMarkup, setSourceMarkup] = useState<string | null>(null);
|
||||
const [sourceLoadFailed, setSourceLoadFailed] = useState(false);
|
||||
const sourceVectorRef = useRef<HTMLDivElement | null>(null);
|
||||
const sourceAspect = sourceCanvas
|
||||
? sourceCanvas.width / sourceCanvas.height
|
||||
: null;
|
||||
const fallbackWidth = typeof window === 'undefined' ? 0 : window.innerWidth;
|
||||
const fallbackHeight = typeof window === 'undefined' ? 0 : window.innerHeight;
|
||||
const fitWidth = sourceAspect
|
||||
? Math.min(
|
||||
viewport.width || fallbackWidth,
|
||||
(viewport.height || fallbackHeight) * sourceAspect,
|
||||
)
|
||||
: viewport.width || fallbackWidth;
|
||||
const cloudScale = sourceCanvas && sourceCanvas.width > 0
|
||||
? fitWidth / sourceCanvas.width
|
||||
: 1;
|
||||
const hasActiveTarget = activeIds.size > 0;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!sourceSvg) {
|
||||
setSourceMarkup(null);
|
||||
setSourceLoadFailed(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setSourceMarkup(null);
|
||||
setSourceLoadFailed(false);
|
||||
|
||||
void fetch(sourceSvg)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load source SVG: ${response.status}`);
|
||||
}
|
||||
|
||||
return groupSvgPathsByWord(await response.text(), words);
|
||||
})
|
||||
.then((markup) => {
|
||||
if (!cancelled) {
|
||||
setSourceMarkup(markup);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setSourceLoadFailed(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sourceSvg, words]);
|
||||
|
||||
useEffect(() => {
|
||||
const sourceVector = sourceVectorRef.current;
|
||||
|
||||
if (!sourceVector) {
|
||||
return;
|
||||
}
|
||||
|
||||
sourceVector.querySelectorAll<SVGGElement>('g[data-word-id]').forEach((group) => {
|
||||
group.dataset.active = String(activeIds.has(group.dataset.wordId ?? ''));
|
||||
});
|
||||
}, [activeIds, sourceMarkup]);
|
||||
|
||||
const showSourcePreview = Boolean(sourceSvg && !sourceMarkup && !sourceLoadFailed);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.canvas}
|
||||
tabIndex={0}
|
||||
role="application"
|
||||
aria-label="词云画面,可以使用方向键平移,使用加号或减号缩放"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
onWheel={onWheel}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<div className={styles.stageFrame}>
|
||||
<div
|
||||
ref={cloudRef}
|
||||
className={styles.stageBox}
|
||||
data-fitted={Boolean(sourceCanvas)}
|
||||
style={
|
||||
sourceAspect
|
||||
? ({ '--source-aspect': sourceAspect } as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={styles.stage}
|
||||
data-phase={phase}
|
||||
style={{
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0) scale(${transform.scale})`,
|
||||
}}
|
||||
>
|
||||
{showSourcePreview ? (
|
||||
<img
|
||||
className={styles.sourcePreview}
|
||||
src={sourceSvg}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{sourceMarkup ? (
|
||||
<div
|
||||
ref={sourceVectorRef}
|
||||
className={styles.sourceVector}
|
||||
data-has-active={hasActiveTarget}
|
||||
style={{
|
||||
'--dimmed-opacity': FOCUS_CONFIG.dimmedOpacity,
|
||||
'--target-scale': FOCUS_CONFIG.targetScale,
|
||||
} as CSSProperties}
|
||||
dangerouslySetInnerHTML={{ __html: sourceMarkup }}
|
||||
/>
|
||||
) : null}
|
||||
{!sourceSvg || sourceLoadFailed ? words.map((word) => {
|
||||
const isActive = activeIds.has(word.id);
|
||||
const wordTransform = [
|
||||
'translate(-50%, -50%)',
|
||||
word.rotation ? `rotate(${word.rotation}deg)` : '',
|
||||
isActive ? `scale(${FOCUS_CONFIG.targetScale})` : '',
|
||||
].filter(Boolean).join(' ');
|
||||
const sourceLineHeight = sourceCanvas && word.sourceBox
|
||||
? (word.rotation ? word.sourceBox.width : word.sourceBox.height) * cloudScale
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<span
|
||||
key={word.id}
|
||||
className={styles.word}
|
||||
data-active={isActive}
|
||||
data-dimmed={hasActiveTarget && !isActive}
|
||||
style={{
|
||||
left: `${word.x}%`,
|
||||
top: `${word.y}%`,
|
||||
fontSize: sourceCanvas
|
||||
? `${word.fontSize * cloudScale}px`
|
||||
: `calc(${word.fontSize}px * var(--cloud-scale))`,
|
||||
lineHeight: sourceLineHeight
|
||||
? `${sourceLineHeight}px`
|
||||
: undefined,
|
||||
opacity: undefined,
|
||||
'--base-opacity': word.tier === 1 ? 0.55 : word.tier === 2 ? 0.7 : word.tier === 3 ? 0.85 : 0.94,
|
||||
'--dimmed-opacity': FOCUS_CONFIG.dimmedOpacity,
|
||||
'--target-scale': FOCUS_CONFIG.targetScale,
|
||||
'--cloud-foreground': 'var(--text-primary)',
|
||||
'--cloud-accent': 'var(--accent)',
|
||||
transform: wordTransform,
|
||||
} as CSSProperties}
|
||||
>
|
||||
{word.name}
|
||||
</span>
|
||||
);
|
||||
}) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export const FOCUS_CONFIG = {
|
||||
focusDuration: 720,
|
||||
holdDuration: 5500,
|
||||
returnDuration: 680,
|
||||
minScale: 1.35,
|
||||
maxScale: 2.5,
|
||||
manualMaxScale: 3,
|
||||
targetPadding: 160,
|
||||
targetScale: 1.18,
|
||||
dimmedOpacity: 0.16,
|
||||
nearbyThreshold: 25,
|
||||
queueSize: 5,
|
||||
panStep: 48,
|
||||
zoomStep: 1.08,
|
||||
channelName: 'wordcloud-demo',
|
||||
} as const;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { wordCloudApiUrl } from './wordcloudApi';
|
||||
|
||||
describe('wordCloudApiUrl', () => {
|
||||
it('uses the same-origin WordCloud gateway by default', () => {
|
||||
expect(wordCloudApiUrl('/api/jobs/example/locations?name=')).toBe(
|
||||
'/wordcloud-api/api/jobs/example/locations?name=',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
const DEFAULT_API_BASE_URL = '/wordcloud-api';
|
||||
const DEFAULT_JOB_ID = 'bd1f240adcb4458f857c40ac427122c6';
|
||||
|
||||
function trimTrailingSlashes(value: string): string {
|
||||
return value.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export const WORDCLOUD_API_BASE_URL = trimTrailingSlashes(
|
||||
import.meta.env.VITE_WORDCLOUD_API_BASE_URL?.trim() || DEFAULT_API_BASE_URL,
|
||||
);
|
||||
|
||||
export const DEFAULT_WORDCLOUD_JOB_ID =
|
||||
import.meta.env.VITE_WORDCLOUD_DEFAULT_JOB_ID?.trim() || DEFAULT_JOB_ID;
|
||||
|
||||
export function resolveWordCloudJobId(cloudId: string): string {
|
||||
return cloudId === 'imported' ? DEFAULT_WORDCLOUD_JOB_ID : cloudId;
|
||||
}
|
||||
|
||||
export function wordCloudApiUrl(path: string): string {
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
|
||||
return `${WORDCLOUD_API_BASE_URL}${normalizedPath}`;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { WordCloud } from '../../types/cloud';
|
||||
|
||||
export const demoCloud: WordCloud = {
|
||||
id: 'demo',
|
||||
eyebrow: 'LIVE ARCHIVE · 2026',
|
||||
title: '2026 毕业典礼 · 全体名单',
|
||||
subtitle: 'CLASS OF 2026 · WORD CLOUD',
|
||||
caption: 'THIS WALL IS MADE OF EVERYONE HERE',
|
||||
theme: {
|
||||
background: '#ffffff',
|
||||
foreground: '#211922',
|
||||
accent: '#e60023',
|
||||
},
|
||||
words: [
|
||||
{ id: 'wang-xiaoming-1', name: '王小明', x: 50, y: 44, fontSize: 52, tier: 4 },
|
||||
{ id: 'lin-jiayi', name: '林嘉怡', x: 32, y: 30, fontSize: 34, tier: 3 },
|
||||
{ id: 'sophia-1', name: 'Sophia', x: 68, y: 30, fontSize: 34, tier: 3 },
|
||||
{ id: 'li-xin', name: '李欣', x: 24, y: 58, fontSize: 34, tier: 3 },
|
||||
{ id: 'kevin', name: 'Kevin', x: 76, y: 60, fontSize: 34, tier: 3 },
|
||||
{ id: 'chen-chen', name: '陈晨', x: 50, y: 66, fontSize: 34, tier: 3 },
|
||||
{ id: 'alex-1', name: 'Alex', x: 38, y: 52, fontSize: 22, tier: 2 },
|
||||
{ id: 'lily', name: 'Lily', x: 62, y: 52, fontSize: 22, tier: 2 },
|
||||
{ id: 'zhang-yu', name: '张雨', x: 44, y: 24, fontSize: 22, tier: 2 },
|
||||
{ id: 'daniel', name: 'Daniel', x: 58, y: 76, fontSize: 22, tier: 2 },
|
||||
{ id: 'zhao-tianyu', name: '赵天宇', x: 18, y: 40, fontSize: 22, tier: 2 },
|
||||
{ id: 'emma', name: 'Emma', x: 82, y: 42, fontSize: 22, tier: 2 },
|
||||
{ id: 'su-wanqing', name: '苏晚晴', x: 30, y: 76, fontSize: 22, tier: 2 },
|
||||
{ id: 'zhou-zimo', name: '周子墨', x: 70, y: 78, fontSize: 22, tier: 2 },
|
||||
{ id: 'olivia', name: 'Olivia', x: 50, y: 14, fontSize: 22, tier: 2 },
|
||||
{ id: 'shen-zhiyao', name: '沈知遥', x: 50, y: 88, fontSize: 22, tier: 2 },
|
||||
{ id: 'wangzihan', name: '王梓涵', x: 14, y: 66, fontSize: 15, tier: 1 },
|
||||
{ id: 'li-yutong', name: '李雨桐', x: 86, y: 66, fontSize: 15, tier: 1 },
|
||||
{ id: 'zhang-haoran', name: '张浩然', x: 22, y: 22, fontSize: 15, tier: 1 },
|
||||
{ id: 'liu-yifei', name: '刘亦菲', x: 78, y: 20, fontSize: 15, tier: 1 },
|
||||
{ id: 'chen-zihao', name: '陈子豪', x: 12, y: 52, fontSize: 15, tier: 1 },
|
||||
{ id: 'yang-chenxi', name: '杨晨曦', x: 88, y: 52, fontSize: 15, tier: 1 },
|
||||
{ id: 'huang-junjie', name: '黄俊杰', x: 36, y: 84, fontSize: 15, tier: 1 },
|
||||
{ id: 'wu-siyuan', name: '吴思远', x: 64, y: 84, fontSize: 15, tier: 1 },
|
||||
{ id: 'xu-ruoxuan', name: '徐若瑄', x: 26, y: 44, fontSize: 15, tier: 1 },
|
||||
{ id: 'sun-haoyu', name: '孙浩宇', x: 74, y: 46, fontSize: 15, tier: 1 },
|
||||
{ id: 'ma-xiaotong', name: '马晓彤', x: 42, y: 38, fontSize: 15, tier: 1 },
|
||||
{ id: 'zhu-zirui', name: '朱子睿', x: 58, y: 38, fontSize: 15, tier: 1 },
|
||||
{ id: 'hu-wenxuan', name: '胡文轩', x: 34, y: 64, fontSize: 15, tier: 1 },
|
||||
{ id: 'guo-meiling', name: '郭美玲', x: 66, y: 64, fontSize: 15, tier: 1 },
|
||||
{ id: 'he-jiayi', name: '何佳怡', x: 46, y: 58, fontSize: 15, tier: 1 },
|
||||
{ id: 'luo-zihan', name: '罗子涵', x: 55, y: 59, fontSize: 15, tier: 1 },
|
||||
{ id: 'james', name: 'James', x: 20, y: 82, fontSize: 15, tier: 1 },
|
||||
{ id: 'mary', name: 'Mary', x: 80, y: 82, fontSize: 15, tier: 1 },
|
||||
{ id: 'john', name: 'John', x: 16, y: 30, fontSize: 15, tier: 1 },
|
||||
{ id: 'grace', name: 'Grace', x: 84, y: 30, fontSize: 15, tier: 1 },
|
||||
{ id: 'noah', name: 'Noah', x: 40, y: 10, fontSize: 15, tier: 1 },
|
||||
{ id: 'mia', name: 'Mia', x: 60, y: 10, fontSize: 15, tier: 1 },
|
||||
{ id: 'ai', name: '艾', x: 50, y: 94, fontSize: 15, tier: 1 },
|
||||
{ id: 'alexandrina', name: 'Alexandrina', x: 68, y: 14, fontSize: 22, tier: 2 },
|
||||
{ id: 'ouyang-yutongxi', name: '欧阳雨桐溪', x: 30, y: 90, fontSize: 22, tier: 2 },
|
||||
{ id: 'wang-xiaoming-2', name: '王小明', x: 70, y: 68, fontSize: 22, tier: 2 },
|
||||
{ id: 'sophia-2', name: 'Sophia', x: 52, y: 32, fontSize: 22, tier: 2 },
|
||||
{ id: 'alex-2', name: 'Alex', x: 18, y: 20, fontSize: 15, tier: 1 },
|
||||
{ id: 'chen-leyao', name: '陈乐瑶', x: 25, y: 12, fontSize: 15, tier: 1 },
|
||||
{ id: 'chen-zhiyuan', name: '陈志远', x: 75, y: 12, fontSize: 15, tier: 1 },
|
||||
{ id: 'huang-sihan', name: '黄思涵', x: 32, y: 16, fontSize: 15, tier: 1 },
|
||||
{ id: 'wu-yuze', name: '吴雨泽', x: 68, y: 20, fontSize: 15, tier: 1 },
|
||||
{ id: 'xu-qinghe', name: '许清和', x: 25, y: 34, fontSize: 15, tier: 1 },
|
||||
{ id: 'chen-zhuoyuan', name: '陈卓远', x: 88, y: 38, fontSize: 15, tier: 1 },
|
||||
{ id: 'li-chengze', name: '李承泽', x: 10, y: 22, fontSize: 15, tier: 1 },
|
||||
{ id: 'zhou-yuxin', name: '周雨欣', x: 90, y: 72, fontSize: 15, tier: 1 },
|
||||
{ id: 'lin-shuomei', name: '林烁玫', x: 15, y: 78, fontSize: 15, tier: 1 },
|
||||
{ id: 'chen-jiahe', name: '陈嘉禾', x: 85, y: 90, fontSize: 15, tier: 1 },
|
||||
{ id: 'huang-kexin', name: '黄可欣', x: 40, y: 74, fontSize: 15, tier: 1 },
|
||||
{ id: 'luo-siyuan', name: '罗思远', x: 55, y: 84, fontSize: 15, tier: 1 },
|
||||
{ id: 'zhang-jingyi', name: '张静怡', x: 62, y: 26, fontSize: 15, tier: 1 },
|
||||
{ id: 'lin-yinuo', name: '林一诺', x: 38, y: 94, fontSize: 15, tier: 1 },
|
||||
{ id: 'liu-xinran', name: '刘欣然', x: 62, y: 90, fontSize: 15, tier: 1 },
|
||||
{ id: 'gao-zimo', name: '高子墨', x: 20, y: 90, fontSize: 15, tier: 1 },
|
||||
{ id: 'xu-jingxing', name: '许景行', x: 80, y: 62, fontSize: 15, tier: 1 },
|
||||
{ id: 'shen-yizhou', name: '沈亦舟', x: 40, y: 30, fontSize: 15, tier: 1 },
|
||||
{ id: 'chen-siyuan', name: '陈思远', x: 60, y: 70, fontSize: 15, tier: 1 },
|
||||
{ id: 'han-muchen', name: '韩沐宸', x: 12, y: 84, fontSize: 15, tier: 1 },
|
||||
{ id: 'song-zhixia', name: '宋知夏', x: 88, y: 84, fontSize: 15, tier: 1 },
|
||||
{ id: 'jiang-yiran', name: '姜亦然', x: 28, y: 58, fontSize: 15, tier: 1 },
|
||||
{ id: 'bai-ruoxi', name: '白若溪', x: 72, y: 92, fontSize: 15, tier: 1 },
|
||||
{ id: 'qin-wanzhou', name: '秦晚舟', x: 45, y: 80, fontSize: 15, tier: 1 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import importedResultJson from './importedWordcloud.json';
|
||||
import {
|
||||
isImportedWordLocationResult,
|
||||
mapImportedWordLocations,
|
||||
} from '../../lib/wordcloudImport';
|
||||
import type { WordCloud } from '../../types/cloud';
|
||||
|
||||
const importedResult = isImportedWordLocationResult(importedResultJson)
|
||||
? importedResultJson
|
||||
: null;
|
||||
|
||||
const jobId = importedResult?.job_id ?? 'imported';
|
||||
|
||||
export const importedCloud: WordCloud | null = importedResult
|
||||
? {
|
||||
id: 'imported',
|
||||
eyebrow: 'LIVE ARCHIVE · 2026',
|
||||
title: '2026 毕业典礼 · 全体名单',
|
||||
subtitle: 'CLASS OF 2026 · WORD CLOUD',
|
||||
caption: 'REUSED FROM WORDCLOUD DATABASE',
|
||||
theme: {
|
||||
background: '#ffffff',
|
||||
foreground: '#211922',
|
||||
accent: '#e60023',
|
||||
},
|
||||
sourceCanvas: importedResult
|
||||
? {
|
||||
width: importedResult.canvas_width,
|
||||
height: importedResult.canvas_height,
|
||||
}
|
||||
: undefined,
|
||||
sourceSvg: '/wordclouds/40588babde574efeb9643f4af05c8d43.svg',
|
||||
words: mapImportedWordLocations(importedResult, {
|
||||
jobId,
|
||||
eyebrow: 'LIVE ARCHIVE · 2026',
|
||||
title: '2026 毕业典礼 · 全体名单',
|
||||
subtitle: 'CLASS OF 2026 · WORD CLOUD',
|
||||
}),
|
||||
}
|
||||
: null;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
|
||||
import type { WordCloud } from '../../types/cloud';
|
||||
import { demoCloud } from './demo';
|
||||
|
||||
const clouds: WordCloud[] = [demoCloud];
|
||||
|
||||
export function getCloudById(cloudId: string): WordCloud | undefined {
|
||||
return clouds.find((cloud) => cloud.id === cloudId);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { focusQueueReducer } from '../focusQueue';
|
||||
import type { FocusEvent } from '../../types/channel';
|
||||
import type { FocusQueueState } from '../focusQueue';
|
||||
|
||||
const event = (id: string, name: string): FocusEvent => ({
|
||||
type: 'FOCUS_NAME',
|
||||
cloudId: 'demo',
|
||||
name,
|
||||
messageId: id,
|
||||
sentAt: 1000,
|
||||
});
|
||||
|
||||
describe('focus queue', () => {
|
||||
it('starts the first name and queues later names without interruption', () => {
|
||||
let state: FocusQueueState = { current: null, pending: [] };
|
||||
|
||||
state = focusQueueReducer(state, { type: 'ENQUEUE', event: event('1', '王小明') });
|
||||
state = focusQueueReducer(state, { type: 'ENQUEUE', event: event('2', 'Sophia') });
|
||||
|
||||
expect(state.current?.messageId).toBe('1');
|
||||
expect(state.pending.map(({ messageId }) => messageId)).toEqual(['2']);
|
||||
});
|
||||
|
||||
it('advances to the next queued name after the current focus completes', () => {
|
||||
let state: FocusQueueState = { current: null, pending: [] };
|
||||
state = focusQueueReducer(state, { type: 'ENQUEUE', event: event('1', '王小明') });
|
||||
state = focusQueueReducer(state, { type: 'ENQUEUE', event: event('2', 'Kevin') });
|
||||
state = focusQueueReducer(state, { type: 'ADVANCE' });
|
||||
|
||||
expect(state.current?.messageId).toBe('2');
|
||||
});
|
||||
|
||||
it('ignores incoming names after the queue limit is reached', () => {
|
||||
let state: FocusQueueState = { current: null, pending: [] };
|
||||
state = focusQueueReducer(state, { type: 'ENQUEUE', event: event('1', '王小明') });
|
||||
state = focusQueueReducer(state, { type: 'ENQUEUE', event: event('2', 'Sophia') });
|
||||
state = focusQueueReducer(state, { type: 'ENQUEUE', event: event('3', 'Kevin') });
|
||||
state = focusQueueReducer(state, { type: 'ENQUEUE', event: event('4', 'Alex') });
|
||||
state = focusQueueReducer(state, { type: 'ENQUEUE', event: event('5', 'Mary') });
|
||||
state = focusQueueReducer(state, { type: 'ENQUEUE', event: event('6', 'John') });
|
||||
|
||||
expect(state.current?.messageId).toBe('1');
|
||||
expect(state.pending).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useWordCloudFocus } from '../useWordCloudFocus';
|
||||
import type { WordCloudWord } from '../../types/cloud';
|
||||
|
||||
const word = (id: string, name: string, x: number, y: number): WordCloudWord => ({
|
||||
id,
|
||||
name,
|
||||
x,
|
||||
y,
|
||||
fontSize: 28,
|
||||
tier: 2,
|
||||
});
|
||||
|
||||
describe('useWordCloudFocus', () => {
|
||||
it('focuses the requested occurrence of a duplicate name', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useWordCloudFocus({
|
||||
words: [
|
||||
word('sophia-1', 'Sophia', 68, 30),
|
||||
word('sophia-2', 'Sophia', 18, 20),
|
||||
],
|
||||
autoReturn: true,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.focusName('Sophia', 1);
|
||||
});
|
||||
|
||||
expect(result.current.searchResult?.selectedTargetIndex).toBe(1);
|
||||
expect(result.current.activeIds.has('sophia-2')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { FOCUS_CONFIG } from '../config/focus';
|
||||
import type { FocusEvent } from '../types/channel';
|
||||
|
||||
export interface FocusQueueState {
|
||||
current: FocusEvent | null;
|
||||
pending: FocusEvent[];
|
||||
}
|
||||
|
||||
export type FocusQueueAction =
|
||||
| { type: 'ENQUEUE'; event: FocusEvent; maxSize?: number }
|
||||
| { type: 'ADVANCE' }
|
||||
| { type: 'CLEAR' };
|
||||
|
||||
export function focusQueueReducer(
|
||||
state: FocusQueueState,
|
||||
action: FocusQueueAction,
|
||||
): FocusQueueState {
|
||||
const maxSize = 'maxSize' in action && action.maxSize !== undefined
|
||||
? action.maxSize
|
||||
: FOCUS_CONFIG.queueSize;
|
||||
|
||||
switch (action.type) {
|
||||
case 'ENQUEUE': {
|
||||
if (state.current || state.pending.length > 0) {
|
||||
if (state.current !== null && state.pending.length >= maxSize - 1) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return { ...state, pending: [...state.pending, action.event] };
|
||||
}
|
||||
|
||||
return { ...state, current: action.event };
|
||||
}
|
||||
case 'ADVANCE':
|
||||
return {
|
||||
current: state.pending[0] ?? null,
|
||||
pending: state.pending.slice(1),
|
||||
};
|
||||
case 'CLEAR':
|
||||
return { current: null, pending: [] };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getCloudById } from '../data/clouds';
|
||||
import { loadRemoteWordCloud } from '../lib/wordcloudApi';
|
||||
import type { WordCloud } from '../types/cloud';
|
||||
|
||||
export type CloudLoadStatus = 'loading' | 'ready' | 'unavailable';
|
||||
|
||||
interface CloudLoadState {
|
||||
status: CloudLoadStatus;
|
||||
cloud: WordCloud | null;
|
||||
}
|
||||
|
||||
function getInitialState(cloudId: string): CloudLoadState {
|
||||
const localCloud = getCloudById(cloudId);
|
||||
|
||||
return localCloud
|
||||
? { status: 'ready', cloud: localCloud }
|
||||
: { status: 'loading', cloud: null };
|
||||
}
|
||||
|
||||
export function useCloud(cloudId: string): CloudLoadState {
|
||||
const [state, setState] = useState<CloudLoadState>(() => getInitialState(cloudId));
|
||||
|
||||
useEffect(() => {
|
||||
const localCloud = getCloudById(cloudId);
|
||||
|
||||
if (localCloud) {
|
||||
setState({ status: 'ready', cloud: localCloud });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setState({ status: 'loading', cloud: null });
|
||||
|
||||
void loadRemoteWordCloud(cloudId)
|
||||
.then((cloud) => {
|
||||
if (!cancelled) {
|
||||
setState({ status: 'ready', cloud });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setState({ status: 'unavailable', cloud: null });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cloudId]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { FOCUS_CONFIG } from '../config/focus';
|
||||
import type { FocusEvent } from '../types/channel';
|
||||
import {
|
||||
focusQueueReducer,
|
||||
type FocusQueueState,
|
||||
} from './focusQueue';
|
||||
|
||||
export function useFocusQueue(maxSize: number = FOCUS_CONFIG.queueSize) {
|
||||
const [state, setState] = useState<FocusQueueState>({
|
||||
current: null,
|
||||
pending: [],
|
||||
});
|
||||
|
||||
const enqueue = useCallback(
|
||||
(event: FocusEvent) => {
|
||||
setState((currentState) =>
|
||||
focusQueueReducer(currentState, { type: 'ENQUEUE', event, maxSize }),
|
||||
);
|
||||
},
|
||||
[maxSize],
|
||||
);
|
||||
|
||||
const advance = useCallback(() => {
|
||||
setState((currentState) =>
|
||||
focusQueueReducer(currentState, { type: 'ADVANCE' }),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setState((currentState) =>
|
||||
focusQueueReducer(currentState, { type: 'CLEAR' }),
|
||||
);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
enqueue,
|
||||
advance,
|
||||
clear,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { FOCUS_CONFIG } from '../config/focus';
|
||||
import { createBroadcastRealtimeChannel } from '../realtime/broadcastRealtimeChannel';
|
||||
import {
|
||||
buildRealtimeWebSocketUrl,
|
||||
resolveRealtimeMode,
|
||||
} from '../realtime/realtimeConfig';
|
||||
import { createWebSocketRealtimeChannel } from '../realtime/websocketRealtimeChannel';
|
||||
import type { FocusEvent, RealtimeChannel } from '../types/channel';
|
||||
|
||||
function createMessageId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export function useWordCloudChannel(
|
||||
cloudId: string,
|
||||
onEvent: (message: FocusEvent) => void,
|
||||
) {
|
||||
const channelRef = useRef<RealtimeChannel | null>(null);
|
||||
const onEventRef = useRef(onEvent);
|
||||
|
||||
useEffect(() => {
|
||||
onEventRef.current = onEvent;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const mode = resolveRealtimeMode(
|
||||
import.meta.env.VITE_REALTIME_MODE,
|
||||
import.meta.env.PROD,
|
||||
);
|
||||
const channel =
|
||||
mode === 'websocket'
|
||||
? createWebSocketRealtimeChannel(
|
||||
buildRealtimeWebSocketUrl(import.meta.env.VITE_REALTIME_WS_URL),
|
||||
)
|
||||
: createBroadcastRealtimeChannel(FOCUS_CONFIG.channelName);
|
||||
channelRef.current = channel;
|
||||
|
||||
const unsubscribe = channel.subscribe((message) => {
|
||||
if (message.cloudId === cloudId) {
|
||||
onEventRef.current(message);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
channel.close();
|
||||
channelRef.current = null;
|
||||
};
|
||||
}, [cloudId]);
|
||||
|
||||
const send = useCallback(
|
||||
(name: string, targetIndex = 0) => {
|
||||
const message: FocusEvent = {
|
||||
type: 'FOCUS_NAME',
|
||||
cloudId,
|
||||
name,
|
||||
targetIndex,
|
||||
messageId: createMessageId(),
|
||||
sentAt: Date.now(),
|
||||
};
|
||||
|
||||
channelRef.current?.publish(message);
|
||||
},
|
||||
[cloudId],
|
||||
);
|
||||
|
||||
return send;
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type {
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
WheelEvent as ReactWheelEvent,
|
||||
} from 'react';
|
||||
import { FOCUS_CONFIG } from '../config/focus';
|
||||
import {
|
||||
buildFocusTargets,
|
||||
calculateFocusTransform,
|
||||
clamp,
|
||||
type FocusTarget,
|
||||
} from '../lib/wordCloud';
|
||||
import type { WordCloudWord } from '../types/cloud';
|
||||
import type { FocusTransform } from '../lib/wordCloud';
|
||||
|
||||
export type FocusPhase = 'idle' | 'focusing' | 'holding' | 'returning';
|
||||
|
||||
export interface CloudSearchResult {
|
||||
query: string;
|
||||
targets: FocusTarget[];
|
||||
selectedTargetIndex: number;
|
||||
}
|
||||
|
||||
const IDENTITY_TRANSFORM: FocusTransform = { x: 0, y: 0, scale: 1 };
|
||||
|
||||
interface UseWordCloudFocusOptions {
|
||||
words: WordCloudWord[];
|
||||
autoReturn?: boolean;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
interface PointerInteraction {
|
||||
pointers: Map<number, { x: number; y: number }>;
|
||||
initialTransform?: FocusTransform;
|
||||
initialDistance?: number;
|
||||
initialMidpoint?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export function useWordCloudFocus({
|
||||
words,
|
||||
autoReturn = false,
|
||||
onComplete,
|
||||
}: UseWordCloudFocusOptions) {
|
||||
const cloudRef = useRef<HTMLDivElement | null>(null);
|
||||
const [viewport, setViewport] = useState({ width: 0, height: 0 });
|
||||
const [phase, setPhase] = useState<FocusPhase>('idle');
|
||||
const [transform, setTransform] = useState<FocusTransform>(IDENTITY_TRANSFORM);
|
||||
const [activeIds, setActiveIds] = useState<Set<string>>(new Set());
|
||||
const [searchResult, setSearchResult] = useState<CloudSearchResult | null>(null);
|
||||
const timersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
||||
const interactionRef = useRef<PointerInteraction>({ pointers: new Map() });
|
||||
const onCompleteRef = useRef(onComplete);
|
||||
|
||||
const activeTarget = searchResult?.targets[searchResult.selectedTargetIndex] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
onCompleteRef.current = onComplete;
|
||||
});
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
timersRef.current.forEach((timer) => clearTimeout(timer));
|
||||
timersRef.current.clear();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const cloud = cloudRef.current;
|
||||
|
||||
if (!cloud) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateViewport = () => {
|
||||
const bounds = cloud.getBoundingClientRect();
|
||||
|
||||
setViewport({ width: bounds.width, height: bounds.height });
|
||||
};
|
||||
|
||||
updateViewport();
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(updateViewport);
|
||||
observer.observe(cloud);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [words.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!activeTarget
|
||||
|| (phase !== 'focusing' && phase !== 'holding')
|
||||
|| viewport.width <= 0
|
||||
|| viewport.height <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTransform(calculateFocusTransform(activeTarget, viewport));
|
||||
}, [activeTarget, phase, viewport]);
|
||||
|
||||
useEffect(() => clearTimers, [clearTimers]);
|
||||
|
||||
const registerTimer = useCallback((callback: () => void, delay: number) => {
|
||||
const timer = setTimeout(() => {
|
||||
timersRef.current.delete(timer);
|
||||
callback();
|
||||
}, delay);
|
||||
timersRef.current.add(timer);
|
||||
}, []);
|
||||
|
||||
const startFocus = useCallback(
|
||||
(target: FocusTarget, query: string, targets: FocusTarget[], targetIndex: number) => {
|
||||
const bounds = cloudRef.current?.getBoundingClientRect();
|
||||
const currentViewport = {
|
||||
width: bounds?.width || viewport.width || window.innerWidth,
|
||||
height: bounds?.height || viewport.height || window.innerHeight,
|
||||
};
|
||||
|
||||
clearTimers();
|
||||
setSearchResult({
|
||||
query,
|
||||
targets,
|
||||
selectedTargetIndex: targetIndex,
|
||||
});
|
||||
setActiveIds(new Set(target.words.map((word) => word.id)));
|
||||
setTransform(calculateFocusTransform(target, currentViewport));
|
||||
setPhase('focusing');
|
||||
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const focusDuration = reducedMotion ? 0 : FOCUS_CONFIG.focusDuration;
|
||||
const holdDuration = reducedMotion ? 0 : FOCUS_CONFIG.holdDuration;
|
||||
const returnDuration = reducedMotion ? 0 : FOCUS_CONFIG.returnDuration;
|
||||
|
||||
registerTimer(() => setPhase('holding'), focusDuration);
|
||||
|
||||
if (autoReturn) {
|
||||
registerTimer(() => {
|
||||
setPhase('returning');
|
||||
setTransform(IDENTITY_TRANSFORM);
|
||||
}, focusDuration + holdDuration);
|
||||
registerTimer(() => {
|
||||
setPhase('idle');
|
||||
setActiveIds(new Set());
|
||||
setSearchResult(null);
|
||||
onCompleteRef.current?.();
|
||||
}, focusDuration + holdDuration + returnDuration);
|
||||
}
|
||||
},
|
||||
[autoReturn, clearTimers, registerTimer, viewport.height, viewport.width],
|
||||
);
|
||||
|
||||
const focusName = useCallback(
|
||||
(query: string, requestedTargetIndex = 0) => {
|
||||
const targets = buildFocusTargets(words, query);
|
||||
|
||||
if (targets.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetIndex = Math.min(
|
||||
Math.max(0, Math.floor(requestedTargetIndex)),
|
||||
targets.length - 1,
|
||||
);
|
||||
|
||||
startFocus(targets[targetIndex], query, targets, targetIndex);
|
||||
|
||||
return true;
|
||||
},
|
||||
[startFocus, words],
|
||||
);
|
||||
|
||||
const selectTarget = useCallback(
|
||||
(targetIndex: number) => {
|
||||
if (!searchResult || targetIndex < 0 || targetIndex >= searchResult.targets.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = searchResult.targets[targetIndex];
|
||||
startFocus(target, searchResult.query, searchResult.targets, targetIndex);
|
||||
},
|
||||
[searchResult, startFocus],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
clearTimers();
|
||||
setPhase('idle');
|
||||
setTransform(IDENTITY_TRANSFORM);
|
||||
setActiveIds(new Set());
|
||||
setSearchResult(null);
|
||||
}, [clearTimers]);
|
||||
|
||||
const getPointerPosition = (event: PointerEvent | WheelEvent) => {
|
||||
const bounds = cloudRef.current?.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
x: event.clientX - (bounds?.left || 0),
|
||||
y: event.clientY - (bounds?.top || 0),
|
||||
};
|
||||
};
|
||||
|
||||
const handlePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (phase !== 'idle') {
|
||||
return;
|
||||
}
|
||||
|
||||
const position = getPointerPosition(event.nativeEvent);
|
||||
const interaction = interactionRef.current;
|
||||
interaction.pointers.set(event.pointerId, position);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
|
||||
if (interaction.pointers.size === 2) {
|
||||
const [first, second] = [...interaction.pointers.values()];
|
||||
interaction.initialTransform = transform;
|
||||
interaction.initialDistance = Math.hypot(second.x - first.x, second.y - first.y);
|
||||
interaction.initialMidpoint = {
|
||||
x: (first.x + second.x) / 2,
|
||||
y: (first.y + second.y) / 2,
|
||||
};
|
||||
}
|
||||
}, [phase, transform]);
|
||||
|
||||
const handlePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const interaction = interactionRef.current;
|
||||
|
||||
if (phase !== 'idle' || !interaction.pointers.has(event.pointerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const position = getPointerPosition(event.nativeEvent);
|
||||
const previousPosition = interaction.pointers.get(event.pointerId);
|
||||
interaction.pointers.set(event.pointerId, position);
|
||||
|
||||
if (interaction.pointers.size === 1 && previousPosition) {
|
||||
setTransform((current) => ({
|
||||
...current,
|
||||
x: current.x + position.x - previousPosition.x,
|
||||
y: current.y + position.y - previousPosition.y,
|
||||
}));
|
||||
}
|
||||
|
||||
if (interaction.pointers.size === 2 && interaction.initialTransform) {
|
||||
const [first, second] = [...interaction.pointers.values()];
|
||||
const distance = Math.hypot(second.x - first.x, second.y - first.y);
|
||||
const initialDistance = interaction.initialDistance || distance;
|
||||
const initialMidpoint = interaction.initialMidpoint || { x: 0, y: 0 };
|
||||
const nextScale = clamp(
|
||||
interaction.initialTransform.scale * (distance / initialDistance),
|
||||
1,
|
||||
FOCUS_CONFIG.manualMaxScale,
|
||||
);
|
||||
const zoomRatio = nextScale / interaction.initialTransform.scale;
|
||||
|
||||
setTransform({
|
||||
scale: nextScale,
|
||||
x: initialMidpoint.x - (initialMidpoint.x - interaction.initialTransform.x) * zoomRatio,
|
||||
y: initialMidpoint.y - (initialMidpoint.y - interaction.initialTransform.y) * zoomRatio,
|
||||
});
|
||||
}
|
||||
}, [phase]);
|
||||
|
||||
const handlePointerUp = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
interactionRef.current.pointers.delete(event.pointerId);
|
||||
|
||||
if (interactionRef.current.pointers.size < 2) {
|
||||
interactionRef.current.initialTransform = undefined;
|
||||
interactionRef.current.initialDistance = undefined;
|
||||
interactionRef.current.initialMidpoint = undefined;
|
||||
}
|
||||
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {
|
||||
if (phase !== 'idle') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const pointer = getPointerPosition(event.nativeEvent);
|
||||
|
||||
setTransform((current) => {
|
||||
const factor = event.deltaY < 0 ? 1.08 : 1 / 1.08;
|
||||
const nextScale = clamp(
|
||||
current.scale * factor,
|
||||
1,
|
||||
FOCUS_CONFIG.manualMaxScale,
|
||||
);
|
||||
const zoomRatio = nextScale / current.scale;
|
||||
|
||||
return {
|
||||
scale: nextScale,
|
||||
x: pointer.x - (pointer.x - current.x) * zoomRatio,
|
||||
y: pointer.y - (pointer.y - current.y) * zoomRatio,
|
||||
};
|
||||
});
|
||||
}, [phase]);
|
||||
|
||||
const handleKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
if (phase !== 'idle') {
|
||||
return;
|
||||
}
|
||||
|
||||
const keyActions: Record<string, () => void> = {
|
||||
ArrowLeft: () => setTransform((current) => ({ ...current, x: current.x + FOCUS_CONFIG.panStep })),
|
||||
ArrowRight: () => setTransform((current) => ({ ...current, x: current.x - FOCUS_CONFIG.panStep })),
|
||||
ArrowUp: () => setTransform((current) => ({ ...current, y: current.y + FOCUS_CONFIG.panStep })),
|
||||
ArrowDown: () => setTransform((current) => ({ ...current, y: current.y - FOCUS_CONFIG.panStep })),
|
||||
'+': () => setTransform((current) => ({
|
||||
...current,
|
||||
scale: clamp(current.scale * FOCUS_CONFIG.zoomStep, 1, FOCUS_CONFIG.manualMaxScale),
|
||||
})),
|
||||
'=': () => setTransform((current) => ({
|
||||
...current,
|
||||
scale: clamp(current.scale * FOCUS_CONFIG.zoomStep, 1, FOCUS_CONFIG.manualMaxScale),
|
||||
})),
|
||||
'-': () => setTransform((current) => ({
|
||||
...current,
|
||||
scale: clamp(current.scale / FOCUS_CONFIG.zoomStep, 1, FOCUS_CONFIG.manualMaxScale),
|
||||
})),
|
||||
};
|
||||
const action = keyActions[event.key];
|
||||
|
||||
if (action) {
|
||||
event.preventDefault();
|
||||
action();
|
||||
}
|
||||
}, [phase]);
|
||||
|
||||
return {
|
||||
cloudRef,
|
||||
viewport,
|
||||
phase,
|
||||
transform,
|
||||
activeIds,
|
||||
activeTarget,
|
||||
searchResult,
|
||||
focusName,
|
||||
focusTarget: startFocus,
|
||||
selectTarget,
|
||||
reset,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
handleWheel,
|
||||
handleKeyDown,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
buildFocusTargets,
|
||||
calculateFocusTransform,
|
||||
normalizeName,
|
||||
} from '../wordCloud';
|
||||
import type { WordCloudWord } from '../../types/cloud';
|
||||
|
||||
const word = (
|
||||
id: string,
|
||||
name: string,
|
||||
x: number,
|
||||
y: number,
|
||||
): WordCloudWord => ({ id, name, x, y, fontSize: 28, tier: 3 });
|
||||
|
||||
describe('word cloud search', () => {
|
||||
it('normalizes trimmed English names case-insensitively', () => {
|
||||
expect(normalizeName(' Sophia ')).toBe('sophia');
|
||||
expect(normalizeName('SOPHIA')).toBe(normalizeName('sophia'));
|
||||
});
|
||||
|
||||
it('keeps Chinese names stable', () => {
|
||||
expect(normalizeName(' 王小明 ')).toBe('王小明');
|
||||
});
|
||||
|
||||
it('returns no targets for an unknown name', () => {
|
||||
const targets = buildFocusTargets([word('a', '王小明', 50, 44)], '不存在');
|
||||
|
||||
expect(targets).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('groups close duplicate matches into one target', () => {
|
||||
const targets = buildFocusTargets(
|
||||
[
|
||||
word('sophia-1', 'Sophia', 68, 30),
|
||||
word('sophia-2', 'Sophia', 58, 38),
|
||||
],
|
||||
'sophia',
|
||||
);
|
||||
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0].words.map(({ id }) => id)).toEqual(['sophia-1', 'sophia-2']);
|
||||
expect(targets[0].center).toEqual({ x: 63, y: 34 });
|
||||
});
|
||||
|
||||
it('keeps distant duplicate matches as separate paginated targets', () => {
|
||||
const targets = buildFocusTargets(
|
||||
[
|
||||
word('alex-1', 'Alex', 38, 52),
|
||||
word('alex-2', 'Alex', 18, 20),
|
||||
],
|
||||
'Alex',
|
||||
);
|
||||
|
||||
expect(targets).toHaveLength(2);
|
||||
expect(targets[0].words[0].id).toBe('alex-1');
|
||||
expect(targets[1].words[0].id).toBe('alex-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('focus camera calculation', () => {
|
||||
it('moves the requested point to the viewport center and clamps zoom', () => {
|
||||
const target = buildFocusTargets(
|
||||
[
|
||||
word('target-a', 'Sophia', 68, 30),
|
||||
word('target-b', 'Sophia', 58, 38),
|
||||
],
|
||||
'Sophia',
|
||||
)[0];
|
||||
const transform = calculateFocusTransform(target, { width: 1200, height: 800 });
|
||||
|
||||
expect(transform.scale).toBeGreaterThan(1);
|
||||
expect(transform.scale).toBeLessThanOrEqual(2.5);
|
||||
|
||||
const screenX = transform.x + (target.center.x / 100) * 1200 * transform.scale;
|
||||
const screenY = transform.y + (target.center.y / 100) * 800 * transform.scale;
|
||||
|
||||
expect(screenX).toBeCloseTo(600);
|
||||
expect(screenY).toBeCloseTo(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { mapImportedWordLocations } from '../wordcloudImport';
|
||||
import type { ImportedWordLocationResult } from '../wordcloudImport';
|
||||
|
||||
const result: ImportedWordLocationResult = {
|
||||
job_id: 'job-1',
|
||||
query: '',
|
||||
mode: 'exact',
|
||||
total: 2,
|
||||
canvas_width: 1000,
|
||||
canvas_height: 2000,
|
||||
matches: [
|
||||
{
|
||||
id: 1,
|
||||
name: '王小明',
|
||||
x: 100,
|
||||
y: 200,
|
||||
font_size: 100,
|
||||
color: '#000000',
|
||||
orientation: 'horizontal',
|
||||
box_x: 100,
|
||||
box_y: 200,
|
||||
box_width: 100,
|
||||
box_height: 50,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '李欣',
|
||||
x: 700,
|
||||
y: 1200,
|
||||
font_size: 25,
|
||||
color: '#000000',
|
||||
orientation: 'vertical',
|
||||
box_x: 700,
|
||||
box_y: 1200,
|
||||
box_width: 60,
|
||||
box_height: 200,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('imported word locations', () => {
|
||||
it('maps database pixels to responsive percentage coordinates', () => {
|
||||
const words = mapImportedWordLocations(result, {
|
||||
jobId: 'job-1',
|
||||
eyebrow: '',
|
||||
title: '',
|
||||
subtitle: '',
|
||||
});
|
||||
|
||||
expect(words).toHaveLength(2);
|
||||
expect(words[0].x).toBeCloseTo(15);
|
||||
expect(words[0].y).toBeCloseTo(11.25);
|
||||
expect(words[0].fontSize).toBe(100);
|
||||
expect(words[0]).toMatchObject({
|
||||
sourceBox: { width: 100, height: 50 },
|
||||
});
|
||||
expect(words[0].tier).toBe(4);
|
||||
expect(words[1].x).toBeCloseTo(73);
|
||||
expect(words[1].y).toBeCloseTo(65);
|
||||
expect(words[1].fontSize).toBe(25);
|
||||
expect(words[1].tier).toBe(1);
|
||||
expect(words[1].rotation).toBe(90);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { WordCloudWord } from '../types/cloud';
|
||||
|
||||
function countRenderedGlyphs(name: string): number {
|
||||
return Array.from(name).filter((character) => !/\s/u.test(character)).length;
|
||||
}
|
||||
|
||||
export function groupSvgPathsByWord(
|
||||
svgMarkup: string,
|
||||
words: WordCloudWord[],
|
||||
): string {
|
||||
const document = new DOMParser().parseFromString(svgMarkup, 'image/svg+xml');
|
||||
const root = document.documentElement;
|
||||
|
||||
if (root.localName !== 'svg' || document.querySelector('parsererror')) {
|
||||
throw new Error('The source SVG could not be parsed.');
|
||||
}
|
||||
|
||||
const paths = Array.from(root.children).filter((element) => element.localName === 'path');
|
||||
const glyphPathCount = words.reduce(
|
||||
(total, word) => total + countRenderedGlyphs(word.name),
|
||||
0,
|
||||
);
|
||||
const pathsPerWord = paths.length === words.length ? 1 : 0;
|
||||
|
||||
if (paths.length !== glyphPathCount && pathsPerWord === 0) {
|
||||
throw new Error('The source SVG paths do not match the imported word list.');
|
||||
}
|
||||
|
||||
let pathIndex = 0;
|
||||
for (const word of words) {
|
||||
const pathCount = pathsPerWord || countRenderedGlyphs(word.name);
|
||||
const group = document.createElementNS(root.namespaceURI, 'g');
|
||||
group.setAttribute('data-word-id', word.id);
|
||||
group.setAttribute('data-active', 'false');
|
||||
|
||||
root.insertBefore(group, paths[pathIndex]);
|
||||
for (let glyphIndex = 0; glyphIndex < pathCount; glyphIndex += 1) {
|
||||
group.append(paths[pathIndex]);
|
||||
pathIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return new XMLSerializer().serializeToString(root);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { FOCUS_CONFIG } from '../config/focus';
|
||||
import type { WordCloudWord } from '../types/cloud';
|
||||
|
||||
export interface FocusBounds {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
export interface FocusTarget {
|
||||
id: string;
|
||||
words: WordCloudWord[];
|
||||
center: { x: number; y: number };
|
||||
bounds: FocusBounds;
|
||||
}
|
||||
|
||||
export interface FocusTransform {
|
||||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export function normalizeName(value: string): string {
|
||||
return value.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
}
|
||||
|
||||
export function findWordsByName(
|
||||
words: WordCloudWord[],
|
||||
query: string,
|
||||
): WordCloudWord[] {
|
||||
const normalized = normalizeName(query);
|
||||
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return words.filter((word) => normalizeName(word.name) === normalized);
|
||||
}
|
||||
|
||||
export function buildFocusTargets(
|
||||
words: WordCloudWord[],
|
||||
query: string,
|
||||
): FocusTarget[] {
|
||||
const matches = findWordsByName(words, query);
|
||||
const targets: FocusTarget[] = [];
|
||||
const visited = new Set<string>();
|
||||
|
||||
for (let index = 0; index < matches.length; index += 1) {
|
||||
const current = matches[index];
|
||||
|
||||
if (visited.has(current.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const group = [current];
|
||||
visited.add(current.id);
|
||||
|
||||
for (let nextIndex = index + 1; nextIndex < matches.length; nextIndex += 1) {
|
||||
const candidate = matches[nextIndex];
|
||||
|
||||
if (visited.has(candidate.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const groupMinX = Math.min(...group.map((word) => word.x));
|
||||
const groupMaxX = Math.max(...group.map((word) => word.x));
|
||||
const groupMinY = Math.min(...group.map((word) => word.y));
|
||||
const groupMaxY = Math.max(...group.map((word) => word.y));
|
||||
const isNearby =
|
||||
candidate.x >= groupMinX - FOCUS_CONFIG.nearbyThreshold &&
|
||||
candidate.x <= groupMaxX + FOCUS_CONFIG.nearbyThreshold &&
|
||||
candidate.y >= groupMinY - FOCUS_CONFIG.nearbyThreshold &&
|
||||
candidate.y <= groupMaxY + FOCUS_CONFIG.nearbyThreshold;
|
||||
|
||||
if (!isNearby) {
|
||||
continue;
|
||||
}
|
||||
|
||||
group.push(candidate);
|
||||
visited.add(candidate.id);
|
||||
}
|
||||
|
||||
targets.push(createFocusTarget(group));
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
export function createFocusTarget(words: WordCloudWord[]): FocusTarget {
|
||||
const xValues = words.map((word) => word.x);
|
||||
const yValues = words.map((word) => word.y);
|
||||
const minX = Math.min(...xValues);
|
||||
const maxX = Math.max(...xValues);
|
||||
const minY = Math.min(...yValues);
|
||||
const maxY = Math.max(...yValues);
|
||||
|
||||
return {
|
||||
id: `target-${words.map((word) => word.id).join('-')}`,
|
||||
words,
|
||||
center: {
|
||||
x: (minX + maxX) / 2,
|
||||
y: (minY + maxY) / 2,
|
||||
},
|
||||
bounds: { minX, minY, maxX, maxY },
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateFocusTransform(
|
||||
target: FocusTarget,
|
||||
viewport: { width: number; height: number },
|
||||
): FocusTransform {
|
||||
const boundsWidth = target.bounds.maxX - target.bounds.minX;
|
||||
const boundsHeight = target.bounds.maxY - target.bounds.minY;
|
||||
const contentWidth =
|
||||
(boundsWidth / 100) * viewport.width + FOCUS_CONFIG.targetPadding;
|
||||
const contentHeight =
|
||||
(boundsHeight / 100) * viewport.height + FOCUS_CONFIG.targetPadding;
|
||||
const scale = clamp(
|
||||
Math.min(
|
||||
FOCUS_CONFIG.maxScale,
|
||||
viewport.width / contentWidth,
|
||||
viewport.height / contentHeight,
|
||||
),
|
||||
FOCUS_CONFIG.minScale,
|
||||
FOCUS_CONFIG.maxScale,
|
||||
);
|
||||
|
||||
return {
|
||||
x: viewport.width / 2 - (target.center.x / 100) * viewport.width * scale,
|
||||
y: viewport.height / 2 - (target.center.y / 100) * viewport.height * scale,
|
||||
scale,
|
||||
};
|
||||
}
|
||||
|
||||
export function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(Math.max(value, minimum), maximum);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
isImportedWordLocationResult,
|
||||
mapImportedWordLocations,
|
||||
} from './wordcloudImport';
|
||||
import {
|
||||
resolveWordCloudJobId,
|
||||
wordCloudApiUrl,
|
||||
} from '../config/wordcloudApi';
|
||||
import type { WordCloud } from '../types/cloud';
|
||||
|
||||
const REMOTE_PRESENTATION = {
|
||||
eyebrow: 'LIVE ARCHIVE · 2026',
|
||||
title: '2026 毕业典礼 · 全体名单',
|
||||
subtitle: 'CLASS OF 2026 · WORD CLOUD',
|
||||
caption: 'REUSED FROM WORDCLOUD DATABASE',
|
||||
theme: {
|
||||
background: '#ffffff',
|
||||
foreground: '#211922',
|
||||
accent: '#e60023',
|
||||
},
|
||||
} satisfies Omit<WordCloud, 'id' | 'words' | 'sourceCanvas' | 'sourceSvg'>;
|
||||
|
||||
async function getJson(path: string): Promise<unknown> {
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(wordCloudApiUrl(path));
|
||||
} catch {
|
||||
throw new Error('无法连接词云数据服务');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`词云数据服务返回 ${response.status}`);
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
throw new Error('词云数据服务返回了无效数据');
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadRemoteWordCloud(cloudId: string): Promise<WordCloud> {
|
||||
const jobId = resolveWordCloudJobId(cloudId);
|
||||
const locations = await getJson(
|
||||
`/api/jobs/${encodeURIComponent(jobId)}/locations?name=`,
|
||||
);
|
||||
|
||||
if (
|
||||
!isImportedWordLocationResult(locations)
|
||||
|| locations.job_id !== jobId
|
||||
|| locations.matches.length === 0
|
||||
) {
|
||||
throw new Error('词云任务没有可用的位置数据');
|
||||
}
|
||||
|
||||
return {
|
||||
id: cloudId,
|
||||
...REMOTE_PRESENTATION,
|
||||
sourceCanvas: {
|
||||
width: locations.canvas_width,
|
||||
height: locations.canvas_height,
|
||||
},
|
||||
sourceSvg: wordCloudApiUrl(
|
||||
`/api/jobs/${encodeURIComponent(jobId)}/files/svg`,
|
||||
),
|
||||
words: mapImportedWordLocations(locations, {
|
||||
jobId,
|
||||
eyebrow: REMOTE_PRESENTATION.eyebrow,
|
||||
title: REMOTE_PRESENTATION.title,
|
||||
subtitle: REMOTE_PRESENTATION.subtitle,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { WordCloudWord, WordTier } from '../types/cloud';
|
||||
|
||||
export interface ImportedWordLocation {
|
||||
id: number;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
font_size: number;
|
||||
color: string;
|
||||
orientation: string;
|
||||
box_x: number;
|
||||
box_y: number;
|
||||
box_width: number;
|
||||
box_height: number;
|
||||
}
|
||||
|
||||
export interface ImportedWordLocationResult {
|
||||
job_id: string;
|
||||
query: string;
|
||||
mode: 'exact' | 'contains';
|
||||
total: number;
|
||||
canvas_width: number;
|
||||
canvas_height: number;
|
||||
matches: ImportedWordLocation[];
|
||||
}
|
||||
|
||||
export interface ImportedWordCloudMetadata {
|
||||
jobId: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
eyebrow: string;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
const FONT_TIERS: ReadonlyArray<{ minimumRatio: number; tier: WordTier }> = [
|
||||
{ minimumRatio: 0.74, tier: 4 },
|
||||
{ minimumRatio: 0.52, tier: 3 },
|
||||
{ minimumRatio: 0.28, tier: 2 },
|
||||
{ minimumRatio: 0, tier: 1 },
|
||||
];
|
||||
|
||||
export function isImportedWordLocationResult(value: unknown): value is ImportedWordLocationResult {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = value as Partial<ImportedWordLocationResult>;
|
||||
|
||||
return (
|
||||
typeof result.job_id === 'string' &&
|
||||
(result.mode === 'exact' || result.mode === 'contains') &&
|
||||
typeof result.total === 'number' &&
|
||||
typeof result.canvas_width === 'number' &&
|
||||
typeof result.canvas_height === 'number' &&
|
||||
Array.isArray(result.matches)
|
||||
);
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function normalizeMatch(
|
||||
value: unknown,
|
||||
maxHeightFontSize: number,
|
||||
): ImportedWordLocation | null {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = value as Partial<ImportedWordLocation>;
|
||||
if (
|
||||
typeof match.name !== 'string' ||
|
||||
!isFiniteNumber(match.id) ||
|
||||
!isFiniteNumber(match.x) ||
|
||||
!isFiniteNumber(match.y) ||
|
||||
!isFiniteNumber(match.font_size) ||
|
||||
!isFiniteNumber(match.box_x) ||
|
||||
!isFiniteNumber(match.box_y) ||
|
||||
!isFiniteNumber(match.box_width) ||
|
||||
!isFiniteNumber(match.box_height) ||
|
||||
!isFiniteNumber(maxHeightFontSize) ||
|
||||
maxHeightFontSize <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: match.id,
|
||||
name: match.name,
|
||||
x: match.x,
|
||||
y: match.y,
|
||||
font_size: match.font_size,
|
||||
color: typeof match.color === 'string' ? match.color : '',
|
||||
orientation: typeof match.orientation === 'string' ? match.orientation : 'horizontal',
|
||||
box_x: match.box_x,
|
||||
box_y: match.box_y,
|
||||
box_width: match.box_width,
|
||||
box_height: match.box_height,
|
||||
};
|
||||
}
|
||||
|
||||
function toTier(fontSize: number, maximumFontSize: number): WordTier {
|
||||
const ratio = fontSize / maximumFontSize;
|
||||
return FONT_TIERS.find((entry) => ratio >= entry.minimumRatio)?.tier ?? 1;
|
||||
}
|
||||
|
||||
export function mapImportedWordLocations(
|
||||
result: ImportedWordLocationResult,
|
||||
metadata: ImportedWordCloudMetadata,
|
||||
): WordCloudWord[] {
|
||||
const maximumFontSize = Math.max(
|
||||
...result.matches.map((match) => match.font_size),
|
||||
);
|
||||
|
||||
return result.matches
|
||||
.map((match) => normalizeMatch(match, maximumFontSize))
|
||||
.filter((match): match is ImportedWordLocation => match !== null)
|
||||
.map((match) => {
|
||||
const hasBox = match.box_width > 0 && match.box_height > 0;
|
||||
const centerX = hasBox ? match.box_x + match.box_width / 2 : match.x;
|
||||
const centerY = hasBox ? match.box_y + match.box_height / 2 : match.y;
|
||||
|
||||
return {
|
||||
id: `${metadata.jobId}-${match.id}`,
|
||||
name: match.name,
|
||||
x: (centerX / result.canvas_width) * 100,
|
||||
y: (centerY / result.canvas_height) * 100,
|
||||
fontSize: match.font_size,
|
||||
sourceBox: hasBox
|
||||
? { width: match.box_width, height: match.box_height }
|
||||
: undefined,
|
||||
rotation: match.orientation.trim().toLowerCase() === 'vertical' ? 90 : undefined,
|
||||
tier: toTier(match.font_size, maximumFontSize),
|
||||
} satisfies WordCloudWord;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './styles/global.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
.page {
|
||||
position: relative;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
background: var(--background);
|
||||
--cloud-scale: 1;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.page {
|
||||
--cloud-scale: 1.15;
|
||||
}
|
||||
}
|
||||
|
||||
.page[data-cloud='imported'] {
|
||||
--cloud-scale: 0.12;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.page[data-cloud='imported'] {
|
||||
--cloud-scale: 0.25;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { CloudHeader } from '../components/CloudHeader';
|
||||
import { FloatingSearchDock } from '../components/FloatingSearchDock';
|
||||
import { WordCloudCanvas } from '../components/WordCloudCanvas';
|
||||
import { CloudLoadingState } from '../components/CloudLoadingState';
|
||||
import { CloudUnavailableState } from '../components/CloudUnavailableState';
|
||||
import { TitleFrost } from '../components/TitleFrost';
|
||||
import { useCloud } from '../hooks/useCloud';
|
||||
import { useWordCloudFocus } from '../hooks/useWordCloudFocus';
|
||||
import styles from './PersonalSearchPage.module.css';
|
||||
|
||||
interface PersonalSearchPageProps {
|
||||
cloudId: string;
|
||||
}
|
||||
|
||||
export function PersonalSearchPage({ cloudId }: PersonalSearchPageProps) {
|
||||
const cloudState = useCloud(cloudId);
|
||||
const cloud = cloudState.cloud;
|
||||
const [query, setQuery] = useState('');
|
||||
const [hasNotFound, setHasNotFound] = useState(false);
|
||||
const focus = useWordCloudFocus({ words: cloud?.words ?? [] });
|
||||
|
||||
if (cloudState.status === 'loading') {
|
||||
return <CloudLoadingState />;
|
||||
}
|
||||
|
||||
if (!cloud) {
|
||||
return <CloudUnavailableState />;
|
||||
}
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!query.trim()) {
|
||||
setHasNotFound(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const found = focus.focusName(query);
|
||||
setHasNotFound(!found);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
focus.reset();
|
||||
setQuery('');
|
||||
setHasNotFound(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className={styles.page} data-cloud={cloud.id} aria-label="个人查找词云">
|
||||
<WordCloudCanvas
|
||||
words={cloud.words}
|
||||
phase={focus.phase}
|
||||
transform={focus.transform}
|
||||
activeIds={focus.activeIds}
|
||||
cloudRef={focus.cloudRef}
|
||||
onPointerDown={focus.handlePointerDown}
|
||||
onPointerMove={focus.handlePointerMove}
|
||||
onPointerUp={focus.handlePointerUp}
|
||||
onWheel={focus.handleWheel}
|
||||
onKeyDown={focus.handleKeyDown}
|
||||
sourceCanvas={cloud.sourceCanvas}
|
||||
sourceSvg={cloud.sourceSvg}
|
||||
viewport={focus.viewport}
|
||||
/>
|
||||
<TitleFrost />
|
||||
<CloudHeader
|
||||
eyebrow={cloud.eyebrow}
|
||||
title={cloud.title}
|
||||
subtitle={cloud.subtitle}
|
||||
dimmed={focus.phase !== 'idle'}
|
||||
layered
|
||||
/>
|
||||
<FloatingSearchDock
|
||||
name={query}
|
||||
status={focus.searchResult ? 'found' : hasNotFound ? 'not-found' : 'idle'}
|
||||
targetCount={focus.searchResult?.targets.length ?? 0}
|
||||
selectedTargetIndex={focus.searchResult?.selectedTargetIndex ?? 0}
|
||||
onChange={(name) => {
|
||||
setQuery(name);
|
||||
setHasNotFound(false);
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
onReset={handleReset}
|
||||
onSelectTarget={focus.selectTarget}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
.page {
|
||||
position: relative;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
background: var(--background);
|
||||
--cloud-scale: 1;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.page {
|
||||
--cloud-scale: 1.15;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.page {
|
||||
--cloud-scale: 1.35;
|
||||
}
|
||||
}
|
||||
|
||||
.caption {
|
||||
position: absolute;
|
||||
bottom: 48px;
|
||||
left: 48px;
|
||||
z-index: 3;
|
||||
margin: 0;
|
||||
color: var(--text-meta);
|
||||
font-size: var(--text-sm);
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.caption {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { CloudHeader } from '../components/CloudHeader';
|
||||
import { FullscreenButton } from '../components/FullscreenButton';
|
||||
import { QRPanel } from '../components/QRPanel';
|
||||
import { WordCloudCanvas } from '../components/WordCloudCanvas';
|
||||
import { CloudLoadingState } from '../components/CloudLoadingState';
|
||||
import { CloudUnavailableState } from '../components/CloudUnavailableState';
|
||||
import { useFocusQueue } from '../hooks/useFocusQueue';
|
||||
import { useCloud } from '../hooks/useCloud';
|
||||
import { useWordCloudChannel } from '../hooks/useWordCloudChannel';
|
||||
import { useWordCloudFocus } from '../hooks/useWordCloudFocus';
|
||||
import { TitleFrost } from '../components/TitleFrost';
|
||||
import type { FocusEvent } from '../types/channel';
|
||||
import styles from './PublicDisplayPage.module.css';
|
||||
|
||||
interface PublicDisplayPageProps {
|
||||
cloudId: string;
|
||||
}
|
||||
|
||||
export function PublicDisplayPage({ cloudId }: PublicDisplayPageProps) {
|
||||
const cloudState = useCloud(cloudId);
|
||||
const cloud = cloudState.cloud;
|
||||
const queue = useFocusQueue();
|
||||
const focus = useWordCloudFocus({
|
||||
words: cloud?.words ?? [],
|
||||
autoReturn: true,
|
||||
onComplete: queue.advance,
|
||||
});
|
||||
|
||||
const receiveMessage = useCallback(
|
||||
(message: FocusEvent) => {
|
||||
queue.enqueue(message);
|
||||
},
|
||||
[queue],
|
||||
);
|
||||
useWordCloudChannel(cloudId, receiveMessage);
|
||||
|
||||
useEffect(() => {
|
||||
if (!queue.current || focus.phase !== 'idle' || !cloud) {
|
||||
return;
|
||||
}
|
||||
|
||||
const found = focus.focusName(queue.current.name, queue.current.targetIndex ?? 0);
|
||||
|
||||
if (!found) {
|
||||
queue.advance();
|
||||
}
|
||||
}, [cloud, focus, queue]);
|
||||
|
||||
if (cloudState.status === 'loading') {
|
||||
return <CloudLoadingState />;
|
||||
}
|
||||
|
||||
if (!cloud) {
|
||||
return <CloudUnavailableState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={styles.page} aria-label="大屏展示词云">
|
||||
<WordCloudCanvas
|
||||
words={cloud.words}
|
||||
phase={focus.phase}
|
||||
transform={focus.transform}
|
||||
activeIds={focus.activeIds}
|
||||
cloudRef={focus.cloudRef}
|
||||
onPointerDown={focus.handlePointerDown}
|
||||
onPointerMove={focus.handlePointerMove}
|
||||
onPointerUp={focus.handlePointerUp}
|
||||
onWheel={focus.handleWheel}
|
||||
onKeyDown={focus.handleKeyDown}
|
||||
sourceCanvas={cloud.sourceCanvas}
|
||||
sourceSvg={cloud.sourceSvg}
|
||||
viewport={focus.viewport}
|
||||
/>
|
||||
<TitleFrost />
|
||||
<CloudHeader
|
||||
eyebrow={cloud.eyebrow}
|
||||
title={cloud.title}
|
||||
subtitle={cloud.subtitle}
|
||||
dimmed={focus.phase !== 'idle'}
|
||||
layered
|
||||
/>
|
||||
<p className={styles.caption}>{cloud.caption}</p>
|
||||
<QRPanel cloudId={cloudId} />
|
||||
<FullscreenButton />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
.page {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
padding: max(48px, env(safe-area-inset-top)) 16px 32px;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.box {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
align-content: start;
|
||||
width: min(440px, 100%);
|
||||
margin-top: 6vh;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: var(--text-meta);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-2xl);
|
||||
letter-spacing: var(--tracking-display);
|
||||
}
|
||||
|
||||
.lede {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nameInput {
|
||||
min-height: 48px;
|
||||
padding: 12px 15px;
|
||||
color: var(--text-primary);
|
||||
background: var(--background);
|
||||
border: 1px solid var(--text-meta);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.nameInput::placeholder {
|
||||
color: var(--text-meta);
|
||||
}
|
||||
|
||||
.mainButton {
|
||||
min-height: 48px;
|
||||
padding: 14px 20px;
|
||||
color: var(--text-strong);
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notFound {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.foot {
|
||||
margin: 0;
|
||||
color: var(--text-meta);
|
||||
font-size: var(--text-xs);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { ControllerSuccessState } from '../components/ControllerSuccessState';
|
||||
import { CloudLoadingState } from '../components/CloudLoadingState';
|
||||
import { CloudUnavailableState } from '../components/CloudUnavailableState';
|
||||
import { useCloud } from '../hooks/useCloud';
|
||||
import { useWordCloudChannel } from '../hooks/useWordCloudChannel';
|
||||
import { buildFocusTargets, type FocusTarget } from '../lib/wordCloud';
|
||||
import styles from './RemoteControllerPage.module.css';
|
||||
|
||||
interface RemoteControllerPageProps {
|
||||
cloudId: string;
|
||||
}
|
||||
|
||||
export function RemoteControllerPage({ cloudId }: RemoteControllerPageProps) {
|
||||
const cloudState = useCloud(cloudId);
|
||||
const cloud = cloudState.cloud;
|
||||
const [name, setName] = useState('');
|
||||
const [status, setStatus] = useState<'idle' | 'not-found' | 'success'>('idle');
|
||||
const [controllerTargets, setControllerTargets] = useState<FocusTarget[]>([]);
|
||||
const [selectedTargetIndex, setSelectedTargetIndex] = useState(0);
|
||||
const sendFocusEvent = useWordCloudChannel(cloudId, () => undefined);
|
||||
|
||||
if (cloudState.status === 'loading') {
|
||||
return <CloudLoadingState />;
|
||||
}
|
||||
|
||||
if (!cloud) {
|
||||
return <CloudUnavailableState />;
|
||||
}
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const normalizedName = name.trim();
|
||||
|
||||
if (!normalizedName) {
|
||||
setStatus('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
const targets = buildFocusTargets(cloud.words, normalizedName);
|
||||
|
||||
if (targets.length === 0) {
|
||||
setControllerTargets([]);
|
||||
setStatus('not-found');
|
||||
return;
|
||||
}
|
||||
|
||||
setControllerTargets(targets);
|
||||
setSelectedTargetIndex(0);
|
||||
sendFocusEvent(normalizedName, 0);
|
||||
setStatus('success');
|
||||
};
|
||||
|
||||
return (
|
||||
<main className={styles.page} aria-label="遥控器">
|
||||
<div className={styles.box}>
|
||||
<p className={styles.eyebrow}>LIVE · 现场互动</p>
|
||||
<h1 className={styles.title}>找到你自己</h1>
|
||||
<p className={styles.lede}>输入名字,它会出现在大屏幕上。</p>
|
||||
<form className={styles.field} onSubmit={handleSubmit}>
|
||||
<label htmlFor="controller-name">你的名字</label>
|
||||
<input
|
||||
id="controller-name"
|
||||
className={styles.nameInput}
|
||||
type="text"
|
||||
name="name"
|
||||
autoComplete="off"
|
||||
placeholder="输入你的名字"
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
setStatus('idle');
|
||||
}}
|
||||
/>
|
||||
<button className={styles.mainButton} type="submit">
|
||||
在大屏上找到我
|
||||
</button>
|
||||
</form>
|
||||
{status === 'not-found' ? (
|
||||
<p className={styles.notFound} aria-live="polite">
|
||||
暂时没有找到这个名字
|
||||
</p>
|
||||
) : null}
|
||||
{status === 'success' ? (
|
||||
<ControllerSuccessState
|
||||
targetCount={controllerTargets.length}
|
||||
selectedTargetIndex={selectedTargetIndex}
|
||||
onSelectTarget={(targetIndex) => {
|
||||
const target = controllerTargets[targetIndex];
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedTargetIndex(targetIndex);
|
||||
sendFocusEvent(name.trim(), targetIndex);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<p className={styles.foot}>请留意现场大屏</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createBroadcastRealtimeChannel } from '../broadcastRealtimeChannel';
|
||||
import type { FocusEvent } from '../../types/channel';
|
||||
|
||||
const event: FocusEvent = {
|
||||
type: 'FOCUS_NAME',
|
||||
cloudId: 'demo',
|
||||
name: '王小明',
|
||||
targetIndex: 1,
|
||||
messageId: 'focus-1',
|
||||
sentAt: 123,
|
||||
};
|
||||
|
||||
describe('BroadcastChannel realtime adapter', () => {
|
||||
it('publishes an event from the controller to a separate screen channel', async () => {
|
||||
const controller = createBroadcastRealtimeChannel('wordcloud-test');
|
||||
const screen = createBroadcastRealtimeChannel('wordcloud-test');
|
||||
const received: FocusEvent[] = [];
|
||||
screen.subscribe((message) => received.push(message));
|
||||
|
||||
controller.publish(event);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(received).toEqual([event]);
|
||||
controller.close();
|
||||
screen.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
buildRealtimeWebSocketUrl,
|
||||
resolveRealtimeMode,
|
||||
} from '../realtimeConfig';
|
||||
|
||||
describe('realtime configuration', () => {
|
||||
it('uses BroadcastChannel for local development by default', () => {
|
||||
expect(resolveRealtimeMode(undefined, false)).toBe('broadcast');
|
||||
});
|
||||
|
||||
it('uses WebSocket for production by default', () => {
|
||||
expect(resolveRealtimeMode(undefined, true)).toBe('websocket');
|
||||
});
|
||||
|
||||
it('honors an explicit realtime mode override', () => {
|
||||
expect(resolveRealtimeMode('broadcast', true)).toBe('broadcast');
|
||||
expect(resolveRealtimeMode('websocket', false)).toBe('websocket');
|
||||
});
|
||||
|
||||
it('builds a same-origin WebSocket URL behind any deployed hostname', () => {
|
||||
expect(buildRealtimeWebSocketUrl()).toBe('ws://localhost:3000/ws');
|
||||
});
|
||||
|
||||
it('builds a secure WebSocket URL for HTTPS deployments', () => {
|
||||
expect(
|
||||
buildRealtimeWebSocketUrl(
|
||||
'/ws',
|
||||
'https://screen.example.com',
|
||||
),
|
||||
).toBe('wss://screen.example.com/ws');
|
||||
});
|
||||
|
||||
it('uses an explicit absolute WebSocket URL when provided', () => {
|
||||
expect(
|
||||
buildRealtimeWebSocketUrl(
|
||||
'wss://realtime.example.com/socket',
|
||||
'https://screen.example.com',
|
||||
),
|
||||
).toBe('wss://realtime.example.com/socket');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createWebSocketRealtimeChannel } from '../websocketRealtimeChannel';
|
||||
import type { FocusEvent } from '../../types/channel';
|
||||
|
||||
class FakeWebSocket {
|
||||
static instances: FakeWebSocket[] = [];
|
||||
sent: unknown[] = [];
|
||||
closed = false;
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
|
||||
constructor(public url: string) {
|
||||
FakeWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
send(message: unknown) {
|
||||
this.sent.push(message);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const event: FocusEvent = {
|
||||
type: 'FOCUS_NAME',
|
||||
cloudId: 'demo',
|
||||
name: 'Sophia',
|
||||
targetIndex: 1,
|
||||
messageId: 'focus-1',
|
||||
sentAt: 1,
|
||||
};
|
||||
|
||||
describe('WebSocket realtime channel', () => {
|
||||
beforeEach(() => {
|
||||
FakeWebSocket.instances = [];
|
||||
});
|
||||
|
||||
it('publishes valid focus events', () => {
|
||||
const received: FocusEvent[] = [];
|
||||
const channel = createWebSocketRealtimeChannel('ws://realtime/ws', FakeWebSocket);
|
||||
channel.subscribe((message) => received.push(message));
|
||||
|
||||
channel.publish(event);
|
||||
const socket = FakeWebSocket.instances[0];
|
||||
socket.onmessage?.({ data: socket.sent[0] } as MessageEvent);
|
||||
|
||||
expect(socket.sent).toEqual([JSON.stringify(event)]);
|
||||
expect(received).toEqual([event]);
|
||||
channel.close();
|
||||
});
|
||||
|
||||
it('ignores malformed server messages and closes cleanly', () => {
|
||||
const received: FocusEvent[] = [];
|
||||
const channel = createWebSocketRealtimeChannel('ws://realtime/ws', FakeWebSocket);
|
||||
channel.subscribe((message) => received.push(message));
|
||||
|
||||
const socket = FakeWebSocket.instances[0];
|
||||
socket.onmessage?.({ data: { type: 'NOISE' } } as MessageEvent);
|
||||
|
||||
expect(received).toEqual([]);
|
||||
|
||||
channel.close();
|
||||
expect(socket.closed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type {
|
||||
FocusEvent,
|
||||
RealtimeChannel,
|
||||
RealtimeListener,
|
||||
} from '../types/channel';
|
||||
import { isFocusEvent } from './focusEventGuard';
|
||||
|
||||
export function createBroadcastRealtimeChannel(
|
||||
channelName: string,
|
||||
): RealtimeChannel {
|
||||
const channel = new BroadcastChannel(channelName);
|
||||
|
||||
return {
|
||||
publish(message: FocusEvent) {
|
||||
channel.postMessage(message);
|
||||
},
|
||||
subscribe(listener: RealtimeListener) {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
const data: unknown = event.data;
|
||||
|
||||
if (isFocusEvent(data)) {
|
||||
listener(data);
|
||||
}
|
||||
};
|
||||
|
||||
channel.addEventListener('message', handleMessage);
|
||||
|
||||
return () => {
|
||||
channel.removeEventListener('message', handleMessage);
|
||||
};
|
||||
},
|
||||
close() {
|
||||
channel.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { FocusEvent } from '../types/channel';
|
||||
|
||||
export function isFocusEvent(value: unknown): value is FocusEvent {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const event = value as Partial<FocusEvent>;
|
||||
|
||||
return (
|
||||
event.type === 'FOCUS_NAME' &&
|
||||
typeof event.cloudId === 'string' &&
|
||||
typeof event.name === 'string' &&
|
||||
typeof event.messageId === 'string' &&
|
||||
typeof event.sentAt === 'number' &&
|
||||
(event.targetIndex === undefined ||
|
||||
(typeof event.targetIndex === 'number' &&
|
||||
Number.isInteger(event.targetIndex) &&
|
||||
event.targetIndex >= 0))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export type RealtimeMode = 'broadcast' | 'websocket';
|
||||
|
||||
export function resolveRealtimeMode(
|
||||
configuredMode: string | undefined,
|
||||
isProduction: boolean,
|
||||
): RealtimeMode {
|
||||
if (configuredMode === 'broadcast' || configuredMode === 'websocket') {
|
||||
return configuredMode;
|
||||
}
|
||||
|
||||
return isProduction ? 'websocket' : 'broadcast';
|
||||
}
|
||||
|
||||
export function buildRealtimeWebSocketUrl(
|
||||
configuredUrl?: string,
|
||||
origin?: string,
|
||||
): string {
|
||||
const baseOrigin = origin ?? window.location.origin;
|
||||
const url = new URL(configuredUrl || '/ws', baseOrigin);
|
||||
|
||||
if (url.protocol === 'https:') {
|
||||
url.protocol = 'wss:';
|
||||
}
|
||||
|
||||
if (url.protocol === 'http:') {
|
||||
url.protocol = 'ws:';
|
||||
}
|
||||
|
||||
return url.href;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { isFocusEvent } from './focusEventGuard';
|
||||
import type { FocusEvent, RealtimeChannel, RealtimeListener } from '../types/channel';
|
||||
|
||||
interface MinimalWebSocket {
|
||||
send: (data: string) => void;
|
||||
close: (code?: number, reason?: string) => void;
|
||||
onmessage: ((event: MessageEvent) => void) | null;
|
||||
}
|
||||
|
||||
type WebSocketConstructor = new (url: string) => MinimalWebSocket;
|
||||
|
||||
export function createWebSocketRealtimeChannel(
|
||||
url: string,
|
||||
WebSocketConstructor: WebSocketConstructor = WebSocket,
|
||||
): RealtimeChannel {
|
||||
const socket = new WebSocketConstructor(url);
|
||||
let closed = false;
|
||||
|
||||
return {
|
||||
publish(message: FocusEvent) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.send(JSON.stringify(message));
|
||||
},
|
||||
subscribe(listener: RealtimeListener) {
|
||||
socket.onmessage = (event) => {
|
||||
let data: unknown;
|
||||
|
||||
try {
|
||||
data = JSON.parse(String(event.data));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFocusEvent(data)) {
|
||||
listener(data);
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
socket.onmessage = null;
|
||||
};
|
||||
},
|
||||
close() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
closed = true;
|
||||
socket.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--surface: #f6f6f3;
|
||||
--surface-warm: #e5e5e0;
|
||||
--text-primary: #211922;
|
||||
--text-strong: #000000;
|
||||
--text-secondary: #62625b;
|
||||
--text-meta: #91918c;
|
||||
--border: #c8c8c1;
|
||||
--border-soft: #e0e0d9;
|
||||
--accent: #e60023;
|
||||
--success: #103c25;
|
||||
--focus-ring: 0 0 0 3px rgba(67, 94, 229, 0.35);
|
||||
--elev-raised: 0 4px 16px rgba(33, 25, 34, 0.06);
|
||||
|
||||
--font-body: "Pin Sans", -apple-system, system-ui, "Segoe UI", Roboto, Arial, sans-serif;
|
||||
--font-art: "Pin Sans", -apple-system, "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
|
||||
--text-xs: 12px;
|
||||
--text-sm: 14px;
|
||||
--text-base: 16px;
|
||||
--text-lg: 18px;
|
||||
--text-xl: 22px;
|
||||
--text-2xl: 28px;
|
||||
--leading-body: 1.4;
|
||||
--tracking-display: -0.02em;
|
||||
|
||||
--space-1: 8px;
|
||||
--space-2: 12px;
|
||||
--space-3: 16px;
|
||||
--space-4: 24px;
|
||||
--space-5: 32px;
|
||||
--space-6: 48px;
|
||||
|
||||
--radius-sm: 12px;
|
||||
--radius-md: 16px;
|
||||
--radius-pill: 9999px;
|
||||
|
||||
--motion-fast: 180ms;
|
||||
--motion-normal: 280ms;
|
||||
--motion-focus: 720ms;
|
||||
--motion-hold: 5500ms;
|
||||
--motion-return: 680ms;
|
||||
--ease-camera: cubic-bezier(0.32, 0.72, 0.34, 1);
|
||||
--cloud-scale: 1;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
window.matchMedia = (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
addEventListener: () => () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface FocusEvent {
|
||||
type: 'FOCUS_NAME';
|
||||
cloudId: string;
|
||||
name: string;
|
||||
targetIndex?: number;
|
||||
messageId: string;
|
||||
sentAt: number;
|
||||
}
|
||||
|
||||
export type RealtimeMessage = FocusEvent;
|
||||
|
||||
export type RealtimeListener = (message: RealtimeMessage) => void;
|
||||
|
||||
export interface RealtimeChannel {
|
||||
publish: (message: RealtimeMessage) => void;
|
||||
subscribe: (listener: RealtimeListener) => () => void;
|
||||
close: () => void;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export type WordTier = 1 | 2 | 3 | 4;
|
||||
|
||||
export interface WordCloudWord {
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
fontSize: number;
|
||||
sourceBox?: { width: number; height: number };
|
||||
rotation?: number;
|
||||
tier: WordTier;
|
||||
}
|
||||
|
||||
export interface WordCloudTheme {
|
||||
accent: string;
|
||||
foreground: string;
|
||||
background: string;
|
||||
}
|
||||
|
||||
export interface WordCloud {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
eyebrow: string;
|
||||
caption?: string;
|
||||
words: WordCloudWord[];
|
||||
theme: WordCloudTheme;
|
||||
sourceCanvas?: { width: number; height: number };
|
||||
sourceSvg?: string;
|
||||
}
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.module.css' {
|
||||
const classes: { readonly [key: string]: string };
|
||||
|
||||
export default classes;
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_REALTIME_MODE?: 'broadcast' | 'websocket';
|
||||
readonly VITE_REALTIME_WS_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts", "eslint.config.js"]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/ws': {
|
||||
target: 'ws://127.0.0.1:8787',
|
||||
ws: true,
|
||||
},
|
||||
'/wordcloud-api': {
|
||||
target: process.env.WORDCLOUD_API_UPSTREAM || 'http://192.168.31.213:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/wordcloud-api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: './src/test/setup.ts',
|
||||
css: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user