fix: show published big fish works in gallery
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-27 00:09:09 +08:00
parent 44b08dd51a
commit 615d828add
19 changed files with 663 additions and 114 deletions

View File

@@ -147,7 +147,7 @@
## 6. HTTP contract ## 6. HTTP contract
所有接口挂在 `/api/runtime/big-fish/*`,全部需要 Bearer 鉴权 所有接口挂在 `/api/runtime/big-fish/*`。创作、私有作品列表、删除、运行态启动与输入推进需要 Bearer 鉴权;公开广场读取接口不要求登录,只返回已发布作品
开发态本地链路补充约定: 开发态本地链路补充约定:
@@ -191,12 +191,24 @@
2. `GET /api/runtime/big-fish/runs/{runId}` 2. `GET /api/runtime/big-fish/runs/{runId}`
3. `POST /api/runtime/big-fish/runs/{runId}/input` 3. `POST /api/runtime/big-fish/runs/{runId}/input`
运行态启动规则:
1. 当前用户启动自己未发布草稿时,`session.owner_user_id` 必须等于当前登录用户。
2. 当前用户启动别人作品时,只允许启动 `stage = published` 的公开作品。
3. 新建的 `big_fish_runtime_run.owner_user_id` 始终写入当前游玩用户,不能写入作品作者,后续 run 查询与输入推进仍按游玩用户隔离。
### 6.3 作品列表 ### 6.3 作品列表
1. `GET /api/runtime/big-fish/works` 1. `GET /api/runtime/big-fish/works`
开发态 Vite 必须把该同源接口代理到 Rust `api-server`;前端作品页只调用同源 `/api/runtime/big-fish/works`,不得直连 Rust 端口或回退到 `server-node` 开发态 Vite 必须把该同源接口代理到 Rust `api-server`;前端作品页只调用同源 `/api/runtime/big-fish/works`,不得直连 Rust 端口或回退到 `server-node`
### 6.4 公开广场
1. `GET /api/runtime/big-fish/gallery`
公开广场只返回 `status = published` 的大鱼吃小鱼作品。响应复用 `BigFishWorksResponse`,每个条目必须包含 `ownerUserId`,供前端生成稳定广场卡片 key 与后续运行态权限判断。发布动作完成后,前端必须同时刷新私有作品列表和公开广场列表,保证发布结果能立即出现在首页与分类页。
`input` 请求体: `input` 请求体:
```json ```json

View File

