fix: show published big fish works in gallery
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -147,7 +147,7 @@
|
||||
|
||||
## 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}`
|
||||
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 作品列表
|
||||
|
||||
1. `GET /api/runtime/big-fish/works`
|
||||
|
||||
开发态 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` 请求体:
|
||||
|
||||
```json
|
||||
|
||||
@@ -3,6 +3,7 @@ export type BigFishWorkStatus = 'draft' | 'published';
|
||||
export interface BigFishWorkSummary {
|
||||
workId: string;
|
||||
sourceSessionId: string;
|
||||
ownerUserId: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
summary: string;
|
||||
|
||||
@@ -34,8 +34,8 @@ use crate::{
|
||||
auth_sessions::auth_sessions,
|
||||
big_fish::{
|
||||
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,
|
||||
submit_big_fish_input, submit_big_fish_message,
|
||||
get_big_fish_session, get_big_fish_works, list_big_fish_gallery, start_big_fish_run,
|
||||
stream_big_fish_message, submit_big_fish_input, submit_big_fish_message,
|
||||
},
|
||||
character_animation_assets::{
|
||||
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,
|
||||
)),
|
||||
)
|
||||
.route("/api/runtime/big-fish/gallery", get(list_big_fish_gallery))
|
||||
.route(
|
||||
"/api/runtime/big-fish/works/{session_id}",
|
||||
delete(delete_big_fish_work).route_layer(middleware::from_fn_with_state(
|
||||
|
||||
@@ -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(
|
||||
State(state): State<AppState>,
|
||||
Path(session_id): Path<String>,
|
||||
@@ -917,6 +940,7 @@ fn map_big_fish_work_summary_response(
|
||||
BigFishWorkSummaryResponse {
|
||||
work_id: item.work_id,
|
||||
source_session_id: item.source_session_id,
|
||||
owner_user_id: item.owner_user_id,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
summary: item.summary,
|
||||
|
||||
@@ -257,6 +257,7 @@ pub struct BigFishSessionProcedureResult {
|
||||
pub struct BigFishWorkSummarySnapshot {
|
||||
pub work_id: String,
|
||||
pub source_session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub summary: String,
|
||||
@@ -274,6 +275,7 @@ pub struct BigFishWorkSummarySnapshot {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishWorksListInput {
|
||||
pub owner_user_id: String,
|
||||
pub published_only: bool,
|
||||
}
|
||||
|
||||
#[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> {
|
||||
if input.published_only {
|
||||
return Ok(());
|
||||
}
|
||||
if normalize_required_string(&input.owner_user_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingOwnerUserId);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
|
||||
pub struct BigFishWorkSummaryResponse {
|
||||
pub work_id: String,
|
||||
pub source_session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub summary: String,
|
||||
|
||||
@@ -57,8 +57,28 @@ impl SpacetimeClient {
|
||||
&self,
|
||||
owner_user_id: String,
|
||||
) -> 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| {
|
||||
connection
|
||||
.procedures()
|
||||
|
||||
@@ -4590,6 +4590,7 @@ pub struct BigFishSessionRecord {
|
||||
pub struct BigFishWorkSummaryRecord {
|
||||
pub work_id: String,
|
||||
pub source_session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub summary: String,
|
||||
|
||||
@@ -8,6 +8,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
#[sats(crate = __lib)]
|
||||
pub struct BigFishWorksListInput {
|
||||
pub owner_user_id: String,
|
||||
pub published_only: bool,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for BigFishWorksListInput {
|
||||
|
||||
@@ -77,8 +77,12 @@ fn start_big_fish_run_tx(
|
||||
.big_fish_creation_session()
|
||||
.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())?;
|
||||
if session.owner_user_id != input.owner_user_id
|
||||
&& session.stage != BigFishCreationStage::Published
|
||||
{
|
||||
return Err("big_fish_creation_session 不存在".to_string());
|
||||
}
|
||||
let draft = session
|
||||
.draft_json
|
||||
.as_deref()
|
||||
@@ -124,8 +128,12 @@ fn submit_big_fish_input_tx(
|
||||
.big_fish_creation_session()
|
||||
.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())?;
|
||||
if session.owner_user_id != input.owner_user_id
|
||||
&& session.stage != BigFishCreationStage::Published
|
||||
{
|
||||
return Err("big_fish_creation_session 不存在".to_string());
|
||||
}
|
||||
let draft = session
|
||||
.draft_json
|
||||
.as_deref()
|
||||
|
||||
@@ -239,6 +239,10 @@ pub(crate) fn list_big_fish_works_tx(
|
||||
.big_fish_creation_session()
|
||||
.iter()
|
||||
.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)
|
||||
})
|
||||
.map(|row| build_big_fish_work_summary(ctx, &row))
|
||||
@@ -330,6 +334,7 @@ pub(crate) fn delete_big_fish_work_tx(
|
||||
ctx,
|
||||
BigFishWorksListInput {
|
||||
owner_user_id: input.owner_user_id,
|
||||
published_only: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -642,6 +647,7 @@ pub(crate) fn build_big_fish_work_summary(
|
||||
Ok(BigFishWorkSummarySnapshot {
|
||||
work_id: format!("big-fish-work-{}", row.session_id),
|
||||
source_session_id: row.session_id.clone(),
|
||||
owner_user_id: row.owner_user_id.clone(),
|
||||
title,
|
||||
subtitle,
|
||||
summary,
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
startBigFishRuntimeRun,
|
||||
submitBigFishRuntimeInput,
|
||||
} from '../../services/big-fish-runtime';
|
||||
import { listBigFishGallery } from '../../services/big-fish-gallery';
|
||||
import {
|
||||
deleteBigFishWork,
|
||||
listBigFishWorks,
|
||||
@@ -64,7 +65,10 @@ import {
|
||||
type MiniGameDraftGenerationState,
|
||||
} from '../../services/miniGameDraftGenerationProgress';
|
||||
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
||||
import { isSamePuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||
import {
|
||||
isSameBigFishPublicWorkCode,
|
||||
isSamePuzzlePublicWorkCode,
|
||||
} from '../../services/publicWorkCode';
|
||||
import {
|
||||
createPuzzleAgentSession,
|
||||
executePuzzleAgentAction,
|
||||
@@ -91,7 +95,9 @@ import {
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import {
|
||||
isBigFishGalleryEntry,
|
||||
isPuzzleGalleryEntry,
|
||||
mapBigFishWorkToPlatformGalleryCard,
|
||||
mapPuzzleWorkToPlatformGalleryCard,
|
||||
type PlatformPublicGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
@@ -146,7 +152,12 @@ function getPlatformPublicGalleryEntryTime(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(
|
||||
@@ -393,6 +404,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
const [selectedDetailEntry, setSelectedDetailEntry] =
|
||||
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
|
||||
const [bigFishWorks, setBigFishWorks] = useState<BigFishWorkSummary[]>([]);
|
||||
const [bigFishGalleryEntries, setBigFishGalleryEntries] = useState<
|
||||
BigFishWorkSummary[]
|
||||
>([]);
|
||||
const [bigFishRun, setBigFishRun] =
|
||||
useState<BigFishRuntimeSnapshotResponse | null>(null);
|
||||
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({
|
||||
userId: authUi?.user?.id,
|
||||
openLoginModal: authUi?.openLoginModal,
|
||||
@@ -552,6 +624,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
await Promise.allSettled([
|
||||
platformBootstrap.refreshPublishedGallery(),
|
||||
platformBootstrap.refreshCustomWorldWorks(),
|
||||
refreshBigFishGallery(),
|
||||
refreshPuzzleGallery(),
|
||||
]);
|
||||
return latestSession;
|
||||
@@ -606,21 +679,35 @@ export function PlatformEntryFlowShellImpl({
|
||||
}, [agentResultPreview]);
|
||||
|
||||
const featuredGalleryEntries = useMemo(() => {
|
||||
const bigFishPublicEntries = bigFishGalleryEntries.map(
|
||||
mapBigFishWorkToPlatformGalleryCard,
|
||||
);
|
||||
const puzzlePublicEntries = puzzleGalleryEntries.map(
|
||||
mapPuzzleWorkToPlatformGalleryCard,
|
||||
);
|
||||
return mergePlatformPublicGalleryEntries(
|
||||
platformBootstrap.publishedGalleryEntries,
|
||||
puzzlePublicEntries,
|
||||
[...bigFishPublicEntries, ...puzzlePublicEntries],
|
||||
).slice(0, 6);
|
||||
}, [platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries]);
|
||||
}, [
|
||||
bigFishGalleryEntries,
|
||||
platformBootstrap.publishedGalleryEntries,
|
||||
puzzleGalleryEntries,
|
||||
]);
|
||||
const latestGalleryEntries = useMemo(
|
||||
() =>
|
||||
mergePlatformPublicGalleryEntries(
|
||||
platformBootstrap.publishedGalleryEntries,
|
||||
puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard),
|
||||
[
|
||||
...bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard),
|
||||
...puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard),
|
||||
],
|
||||
),
|
||||
[platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries],
|
||||
[
|
||||
bigFishGalleryEntries,
|
||||
platformBootstrap.publishedGalleryEntries,
|
||||
puzzleGalleryEntries,
|
||||
],
|
||||
);
|
||||
|
||||
const creationHubItems =
|
||||
@@ -680,50 +767,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
setShowCreationTypeModal(true);
|
||||
}, [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<
|
||||
BigFishSessionSnapshotResponse,
|
||||
Record<string, never>,
|
||||
@@ -761,6 +804,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSession(response.session);
|
||||
if (payload.action === 'big_fish_publish_game') {
|
||||
void refreshBigFishShelf();
|
||||
void refreshBigFishGallery();
|
||||
}
|
||||
if (payload.action !== 'big_fish_compile_draft') {
|
||||
return;
|
||||
@@ -1081,6 +1125,34 @@ export function PlatformEntryFlowShellImpl({
|
||||
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(
|
||||
async (profileId: string) => {
|
||||
if (isPuzzleBusy) {
|
||||
@@ -1390,8 +1462,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setBigFishError(null);
|
||||
|
||||
void deleteBigFishWork(work.sourceSessionId)
|
||||
.then((response) => {
|
||||
.then(async (response) => {
|
||||
setBigFishWorks(response.items);
|
||||
await refreshBigFishGallery().catch(() => []);
|
||||
})
|
||||
.catch((error) => {
|
||||
setBigFishError(
|
||||
@@ -1403,7 +1476,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
});
|
||||
});
|
||||
},
|
||||
[deletingCreationWorkId, resolveBigFishErrorMessage, runProtectedAction],
|
||||
[
|
||||
deletingCreationWorkId,
|
||||
refreshBigFishGallery,
|
||||
resolveBigFishErrorMessage,
|
||||
runProtectedAction,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDeletePuzzleWork = useCallback(
|
||||
@@ -1499,6 +1577,33 @@ export function PlatformEntryFlowShellImpl({
|
||||
[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(
|
||||
async (keyword: string) => {
|
||||
const normalizedKeyword = keyword.trim();
|
||||
@@ -1514,15 +1619,19 @@ export function PlatformEntryFlowShellImpl({
|
||||
const shouldSearchUserIdFirst = /^user[_-][a-z0-9_-]+$/iu.test(
|
||||
normalizedKeyword,
|
||||
);
|
||||
const shouldSearchBigFishFirst = upperKeyword.startsWith('BF');
|
||||
const shouldSearchPuzzleFirst = upperKeyword.startsWith('PZ');
|
||||
const shouldSearchWorkFirst =
|
||||
!shouldSearchUserIdFirst &&
|
||||
!shouldSearchBigFishFirst &&
|
||||
!shouldSearchPuzzleFirst &&
|
||||
(upperKeyword.startsWith('CW') || /^\d{1,8}$/u.test(normalizedKeyword));
|
||||
const shouldSearchUserFirst =
|
||||
shouldSearchUserIdFirst ||
|
||||
upperKeyword.startsWith('SY') ||
|
||||
(!shouldSearchWorkFirst && !shouldSearchPuzzleFirst);
|
||||
(!shouldSearchWorkFirst &&
|
||||
!shouldSearchBigFishFirst &&
|
||||
!shouldSearchPuzzleFirst);
|
||||
|
||||
const tryOpenGalleryEntry = async () => {
|
||||
const entry =
|
||||
@@ -1562,6 +1671,21 @@ export function PlatformEntryFlowShellImpl({
|
||||
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 {
|
||||
if (shouldSearchUserIdFirst) {
|
||||
@@ -1575,6 +1699,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldSearchBigFishFirst) {
|
||||
await tryOpenBigFishGalleryEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldSearchWorkFirst) {
|
||||
try {
|
||||
await tryOpenGalleryEntry();
|
||||
@@ -1611,10 +1740,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
},
|
||||
[
|
||||
detailNavigation,
|
||||
bigFishGalleryEntries,
|
||||
openPuzzleDetail,
|
||||
platformBootstrap.platformTab,
|
||||
puzzleGalleryEntries,
|
||||
refreshBigFishGallery,
|
||||
refreshPuzzleGallery,
|
||||
startBigFishRunFromWork,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1631,39 +1763,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
[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(() => {
|
||||
if (selectionStage === 'platform') {
|
||||
void refreshBigFishGallery();
|
||||
void refreshPuzzleGallery();
|
||||
}
|
||||
}, [refreshPuzzleGallery, selectionStage]);
|
||||
}, [refreshBigFishGallery, refreshPuzzleGallery, selectionStage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -1836,6 +1941,33 @@ export function PlatformEntryFlowShellImpl({
|
||||
onOpenCreateWorld={openCreationTypePicker}
|
||||
onOpenCreateTypePicker={openCreationTypePicker}
|
||||
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)) {
|
||||
void openPuzzleDetail(entry.profileId, {
|
||||
tab: platformBootstrap.platformTab,
|
||||
@@ -2109,10 +2241,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
isBusy={isBigFishBusy}
|
||||
error={bigFishError}
|
||||
onBack={() => {
|
||||
setSelectionStage('big-fish-result');
|
||||
setSelectionStage(
|
||||
bigFishSession ? 'big-fish-result' : 'platform',
|
||||
);
|
||||
}}
|
||||
onRestart={() => {
|
||||
void startBigFishRun();
|
||||
void restartBigFishRun();
|
||||
}}
|
||||
onSubmitInput={submitBigFishInput}
|
||||
/>
|
||||
@@ -2128,7 +2262,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense fallback={<LazyPanelFallback label="正在加载拼图创作..." />}>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载拼图创作..." />}
|
||||
>
|
||||
<PuzzleAgentWorkspace
|
||||
session={puzzleSession}
|
||||
activeOperation={puzzleOperation}
|
||||
@@ -2201,7 +2337,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense fallback={<LazyPanelFallback label="正在加载拼图结果..." />}>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载拼图结果..." />}
|
||||
>
|
||||
<PuzzleResultView
|
||||
session={puzzleSession}
|
||||
author={authUi?.user ?? null}
|
||||
@@ -2226,7 +2364,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense fallback={<LazyPanelFallback label="正在加载拼图详情..." />}>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载拼图详情..." />}
|
||||
>
|
||||
<PuzzleGalleryDetailView
|
||||
item={selectedPuzzleDetail}
|
||||
isBusy={isPuzzleBusy}
|
||||
@@ -2249,7 +2389,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
: null
|
||||
}
|
||||
onStartGame={() => {
|
||||
void startPuzzleRunFromProfile(selectedPuzzleDetail.profileId);
|
||||
void startPuzzleRunFromProfile(
|
||||
selectedPuzzleDetail.profileId,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
@@ -2264,7 +2406,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100]"
|
||||
>
|
||||
<Suspense fallback={<LazyPanelFallback label="正在加载拼图玩法..." />}>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载拼图玩法..." />}
|
||||
>
|
||||
<PuzzleRuntimeShell
|
||||
run={puzzleRun}
|
||||
isBusy={isPuzzleBusy || isPuzzleNextLevelGenerating}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useState } from 'react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
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 { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
@@ -15,6 +16,8 @@ import {
|
||||
executeBigFishCreationAction,
|
||||
getBigFishCreationSession,
|
||||
} 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 {
|
||||
createPuzzleAgentSession,
|
||||
@@ -145,6 +148,15 @@ vi.mock('../../services/big-fish-works', () => ({
|
||||
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', () => ({
|
||||
createPuzzleAgentSession: vi.fn(),
|
||||
executePuzzleAgentAction: vi.fn(),
|
||||
@@ -152,6 +164,69 @@ vi.mock('../../services/puzzle-agent', () => ({
|
||||
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', () => ({
|
||||
BigFishAgentWorkspace: ({
|
||||
session,
|
||||
@@ -232,8 +307,7 @@ const mockSession: CustomWorldAgentSessionSnapshot = {
|
||||
'玩家以返乡守灯人继承者身份切入,回港首夜撞见禁航区假航灯重亮,动机是阻止更多船只误入死潮。',
|
||||
coreConflict:
|
||||
'守灯会与航运公会争夺航路解释权,有人在借假航灯持续清洗旧案证据,玩家返乡当夜就被卷进封航冲突。',
|
||||
keyRelationships:
|
||||
'玩家与沈砺旧友互疑,沈砺知道沉船夜的另一半真相。',
|
||||
keyRelationships: '玩家与沈砺旧友互疑,沈砺知道沉船夜的另一半真相。',
|
||||
hiddenLines:
|
||||
'沉船夜与假航灯骗局属于同一操盘链条,表面像海雾自然失控,揭示节奏是先见异常,再见旧案,再见操盘者。',
|
||||
iconicElements:
|
||||
@@ -956,6 +1030,34 @@ beforeEach(() => {
|
||||
vi.mocked(listBigFishWorks).mockResolvedValue({
|
||||
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({
|
||||
items: [],
|
||||
});
|
||||
@@ -1329,6 +1431,7 @@ test('creation hub clears all private work shelves immediately after logout stat
|
||||
{
|
||||
workId: 'big-fish-logout-cache-1',
|
||||
sourceSessionId: 'big-fish-logout-cache-session',
|
||||
ownerUserId: 'user-1',
|
||||
title: '大鱼退出缓存作品',
|
||||
subtitle: '登出后不应继续可见',
|
||||
summary: '这条大鱼私有作品只能在登录态展示。',
|
||||
@@ -1427,6 +1530,48 @@ test('published puzzle works appear on home and category public shelves', async
|
||||
).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 () => {
|
||||
const user = userEvent.setup();
|
||||
const publishedPuzzleWork = {
|
||||
@@ -1797,8 +1942,9 @@ test('public code search opens a published puzzle by PZ code', async () => {
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
const searchInput =
|
||||
await screen.findByPlaceholderText('输入 SY / CW / PZ 编号');
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'输入 SY / CW / BF / PZ 编号',
|
||||
);
|
||||
await user.type(searchInput, 'PZ-EPUBLIC1');
|
||||
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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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',
|
||||
sourceSessionId: 'big-fish-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
title: '机械深海 大鱼吃小鱼',
|
||||
subtitle: '机械微生物吞并进化 · 偏爽快节奏',
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
const baseBigFishSession = (await getBigFishCreationSession('big-fish-session-1'))
|
||||
.session;
|
||||
const baseBigFishSession = (
|
||||
await getBigFishCreationSession('big-fish-session-1')
|
||||
).session;
|
||||
vi.mocked(getBigFishCreationSession).mockClear();
|
||||
vi.mocked(listBigFishWorks).mockClear();
|
||||
vi.mocked(listBigFishGallery).mockClear();
|
||||
const publishedBigFishSession = {
|
||||
...baseBigFishSession,
|
||||
stage: 'published',
|
||||
@@ -1894,6 +2086,7 @@ test('big fish result publish action refreshes creation works', async () => {
|
||||
{
|
||||
workId: 'big-fish-work-big-fish-session-1',
|
||||
sourceSessionId: 'big-fish-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
title: '机械深海 大鱼吃小鱼',
|
||||
subtitle: '机械微生物吞并进化 · 偏爽快节奏',
|
||||
summary: '机械微生物吞并进化',
|
||||
@@ -1913,6 +2106,7 @@ test('big fish result publish action refreshes creation works', async () => {
|
||||
{
|
||||
workId: 'big-fish-work-big-fish-session-1',
|
||||
sourceSessionId: 'big-fish-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
title: '机械深海 大鱼吃小鱼',
|
||||
subtitle: '机械微生物吞并进化 · 偏爽快节奏',
|
||||
summary: '机械微生物吞并进化',
|
||||
@@ -1959,6 +2153,9 @@ test('big fish result publish action refreshes creation works', async () => {
|
||||
await waitFor(() => {
|
||||
expect(listBigFishWorks).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(listBigFishGallery).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
|
||||
|
||||
@@ -5,7 +5,10 @@ import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import { RpgEntryHomeView, type RpgEntryHomeViewProps } from './RpgEntryHomeView';
|
||||
import {
|
||||
RpgEntryHomeView,
|
||||
type RpgEntryHomeViewProps,
|
||||
} from './RpgEntryHomeView';
|
||||
import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation';
|
||||
|
||||
vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
||||
@@ -343,7 +346,9 @@ test('mobile home search submits public work code', async () => {
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('输入 SY / CW / PZ 编号');
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
'输入 SY / CW / BF / PZ 编号',
|
||||
);
|
||||
await user.type(searchInput, 'PZ-PROFILE1{enter}');
|
||||
|
||||
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.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }))
|
||||
.toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }),
|
||||
).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /查看作品/u }));
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
buildPlatformWorldTags,
|
||||
describePlatformThemeLabel,
|
||||
formatPlatformWorldTime,
|
||||
isBigFishGalleryEntry,
|
||||
isPuzzleGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
type PlatformWorldCardLike,
|
||||
@@ -223,7 +224,7 @@ function PublicCodeSearchBar({
|
||||
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)]"
|
||||
/>
|
||||
<button
|
||||
@@ -665,9 +666,11 @@ function DesktopTrendingItem({
|
||||
))
|
||||
) : (
|
||||
<span className="platform-pill platform-pill--neutral px-2.5">
|
||||
{isPuzzleGalleryEntry(entry)
|
||||
? '拼图'
|
||||
: describePlatformThemeLabel(entry.themeMode)}
|
||||
{isBigFishGalleryEntry(entry)
|
||||
? '大鱼'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? '拼图'
|
||||
: describePlatformThemeLabel(entry.themeMode)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -714,13 +717,20 @@ function buildPublicCategoryGroups(
|
||||
}
|
||||
|
||||
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) {
|
||||
return isPuzzleGalleryEntry(entry)
|
||||
? '拼图'
|
||||
: describePlatformThemeLabel(entry.themeMode);
|
||||
return isBigFishGalleryEntry(entry)
|
||||
? '大鱼'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? '拼图'
|
||||
: describePlatformThemeLabel(entry.themeMode);
|
||||
}
|
||||
|
||||
function formatSnapshotTime(value: string | null | undefined) {
|
||||
|
||||
@@ -2,15 +2,20 @@ import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
} 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 { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
|
||||
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||
import {
|
||||
buildBigFishPublicWorkCode,
|
||||
buildPuzzlePublicWorkCode,
|
||||
} from '../../services/publicWorkCode';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type PlatformWorldCardLike =
|
||||
| CustomWorldGalleryCard
|
||||
| CustomWorldLibraryEntry<CustomWorldProfile>
|
||||
| PlatformBigFishGalleryCard
|
||||
| PlatformPuzzleGalleryCard;
|
||||
|
||||
export type PlatformPuzzleGalleryCard = {
|
||||
@@ -30,8 +35,26 @@ export type PlatformPuzzleGalleryCard = {
|
||||
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 =
|
||||
| CustomWorldGalleryCard
|
||||
| PlatformBigFishGalleryCard
|
||||
| PlatformPuzzleGalleryCard;
|
||||
|
||||
export function isLibraryWorldEntry(
|
||||
@@ -46,6 +69,12 @@ export function isPuzzleGalleryEntry(
|
||||
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(
|
||||
work: PuzzleWorkSummary,
|
||||
): 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) {
|
||||
if (entry.coverImageSrc) {
|
||||
return entry.coverImageSrc;
|
||||
@@ -88,6 +138,10 @@ export function resolvePlatformWorldLeadPortrait(entry: PlatformWorldCardLike) {
|
||||
}
|
||||
|
||||
export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['大鱼'];
|
||||
}
|
||||
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['拼图'];
|
||||
}
|
||||
@@ -128,6 +182,10 @@ export function formatPlatformWorldTime(value: string | null) {
|
||||
export function resolvePlatformPublicWorkCode(
|
||||
entry: PlatformWorldCardLike,
|
||||
): string | null {
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
29
src/services/big-fish-gallery/bigFishGalleryClient.ts
Normal file
29
src/services/big-fish-gallery/bigFishGalleryClient.ts
Normal 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,
|
||||
};
|
||||
4
src/services/big-fish-gallery/index.ts
Normal file
4
src/services/big-fish-gallery/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
bigFishGalleryClient,
|
||||
listBigFishGallery,
|
||||
} from './bigFishGalleryClient';
|
||||
@@ -13,6 +13,14 @@ export function buildPuzzlePublicWorkCode(profileId: string) {
|
||||
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) {
|
||||
const normalizedKeyword = normalizePublicCodeText(keyword);
|
||||
|
||||
@@ -22,3 +30,16 @@ export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
|
||||
normalizedKeyword === normalizePublicCodeText(profileId)
|
||||
);
|
||||
}
|
||||
|
||||
export function isSameBigFishPublicWorkCode(
|
||||
keyword: string,
|
||||
sessionId: string,
|
||||
) {
|
||||
const normalizedKeyword = normalizePublicCodeText(keyword);
|
||||
|
||||
return (
|
||||
normalizedKeyword ===
|
||||
normalizePublicCodeText(buildBigFishPublicWorkCode(sessionId)) ||
|
||||
normalizedKeyword === normalizePublicCodeText(sessionId)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user