收口前端平台组件库能力

新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
This commit is contained in:
2026-06-10 10:24:18 +08:00
parent a4ee6ff698
commit 1ad25e30f8
226 changed files with 23364 additions and 7825 deletions

View File

@@ -1,9 +1,10 @@
// @vitest-environment jsdom
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { BigFishRuntimeSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
import * as clipboardService from '../../services/clipboard';
import { BigFishRuntimeShell } from './BigFishRuntimeShell';
vi.mock('../ResolvedAssetImage', () => ({
@@ -18,6 +19,10 @@ vi.mock('../ResolvedAssetImage', () => ({
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
vi.mock('../../services/clipboard', () => ({
copyTextToClipboard: vi.fn(),
}));
function createRun(
status: BigFishRuntimeSnapshotResponse['status'],
): BigFishRuntimeSnapshotResponse {
@@ -48,6 +53,10 @@ function dispatchPointerEvent(
target.dispatchEvent(event);
}
afterEach(() => {
vi.clearAllMocks();
});
describe('BigFishRuntimeShell', () => {
test('renders restart and exit actions after a failed run', () => {
const onBack = vi.fn();
@@ -107,6 +116,36 @@ describe('BigFishRuntimeShell', () => {
expect(screen.queryByRole('dialog', { name: '玩法规则' })).toBeNull();
});
test('copies public work share text through unified feedback', async () => {
vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true);
render(
<BigFishRuntimeShell
run={createRun('running')}
shareTitle="深海追击"
sharePublicWorkCode="BF-001"
onBack={() => {}}
onSubmitInput={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '分享作品' }));
await waitFor(() => {
expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith(
expect.stringContaining('邀请你来玩《深海追击》'),
);
});
const copiedText = vi.mocked(clipboardService.copyTextToClipboard).mock
.calls[0]?.[0];
expect(copiedText).toContain('作品号BF-001');
expect(copiedText).toContain('/runtime/big-fish?work=BF-001');
expect(
screen.getByRole('button', { name: '分享内容已复制' }),
).toBeTruthy();
});
test('keeps moving in the last sampled direction after drag ends', async () => {
const onSubmitInput = vi.fn();

View File

@@ -1,5 +1,11 @@
import { ArrowLeft, CircleHelp, Loader2, RotateCcw, Share2 } from 'lucide-react';
import { type PointerEvent, useEffect, useRef, useState } from 'react';
import {
type PointerEvent,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import type {
BigFishAssetSlotResponse,
@@ -8,8 +14,9 @@ import type {
SubmitBigFishInputRequest,
} from '../../../packages/shared/src/contracts/bigFish';
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import { copyTextToClipboard } from '../../services/clipboard';
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
import { UnifiedModal } from '../common/UnifiedModal';
import { useCopyFeedback } from '../common/useCopyFeedback';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type TouchOrigin = {
@@ -238,9 +245,7 @@ export function BigFishRuntimeShell({
const currentTouchRef = useRef<TouchSample | null>(null);
const lastTouchSampleRef = useRef<TouchSample | null>(null);
const [isRuleModalOpen, setIsRuleModalOpen] = useState(false);
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const { copyState: shareState, copyText: copyShareText } = useCopyFeedback();
const [stick, setStick] = useState({ x: 0, y: 0 });
const stickRef = useRef(stick);
@@ -248,6 +253,11 @@ export function BigFishRuntimeShell({
stickRef.current = stick;
}, [stick]);
const submitDirection = useCallback((direction: SubmitBigFishInputRequest) => {
setStick(direction);
onSubmitInput(direction);
}, [onSubmitInput]);
useEffect(() => {
if (run?.status !== 'running') {
return undefined;
@@ -287,12 +297,7 @@ export function BigFishRuntimeShell({
return () => {
window.clearInterval(timer);
};
}, [run?.status, touchOrigin]);
const submitDirection = (direction: SubmitBigFishInputRequest) => {
setStick(direction);
onSubmitInput(direction);
};
}, [run?.status, submitDirection, touchOrigin]);
const sharePublicWork = () => {
const publicWorkCode = sharePublicWorkCode?.trim();
if (!publicWorkCode) {
@@ -310,10 +315,7 @@ export function BigFishRuntimeShell({
const title = shareTitle?.trim() || '大鱼吃小鱼';
const shareText = `邀请你来玩《${title}\n作品号${publicWorkCode}\n${shareUrl}`;
void copyTextToClipboard(shareText).then((copied) => {
setShareState(copied ? 'copied' : 'failed');
window.setTimeout(() => setShareState('idle'), 1400);
});
void copyShareText(shareText);
};
const beginTouchControl = (event: PointerEvent<HTMLDivElement>) => {
@@ -411,27 +413,17 @@ export function BigFishRuntimeShell({
</button>
<div className="flex items-center gap-2">
{sharePublicWorkCode?.trim() ? (
<button
type="button"
aria-label={
shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享作品'
}
title={
shareState === 'copied'
? '已复制'
: shareState === 'failed'
? '复制失败'
: '分享作品'
}
<CopyFeedbackButton
state={shareState}
onClick={sharePublicWork}
idleLabel="分享作品"
copiedLabel="分享内容已复制"
failedLabel="分享内容复制失败"
idleIcon={<Share2 className="h-4 w-4" />}
copiedIcon={<Share2 className="h-4 w-4" />}
showLabel={false}
className="pointer-events-auto inline-flex h-10 w-10 items-center justify-center rounded-full bg-black/28 text-white backdrop-blur"
>
<Share2 className="h-4 w-4" />
</button>
/>
) : null}
<button
type="button"