@@ -3,6 +3,7 @@ export type BigFishWorkStatus = 'draft' | 'published';
export interface BigFishWorkSummary { export interface BigFishWorkSummary {
workId: string; workId: string;
sourceSessionId: string; sourceSessionId: string;
ownerUserId: string;
title: string; title: string;
subtitle: string; subtitle: string;
summary: string; summary: string;

View File

@@ -34,8 +34,8 @@ use crate::{
auth_sessions::auth_sessions, auth_sessions::auth_sessions,
big_fish::{ big_fish::{
create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run, create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run,
get_big_fish_session, get_big_fish_works, start_big_fish_run, stream_big_fish_message, get_big_fish_session, get_big_fish_works, list_big_fish_gallery, start_big_fish_run,
submit_big_fish_input, submit_big_fish_message, stream_big_fish_message, submit_big_fish_input, submit_big_fish_message,
}, },
character_animation_assets::{ character_animation_assets::{
generate_character_animation, get_character_animation_job, get_character_workflow_cache, generate_character_animation, get_character_animation_job, get_character_workflow_cache,
@@ -562,6 +562,7 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth, require_bearer_auth,
)), )),
) )
.route("/api/runtime/big-fish/gallery", get(list_big_fish_gallery))
.route( .route(
"/api/runtime/big-fish/works/{session_id}", "/api/runtime/big-fish/works/{session_id}",
delete(delete_big_fish_work).route_layer(middleware::from_fn_with_state( delete(delete_big_fish_work).route_layer(middleware::from_fn_with_state(

View File

@@ -144,6 +144,29 @@ pub async fn get_big_fish_works(
)) ))
} }
pub async fn list_big_fish_gallery(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
let items = state
.spacetime_client()
.list_big_fish_gallery()
.await
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
BigFishWorksResponse {
items: items
.into_iter()
.map(map_big_fish_work_summary_response)
.collect(),
},
))
}
pub async fn delete_big_fish_work( pub async fn delete_big_fish_work(
State(state): State<AppState>, State(state): State<AppState>,
Path(session_id): Path<String>, Path(session_id): Path<String>,
@@ -917,6 +940,7 @@ fn map_big_fish_work_summary_response(
BigFishWorkSummaryResponse { BigFishWorkSummaryResponse {
work_id: item.work_id, work_id: item.work_id,
source_session_id: item.source_session_id, source_session_id: item.source_session_id,
owner_user_id: item.owner_user_id,
title: item.title, title: item.title,
subtitle: item.subtitle, subtitle: item.subtitle,
summary: item.summary, summary: item.summary,

View File

@@ -257,6 +257,7 @@ pub struct BigFishSessionProcedureResult {
pub struct BigFishWorkSummarySnapshot { pub struct BigFishWorkSummarySnapshot {
pub work_id: String, pub work_id: String,
pub source_session_id: String, pub source_session_id: String,
pub owner_user_id: String,
pub title: String, pub title: String,
pub subtitle: String, pub subtitle: String,
pub summary: String, pub summary: String,
@@ -274,6 +275,7 @@ pub struct BigFishWorkSummarySnapshot {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorksListInput { pub struct BigFishWorksListInput {
pub owner_user_id: String, pub owner_user_id: String,
pub published_only: bool,
} }
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -747,6 +749,9 @@ pub fn validate_session_get_input(input: &BigFishSessionGetInput) -> Result<(),
} }
pub fn validate_works_list_input(input: &BigFishWorksListInput) -> Result<(), BigFishFieldError> { pub fn validate_works_list_input(input: &BigFishWorksListInput) -> Result<(), BigFishFieldError> {
if input.published_only {
return Ok(());
}
if normalize_required_string(&input.owner_user_id).is_none() { if normalize_required_string(&input.owner_user_id).is_none() {
return Err(BigFishFieldError::MissingOwnerUserId); return Err(BigFishFieldError::MissingOwnerUserId);
} }

View File

@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
pub struct BigFishWorkSummaryResponse { pub struct BigFishWorkSummaryResponse {
pub work_id: String, pub work_id: String,
pub source_session_id: String, pub source_session_id: String,
pub owner_user_id: String,
pub title: String, pub title: String,
pub subtitle: String, pub subtitle: String,
pub summary: String, pub summary: String,

View File

@@ -57,8 +57,28 @@ impl SpacetimeClient {
&self, &self,
owner_user_id: String, owner_user_id: String,
) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> { ) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> {
let procedure_input = BigFishWorksListInput { owner_user_id }; let procedure_input = BigFishWorksListInput {
owner_user_id,
published_only: false,
};
self.list_big_fish_works_with_input(procedure_input).await
}
pub async fn list_big_fish_gallery(
&self,
) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> {
self.list_big_fish_works_with_input(BigFishWorksListInput {
owner_user_id: String::new(),
published_only: true,
})
.await
}
async fn list_big_fish_works_with_input(
&self,
procedure_input: BigFishWorksListInput,
) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> {
self.call_after_connect(move |connection, sender| { self.call_after_connect(move |connection, sender| {
connection connection
.procedures() .procedures()

View File

@@ -4590,6 +4590,7 @@ pub struct BigFishSessionRecord {
pub struct BigFishWorkSummaryRecord { pub struct BigFishWorkSummaryRecord {
pub work_id: String, pub work_id: String,
pub source_session_id: String, pub source_session_id: String,
pub owner_user_id: String,
pub title: String, pub title: String,
pub subtitle: String, pub subtitle: String,
pub summary: String, pub summary: String,

View File

@@ -8,6 +8,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[sats(crate = __lib)] #[sats(crate = __lib)]
pub struct BigFishWorksListInput { pub struct BigFishWorksListInput {
pub owner_user_id: String, pub owner_user_id: String,
pub published_only: bool,
} }
impl __sdk::InModule for BigFishWorksListInput { impl __sdk::InModule for BigFishWorksListInput {

View File

@@ -77,8 +77,12 @@ fn start_big_fish_run_tx(
.big_fish_creation_session() .big_fish_creation_session()
.session_id() .session_id()
.find(&input.session_id) .find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
if session.owner_user_id != input.owner_user_id
&& session.stage != BigFishCreationStage::Published
{
return Err("big_fish_creation_session 不存在".to_string());
}
let draft = session let draft = session
.draft_json .draft_json
.as_deref() .as_deref()
@@ -124,8 +128,12 @@ fn submit_big_fish_input_tx(
.big_fish_creation_session() .big_fish_creation_session()
.session_id() .session_id()
.find(&run.session_id) .find(&run.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
if session.owner_user_id != input.owner_user_id
&& session.stage != BigFishCreationStage::Published
{
return Err("big_fish_creation_session 不存在".to_string());
}
let draft = session let draft = session
.draft_json .draft_json
.as_deref() .as_deref()

View File

@@ -239,6 +239,10 @@ pub(crate) fn list_big_fish_works_tx(
.big_fish_creation_session() .big_fish_creation_session()
.iter() .iter()
.filter(|row| { .filter(|row| {
if input.published_only {
return row.stage == BigFishCreationStage::Published;
}
row.owner_user_id == input.owner_user_id && should_include_big_fish_work(ctx, row) row.owner_user_id == input.owner_user_id && should_include_big_fish_work(ctx, row)
}) })
.map(|row| build_big_fish_work_summary(ctx, &row)) .map(|row| build_big_fish_work_summary(ctx, &row))
@@ -330,6 +334,7 @@ pub(crate) fn delete_big_fish_work_tx(
ctx, ctx,
BigFishWorksListInput { BigFishWorksListInput {
owner_user_id: input.owner_user_id, owner_user_id: input.owner_user_id,
published_only: false,
}, },
) )
} }
@@ -642,6 +647,7 @@ pub(crate) fn build_big_fish_work_summary(
Ok(BigFishWorkSummarySnapshot { Ok(BigFishWorkSummarySnapshot {
work_id: format!("big-fish-work-{}", row.session_id), work_id: format!("big-fish-work-{}", row.session_id),
source_session_id: row.session_id.clone(), source_session_id: row.session_id.clone(),
owner_user_id: row.owner_user_id.clone(),
title, title,
subtitle, subtitle,
summary, summary,

View File

@@ -48,6 +48,7 @@ import {
startBigFishRuntimeRun, startBigFishRuntimeRun,
submitBigFishRuntimeInput, submitBigFishRuntimeInput,
} from '../../services/big-fish-runtime'; } from '../../services/big-fish-runtime';
import { listBigFishGallery } from '../../services/big-fish-gallery';
import { import {
deleteBigFishWork, deleteBigFishWork,
listBigFishWorks, listBigFishWorks,
@@ -64,7 +65,10 @@ import {
type MiniGameDraftGenerationState, type MiniGameDraftGenerationState,
} from '../../services/miniGameDraftGenerationProgress'; } from '../../services/miniGameDraftGenerationProgress';
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
import { isSamePuzzlePublicWorkCode } from '../../services/publicWorkCode'; import {
isSameBigFishPublicWorkCode,
isSamePuzzlePublicWorkCode,
} from '../../services/publicWorkCode';
import { import {
createPuzzleAgentSession, createPuzzleAgentSession,
executePuzzleAgentAction, executePuzzleAgentAction,
@@ -91,7 +95,9 @@ import {
import type { CustomWorldProfile } from '../../types'; import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext'; import { useAuthUi } from '../auth/AuthUiContext';
import { import {
isBigFishGalleryEntry,
isPuzzleGalleryEntry, isPuzzleGalleryEntry,
mapBigFishWorkToPlatformGalleryCard,
mapPuzzleWorkToPlatformGalleryCard, mapPuzzleWorkToPlatformGalleryCard,
type PlatformPublicGalleryCard, type PlatformPublicGalleryCard,
} from '../rpg-entry/rpgEntryWorldPresentation'; } from '../rpg-entry/rpgEntryWorldPresentation';
@@ -146,7 +152,12 @@ function getPlatformPublicGalleryEntryTime(entry: PlatformPublicGalleryCard) {
} }
function getPlatformPublicGalleryEntryKey(entry: PlatformPublicGalleryCard) { function getPlatformPublicGalleryEntryKey(entry: PlatformPublicGalleryCard) {
return `${isPuzzleGalleryEntry(entry) ? 'puzzle' : 'rpg'}:${entry.ownerUserId}:${entry.profileId}`; const kind = isBigFishGalleryEntry(entry)
? 'big-fish'
: isPuzzleGalleryEntry(entry)
? 'puzzle'
: 'rpg';
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
} }
function mergePlatformPublicGalleryEntries( function mergePlatformPublicGalleryEntries(
@@ -393,6 +404,9 @@ export function PlatformEntryFlowShellImpl({
const [selectedDetailEntry, setSelectedDetailEntry] = const [selectedDetailEntry, setSelectedDetailEntry] =
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null); useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
const [bigFishWorks, setBigFishWorks] = useState<BigFishWorkSummary[]>([]); const [bigFishWorks, setBigFishWorks] = useState<BigFishWorkSummary[]>([]);
const [bigFishGalleryEntries, setBigFishGalleryEntries] = useState<
BigFishWorkSummary[]
>([]);
const [bigFishRun, setBigFishRun] = const [bigFishRun, setBigFishRun] =
useState<BigFishRuntimeSnapshotResponse | null>(null); useState<BigFishRuntimeSnapshotResponse | null>(null);
const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false); const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false);
@@ -460,6 +474,64 @@ export function PlatformEntryFlowShellImpl({
[], [],
); );
const refreshBigFishShelf = useCallback(async () => {
setIsBigFishLoadingLibrary(true);
try {
const worksResponse = await listBigFishWorks();
setBigFishWorks(worksResponse.items);
setBigFishError(null);
} catch (error) {
setBigFishError(
resolveBigFishErrorMessage(error, '读取大鱼吃小鱼作品列表失败。'),
);
} finally {
setIsBigFishLoadingLibrary(false);
}
}, [resolveBigFishErrorMessage]);
const refreshBigFishGallery = useCallback(async () => {
try {
const galleryResponse = await listBigFishGallery();
setBigFishGalleryEntries(galleryResponse.items);
return galleryResponse.items;
} catch (error) {
setBigFishGalleryEntries([]);
setBigFishError(
resolveBigFishErrorMessage(error, '读取大鱼吃小鱼广场失败。'),
);
return [];
}
}, [resolveBigFishErrorMessage]);
const refreshPuzzleShelf = useCallback(async () => {
setIsPuzzleLoadingLibrary(true);
try {
const worksResponse = await listPuzzleWorks();
setPuzzleWorks(worksResponse.items);
setPuzzleError(null);
} catch (error) {
setPuzzleError(
resolvePuzzleErrorMessage(error, '读取拼图作品列表失败。'),
);
} finally {
setIsPuzzleLoadingLibrary(false);
}
}, [resolvePuzzleErrorMessage]);
const refreshPuzzleGallery = useCallback(async () => {
try {
const galleryResponse = await listPuzzleGallery();
setPuzzleGalleryEntries(galleryResponse.items);
return galleryResponse.items;
} catch (error) {
setPuzzleGalleryEntries([]);
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图广场失败。'));
return [];
}
}, [resolvePuzzleErrorMessage]);
const sessionController = useRpgCreationSessionController({ const sessionController = useRpgCreationSessionController({
userId: authUi?.user?.id, userId: authUi?.user?.id,
openLoginModal: authUi?.openLoginModal, openLoginModal: authUi?.openLoginModal,
@@ -552,6 +624,7 @@ export function PlatformEntryFlowShellImpl({
await Promise.allSettled([ await Promise.allSettled([
platformBootstrap.refreshPublishedGallery(), platformBootstrap.refreshPublishedGallery(),
platformBootstrap.refreshCustomWorldWorks(), platformBootstrap.refreshCustomWorldWorks(),
refreshBigFishGallery(),
refreshPuzzleGallery(), refreshPuzzleGallery(),
]); ]);
return latestSession; return latestSession;
@@ -606,21 +679,35 @@ export function PlatformEntryFlowShellImpl({
}, [agentResultPreview]); }, [agentResultPreview]);
const featuredGalleryEntries = useMemo(() => { const featuredGalleryEntries = useMemo(() => {
const bigFishPublicEntries = bigFishGalleryEntries.map(
mapBigFishWorkToPlatformGalleryCard,
);
const puzzlePublicEntries = puzzleGalleryEntries.map( const puzzlePublicEntries = puzzleGalleryEntries.map(
mapPuzzleWorkToPlatformGalleryCard, mapPuzzleWorkToPlatformGalleryCard,
); );
return mergePlatformPublicGalleryEntries( return mergePlatformPublicGalleryEntries(
platformBootstrap.publishedGalleryEntries, platformBootstrap.publishedGalleryEntries,
puzzlePublicEntries, [...bigFishPublicEntries, ...puzzlePublicEntries],
).slice(0, 6); ).slice(0, 6);
}, [platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries]); }, [
bigFishGalleryEntries,
platformBootstrap.publishedGalleryEntries,
puzzleGalleryEntries,
]);
const latestGalleryEntries = useMemo( const latestGalleryEntries = useMemo(
() => () =>
mergePlatformPublicGalleryEntries( mergePlatformPublicGalleryEntries(
platformBootstrap.publishedGalleryEntries, platformBootstrap.publishedGalleryEntries,
puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard), [
...bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard),
...puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard),
],
), ),
[platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries], [
bigFishGalleryEntries,
platformBootstrap.publishedGalleryEntries,
puzzleGalleryEntries,
],
); );
const creationHubItems = const creationHubItems =
@@ -680,50 +767,6 @@ export function PlatformEntryFlowShellImpl({
setShowCreationTypeModal(true); setShowCreationTypeModal(true);
}, [prepareCreationLaunch]); }, [prepareCreationLaunch]);
const refreshBigFishShelf = useCallback(async () => {
setIsBigFishLoadingLibrary(true);
try {
const worksResponse = await listBigFishWorks();
setBigFishWorks(worksResponse.items);
setBigFishError(null);
} catch (error) {
setBigFishError(
resolveBigFishErrorMessage(error, '读取大鱼吃小鱼作品列表失败。'),
);
} finally {
setIsBigFishLoadingLibrary(false);
}
}, [resolveBigFishErrorMessage]);
const refreshPuzzleShelf = useCallback(async () => {
setIsPuzzleLoadingLibrary(true);
try {
const worksResponse = await listPuzzleWorks();
setPuzzleWorks(worksResponse.items);
setPuzzleError(null);
} catch (error) {
setPuzzleError(
resolvePuzzleErrorMessage(error, '读取拼图作品列表失败。'),
);
} finally {
setIsPuzzleLoadingLibrary(false);
}
}, [resolvePuzzleErrorMessage]);
const refreshPuzzleGallery = useCallback(async () => {
try {
const galleryResponse = await listPuzzleGallery();
setPuzzleGalleryEntries(galleryResponse.items);
return galleryResponse.items;
} catch (error) {
setPuzzleGalleryEntries([]);
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图广场失败。'));
return [];
}
}, [resolvePuzzleErrorMessage]);
const bigFishFlow = usePlatformCreationAgentFlowController< const bigFishFlow = usePlatformCreationAgentFlowController<
BigFishSessionSnapshotResponse, BigFishSessionSnapshotResponse,
Record<string, never>, Record<string, never>,
@@ -761,6 +804,7 @@ export function PlatformEntryFlowShellImpl({
setSession(response.session); setSession(response.session);
if (payload.action === 'big_fish_publish_game') { if (payload.action === 'big_fish_publish_game') {
void refreshBigFishShelf(); void refreshBigFishShelf();
void refreshBigFishGallery();
} }
if (payload.action !== 'big_fish_compile_draft') { if (payload.action !== 'big_fish_compile_draft') {
return; return;
@@ -1081,6 +1125,34 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage, setSelectionStage,
]); ]);
const restartBigFishRun = useCallback(async () => {
const sessionId = bigFishSession?.sessionId ?? bigFishRun?.sessionId;
if (!sessionId || isBigFishBusy) {
return;
}
setIsBigFishBusy(true);
setBigFishError(null);
try {
const { run } = await startBigFishRuntimeRun(sessionId);
setBigFishRun(run);
setSelectionStage('big-fish-runtime');
} catch (error) {
setBigFishError(
resolveBigFishErrorMessage(error, '重新开始大鱼吃小鱼玩法失败。'),
);
} finally {
setIsBigFishBusy(false);
}
}, [
bigFishRun?.sessionId,
bigFishSession?.sessionId,
isBigFishBusy,
resolveBigFishErrorMessage,
setSelectionStage,
]);
const startPuzzleRunFromProfile = useCallback( const startPuzzleRunFromProfile = useCallback(
async (profileId: string) => { async (profileId: string) => {
if (isPuzzleBusy) { if (isPuzzleBusy) {
@@ -1390,8 +1462,9 @@ export function PlatformEntryFlowShellImpl({
setBigFishError(null); setBigFishError(null);
void deleteBigFishWork(work.sourceSessionId) void deleteBigFishWork(work.sourceSessionId)
.then((response) => { .then(async (response) => {
setBigFishWorks(response.items); setBigFishWorks(response.items);
await refreshBigFishGallery().catch(() => []);
}) })
.catch((error) => { .catch((error) => {
setBigFishError( setBigFishError(
@@ -1403,7 +1476,12 @@ export function PlatformEntryFlowShellImpl({
}); });
}); });
}, },
[deletingCreationWorkId, resolveBigFishErrorMessage, runProtectedAction], [
deletingCreationWorkId,
refreshBigFishGallery,
resolveBigFishErrorMessage,
runProtectedAction,
],
); );
const handleDeletePuzzleWork = useCallback( const handleDeletePuzzleWork = useCallback(
@@ -1499,6 +1577,33 @@ export function PlatformEntryFlowShellImpl({
[openPuzzleDetail, puzzleFlow, refreshPuzzleShelf, setPuzzleError], [openPuzzleDetail, puzzleFlow, refreshPuzzleShelf, setPuzzleError],
); );
const startBigFishRunFromWork = useCallback(
async (item: BigFishWorkSummary) => {
const sessionId = item.sourceSessionId?.trim();
if (!sessionId) {
setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。');
return;
}
setIsBigFishBusy(true);
setBigFishError(null);
try {
const { run } = await startBigFishRuntimeRun(sessionId);
bigFishFlow.setSession(null);
setBigFishRun(run);
setSelectionStage('big-fish-runtime');
} catch (error) {
setBigFishError(
resolveBigFishErrorMessage(error, '启动大鱼吃小鱼玩法失败。'),
);
} finally {
setIsBigFishBusy(false);
}
},
[bigFishFlow, resolveBigFishErrorMessage, setSelectionStage],
);
const handlePublicCodeSearch = useCallback( const handlePublicCodeSearch = useCallback(
async (keyword: string) => { async (keyword: string) => {
const normalizedKeyword = keyword.trim(); const normalizedKeyword = keyword.trim();
@@ -1514,15 +1619,19 @@ export function PlatformEntryFlowShellImpl({
const shouldSearchUserIdFirst = /^user[_-][a-z0-9_-]+$/iu.test( const shouldSearchUserIdFirst = /^user[_-][a-z0-9_-]+$/iu.test(
normalizedKeyword, normalizedKeyword,
); );
const shouldSearchBigFishFirst = upperKeyword.startsWith('BF');
const shouldSearchPuzzleFirst = upperKeyword.startsWith('PZ'); const shouldSearchPuzzleFirst = upperKeyword.startsWith('PZ');
const shouldSearchWorkFirst = const shouldSearchWorkFirst =
!shouldSearchUserIdFirst && !shouldSearchUserIdFirst &&
!shouldSearchBigFishFirst &&
!shouldSearchPuzzleFirst && !shouldSearchPuzzleFirst &&
(upperKeyword.startsWith('CW') || /^\d{1,8}$/u.test(normalizedKeyword)); (upperKeyword.startsWith('CW') || /^\d{1,8}$/u.test(normalizedKeyword));
const shouldSearchUserFirst = const shouldSearchUserFirst =
shouldSearchUserIdFirst || shouldSearchUserIdFirst ||
upperKeyword.startsWith('SY') || upperKeyword.startsWith('SY') ||
(!shouldSearchWorkFirst && !shouldSearchPuzzleFirst); (!shouldSearchWorkFirst &&
!shouldSearchBigFishFirst &&
!shouldSearchPuzzleFirst);
const tryOpenGalleryEntry = async () => { const tryOpenGalleryEntry = async () => {
const entry = const entry =
@@ -1562,6 +1671,21 @@ export function PlatformEntryFlowShellImpl({
tab: platformBootstrap.platformTab, tab: platformBootstrap.platformTab,
}); });
}; };
const tryOpenBigFishGalleryEntry = async () => {
const entries =
bigFishGalleryEntries.length > 0
? bigFishGalleryEntries
: await refreshBigFishGallery();
const matchedEntry = entries.find((entry) =>
isSameBigFishPublicWorkCode(normalizedKeyword, entry.sourceSessionId),
);
if (!matchedEntry) {
throw new Error('未找到大鱼吃小鱼作品。');
}
await startBigFishRunFromWork(matchedEntry);
};
try { try {
if (shouldSearchUserIdFirst) { if (shouldSearchUserIdFirst) {
@@ -1575,6 +1699,11 @@ export function PlatformEntryFlowShellImpl({
return; return;
} }
if (shouldSearchBigFishFirst) {
await tryOpenBigFishGalleryEntry();
return;
}
if (shouldSearchWorkFirst) { if (shouldSearchWorkFirst) {
try { try {
await tryOpenGalleryEntry(); await tryOpenGalleryEntry();
@@ -1611,10 +1740,13 @@ export function PlatformEntryFlowShellImpl({
}, },
[ [
detailNavigation, detailNavigation,
bigFishGalleryEntries,
openPuzzleDetail, openPuzzleDetail,
platformBootstrap.platformTab, platformBootstrap.platformTab,
puzzleGalleryEntries, puzzleGalleryEntries,
refreshBigFishGallery,
refreshPuzzleGallery, refreshPuzzleGallery,
startBigFishRunFromWork,
], ],
); );
@@ -1631,39 +1763,12 @@ export function PlatformEntryFlowShellImpl({
[bigFishFlow, refreshBigFishShelf], [bigFishFlow, refreshBigFishShelf],
); );
const startBigFishRunFromWork = useCallback(
async (item: BigFishWorkSummary) => {
const sessionId = item.sourceSessionId?.trim();
if (!sessionId) {
setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。');
return;
}
setIsBigFishBusy(true);
setBigFishError(null);
try {
const { session } = await getBigFishCreationSession(sessionId);
const { run } = await startBigFishRuntimeRun(sessionId);
bigFishFlow.setSession(session);
setBigFishRun(run);
setSelectionStage('big-fish-runtime');
} catch (error) {
setBigFishError(
resolveBigFishErrorMessage(error, '启动大鱼吃小鱼玩法失败。'),
);
} finally {
setIsBigFishBusy(false);
}
},
[bigFishFlow, resolveBigFishErrorMessage, setSelectionStage],
);
useEffect(() => { useEffect(() => {
if (selectionStage === 'platform') { if (selectionStage === 'platform') {
void refreshBigFishGallery();
void refreshPuzzleGallery(); void refreshPuzzleGallery();
} }
}, [refreshPuzzleGallery, selectionStage]); }, [refreshBigFishGallery, refreshPuzzleGallery, selectionStage]);
useEffect(() => { useEffect(() => {
if ( if (
@@ -1836,6 +1941,33 @@ export function PlatformEntryFlowShellImpl({
onOpenCreateWorld={openCreationTypePicker} onOpenCreateWorld={openCreationTypePicker}
onOpenCreateTypePicker={openCreationTypePicker} onOpenCreateTypePicker={openCreationTypePicker}
onOpenGalleryDetail={(entry) => { onOpenGalleryDetail={(entry) => {
if (isBigFishGalleryEntry(entry)) {
runProtectedAction(() => {
void startBigFishRunFromWork({
workId: entry.workId,
sourceSessionId: entry.profileId,
ownerUserId: entry.ownerUserId,
title: entry.worldName,
subtitle: entry.subtitle,
summary: entry.summaryText,
coverImageSrc: entry.coverImageSrc,
status: 'published',
updatedAt: entry.updatedAt,
publishReady: true,
levelCount: Number.parseInt(
entry.themeTags
.find((tag) => /^\d+$/u.test(tag))
?.replace('级', '') ?? '0',
10,
),
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: Boolean(entry.coverImageSrc),
});
});
return;
}
if (isPuzzleGalleryEntry(entry)) { if (isPuzzleGalleryEntry(entry)) {
void openPuzzleDetail(entry.profileId, { void openPuzzleDetail(entry.profileId, {
tab: platformBootstrap.platformTab, tab: platformBootstrap.platformTab,
@@ -2109,10 +2241,12 @@ export function PlatformEntryFlowShellImpl({
isBusy={isBigFishBusy} isBusy={isBigFishBusy}
error={bigFishError} error={bigFishError}
onBack={() => { onBack={() => {
setSelectionStage('big-fish-result'); setSelectionStage(
bigFishSession ? 'big-fish-result' : 'platform',
);
}} }}
onRestart={() => { onRestart={() => {
void startBigFishRun(); void restartBigFishRun();
}} }}
onSubmitInput={submitBigFishInput} onSubmitInput={submitBigFishInput}
/> />
@@ -2128,7 +2262,9 @@ export function PlatformEntryFlowShellImpl({
exit={{ opacity: 0, y: -12 }} exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col" className="flex h-full min-h-0 flex-col"
> >
<Suspense fallback={<LazyPanelFallback label="正在加载拼图创作..." />}> <Suspense
fallback={<LazyPanelFallback label="正在加载拼图创作..." />}
>
<PuzzleAgentWorkspace <PuzzleAgentWorkspace
session={puzzleSession} session={puzzleSession}
activeOperation={puzzleOperation} activeOperation={puzzleOperation}
@@ -2201,7 +2337,9 @@ export function PlatformEntryFlowShellImpl({
exit={{ opacity: 0, y: -12 }} exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col" className="flex h-full min-h-0 flex-col"
> >
<Suspense fallback={<LazyPanelFallback label="正在加载拼图结果..." />}> <Suspense
fallback={<LazyPanelFallback label="正在加载拼图结果..." />}
>
<PuzzleResultView <PuzzleResultView
session={puzzleSession} session={puzzleSession}
author={authUi?.user ?? null} author={authUi?.user ?? null}
@@ -2226,7 +2364,9 @@ export function PlatformEntryFlowShellImpl({
exit={{ opacity: 0, y: -12 }} exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col" className="flex h-full min-h-0 flex-col"
> >
<Suspense fallback={<LazyPanelFallback label="正在加载拼图详情..." />}> <Suspense
fallback={<LazyPanelFallback label="正在加载拼图详情..." />}
>
<PuzzleGalleryDetailView <PuzzleGalleryDetailView
item={selectedPuzzleDetail} item={selectedPuzzleDetail}
isBusy={isPuzzleBusy} isBusy={isPuzzleBusy}
@@ -2249,7 +2389,9 @@ export function PlatformEntryFlowShellImpl({
: null : null
} }
onStartGame={() => { onStartGame={() => {
void startPuzzleRunFromProfile(selectedPuzzleDetail.profileId); void startPuzzleRunFromProfile(
selectedPuzzleDetail.profileId,
);
}} }}
/> />
</Suspense> </Suspense>
@@ -2264,7 +2406,9 @@ export function PlatformEntryFlowShellImpl({
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-[100]" className="fixed inset-0 z-[100]"
> >
<Suspense fallback={<LazyPanelFallback label="正在加载拼图玩法..." />}> <Suspense
fallback={<LazyPanelFallback label="正在加载拼图玩法..." />}
>
<PuzzleRuntimeShell <PuzzleRuntimeShell
run={puzzleRun} run={puzzleRun}
isBusy={isPuzzleBusy || isPuzzleNextLevelGenerating} isBusy={isPuzzleBusy || isPuzzleNextLevelGenerating}

View File

@@ -6,6 +6,7 @@ import { useState } from 'react';
import { beforeEach, expect, test, vi } from 'vitest'; import { beforeEach, expect, test, vi } from 'vitest';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { ApiClientError } from '../../services/apiClient'; import { ApiClientError } from '../../services/apiClient';
@@ -15,6 +16,8 @@ import {
executeBigFishCreationAction, executeBigFishCreationAction,
getBigFishCreationSession, getBigFishCreationSession,
} from '../../services/big-fish-creation'; } from '../../services/big-fish-creation';
import { listBigFishGallery } from '../../services/big-fish-gallery';
import { startBigFishRuntimeRun } from '../../services/big-fish-runtime';
import { listBigFishWorks } from '../../services/big-fish-works'; import { listBigFishWorks } from '../../services/big-fish-works';
import { import {
createPuzzleAgentSession, createPuzzleAgentSession,
@@ -145,6 +148,15 @@ vi.mock('../../services/big-fish-works', () => ({
listBigFishWorks: vi.fn(), listBigFishWorks: vi.fn(),
})); }));
vi.mock('../../services/big-fish-gallery', () => ({
listBigFishGallery: vi.fn(),
}));
vi.mock('../../services/big-fish-runtime', () => ({
startBigFishRuntimeRun: vi.fn(),
submitBigFishRuntimeInput: vi.fn(),
}));
vi.mock('../../services/puzzle-agent', () => ({ vi.mock('../../services/puzzle-agent', () => ({
createPuzzleAgentSession: vi.fn(), createPuzzleAgentSession: vi.fn(),
executePuzzleAgentAction: vi.fn(), executePuzzleAgentAction: vi.fn(),
@@ -152,6 +164,69 @@ vi.mock('../../services/puzzle-agent', () => ({
streamPuzzleAgentMessage: vi.fn(), streamPuzzleAgentMessage: vi.fn(),
})); }));
vi.mock('../puzzle-agent/PuzzleAgentWorkspace', () => ({
PuzzleAgentWorkspace: ({
session,
onBack,
}: {
session: { sessionId: string; messages: Array<{ text: string }> } | null;
onBack: () => void;
}) => (
<div className="puzzle-agent-workspace-mock">
<div>{session?.sessionId ?? 'missing-session'}</div>
{session?.messages.map((message) => (
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
))}
<button type="button" onClick={onBack}>
</button>
</div>
),
}));
vi.mock('../puzzle-result/PuzzleResultView', () => ({
PuzzleResultView: ({
session,
onBack,
}: {
session: { draft?: { levelName: string } | null };
onBack: () => void;
}) => (
<div className="puzzle-result-view-mock">
<div></div>
<label>
<input readOnly value={session.draft?.levelName ?? ''} />
</label>
<button type="button" onClick={onBack}>
</button>
</div>
),
}));
vi.mock('../puzzle-gallery/PuzzleGalleryDetailView', () => ({
PuzzleGalleryDetailView: ({
item,
onBack,
onStartGame,
}: {
item: { levelName: string };
onBack: () => void;
onStartGame: () => void;
}) => (
<div className="puzzle-gallery-detail-view-mock">
<div>{item.levelName}</div>
<button type="button" onClick={onStartGame}>
1
</button>
<button type="button" onClick={onBack}>
</button>
</div>
),
}));
vi.mock('../big-fish-creation/BigFishAgentWorkspace', () => ({ vi.mock('../big-fish-creation/BigFishAgentWorkspace', () => ({
BigFishAgentWorkspace: ({ BigFishAgentWorkspace: ({
session, session,
@@ -232,8 +307,7 @@ const mockSession: CustomWorldAgentSessionSnapshot = {
'玩家以返乡守灯人继承者身份切入,回港首夜撞见禁航区假航灯重亮,动机是阻止更多船只误入死潮。', '玩家以返乡守灯人继承者身份切入,回港首夜撞见禁航区假航灯重亮,动机是阻止更多船只误入死潮。',
coreConflict: coreConflict:
'守灯会与航运公会争夺航路解释权,有人在借假航灯持续清洗旧案证据,玩家返乡当夜就被卷进封航冲突。', '守灯会与航运公会争夺航路解释权,有人在借假航灯持续清洗旧案证据,玩家返乡当夜就被卷进封航冲突。',
keyRelationships: keyRelationships: '玩家与沈砺旧友互疑,沈砺知道沉船夜的另一半真相。',
'玩家与沈砺旧友互疑,沈砺知道沉船夜的另一半真相。',
hiddenLines: hiddenLines:
'沉船夜与假航灯骗局属于同一操盘链条,表面像海雾自然失控,揭示节奏是先见异常,再见旧案,再见操盘者。', '沉船夜与假航灯骗局属于同一操盘链条,表面像海雾自然失控,揭示节奏是先见异常,再见旧案,再见操盘者。',
iconicElements: iconicElements:
@@ -956,6 +1030,34 @@ beforeEach(() => {
vi.mocked(listBigFishWorks).mockResolvedValue({ vi.mocked(listBigFishWorks).mockResolvedValue({
items: [], items: [],
}); });
vi.mocked(listBigFishGallery).mockResolvedValue({
items: [],
});
vi.mocked(startBigFishRuntimeRun).mockResolvedValue({
run: {
runId: 'big-fish-run-1',
sessionId: 'big-fish-session-public-1',
status: 'running',
tick: 0,
playerLevel: 1,
winLevel: 8,
leaderEntityId: 'owned-1',
ownedEntities: [
{
entityId: 'owned-1',
level: 1,
position: { x: 0, y: 0 },
radius: 12,
offscreenSeconds: 0,
},
],
wildEntities: [],
cameraCenter: { x: 0, y: 0 },
lastInput: { x: 0, y: 0 },
eventLog: ['机械鱼群开始巡游。'],
updatedAt: '2026-04-25T12:12:00.000Z',
},
});
vi.mocked(listPuzzleWorks).mockResolvedValue({ vi.mocked(listPuzzleWorks).mockResolvedValue({
items: [], items: [],
}); });
@@ -1329,6 +1431,7 @@ test('creation hub clears all private work shelves immediately after logout stat
{ {
workId: 'big-fish-logout-cache-1', workId: 'big-fish-logout-cache-1',
sourceSessionId: 'big-fish-logout-cache-session', sourceSessionId: 'big-fish-logout-cache-session',
ownerUserId: 'user-1',
title: '大鱼退出缓存作品', title: '大鱼退出缓存作品',
subtitle: '登出后不应继续可见', subtitle: '登出后不应继续可见',
summary: '这条大鱼私有作品只能在登录态展示。', summary: '这条大鱼私有作品只能在登录态展示。',
@@ -1427,6 +1530,48 @@ test('published puzzle works appear on home and category public shelves', async
).toBeGreaterThan(0); ).toBeGreaterThan(0);
}); });
test('published big fish works appear on home and category public shelves', async () => {
const user = userEvent.setup();
const publishedBigFishWork: BigFishWorkSummary = {
workId: 'big-fish-work-public-1',
sourceSessionId: 'big-fish-session-public-1',
ownerUserId: 'user-2',
title: '机械深海 大鱼吃小鱼',
subtitle: '机械微生物吞并进化',
summary: '从微光孢子一路吞并成长到深海巨鲲。',
coverImageSrc: null,
status: 'published',
updatedAt: '2026-04-25T10:30:00.000Z',
publishReady: true,
levelCount: 8,
levelMainImageReadyCount: 8,
levelMotionReadyCount: 16,
backgroundReady: true,
};
vi.mocked(listBigFishGallery).mockResolvedValue({
items: [publishedBigFishWork],
});
render(<TestWrapper />);
await waitFor(() => {
expect(screen.getAllByText('机械深海 大鱼吃小鱼').length).toBeGreaterThan(
0,
);
});
await user.click(screen.getByRole('button', { name: '分类' }));
const categoryPanel = getPlatformTabPanel('category');
expect(
within(categoryPanel).getAllByText('机械深海 大鱼吃小鱼').length,
).toBeGreaterThan(0);
expect(
within(categoryPanel).getAllByRole('button', { name: //u }).length,
).toBeGreaterThan(0);
});
test('published puzzle detail returns to the source platform tab', async () => { test('published puzzle detail returns to the source platform tab', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const publishedPuzzleWork = { const publishedPuzzleWork = {
@@ -1797,8 +1942,9 @@ test('public code search opens a published puzzle by PZ code', async () => {
render(<TestWrapper withAuth />); render(<TestWrapper withAuth />);
const searchInput = const searchInput = await screen.findByPlaceholderText(
await screen.findByPlaceholderText('输入 SY / CW / PZ 编号'); '输入 SY / CW / BF / PZ 编号',
);
await user.type(searchInput, 'PZ-EPUBLIC1'); await user.type(searchInput, 'PZ-EPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' })); await user.click(screen.getByRole('button', { name: '搜索' }));
@@ -1812,6 +1958,49 @@ test('public code search opens a published puzzle by PZ code', async () => {
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled(); expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
}); });
test('public code search opens a published big fish work by BF code', async () => {
const user = userEvent.setup();
const bigFishWork: BigFishWorkSummary = {
workId: 'big-fish-work-public-1',
sourceSessionId: 'big-fish-session-public-1',
ownerUserId: 'user-2',
title: '机械深海 大鱼吃小鱼',
subtitle: '机械微生物吞并进化',
summary: '从微光孢子一路吞并成长到深海巨鲲。',
coverImageSrc: null,
status: 'published',
updatedAt: '2026-04-25T10:30:00.000Z',
publishReady: true,
levelCount: 8,
levelMainImageReadyCount: 8,
levelMotionReadyCount: 16,
backgroundReady: true,
};
vi.mocked(listBigFishGallery).mockResolvedValue({
items: [bigFishWork],
});
render(<TestWrapper withAuth />);
const searchInput = await screen.findByPlaceholderText(
'输入 SY / CW / BF / PZ 编号',
);
await user.type(searchInput, 'BF-NPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
await waitFor(() => {
expect(startBigFishRuntimeRun).toHaveBeenCalledWith(
'big-fish-session-public-1',
);
});
expect(await screen.findByText('Lv.1/8 · 进行中')).toBeTruthy();
expect(getBigFishCreationSession).not.toHaveBeenCalledWith(
'big-fish-session-public-1',
);
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
});
test('big fish draft card restores the bound agent session and opens the result view', async () => { test('big fish draft card restores the bound agent session and opens the result view', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
@@ -1820,6 +2009,7 @@ test('big fish draft card restores the bound agent session and opens the result
{ {
workId: 'big-fish-work-big-fish-session-1', workId: 'big-fish-work-big-fish-session-1',
sourceSessionId: 'big-fish-session-1', sourceSessionId: 'big-fish-session-1',
ownerUserId: 'user-1',
title: '机械深海 大鱼吃小鱼', title: '机械深海 大鱼吃小鱼',
subtitle: '机械微生物吞并进化 · 偏爽快节奏', subtitle: '机械微生物吞并进化 · 偏爽快节奏',
summary: '机械微生物吞并进化', summary: '机械微生物吞并进化',
@@ -1867,10 +2057,12 @@ test('big fish draft card restores the bound agent session and opens the result
test('big fish result publish action refreshes creation works', async () => { test('big fish result publish action refreshes creation works', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const baseBigFishSession = (await getBigFishCreationSession('big-fish-session-1')) const baseBigFishSession = (
.session; await getBigFishCreationSession('big-fish-session-1')
).session;
vi.mocked(getBigFishCreationSession).mockClear(); vi.mocked(getBigFishCreationSession).mockClear();
vi.mocked(listBigFishWorks).mockClear(); vi.mocked(listBigFishWorks).mockClear();
vi.mocked(listBigFishGallery).mockClear();
const publishedBigFishSession = { const publishedBigFishSession = {
...baseBigFishSession, ...baseBigFishSession,
stage: 'published', stage: 'published',
@@ -1894,6 +2086,7 @@ test('big fish result publish action refreshes creation works', async () => {
{ {
workId: 'big-fish-work-big-fish-session-1', workId: 'big-fish-work-big-fish-session-1',
sourceSessionId: 'big-fish-session-1', sourceSessionId: 'big-fish-session-1',
ownerUserId: 'user-1',
title: '机械深海 大鱼吃小鱼', title: '机械深海 大鱼吃小鱼',
subtitle: '机械微生物吞并进化 · 偏爽快节奏', subtitle: '机械微生物吞并进化 · 偏爽快节奏',
summary: '机械微生物吞并进化', summary: '机械微生物吞并进化',
@@ -1913,6 +2106,7 @@ test('big fish result publish action refreshes creation works', async () => {
{ {
workId: 'big-fish-work-big-fish-session-1', workId: 'big-fish-work-big-fish-session-1',
sourceSessionId: 'big-fish-session-1', sourceSessionId: 'big-fish-session-1',
ownerUserId: 'user-1',
title: '机械深海 大鱼吃小鱼', title: '机械深海 大鱼吃小鱼',
subtitle: '机械微生物吞并进化 · 偏爽快节奏', subtitle: '机械微生物吞并进化 · 偏爽快节奏',
summary: '机械微生物吞并进化', summary: '机械微生物吞并进化',
@@ -1959,6 +2153,9 @@ test('big fish result publish action refreshes creation works', async () => {
await waitFor(() => { await waitFor(() => {
expect(listBigFishWorks).toHaveBeenCalled(); expect(listBigFishWorks).toHaveBeenCalled();
}); });
await waitFor(() => {
expect(listBigFishGallery).toHaveBeenCalled();
});
}); });
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => { test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {

View File

@@ -5,7 +5,10 @@ import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest'; import { afterEach, expect, test, vi } from 'vitest';
import { AuthUiContext } from '../auth/AuthUiContext'; import { AuthUiContext } from '../auth/AuthUiContext';
import { RpgEntryHomeView, type RpgEntryHomeViewProps } from './RpgEntryHomeView'; import {
RpgEntryHomeView,
type RpgEntryHomeViewProps,
} from './RpgEntryHomeView';
import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation'; import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation';
vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
@@ -343,7 +346,9 @@ test('mobile home search submits public work code', async () => {
</AuthUiContext.Provider>, </AuthUiContext.Provider>,
); );
const searchInput = screen.getByPlaceholderText('输入 SY / CW / PZ 编号'); const searchInput = screen.getByPlaceholderText(
'输入 SY / CW / BF / PZ 编号',
);
await user.type(searchInput, 'PZ-PROFILE1{enter}'); await user.type(searchInput, 'PZ-PROFILE1{enter}');
expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1'); expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1');
@@ -359,8 +364,9 @@ test('public gallery cards hide work code until detail is opened', async () => {
}); });
expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull(); expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull();
expect(screen.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' })) expect(
.toBeNull(); screen.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }),
).toBeNull();
await user.click(screen.getByRole('button', { name: //u })); await user.click(screen.getByRole('button', { name: //u }));

View File

@@ -56,6 +56,7 @@ import {
buildPlatformWorldTags, buildPlatformWorldTags,
describePlatformThemeLabel, describePlatformThemeLabel,
formatPlatformWorldTime, formatPlatformWorldTime,
isBigFishGalleryEntry,
isPuzzleGalleryEntry, isPuzzleGalleryEntry,
type PlatformPublicGalleryCard, type PlatformPublicGalleryCard,
type PlatformWorldCardLike, type PlatformWorldCardLike,
@@ -223,7 +224,7 @@ function PublicCodeSearchBar({
onSubmit(); onSubmit();
} }
}} }}
placeholder="输入 SY / CW / PZ 编号" placeholder="输入 SY / CW / BF / PZ 编号"
className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]" className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
/> />
<button <button
@@ -665,9 +666,11 @@ function DesktopTrendingItem({
)) ))
) : ( ) : (
<span className="platform-pill platform-pill--neutral px-2.5"> <span className="platform-pill platform-pill--neutral px-2.5">
{isPuzzleGalleryEntry(entry) {isBigFishGalleryEntry(entry)
? '拼图' ? '大鱼'
: describePlatformThemeLabel(entry.themeMode)} : isPuzzleGalleryEntry(entry)
? '拼图'
: describePlatformThemeLabel(entry.themeMode)}
</span> </span>
)} )}
</div> </div>
@@ -714,13 +717,20 @@ function buildPublicCategoryGroups(
} }
function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) { function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
return `${isPuzzleGalleryEntry(entry) ? 'puzzle' : 'rpg'}:${entry.ownerUserId}:${entry.profileId}`; const kind = isBigFishGalleryEntry(entry)
? 'big-fish'
: isPuzzleGalleryEntry(entry)
? 'puzzle'
: 'rpg';
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
} }
function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) { function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
return isPuzzleGalleryEntry(entry) return isBigFishGalleryEntry(entry)
? '拼图' ? '大鱼'
: describePlatformThemeLabel(entry.themeMode); : isPuzzleGalleryEntry(entry)
? '拼图'
: describePlatformThemeLabel(entry.themeMode);
} }
function formatSnapshotTime(value: string | null | undefined) { function formatSnapshotTime(value: string | null | undefined) {

View File

@@ -2,15 +2,20 @@ import type {
CustomWorldGalleryCard, CustomWorldGalleryCard,
CustomWorldLibraryEntry, CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime'; } from '../../../packages/shared/src/contracts/runtime';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals'; import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode'; import {
buildBigFishPublicWorkCode,
buildPuzzlePublicWorkCode,
} from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types'; import type { CustomWorldProfile } from '../../types';
export type PlatformWorldCardLike = export type PlatformWorldCardLike =
| CustomWorldGalleryCard | CustomWorldGalleryCard
| CustomWorldLibraryEntry<CustomWorldProfile> | CustomWorldLibraryEntry<CustomWorldProfile>
| PlatformBigFishGalleryCard
| PlatformPuzzleGalleryCard; | PlatformPuzzleGalleryCard;
export type PlatformPuzzleGalleryCard = { export type PlatformPuzzleGalleryCard = {
@@ -30,8 +35,26 @@ export type PlatformPuzzleGalleryCard = {
updatedAt: string; updatedAt: string;
}; };
export type PlatformBigFishGalleryCard = {
sourceType: 'big-fish';
workId: string;
profileId: string;
publicWorkCode: string;
ownerUserId: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeTags: string[];
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
};
export type PlatformPublicGalleryCard = export type PlatformPublicGalleryCard =
| CustomWorldGalleryCard | CustomWorldGalleryCard
| PlatformBigFishGalleryCard
| PlatformPuzzleGalleryCard; | PlatformPuzzleGalleryCard;
export function isLibraryWorldEntry( export function isLibraryWorldEntry(
@@ -46,6 +69,12 @@ export function isPuzzleGalleryEntry(
return 'sourceType' in entry && entry.sourceType === 'puzzle'; return 'sourceType' in entry && entry.sourceType === 'puzzle';
} }
export function isBigFishGalleryEntry(
entry: PlatformWorldCardLike,
): entry is PlatformBigFishGalleryCard {
return 'sourceType' in entry && entry.sourceType === 'big-fish';
}
export function mapPuzzleWorkToPlatformGalleryCard( export function mapPuzzleWorkToPlatformGalleryCard(
work: PuzzleWorkSummary, work: PuzzleWorkSummary,
): PlatformPuzzleGalleryCard { ): PlatformPuzzleGalleryCard {
@@ -67,6 +96,27 @@ export function mapPuzzleWorkToPlatformGalleryCard(
}; };
} }
export function mapBigFishWorkToPlatformGalleryCard(
work: BigFishWorkSummary,
): PlatformBigFishGalleryCard {
return {
sourceType: 'big-fish',
workId: work.workId,
profileId: work.sourceSessionId,
publicWorkCode: buildBigFishPublicWorkCode(work.sourceSessionId),
ownerUserId: work.ownerUserId,
authorDisplayName: '大鱼创作者',
worldName: work.title,
subtitle: work.subtitle || '大鱼吃小鱼',
summaryText: work.summary,
coverImageSrc: work.coverImageSrc,
themeTags: ['大鱼', `${work.levelCount}`],
visibility: 'published',
publishedAt: work.updatedAt,
updatedAt: work.updatedAt,
};
}
export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) { export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) {
if (entry.coverImageSrc) { if (entry.coverImageSrc) {
return entry.coverImageSrc; return entry.coverImageSrc;
@@ -88,6 +138,10 @@ export function resolvePlatformWorldLeadPortrait(entry: PlatformWorldCardLike) {
} }
export function buildPlatformWorldTags(entry: PlatformWorldCardLike) { export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
if (isBigFishGalleryEntry(entry)) {
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['大鱼'];
}
if (isPuzzleGalleryEntry(entry)) { if (isPuzzleGalleryEntry(entry)) {
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['拼图']; return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['拼图'];
} }
@@ -128,6 +182,10 @@ export function formatPlatformWorldTime(value: string | null) {
export function resolvePlatformPublicWorkCode( export function resolvePlatformPublicWorkCode(
entry: PlatformWorldCardLike, entry: PlatformWorldCardLike,
): string | null { ): string | null {
if (isBigFishGalleryEntry(entry)) {
return entry.publicWorkCode;
}
if (isPuzzleGalleryEntry(entry)) { if (isPuzzleGalleryEntry(entry)) {
return entry.publicWorkCode; return entry.publicWorkCode;
} }

View File

@@ -0,0 +1,29 @@
import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import { type ApiRetryOptions, requestJson } from '../apiClient';
const BIG_FISH_GALLERY_API_BASE = '/api/runtime/big-fish/gallery';
const BIG_FISH_GALLERY_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
};
/**
* 读取大鱼吃小鱼公开广场列表。
*/
export async function listBigFishGallery() {
return requestJson<BigFishWorksResponse>(
BIG_FISH_GALLERY_API_BASE,
{
method: 'GET',
},
'读取大鱼吃小鱼广场失败',
{
retry: BIG_FISH_GALLERY_READ_RETRY,
},
);
}
export const bigFishGalleryClient = {
list: listBigFishGallery,
};

View File

@@ -0,0 +1,4 @@
export {
bigFishGalleryClient,
listBigFishGallery,
} from './bigFishGalleryClient';

View File

@@ -13,6 +13,14 @@ export function buildPuzzlePublicWorkCode(profileId: string) {
return `PZ-${suffix}`; return `PZ-${suffix}`;
} }
export function buildBigFishPublicWorkCode(sessionId: string) {
const normalized = normalizePublicCodeText(sessionId);
const fallback = normalized || '00000000';
const suffix = fallback.slice(-8).padStart(8, '0');
return `BF-${suffix}`;
}
export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) { export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
const normalizedKeyword = normalizePublicCodeText(keyword); const normalizedKeyword = normalizePublicCodeText(keyword);
@@ -22,3 +30,16 @@ export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
normalizedKeyword === normalizePublicCodeText(profileId) normalizedKeyword === normalizePublicCodeText(profileId)
); );
} }
export function isSameBigFishPublicWorkCode(
keyword: string,
sessionId: string,
) {
const normalizedKeyword = normalizePublicCodeText(keyword);
return (
normalizedKeyword ===
normalizePublicCodeText(buildBigFishPublicWorkCode(sessionId)) ||
normalizedKeyword === normalizePublicCodeText(sessionId)
);
}