Add product source job resolver
Build, Push and Deploy / build (push) Successful in 7s
Build, Push and Deploy / deploy (push) Successful in 18s

This commit is contained in:
2026-09-13 18:00:35 +08:00
parent 807b1252ad
commit 293e346f0a
19 changed files with 269 additions and 64 deletions
+4 -2
View File
@@ -2,8 +2,10 @@ APP_PORT=47880
VITE_REALTIME_MODE=websocket VITE_REALTIME_MODE=websocket
VITE_REALTIME_WS_URL=/ws VITE_REALTIME_WS_URL=/ws
VITE_WORDCLOUD_API_BASE_URL=/wordcloud-api VITE_WORDCLOUD_API_BASE_URL=/wordcloud-api
VITE_WORDCLOUD_DEFAULT_JOB_ID=bd1f240adcb4458f857c40ac427122c6 VITE_WORDCLOUD_DEFAULT_PRODUCT_ID=prod_362d8ecaced241e5ab81dddfc880d6b0
WORDCLOUD_API_UPSTREAM=http://114.55.99.6:8000 WORDCLOUD_API_UPSTREAM=http://192.168.31.213:8000
ORDERS_ADMIN_PASSWORD=zhihui2024
ORDERS_TOKEN_SECRET=wordcloud-orders-demo
HARBOR_REGISTRY=192.168.31.213:10081 HARBOR_REGISTRY=192.168.31.213:10081
HARBOR_PROJECT=wordcloud HARBOR_PROJECT=wordcloud
IMAGE_TAG=latest IMAGE_TAG=latest
+2 -2
View File
@@ -8,11 +8,11 @@ COPY . .
ARG VITE_REALTIME_MODE=websocket ARG VITE_REALTIME_MODE=websocket
ARG VITE_REALTIME_WS_URL=/ws ARG VITE_REALTIME_WS_URL=/ws
ARG VITE_WORDCLOUD_API_BASE_URL=/wordcloud-api ARG VITE_WORDCLOUD_API_BASE_URL=/wordcloud-api
ARG VITE_WORDCLOUD_DEFAULT_JOB_ID=bd1f240adcb4458f857c40ac427122c6 ARG VITE_WORDCLOUD_DEFAULT_PRODUCT_ID=prod_362d8ecaced241e5ab81dddfc880d6b0
ENV VITE_REALTIME_MODE=$VITE_REALTIME_MODE ENV VITE_REALTIME_MODE=$VITE_REALTIME_MODE
ENV VITE_REALTIME_WS_URL=$VITE_REALTIME_WS_URL ENV VITE_REALTIME_WS_URL=$VITE_REALTIME_WS_URL
ENV VITE_WORDCLOUD_API_BASE_URL=$VITE_WORDCLOUD_API_BASE_URL ENV VITE_WORDCLOUD_API_BASE_URL=$VITE_WORDCLOUD_API_BASE_URL
ENV VITE_WORDCLOUD_DEFAULT_JOB_ID=$VITE_WORDCLOUD_DEFAULT_JOB_ID ENV VITE_WORDCLOUD_DEFAULT_PRODUCT_ID=$VITE_WORDCLOUD_DEFAULT_PRODUCT_ID
RUN npm run build RUN npm run build
FROM nginx:1.27-alpine FROM nginx:1.27-alpine
+6 -6
View File
@@ -36,7 +36,7 @@ npm run lint
- `src/components/`:词云、搜索 dock、QR panel、状态组件。 - `src/components/`:词云、搜索 dock、QR panel、状态组件。
- `src/data/clouds/`:离线 `demo` 词云数据。 - `src/data/clouds/`:离线 `demo` 词云数据。
- `src/lib/wordcloudApi.ts`WordCloud API 适配器;运行时读取位置数据与 SVG。 - `src/lib/wordcloudApi.ts`WordCloud API 适配器;运行时读取位置数据与 SVG。
- `src/hooks/useCloud.ts`:为三个页面统一加载本地 demo 或远程任务词云。 - `src/hooks/useCloud.ts`:为三个页面统一加载本地 demo 或远程产品词云。
- `src/hooks/`focus lifecycle、queue 和 realtime integration。 - `src/hooks/`focus lifecycle、queue 和 realtime integration。
- `src/realtime/`BroadcastChannel adapter;后续 WebSocket/Supabase 等实现替换这一层。 - `src/realtime/`BroadcastChannel adapter;后续 WebSocket/Supabase 等实现替换这一层。
- `src/styles/global.css`:从既有设计提取的全局 tokens。 - `src/styles/global.css`:从既有设计提取的全局 tokens。
@@ -63,14 +63,14 @@ http://<server-host>:47880/control/demo
如果 A 和 B 是不同域名并都指向同一台服务器,可以在外层网关或 DNS 上都转发到 Compose 暴露的端口;前端与二维码会保留访问者实际使用的域名。 如果 A 和 B 是不同域名并都指向同一台服务器,可以在外层网关或 DNS 上都转发到 Compose 暴露的端口;前端与二维码会保留访问者实际使用的域名。
`demo` 外,路由参数即 WordCloud `jobId`。例如: `demo` 外,路由参数即 WordCloud `productId`。例如:
```text ```text
/cloud/bd1f240adcb4458f857c40ac427122c6 /cloud/prod_362d8ecaced241e5ab81dddfc880d6b0
/screen/bd1f240adcb4458f857c40ac427122c6 /screen/prod_362d8ecaced241e5ab81dddfc880d6b0
/control/bd1f240adcb4458f857c40ac427122c6 /control/prod_362d8ecaced241e5ab81dddfc880d6b0
``` ```
`/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,且多域名部署时仍保留访问者实际使用的域名 `/cloud/imported``/screen/imported``/control/imported` 是由 `VITE_WORDCLOUD_DEFAULT_PRODUCT_ID` 配置的默认产品别名。应用从同源 `/wordcloud-product-api` 解析 Product ID 对应的最新词云归档,再从 `/wordcloud-api` 读取位置数据与 SVG。这样浏览器不需要读取产品管理鉴权信息,多域名部署时仍保留访问者实际使用的域名。一个 Product 下存在多个词云时,当前版本先取最新归档中的第一个词云
本地 `npm run dev` 也会将 `/wordcloud-api` 转发到同一个后端。只有在已具备可靠 CORS 的场景,才需要把 `VITE_WORDCLOUD_API_BASE_URL` 改为完整的远端 URL。 本地 `npm run dev` 也会将 `/wordcloud-api` 转发到同一个后端。只有在已具备可靠 CORS 的场景,才需要把 `VITE_WORDCLOUD_API_BASE_URL` 改为完整的远端 URL。
+9
View File
@@ -45,6 +45,15 @@ server {
proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Forwarded-Port $server_port;
} }
location ^~ /wordcloud-product-api/ {
proxy_pass http://realtime:8787/;
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;
}
location ^~ /assets/ { location ^~ /assets/ {
try_files $uri =404; try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable"; add_header Cache-Control "public, max-age=31536000, immutable";
+4 -1
View File
@@ -8,7 +8,7 @@ services:
VITE_REALTIME_MODE: ${VITE_REALTIME_MODE:-websocket} VITE_REALTIME_MODE: ${VITE_REALTIME_MODE:-websocket}
VITE_REALTIME_WS_URL: ${VITE_REALTIME_WS_URL:-/ws} VITE_REALTIME_WS_URL: ${VITE_REALTIME_WS_URL:-/ws}
VITE_WORDCLOUD_API_BASE_URL: ${VITE_WORDCLOUD_API_BASE_URL:-/wordcloud-api} VITE_WORDCLOUD_API_BASE_URL: ${VITE_WORDCLOUD_API_BASE_URL:-/wordcloud-api}
VITE_WORDCLOUD_DEFAULT_JOB_ID: ${VITE_WORDCLOUD_DEFAULT_JOB_ID:-bd1f240adcb4458f857c40ac427122c6} VITE_WORDCLOUD_DEFAULT_PRODUCT_ID: ${VITE_WORDCLOUD_DEFAULT_PRODUCT_ID:-prod_362d8ecaced241e5ab81dddfc880d6b0}
ports: ports:
- "${APP_PORT:-47880}:80" - "${APP_PORT:-47880}:80"
depends_on: depends_on:
@@ -25,6 +25,9 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
environment: environment:
REALTIME_PORT: 8787 REALTIME_PORT: 8787
WORDCLOUD_API_UPSTREAM: ${WORDCLOUD_API_UPSTREAM:-http://192.168.31.213:8000}
ORDERS_ADMIN_PASSWORD: ${ORDERS_ADMIN_PASSWORD:-zhihui2024}
ORDERS_TOKEN_SECRET: ${ORDERS_TOKEN_SECRET:-wordcloud-orders-demo}
expose: expose:
- "8787" - "8787"
restart: unless-stopped restart: unless-stopped
+80 -1
View File
@@ -1,7 +1,30 @@
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import { createHash } from 'node:crypto';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws';
function selectLatestSourceJobId(product) {
const versions = (product?.versions ?? [])
.filter((version) => Array.isArray(version?.wordcloud_archives))
.sort((left, right) => {
const leftTime = Date.parse(left?.created_at ?? '');
const rightTime = Date.parse(right?.created_at ?? '');
return (Number.isFinite(rightTime) ? rightTime : 0)
- (Number.isFinite(leftTime) ? leftTime : 0);
});
for (const version of versions) {
const archive = version.wordcloud_archives.find((item) => (
typeof item?.source_job_id === 'string' && item.source_job_id.length > 0
));
if (archive) {
return archive.source_job_id;
}
}
return null;
}
function isFocusEvent(value) { function isFocusEvent(value) {
return ( return (
typeof value === 'object' && typeof value === 'object' &&
@@ -18,7 +41,22 @@ function isFocusEvent(value) {
); );
} }
export function createRealtimeServer(port = Number(process.env.REALTIME_PORT || 8787)) { export function createRealtimeServer(
port = Number(process.env.REALTIME_PORT || 8787),
options = {},
) {
const upstreamUrl = options.upstreamUrl
?? process.env.WORDCLOUD_API_UPSTREAM
?? 'http://192.168.31.213:8000';
const adminPassword = options.adminPassword
?? process.env.ORDERS_ADMIN_PASSWORD
?? 'zhihui2024';
const tokenSecret = options.tokenSecret
?? process.env.ORDERS_TOKEN_SECRET
?? 'wordcloud-orders-demo';
const fetchImpl = options.fetchImpl ?? fetch;
const upstream = upstreamUrl.replace(/\/+$/, '');
const server = createServer((request, response) => { const server = createServer((request, response) => {
if (request.url === '/healthz') { if (request.url === '/healthz') {
response.writeHead(200, { 'content-type': 'application/json' }); response.writeHead(200, { 'content-type': 'application/json' });
@@ -26,6 +64,47 @@ export function createRealtimeServer(port = Number(process.env.REALTIME_PORT ||
return; return;
} }
const sourceJobMatch = request.url.match(/^\/api\/products\/([^/]+)\/source-job$/);
if (sourceJobMatch && request.method === 'GET') {
const productId = decodeURIComponent(sourceJobMatch[1]);
const sendError = (status, message) => {
response.writeHead(status, { 'content-type': 'application/json' });
response.end(JSON.stringify({ detail: message }));
};
fetchImpl(`${upstream}/api/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ password: adminPassword }),
})
.then(async (loginResponse) => {
if (!loginResponse.ok) {
return sendError(502, '词云产品服务认证失败');
}
const { token } = await loginResponse.json();
return fetchImpl(`${upstream}/api/products/${encodeURIComponent(productId)}`, {
headers: { Authorization: `Bearer ${token}` },
});
})
.then(async (productResponse) => {
if (!productResponse.ok) {
return sendError(
productResponse.status === 404 ? 404 : 502,
productResponse.status === 404 ? 'product not found' : '词云产品服务不可用',
);
}
const sourceJobId = selectLatestSourceJobId(await productResponse.json());
if (!sourceJobId) {
return sendError(404, 'product has no wordcloud archive');
}
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify({ product_id: productId, source_job_id: sourceJobId }));
})
.catch(() => sendError(502, '词云产品服务不可用'));
return;
}
response.writeHead(404, { 'content-type': 'text/plain' }); response.writeHead(404, { 'content-type': 'text/plain' });
response.end('Not found'); response.end('Not found');
}); });
+62
View File
@@ -3,6 +3,68 @@ import assert from 'node:assert/strict';
import { createRealtimeServer } from './realtime-server.mjs'; import { createRealtimeServer } from './realtime-server.mjs';
import WebSocket from 'ws'; import WebSocket from 'ws';
const productDetail = {
product_id: 'prod_abc123',
versions: [
{
version_id: 'ver_old',
created_at: '2026-09-13T08:00:00Z',
wordcloud_archives: [
{ source_job_id: 'job_old', created_at: '2026-09-13T08:00:00Z' },
],
},
{
version_id: 'ver_new',
created_at: '2026-09-13T09:00:00Z',
wordcloud_archives: [
{ source_job_id: 'job_new', created_at: '2026-09-13T09:00:00Z' },
],
},
],
};
test('resolves a product id to the latest source job id', async () => {
const requests = [];
const fetchImpl = async (input, init) => {
requests.push({ input: String(input), init });
if (input === 'http://wordcloud.test/api/login') {
return new Response(JSON.stringify({ token: 'product-token' }), { status: 200 });
}
if (input === 'http://wordcloud.test/api/products/prod_abc123') {
return new Response(JSON.stringify(productDetail), { status: 200 });
}
return new Response('not found', { status: 404 });
};
const server = createRealtimeServer(0, {
upstreamUrl: 'http://wordcloud.test',
adminPassword: 'test-password',
tokenSecret: 'test-secret',
fetchImpl,
});
await new Promise((resolve) => server.listen(resolve));
const { port } = server.address();
try {
const response = await fetch(`http://127.0.0.1:${port}/api/products/prod_abc123/source-job`);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), {
product_id: 'prod_abc123',
source_job_id: 'job_new',
});
assert.deepEqual(requests.map((request) => request.input), [
'http://wordcloud.test/api/login',
'http://wordcloud.test/api/products/prod_abc123',
]);
const loginBody = JSON.parse(requests[0].init.body);
assert.equal(loginBody.password, 'test-password');
assert.equal(requests[1].init.headers.Authorization, 'Bearer product-token');
} finally {
server.close();
}
});
test('broadcasts focus events to other connected clients', async () => { test('broadcasts focus events to other connected clients', async () => {
const server = createRealtimeServer(0); const server = createRealtimeServer(0);
await new Promise((resolve) => server.listen(resolve)); await new Promise((resolve) => server.listen(resolve));
+13 -3
View File
@@ -4,7 +4,8 @@ import { MemoryRouter } from 'react-router-dom';
import { afterEach, vi } from 'vitest'; import { afterEach, vi } from 'vitest';
import { AppRoutes } from './AppRoutes'; import { AppRoutes } from './AppRoutes';
const remoteJobId = 'bd1f240adcb4458f857c40ac427122c6'; const remoteProductId = 'prod_abc123';
const remoteJobId = 'job_abc123';
const remoteLocationResult = { const remoteLocationResult = {
job_id: remoteJobId, job_id: remoteJobId,
query: '', query: '',
@@ -49,6 +50,12 @@ function mockRemoteCloudApi() {
return vi.fn(async (input: RequestInfo | URL) => { return vi.fn(async (input: RequestInfo | URL) => {
const url = String(input); const url = String(input);
if (url === `/wordcloud-product-api/api/products/${remoteProductId}/source-job`) {
return new Response(JSON.stringify({ product_id: remoteProductId, source_job_id: remoteJobId }), {
status: 200,
});
}
if (url.endsWith(`/api/jobs/${remoteJobId}`)) { if (url.endsWith(`/api/jobs/${remoteJobId}`)) {
return new Response('job not found', { status: 404 }); return new Response('job not found', { status: 404 });
} }
@@ -150,7 +157,7 @@ describe('application routes', () => {
const user = userEvent.setup(); const user = userEvent.setup();
const personal = render( const personal = render(
<MemoryRouter initialEntries={[`/cloud/${remoteJobId}`]}> <MemoryRouter initialEntries={[`/cloud/${remoteProductId}`]}>
<AppRoutes /> <AppRoutes />
</MemoryRouter>, </MemoryRouter>,
); );
@@ -159,6 +166,9 @@ describe('application routes', () => {
expect(personal.container.querySelectorAll('g[data-word-id]')).toHaveLength(2); expect(personal.container.querySelectorAll('g[data-word-id]')).toHaveLength(2);
}); });
expect(observe).toHaveBeenCalledWith(expect.any(HTMLDivElement)); expect(observe).toHaveBeenCalledWith(expect.any(HTMLDivElement));
expect(fetchMock).toHaveBeenCalledWith(
`/wordcloud-product-api/api/products/${remoteProductId}/source-job`,
);
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
`/wordcloud-api/api/jobs/${remoteJobId}/locations?name=`, `/wordcloud-api/api/jobs/${remoteJobId}/locations?name=`,
); );
@@ -174,7 +184,7 @@ describe('application routes', () => {
personal.unmount(); personal.unmount();
const screenView = render( const screenView = render(
<MemoryRouter initialEntries={[`/screen/${remoteJobId}`]}> <MemoryRouter initialEntries={[`/screen/${remoteProductId}`]}>
<AppRoutes /> <AppRoutes />
</MemoryRouter>, </MemoryRouter>,
); );
+9 -9
View File
@@ -7,28 +7,28 @@ export function AppRoutes() {
return ( return (
<Routes> <Routes>
<Route path="/" element={<Navigate to="/cloud/imported" replace />} /> <Route path="/" element={<Navigate to="/cloud/imported" replace />} />
<Route path="/cloud/:cloudId" element={<PersonalRoute />} /> <Route path="/cloud/:productId" element={<PersonalRoute />} />
<Route path="/screen/:cloudId" element={<PublicRoute />} /> <Route path="/screen/:productId" element={<PublicRoute />} />
<Route path="/control/:cloudId" element={<ControllerRoute />} /> <Route path="/control/:productId" element={<ControllerRoute />} />
<Route path="*" element={<Navigate to="/cloud/imported" replace />} /> <Route path="*" element={<Navigate to="/cloud/imported" replace />} />
</Routes> </Routes>
); );
} }
function PersonalRoute() { function PersonalRoute() {
const { cloudId = '' } = useParams(); const { productId = '' } = useParams();
return <PersonalSearchPage cloudId={cloudId} />; return <PersonalSearchPage productId={productId} />;
} }
function PublicRoute() { function PublicRoute() {
const { cloudId = '' } = useParams(); const { productId = '' } = useParams();
return <PublicDisplayPage cloudId={cloudId} />; return <PublicDisplayPage productId={productId} />;
} }
function ControllerRoute() { function ControllerRoute() {
const { cloudId = '' } = useParams(); const { productId = '' } = useParams();
return <RemoteControllerPage cloudId={cloudId} />; return <RemoteControllerPage productId={productId} />;
} }
+4 -4
View File
@@ -3,15 +3,15 @@ import { toDataURL } from 'qrcode';
import styles from './QRPanel.module.css'; import styles from './QRPanel.module.css';
interface QRPanelProps { interface QRPanelProps {
cloudId: string; productId: string;
} }
export function QRPanel({ cloudId }: QRPanelProps) { export function QRPanel({ productId }: QRPanelProps) {
const [qrDataUrl, setQRDataUrl] = useState<string | null>(null); const [qrDataUrl, setQRDataUrl] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const url = new URL(`/control/${encodeURIComponent(cloudId)}`, window.location.origin).href; const url = new URL(`/control/${encodeURIComponent(productId)}`, window.location.origin).href;
toDataURL(url, { toDataURL(url, {
errorCorrectionLevel: 'M', errorCorrectionLevel: 'M',
@@ -38,7 +38,7 @@ export function QRPanel({ cloudId }: QRPanelProps) {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [cloudId]); }, [productId]);
return ( return (
<aside className={styles.panel} aria-label="扫码互动"> <aside className={styles.panel} aria-label="扫码互动">
+9 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { wordCloudApiUrl } from './wordcloudApi'; import { productSourceJobUrl, wordCloudApiUrl } from './wordcloudApi';
describe('wordCloudApiUrl', () => { describe('wordCloudApiUrl', () => {
it('uses the same-origin WordCloud gateway by default', () => { it('uses the same-origin WordCloud gateway by default', () => {
@@ -8,3 +8,11 @@ describe('wordCloudApiUrl', () => {
); );
}); });
}); });
describe('productSourceJobUrl', () => {
it('points to the same-origin product resolver', () => {
expect(productSourceJobUrl('prod_abc123')).toBe(
'/wordcloud-product-api/api/products/prod_abc123/source-job',
);
});
});
+9 -5
View File
@@ -1,5 +1,5 @@
const DEFAULT_API_BASE_URL = '/wordcloud-api'; const DEFAULT_API_BASE_URL = '/wordcloud-api';
const DEFAULT_JOB_ID = 'bd1f240adcb4458f857c40ac427122c6'; const DEFAULT_PRODUCT_ID = 'prod_362d8ecaced241e5ab81dddfc880d6b0';
function trimTrailingSlashes(value: string): string { function trimTrailingSlashes(value: string): string {
return value.replace(/\/+$/, ''); return value.replace(/\/+$/, '');
@@ -9,11 +9,15 @@ export const WORDCLOUD_API_BASE_URL = trimTrailingSlashes(
import.meta.env.VITE_WORDCLOUD_API_BASE_URL?.trim() || DEFAULT_API_BASE_URL, import.meta.env.VITE_WORDCLOUD_API_BASE_URL?.trim() || DEFAULT_API_BASE_URL,
); );
export const DEFAULT_WORDCLOUD_JOB_ID = export const DEFAULT_WORDCLOUD_PRODUCT_ID =
import.meta.env.VITE_WORDCLOUD_DEFAULT_JOB_ID?.trim() || DEFAULT_JOB_ID; import.meta.env.VITE_WORDCLOUD_DEFAULT_PRODUCT_ID?.trim() || DEFAULT_PRODUCT_ID;
export function resolveWordCloudJobId(cloudId: string): string { export function resolveWordCloudProductId(productId: string): string {
return cloudId === 'imported' ? DEFAULT_WORDCLOUD_JOB_ID : cloudId; return productId === 'imported' ? DEFAULT_WORDCLOUD_PRODUCT_ID : productId;
}
export function productSourceJobUrl(productId: string): string {
return `/wordcloud-product-api/api/products/${encodeURIComponent(productId)}/source-job`;
} }
export function wordCloudApiUrl(path: string): string { export function wordCloudApiUrl(path: string): string {
+7 -7
View File
@@ -10,19 +10,19 @@ interface CloudLoadState {
cloud: WordCloud | null; cloud: WordCloud | null;
} }
function getInitialState(cloudId: string): CloudLoadState { function getInitialState(productId: string): CloudLoadState {
const localCloud = getCloudById(cloudId); const localCloud = getCloudById(productId);
return localCloud return localCloud
? { status: 'ready', cloud: localCloud } ? { status: 'ready', cloud: localCloud }
: { status: 'loading', cloud: null }; : { status: 'loading', cloud: null };
} }
export function useCloud(cloudId: string): CloudLoadState { export function useCloud(productId: string): CloudLoadState {
const [state, setState] = useState<CloudLoadState>(() => getInitialState(cloudId)); const [state, setState] = useState<CloudLoadState>(() => getInitialState(productId));
useEffect(() => { useEffect(() => {
const localCloud = getCloudById(cloudId); const localCloud = getCloudById(productId);
if (localCloud) { if (localCloud) {
setState({ status: 'ready', cloud: localCloud }); setState({ status: 'ready', cloud: localCloud });
@@ -32,7 +32,7 @@ export function useCloud(cloudId: string): CloudLoadState {
let cancelled = false; let cancelled = false;
setState({ status: 'loading', cloud: null }); setState({ status: 'loading', cloud: null });
void loadRemoteWordCloud(cloudId) void loadRemoteWordCloud(productId)
.then((cloud) => { .then((cloud) => {
if (!cancelled) { if (!cancelled) {
setState({ status: 'ready', cloud }); setState({ status: 'ready', cloud });
@@ -47,7 +47,7 @@ export function useCloud(cloudId: string): CloudLoadState {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [cloudId]); }, [productId]);
return state; return state;
} }
+5 -5
View File
@@ -17,7 +17,7 @@ function createMessageId(): string {
} }
export function useWordCloudChannel( export function useWordCloudChannel(
cloudId: string, productId: string,
onEvent: (message: FocusEvent) => void, onEvent: (message: FocusEvent) => void,
) { ) {
const channelRef = useRef<RealtimeChannel | null>(null); const channelRef = useRef<RealtimeChannel | null>(null);
@@ -41,7 +41,7 @@ export function useWordCloudChannel(
channelRef.current = channel; channelRef.current = channel;
const unsubscribe = channel.subscribe((message) => { const unsubscribe = channel.subscribe((message) => {
if (message.cloudId === cloudId) { if (message.cloudId === productId) {
onEventRef.current(message); onEventRef.current(message);
} }
}); });
@@ -51,13 +51,13 @@ export function useWordCloudChannel(
channel.close(); channel.close();
channelRef.current = null; channelRef.current = null;
}; };
}, [cloudId]); }, [productId]);
const send = useCallback( const send = useCallback(
(name: string, targetIndex = 0) => { (name: string, targetIndex = 0) => {
const message: FocusEvent = { const message: FocusEvent = {
type: 'FOCUS_NAME', type: 'FOCUS_NAME',
cloudId, cloudId: productId,
name, name,
targetIndex, targetIndex,
messageId: createMessageId(), messageId: createMessageId(),
@@ -66,7 +66,7 @@ export function useWordCloudChannel(
channelRef.current?.publish(message); channelRef.current?.publish(message);
}, },
[cloudId], [productId],
); );
return send; return send;
+29 -6
View File
@@ -3,11 +3,17 @@ import {
mapImportedWordLocations, mapImportedWordLocations,
} from './wordcloudImport'; } from './wordcloudImport';
import { import {
resolveWordCloudJobId, productSourceJobUrl,
resolveWordCloudProductId,
wordCloudApiUrl, wordCloudApiUrl,
} from '../config/wordcloudApi'; } from '../config/wordcloudApi';
import type { WordCloud } from '../types/cloud'; import type { WordCloud } from '../types/cloud';
interface ProductSourceJob {
product_id?: unknown;
source_job_id?: unknown;
}
const REMOTE_PRESENTATION = { const REMOTE_PRESENTATION = {
eyebrow: 'LIVE ARCHIVE · 2026', eyebrow: 'LIVE ARCHIVE · 2026',
title: '2026 毕业典礼 · 全体名单', title: '2026 毕业典礼 · 全体名单',
@@ -20,11 +26,11 @@ const REMOTE_PRESENTATION = {
}, },
} satisfies Omit<WordCloud, 'id' | 'words' | 'sourceCanvas' | 'sourceSvg'>; } satisfies Omit<WordCloud, 'id' | 'words' | 'sourceCanvas' | 'sourceSvg'>;
async function getJson(path: string): Promise<unknown> { async function fetchJson<T = unknown>(url: string): Promise<T> {
let response: Response; let response: Response;
try { try {
response = await fetch(wordCloudApiUrl(path)); response = await fetch(url);
} catch { } catch {
throw new Error('无法连接词云数据服务'); throw new Error('无法连接词云数据服务');
} }
@@ -40,8 +46,25 @@ async function getJson(path: string): Promise<unknown> {
} }
} }
export async function loadRemoteWordCloud(cloudId: string): Promise<WordCloud> { async function getJson<T = unknown>(path: string): Promise<T> {
const jobId = resolveWordCloudJobId(cloudId); return fetchJson<T>(wordCloudApiUrl(path));
}
export async function loadRemoteWordCloud(productId: string): Promise<WordCloud> {
const normalizedProductId = resolveWordCloudProductId(productId);
const product = await fetchJson<ProductSourceJob>(
productSourceJobUrl(normalizedProductId),
);
const jobId = product?.source_job_id;
if (
typeof jobId !== 'string'
|| jobId.length === 0
|| product?.product_id !== normalizedProductId
) {
throw new Error('产品没有可用的词云归档');
}
const locations = await getJson( const locations = await getJson(
`/api/jobs/${encodeURIComponent(jobId)}/locations?name=`, `/api/jobs/${encodeURIComponent(jobId)}/locations?name=`,
); );
@@ -55,7 +78,7 @@ export async function loadRemoteWordCloud(cloudId: string): Promise<WordCloud> {
} }
return { return {
id: cloudId, id: normalizedProductId,
...REMOTE_PRESENTATION, ...REMOTE_PRESENTATION,
sourceCanvas: { sourceCanvas: {
width: locations.canvas_width, width: locations.canvas_width,
+3 -3
View File
@@ -11,11 +11,11 @@ import { useWordCloudFocus } from '../hooks/useWordCloudFocus';
import styles from './PersonalSearchPage.module.css'; import styles from './PersonalSearchPage.module.css';
interface PersonalSearchPageProps { interface PersonalSearchPageProps {
cloudId: string; productId: string;
} }
export function PersonalSearchPage({ cloudId }: PersonalSearchPageProps) { export function PersonalSearchPage({ productId }: PersonalSearchPageProps) {
const cloudState = useCloud(cloudId); const cloudState = useCloud(productId);
const cloud = cloudState.cloud; const cloud = cloudState.cloud;
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [hasNotFound, setHasNotFound] = useState(false); const [hasNotFound, setHasNotFound] = useState(false);
+5 -5
View File
@@ -14,11 +14,11 @@ import type { FocusEvent } from '../types/channel';
import styles from './PublicDisplayPage.module.css'; import styles from './PublicDisplayPage.module.css';
interface PublicDisplayPageProps { interface PublicDisplayPageProps {
cloudId: string; productId: string;
} }
export function PublicDisplayPage({ cloudId }: PublicDisplayPageProps) { export function PublicDisplayPage({ productId }: PublicDisplayPageProps) {
const cloudState = useCloud(cloudId); const cloudState = useCloud(productId);
const cloud = cloudState.cloud; const cloud = cloudState.cloud;
const queue = useFocusQueue(); const queue = useFocusQueue();
const focus = useWordCloudFocus({ const focus = useWordCloudFocus({
@@ -33,7 +33,7 @@ export function PublicDisplayPage({ cloudId }: PublicDisplayPageProps) {
}, },
[queue], [queue],
); );
useWordCloudChannel(cloudId, receiveMessage); useWordCloudChannel(productId, receiveMessage);
useEffect(() => { useEffect(() => {
if (!queue.current || focus.phase !== 'idle' || !cloud) { if (!queue.current || focus.phase !== 'idle' || !cloud) {
@@ -81,7 +81,7 @@ export function PublicDisplayPage({ cloudId }: PublicDisplayPageProps) {
layered layered
/> />
<p className={styles.caption}>{cloud.caption}</p> <p className={styles.caption}>{cloud.caption}</p>
<QRPanel cloudId={cloudId} /> <QRPanel productId={productId} />
<FullscreenButton /> <FullscreenButton />
</main> </main>
); );
+4 -4
View File
@@ -9,17 +9,17 @@ import { buildFocusTargets, type FocusTarget } from '../lib/wordCloud';
import styles from './RemoteControllerPage.module.css'; import styles from './RemoteControllerPage.module.css';
interface RemoteControllerPageProps { interface RemoteControllerPageProps {
cloudId: string; productId: string;
} }
export function RemoteControllerPage({ cloudId }: RemoteControllerPageProps) { export function RemoteControllerPage({ productId }: RemoteControllerPageProps) {
const cloudState = useCloud(cloudId); const cloudState = useCloud(productId);
const cloud = cloudState.cloud; const cloud = cloudState.cloud;
const [name, setName] = useState(''); const [name, setName] = useState('');
const [status, setStatus] = useState<'idle' | 'not-found' | 'success'>('idle'); const [status, setStatus] = useState<'idle' | 'not-found' | 'success'>('idle');
const [controllerTargets, setControllerTargets] = useState<FocusTarget[]>([]); const [controllerTargets, setControllerTargets] = useState<FocusTarget[]>([]);
const [selectedTargetIndex, setSelectedTargetIndex] = useState(0); const [selectedTargetIndex, setSelectedTargetIndex] = useState(0);
const sendFocusEvent = useWordCloudChannel(cloudId, () => undefined); const sendFocusEvent = useWordCloudChannel(productId, () => undefined);
if (cloudState.status === 'loading') { if (cloudState.status === 'loading') {
return <CloudLoadingState />; return <CloudLoadingState />;
+5
View File
@@ -14,6 +14,11 @@ export default defineConfig({
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/wordcloud-api/, ''), rewrite: (path) => path.replace(/^\/wordcloud-api/, ''),
}, },
'/wordcloud-product-api': {
target: 'http://127.0.0.1:8787',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/wordcloud-product-api/, ''),
},
}, },
}, },
test: { test: {