收口前端平台组件库能力

新增 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

@@ -15,6 +15,8 @@ import type {
PuzzleClearRuntimeSnapshotResponse,
PuzzleClearWorkProfileResponse,
} from '../../../packages/shared/src/contracts/puzzleClear';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type PuzzleClearRuntimeShellProps = {
@@ -176,7 +178,9 @@ function readCellPositionFromElement(element: Element | null) {
return { row, col };
}
function resolvePointerReleaseCell(event: ReactPointerEvent<HTMLButtonElement>) {
function resolvePointerReleaseCell(
event: ReactPointerEvent<HTMLButtonElement>,
) {
if (typeof document.elementFromPoint !== 'function') {
return null;
}
@@ -218,9 +222,7 @@ function mergeRects(rects: PuzzleClearRect[]) {
}
const left = Math.min(...visibleRects.map((rect) => rect.left));
const top = Math.min(...visibleRects.map((rect) => rect.top));
const right = Math.max(
...visibleRects.map((rect) => rect.left + rect.width),
);
const right = Math.max(...visibleRects.map((rect) => rect.left + rect.width));
const bottom = Math.max(
...visibleRects.map((rect) => rect.top + rect.height),
);
@@ -249,7 +251,9 @@ function isSameGridPosition(
left: PuzzleClearGridPosition | null | undefined,
right: PuzzleClearGridPosition | null | undefined,
) {
return Boolean(left && right && left.row === right.row && left.col === right.col);
return Boolean(
left && right && left.row === right.row && left.col === right.col,
);
}
function resolveBoardPreviewState(
@@ -357,11 +361,14 @@ function buildPuzzleClearClearTransition(
}
}
const nextCardIds = new Set(
nextRun.board.cells.flatMap((cell) => (cell.card ? [cell.card.cardId] : [])),
nextRun.board.cells.flatMap((cell) =>
cell.card ? [cell.card.cardId] : [],
),
);
const clearedCells = previousRun.board.cells
.filter((cell): cell is PuzzleClearBoardCell & { card: PuzzleClearCardAsset } =>
Boolean(cell.card),
.filter(
(cell): cell is PuzzleClearBoardCell & { card: PuzzleClearCardAsset } =>
Boolean(cell.card),
)
.filter((cell) => !nextCardIds.has(cell.card.cardId))
.map((cell) => ({
@@ -420,7 +427,9 @@ function buildPuzzleClearClearTransition(
if (!previousPosition) {
return false;
}
return previousPosition.row !== cell.row || previousPosition.col !== cell.col;
return (
previousPosition.row !== cell.row || previousPosition.col !== cell.col
);
});
return {
@@ -450,19 +459,19 @@ export function PuzzleClearRuntimeShell({
const dragDropTimerRef = useRef<number | null>(null);
const swapFeedbackTimerRef = useRef<number | null>(null);
const swapFlightTimerRef = useRef<number | null>(null);
const previousRunRef = useRef<PuzzleClearRuntimeSnapshotResponse | null>(null);
const previousRunRef = useRef<PuzzleClearRuntimeSnapshotResponse | null>(
null,
);
const clearTransitionTimerRef = useRef<number | null>(null);
const mergeHighlightTimerRef = useRef<number | null>(null);
const cellElementRefMap = useRef(new Map<string, HTMLButtonElement>());
const [selectedCell, setSelectedCell] = useState<PuzzleClearGridPosition | null>(
null,
);
const [selectedCell, setSelectedCell] =
useState<PuzzleClearGridPosition | null>(null);
const [dragState, setDragState] = useState<PuzzleClearDragState | null>(null);
const [swapFeedback, setSwapFeedback] =
useState<PuzzleClearSwapFeedbackState | null>(null);
const [swapFlight, setSwapFlight] = useState<PuzzleClearSwapFlightState | null>(
null,
);
const [swapFlight, setSwapFlight] =
useState<PuzzleClearSwapFlightState | null>(null);
const [clearTransition, setClearTransition] =
useState<PuzzleClearClearTransitionState | null>(null);
const [mergeHighlight, setMergeHighlight] =
@@ -551,7 +560,10 @@ export function PuzzleClearRuntimeShell({
'';
const cardBackSrc = profile?.draft.cardBackImageSrc?.trim() || '';
const fallbackCardBackSrc = '/creation-type-references/puzzle.webp';
const effectiveCardBackSrc = resolveCardBackSrc(cardBackSrc, fallbackCardBackSrc);
const effectiveCardBackSrc = resolveCardBackSrc(
cardBackSrc,
fallbackCardBackSrc,
);
const isPlaying = activeRun?.status === 'playing';
const hasWonLevel = activeRun?.status === 'level_cleared';
const hasFinished = activeRun?.status === 'finished';
@@ -571,12 +583,14 @@ export function PuzzleClearRuntimeShell({
dragState.phase === 'dropping'
? `translate(${dragState.targetRect ? dragState.targetRect.left - (dragState.currentX - dragState.pointerOffsetX) : 0}px, ${
dragState.targetRect
? dragState.targetRect.top - (dragState.currentY - dragState.pointerOffsetY)
? dragState.targetRect.top -
(dragState.currentY - dragState.pointerOffsetY)
: 0
}px) scale(0.94)`
: dragState.phase === 'returning'
? `translate(${dragState.sourceRect.left - (dragState.currentX - dragState.pointerOffsetX)}px, ${
dragState.sourceRect.top - (dragState.currentY - dragState.pointerOffsetY)
dragState.sourceRect.top -
(dragState.currentY - dragState.pointerOffsetY)
}px) scale(1)`
: 'translate3d(0,0,0) scale(1.04)',
opacity: dragState.phase === 'returning' ? 0.86 : 0.98,
@@ -842,25 +856,25 @@ export function PuzzleClearRuntimeShell({
const sourceGroupId = target.lockedGroupId;
const sourceGroup =
sourceGroupId !== null
? lockedGroups.find((group) => group.groupId === sourceGroupId) ?? null
? (lockedGroups.find((group) => group.groupId === sourceGroupId) ??
null)
: null;
const sourceRect =
(sourceGroup
? mergeRects(
sourceGroup.cells
.map((entry) =>
readElementRect(
cellElementRefMap.current.get(getCellKey(entry.row, entry.col)),
),
)
.filter((rect): rect is PuzzleClearRect => rect !== null),
)
: readElementRect(event.currentTarget)) ?? {
left: 0,
top: 0,
width: 0,
height: 0,
};
const sourceRect = (sourceGroup
? mergeRects(
sourceGroup.cells
.map((entry) =>
readElementRect(
cellElementRefMap.current.get(getCellKey(entry.row, entry.col)),
),
)
.filter((rect): rect is PuzzleClearRect => rect !== null),
)
: readElementRect(event.currentTarget)) ?? {
left: 0,
top: 0,
width: 0,
height: 0,
};
setDragState({
card: target.card,
from: origin,
@@ -876,7 +890,9 @@ export function PuzzleClearRuntimeShell({
});
};
const handleCellPointerMove = (event: ReactPointerEvent<HTMLButtonElement>) => {
const handleCellPointerMove = (
event: ReactPointerEvent<HTMLButtonElement>,
) => {
if (dragPointerIdRef.current !== event.pointerId) {
return;
}
@@ -928,9 +944,7 @@ export function PuzzleClearRuntimeShell({
scheduleDragFeedbackReset(140);
return;
}
const target = cells.get(
getCellKey(releaseTarget.row, releaseTarget.col),
);
const target = cells.get(getCellKey(releaseTarget.row, releaseTarget.col));
if (!target) {
setSelectedCell(null);
setDragState((current) =>
@@ -953,7 +967,9 @@ export function PuzzleClearRuntimeShell({
suppressNextClickRef.current = false;
}, 0);
const targetRect = readElementRect(
cellElementRefMap.current.get(getCellKey(releaseTarget.row, releaseTarget.col)),
cellElementRefMap.current.get(
getCellKey(releaseTarget.row, releaseTarget.col),
),
);
const sourceRect =
dragState?.sourceRect ??
@@ -973,7 +989,7 @@ export function PuzzleClearRuntimeShell({
currentY: point.y,
targetRect,
target: releaseTarget,
}
}
: current,
);
if (targetRect && sourceRect && sourceCard) {
@@ -1000,26 +1016,28 @@ export function PuzzleClearRuntimeShell({
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(248,251,255,0.92),rgba(236,253,245,0.9))]" />
<header className="relative z-20 flex items-center justify-between gap-2 px-3 pb-2 pt-[max(0.75rem,env(safe-area-inset-top))] sm:px-4">
<button
type="button"
<PlatformActionButton
onClick={onBack}
className="platform-button platform-button--ghost min-h-0 rounded-full bg-white/82 px-3 py-2 text-sm shadow-sm backdrop-blur"
tone="ghost"
shape="pill"
className="min-h-0 bg-white/82 px-3 py-2 text-sm shadow-sm backdrop-blur"
>
<ArrowLeft className="h-4 w-4" />
</button>
</PlatformActionButton>
<div className="rounded-full border border-white/70 bg-white/86 px-3 py-2 text-sm font-black shadow-sm backdrop-blur">
{formatTimer(secondsLeft)}
</div>
<button
type="button"
<PlatformActionButton
onClick={onRetryLevel}
disabled={isBusy}
className="platform-button platform-button--ghost min-h-0 rounded-full bg-white/82 px-3 py-2 text-sm shadow-sm backdrop-blur"
tone="ghost"
shape="pill"
className="min-h-0 bg-white/82 px-3 py-2 text-sm shadow-sm backdrop-blur"
>
<RotateCcw className="h-4 w-4" />
</button>
</PlatformActionButton>
</header>
<main className="relative z-10 mx-auto flex w-full max-w-[34rem] flex-1 flex-col px-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] sm:px-4">
@@ -1089,133 +1107,146 @@ export function PuzzleClearRuntimeShell({
className="pointer-events-none absolute inset-0 h-full w-full select-none object-cover"
/>
) : null}
{Array.from({ length: (board?.rows ?? 3) * (board?.cols ?? 3) }, (_, index) => {
const cols = board?.cols ?? 3;
const row = Math.floor(index / cols);
const col = index % cols;
const cell = cells.get(getCellKey(row, col));
const card = cell?.card ?? null;
const isDragOrigin = isSameGridPosition(
boardPreview.dragOrigin,
{ row, col },
);
const isDraggedGroupCell = draggedGroupCellKeys.has(
getCellKey(row, col),
);
const selected =
selectedCell?.row === row && selectedCell?.col === col;
const isSwapFeedbackCell =
boardPreview.swapFeedback &&
((boardPreview.swapFeedback.from.row === row &&
boardPreview.swapFeedback.from.col === col) ||
(boardPreview.swapFeedback.to.row === row &&
boardPreview.swapFeedback.to.col === col));
const style = {
...getCardImageStyle(card),
'--puzzle-clear-card-index': index,
} as CSSProperties;
{Array.from(
{ length: (board?.rows ?? 3) * (board?.cols ?? 3) },
(_, index) => {
const cols = board?.cols ?? 3;
const row = Math.floor(index / cols);
const col = index % cols;
const cell = cells.get(getCellKey(row, col));
const card = cell?.card ?? null;
const isDragOrigin = isSameGridPosition(
boardPreview.dragOrigin,
{ row, col },
);
const isDraggedGroupCell = draggedGroupCellKeys.has(
getCellKey(row, col),
);
const selected =
selectedCell?.row === row && selectedCell?.col === col;
const isSwapFeedbackCell =
boardPreview.swapFeedback &&
((boardPreview.swapFeedback.from.row === row &&
boardPreview.swapFeedback.from.col === col) ||
(boardPreview.swapFeedback.to.row === row &&
boardPreview.swapFeedback.to.col === col));
const style = {
...getCardImageStyle(card),
'--puzzle-clear-card-index': index,
} as CSSProperties;
return (
<button
key={getCellKey(row, col)}
ref={(node) => {
const key = getCellKey(row, col);
if (node) {
cellElementRefMap.current.set(key, node);
return;
return (
<button
key={getCellKey(row, col)}
ref={(node) => {
const key = getCellKey(row, col);
if (node) {
cellElementRefMap.current.set(key, node);
return;
}
cellElementRefMap.current.delete(key);
}}
type="button"
disabled={!isPlaying || isBusy || isTransitioning}
onPointerDown={(event) =>
handleCellPointerDown(event, row, col)
}
cellElementRefMap.current.delete(key);
}}
type="button"
disabled={!isPlaying || isBusy || isTransitioning}
onPointerDown={(event) => handleCellPointerDown(event, row, col)}
onPointerMove={handleCellPointerMove}
onPointerCancel={() => {
dragOriginRef.current = null;
dragPointerIdRef.current = null;
setDragState(null);
setSelectedCell(null);
}}
onPointerUp={(event) => handleCellPointerUp(event, row, col)}
onLostPointerCapture={(event) => {
if (dragPointerIdRef.current !== event.pointerId) {
return;
onPointerMove={handleCellPointerMove}
onPointerCancel={() => {
dragOriginRef.current = null;
dragPointerIdRef.current = null;
setDragState(null);
setSelectedCell(null);
}}
onPointerUp={(event) =>
handleCellPointerUp(event, row, col)
}
if (dragReleasePointerIdRef.current === event.pointerId) {
return;
}
dragOriginRef.current = null;
dragPointerIdRef.current = null;
setSelectedCell(null);
setDragState(null);
}}
onClick={() => handleCellClick(row, col)}
data-puzzle-clear-cell="true"
data-puzzle-clear-row={row}
data-puzzle-clear-col={col}
draggable={false}
onDragStart={(event) => event.preventDefault()}
className={`puzzle-clear-card relative z-10 grid min-h-0 touch-none select-none place-items-center overflow-hidden rounded-[0.6rem] border transition ${
card ? 'bg-white/78 p-0.5 shadow-sm' : 'bg-transparent p-0 shadow-none'
} ${
selected
? 'border-emerald-500 ring-2 ring-emerald-300'
: card
? 'border-white/80'
: 'border-white/25'
} ${
cell?.lockedGroupId && !isDraggedGroupCell
? 'puzzle-clear-card--locked-group-cell'
: ''
} ${
card && showOpeningPhase ? 'puzzle-clear-card--opening' : ''
} ${
hasWonLevel || hasFinished
? 'puzzle-clear-card--cleared'
: ''
} ${
isDragOrigin
? 'puzzle-clear-card--ghost-origin puzzle-clear-card--drag-origin-empty'
: ''
} ${
isDraggedGroupCell && !isDragOrigin
? 'puzzle-clear-card--drag-group-empty'
: ''
} ${
isSwapFeedbackCell ? 'puzzle-clear-card--swap-feedback' : ''
} ${
cell?.lockedGroupId && !isDraggedGroupCell
? 'puzzle-clear-card--merge-feedback'
: ''
} ${
clearTransitionCellKeys.dropping.has(getCellKey(row, col))
? 'puzzle-clear-card--transition-hidden'
: ''
}`}
style={style}
aria-label={`卡片 ${row + 1}-${col + 1}`}
>
{card && !isDraggedGroupCell ? (
<span className="absolute inset-1 rounded-[0.48rem] bg-[var(--puzzle-clear-card-tone)] opacity-45" />
) : null}
{card?.imageSrc &&
!isDragOrigin &&
!isDraggedGroupCell &&
!cell?.lockedGroupId ? (
<ResolvedAssetImage
src={card.imageSrc}
alt=""
draggable={false}
onDragStart={(event) => event.preventDefault()}
className="pointer-events-none relative z-10 h-full w-full select-none rounded-[0.48rem] object-cover"
/>
) : null}
{isDragOrigin ? (
<span className="relative z-10 h-full w-full rounded-[0.48rem] border border-dashed border-white/28 bg-white/8" />
) : null}
</button>
);
})}
onLostPointerCapture={(event) => {
if (dragPointerIdRef.current !== event.pointerId) {
return;
}
if (dragReleasePointerIdRef.current === event.pointerId) {
return;
}
dragOriginRef.current = null;
dragPointerIdRef.current = null;
setSelectedCell(null);
setDragState(null);
}}
onClick={() => handleCellClick(row, col)}
data-puzzle-clear-cell="true"
data-puzzle-clear-row={row}
data-puzzle-clear-col={col}
draggable={false}
onDragStart={(event) => event.preventDefault()}
className={`puzzle-clear-card relative z-10 grid min-h-0 touch-none select-none place-items-center overflow-hidden rounded-[0.6rem] border transition ${
card
? 'bg-white/78 p-0.5 shadow-sm'
: 'bg-transparent p-0 shadow-none'
} ${
selected
? 'border-emerald-500 ring-2 ring-emerald-300'
: card
? 'border-white/80'
: 'border-white/25'
} ${
cell?.lockedGroupId && !isDraggedGroupCell
? 'puzzle-clear-card--locked-group-cell'
: ''
} ${
card && showOpeningPhase
? 'puzzle-clear-card--opening'
: ''
} ${
hasWonLevel || hasFinished
? 'puzzle-clear-card--cleared'
: ''
} ${
isDragOrigin
? 'puzzle-clear-card--ghost-origin puzzle-clear-card--drag-origin-empty'
: ''
} ${
isDraggedGroupCell && !isDragOrigin
? 'puzzle-clear-card--drag-group-empty'
: ''
} ${
isSwapFeedbackCell
? 'puzzle-clear-card--swap-feedback'
: ''
} ${
cell?.lockedGroupId && !isDraggedGroupCell
? 'puzzle-clear-card--merge-feedback'
: ''
} ${
clearTransitionCellKeys.dropping.has(getCellKey(row, col))
? 'puzzle-clear-card--transition-hidden'
: ''
}`}
style={style}
aria-label={`卡片 ${row + 1}-${col + 1}`}
>
{card && !isDraggedGroupCell ? (
<span className="absolute inset-1 rounded-[0.48rem] bg-[var(--puzzle-clear-card-tone)] opacity-45" />
) : null}
{card?.imageSrc &&
!isDragOrigin &&
!isDraggedGroupCell &&
!cell?.lockedGroupId ? (
<ResolvedAssetImage
src={card.imageSrc}
alt=""
draggable={false}
onDragStart={(event) => event.preventDefault()}
className="pointer-events-none relative z-10 h-full w-full select-none rounded-[0.48rem] object-cover"
/>
) : null}
{isDragOrigin ? (
<span className="relative z-10 h-full w-full rounded-[0.48rem] border border-dashed border-white/28 bg-white/8" />
) : null}
</button>
);
},
)}
{lockedGroups.length > 0 ? (
<div
aria-hidden="true"
@@ -1304,15 +1335,16 @@ export function PuzzleClearRuntimeShell({
<div
key={`drop:${clearTransition.transitionKey}:${cell.row}:${cell.col}:${cell.card.cardId}`}
className="puzzle-clear-transition-piece puzzle-clear-transition-piece--drop overflow-hidden rounded-[0.6rem] border border-white/72 bg-white/70"
style={{
gridColumn: cell.col + 1,
gridRow: cell.row + 1,
'--puzzle-clear-drop-delay': `${cell.delayMs}ms`,
'--puzzle-clear-drop-start-y': `calc(-120% - ${Math.max(
0,
cell.distance - 1,
) * 25}%)`,
} as CSSProperties}
style={
{
gridColumn: cell.col + 1,
gridRow: cell.row + 1,
'--puzzle-clear-drop-delay': `${cell.delayMs}ms`,
'--puzzle-clear-drop-start-y': `calc(-120% - ${
Math.max(0, cell.distance - 1) * 25
}%)`,
} as CSSProperties
}
>
<ResolvedAssetImage
src={cell.card.imageSrc}
@@ -1425,9 +1457,13 @@ export function PuzzleClearRuntimeShell({
</section>
{error ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
<PlatformStatusMessage
tone="error"
surface="platform"
className="mt-3 rounded-2xl text-sm leading-6"
>
{error}
</div>
</PlatformStatusMessage>
) : null}
</main>
@@ -1446,25 +1482,24 @@ export function PuzzleClearRuntimeShell({
{hasFailed ? '本关失败' : hasFinished ? '已完成' : '本关完成'}
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
<button
type="button"
<PlatformActionButton
onClick={onRetryLevel}
disabled={isBusy}
className="platform-button platform-button--ghost min-h-11 justify-center bg-white px-3 py-2 text-sm"
tone="ghost"
className="min-h-11 justify-center bg-white px-3 py-2 text-sm"
>
</button>
<button
type="button"
</PlatformActionButton>
<PlatformActionButton
onClick={hasFinished || hasFailed ? onBack : onNextLevel}
disabled={isBusy}
className="platform-button platform-button--primary min-h-11 justify-center gap-2 px-3 py-2 text-sm"
className="min-h-11 justify-center gap-2 px-3 py-2 text-sm"
>
{hasFinished || hasFailed ? '返回' : '下一关'}
{!hasFinished && !hasFailed ? (
<ChevronRight className="h-4 w-4" />
) : null}
</button>
</PlatformActionButton>
</div>
</div>
</div>