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_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
|
||||
VITE_WORDCLOUD_DEFAULT_PRODUCT_ID=prod_362d8ecaced241e5ab81dddfc880d6b0
|
||||
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_PROJECT=wordcloud
|
||||
IMAGE_TAG=latest
|
||||
|
||||
+2
-2
@@ -8,11 +8,11 @@ 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
|
||||
ARG VITE_WORDCLOUD_DEFAULT_PRODUCT_ID=prod_362d8ecaced241e5ab81dddfc880d6b0
|
||||
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
|
||||
ENV VITE_WORDCLOUD_DEFAULT_PRODUCT_ID=$VITE_WORDCLOUD_DEFAULT_PRODUCT_ID
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
@@ -36,7 +36,7 @@ npm run lint
|
||||
- `src/components/`:词云、搜索 dock、QR panel、状态组件。
|
||||
- `src/data/clouds/`:离线 `demo` 词云数据。
|
||||
- `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/realtime/`:BroadcastChannel adapter;后续 WebSocket/Supabase 等实现替换这一层。
|
||||
- `src/styles/global.css`:从既有设计提取的全局 tokens。
|
||||
@@ -63,14 +63,14 @@ http://<server-host>:47880/control/demo
|
||||
|
||||
如果 A 和 B 是不同域名并都指向同一台服务器,可以在外层网关或 DNS 上都转发到 Compose 暴露的端口;前端与二维码会保留访问者实际使用的域名。
|
||||
|
||||
除 `demo` 外,路由参数即 WordCloud `jobId`。例如:
|
||||
除 `demo` 外,路由参数即 WordCloud `productId`。例如:
|
||||
|
||||
```text
|
||||
/cloud/bd1f240adcb4458f857c40ac427122c6
|
||||
/screen/bd1f240adcb4458f857c40ac427122c6
|
||||
/control/bd1f240adcb4458f857c40ac427122c6
|
||||
/cloud/prod_362d8ecaced241e5ab81dddfc880d6b0
|
||||
/screen/prod_362d8ecaced241e5ab81dddfc880d6b0
|
||||
/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。
|
||||
|
||||
@@ -45,6 +45,15 @@ server {
|
||||
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/ {
|
||||
try_files $uri =404;
|
||||
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_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}
|
||||
VITE_WORDCLOUD_DEFAULT_PRODUCT_ID: ${VITE_WORDCLOUD_DEFAULT_PRODUCT_ID:-prod_362d8ecaced241e5ab81dddfc880d6b0}
|
||||
ports:
|
||||
- "${APP_PORT:-47880}:80"
|
||||
depends_on:
|
||||
@@ -25,6 +25,9 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
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:
|
||||
- "8787"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
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) {
|
||||
return (
|
||||
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) => {
|
||||
if (request.url === '/healthz') {
|
||||
response.writeHead(200, { 'content-type': 'application/json' });
|
||||
@@ -26,6 +64,47 @@ export function createRealtimeServer(port = Number(process.env.REALTIME_PORT ||
|
||||
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.end('Not found');
|
||||
});
|
||||
|
||||
@@ -3,6 +3,68 @@ import assert from 'node:assert/strict';
|
||||
import { createRealtimeServer } from './realtime-server.mjs';
|
||||
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 () => {
|
||||
const server = createRealtimeServer(0);
|
||||
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 { AppRoutes } from './AppRoutes';
|
||||
|
||||
const remoteJobId = 'bd1f240adcb4458f857c40ac427122c6';
|
||||
const remoteProductId = 'prod_abc123';
|
||||
const remoteJobId = 'job_abc123';
|
||||
const remoteLocationResult = {
|
||||
job_id: remoteJobId,
|
||||
query: '',
|
||||
@@ -49,6 +50,12 @@ function mockRemoteCloudApi() {
|
||||
return vi.fn(async (input: RequestInfo | URL) => {
|
||||
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}`)) {
|
||||
return new Response('job not found', { status: 404 });
|
||||
}
|
||||
@@ -150,7 +157,7 @@ describe('application routes', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const personal = render(
|
||||
<MemoryRouter initialEntries={[`/cloud/${remoteJobId}`]}>
|
||||
<MemoryRouter initialEntries={[`/cloud/${remoteProductId}`]}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@@ -159,6 +166,9 @@ describe('application routes', () => {
|
||||
expect(personal.container.querySelectorAll('g[data-word-id]')).toHaveLength(2);
|
||||
});
|
||||
expect(observe).toHaveBeenCalledWith(expect.any(HTMLDivElement));
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`/wordcloud-product-api/api/products/${remoteProductId}/source-job`,
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`/wordcloud-api/api/jobs/${remoteJobId}/locations?name=`,
|
||||
);
|
||||
@@ -174,7 +184,7 @@ describe('application routes', () => {
|
||||
personal.unmount();
|
||||
|
||||
const screenView = render(
|
||||
<MemoryRouter initialEntries={[`/screen/${remoteJobId}`]}>
|
||||
<MemoryRouter initialEntries={[`/screen/${remoteProductId}`]}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
+9
-9
@@ -7,28 +7,28 @@ 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="/cloud/:productId" element={<PersonalRoute />} />
|
||||
<Route path="/screen/:productId" element={<PublicRoute />} />
|
||||
<Route path="/control/:productId" element={<ControllerRoute />} />
|
||||
<Route path="*" element={<Navigate to="/cloud/imported" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonalRoute() {
|
||||
const { cloudId = '' } = useParams();
|
||||
const { productId = '' } = useParams();
|
||||
|
||||
return <PersonalSearchPage cloudId={cloudId} />;
|
||||
return <PersonalSearchPage productId={productId} />;
|
||||
}
|
||||
|
||||
function PublicRoute() {
|
||||
const { cloudId = '' } = useParams();
|
||||
const { productId = '' } = useParams();
|
||||
|
||||
return <PublicDisplayPage cloudId={cloudId} />;
|
||||
return <PublicDisplayPage productId={productId} />;
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
interface QRPanelProps {
|
||||
cloudId: string;
|
||||
productId: string;
|
||||
}
|
||||
|
||||
export function QRPanel({ cloudId }: QRPanelProps) {
|
||||
export function QRPanel({ productId }: QRPanelProps) {
|
||||
const [qrDataUrl, setQRDataUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
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, {
|
||||
errorCorrectionLevel: 'M',
|
||||
@@ -38,7 +38,7 @@ export function QRPanel({ cloudId }: QRPanelProps) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cloudId]);
|
||||
}, [productId]);
|
||||
|
||||
return (
|
||||
<aside className={styles.panel} aria-label="扫码互动">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { wordCloudApiUrl } from './wordcloudApi';
|
||||
import { productSourceJobUrl, wordCloudApiUrl } from './wordcloudApi';
|
||||
|
||||
describe('wordCloudApiUrl', () => {
|
||||
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_JOB_ID = 'bd1f240adcb4458f857c40ac427122c6';
|
||||
const DEFAULT_PRODUCT_ID = 'prod_362d8ecaced241e5ab81dddfc880d6b0';
|
||||
|
||||
function trimTrailingSlashes(value: string): string {
|
||||
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,
|
||||
);
|
||||
|
||||
export const DEFAULT_WORDCLOUD_JOB_ID =
|
||||
import.meta.env.VITE_WORDCLOUD_DEFAULT_JOB_ID?.trim() || DEFAULT_JOB_ID;
|
||||
export const DEFAULT_WORDCLOUD_PRODUCT_ID =
|
||||
import.meta.env.VITE_WORDCLOUD_DEFAULT_PRODUCT_ID?.trim() || DEFAULT_PRODUCT_ID;
|
||||
|
||||
export function resolveWordCloudJobId(cloudId: string): string {
|
||||
return cloudId === 'imported' ? DEFAULT_WORDCLOUD_JOB_ID : cloudId;
|
||||
export function resolveWordCloudProductId(productId: string): string {
|
||||
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 {
|
||||
|
||||
@@ -10,19 +10,19 @@ interface CloudLoadState {
|
||||
cloud: WordCloud | null;
|
||||
}
|
||||
|
||||
function getInitialState(cloudId: string): CloudLoadState {
|
||||
const localCloud = getCloudById(cloudId);
|
||||
function getInitialState(productId: string): CloudLoadState {
|
||||
const localCloud = getCloudById(productId);
|
||||
|
||||
return localCloud
|
||||
? { status: 'ready', cloud: localCloud }
|
||||
: { status: 'loading', cloud: null };
|
||||
}
|
||||
|
||||
export function useCloud(cloudId: string): CloudLoadState {
|
||||
const [state, setState] = useState<CloudLoadState>(() => getInitialState(cloudId));
|
||||
export function useCloud(productId: string): CloudLoadState {
|
||||
const [state, setState] = useState<CloudLoadState>(() => getInitialState(productId));
|
||||
|
||||
useEffect(() => {
|
||||
const localCloud = getCloudById(cloudId);
|
||||
const localCloud = getCloudById(productId);
|
||||
|
||||
if (localCloud) {
|
||||
setState({ status: 'ready', cloud: localCloud });
|
||||
@@ -32,7 +32,7 @@ export function useCloud(cloudId: string): CloudLoadState {
|
||||
let cancelled = false;
|
||||
setState({ status: 'loading', cloud: null });
|
||||
|
||||
void loadRemoteWordCloud(cloudId)
|
||||
void loadRemoteWordCloud(productId)
|
||||
.then((cloud) => {
|
||||
if (!cancelled) {
|
||||
setState({ status: 'ready', cloud });
|
||||
@@ -47,7 +47,7 @@ export function useCloud(cloudId: string): CloudLoadState {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cloudId]);
|
||||
}, [productId]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ function createMessageId(): string {
|
||||
}
|
||||
|
||||
export function useWordCloudChannel(
|
||||
cloudId: string,
|
||||
productId: string,
|
||||
onEvent: (message: FocusEvent) => void,
|
||||
) {
|
||||
const channelRef = useRef<RealtimeChannel | null>(null);
|
||||
@@ -41,7 +41,7 @@ export function useWordCloudChannel(
|
||||
channelRef.current = channel;
|
||||
|
||||
const unsubscribe = channel.subscribe((message) => {
|
||||
if (message.cloudId === cloudId) {
|
||||
if (message.cloudId === productId) {
|
||||
onEventRef.current(message);
|
||||
}
|
||||
});
|
||||
@@ -51,13 +51,13 @@ export function useWordCloudChannel(
|
||||
channel.close();
|
||||
channelRef.current = null;
|
||||
};
|
||||
}, [cloudId]);
|
||||
}, [productId]);
|
||||
|
||||
const send = useCallback(
|
||||
(name: string, targetIndex = 0) => {
|
||||
const message: FocusEvent = {
|
||||
type: 'FOCUS_NAME',
|
||||
cloudId,
|
||||
cloudId: productId,
|
||||
name,
|
||||
targetIndex,
|
||||
messageId: createMessageId(),
|
||||
@@ -66,7 +66,7 @@ export function useWordCloudChannel(
|
||||
|
||||
channelRef.current?.publish(message);
|
||||
},
|
||||
[cloudId],
|
||||
[productId],
|
||||
);
|
||||
|
||||
return send;
|
||||
|
||||
+29
-6
@@ -3,11 +3,17 @@ import {
|
||||
mapImportedWordLocations,
|
||||
} from './wordcloudImport';
|
||||
import {
|
||||
resolveWordCloudJobId,
|
||||
productSourceJobUrl,
|
||||
resolveWordCloudProductId,
|
||||
wordCloudApiUrl,
|
||||
} from '../config/wordcloudApi';
|
||||
import type { WordCloud } from '../types/cloud';
|
||||
|
||||
interface ProductSourceJob {
|
||||
product_id?: unknown;
|
||||
source_job_id?: unknown;
|
||||
}
|
||||
|
||||
const REMOTE_PRESENTATION = {
|
||||
eyebrow: 'LIVE ARCHIVE · 2026',
|
||||
title: '2026 毕业典礼 · 全体名单',
|
||||
@@ -20,11 +26,11 @@ const REMOTE_PRESENTATION = {
|
||||
},
|
||||
} 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;
|
||||
|
||||
try {
|
||||
response = await fetch(wordCloudApiUrl(path));
|
||||
response = await fetch(url);
|
||||
} catch {
|
||||
throw new Error('无法连接词云数据服务');
|
||||
}
|
||||
@@ -40,8 +46,25 @@ async function getJson(path: string): Promise<unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadRemoteWordCloud(cloudId: string): Promise<WordCloud> {
|
||||
const jobId = resolveWordCloudJobId(cloudId);
|
||||
async function getJson<T = unknown>(path: string): Promise<T> {
|
||||
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(
|
||||
`/api/jobs/${encodeURIComponent(jobId)}/locations?name=`,
|
||||
);
|
||||
@@ -55,7 +78,7 @@ export async function loadRemoteWordCloud(cloudId: string): Promise<WordCloud> {
|
||||
}
|
||||
|
||||
return {
|
||||
id: cloudId,
|
||||
id: normalizedProductId,
|
||||
...REMOTE_PRESENTATION,
|
||||
sourceCanvas: {
|
||||
width: locations.canvas_width,
|
||||
|
||||
@@ -11,11 +11,11 @@ import { useWordCloudFocus } from '../hooks/useWordCloudFocus';
|
||||
import styles from './PersonalSearchPage.module.css';
|
||||
|
||||
interface PersonalSearchPageProps {
|
||||
cloudId: string;
|
||||
productId: string;
|
||||
}
|
||||
|
||||
export function PersonalSearchPage({ cloudId }: PersonalSearchPageProps) {
|
||||
const cloudState = useCloud(cloudId);
|
||||
export function PersonalSearchPage({ productId }: PersonalSearchPageProps) {
|
||||
const cloudState = useCloud(productId);
|
||||
const cloud = cloudState.cloud;
|
||||
const [query, setQuery] = useState('');
|
||||
const [hasNotFound, setHasNotFound] = useState(false);
|
||||
|
||||
@@ -14,11 +14,11 @@ import type { FocusEvent } from '../types/channel';
|
||||
import styles from './PublicDisplayPage.module.css';
|
||||
|
||||
interface PublicDisplayPageProps {
|
||||
cloudId: string;
|
||||
productId: string;
|
||||
}
|
||||
|
||||
export function PublicDisplayPage({ cloudId }: PublicDisplayPageProps) {
|
||||
const cloudState = useCloud(cloudId);
|
||||
export function PublicDisplayPage({ productId }: PublicDisplayPageProps) {
|
||||
const cloudState = useCloud(productId);
|
||||
const cloud = cloudState.cloud;
|
||||
const queue = useFocusQueue();
|
||||
const focus = useWordCloudFocus({
|
||||
@@ -33,7 +33,7 @@ export function PublicDisplayPage({ cloudId }: PublicDisplayPageProps) {
|
||||
},
|
||||
[queue],
|
||||
);
|
||||
useWordCloudChannel(cloudId, receiveMessage);
|
||||
useWordCloudChannel(productId, receiveMessage);
|
||||
|
||||
useEffect(() => {
|
||||
if (!queue.current || focus.phase !== 'idle' || !cloud) {
|
||||
@@ -81,7 +81,7 @@ export function PublicDisplayPage({ cloudId }: PublicDisplayPageProps) {
|
||||
layered
|
||||
/>
|
||||
<p className={styles.caption}>{cloud.caption}</p>
|
||||
<QRPanel cloudId={cloudId} />
|
||||
<QRPanel productId={productId} />
|
||||
<FullscreenButton />
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -9,17 +9,17 @@ import { buildFocusTargets, type FocusTarget } from '../lib/wordCloud';
|
||||
import styles from './RemoteControllerPage.module.css';
|
||||
|
||||
interface RemoteControllerPageProps {
|
||||
cloudId: string;
|
||||
productId: string;
|
||||
}
|
||||
|
||||
export function RemoteControllerPage({ cloudId }: RemoteControllerPageProps) {
|
||||
const cloudState = useCloud(cloudId);
|
||||
export function RemoteControllerPage({ productId }: RemoteControllerPageProps) {
|
||||
const cloudState = useCloud(productId);
|
||||
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);
|
||||
const sendFocusEvent = useWordCloudChannel(productId, () => undefined);
|
||||
|
||||
if (cloudState.status === 'loading') {
|
||||
return <CloudLoadingState />;
|
||||
|
||||
@@ -14,6 +14,11 @@ export default defineConfig({
|
||||
changeOrigin: true,
|
||||
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: {
|
||||
|
||||
Reference in New Issue
Block a user