Add product source job resolver
This commit is contained in:
+4
-2
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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="扫码互动">
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 />;
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user