diff --git a/docker-compose.yml b/docker-compose.yml index 011f187..3fa41e7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,6 +56,8 @@ services: build: context: ./frontend dockerfile: Dockerfile + args: + VITE_WORDCLOUD_BOARD_BASE_URL: ${VITE_WORDCLOUD_BOARD_BASE_URL:-http://114.55.99.6:47880} container_name: wordcloud-frontend ports: - "3000:80" diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 619b766..129911f 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -9,6 +9,8 @@ COPY package.json package-lock.json* ./ RUN npm ci COPY . . +ARG VITE_WORDCLOUD_BOARD_BASE_URL=http://114.55.99.6:47880 +ENV VITE_WORDCLOUD_BOARD_BASE_URL=$VITE_WORDCLOUD_BOARD_BASE_URL RUN npm run build # ── Stage 2: Serve with Nginx ────────────────────────────────────────────── diff --git a/frontend/src/lib/wordcloudBoard.ts b/frontend/src/lib/wordcloudBoard.ts new file mode 100644 index 0000000..f48fe37 --- /dev/null +++ b/frontend/src/lib/wordcloudBoard.ts @@ -0,0 +1,19 @@ +const DEFAULT_WORDCLOUD_BOARD_BASE_URL = 'http://114.55.99.6:47880'; + +export type WordCloudBoardRoute = 'cloud' | 'screen' | 'control'; + +function trimTrailingSlashes(value: string): string { + return value.replace(/\/+$/, ''); +} + +export const WORDCLOUD_BOARD_BASE_URL = trimTrailingSlashes( + import.meta.env.VITE_WORDCLOUD_BOARD_BASE_URL?.trim() + || DEFAULT_WORDCLOUD_BOARD_BASE_URL, +); + +export function wordCloudBoardUrl( + route: WordCloudBoardRoute, + productId: string, +): string { + return `${WORDCLOUD_BOARD_BASE_URL}/${route}/${encodeURIComponent(productId)}`; +} diff --git a/frontend/src/pages/ProductArchivePage.tsx b/frontend/src/pages/ProductArchivePage.tsx index 8ad718a..47ddd14 100644 --- a/frontend/src/pages/ProductArchivePage.tsx +++ b/frontend/src/pages/ProductArchivePage.tsx @@ -3,6 +3,7 @@ import type { FormEvent } from 'react'; import AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow'; import { apiUrl, ensureOk } from '../lib/api'; import { IconArchive, IconGrid, IconHelp, IconLock, IconRefresh, IconTrash } from '../components/Icons'; +import { wordCloudBoardUrl, type WordCloudBoardRoute } from '../lib/wordcloudBoard'; import { deleteProduct, getProduct, @@ -21,6 +22,12 @@ interface ProductArchivePageProps { const TOKEN_KEY = 'wordcloud-orders-token'; +const wordCloudBoardActions: Array<{ label: string; route: WordCloudBoardRoute }> = [ + { label: '个人查找', route: 'cloud' }, + { label: '现场大屏', route: 'screen' }, + { label: '手机控制', route: 'control' }, +]; + function statusClass(status: ProductStatus): string { if (status === 'active') return 'product-status-archived'; if (status === 'pending_cleanup' || status === 'failed_cleanup') return 'product-status-pending-cleanup'; @@ -57,6 +64,12 @@ function formatDateTime(value: string): string { }); } +function hasWordCloudArchive(detail: ProductDetail): boolean { + return detail.versions.some(version => + version.wordcloud_archives.some(archive => archive.source_job_id.trim().length > 0), + ); +} + function AuthenticatedImage({ token, imageUrl, @@ -257,6 +270,9 @@ export default function ProductArchivePage({ : null; const previewUrl = previewImage?.image_url || ''; const latestVersion = detail?.versions[detail.versions.length - 1] || null; + const canOpenWordCloudBoard = detail !== null + && selected?.status === 'active' + && hasWordCloudArchive(detail); return (
暂无可用词云归档
+ )} +暂无设计版本。
} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 4806bab..da373ba 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -3777,6 +3777,30 @@ body.resizing .studio-panel { color: var(--text-primary); font-size: 15px; } +.product-archive-board-links { + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-panel-alt); +} +.product-archive-board-links h3 { + margin: 0 0 10px; + color: var(--text-primary); + font-size: 13px; +} +.product-archive-board-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.product-archive-board-actions .btn { + text-decoration: none; +} +.product-archive-board-note { + margin: 8px 0 0; + color: var(--text-muted); + font-size: 11px; +} .product-archive-timeline { padding: 12px; border: 1px solid var(--border); diff --git a/frontend/tests/product-archive-ui.test.mjs b/frontend/tests/product-archive-ui.test.mjs index 8f96706..4906d45 100644 --- a/frontend/tests/product-archive-ui.test.mjs +++ b/frontend/tests/product-archive-ui.test.mjs @@ -56,8 +56,8 @@ test('new product creation archives with the created product id instead of a dra }); test('app routes to product archives and list keeps IDs out of primary cells', async () => { - const app = await readFile('frontend/src/App.tsx', 'utf8'); - const page = await readFile('frontend/src/pages/ProductArchivePage.tsx', 'utf8'); + const app = await readFile(new URL('../src/App.tsx', import.meta.url), 'utf8'); + const page = await readFile(new URL('../src/pages/ProductArchivePage.tsx', import.meta.url), 'utf8'); assert.match(app, /'products'/); assert.match(page, /产品档案/); assert.match(page, /产品名称/); @@ -65,9 +65,28 @@ test('app routes to product archives and list keeps IDs out of primary cells', a }); test('product archive page exposes restore and soft delete actions', async () => { - const page = await readFile('frontend/src/pages/ProductArchivePage.tsx', 'utf8'); + const page = await readFile(new URL('../src/pages/ProductArchivePage.tsx', import.meta.url), 'utf8'); assert.match(page, /恢复产品/); assert.match(page, /软删除/); assert.match(page, /系统信息/); assert.match(page, /待清理 · 将于/); }); + +test('product archive links use Product IDs and a configurable board origin', async () => { + const links = await readFile(new URL('../src/lib/wordcloudBoard.ts', import.meta.url), 'utf8'); + const page = await readFile(new URL('../src/pages/ProductArchivePage.tsx', import.meta.url), 'utf8'); + + assert.match(links, /VITE_WORDCLOUD_BOARD_BASE_URL/); + assert.match(links, /http:\/\/114\.55\.99\.6:47880/); + assert.match(links, /encodeURIComponent\(productId\)/); + assert.match(links, /'cloud' \| 'screen' \| 'control'/); + assert.match(page, /product-archive-board-links/); + assert.match(page, /个人查找/); + assert.match(page, /现场大屏/); + assert.match(page, /手机控制/); + assert.match(page, /canOpenWordCloudBoard/); + assert.match(page, /disabled/); + assert.match(page, /target="_blank"/); + assert.match(page, /rel="noopener noreferrer"/); + assert.match(page, /detail\.product_id/); +});