Add product wordcloud board links
This commit is contained in:
@@ -56,6 +56,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_WORDCLOUD_BOARD_BASE_URL: ${VITE_WORDCLOUD_BOARD_BASE_URL:-http://114.55.99.6:47880}
|
||||||
container_name: wordcloud-frontend
|
container_name: wordcloud-frontend
|
||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "3000:80"
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ COPY package.json package-lock.json* ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
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
|
RUN npm run build
|
||||||
|
|
||||||
# ── Stage 2: Serve with Nginx ──────────────────────────────────────────────
|
# ── Stage 2: Serve with Nginx ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { FormEvent } from 'react';
|
|||||||
import AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow';
|
import AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow';
|
||||||
import { apiUrl, ensureOk } from '../lib/api';
|
import { apiUrl, ensureOk } from '../lib/api';
|
||||||
import { IconArchive, IconGrid, IconHelp, IconLock, IconRefresh, IconTrash } from '../components/Icons';
|
import { IconArchive, IconGrid, IconHelp, IconLock, IconRefresh, IconTrash } from '../components/Icons';
|
||||||
|
import { wordCloudBoardUrl, type WordCloudBoardRoute } from '../lib/wordcloudBoard';
|
||||||
import {
|
import {
|
||||||
deleteProduct,
|
deleteProduct,
|
||||||
getProduct,
|
getProduct,
|
||||||
@@ -21,6 +22,12 @@ interface ProductArchivePageProps {
|
|||||||
|
|
||||||
const TOKEN_KEY = 'wordcloud-orders-token';
|
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 {
|
function statusClass(status: ProductStatus): string {
|
||||||
if (status === 'active') return 'product-status-archived';
|
if (status === 'active') return 'product-status-archived';
|
||||||
if (status === 'pending_cleanup' || status === 'failed_cleanup') return 'product-status-pending-cleanup';
|
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({
|
function AuthenticatedImage({
|
||||||
token,
|
token,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
@@ -257,6 +270,9 @@ export default function ProductArchivePage({
|
|||||||
: null;
|
: null;
|
||||||
const previewUrl = previewImage?.image_url || '';
|
const previewUrl = previewImage?.image_url || '';
|
||||||
const latestVersion = detail?.versions[detail.versions.length - 1] || null;
|
const latestVersion = detail?.versions[detail.versions.length - 1] || null;
|
||||||
|
const canOpenWordCloudBoard = detail !== null
|
||||||
|
&& selected?.status === 'active'
|
||||||
|
&& hasWordCloudArchive(detail);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="orders-page product-archive-page">
|
<div className="orders-page product-archive-page">
|
||||||
@@ -426,6 +442,32 @@ export default function ProductArchivePage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section className="product-archive-board-links" aria-labelledby="wordcloud-board-title">
|
||||||
|
<h3 id="wordcloud-board-title">词云互动</h3>
|
||||||
|
<div className="product-archive-board-actions">
|
||||||
|
{wordCloudBoardActions.map(action => (
|
||||||
|
canOpenWordCloudBoard ? (
|
||||||
|
<a
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
href={wordCloudBoardUrl(action.route, detail.product_id)}
|
||||||
|
key={action.route}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-secondary btn-sm" disabled key={action.route} type="button">
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!canOpenWordCloudBoard && (
|
||||||
|
<p className="product-archive-board-note">暂无可用词云归档</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="product-archive-timeline">
|
<div className="product-archive-timeline">
|
||||||
<h3>版本时间线</h3>
|
<h3>版本时间线</h3>
|
||||||
{detail.versions.length === 0 && <p>暂无设计版本。</p>}
|
{detail.versions.length === 0 && <p>暂无设计版本。</p>}
|
||||||
|
|||||||
@@ -3777,6 +3777,30 @@ body.resizing .studio-panel {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 15px;
|
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 {
|
.product-archive-timeline {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|||||||
@@ -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 () => {
|
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 app = await readFile(new URL('../src/App.tsx', import.meta.url), 'utf8');
|
||||||
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(app, /'products'/);
|
assert.match(app, /'products'/);
|
||||||
assert.match(page, /产品档案/);
|
assert.match(page, /产品档案/);
|
||||||
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 () => {
|
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, /软删除/);
|
||||||
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/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user