This commit is contained in:
2026-05-10 22:28:43 +08:00
46 changed files with 5894 additions and 341 deletions

View File

@@ -291,6 +291,11 @@ import {
type VisualNovelEntryFormPayload,
} from '../visual-novel-creation/VisualNovelAgentWorkspace';
import { createMockVisualNovelRunFromDraft } from '../visual-novel-runtime/visualNovelMockData';
import {
canExposePublicWork,
EDUTAINMENT_HIDDEN_MESSAGE,
filterGeneralPublicWorks,
} from './platformEdutainmentVisibility';
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
import {
@@ -865,6 +870,19 @@ function shouldUseLocalPuzzleOnboardingFallback(error: unknown) {
);
}
function isMissingPuzzleWorkError(error: unknown) {
return (
(error instanceof ApiClientError &&
error.status === 404 &&
(error.code === 'NOT_FOUND' ||
error.message.includes('资源不存在') ||
error.message.includes('未找到'))) ||
(error instanceof Error &&
(error.message.includes('资源不存在') ||
error.message.includes('未找到拼图作品')))
);
}
function hasSeenPuzzleOnboarding() {
if (typeof window === 'undefined') {
return true;
@@ -2165,7 +2183,10 @@ export function PlatformEntryFlowShellImpl({
const recommendRuntimeEntries = useMemo(
() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
[...featuredGalleryEntries, ...latestGalleryEntries].forEach((entry) => {
filterGeneralPublicWorks([
...featuredGalleryEntries,
...latestGalleryEntries,
]).forEach((entry) => {
entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry);
});
return Array.from(entryMap.values());
@@ -4244,6 +4265,19 @@ export function PlatformEntryFlowShellImpl({
}
return true;
} catch (error) {
if (isMissingPuzzleWorkError(error)) {
setSelectedPuzzleDetail(null);
setPuzzleDetailReturnTarget(null);
setPuzzleRun(null);
setPuzzleRuntimeAuthMode('default');
setPuzzleError(null);
setPublicWorkDetailError(null);
setPlatformTab('home');
setSelectionStage('platform');
pushAppHistoryPath('/');
return false;
}
const message = resolvePuzzleErrorMessage(error, '启动拼图玩法失败。');
setPuzzleError(message);
if (mirrorErrorToPublicDetail) {
@@ -4259,8 +4293,8 @@ export function PlatformEntryFlowShellImpl({
resolvePuzzleErrorMessage,
setIsPuzzleBusy,
setPuzzleError,
setPlatformTab,
setSelectionStage,
startPuzzleRun,
],
);
@@ -5421,6 +5455,13 @@ export function PlatformEntryFlowShellImpl({
const openPublicWorkDetail = useCallback(
(entry: PlatformPublicGalleryCard) => {
if (!canExposePublicWork(entry)) {
setSelectedPublicWorkDetail(null);
setPublicWorkDetailError(EDUTAINMENT_HIDDEN_MESSAGE);
setSelectionStage('platform');
return;
}
setSelectedPublicWorkDetail(entry);
setPublicWorkDetailError(null);
setSelectionStage('work-detail');
@@ -5648,6 +5689,13 @@ export function PlatformEntryFlowShellImpl({
const openRpgPublicWorkDetail = useCallback(
async (entry: CustomWorldGalleryCard) => {
if (!canExposePublicWork(entry)) {
setSelectedPublicWorkDetail(null);
setPublicWorkDetailError(EDUTAINMENT_HIDDEN_MESSAGE);
setSelectionStage('platform');
return;
}
setIsPublicWorkDetailBusy(true);
setPublicWorkDetailError(null);
clearSelectedPublicWorkAuthor();
@@ -5659,6 +5707,14 @@ export function PlatformEntryFlowShellImpl({
await detailNavigation.loadGalleryDetailEntry(entry);
setSelectedDetailEntry(detailEntry);
const detailCard = mapRpgGalleryCardToPublicWorkDetail(detailEntry);
if (!canExposePublicWork(detailCard)) {
setSelectedDetailEntry(null);
setSelectedPublicWorkDetail(null);
setPublicWorkDetailError(EDUTAINMENT_HIDDEN_MESSAGE);
setSelectionStage('platform');
return;
}
setSelectedPublicWorkDetail(detailCard);
if (detailEntry.publicWorkCode?.trim()) {
pushAppHistoryPath(
@@ -5697,10 +5753,31 @@ export function PlatformEntryFlowShellImpl({
try {
const { item } = await getPuzzleGalleryDetail(profileId);
const detailEntry = mapPuzzleWorkToPublicWorkDetail(item);
if (!canExposePublicWork(detailEntry)) {
setSelectedPuzzleDetail(null);
setPublicWorkDetailError(EDUTAINMENT_HIDDEN_MESSAGE);
setSelectionStage('platform');
return;
}
setSelectedPuzzleDetail(item);
setPuzzleDetailReturnTarget(returnTarget);
openPublicWorkDetail(mapPuzzleWorkToPublicWorkDetail(item));
openPublicWorkDetail(detailEntry);
} catch (error) {
if (isMissingPuzzleWorkError(error)) {
setSelectedPuzzleDetail(null);
setPuzzleDetailReturnTarget(null);
setPuzzleRun(null);
setPuzzleRuntimeAuthMode('default');
setPuzzleError(null);
setPublicWorkDetailError(null);
setPlatformTab('home');
setSelectionStage('platform');
pushAppHistoryPath('/');
return;
}
setPublicWorkDetailError(
resolvePuzzleErrorMessage(error, '读取拼图详情失败。'),
);
@@ -5715,6 +5792,7 @@ export function PlatformEntryFlowShellImpl({
resolvePuzzleErrorMessage,
setIsPuzzleBusy,
setPuzzleError,
setPlatformTab,
setSelectionStage,
],
);
@@ -5906,6 +5984,19 @@ export function PlatformEntryFlowShellImpl({
),
);
} catch (error) {
if (isMissingPuzzleWorkError(error)) {
setSelectedPuzzleDetail(null);
setPuzzleDetailReturnTarget(null);
setPuzzleRun(null);
setPuzzleRuntimeAuthMode('default');
setPuzzleError(null);
setPublicWorkDetailError(null);
setPlatformTab('home');
setSelectionStage('platform');
pushAppHistoryPath('/');
return;
}
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图详情失败。'));
} finally {
setIsPuzzleBusy(false);
@@ -5916,6 +6007,7 @@ export function PlatformEntryFlowShellImpl({
resolvePuzzleErrorMessage,
setIsPuzzleBusy,
setPuzzleError,
setPlatformTab,
setSelectionStage,
],
);
@@ -6710,11 +6802,14 @@ export function PlatformEntryFlowShellImpl({
match3dError,
match3dFlow,
match3dRun,
platformBootstrap.platformTab,
platformThemeClass,
puzzleError,
puzzleRun,
recommendRuntimeEntries,
remodelCurrentPuzzleRuntimeWork,
resolveMatch3DErrorMessage,
resolveSquareHoleErrorMessage,
reportBigFishObservedPlayTime,
restartBigFishRun,
selectedPuzzleDetail,
@@ -7063,6 +7158,10 @@ export function PlatformEntryFlowShellImpl({
remixCount: entry.remixCount ?? 0,
likeCount: entry.likeCount ?? 0,
} satisfies CustomWorldGalleryCard;
if (!canExposePublicWork(card)) {
throw new Error(EDUTAINMENT_HIDDEN_MESSAGE);
}
setSelectedDetailEntry(entry);
openPublicWorkDetail(card);
};
@@ -7071,9 +7170,12 @@ export function PlatformEntryFlowShellImpl({
puzzleGalleryEntries.length > 0
? puzzleGalleryEntries
: await refreshPuzzleGallery();
const matchedEntry = entries.find((entry) =>
isSamePuzzlePublicWorkCode(normalizedKeyword, entry.profileId),
);
const matchedEntry = entries
.map(mapPuzzleWorkToPublicWorkDetail)
.filter(canExposePublicWork)
.find((entry) =>
isSamePuzzlePublicWorkCode(normalizedKeyword, entry.profileId),
);
if (!matchedEntry) {
throw new Error('未找到拼图作品。');
@@ -7088,9 +7190,13 @@ export function PlatformEntryFlowShellImpl({
bigFishGalleryEntries.length > 0
? bigFishGalleryEntries
: await refreshBigFishGallery();
const matchedEntry = entries.find((entry) =>
isSameBigFishPublicWorkCode(normalizedKeyword, entry.sourceSessionId),
);
const matchedEntry = entries.find((entry) => {
const detailEntry = mapBigFishWorkToPublicWorkDetail(entry);
return (
canExposePublicWork(detailEntry) &&
isSameBigFishPublicWorkCode(normalizedKeyword, entry.sourceSessionId)
);
});
if (!matchedEntry) {
throw new Error('未找到大鱼吃小鱼作品。');
@@ -7103,9 +7209,13 @@ export function PlatformEntryFlowShellImpl({
match3dGalleryEntries.length > 0
? match3dGalleryEntries
: await refreshMatch3DGallery();
const matchedEntry = entries.find((entry) =>
isSameMatch3DPublicWorkCode(normalizedKeyword, entry.profileId),
);
const matchedEntry = entries.find((entry) => {
const detailEntry = mapMatch3DWorkToPublicWorkDetail(entry);
return (
canExposePublicWork(detailEntry) &&
isSameMatch3DPublicWorkCode(normalizedKeyword, entry.profileId)
);
});
if (!matchedEntry) {
throw new Error('未找到抓大鹅作品。');
@@ -7118,9 +7228,13 @@ export function PlatformEntryFlowShellImpl({
squareHoleGalleryEntries.length > 0
? squareHoleGalleryEntries
: await refreshSquareHoleGallery();
const matchedEntry = entries.find((entry) =>
isSameSquareHolePublicWorkCode(normalizedKeyword, entry.profileId),
);
const matchedEntry = entries.find((entry) => {
const detailEntry = mapSquareHoleWorkToPublicWorkDetail(entry);
return (
canExposePublicWork(detailEntry) &&
isSameSquareHolePublicWorkCode(normalizedKeyword, entry.profileId)
);
});
if (!matchedEntry) {
throw new Error('未找到方洞挑战作品。');
@@ -7133,9 +7247,13 @@ export function PlatformEntryFlowShellImpl({
visualNovelGalleryEntries.length > 0
? visualNovelGalleryEntries
: await refreshVisualNovelGallery();
const matchedEntry = entries.find((entry) =>
isSameVisualNovelPublicWorkCode(normalizedKeyword, entry.profileId),
);
const matchedEntry = entries.find((entry) => {
const detailEntry = mapVisualNovelWorkToPublicWorkDetail(entry);
return (
canExposePublicWork(detailEntry) &&
isSameVisualNovelPublicWorkCode(normalizedKeyword, entry.profileId)
);
});
if (!matchedEntry) {
throw new Error('未找到视觉小说作品。');

View File

@@ -0,0 +1,59 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
import {
canExposePublicWork,
filterEdutainmentPublicWorks,
filterGeneralPublicWorks,
isEdutainmentEntryEnabled,
isEdutainmentPublicWork,
} from './platformEdutainmentVisibility';
function buildPuzzleCard(themeTags: string[]): PlatformPublicGalleryCard {
return {
sourceType: 'puzzle',
workId: 'puzzle-work-education-demo',
profileId: 'puzzle-profile-education-demo',
publicWorkCode: 'PZ-EDUDEMO',
ownerUserId: 'user-education',
authorDisplayName: '动作 Demo 作者',
worldName: '儿童动作热身 Demo',
subtitle: '拼图关卡',
summaryText: '本地动作 Demo。',
coverImageSrc: null,
themeTags,
visibility: 'published',
publishedAt: '2026-05-09T10:00:00.000Z',
updatedAt: '2026-05-09T10:00:00.000Z',
};
}
afterEach(() => {
vi.unstubAllEnvs();
});
describe('platformEdutainmentVisibility', () => {
test('matches only the exact edutainment tag from full work tags', () => {
const exact = buildPuzzleCard(['运动', '安全', '拼图', '寓教于乐']);
const fuzzy = buildPuzzleCard(['儿童教育', '寓教于乐 ']);
expect(isEdutainmentPublicWork(exact)).toBe(true);
expect(isEdutainmentPublicWork(fuzzy)).toBe(false);
expect(filterEdutainmentPublicWorks([exact, fuzzy])).toEqual([exact]);
expect(filterGeneralPublicWorks([exact, fuzzy])).toEqual([fuzzy]);
});
test('defaults to enabled and blocks exact edutainment works only when disabled', () => {
const exact = buildPuzzleCard(['寓教于乐']);
const general = buildPuzzleCard(['儿童教育']);
expect(isEdutainmentEntryEnabled()).toBe(true);
expect(canExposePublicWork(exact)).toBe(true);
vi.stubEnv('VITE_ENABLE_EDUTAINMENT_ENTRY', 'false');
expect(isEdutainmentEntryEnabled()).toBe(false);
expect(canExposePublicWork(exact)).toBe(false);
expect(canExposePublicWork(general)).toBe(true);
});
});

View File

@@ -0,0 +1,58 @@
import type { PlatformBrowseHistoryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
export const EDUTAINMENT_WORK_TAG = '寓教于乐';
export const EDUTAINMENT_HIDDEN_MESSAGE = '该内容暂不可见。';
const EDUTAINMENT_ENTRY_DISABLED_VALUES = new Set(['false', '0', 'off', 'no']);
// 中文注释:入口默认开启;只有明确写入关闭值时才完全隐藏寓教于乐内容。
export function isEdutainmentEntryEnabled(
rawValue = import.meta.env.VITE_ENABLE_EDUTAINMENT_ENTRY,
) {
const normalized = (rawValue ?? '').trim().toLowerCase();
return !EDUTAINMENT_ENTRY_DISABLED_VALUES.has(normalized);
}
function getPlatformPublicWorkTags(entry: PlatformPublicGalleryCard) {
if ('themeTags' in entry) {
return entry.themeTags;
}
return [];
}
export function isEdutainmentPublicWork(entry: PlatformPublicGalleryCard) {
return getPlatformPublicWorkTags(entry).some(
(tag) => tag === EDUTAINMENT_WORK_TAG,
);
}
export function canExposePublicWork(entry: PlatformPublicGalleryCard) {
return isEdutainmentEntryEnabled() || !isEdutainmentPublicWork(entry);
}
export function filterGeneralPublicWorks(entries: PlatformPublicGalleryCard[]) {
return entries.filter((entry) => !isEdutainmentPublicWork(entry));
}
export function filterEdutainmentPublicWorks(
entries: PlatformPublicGalleryCard[],
) {
return entries.filter(isEdutainmentPublicWork);
}
export function filterVisiblePublicWorks(entries: PlatformPublicGalleryCard[]) {
return entries.filter(canExposePublicWork);
}
export function findPublicWorkForHistoryEntry(
historyEntry: PlatformBrowseHistoryEntry,
entries: PlatformPublicGalleryCard[],
) {
return entries.find(
(entry) =>
entry.ownerUserId === historyEntry.ownerUserId &&
entry.profileId === historyEntry.profileId,
);
}