- {coverImage ? (
+ {appIconImage ? (
({
useResolvedAssetReadUrl: (src: string | null) => ({
@@ -450,16 +451,44 @@ test('合并块按实际拼块外轮廓描边', () => {
expect(outlinedPieces).toHaveLength(3);
expect(container.querySelector('.ring-2.ring-emerald-100\\/58')).toBeNull();
- expect(outlinedPieces[0]?.className).toContain('border-r-0');
- expect(outlinedPieces[0]?.className).toContain('border-b-0');
- expect(outlinedPieces[0]?.className).toContain('rounded-tl-[0.85rem]');
- expect(outlinedPieces[0]?.className).toContain('rounded-br-[0.35rem]');
- expect(outlinedPieces[1]?.className).toContain('border-l-0');
- expect(outlinedPieces[1]?.className).toContain('rounded-tr-[0.85rem]');
- expect(outlinedPieces[1]?.className).toContain('rounded-bl-[0.35rem]');
- expect(outlinedPieces[2]?.className).toContain('border-t-0');
- expect(outlinedPieces[2]?.className).toContain('rounded-tr-[0.35rem]');
- expect(outlinedPieces[2]?.className).toContain('rounded-bl-[0.85rem]');
+ expect(
+ container.querySelector('[data-merged-group-outline="true"]'),
+ ).toBeTruthy();
+ const outlineStroke = container.querySelector(
+ '[data-merged-group-outline-stroke="true"]',
+ );
+ expect(outlineStroke).toBeTruthy();
+ expect(outlineStroke?.getAttribute('d')).toContain('Q 2 1 1.84 1');
+ expect(outlineStroke?.getAttribute('d')).toContain('Q 1 1 1 1.16');
+ expect((outlinedPieces[0] as HTMLElement).style.clipPath).toBe('');
+ const clippedLayer = container.querySelector(
+ '[style*="clip-path"]',
+ ) as HTMLElement | null;
+ expect(clippedLayer?.style.clipPath).toContain('url(#');
+});
+
+test('合并块轮廓路径为内凹角生成圆角曲线', () => {
+ const outlinePath = buildMergedGroupOutlinePath({
+ rowSpan: 2,
+ colSpan: 2,
+ pieces: [
+ {
+ localRow: 0,
+ localCol: 0,
+ },
+ {
+ localRow: 0,
+ localCol: 1,
+ },
+ {
+ localRow: 1,
+ localCol: 0,
+ },
+ ],
+ });
+
+ expect(outlinePath).toContain('Q 2 1 1.84 1');
+ expect(outlinePath).toContain('Q 1 1 1 1.16');
});
test('基础单块使用圆角裁剪图片', () => {
@@ -634,7 +663,7 @@ test('道具确认弹窗暂停时间,提示只演示不直接移动拼块', as
fireEvent.click(screen.getByRole('button', { name: '提示' }));
expect(screen.getByRole('dialog', { name: '使用提示' })).toBeTruthy();
- expect(screen.getByText('消耗 1 陶泥币')).toBeTruthy();
+ expect(screen.getByText('消耗 1 光点')).toBeTruthy();
expect(onPauseChange).toHaveBeenLastCalledWith(true);
await act(async () => {
@@ -651,7 +680,7 @@ test('道具确认弹窗暂停时间,提示只演示不直接移动拼块', as
test('道具使用失败时保留确认弹窗和暂停态', async () => {
const onPauseChange = vi.fn();
- const onUseProp = vi.fn().mockRejectedValue(new Error('陶泥币余额不足'));
+ const onUseProp = vi.fn().mockRejectedValue(new Error('光点余额不足'));
const playingRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
@@ -684,7 +713,7 @@ test('道具使用失败时保留确认弹窗和暂停态', async () => {
});
expect(screen.getByRole('dialog', { name: '冻结时间' })).toBeTruthy();
- expect(screen.getByText('陶泥币余额不足')).toBeTruthy();
+ expect(screen.getByText('光点余额不足')).toBeTruthy();
expect(onPauseChange).toHaveBeenLastCalledWith(true);
});
@@ -836,7 +865,7 @@ test('失败弹窗支持重开当前关和续时确认', async () => {
within(failedDialog).getByRole('button', { name: '继续1分钟' }),
);
expect(screen.getByRole('dialog', { name: '继续1分钟' })).toBeTruthy();
- expect(screen.getByText('消耗 1 陶泥币')).toBeTruthy();
+ expect(screen.getByText('消耗 1 光点')).toBeTruthy();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '确定' }));
@@ -846,7 +875,7 @@ test('失败弹窗支持重开当前关和续时确认', async () => {
});
test('失败续时扣费失败时保留确认弹窗', async () => {
- const onUseProp = vi.fn().mockRejectedValue(new Error('陶泥币余额不足'));
+ const onUseProp = vi.fn().mockRejectedValue(new Error('光点余额不足'));
const failedRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
@@ -878,7 +907,7 @@ test('失败续时扣费失败时保留确认弹窗', async () => {
});
expect(screen.getByRole('dialog', { name: '继续1分钟' })).toBeTruthy();
- expect(screen.getByText('陶泥币余额不足')).toBeTruthy();
+ expect(screen.getByText('光点余额不足')).toBeTruthy();
});
test('查看原图开关打开覆盖层并在关闭后恢复计时', async () => {
diff --git a/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx b/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx
index fbce4d5c..dc518179 100644
--- a/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx
+++ b/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx
@@ -9,7 +9,7 @@ import {
Sparkles,
Trophy,
} from 'lucide-react';
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { useEffect, useId, useMemo, useRef, useState } from 'react';
import type {
DragPuzzlePieceRequest,
@@ -27,6 +27,14 @@ import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import { useAuthUi } from '../auth/AuthUiContext';
import { PixelIcon } from '../PixelIcon';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
+import {
+ buildMergedGroupClipPath,
+ buildMergedGroupOutlinePath,
+ resolveDraggedMergedGroupLayer,
+ resolveDraggedPieceCellLayer,
+ resolveDraggedPieceLayer,
+ sanitizeSvgId,
+} from './puzzleRuntimeShape';
type PuzzleRuntimeShellProps = {
run: PuzzleRunSnapshot | null;
@@ -85,127 +93,6 @@ function buildBoardCells(board: PuzzleBoardSnapshot) {
}));
}
-function buildLocalCellKey(row: number, col: number) {
- return `${row}:${col}`;
-}
-
-export function resolveDraggedPieceCellLayer(
- pieceId: string | null | undefined,
- draggingPieceId: string | null,
- isMerged: boolean,
-) {
- if (!pieceId || isMerged || pieceId !== draggingPieceId) {
- return undefined;
- }
- return 80;
-}
-
-export function resolveDraggedPieceLayer(
- pieceId: string | null | undefined,
- draggingPieceId: string | null,
- isMerged: boolean,
-) {
- if (!pieceId || isMerged || pieceId !== draggingPieceId) {
- return undefined;
- }
- return 81;
-}
-
-export function resolveDraggedMergedGroupLayer(
- groupId: string,
- draggingGroupId: string | null,
-) {
- return groupId === draggingGroupId ? 90 : undefined;
-}
-
-function resolveMergedPieceOutlineClass(
- group: PuzzleMergedGroupViewModel,
- piece: PuzzleMergedGroupViewModel['pieces'][number],
-) {
- const groupCellKeys = new Set(
- group.pieces.map((groupPiece) =>
- buildLocalCellKey(groupPiece.localRow, groupPiece.localCol),
- ),
- );
- const hasCell = (row: number, col: number) =>
- groupCellKeys.has(buildLocalCellKey(row, col));
- const hasTopBoundary = (row: number, col: number) => !hasCell(row - 1, col);
- const hasRightBoundary = (row: number, col: number) => !hasCell(row, col + 1);
- const hasBottomBoundary = (row: number, col: number) =>
- !hasCell(row + 1, col);
- const hasLeftBoundary = (row: number, col: number) => !hasCell(row, col - 1);
- const hasTopEdge = !groupCellKeys.has(
- buildLocalCellKey(piece.localRow - 1, piece.localCol),
- );
- const hasRightEdge = !groupCellKeys.has(
- buildLocalCellKey(piece.localRow, piece.localCol + 1),
- );
- const hasBottomEdge = !groupCellKeys.has(
- buildLocalCellKey(piece.localRow + 1, piece.localCol),
- );
- const hasLeftEdge = !groupCellKeys.has(
- buildLocalCellKey(piece.localRow, piece.localCol - 1),
- );
- const topLeftRadius =
- hasTopEdge && hasLeftEdge
- ? 'rounded-tl-[0.85rem]'
- : (!hasTopEdge && !hasLeftEdge) ||
- (hasTopEdge &&
- !hasLeftEdge &&
- !hasTopBoundary(piece.localRow, piece.localCol - 1)) ||
- (hasLeftEdge &&
- !hasTopEdge &&
- !hasLeftBoundary(piece.localRow - 1, piece.localCol))
- ? 'rounded-tl-[0.35rem]'
- : 'rounded-tl-none';
- const topRightRadius =
- hasTopEdge && hasRightEdge
- ? 'rounded-tr-[0.85rem]'
- : (!hasTopEdge && !hasRightEdge) ||
- (hasTopEdge &&
- !hasRightEdge &&
- !hasTopBoundary(piece.localRow, piece.localCol + 1)) ||
- (hasRightEdge &&
- !hasTopEdge &&
- !hasRightBoundary(piece.localRow - 1, piece.localCol))
- ? 'rounded-tr-[0.35rem]'
- : 'rounded-tr-none';
- const bottomRightRadius =
- hasBottomEdge && hasRightEdge
- ? 'rounded-br-[0.85rem]'
- : (!hasBottomEdge && !hasRightEdge) ||
- (hasBottomEdge &&
- !hasRightEdge &&
- !hasBottomBoundary(piece.localRow, piece.localCol + 1)) ||
- (hasRightEdge &&
- !hasBottomEdge &&
- !hasRightBoundary(piece.localRow + 1, piece.localCol))
- ? 'rounded-br-[0.35rem]'
- : 'rounded-br-none';
- const bottomLeftRadius =
- hasBottomEdge && hasLeftEdge
- ? 'rounded-bl-[0.85rem]'
- : (!hasBottomEdge && !hasLeftEdge) ||
- (hasBottomEdge &&
- !hasLeftEdge &&
- !hasBottomBoundary(piece.localRow, piece.localCol - 1)) ||
- (hasLeftEdge &&
- !hasBottomEdge &&
- !hasLeftBoundary(piece.localRow + 1, piece.localCol))
- ? 'rounded-bl-[0.35rem]'
- : 'rounded-bl-none';
- return [
- hasTopEdge ? 'border-t-2' : 'border-t-0',
- hasRightEdge ? 'border-r-2' : 'border-r-0',
- hasBottomEdge ? 'border-b-2' : 'border-b-0',
- hasLeftEdge ? 'border-l-2' : 'border-l-0',
- topLeftRadius,
- topRightRadius,
- bottomRightRadius,
- bottomLeftRadius,
- ].join(' ');
-}
-
function buildMergedGroupViewModels(
groups: PuzzleMergedGroupState[],
pieces: PuzzleBoardPieceViewModel[],
@@ -372,6 +259,7 @@ export function PuzzleRuntimeShell({
onUseProp,
onTimeExpired,
}: PuzzleRuntimeShellProps) {
+ const mergedGroupSvgIdPrefix = sanitizeSvgId(useId());
const authUi = useAuthUi();
const [selectedPieceId, setSelectedPieceId] = useState(null);
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
@@ -1315,100 +1203,150 @@ export function PuzzleRuntimeShell({
);
})}
- {mergedGroups.map((group) => (
-
{
- if (node) {
- groupElementRefMap.current.set(group.groupId, node);
- return;
- }
- groupElementRefMap.current.delete(group.groupId);
- }}
- data-merged-group-id={group.groupId}
- className="pointer-events-none absolute z-10"
- style={{
- zIndex: resolveDraggedMergedGroupLayer(
- group.groupId,
- draggingGroupId,
- ),
- transform: hintDemo?.pieceIds.some((pieceId) =>
- group.pieceIds.includes(pieceId),
- )
- ? `translate(${hintDemo.offsetXPercent}%, ${hintDemo.offsetYPercent}%) scale(1.02)`
- : undefined,
- transition: hintDemo?.pieceIds.some((pieceId) =>
- group.pieceIds.includes(pieceId),
- )
- ? `transform ${PUZZLE_HINT_DEMO_DURATION_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1)`
- : undefined,
- left: `${(group.minCol / board.cols) * 100}%`,
- top: `${(group.minRow / board.rows) * 100}%`,
- width: `${(group.colSpan / board.cols) * 100}%`,
- height: `${(group.rowSpan / board.rows) * 100}%`,
- }}
- >
+ {mergedGroups.map((group) => {
+ const outlinePath = buildMergedGroupOutlinePath(group);
+ const clipPath = buildMergedGroupClipPath(group);
+
+ return (
{
+ if (node) {
+ groupElementRefMap.current.set(group.groupId, node);
+ return;
+ }
+ groupElementRefMap.current.delete(group.groupId);
+ }}
+ data-merged-group-id={group.groupId}
+ className="pointer-events-none absolute z-10"
style={{
- gridTemplateColumns: `repeat(${group.colSpan}, minmax(0, 1fr))`,
- gridTemplateRows: `repeat(${group.rowSpan}, minmax(0, 1fr))`,
+ zIndex: resolveDraggedMergedGroupLayer(
+ group.groupId,
+ draggingGroupId,
+ ),
+ transform: hintDemo?.pieceIds.some((pieceId) =>
+ group.pieceIds.includes(pieceId),
+ )
+ ? `translate(${hintDemo.offsetXPercent}%, ${hintDemo.offsetYPercent}%) scale(1.02)`
+ : undefined,
+ transition: hintDemo?.pieceIds.some((pieceId) =>
+ group.pieceIds.includes(pieceId),
+ )
+ ? `transform ${PUZZLE_HINT_DEMO_DURATION_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1)`
+ : undefined,
+ left: `${(group.minCol / board.cols) * 100}%`,
+ top: `${(group.minRow / board.rows) * 100}%`,
+ width: `${(group.colSpan / board.cols) * 100}%`,
+ height: `${(group.rowSpan / board.rows) * 100}%`,
}}
>
- {group.pieces.map((piece) => (
-
{
- handlePiecePointerDown(piece.pieceId, event);
- }}
- onPointerMove={(event) => {
- handlePiecePointerMove(piece.pieceId, event);
- }}
- onPointerUp={(event) => {
- handlePiecePointerUp(piece.pieceId, event);
- }}
- onPointerCancel={() => {
- resetDragInteraction();
- }}
- onLostPointerCapture={() => {
- resetDragInteraction();
- }}
+ {outlinePath ? (
+
+ ) : null}
+
+ {group.pieces.map((piece) => (
+
{
+ handlePiecePointerDown(piece.pieceId, event);
+ }}
+ onPointerMove={(event) => {
+ handlePiecePointerMove(piece.pieceId, event);
+ }}
+ onPointerUp={(event) => {
+ handlePiecePointerUp(piece.pieceId, event);
+ }}
+ onPointerCancel={() => {
+ resetDragInteraction();
+ }}
+ onLostPointerCapture={() => {
+ resetDragInteraction();
+ }}
+ >
+ {resolvedCoverImage ? (
+
1
+ ? (piece.correctCol / (board.cols - 1)) * 100
+ : 0
+ }% ${
+ board.rows > 1
+ ? (piece.correctRow / (board.rows - 1)) * 100
+ : 0
+ }%`,
+ }}
+ />
+ ) : (
+
+ )}
+
+
+ ))}
+
-
- ))}
+ );
+ })}
{isOriginalOverlayVisible && resolvedCoverImage ? (
- 消耗 1 陶泥币
+ 消耗 1 光点
{propConfirmError ? (
{propConfirmError}
diff --git a/src/components/puzzle-runtime/puzzleRuntimeShape.ts b/src/components/puzzle-runtime/puzzleRuntimeShape.ts
new file mode 100644
index 00000000..248f395f
--- /dev/null
+++ b/src/components/puzzle-runtime/puzzleRuntimeShape.ts
@@ -0,0 +1,276 @@
+type PuzzleMergedGroupShape = {
+ colSpan: number;
+ rowSpan: number;
+ pieces: Array<{
+ localRow: number;
+ localCol: number;
+ }>;
+};
+
+type GridPoint = {
+ x: number;
+ y: number;
+};
+
+type GridEdge = {
+ start: GridPoint;
+ end: GridPoint;
+};
+
+const MERGED_GROUP_OUTLINE_CORNER_RADIUS = 0.16;
+
+function buildLocalCellKey(row: number, col: number) {
+ return `${row}:${col}`;
+}
+
+function formatSvgNumber(value: number) {
+ const normalizedValue = Object.is(value, -0) ? 0 : value;
+ return Number(normalizedValue.toFixed(4)).toString();
+}
+
+function formatSvgPoint(point: GridPoint) {
+ return `${formatSvgNumber(point.x)} ${formatSvgNumber(point.y)}`;
+}
+
+function gridPointKey(point: GridPoint) {
+ return `${formatSvgNumber(point.x)}:${formatSvgNumber(point.y)}`;
+}
+
+function distanceBetweenGridPoints(first: GridPoint, second: GridPoint) {
+ return Math.hypot(second.x - first.x, second.y - first.y);
+}
+
+function moveGridPointToward(
+ from: GridPoint,
+ target: GridPoint,
+ distance: number,
+) {
+ const fullDistance = distanceBetweenGridPoints(from, target);
+ if (fullDistance <= 0) {
+ return from;
+ }
+ const ratio = Math.min(1, distance / fullDistance);
+ return {
+ x: from.x + (target.x - from.x) * ratio,
+ y: from.y + (target.y - from.y) * ratio,
+ };
+}
+
+function isCollinearGridCorner(
+ previous: GridPoint,
+ current: GridPoint,
+ next: GridPoint,
+) {
+ return (
+ (previous.x === current.x && current.x === next.x) ||
+ (previous.y === current.y && current.y === next.y)
+ );
+}
+
+function removeCollinearGridPoints(points: GridPoint[]) {
+ if (points.length <= 3) {
+ return points;
+ }
+ return points.filter((point, index) => {
+ const previous = points[(index - 1 + points.length) % points.length];
+ const next = points[(index + 1) % points.length];
+ return previous && next && !isCollinearGridCorner(previous, point, next);
+ });
+}
+
+function buildRoundedGridCyclePath(
+ points: GridPoint[],
+ radius: number,
+ transformPoint: (point: GridPoint) => GridPoint = (point) => point,
+) {
+ const cyclePoints = removeCollinearGridPoints(points);
+ if (cyclePoints.length < 3) {
+ return '';
+ }
+ const resolveCorner = (index: number) => {
+ const point = cyclePoints[index];
+ const previous = cyclePoints[
+ (index - 1 + cyclePoints.length) % cyclePoints.length
+ ];
+ const next = cyclePoints[(index + 1) % cyclePoints.length];
+ if (!point || !previous || !next) {
+ return null;
+ }
+ const safeRadius = Math.min(
+ radius,
+ distanceBetweenGridPoints(point, previous) / 2,
+ distanceBetweenGridPoints(point, next) / 2,
+ );
+ return {
+ point,
+ entry: moveGridPointToward(point, previous, safeRadius),
+ exit: moveGridPointToward(point, next, safeRadius),
+ };
+ };
+ const firstCorner = resolveCorner(0);
+ if (!firstCorner) {
+ return '';
+ }
+ const commands = [`M ${formatSvgPoint(transformPoint(firstCorner.exit))}`];
+ for (let index = 1; index <= cyclePoints.length; index += 1) {
+ const corner = resolveCorner(index % cyclePoints.length);
+ if (!corner) {
+ continue;
+ }
+ commands.push(`L ${formatSvgPoint(transformPoint(corner.entry))}`);
+ commands.push(
+ `Q ${formatSvgPoint(transformPoint(corner.point))} ${formatSvgPoint(
+ transformPoint(corner.exit),
+ )}`,
+ );
+ }
+ commands.push('Z');
+ return commands.join(' ');
+}
+
+function buildMergedGroupBoundaryCycles(group: PuzzleMergedGroupShape) {
+ const groupCellKeys = new Set(
+ group.pieces.map((piece) =>
+ buildLocalCellKey(piece.localRow, piece.localCol),
+ ),
+ );
+ const hasCell = (row: number, col: number) =>
+ groupCellKeys.has(buildLocalCellKey(row, col));
+ const edges: GridEdge[] = [];
+
+ for (const piece of group.pieces) {
+ const { localRow: row, localCol: col } = piece;
+ if (!hasCell(row - 1, col)) {
+ edges.push({ start: { x: col, y: row }, end: { x: col + 1, y: row } });
+ }
+ if (!hasCell(row, col + 1)) {
+ edges.push({
+ start: { x: col + 1, y: row },
+ end: { x: col + 1, y: row + 1 },
+ });
+ }
+ if (!hasCell(row + 1, col)) {
+ edges.push({
+ start: { x: col + 1, y: row + 1 },
+ end: { x: col, y: row + 1 },
+ });
+ }
+ if (!hasCell(row, col - 1)) {
+ edges.push({ start: { x: col, y: row + 1 }, end: { x: col, y: row } });
+ }
+ }
+
+ const edgeIndexesByStart = new Map
();
+ edges.forEach((edge, index) => {
+ const key = gridPointKey(edge.start);
+ const indexes = edgeIndexesByStart.get(key) ?? [];
+ indexes.push(index);
+ edgeIndexesByStart.set(key, indexes);
+ });
+
+ const unusedEdgeIndexes = new Set(edges.map((_, index) => index));
+ const cycles: GridPoint[][] = [];
+ while (unusedEdgeIndexes.size > 0) {
+ const firstEdgeIndex = unusedEdgeIndexes.values().next().value as
+ | number
+ | undefined;
+ if (firstEdgeIndex === undefined) {
+ break;
+ }
+ const firstEdge = edges[firstEdgeIndex];
+ if (!firstEdge) {
+ unusedEdgeIndexes.delete(firstEdgeIndex);
+ continue;
+ }
+ const cycle: GridPoint[] = [firstEdge.start];
+ let currentEdge = firstEdge;
+ unusedEdgeIndexes.delete(firstEdgeIndex);
+
+ for (let guard = 0; guard < edges.length + 1; guard += 1) {
+ const currentEnd = currentEdge.end;
+ const cycleStart = cycle[0];
+ if (!cycleStart || gridPointKey(currentEnd) === gridPointKey(cycleStart)) {
+ break;
+ }
+ cycle.push(currentEnd);
+ const nextEdgeIndex = (
+ edgeIndexesByStart.get(gridPointKey(currentEnd)) ?? []
+ ).find((index) => unusedEdgeIndexes.has(index));
+ if (nextEdgeIndex === undefined) {
+ break;
+ }
+ const nextEdge = edges[nextEdgeIndex];
+ if (!nextEdge) {
+ unusedEdgeIndexes.delete(nextEdgeIndex);
+ break;
+ }
+ currentEdge = nextEdge;
+ unusedEdgeIndexes.delete(nextEdgeIndex);
+ }
+
+ if (cycle.length >= 3) {
+ cycles.push(cycle);
+ }
+ }
+
+ return cycles;
+}
+
+export function buildMergedGroupOutlinePath(
+ group: PuzzleMergedGroupShape,
+ radius = MERGED_GROUP_OUTLINE_CORNER_RADIUS,
+) {
+ // 合并块的凹入角不能靠单格 border-radius 稳定拼出来,必须先生成整体外轮廓。
+ return buildMergedGroupBoundaryCycles(group)
+ .map((cycle) => buildRoundedGridCyclePath(cycle, radius))
+ .filter(Boolean)
+ .join(' ');
+}
+
+export function buildMergedGroupClipPath(
+ group: PuzzleMergedGroupShape,
+ radius = MERGED_GROUP_OUTLINE_CORNER_RADIUS,
+) {
+ return buildMergedGroupBoundaryCycles(group)
+ .map((cycle) =>
+ buildRoundedGridCyclePath(cycle, radius, (point) => ({
+ x: point.x / group.colSpan,
+ y: point.y / group.rowSpan,
+ })),
+ )
+ .filter(Boolean)
+ .join(' ');
+}
+
+export function sanitizeSvgId(value: string) {
+ return value.replace(/[^a-zA-Z0-9_-]/g, '-');
+}
+
+export function resolveDraggedPieceCellLayer(
+ pieceId: string | null | undefined,
+ draggingPieceId: string | null,
+ isMerged: boolean,
+) {
+ if (!pieceId || isMerged || pieceId !== draggingPieceId) {
+ return undefined;
+ }
+ return 80;
+}
+
+export function resolveDraggedPieceLayer(
+ pieceId: string | null | undefined,
+ draggingPieceId: string | null,
+ isMerged: boolean,
+) {
+ if (!pieceId || isMerged || pieceId !== draggingPieceId) {
+ return undefined;
+ }
+ return 81;
+}
+
+export function resolveDraggedMergedGroupLayer(
+ groupId: string,
+ draggingGroupId: string | null,
+) {
+ return groupId === draggingGroupId ? 90 : undefined;
+}
diff --git a/src/components/rpg-creation-asset-studio/RpgCreationRoleAnimationSection.tsx b/src/components/rpg-creation-asset-studio/RpgCreationRoleAnimationSection.tsx
index 70973a1c..816a9c70 100644
--- a/src/components/rpg-creation-asset-studio/RpgCreationRoleAnimationSection.tsx
+++ b/src/components/rpg-creation-asset-studio/RpgCreationRoleAnimationSection.tsx
@@ -219,7 +219,7 @@ export function RpgCreationRoleAnimationSection(props: {
}
label={isSelectedAnimationGenerating ? '生成中...' : '生成动作'}
- subLabel={`消耗${animationPointCost}陶泥币`}
+ subLabel={`消耗${animationPointCost}光点`}
onClick={onGenerateAnimation}
disabled={
isSelectedAnimationGenerating ||
diff --git a/src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx b/src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx
index 1cc1657a..0772da7b 100644
--- a/src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx
+++ b/src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx
@@ -790,7 +790,7 @@ export function RpgCreationRoleAssetStudioModal({
}
return window.confirm(
- `${params.kindLabel}预计消耗 ${params.points} 陶泥币。\n${params.description}`,
+ `${params.kindLabel}预计消耗 ${params.points} 光点。\n${params.description}`,
);
};
diff --git a/src/components/rpg-creation-asset-studio/RpgCreationRoleVisualSection.tsx b/src/components/rpg-creation-asset-studio/RpgCreationRoleVisualSection.tsx
index f4672d56..ed186b41 100644
--- a/src/components/rpg-creation-asset-studio/RpgCreationRoleVisualSection.tsx
+++ b/src/components/rpg-creation-asset-studio/RpgCreationRoleVisualSection.tsx
@@ -166,7 +166,7 @@ export function RpgCreationRoleVisualSection(props: {
? '重新生成角色形象'
: '生成角色形象'
}
- subLabel={`消耗${visualPointCost}陶泥币`}
+ subLabel={`消耗${visualPointCost}光点`}
onClick={onGenerateVisuals}
disabled={isGeneratingVisuals || isApplyingVisual || syncBusy}
tone="sky"
diff --git a/src/components/rpg-entry/RpgEntryBrandLogo.tsx b/src/components/rpg-entry/RpgEntryBrandLogo.tsx
index abf511f6..9375a400 100644
--- a/src/components/rpg-entry/RpgEntryBrandLogo.tsx
+++ b/src/components/rpg-entry/RpgEntryBrandLogo.tsx
@@ -16,9 +16,9 @@ export function RpgEntryBrandLogo({
className={`platform-brand-logo ${className}`.trim()}
role={decorative ? undefined : 'img'}
aria-hidden={decorative || undefined}
- aria-label={decorative ? undefined : '陶泥 GENARRATIVE'}
+ aria-label={decorative ? undefined : '百梦 GENARRATIVE'}
>
- 陶泥
+ 百梦
GENARRATIVE
);
diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
index 70dda925..a7521240 100644
--- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
+++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
@@ -97,11 +97,11 @@ import {
recordRpgEntryWorldGalleryPlay,
remixRpgEntryWorldGallery,
} from '../../services/rpg-entry/rpgEntryLibraryClient';
+import { type CustomWorldProfile, WorldType } from '../../types';
import {
AuthUiContext,
type PlatformSettingsSection,
} from '../auth/AuthUiContext';
-import { type CustomWorldProfile, WorldType } from '../../types';
import {
RpgEntryFlowShell,
type RpgEntryFlowShellProps,
@@ -129,17 +129,6 @@ async function openCreationHub(user: ReturnType) {
expect(await screen.findByText('角色扮演')).toBeTruthy();
}
-async function expectRpgCreationLocked(
- user: ReturnType,
-) {
- await openCreationHub(user);
- const rpgButton = screen.getByRole('button', { name: /角色扮演/u });
- expect((rpgButton as HTMLButtonElement).disabled).toBe(true);
- expect(within(rpgButton).getAllByText('敬请期待').length).toBeGreaterThan(0);
- await user.click(rpgButton);
- expect(createRpgCreationSession).not.toHaveBeenCalled();
-}
-
async function openExistingRpgDraft(
user: ReturnType,
actionName: string | RegExp = /继续(?:完善|创作)/u,
@@ -552,6 +541,7 @@ const mockAuthUser: AuthUser = {
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
+ createdAt: new Date().toISOString(),
};
function buildMockPuzzleRun(
@@ -2170,7 +2160,7 @@ test('logged out public detail gates big fish start before local runtime', async
);
const searchInput = await screen.findByPlaceholderText(
- '输入 SY / CW / BF / M3 / PZ 编号',
+ '搜索作品号、名称、作者、描述',
);
await user.type(searchInput, 'BF-NPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
@@ -2844,7 +2834,7 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
render();
const searchInput = await screen.findByPlaceholderText(
- '输入 SY / CW / BF / M3 / PZ 编号',
+ '搜索作品号、名称、作者、描述',
);
await user.type(searchInput, 'PZ-EPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
@@ -2917,7 +2907,7 @@ test('public code search opens a published puzzle by PZ code', async () => {
render();
const searchInput = await screen.findByPlaceholderText(
- '输入 SY / CW / BF / M3 / PZ 编号',
+ '搜索作品号、名称、作者、描述',
);
await user.type(searchInput, 'PZ-EPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
@@ -2960,7 +2950,7 @@ test('public code search opens a published big fish work by BF code', async () =
render();
const searchInput = await screen.findByPlaceholderText(
- '输入 SY / CW / BF / M3 / PZ 编号',
+ '搜索作品号、名称、作者、描述',
);
await user.type(searchInput, 'BF-NPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
@@ -3014,7 +3004,7 @@ test('public code search opens a published Match3D work by M3 code and starts ru
render();
const searchInput = await screen.findByPlaceholderText(
- '输入 SY / CW / BF / M3 / PZ 编号',
+ '搜索作品号、名称、作者、描述',
);
await user.type(searchInput, 'M3-EPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
@@ -4133,6 +4123,31 @@ test('authenticated users with save archives default into the saves tab', async
expect((await screen.findAllByText('潮雾列岛')).length).toBeGreaterThan(0);
expect(screen.getAllByText('最近更新时间排序').length).toBeGreaterThan(0);
expect(screen.queryByText('SAVE ARCHIVE')).toBeNull();
+ expect(screen.queryByText('ARCHIVE')).toBeNull();
+ expect(screen.queryByText('最近存档')).toBeNull();
+});
+
+test('puzzle save archive highlights work title and level subtitle', async () => {
+ vi.mocked(listProfileSaveArchives).mockResolvedValue([
+ {
+ worldKey: 'puzzle:puzzle-profile-1',
+ ownerUserId: 'user-2',
+ profileId: 'puzzle-profile-1',
+ worldType: 'PUZZLE',
+ worldName: '雨夜猫塔',
+ subtitle: '第 2 关 · 星桥机关',
+ summaryText: '拼图进行中',
+ coverImageSrc: '/generated-puzzle-assets/puzzle-1/level-2.png',
+ lastPlayedAt: '2026-04-19T12:00:00.000Z',
+ },
+ ]);
+
+ render();
+
+ expect((await screen.findAllByText('雨夜猫塔')).length).toBeGreaterThan(0);
+ expect(screen.getAllByText('第 2 关 · 星桥机关').length).toBeGreaterThan(0);
+ expect(screen.queryByText('ARCHIVE')).toBeNull();
+ expect(screen.queryByText('最近存档')).toBeNull();
});
test('manual tab switch is preserved after platform bootstrap requests finish', async () => {
diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx
index f9758325..91f5d14b 100644
--- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx
+++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx
@@ -1,11 +1,15 @@
/* @vitest-environment jsdom */
-import { act, render, screen, within } from '@testing-library/react';
+import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { afterEach, expect, test, vi } from 'vitest';
-import type { AuthUser } from '../../services/authService';
+import type {
+ AuthUser,
+ PublicUserSummary,
+} from '../../../packages/shared/src/contracts/auth';
+import type { ProfileReferralInviteCenterResponse } from '../../../packages/shared/src/contracts/runtime';
import { AuthUiContext } from '../auth/AuthUiContext';
import {
RpgEntryHomeView,
@@ -13,29 +17,122 @@ import {
} from './RpgEntryHomeView';
import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation';
-const { mockGetRpgProfileWalletLedger } = vi.hoisted(() => ({
- mockGetRpgProfileWalletLedger: vi.fn(async () => ({
- entries: [
+const {
+ mockBuildReferralCenter,
+ mockGetRpgProfileReferralInviteCenter,
+ mockGetRpgProfileWalletLedger,
+ mockRedeemRpgProfileReferralInviteCode,
+} = vi.hoisted(() => {
+ const buildReferralCenter = (
+ overrides: Partial = {},
+ ): ProfileReferralInviteCenterResponse => ({
+ inviteCode: 'SY12345678',
+ inviteLinkPath: '/?inviteCode=SY12345678',
+ invitedCount: 1,
+ rewardedInviteCount: 1,
+ todayInviterRewardCount: 1,
+ todayInviterRewardRemaining: 9,
+ rewardPoints: 30,
+ invitedUsers: [
{
- id: 'ledger-1',
- amountDelta: -1,
- balanceAfter: 29,
- sourceType: 'asset_operation_consume',
- createdAt: '2026-04-28T10:00:00Z',
- },
- {
- id: 'ledger-2',
- amountDelta: 30,
- balanceAfter: 30,
- sourceType: 'invite_invitee_reward',
- createdAt: '2026-04-28T09:00:00Z',
+ userId: 'user-2',
+ displayName: '被邀请玩家',
+ avatarUrl: null,
+ boundAt: '2026-05-01T08:00:00Z',
},
],
- })),
+ hasRedeemedCode: false,
+ boundInviterUserId: null,
+ boundAt: null,
+ updatedAt: '2026-05-01T08:00:00Z',
+ ...overrides,
+ });
+
+ return {
+ mockBuildReferralCenter: buildReferralCenter,
+ mockGetRpgProfileReferralInviteCenter: vi.fn(async () =>
+ buildReferralCenter(),
+ ),
+ mockRedeemRpgProfileReferralInviteCode: vi.fn(async () => ({
+ center: buildReferralCenter({
+ invitedUsers: [],
+ hasRedeemedCode: true,
+ boundInviterUserId: 'user-2',
+ boundAt: '2026-05-01T08:00:00Z',
+ }),
+ inviteeRewardGranted: true,
+ inviterRewardGranted: true,
+ inviteeBalanceAfter: 30,
+ inviterBalanceAfter: 30,
+ })),
+ mockGetRpgProfileWalletLedger: vi.fn(async () => ({
+ entries: [
+ {
+ id: 'ledger-1',
+ amountDelta: -1,
+ balanceAfter: 29,
+ sourceType: 'asset_operation_consume',
+ createdAt: '2026-04-28T10:00:00Z',
+ },
+ {
+ id: 'ledger-2',
+ amountDelta: 30,
+ balanceAfter: 30,
+ sourceType: 'invite_invitee_reward',
+ createdAt: '2026-04-28T09:00:00Z',
+ },
+ ],
+ })),
+ };
+});
+
+const {
+ mockGetPublicAuthUserByCode,
+ mockGetPublicAuthUserById,
+ mockUpdateAuthProfile,
+} = vi.hoisted(() => ({
+ mockGetPublicAuthUserByCode: vi.fn(
+ async (code: string): Promise => ({
+ id: `id-${code}`,
+ publicUserCode: code,
+ displayName: '公开作者',
+ avatarUrl: null,
+ }),
+ ),
+ mockGetPublicAuthUserById: vi.fn(
+ async (userId: string): Promise => ({
+ id: userId,
+ publicUserCode: `code-${userId}`,
+ displayName: '公开作者',
+ avatarUrl: null,
+ }),
+ ),
+ mockUpdateAuthProfile: vi.fn(),
}));
+vi.mock('../../services/authService', () => ({
+ getPublicAuthUserByCode: mockGetPublicAuthUserByCode,
+ getPublicAuthUserById: mockGetPublicAuthUserById,
+ updateAuthProfile: mockUpdateAuthProfile,
+}));
+
+mockUpdateAuthProfile.mockResolvedValue({
+ id: 'user-1',
+ publicUserCode: '100001',
+ username: 'tester',
+ displayName: '测试玩家',
+ avatarUrl: null,
+ phoneNumberMasked: null,
+ loginMethod: 'password',
+ bindingStatus: 'active',
+ wechatBound: false,
+ createdAt: new Date().toISOString(),
+});
+
vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
+ getRpgProfileReferralInviteCenter: mockGetRpgProfileReferralInviteCenter,
getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger,
+ redeemRpgProfileReferralInviteCode: mockRedeemRpgProfileReferralInviteCode,
getRpgProfileRechargeCenter: vi.fn(async () => ({
walletBalance: 0,
membership: {
@@ -48,14 +145,14 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
pointProducts: [
{
productId: 'points_60',
- title: '60陶泥币',
+ title: '60光点',
priceCents: 600,
kind: 'points',
pointsAmount: 60,
bonusPoints: 60,
durationDays: 0,
badgeLabel: '首充双倍',
- description: '首充送60陶泥币',
+ description: '首充送60光点',
tier: 'normal',
},
],
@@ -75,7 +172,7 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
],
benefits: [
{
- benefitName: '免陶泥币回合数',
+ benefitName: '免光点回合数',
normalValue: '30',
monthValue: '100',
seasonValue: '100',
@@ -89,7 +186,7 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
order: {
orderId: 'order-1',
productId: 'points_60',
- productTitle: '60陶泥币',
+ productTitle: '60光点',
kind: 'points',
amountCents: 600,
status: 'paid',
@@ -278,6 +375,7 @@ function renderProfileView(
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
+ createdAt: new Date().toISOString(),
...userOverrides,
},
canAccessProtectedData: true,
@@ -457,6 +555,21 @@ function renderStatefulLoggedOutHomeView(
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
+ mockGetRpgProfileReferralInviteCenter.mockResolvedValue(
+ mockBuildReferralCenter(),
+ );
+ mockUpdateAuthProfile.mockResolvedValue({
+ id: 'user-1',
+ publicUserCode: '100001',
+ username: 'tester',
+ displayName: '测试玩家',
+ avatarUrl: null,
+ phoneNumberMasked: null,
+ loginMethod: 'password',
+ bindingStatus: 'active',
+ wechatBound: false,
+ createdAt: new Date().toISOString(),
+ });
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
@@ -482,9 +595,9 @@ test('opens wallet ledger modal from narrative coin card', async () => {
const user = userEvent.setup();
renderProfileView();
- await user.click(screen.getByRole('button', { name: /陶泥币\s*0/u }));
+ await user.click(screen.getByRole('button', { name: /光点\s*0/u }));
- expect(await screen.findByText('陶泥币账单')).toBeTruthy();
+ expect(await screen.findByText('光点账单')).toBeTruthy();
expect(mockGetRpgProfileWalletLedger).toHaveBeenCalledTimes(1);
expect(screen.getByText('资产操作消耗')).toBeTruthy();
expect(screen.getByText('-1')).toBeTruthy();
@@ -534,17 +647,116 @@ test('wallet ledger modal shows empty and error states', async () => {
mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] });
renderProfileView();
- await user.click(screen.getByRole('button', { name: /陶泥币\s*0/u }));
+ await user.click(screen.getByRole('button', { name: /光点\s*0/u }));
expect(await screen.findByText('暂无账单记录')).toBeTruthy();
- await user.click(screen.getByLabelText('关闭陶泥币账单'));
+ await user.click(screen.getByLabelText('关闭光点账单'));
mockGetRpgProfileWalletLedger.mockRejectedValueOnce(new Error('加载失败'));
- await user.click(screen.getByRole('button', { name: /陶泥币\s*0/u }));
+ await user.click(screen.getByRole('button', { name: /光点\s*0/u }));
expect(await screen.findByText('加载失败')).toBeTruthy();
expect(screen.getByText('重新加载')).toBeTruthy();
});
+test('profile invite shortcut shows reward subtitle and invited users', async () => {
+ const user = userEvent.setup();
+
+ renderProfileView();
+
+ const inviteButton = screen.getByRole('button', { name: /邀请好友/u });
+ expect(within(inviteButton).getByText('双方得30')).toBeTruthy();
+
+ const communityButton = screen.getByRole('button', { name: /玩家社区/u });
+ expect(within(communityButton).getByText('每日领福利')).toBeTruthy();
+
+ await user.click(inviteButton);
+
+ expect(mockGetRpgProfileReferralInviteCenter).toHaveBeenCalledTimes(1);
+ expect(
+ await screen.findByText('邀请一个用户注册,双方都可以获得30光点。'),
+ ).toBeTruthy();
+ expect(screen.getByText('每日最多获得十次邀请奖励。')).toBeTruthy();
+ expect(screen.getByText('成功邀请')).toBeTruthy();
+ expect(screen.getByText('被邀请玩家')).toBeTruthy();
+ expect(screen.queryByText('已奖')).toBeNull();
+ expect(screen.queryByText('今日')).toBeNull();
+});
+
+test('profile redeem invite shortcut sits between invite and community for fresh accounts', async () => {
+ renderProfileView();
+
+ const inviteButton = screen.getByRole('button', { name: /邀请好友/u });
+ const redeemButton = await screen.findByRole('button', {
+ name: /填邀请码/u,
+ });
+ const communityButton = screen.getByRole('button', { name: /玩家社区/u });
+
+ expect(
+ inviteButton.compareDocumentPosition(redeemButton) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ expect(
+ redeemButton.compareDocumentPosition(communityButton) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ expect(within(redeemButton).getByText('新用户奖励')).toBeTruthy();
+});
+
+test('profile redeem invite shortcut hides after redeemed or one day old', async () => {
+ const user = userEvent.setup();
+
+ mockGetRpgProfileReferralInviteCenter.mockResolvedValueOnce(
+ mockBuildReferralCenter({
+ invitedUsers: [],
+ hasRedeemedCode: true,
+ boundInviterUserId: 'user-2',
+ boundAt: '2026-05-01T08:00:00Z',
+ }),
+ );
+ const { unmount } = renderProfileView();
+ await user.click(screen.getByRole('button', { name: /邀请好友/u }));
+ await screen.findByText('成功邀请');
+ const firstShortcutRegion = screen.getByRole('region', { name: '常用功能' });
+ expect(
+ within(firstShortcutRegion).queryByRole('button', { name: /填邀请码/u }),
+ ).toBeNull();
+ unmount();
+
+ renderProfileView(vi.fn(), {}, { createdAt: '2026-04-01T00:00:00.000Z' });
+ const expiredShortcutRegion = screen.getByRole('region', { name: '常用功能' });
+ expect(
+ within(expiredShortcutRegion).queryByRole('button', {
+ name: /填邀请码/u,
+ }),
+ ).toBeNull();
+});
+
+test('profile redeem invite modal submits code and hides shortcut after success', async () => {
+ const user = userEvent.setup();
+ const onRechargeSuccess = vi.fn();
+
+ renderProfileView(onRechargeSuccess);
+
+ await user.click(await screen.findByRole('button', { name: /填邀请码/u }));
+ const input = await screen.findByLabelText('邀请码');
+ await user.type(input, 'spring-2026');
+ await user.click(screen.getByRole('button', { name: '提交' }));
+
+ await waitFor(() => {
+ expect(mockRedeemRpgProfileReferralInviteCode).toHaveBeenCalledWith(
+ 'SPRING2026',
+ );
+ });
+ expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
+ expect(await screen.findByText('已填写')).toBeTruthy();
+ const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
+ expect(
+ within(shortcutRegion).queryByRole('button', {
+ name: /填邀请码/u,
+ }),
+ ).toBeNull();
+});
+
test('opens reward code modal from profile action on mobile', async () => {
const user = userEvent.setup();
@@ -620,13 +832,86 @@ test('mobile home search submits public work code', async () => {
);
const searchInput = screen.getByPlaceholderText(
- '输入 SY / CW / BF / M3 / PZ 编号',
+ '搜索作品号、名称、作者、描述',
);
await user.type(searchInput, 'PZ-PROFILE1{enter}');
expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1');
});
+test('home search fuzzy matches public work id, name, author and description', async () => {
+ const user = userEvent.setup();
+ const onOpenGalleryDetail = vi.fn();
+ const onSearchPublicCode = vi.fn();
+ const entries = [
+ {
+ ...puzzlePublicEntry,
+ workId: 'puzzle-work-moon-gate',
+ profileId: 'puzzle-profile-moon-gate',
+ publicWorkCode: 'PZ-MOON01',
+ authorDisplayName: '月井守望',
+ worldName: '月井机关',
+ summaryText: '需要沿着银色水路重新点亮机关。',
+ },
+ {
+ ...puzzlePublicEntry,
+ workId: 'puzzle-work-fire-bridge',
+ profileId: 'puzzle-profile-fire-bridge',
+ publicWorkCode: 'PZ-FIRE02',
+ authorDisplayName: '晨风',
+ worldName: '火桥谜图',
+ summaryText: '跨过熔岩断桥寻找遗失碎片。',
+ },
+ ] satisfies PlatformPublicGalleryCard[];
+
+ renderLoggedOutHomeView(vi.fn(), {
+ latestEntries: entries,
+ onOpenGalleryDetail,
+ onSearchPublicCode,
+ });
+
+ const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
+ await user.type(searchInput, 'MOON01{enter}');
+ expect(await screen.findByText('搜索结果')).toBeTruthy();
+ expect(screen.getByText('月井机关')).toBeTruthy();
+ expect(screen.queryByText('火桥谜图')).toBeNull();
+ expect(onSearchPublicCode).not.toHaveBeenCalled();
+
+ await user.clear(searchInput);
+ await user.type(searchInput, '火桥{enter}');
+ expect(await screen.findByText('火桥谜图')).toBeTruthy();
+ expect(screen.queryByText('月井机关')).toBeNull();
+
+ await user.clear(searchInput);
+ await user.type(searchInput, '月井守望{enter}');
+ expect(await screen.findByText('月井机关')).toBeTruthy();
+ expect(screen.queryByText('火桥谜图')).toBeNull();
+
+ await user.clear(searchInput);
+ await user.type(searchInput, '熔岩断桥{enter}');
+ expect(await screen.findByText('火桥谜图')).toBeTruthy();
+ expect(screen.queryByText('月井机关')).toBeNull();
+
+ await user.click(screen.getByRole('button', { name: /火桥谜图/u }));
+ expect(onOpenGalleryDetail).toHaveBeenCalledWith(entries[1]);
+});
+
+test('home search keeps public code fallback when local works do not match', async () => {
+ const user = userEvent.setup();
+ const onSearchPublicCode = vi.fn();
+
+ renderLoggedOutHomeView(vi.fn(), {
+ latestEntries: [puzzlePublicEntry],
+ onSearchPublicCode,
+ });
+
+ const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
+ await user.type(searchInput, 'CW-REMOTE-ONLY{enter}');
+
+ expect(onSearchPublicCode).toHaveBeenCalledWith('CW-REMOTE-ONLY');
+ expect(screen.queryByText('搜索结果')).toBeNull();
+});
+
test('public gallery cards hide work code until detail is opened', async () => {
const user = userEvent.setup();
const onOpenGalleryDetail = vi.fn();
@@ -686,6 +971,35 @@ test('mobile public work cards render cover, author, kind and cover stats', () =
).toBe('推荐');
});
+test('public work cards load real author avatar from public user summary', async () => {
+ mockGetPublicAuthUserById.mockResolvedValueOnce({
+ id: 'user-2',
+ publicUserCode: 'SY-00000002',
+ displayName: '拼图玩家',
+ avatarUrl: 'data:image/png;base64,AUTHOR',
+ });
+
+ renderLoggedOutHomeView(vi.fn(), {
+ featuredEntries: [puzzlePublicEntry],
+ latestEntries: [puzzlePublicEntry],
+ });
+
+ const card = screen.getByRole('button', {
+ name: /奇幻拼图,拼图,20游玩,5改造,12点赞/u,
+ });
+
+ await waitFor(() => {
+ expect(
+ card
+ .querySelector('.platform-public-work-card__author-avatar-image')
+ ?.getAttribute('src'),
+ ).toBe('data:image/png;base64,AUTHOR');
+ });
+ expect(mockGetPublicAuthUserById).toHaveBeenCalledTimes(1);
+ expect(mockGetPublicAuthUserById).toHaveBeenCalledWith('user-2');
+ expect(mockGetPublicAuthUserByCode).not.toHaveBeenCalled();
+});
+
test('mobile home feed only rotates the card closest to screen center', () => {
vi.useFakeTimers();
Object.defineProperty(window, 'requestAnimationFrame', {
diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx
index cb9ea9c9..1aa1c10e 100644
--- a/src/components/rpg-entry/RpgEntryHomeView.tsx
+++ b/src/components/rpg-entry/RpgEntryHomeView.tsx
@@ -38,6 +38,7 @@ import {
import communityQqQrImage from '../../../media/social-media-group/qq.png';
import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
+import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
import type {
CustomWorldLibraryEntry,
PlatformBrowseHistoryEntry,
@@ -52,11 +53,16 @@ import type {
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { AuthUser } from '../../services/authService';
-import { updateAuthProfile } from '../../services/authService';
+import {
+ getPublicAuthUserByCode,
+ getPublicAuthUserById,
+ updateAuthProfile,
+} from '../../services/authService';
import { copyTextToClipboard } from '../../services/clipboard';
import {
getRpgProfileReferralInviteCenter,
getRpgProfileWalletLedger,
+ redeemRpgProfileReferralInviteCode,
redeemRpgProfileRewardCode,
} from '../../services/rpg-entry/rpgProfileClient';
import type { CustomWorldProfile } from '../../types';
@@ -114,7 +120,7 @@ export interface RpgEntryHomeViewProps {
entry: CustomWorldLibraryEntry,
) => void;
deletingLibraryEntryId?: string | null;
- onSearchPublicCode?: (keyword: string) => void | Promise;
+ onSearchPublicCode?: (keyword: string) => void | Promise;
isSearchingPublicCode?: boolean;
onOpenProfileDashboardCard?: (cardKey: ProfileDashboardCardKey) => void;
profilePlayStats?: ProfilePlayStatsResponse | null;
@@ -146,6 +152,7 @@ const AVATAR_MAX_FILE_SIZE = 5 * 1024 * 1024;
const AVATAR_OUTPUT_SIZE = 256;
const AVATAR_ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200;
+const PROFILE_INVITE_REDEEM_ENTRY_VISIBLE_MS = 24 * 60 * 60 * 1000;
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
type MobileHomeChannel = 'recommend' | 'today' | 'category';
type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like';
@@ -304,7 +311,7 @@ function PublicCodeSearchBar({
onSubmit();
}
}}
- placeholder="输入 SY / CW / BF / M3 / PZ 编号"
+ placeholder="搜索作品号、名称、作者、描述"
className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
/>
)}
-
-
- {label}
-
-
);
}
@@ -366,6 +366,7 @@ function WorldCard({
entry,
onClick,
className,
+ authorAvatarUrl,
feedCardKey,
enableCoverCarousel = false,
isCoverCarouselActive = false,
@@ -373,6 +374,7 @@ function WorldCard({
entry: PlatformPublicGalleryCard;
onClick: () => void;
className?: string;
+ authorAvatarUrl?: string | null;
feedCardKey?: string;
enableCoverCarousel?: boolean;
isCoverCarouselActive?: boolean;
@@ -406,6 +408,7 @@ function WorldCard({
const typeLabel = describePublicGalleryCardKind(entry);
const authorName = entry.authorDisplayName.trim() || '玩家';
const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName);
+ const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
const cardLabel = `${entry.worldName},${typeLabel},${formatCompactCount(playCount)}游玩,${formatCompactCount(remixCount)}改造,${formatCompactCount(likeCount)}点赞`;
const coverStats = [
{
@@ -492,7 +495,15 @@ function WorldCard({
aria-hidden="true"
className="platform-public-work-card__author-avatar"
>
- {authorAvatarLabel}
+ {normalizedAuthorAvatarUrl ? (
+

+ ) : (
+ authorAvatarLabel
+ )}
{authorName}
@@ -656,19 +667,18 @@ function SaveArchiveCard({
>
-
-
ARCHIVE
+
{loading ? '恢复中' : formatSnapshotTime(entry.lastPlayedAt)}
-
+
{displayName}
{entry.subtitle ? (
-
+
{entry.subtitle}
) : null}
@@ -682,8 +692,7 @@ function SaveArchiveCard({
@@ -1016,6 +1025,104 @@ function getPlatformPublicEntries(
return Array.from(entryMap.values());
}
+function normalizePlatformSearchText(value: string | null | undefined) {
+ return (value ?? '').trim().toLocaleLowerCase('zh-CN');
+}
+
+function normalizePlatformCompactSearchText(value: string | null | undefined) {
+ return normalizePlatformSearchText(value).replace(/[\s_-]+/gu, '');
+}
+
+function getPlatformSearchableWorkIds(entry: PlatformPublicGalleryCard) {
+ const ids = [entry.publicWorkCode, entry.profileId];
+ if ('workId' in entry) {
+ ids.push(entry.workId);
+ }
+
+ return ids.filter((value): value is string => Boolean(value?.trim()));
+}
+
+function buildPlatformWorkSearchText(entry: PlatformPublicGalleryCard) {
+ return [
+ ...getPlatformSearchableWorkIds(entry),
+ entry.worldName,
+ entry.authorDisplayName,
+ entry.summaryText,
+ entry.subtitle,
+ ].join(' ');
+}
+
+function matchesPlatformWorkSearch(
+ entry: PlatformPublicGalleryCard,
+ keyword: string,
+) {
+ const normalizedKeyword = normalizePlatformSearchText(keyword);
+ const compactKeyword = normalizePlatformCompactSearchText(keyword);
+ if (!normalizedKeyword) {
+ return false;
+ }
+
+ const normalizedSearchText = normalizePlatformSearchText(
+ buildPlatformWorkSearchText(entry),
+ );
+ if (normalizedSearchText.includes(normalizedKeyword)) {
+ return true;
+ }
+
+ return (
+ Boolean(compactKeyword) &&
+ normalizePlatformCompactSearchText(
+ buildPlatformWorkSearchText(entry),
+ ).includes(compactKeyword)
+ );
+}
+
+function filterPlatformWorkSearchResults(
+ entries: PlatformPublicGalleryCard[],
+ keyword: string,
+) {
+ return entries
+ .filter((entry) => matchesPlatformWorkSearch(entry, keyword))
+ .sort((left, right) => {
+ const leftCode = getPlatformSearchableWorkIds(left)[0] ?? '';
+ const rightCode = getPlatformSearchableWorkIds(right)[0] ?? '';
+ const normalizedKeyword = normalizePlatformSearchText(keyword);
+ const leftNameStarts = normalizePlatformSearchText(
+ left.worldName,
+ ).startsWith(normalizedKeyword);
+ const rightNameStarts = normalizePlatformSearchText(
+ right.worldName,
+ ).startsWith(normalizedKeyword);
+ if (leftNameStarts !== rightNameStarts) {
+ return leftNameStarts ? -1 : 1;
+ }
+
+ const leftCodeStarts = normalizePlatformCompactSearchText(
+ leftCode,
+ ).startsWith(normalizePlatformCompactSearchText(keyword));
+ const rightCodeStarts = normalizePlatformCompactSearchText(
+ rightCode,
+ ).startsWith(normalizePlatformCompactSearchText(keyword));
+ if (leftCodeStarts !== rightCodeStarts) {
+ return leftCodeStarts ? -1 : 1;
+ }
+
+ return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
+ });
+}
+
+function isExactPublicWorkCodeSearch(
+ entries: PlatformPublicGalleryCard[],
+ keyword: string,
+) {
+ const normalizedKeyword = normalizePlatformSearchText(keyword);
+ return entries.some(
+ (entry) =>
+ Boolean(entry.publicWorkCode?.trim()) &&
+ normalizePlatformSearchText(entry.publicWorkCode) === normalizedKeyword,
+ );
+}
+
function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
const kind = isBigFishGalleryEntry(entry)
? 'big-fish'
@@ -1027,6 +1134,103 @@ function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
}
+function PlatformWorkSearchResults({
+ keyword,
+ entries,
+ onOpen,
+ onClear,
+}: {
+ keyword: string;
+ entries: PlatformPublicGalleryCard[];
+ onOpen: (entry: PlatformPublicGalleryCard) => void;
+ onClear: () => void;
+}) {
+ const trimmedKeyword = keyword.trim();
+ if (!trimmedKeyword) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ {entries.length > 0 ? (
+
+ {entries.slice(0, 12).map((entry) => {
+ const displayName = formatPlatformWorkDisplayName(entry.worldName);
+ const workCode = getPlatformSearchableWorkIds(entry)[0] ?? '';
+ const summaryText =
+ entry.summaryText || entry.subtitle || '等待补充世界摘要。';
+
+ return (
+
+ );
+ })}
+
+ ) : (
+
+ )}
+
+ );
+}
+
+function buildPublicWorkAuthorLookupKey(entry: PlatformPublicGalleryCard) {
+ if ('authorPublicUserCode' in entry) {
+ const authorPublicUserCode = entry.authorPublicUserCode?.trim();
+ if (authorPublicUserCode) {
+ return `code:${authorPublicUserCode}`;
+ }
+ }
+
+ const ownerUserId = entry.ownerUserId.trim();
+ return ownerUserId ? `id:${ownerUserId}` : null;
+}
+
+async function getPublicWorkAuthorSummary(
+ authorLookupKey: string,
+): Promise
{
+ if (authorLookupKey.startsWith('code:')) {
+ return getPublicAuthUserByCode(authorLookupKey.slice('code:'.length));
+ }
+
+ if (authorLookupKey.startsWith('id:')) {
+ return getPublicAuthUserById(authorLookupKey.slice('id:'.length));
+ }
+
+ return null;
+}
+
function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
const kind = isBigFishGalleryEntry(entry)
? '大鱼'
@@ -1286,6 +1490,21 @@ function formatDashboardUpdatedAt(value: string | null | undefined) {
});
}
+function isWithinProfileInviteRedeemWindow(
+ createdAt: string | null | undefined,
+) {
+ if (!createdAt) {
+ return false;
+ }
+
+ const createdTime = new Date(createdAt).getTime();
+ if (Number.isNaN(createdTime)) {
+ return false;
+ }
+
+ return Date.now() - createdTime <= PROFILE_INVITE_REDEEM_ENTRY_VISIBLE_MS;
+}
+
function formatPlayedWorkType(value: string | null | undefined) {
const normalizedValue = (value ?? '').toLowerCase();
if (normalizedValue === 'puzzle') {
@@ -1444,10 +1663,12 @@ function ProfileStatCardSkeleton() {
function ProfileShortcutButton({
label,
+ subLabel,
icon,
onClick,
}: {
label: string;
+ subLabel?: ReactNode;
icon: ComponentType<{ className?: string }>;
onClick?: (() => void) | null;
}) {
@@ -1465,10 +1686,41 @@ function ProfileShortcutButton({
{label}
+ {subLabel ? (
+
+ {subLabel}
+
+ ) : null}
);
}
+function ProfileReferralUserAvatar({
+ name,
+ avatarUrl,
+}: {
+ name: string;
+ avatarUrl: string | null;
+}) {
+ const avatarLabel = (name.trim() || '玩').slice(0, 1).toUpperCase();
+
+ return (
+
+ {avatarUrl ? (
+
+ ) : (
+ avatarLabel
+ )}
+
+ );
+}
+
function ProfileNicknameModal({
value,
error,
@@ -1744,7 +1996,8 @@ function ProfileAvatarCropModal({
}
const WALLET_LEDGER_SOURCE_LABELS: Record = {
- points_recharge: '陶泥币充值',
+ new_user_registration_reward: '注册赠送',
+ points_recharge: '光点充值',
invite_inviter_reward: '邀请奖励',
invite_invitee_reward: '填写邀请码奖励',
snapshot_sync: '账户同步',
@@ -1782,7 +2035,7 @@ function WalletLedgerModal({
type="button"
onClick={onClose}
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/78 text-[#ff4056] shadow-sm"
- aria-label="关闭陶泥币账单"
+ aria-label="关闭光点账单"
>
×
@@ -1791,10 +2044,10 @@ function WalletLedgerModal({
LEDGER
- 陶泥币账单
+ 光点账单
- {balance}陶泥币
+ {balance}光点
@@ -1938,20 +2191,37 @@ function ProfileReferralModal({
panel,
center,
isLoading,
+ isSubmittingRedeem,
+ redeemCode,
error,
success,
onClose,
onCopyInvite,
+ onRedeemCodeChange,
+ onSubmitRedeemCode,
}: {
panel: ProfilePopupPanel;
center: ProfileReferralInviteCenterResponse | null;
isLoading: boolean;
+ isSubmittingRedeem: boolean;
+ redeemCode: string;
error: string | null;
success: string | null;
onClose: () => void;
onCopyInvite: () => void;
+ onRedeemCodeChange: (value: string) => void;
+ onSubmitRedeemCode: () => void;
}) {
- const title = panel === 'invite' ? '邀请好友' : '玩家社区';
+ const title =
+ panel === 'invite'
+ ? '邀请好友'
+ : panel === 'redeem'
+ ? '填邀请码'
+ : '玩家社区';
+ const normalizedRedeemCode = redeemCode
+ .trim()
+ .replace(/[^0-9a-z]/gi, '')
+ .toUpperCase();
return (
@@ -1989,6 +2259,42 @@ function ProfileReferralModal({
))}
+ ) : panel === 'redeem' ? (
+ isLoading ? (
+
+ ) : center?.hasRedeemedCode ? (
+
+ 已填写邀请码
+
+ ) : (
+
+ )
) : isLoading ? (
@@ -2004,6 +2310,12 @@ function ProfileReferralModal({
{center?.inviteCode ?? '--------'}
+
+
+ {`邀请一个用户注册,双方都可以获得${center?.rewardPoints ?? 30}光点。`}
+
+
每日最多获得十次邀请奖励。
+
复制邀请
-
-
-
- {center?.invitedCount ?? 0}
-
- 邀请
+
+
+ 成功邀请
-
-
- {center?.rewardedInviteCount ?? 0}
+ {center?.invitedUsers?.length ? (
+
+ {center.invitedUsers.map((user) => (
+
+
+
+
+ {user.displayName || '玩家'}
+
+
+
+ ))}
- 已奖
-
-
-
- {center?.todayInviterRewardRemaining ?? 0}
+ ) : (
+
+ 暂无成功邀请
- 今日
-
+ )}
)}
@@ -2192,6 +2513,7 @@ export function RpgEntryHomeView({
const authUi = useAuthUi();
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
const [mobileSearchKeyword, setMobileSearchKeyword] = useState('');
+ const [activeWorkSearchKeyword, setActiveWorkSearchKeyword] = useState('');
const [isRewardCodeOpen, setIsRewardCodeOpen] = useState(false);
const [rewardCodeInput, setRewardCodeInput] = useState('');
const [isSubmittingRewardCode, setIsSubmittingRewardCode] = useState(false);
@@ -2211,6 +2533,11 @@ export function RpgEntryHomeView({
const [referralCenter, setReferralCenter] =
useState
(null);
const [isLoadingReferral, setIsLoadingReferral] = useState(false);
+ const [isReferralCenterInitialized, setIsReferralCenterInitialized] =
+ useState(false);
+ const [referralRedeemCode, setReferralRedeemCode] = useState('');
+ const [isSubmittingReferralRedeem, setIsSubmittingReferralRedeem] =
+ useState(false);
const [referralError, setReferralError] = useState(null);
const [referralSuccess, setReferralSuccess] = useState(null);
const [selectedCategoryTag, setSelectedCategoryTag] = useState(
@@ -2222,6 +2549,10 @@ export function RpgEntryHomeView({
const [mobileCenteredCardKey, setMobileCenteredCardKey] = useState<
string | null
>(null);
+ const pendingPublicAuthorKeysRef = useRef>(new Set());
+ const [publicAuthorSummariesByKey, setPublicAuthorSummariesByKey] = useState<
+ Record
+ >({});
const [activeRankingTab, setActiveRankingTab] =
useState('hot');
const [visitedTabs, setVisitedTabs] = useState>(
@@ -2259,6 +2590,24 @@ export function RpgEntryHomeView({
() => getPlatformPublicEntries(featuredEntries, latestEntries),
[featuredEntries, latestEntries],
);
+ const workSearchResults = useMemo(
+ () =>
+ filterPlatformWorkSearchResults(publicEntries, activeWorkSearchKeyword),
+ [activeWorkSearchKeyword, publicEntries],
+ );
+ const getPublicEntryAuthorAvatarUrl = useCallback(
+ (entry: PlatformPublicGalleryCard) => {
+ const authorLookupKey = buildPublicWorkAuthorLookupKey(entry);
+ if (!authorLookupKey) {
+ return null;
+ }
+
+ return (
+ publicAuthorSummariesByKey[authorLookupKey]?.avatarUrl?.trim() || null
+ );
+ },
+ [publicAuthorSummariesByKey],
+ );
const activeCategoryGroup =
categoryGroups.find((group) => group.tag === selectedCategoryTag) ??
categoryGroups[0] ??
@@ -2297,6 +2646,12 @@ export function RpgEntryHomeView({
profileDashboard?.totalPlayTimeMs ?? 0,
);
const playedWorkCount = profileDashboard?.playedWorldCount ?? 0;
+ const canShowReferralRedeemShortcut =
+ isAuthenticated &&
+ isWithinProfileInviteRedeemWindow(authUi?.user?.createdAt) &&
+ isReferralCenterInitialized &&
+ Boolean(referralCenter) &&
+ referralCenter?.hasRedeemedCode !== true;
const tabIcons = {
home: House,
category: Trophy,
@@ -2353,6 +2708,67 @@ export function RpgEntryHomeView({
setSelectedCategoryTag(firstCategoryGroup.tag);
}
}, [categoryGroups, selectedCategoryTag]);
+
+ useEffect(() => {
+ const missingAuthorKeys = [
+ ...new Set(
+ publicEntries
+ .map(buildPublicWorkAuthorLookupKey)
+ .filter((key): key is string => Boolean(key)),
+ ),
+ ].filter(
+ (key) =>
+ !(key in publicAuthorSummariesByKey) &&
+ !pendingPublicAuthorKeysRef.current.has(key),
+ );
+
+ if (missingAuthorKeys.length === 0) {
+ return undefined;
+ }
+
+ let cancelled = false;
+ missingAuthorKeys.forEach((key) => {
+ pendingPublicAuthorKeysRef.current.add(key);
+ });
+
+ // 中文注释:头像来自公开用户摘要,失败时缓存空值,避免首页滚动时反复打公开用户接口。
+ void Promise.all(
+ missingAuthorKeys.map(async (authorLookupKey) => {
+ try {
+ const author = await getPublicWorkAuthorSummary(authorLookupKey);
+ return [authorLookupKey, author] as const;
+ } catch {
+ return [authorLookupKey, null] as const;
+ } finally {
+ pendingPublicAuthorKeysRef.current.delete(authorLookupKey);
+ }
+ }),
+ ).then((results) => {
+ if (cancelled) {
+ return;
+ }
+
+ setPublicAuthorSummariesByKey((currentSummaries) => {
+ let changed = false;
+ const nextSummaries = { ...currentSummaries };
+
+ results.forEach(([authorLookupKey, author]) => {
+ if (authorLookupKey in nextSummaries) {
+ return;
+ }
+
+ nextSummaries[authorLookupKey] = author;
+ changed = true;
+ });
+
+ return changed ? nextSummaries : currentSummaries;
+ });
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [publicAuthorSummariesByKey, publicEntries]);
const openUserSurface = () => {
if (authUi?.user) {
authUi.openAccountModal();
@@ -2536,7 +2952,7 @@ export function RpgEntryHomeView({
.catch((error: unknown) => {
setWalletLedger(null);
setWalletLedgerError(
- error instanceof Error ? error.message : '读取陶泥币账单失败',
+ error instanceof Error ? error.message : '读取光点账单失败',
);
})
.finally(() => setIsLoadingWalletLedger(false));
@@ -2545,15 +2961,9 @@ export function RpgEntryHomeView({
setIsWalletLedgerOpen(true);
loadWalletLedger();
};
- const openProfilePopupPanel = (panel: ProfilePopupPanel) => {
- setProfilePopupPanel(panel);
- setReferralError(null);
- setReferralSuccess(null);
- if (panel === 'community') {
- return;
- }
-
+ const loadReferralCenter = useCallback(() => {
setIsLoadingReferral(true);
+ setIsReferralCenterInitialized(false);
void getRpgProfileReferralInviteCenter()
.then(setReferralCenter)
.catch((error: unknown) => {
@@ -2562,7 +2972,38 @@ export function RpgEntryHomeView({
error instanceof Error ? error.message : '读取邀请码失败',
);
})
- .finally(() => setIsLoadingReferral(false));
+ .finally(() => {
+ setIsReferralCenterInitialized(true);
+ setIsLoadingReferral(false);
+ });
+ }, []);
+ useEffect(() => {
+ if (
+ activeTab !== 'profile' ||
+ !isAuthenticated ||
+ !isWithinProfileInviteRedeemWindow(authUi?.user?.createdAt)
+ ) {
+ setIsReferralCenterInitialized(false);
+ setReferralCenter(null);
+ return;
+ }
+
+ loadReferralCenter();
+ }, [activeTab, authUi?.user?.createdAt, isAuthenticated, loadReferralCenter]);
+ const openProfilePopupPanel = (panel: ProfilePopupPanel) => {
+ setProfilePopupPanel(panel);
+ setReferralError(null);
+ setReferralSuccess(null);
+ if (panel === 'redeem') {
+ setReferralRedeemCode('');
+ }
+ if (panel === 'community') {
+ return;
+ }
+
+ if (!isReferralCenterInitialized && !isLoadingReferral) {
+ loadReferralCenter();
+ }
};
const copyInviteInfo = () => {
if (!referralCenter?.inviteCode) {
@@ -2579,6 +3020,32 @@ export function RpgEntryHomeView({
},
);
};
+ const submitReferralRedeemCode = () => {
+ const inviteCode = referralRedeemCode
+ .trim()
+ .replace(/[^0-9a-z]/gi, '')
+ .toUpperCase();
+ if (isSubmittingReferralRedeem || !inviteCode) {
+ return;
+ }
+
+ setIsSubmittingReferralRedeem(true);
+ setReferralError(null);
+ setReferralSuccess(null);
+ void redeemRpgProfileReferralInviteCode(inviteCode)
+ .then((response) => {
+ setReferralCenter(response.center);
+ setReferralRedeemCode('');
+ setReferralSuccess('已填写');
+ void onRechargeSuccess?.();
+ })
+ .catch((error: unknown) => {
+ setReferralError(
+ error instanceof Error ? error.message : '填写邀请码失败',
+ );
+ })
+ .finally(() => setIsSubmittingReferralRedeem(false));
+ };
const openRewardCodeModal = () => {
setIsRewardCodeOpen(true);
setRewardCodeError(null);
@@ -2595,7 +3062,7 @@ export function RpgEntryHomeView({
void redeemRpgProfileRewardCode(rewardCodeInput)
.then((response: RedeemProfileRewardCodeResponse) => {
setRewardCodeInput('');
- setRewardCodeSuccess(`已到账 ${response.amountGranted} 陶泥币`);
+ setRewardCodeSuccess(`已到账 ${response.amountGranted} 光点`);
void onRechargeSuccess?.();
})
.catch((error: unknown) => {
@@ -2603,21 +3070,63 @@ export function RpgEntryHomeView({
})
.finally(() => setIsSubmittingRewardCode(false));
};
- const submitDesktopSearch = () => {
- const keyword = desktopSearchKeyword.trim();
- if (!keyword || !onSearchPublicCode || isSearchingPublicCode) {
+ const clearWorkSearch = () => {
+ setActiveWorkSearchKeyword('');
+ setDesktopSearchKeyword('');
+ setMobileSearchKeyword('');
+ };
+ const updateDesktopSearchKeyword = (value: string) => {
+ setDesktopSearchKeyword(value);
+ if (!value.trim()) {
+ setActiveWorkSearchKeyword('');
+ }
+ };
+ const updateMobileSearchKeyword = (value: string) => {
+ setMobileSearchKeyword(value);
+ if (!value.trim()) {
+ setActiveWorkSearchKeyword('');
+ }
+ };
+ const submitWorkSearch = (keyword: string) => {
+ const trimmedKeyword = keyword.trim();
+ if (!trimmedKeyword) {
return;
}
- void onSearchPublicCode(keyword);
+ // 中文注释:优先使用首页已经聚合好的公开作品读模型做模糊命中;
+ // 无本地命中时继续走既有编号直达兜底,避免破坏深链搜索。
+ const matchedEntries = filterPlatformWorkSearchResults(
+ publicEntries,
+ trimmedKeyword,
+ );
+ if (
+ matchedEntries.length > 0 &&
+ isExactPublicWorkCodeSearch(matchedEntries, trimmedKeyword) &&
+ onSearchPublicCode &&
+ !isSearchingPublicCode
+ ) {
+ setActiveWorkSearchKeyword('');
+ void onSearchPublicCode(trimmedKeyword);
+ return;
+ }
+
+ if (matchedEntries.length > 0) {
+ setActiveWorkSearchKeyword(trimmedKeyword);
+ return;
+ }
+
+ setActiveWorkSearchKeyword('');
+ if (!onSearchPublicCode || isSearchingPublicCode) {
+ return;
+ }
+
+ void onSearchPublicCode(trimmedKeyword);
+ };
+ const submitDesktopSearch = () => {
+ submitWorkSearch(desktopSearchKeyword);
};
const submitMobileSearch = () => {
- const keyword = mobileSearchKeyword.trim();
- if (!keyword || !onSearchPublicCode || isSearchingPublicCode) {
- return;
- }
-
- void onSearchPublicCode(keyword);
+ submitWorkSearch(mobileSearchKeyword);
};
const desktopHeroEntry =
featuredShelf[0] ?? latestEntries[0] ?? myEntries[0] ?? null;
@@ -2761,115 +3270,127 @@ export function RpgEntryHomeView({
-
- {MOBILE_HOME_CHANNELS.map((channel) => {
- const active = mobileHomeChannel === channel.id;
- return (
-
- );
- })}
-
-
- {platformError ? (
-
- {platformError}
-
- ) : null}
-
- {mobileHomeChannel === 'category' ? (
-
- {isLoadingPlatform ? (
-
- ) : categoryGroups.length > 0 && activeCategoryGroup ? (
- <>
-
+ {activeWorkSearchKeyword.trim() ? (
+
+ ) : (
+ <>
+
+ {MOBILE_HOME_CHANNELS.map((channel) => {
+ const active = mobileHomeChannel === channel.id;
+ return (
-
-
- {categoryGroups.map((group) => {
- const active = group.tag === activeCategoryGroup.tag;
+ );
+ })}
+
+
+ {platformError ? (
+
+ {platformError}
+
+ ) : null}
+
+ {mobileHomeChannel === 'category' ? (
+
+ {isLoadingPlatform ? (
+
+ ) : categoryGroups.length > 0 && activeCategoryGroup ? (
+ <>
+
+
+
+
+ {categoryGroups.map((group) => {
+ const active = group.tag === activeCategoryGroup.tag;
+
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+ {activeCategoryEntries.map((entry) => (
+
onOpenGalleryDetail(entry)}
+ />
+ ))}
+
+ >
+ ) : (
+
+ )}
+
+ ) : (
+
+ {isLoadingPlatform ? (
+
+ ) : mobileFeedEntries.length > 0 ? (
+
+ {mobileFeedEntries.map((entry: PlatformPublicGalleryCard) => {
+ const cardKey = buildPublicGalleryCardKey(entry);
return (
-
+ onOpenGalleryDetail(entry)}
+ className="w-full"
+ authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
+ feedCardKey={cardKey}
+ enableCoverCarousel={mobileFeedCarouselEnabled}
+ isCoverCarouselActive={mobileCenteredCardKey === cardKey}
+ />
);
})}
-
-
-
-
-
- {activeCategoryEntries.map((entry) => (
-
onOpenGalleryDetail(entry)}
- />
- ))}
-
- >
- ) : (
-
+ ) : (
+
+ )}
+
)}
-
- ) : (
-
- {isLoadingPlatform ? (
-
- ) : mobileFeedEntries.length > 0 ? (
-
- {mobileFeedEntries.map((entry: PlatformPublicGalleryCard) => {
- const cardKey = buildPublicGalleryCardKey(entry);
-
- return (
- onOpenGalleryDetail(entry)}
- className="w-full"
- feedCardKey={cardKey}
- enableCoverCarousel={mobileFeedCarouselEnabled}
- isCoverCarouselActive={mobileCenteredCardKey === cardKey}
- />
- );
- })}
-
- ) : (
-
- )}
-
+ >
)}
);
@@ -3091,7 +3612,7 @@ export function RpgEntryHomeView({
-
陶泥号 {publicUserCode}
+
百梦号 {publicUserCode}
@@ -3135,7 +3656,7 @@ export function RpgEntryHomeView({
<>
-
-
+
+ 双方得30
+
+ >
+ }
icon={UserPlus}
onClick={() => openProfilePopupPanel('invite')}
/>
+ {canShowReferralRedeemShortcut ? (
+ openProfilePopupPanel('redeem')}
+ />
+ ) : null}
openProfilePopupPanel('community')}
/>
@@ -3254,6 +3792,15 @@ export function RpgEntryHomeView({
) : null}
+ {activeWorkSearchKeyword.trim() ? (
+
+ ) : (
+ <>
@@ -3506,6 +4054,7 @@ export function RpgEntryHomeView({
entry={entry}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full min-w-0"
+ authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
/>
))}
@@ -3514,6 +4063,8 @@ export function RpgEntryHomeView({
)}
+ >
+ )}
);
@@ -3635,10 +4186,14 @@ export function RpgEntryHomeView({
panel={profilePopupPanel}
center={referralCenter}
isLoading={isLoadingReferral}
+ isSubmittingRedeem={isSubmittingReferralRedeem}
+ redeemCode={referralRedeemCode}
error={referralError}
success={referralSuccess}
onClose={() => setProfilePopupPanel(null)}
onCopyInvite={copyInviteInfo}
+ onRedeemCodeChange={setReferralRedeemCode}
+ onSubmitRedeemCode={submitReferralRedeemCode}
/>
) : null}
{rewardCodeModal}
@@ -3675,7 +4230,7 @@ export function RpgEntryHomeView({
setProfilePopupPanel(null)}
onCopyInvite={copyInviteInfo}
+ onRedeemCodeChange={setReferralRedeemCode}
+ onSubmitRedeemCode={submitReferralRedeemCode}
/>
) : null}
{isProfilePlayStatsOpen ? (
diff --git a/src/index.css b/src/index.css
index 06b1496d..546544fe 100644
--- a/src/index.css
+++ b/src/index.css
@@ -962,6 +962,13 @@ body {
font-size: 0.62rem;
font-weight: 900;
line-height: 1;
+ overflow: hidden;
+}
+
+.platform-public-work-card__author-avatar-image {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
}
.platform-ranking-panel {
@@ -4026,10 +4033,34 @@ button {
object-fit: cover;
}
+.platform-work-detail__cover-image--locked {
+ filter: blur(18px) saturate(0.7);
+ opacity: 0.58;
+ transform: scale(1.08);
+}
+
+.platform-work-detail__cover-lock {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(15, 23, 42, 0.28);
+ backdrop-filter: blur(10px);
+}
+
+.platform-work-detail__cover-lock-icon {
+ width: clamp(3.6rem, 18vw, 7rem);
+ height: clamp(3.6rem, 18vw, 7rem);
+ color: rgba(255, 255, 255, 0.88);
+ filter: drop-shadow(0 1.2rem 2rem rgba(15, 23, 42, 0.34));
+}
+
.platform-work-detail__cover-nav {
position: absolute;
top: 50%;
- z-index: 2;
+ z-index: 3;
display: inline-flex;
height: 2.4rem;
width: 2.4rem;
@@ -4056,7 +4087,7 @@ button {
right: 1rem;
bottom: 0.8rem;
left: 1rem;
- z-index: 2;
+ z-index: 3;
display: flex;
justify-content: center;
gap: 0.38rem;
diff --git a/src/services/authService.test.ts b/src/services/authService.test.ts
index 253fd201..11252950 100644
--- a/src/services/authService.test.ts
+++ b/src/services/authService.test.ts
@@ -94,6 +94,7 @@ describe('authService', () => {
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
+ createdAt: '2026-05-01T00:00:00.000Z',
},
});
@@ -130,6 +131,7 @@ describe('authService', () => {
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
+ createdAt: '2026-05-01T00:00:00.000Z',
},
});
@@ -217,6 +219,7 @@ describe('authService', () => {
loginMethod: 'phone',
bindingStatus: 'active',
wechatBound: false,
+ createdAt: '2026-05-01T00:00:00.000Z',
},
});
@@ -295,6 +298,7 @@ describe('authService', () => {
loginMethod: 'wechat',
bindingStatus: 'active',
wechatBound: true,
+ createdAt: '2026-05-01T00:00:00.000Z',
},
});
@@ -317,6 +321,7 @@ describe('authService', () => {
loginMethod: 'phone',
bindingStatus: 'active',
wechatBound: false,
+ createdAt: '2026-05-01T00:00:00.000Z',
},
});
diff --git a/src/services/puzzle-runtime/puzzleRuntimeClient.ts b/src/services/puzzle-runtime/puzzleRuntimeClient.ts
index b0bc81c9..6fef5469 100644
--- a/src/services/puzzle-runtime/puzzleRuntimeClient.ts
+++ b/src/services/puzzle-runtime/puzzleRuntimeClient.ts
@@ -136,7 +136,7 @@ export async function updatePuzzleRunPause(
}
/**
- * 使用正式拼图道具,服务端负责扣除陶泥币并更新运行态。
+ * 使用正式拼图道具,服务端负责扣除光点并更新运行态。
*/
export async function usePuzzleRuntimeProp(
runId: string,
diff --git a/src/services/puzzle-works/puzzleWorksClient.ts b/src/services/puzzle-works/puzzleWorksClient.ts
index 60694c68..a191d7b9 100644
--- a/src/services/puzzle-works/puzzleWorksClient.ts
+++ b/src/services/puzzle-works/puzzleWorksClient.ts
@@ -99,7 +99,7 @@ export async function deletePuzzleWork(profileId: string) {
}
/**
- * 领取当前用户名下拼图作品的整数陶泥币激励。
+ * 领取当前用户名下拼图作品的整数光点激励。
*/
export async function claimPuzzleWorkPointIncentive(profileId: string) {
return requestJson(
diff --git a/vite.config.ts b/vite.config.ts
index e056da05..20f954ae 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -122,6 +122,11 @@ export default defineConfig(({mode}) => {
changeOrigin: true,
secure: false,
},
+ '/api/creation': {
+ target: runtimeServerTarget,
+ changeOrigin: true,
+ secure: false,
+ },
'/api/custom-world': {
target: runtimeServerTarget,
changeOrigin: true,