diff --git a/.env.local b/.env.local index b74994b6..28945919 100644 --- a/.env.local +++ b/.env.local @@ -8,3 +8,9 @@ VOLCENGINE_ACCESS_KEY_ID="AKLTZWFjMmYzZTdjZTIxNDRiNTkzMTZiMTk2NzVmNTUxOGI" VOLCENGINE_SECRET_ACCESS_KEY="TURRMk56bGhZalE0TjJReE5ERmpNMkpoTUdaa1lqRmtaVGt5TVRrM1lXSQ==" WECHAT_AUTH_ENABLED="true" WECHAT_AUTH_PROVIDER="mock" + +SMS_AUTH_ENABLED="true" +SMS_AUTH_PROVIDER="mock" +SMS_AUTH_MOCK_VERIFY_CODE="123456" + +VITE_AUTH_ALLOW_DEV_GUEST="false" diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index 6ab314be..e9898779 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -34,14 +34,64 @@ export type BasicOkResult = { ok: true; }; +export type CustomWorldPublicationStatus = 'draft' | 'published'; +export type CustomWorldThemeMode = + | 'martial' + | 'arcane' + | 'machina' + | 'tide' + | 'rift' + | 'mythic'; + export type CustomWorldProfileRecord = JsonObject & { id?: string; }; +export type CustomWorldLibraryEntry< + TProfile = CustomWorldProfileRecord, +> = { + ownerUserId: string; + profileId: string; + profile: TProfile; + visibility: CustomWorldPublicationStatus; + publishedAt: string | null; + updatedAt: string; + authorDisplayName: string; + worldName: string; + subtitle: string; + summaryText: string; + coverImageSrc: string | null; + themeMode: CustomWorldThemeMode; + playableNpcCount: number; + landmarkCount: number; +}; + +export type CustomWorldGalleryCard = Omit< + CustomWorldLibraryEntry, + 'profile' +>; + export type CustomWorldLibraryResponse< TProfile = CustomWorldProfileRecord, > = { - profiles: TProfile[]; + entries: CustomWorldLibraryEntry[]; +}; + +export type CustomWorldLibraryMutationResponse< + TProfile = CustomWorldProfileRecord, +> = { + entry: CustomWorldLibraryEntry; + entries: CustomWorldLibraryEntry[]; +}; + +export type CustomWorldGalleryResponse = { + entries: CustomWorldGalleryCard[]; +}; + +export type CustomWorldGalleryDetailResponse< + TProfile = CustomWorldProfileRecord, +> = { + entry: CustomWorldLibraryEntry; }; export const CUSTOM_WORLD_GENERATION_MODES = ['fast', 'full'] as const; diff --git a/server-node/sql/schema/00_schema_migrations.sql b/server-node/sql/schema/00_schema_migrations.sql new file mode 100644 index 00000000..70e5a638 --- /dev/null +++ b/server-node/sql/schema/00_schema_migrations.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS schema_migrations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL +); diff --git a/server-node/sql/schema/01_users.sql b/server-node/sql/schema/01_users.sql new file mode 100644 index 00000000..71aa0f93 --- /dev/null +++ b/server-node/sql/schema/01_users.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + token_version INTEGER NOT NULL DEFAULT 1, + display_name TEXT NOT NULL, + login_provider TEXT NOT NULL DEFAULT 'password', + account_status TEXT NOT NULL DEFAULT 'active', + phone_number TEXT, + phone_verified_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS users_phone_number_unique_idx + ON users (phone_number); diff --git a/server-node/sql/schema/02_save_snapshots.sql b/server-node/sql/schema/02_save_snapshots.sql new file mode 100644 index 00000000..75a4c486 --- /dev/null +++ b/server-node/sql/schema/02_save_snapshots.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS save_snapshots ( + user_id TEXT PRIMARY KEY, + version INTEGER NOT NULL, + saved_at TEXT NOT NULL, + bottom_tab TEXT NOT NULL, + game_state_json JSONB NOT NULL, + current_story_json JSONB, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); diff --git a/server-node/sql/schema/03_runtime_settings.sql b/server-node/sql/schema/03_runtime_settings.sql new file mode 100644 index 00000000..1d5a59e0 --- /dev/null +++ b/server-node/sql/schema/03_runtime_settings.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS runtime_settings ( + user_id TEXT PRIMARY KEY, + music_volume REAL NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); diff --git a/server-node/sql/schema/04_custom_world_profiles.sql b/server-node/sql/schema/04_custom_world_profiles.sql new file mode 100644 index 00000000..632db82c --- /dev/null +++ b/server-node/sql/schema/04_custom_world_profiles.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS custom_world_profiles ( + user_id TEXT NOT NULL, + profile_id TEXT NOT NULL, + payload_json JSONB NOT NULL, + visibility TEXT NOT NULL DEFAULT 'draft', + published_at TEXT, + author_display_name TEXT NOT NULL DEFAULT '玩家', + world_name TEXT NOT NULL DEFAULT '', + subtitle TEXT NOT NULL DEFAULT '', + summary_text TEXT NOT NULL DEFAULT '', + cover_image_src TEXT, + theme_mode TEXT NOT NULL DEFAULT 'mythic', + playable_npc_count INTEGER NOT NULL DEFAULT 0, + landmark_count INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + PRIMARY KEY (user_id, profile_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS custom_world_profiles_user_updated_idx + ON custom_world_profiles (user_id, updated_at DESC); + +CREATE INDEX IF NOT EXISTS custom_world_profiles_published_idx + ON custom_world_profiles (visibility, published_at DESC, updated_at DESC); diff --git a/server-node/sql/schema/05_auth_identities.sql b/server-node/sql/schema/05_auth_identities.sql new file mode 100644 index 00000000..21854d7b --- /dev/null +++ b/server-node/sql/schema/05_auth_identities.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS auth_identities ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + provider TEXT NOT NULL, + provider_uid TEXT NOT NULL, + provider_unionid TEXT, + display_name TEXT, + avatar_url TEXT, + is_verified BOOLEAN NOT NULL DEFAULT TRUE, + meta_json JSONB, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS auth_identities_provider_uid_unique_idx + ON auth_identities (provider, provider_uid); + +CREATE UNIQUE INDEX IF NOT EXISTS auth_identities_provider_unionid_unique_idx + ON auth_identities (provider, provider_unionid) + WHERE provider_unionid IS NOT NULL; + +CREATE INDEX IF NOT EXISTS auth_identities_user_idx + ON auth_identities (user_id, provider); diff --git a/server-node/sql/schema/06_user_sessions.sql b/server-node/sql/schema/06_user_sessions.sql new file mode 100644 index 00000000..e980dbe3 --- /dev/null +++ b/server-node/sql/schema/06_user_sessions.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS user_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + refresh_token_hash TEXT NOT NULL UNIQUE, + client_type TEXT NOT NULL, + user_agent TEXT, + ip TEXT, + expires_at TEXT NOT NULL, + revoked_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS user_sessions_user_idx + ON user_sessions (user_id, expires_at DESC); diff --git a/server-node/sql/schema/07_auth_audit_logs.sql b/server-node/sql/schema/07_auth_audit_logs.sql new file mode 100644 index 00000000..5e7e9694 --- /dev/null +++ b/server-node/sql/schema/07_auth_audit_logs.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS auth_audit_logs ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + event_type TEXT NOT NULL, + detail TEXT NOT NULL, + ip TEXT, + user_agent TEXT, + meta_json JSONB, + created_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS auth_audit_logs_user_created_idx + ON auth_audit_logs (user_id, created_at DESC); diff --git a/server-node/sql/schema/08_sms_auth_events.sql b/server-node/sql/schema/08_sms_auth_events.sql new file mode 100644 index 00000000..adaf19a6 --- /dev/null +++ b/server-node/sql/schema/08_sms_auth_events.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS sms_auth_events ( + id TEXT PRIMARY KEY, + phone_number TEXT NOT NULL, + scene TEXT NOT NULL, + action TEXT NOT NULL, + success BOOLEAN NOT NULL, + ip TEXT, + user_agent TEXT, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS sms_auth_events_phone_created_idx + ON sms_auth_events (phone_number, created_at DESC); + +CREATE INDEX IF NOT EXISTS sms_auth_events_ip_created_idx + ON sms_auth_events (ip, created_at DESC); diff --git a/server-node/sql/schema/09_auth_risk_blocks.sql b/server-node/sql/schema/09_auth_risk_blocks.sql new file mode 100644 index 00000000..0cdbc4e2 --- /dev/null +++ b/server-node/sql/schema/09_auth_risk_blocks.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS auth_risk_blocks ( + id TEXT PRIMARY KEY, + scope_type TEXT NOT NULL, + scope_key TEXT NOT NULL, + reason TEXT NOT NULL, + expires_at TEXT NOT NULL, + lifted_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS auth_risk_blocks_scope_idx + ON auth_risk_blocks (scope_type, scope_key, expires_at DESC); diff --git a/server-node/sql/schema/README.md b/server-node/sql/schema/README.md new file mode 100644 index 00000000..68c6c038 --- /dev/null +++ b/server-node/sql/schema/README.md @@ -0,0 +1,8 @@ +# Final Schema SQL + +This folder contains the final PostgreSQL table definitions, one table per file. + +Notes: +- These files keep only the final schema shape. +- They do not preserve historical migration steps. +- The current runtime migration logic in `server-node/src/db/migrations.ts` is unchanged. diff --git a/server-node/src/app.test.ts b/server-node/src/app.test.ts index 296dae11..88923eb1 100644 --- a/server-node/src/app.test.ts +++ b/server-node/src/app.test.ts @@ -1565,9 +1565,164 @@ test('runtime persistence is isolated by user', async () => { }, ); const userBLibraryPayload = (await userBLibrary.json()) as { - profiles: unknown[]; + entries: unknown[]; }; - assert.deepEqual(userBLibraryPayload.profiles, []); + assert.deepEqual(userBLibraryPayload.entries, []); + }); +}); + +test('custom worlds stay private until published and then appear in the public gallery', async () => { + await withTestServer('custom-world-gallery', async ({ baseUrl }) => { + const owner = await authEntry(baseUrl, 'gallery_owner', 'secret123'); + const viewer = await authEntry(baseUrl, 'gallery_viewer', 'secret123'); + + const upsertResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-library/world-a`, + withBearer(owner.token, { + method: 'PUT', + body: JSON.stringify({ + profile: { + id: 'world-a', + name: '裂桥前线', + subtitle: '边境上空的断层回响', + summary: '围绕裂桥哨线与失序潮汐展开的前线世界。', + tone: '压迫、冷峻、持续失衡', + playerGoal: '在裂桥崩塌前守住归路', + majorFactions: ['裂桥守军'], + coreConflicts: ['断层外压正在逼近城线'], + playableNpcs: [ + { + id: 'role-1', + name: '沈昼', + }, + ], + storyNpcs: [], + landmarks: [ + { + id: 'landmark-1', + name: '裂桥前哨', + description: '裂谷边缘的前线哨卡。', + dangerLevel: '高', + sceneNpcIds: [], + connections: [], + }, + ], + }, + }), + }), + ); + const upsertPayload = (await upsertResponse.json()) as { + entry: { + visibility: 'draft' | 'published'; + authorDisplayName: string; + }; + entries: unknown[]; + }; + + assert.equal(upsertResponse.status, 200); + assert.equal(upsertPayload.entry.visibility, 'draft'); + assert.equal(upsertPayload.entry.authorDisplayName, 'gallery_owner'); + + const galleryBeforePublish = await httpRequest( + `${baseUrl}/api/runtime/custom-world-gallery`, + { + headers: { + Authorization: `Bearer ${viewer.token}`, + }, + }, + ); + const galleryBeforePayload = (await galleryBeforePublish.json()) as { + entries: unknown[]; + }; + assert.deepEqual(galleryBeforePayload.entries, []); + + const publishResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-library/world-a/publish`, + withBearer(owner.token, { + method: 'POST', + }), + ); + const publishPayload = (await publishResponse.json()) as { + entry: { + visibility: 'draft' | 'published'; + publishedAt: string | null; + }; + }; + + assert.equal(publishResponse.status, 200); + assert.equal(publishPayload.entry.visibility, 'published'); + assert.ok(publishPayload.entry.publishedAt); + + const galleryAfterPublish = await httpRequest( + `${baseUrl}/api/runtime/custom-world-gallery`, + { + headers: { + Authorization: `Bearer ${viewer.token}`, + }, + }, + ); + const galleryAfterPayload = (await galleryAfterPublish.json()) as { + entries: Array<{ + ownerUserId: string; + profileId: string; + worldName: string; + authorDisplayName: string; + }>; + }; + + assert.equal(galleryAfterPublish.status, 200); + assert.equal(galleryAfterPayload.entries.length, 1); + assert.equal(galleryAfterPayload.entries[0]?.worldName, '裂桥前线'); + assert.equal(galleryAfterPayload.entries[0]?.authorDisplayName, 'gallery_owner'); + + const galleryDetail = await httpRequest( + `${baseUrl}/api/runtime/custom-world-gallery/${encodeURIComponent(galleryAfterPayload.entries[0]?.ownerUserId || '')}/${encodeURIComponent(galleryAfterPayload.entries[0]?.profileId || '')}`, + { + headers: { + Authorization: `Bearer ${viewer.token}`, + }, + }, + ); + const galleryDetailPayload = (await galleryDetail.json()) as { + entry: { + worldName: string; + profile: { + name: string; + }; + }; + }; + + assert.equal(galleryDetail.status, 200); + assert.equal(galleryDetailPayload.entry.worldName, '裂桥前线'); + assert.equal(galleryDetailPayload.entry.profile.name, '裂桥前线'); + + const unpublishResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-library/world-a/unpublish`, + withBearer(owner.token, { + method: 'POST', + }), + ); + const unpublishPayload = (await unpublishResponse.json()) as { + entry: { + visibility: 'draft' | 'published'; + }; + }; + + assert.equal(unpublishResponse.status, 200); + assert.equal(unpublishPayload.entry.visibility, 'draft'); + + const galleryAfterUnpublish = await httpRequest( + `${baseUrl}/api/runtime/custom-world-gallery`, + { + headers: { + Authorization: `Bearer ${viewer.token}`, + }, + }, + ); + const galleryAfterUnpublishPayload = (await galleryAfterUnpublish.json()) as { + entries: unknown[]; + }; + assert.deepEqual(galleryAfterUnpublishPayload.entries, []); }); }); diff --git a/server-node/src/db.test.ts b/server-node/src/db.test.ts index 303f8671..92dba822 100644 --- a/server-node/src/db.test.ts +++ b/server-node/src/db.test.ts @@ -108,6 +108,7 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () = '20260409_006_auth_audit_logs', '20260409_007_sms_auth_events', '20260409_008_auth_risk_blocks', + '20260414_009_custom_world_gallery_metadata', ], ); diff --git a/server-node/src/db/migrations.ts b/server-node/src/db/migrations.ts index fa47f22d..baf3d5bc 100644 --- a/server-node/src/db/migrations.ts +++ b/server-node/src/db/migrations.ts @@ -189,4 +189,32 @@ export const databaseMigrations: readonly DatabaseMigration[] = [ ON auth_risk_blocks (scope_type, scope_key, expires_at DESC)`, ], }, + { + id: '20260414_009_custom_world_gallery_metadata', + name: 'custom world gallery metadata', + statements: [ + `ALTER TABLE custom_world_profiles + ADD COLUMN IF NOT EXISTS visibility TEXT NOT NULL DEFAULT 'draft'`, + `ALTER TABLE custom_world_profiles + ADD COLUMN IF NOT EXISTS published_at TEXT`, + `ALTER TABLE custom_world_profiles + ADD COLUMN IF NOT EXISTS author_display_name TEXT NOT NULL DEFAULT '玩家'`, + `ALTER TABLE custom_world_profiles + ADD COLUMN IF NOT EXISTS world_name TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE custom_world_profiles + ADD COLUMN IF NOT EXISTS subtitle TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE custom_world_profiles + ADD COLUMN IF NOT EXISTS summary_text TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE custom_world_profiles + ADD COLUMN IF NOT EXISTS cover_image_src TEXT`, + `ALTER TABLE custom_world_profiles + ADD COLUMN IF NOT EXISTS theme_mode TEXT NOT NULL DEFAULT 'mythic'`, + `ALTER TABLE custom_world_profiles + ADD COLUMN IF NOT EXISTS playable_npc_count INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE custom_world_profiles + ADD COLUMN IF NOT EXISTS landmark_count INTEGER NOT NULL DEFAULT 0`, + `CREATE INDEX IF NOT EXISTS custom_world_profiles_published_idx + ON custom_world_profiles (visibility, published_at DESC, updated_at DESC)`, + ], + }, ]; diff --git a/server-node/src/repositories/customWorldLibraryMetadata.ts b/server-node/src/repositories/customWorldLibraryMetadata.ts new file mode 100644 index 00000000..0cfb4b28 --- /dev/null +++ b/server-node/src/repositories/customWorldLibraryMetadata.ts @@ -0,0 +1,109 @@ +import type { + CustomWorldProfileRecord, + CustomWorldThemeMode, +} from '../../../packages/shared/src/contracts/runtime.js'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readString(value: unknown, fallback = '') { + return typeof value === 'string' && value.trim() ? value.trim() : fallback; +} + +function readArray(value: unknown) { + return Array.isArray(value) ? value : []; +} + +function readImageSrc(value: unknown) { + return readString(value) || null; +} + +function detectThemeMode( + profile: Pick< + CustomWorldProfileRecord, + | 'settingText' + | 'summary' + | 'tone' + | 'playerGoal' + | 'templateWorldType' + | 'compatibilityTemplateWorldType' + | 'ownedSettingLayers' + >, +): CustomWorldThemeMode { + const semanticAnchor = isRecord(profile.ownedSettingLayers) + && isRecord(profile.ownedSettingLayers.semanticAnchor) + ? profile.ownedSettingLayers.semanticAnchor + : null; + const expressionProfile = isRecord(profile.ownedSettingLayers) + && isRecord(profile.ownedSettingLayers.expressionProfile) + ? profile.ownedSettingLayers.expressionProfile + : null; + const source = [ + readString(profile.settingText), + readString(profile.summary), + readString(profile.tone), + readString(profile.playerGoal), + ...readArray(semanticAnchor?.genreSignals).map((value) => readString(value)), + ...readArray(semanticAnchor?.conflictForms).map((value) => readString(value)), + ...readArray(semanticAnchor?.institutionTypes).map((value) => readString(value)), + ...readArray(semanticAnchor?.tabooTypes).map((value) => readString(value)), + ...readArray(semanticAnchor?.carrierTypes).map((value) => readString(value)), + ...readArray(semanticAnchor?.forceSystemTypes).map((value) => readString(value)), + ...readArray(semanticAnchor?.atmosphereTags).map((value) => readString(value)), + ...readArray(expressionProfile?.presentationTone).map((value) => readString(value)), + ].join(' '); + + if (/[机关蒸汽齿轮工坊机巧城轨炮舰]/u.test(source)) return 'machina'; + if (/[海潮港湾船舟湖泊澜雾湾]/u.test(source)) return 'tide'; + if (/[裂缝裂界边境前线断层界桥灰域]/u.test(source)) return 'rift'; + if (/[修真仙灵宗门法器道脉秘境云阙]/u.test(source)) return 'arcane'; + if (/[江湖门派镖局朝廷刀剑侠客旧案]/u.test(source)) return 'martial'; + + return 'mythic'; +} + +export function buildCustomWorldCoverImageSrc(profile: CustomWorldProfileRecord) { + const explicitCampImage = isRecord(profile.camp) + ? readImageSrc(profile.camp.imageSrc) + : null; + if (explicitCampImage) { + return explicitCampImage; + } + + const landmarkImage = readArray(profile.landmarks) + .map((landmark) => (isRecord(landmark) ? readImageSrc(landmark.imageSrc) : null)) + .find(Boolean); + if (landmarkImage) { + return landmarkImage; + } + + const playableImage = readArray(profile.playableNpcs) + .map((role) => (isRecord(role) ? readImageSrc(role.imageSrc) : null)) + .find(Boolean); + if (playableImage) { + return playableImage; + } + + return null; +} + +export function extractCustomWorldLibraryMetadata(profile: CustomWorldProfileRecord) { + return { + worldName: readString(profile.name, '未命名世界'), + subtitle: readString(profile.subtitle), + summaryText: readString(profile.summary), + coverImageSrc: buildCustomWorldCoverImageSrc(profile), + themeMode: detectThemeMode({ + settingText: profile.settingText, + summary: profile.summary, + tone: profile.tone, + playerGoal: profile.playerGoal, + templateWorldType: profile.templateWorldType, + compatibilityTemplateWorldType: profile.compatibilityTemplateWorldType, + ownedSettingLayers: profile.ownedSettingLayers, + }), + playableNpcCount: readArray(profile.playableNpcs).length, + landmarkCount: readArray(profile.landmarks).length, + }; +} diff --git a/server-node/src/repositories/runtimeRepository.ts b/server-node/src/repositories/runtimeRepository.ts index ec0e6fb4..7bb1ed02 100644 --- a/server-node/src/repositories/runtimeRepository.ts +++ b/server-node/src/repositories/runtimeRepository.ts @@ -1,17 +1,22 @@ import type { QueryResultRow } from 'pg'; -import { - DEFAULT_MUSIC_VOLUME, - SAVE_SNAPSHOT_VERSION, -} from '../../../packages/shared/src/contracts/runtime.js'; import type { CustomWorldProfileRecord, RuntimeSettings, SavedGameSnapshot, } from '../../../packages/shared/src/contracts/runtime.js'; +import { + type CustomWorldGalleryCard, + type CustomWorldLibraryEntry, + type CustomWorldPublicationStatus, + DEFAULT_MUSIC_VOLUME, + SAVE_SNAPSHOT_VERSION, +} from '../../../packages/shared/src/contracts/runtime.js'; import type { AppDatabase } from '../db.js'; +import { extractCustomWorldLibraryMetadata } from './customWorldLibraryMetadata.js'; const MAX_CUSTOM_WORLD_PROFILES = 12; +const MAX_PUBLIC_CUSTOM_WORLD_PROFILES = 36; export type SavedSnapshot = SavedGameSnapshot; @@ -27,8 +32,37 @@ type SettingsRow = QueryResultRow & { musicVolume: number; }; -type ProfileRow = QueryResultRow & { +type CustomWorldEntryRow = QueryResultRow & { + ownerUserId: string; + profileId: string; payload: CustomWorldProfileRecord; + visibility: CustomWorldPublicationStatus; + publishedAt: string | null; + updatedAt: string; + authorDisplayName: string; + worldName: string; + subtitle: string; + summaryText: string; + coverImageSrc: string | null; + themeMode: CustomWorldLibraryEntry['themeMode']; + playableNpcCount: number; + landmarkCount: number; +}; + +type CustomWorldCardRow = QueryResultRow & { + ownerUserId: string; + profileId: string; + visibility: CustomWorldPublicationStatus; + publishedAt: string | null; + updatedAt: string; + authorDisplayName: string; + worldName: string; + subtitle: string; + summaryText: string; + coverImageSrc: string | null; + themeMode: CustomWorldGalleryCard['themeMode']; + playableNpcCount: number; + landmarkCount: number; }; export type RuntimeRepositoryPort = { @@ -43,21 +77,134 @@ export type RuntimeRepositoryPort = { userId: string, settings: RuntimeSettings, ): Promise; - listCustomWorldProfiles(userId: string): Promise; + listCustomWorldProfiles( + userId: string, + ): Promise[]>; upsertCustomWorldProfile( userId: string, profileId: string, profile: Record, - ): Promise; + authorDisplayName: string, + ): Promise<{ + entry: CustomWorldLibraryEntry; + entries: CustomWorldLibraryEntry[]; + }>; deleteCustomWorldProfile( userId: string, profileId: string, - ): Promise; + ): Promise[]>; + publishCustomWorldProfile( + userId: string, + profileId: string, + authorDisplayName: string, + ): Promise<{ + entry: CustomWorldLibraryEntry; + entries: CustomWorldLibraryEntry[]; + } | null>; + unpublishCustomWorldProfile( + userId: string, + profileId: string, + authorDisplayName: string, + ): Promise<{ + entry: CustomWorldLibraryEntry; + entries: CustomWorldLibraryEntry[]; + } | null>; + listPublishedCustomWorldGallery(): Promise; + getPublishedCustomWorldGalleryDetail( + ownerUserId: string, + profileId: string, + ): Promise | null>; }; +function normalizeStoredProfile( + profileId: string, + profile: Record, +): CustomWorldProfileRecord { + return { + ...profile, + id: profileId, + }; +} + +function toCustomWorldLibraryEntry( + row: CustomWorldEntryRow, +): CustomWorldLibraryEntry { + const fallbackMetadata = extractCustomWorldLibraryMetadata(row.payload); + + return { + ownerUserId: row.ownerUserId, + profileId: row.profileId, + profile: row.payload, + visibility: row.visibility, + publishedAt: row.publishedAt, + updatedAt: row.updatedAt, + authorDisplayName: row.authorDisplayName || '玩家', + worldName: row.worldName || fallbackMetadata.worldName, + subtitle: row.subtitle || fallbackMetadata.subtitle, + summaryText: row.summaryText || fallbackMetadata.summaryText, + coverImageSrc: row.coverImageSrc || fallbackMetadata.coverImageSrc, + themeMode: row.themeMode || fallbackMetadata.themeMode, + playableNpcCount: + row.playableNpcCount > 0 + ? row.playableNpcCount + : fallbackMetadata.playableNpcCount, + landmarkCount: + row.landmarkCount > 0 ? row.landmarkCount : fallbackMetadata.landmarkCount, + }; +} + +function toCustomWorldGalleryCard( + row: CustomWorldCardRow, +): CustomWorldGalleryCard { + return { + ownerUserId: row.ownerUserId, + profileId: row.profileId, + visibility: row.visibility, + publishedAt: row.publishedAt, + updatedAt: row.updatedAt, + authorDisplayName: row.authorDisplayName || '玩家', + worldName: row.worldName || '未命名世界', + subtitle: row.subtitle || '', + summaryText: row.summaryText || '', + coverImageSrc: row.coverImageSrc || null, + themeMode: row.themeMode || 'mythic', + playableNpcCount: row.playableNpcCount, + landmarkCount: row.landmarkCount, + }; +} + export class RuntimeRepository implements RuntimeRepositoryPort { constructor(private readonly db: AppDatabase) {} + private async findCustomWorldProfileEntry( + userId: string, + profileId: string, + ) { + const result = await this.db.query( + `SELECT user_id AS "ownerUserId", + profile_id AS "profileId", + payload_json AS payload, + visibility, + published_at AS "publishedAt", + updated_at AS "updatedAt", + author_display_name AS "authorDisplayName", + world_name AS "worldName", + subtitle, + summary_text AS "summaryText", + cover_image_src AS "coverImageSrc", + theme_mode AS "themeMode", + playable_npc_count AS "playableNpcCount", + landmark_count AS "landmarkCount" + FROM custom_world_profiles + WHERE user_id = $1 + AND profile_id = $2`, + [userId, profileId], + ); + + const row = result.rows[0]; + return row ? toCustomWorldLibraryEntry(row) : null; + } + async getSnapshot(userId: string) { const result = await this.db.query( `SELECT version, @@ -174,8 +321,21 @@ export class RuntimeRepository implements RuntimeRepositoryPort { } async listCustomWorldProfiles(userId: string) { - const result = await this.db.query( - `SELECT payload_json AS payload + const result = await this.db.query( + `SELECT user_id AS "ownerUserId", + profile_id AS "profileId", + payload_json AS payload, + visibility, + published_at AS "publishedAt", + updated_at AS "updatedAt", + author_display_name AS "authorDisplayName", + world_name AS "worldName", + subtitle, + summary_text AS "summaryText", + cover_image_src AS "coverImageSrc", + theme_mode AS "themeMode", + playable_npc_count AS "playableNpcCount", + landmark_count AS "landmarkCount" FROM custom_world_profiles WHERE user_id = $1 ORDER BY updated_at DESC @@ -183,29 +343,71 @@ export class RuntimeRepository implements RuntimeRepositoryPort { [userId, MAX_CUSTOM_WORLD_PROFILES], ); - return result.rows.map((row: ProfileRow) => row.payload); + return result.rows.map((row) => toCustomWorldLibraryEntry(row)); } async upsertCustomWorldProfile( userId: string, profileId: string, profile: CustomWorldProfileRecord, + authorDisplayName: string, ) { - const payload = { - ...profile, - id: profileId, - }; + const payload = normalizeStoredProfile(profileId, profile); + const metadata = extractCustomWorldLibraryMetadata(payload); + const now = new Date().toISOString(); await this.db.query( - `INSERT INTO custom_world_profiles (user_id, profile_id, payload_json, updated_at) - VALUES ($1, $2, $3, $4) + `INSERT INTO custom_world_profiles ( + user_id, + profile_id, + payload_json, + updated_at, + author_display_name, + world_name, + subtitle, + summary_text, + cover_image_src, + theme_mode, + playable_npc_count, + landmark_count + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (user_id, profile_id) DO UPDATE SET payload_json = EXCLUDED.payload_json, - updated_at = EXCLUDED.updated_at`, - [userId, profileId, payload, new Date().toISOString()], + updated_at = EXCLUDED.updated_at, + author_display_name = EXCLUDED.author_display_name, + world_name = EXCLUDED.world_name, + subtitle = EXCLUDED.subtitle, + summary_text = EXCLUDED.summary_text, + cover_image_src = EXCLUDED.cover_image_src, + theme_mode = EXCLUDED.theme_mode, + playable_npc_count = EXCLUDED.playable_npc_count, + landmark_count = EXCLUDED.landmark_count`, + [ + userId, + profileId, + payload, + now, + authorDisplayName || '玩家', + metadata.worldName, + metadata.subtitle, + metadata.summaryText, + metadata.coverImageSrc, + metadata.themeMode, + metadata.playableNpcCount, + metadata.landmarkCount, + ], ); - return this.listCustomWorldProfiles(userId); + const entry = await this.findCustomWorldProfileEntry(userId, profileId); + if (!entry) { + throw new Error('failed to resolve custom world after upsert'); + } + + return { + entry, + entries: await this.listCustomWorldProfiles(userId), + }; } async deleteCustomWorldProfile(userId: string, profileId: string) { @@ -217,4 +419,169 @@ export class RuntimeRepository implements RuntimeRepositoryPort { return this.listCustomWorldProfiles(userId); } + + async publishCustomWorldProfile( + userId: string, + profileId: string, + authorDisplayName: string, + ) { + const existingEntry = await this.findCustomWorldProfileEntry(userId, profileId); + if (!existingEntry) { + return null; + } + + const payload = normalizeStoredProfile(profileId, existingEntry.profile); + const metadata = extractCustomWorldLibraryMetadata(payload); + const now = new Date().toISOString(); + + await this.db.query( + `UPDATE custom_world_profiles + SET visibility = 'published', + published_at = $1, + updated_at = $1, + author_display_name = $2, + world_name = $3, + subtitle = $4, + summary_text = $5, + cover_image_src = $6, + theme_mode = $7, + playable_npc_count = $8, + landmark_count = $9 + WHERE user_id = $10 + AND profile_id = $11`, + [ + now, + authorDisplayName || '玩家', + metadata.worldName, + metadata.subtitle, + metadata.summaryText, + metadata.coverImageSrc, + metadata.themeMode, + metadata.playableNpcCount, + metadata.landmarkCount, + userId, + profileId, + ], + ); + + const entry = await this.findCustomWorldProfileEntry(userId, profileId); + if (!entry) { + throw new Error('failed to resolve custom world after publish'); + } + + return { + entry, + entries: await this.listCustomWorldProfiles(userId), + }; + } + + async unpublishCustomWorldProfile( + userId: string, + profileId: string, + authorDisplayName: string, + ) { + const existingEntry = await this.findCustomWorldProfileEntry(userId, profileId); + if (!existingEntry) { + return null; + } + + const payload = normalizeStoredProfile(profileId, existingEntry.profile); + const metadata = extractCustomWorldLibraryMetadata(payload); + const now = new Date().toISOString(); + + await this.db.query( + `UPDATE custom_world_profiles + SET visibility = 'draft', + published_at = NULL, + updated_at = $1, + author_display_name = $2, + world_name = $3, + subtitle = $4, + summary_text = $5, + cover_image_src = $6, + theme_mode = $7, + playable_npc_count = $8, + landmark_count = $9 + WHERE user_id = $10 + AND profile_id = $11`, + [ + now, + authorDisplayName || '玩家', + metadata.worldName, + metadata.subtitle, + metadata.summaryText, + metadata.coverImageSrc, + metadata.themeMode, + metadata.playableNpcCount, + metadata.landmarkCount, + userId, + profileId, + ], + ); + + const entry = await this.findCustomWorldProfileEntry(userId, profileId); + if (!entry) { + throw new Error('failed to resolve custom world after unpublish'); + } + + return { + entry, + entries: await this.listCustomWorldProfiles(userId), + }; + } + + async listPublishedCustomWorldGallery() { + const result = await this.db.query( + `SELECT user_id AS "ownerUserId", + profile_id AS "profileId", + visibility, + published_at AS "publishedAt", + updated_at AS "updatedAt", + author_display_name AS "authorDisplayName", + world_name AS "worldName", + subtitle, + summary_text AS "summaryText", + cover_image_src AS "coverImageSrc", + theme_mode AS "themeMode", + playable_npc_count AS "playableNpcCount", + landmark_count AS "landmarkCount" + FROM custom_world_profiles + WHERE visibility = 'published' + ORDER BY published_at DESC, updated_at DESC + LIMIT $1`, + [MAX_PUBLIC_CUSTOM_WORLD_PROFILES], + ); + + return result.rows.map((row) => toCustomWorldGalleryCard(row)); + } + + async getPublishedCustomWorldGalleryDetail( + ownerUserId: string, + profileId: string, + ) { + const result = await this.db.query( + `SELECT user_id AS "ownerUserId", + profile_id AS "profileId", + payload_json AS payload, + visibility, + published_at AS "publishedAt", + updated_at AS "updatedAt", + author_display_name AS "authorDisplayName", + world_name AS "worldName", + subtitle, + summary_text AS "summaryText", + cover_image_src AS "coverImageSrc", + theme_mode AS "themeMode", + playable_npc_count AS "playableNpcCount", + landmark_count AS "landmarkCount" + FROM custom_world_profiles + WHERE user_id = $1 + AND profile_id = $2 + AND visibility = 'published'`, + [ownerUserId, profileId], + ); + + const row = result.rows[0]; + return row ? toCustomWorldLibraryEntry(row) : null; + } } diff --git a/server-node/src/routes/runtimeRoutes.ts b/server-node/src/routes/runtimeRoutes.ts index 71bf17bc..c9211d0e 100644 --- a/server-node/src/routes/runtimeRoutes.ts +++ b/server-node/src/routes/runtimeRoutes.ts @@ -4,6 +4,10 @@ import { z } from 'zod'; import type { AnswerCustomWorldSessionQuestionRequest, CreateCustomWorldSessionRequest, + CustomWorldGalleryDetailResponse, + CustomWorldGalleryResponse, + CustomWorldLibraryMutationResponse, + CustomWorldLibraryResponse, RuntimeSettings, SavedGameSnapshotInput, } from '../../../packages/shared/src/contracts/runtime.js'; @@ -27,6 +31,8 @@ import { prepareEventStreamResponse, sendApiResponse, } from '../http.js'; +import { requireJwtAuth } from '../middleware/auth.js'; +import { routeMeta } from '../middleware/routeMeta.js'; import { generateCharacterChatSuggestionsFromOrchestrator, generateCharacterChatSummaryFromOrchestrator, @@ -34,8 +40,6 @@ import { streamNpcChatDialogueFromOrchestrator, streamNpcRecruitDialogueFromOrchestrator, } from '../modules/ai/chatOrchestrator.js'; -import { requireJwtAuth } from '../middleware/auth.js'; -import { routeMeta } from '../middleware/routeMeta.js'; import { hydrateSavedSnapshot, normalizeSavedSnapshotPayload, @@ -104,6 +108,15 @@ function readParam(param: string | string[] | undefined) { return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || ''; } +async function resolveAuthDisplayName(context: AppContext, userId: string) { + const user = await context.userRepository.findById(userId); + if (!user) { + throw notFound('user not found'); + } + + return user.displayName?.trim() || '玩家'; +} + export function createRuntimeRoutes(context: AppContext) { const router = Router(); const requireAuth = requireJwtAuth(context.config, context.userRepository); @@ -202,11 +215,55 @@ export function createRuntimeRoutes(context: AppContext) { '/runtime/custom-world-library', routeMeta({ operation: 'runtime.customWorldLibrary.list' }), asyncHandler(async (request, response) => { - sendApiResponse(response, { - profiles: await context.runtimeRepository.listCustomWorldProfiles( - request.userId!, - ), - }); + sendApiResponse( + response, + { + entries: await context.runtimeRepository.listCustomWorldProfiles( + request.userId!, + ), + } satisfies CustomWorldLibraryResponse, + ); + }), + ); + + router.get( + '/runtime/custom-world-gallery', + routeMeta({ operation: 'runtime.customWorldGallery.list' }), + asyncHandler(async (_request, response) => { + sendApiResponse( + response, + { + entries: await context.runtimeRepository.listPublishedCustomWorldGallery(), + } satisfies CustomWorldGalleryResponse, + ); + }), + ); + + router.get( + '/runtime/custom-world-gallery/:ownerUserId/:profileId', + routeMeta({ operation: 'runtime.customWorldGallery.detail' }), + asyncHandler(async (request, response) => { + const ownerUserId = readParam(request.params.ownerUserId); + const profileId = readParam(request.params.profileId); + if (!ownerUserId || !profileId) { + throw badRequest('ownerUserId and profileId are required'); + } + + const entry = + await context.runtimeRepository.getPublishedCustomWorldGalleryDetail( + ownerUserId, + profileId, + ); + if (!entry) { + throw notFound('public custom world not found'); + } + + sendApiResponse( + response, + { + entry, + } satisfies CustomWorldGalleryDetailResponse, + ); }), ); @@ -219,13 +276,19 @@ export function createRuntimeRoutes(context: AppContext) { throw badRequest('profileId is required'); } const payload = customWorldProfileSchema.parse(request.body); - sendApiResponse(response, { - profiles: await context.runtimeRepository.upsertCustomWorldProfile( + const authorDisplayName = await resolveAuthDisplayName( + context, + request.userId!, + ); + sendApiResponse( + response, + await context.runtimeRepository.upsertCustomWorldProfile( request.userId!, profileId, jsonClone(payload.profile), + authorDisplayName, ), - }); + ); }), ); @@ -237,12 +300,75 @@ export function createRuntimeRoutes(context: AppContext) { if (!profileId) { throw badRequest('profileId is required'); } - sendApiResponse(response, { - profiles: await context.runtimeRepository.deleteCustomWorldProfile( + sendApiResponse( + response, + { + entries: await context.runtimeRepository.deleteCustomWorldProfile( + request.userId!, + profileId, + ), + } satisfies CustomWorldLibraryResponse, + ); + }), + ); + + router.post( + '/runtime/custom-world-library/:profileId/publish', + routeMeta({ operation: 'runtime.customWorldLibrary.publish' }), + asyncHandler(async (request, response) => { + const profileId = readParam(request.params.profileId); + if (!profileId) { + throw badRequest('profileId is required'); + } + + const authorDisplayName = await resolveAuthDisplayName( + context, + request.userId!, + ); + const mutation = + await context.runtimeRepository.publishCustomWorldProfile( request.userId!, profileId, - ), - }); + authorDisplayName, + ); + if (!mutation) { + throw notFound('custom world not found'); + } + + sendApiResponse( + response, + mutation satisfies CustomWorldLibraryMutationResponse, + ); + }), + ); + + router.post( + '/runtime/custom-world-library/:profileId/unpublish', + routeMeta({ operation: 'runtime.customWorldLibrary.unpublish' }), + asyncHandler(async (request, response) => { + const profileId = readParam(request.params.profileId); + if (!profileId) { + throw badRequest('profileId is required'); + } + + const authorDisplayName = await resolveAuthDisplayName( + context, + request.userId!, + ); + const mutation = + await context.runtimeRepository.unpublishCustomWorldProfile( + request.userId!, + profileId, + authorDisplayName, + ); + if (!mutation) { + throw notFound('custom world not found'); + } + + sendApiResponse( + response, + mutation satisfies CustomWorldLibraryMutationResponse, + ); }), ); diff --git a/src/components/CustomWorldEntityEditorModal.tsx b/src/components/CustomWorldEntityEditorModal.tsx index 620d9765..44a3d0a5 100644 --- a/src/components/CustomWorldEntityEditorModal.tsx +++ b/src/components/CustomWorldEntityEditorModal.tsx @@ -20,12 +20,8 @@ import { import { type CustomWorldSceneImageResult, generateCustomWorldSceneImage, -} from '../services/ai'; +} from '../services/aiService'; import { resolveCustomWorldCampScene } from '../services/customWorldCamp'; -import { - buildCustomWorldSceneImagePrompt, - DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT, -} from '../services/customWorld'; import { AnimationState, CustomWorldLandmark, diff --git a/src/components/CustomWorldResultView.tsx b/src/components/CustomWorldResultView.tsx index 5d25d548..e13ff512 100644 --- a/src/components/CustomWorldResultView.tsx +++ b/src/components/CustomWorldResultView.tsx @@ -213,7 +213,7 @@ export function CustomWorldResultView({ style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 16, paddingY: 10 })} >
- 保存并进入世界 + 保存到我的作品
diff --git a/src/components/GameShell.tsx b/src/components/GameShell.tsx index 5bc30ffe..81080c5b 100644 --- a/src/components/GameShell.tsx +++ b/src/components/GameShell.tsx @@ -2,6 +2,7 @@ import {AnimatePresence, motion} from 'motion/react'; import {lazy, Suspense, useCallback, useEffect, useMemo, useState} from 'react'; import {getLiveGamePlayTimeMs} from '../data/runtimeStats'; +import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes'; import {getWorldCampScenePreset} from '../data/scenePresets'; import {BottomTab} from '../hooks/useGameFlow'; import { @@ -55,6 +56,7 @@ interface GameShellStoryProps { interface GameShellEntryProps { hasSavedGame: boolean; + savedSnapshot: HydratedSavedGameSnapshot | null; handleContinueGame: () => void; handleStartNewGame: () => void; handleSaveAndExit: () => void; @@ -208,6 +210,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP } = story; const { hasSavedGame, + savedSnapshot, handleContinueGame, handleStartNewGame, handleSaveAndExit, @@ -272,7 +275,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP !gameState.playerCharacter; const hideSelectionHero = gameState.currentScene === 'Selection' && - selectionStage !== 'start'; + selectionStage !== 'platform'; const shouldHideStoryOptions = sceneTransitionPhase !== 'idle'; const dialogueIndicator = useMemo(() => { @@ -428,6 +431,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP setSelectionStage={setSelectionStage} gameState={gameState} hasSavedGame={hasSavedGame} + savedSnapshot={savedSnapshot} handleContinueGame={handleContinueGame} handleStartNewGame={handleStartNewGame} handleCustomWorldSelect={handleCustomWorldSelect} @@ -447,7 +451,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP customWorldProfile={gameState.customWorldProfile} onBack={() => { handleBackToWorldSelect(); - setSelectionStage('world'); + setSelectionStage('platform'); }} onConfirm={handleCharacterSelect} /> diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index 43c9767b..a1eafe3e 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useEffect, useState } from 'react'; +import { type ReactNode, useEffect, useMemo, useState } from 'react'; import { AUTH_STATE_EVENT, @@ -30,6 +30,7 @@ import { startWechatLogin, } from '../../services/authService'; import { AccountModal } from './AccountModal'; +import { AuthUiContext } from './AuthUiContext'; import { BindPhoneScreen } from './BindPhoneScreen'; import { LoginScreen } from './LoginScreen'; @@ -61,6 +62,7 @@ export function AuthGate({ children }: AuthGateProps) { const [bindingPhone, setBindingPhone] = useState(false); const [wechatLoading, setWechatLoading] = useState(false); const [showAccountModal, setShowAccountModal] = useState(false); + const [showGlobalAccountActions, setShowGlobalAccountActions] = useState(true); const [sessions, setSessions] = useState([]); const [loadingSessions, setLoadingSessions] = useState(false); const [auditLogs, setAuditLogs] = useState([]); @@ -304,6 +306,19 @@ export function AuthGate({ children }: AuthGateProps) { }; }, [showAccountModal, status]); + const authUiValue = useMemo( + () => ({ + user, + openAccountModal: () => setShowAccountModal(true), + logout: async () => { + await logoutAuthUser(); + setShowAccountModal(false); + }, + setGlobalAccountActionsVisible: setShowGlobalAccountActions, + }), + [user], + ); + if (status === 'checking') { return (
@@ -468,140 +483,144 @@ export function AuthGate({ children }: AuthGateProps) { } return ( -
-
-
- - -
-
- setShowAccountModal(false)} - onLogout={async () => { - await logoutAuthUser(); - setShowAccountModal(false); - }} - onRefreshRiskBlocks={async () => { - setLoadingRiskBlocks(true); - try { - setRiskBlocks(await getAuthRiskBlocks()); - } catch (blockError) { - setError( - blockError instanceof Error - ? blockError.message - : '读取安全状态失败,请稍后再试。', - ); - } finally { - setLoadingRiskBlocks(false); - } - }} - onLiftRiskBlock={async (scopeType) => { - try { - await liftAuthRiskBlock(scopeType); - setRiskBlocks(await getAuthRiskBlocks()); - setAuditLogs(await getAuthAuditLogs()); - } catch (liftError) { - setError( - liftError instanceof Error - ? liftError.message - : '解除保护失败,请稍后再试。', - ); - } - }} - onRefreshSessions={async () => { - setLoadingSessions(true); - try { - setSessions(await getAuthSessions()); - } catch (sessionError) { - setError( - sessionError instanceof Error - ? sessionError.message - : '读取登录设备失败,请稍后再试。', - ); - } finally { - setLoadingSessions(false); - } - }} - onRefreshAuditLogs={async () => { - setLoadingAuditLogs(true); - try { - setAuditLogs(await getAuthAuditLogs()); - } catch (auditError) { - setError( - auditError instanceof Error - ? auditError.message - : '读取账号操作记录失败,请稍后再试。', - ); - } finally { - setLoadingAuditLogs(false); - } - }} - onRevokeSession={async (sessionId) => { - try { - await revokeAuthSession(sessionId); - setSessions((current) => - current.filter((session) => session.sessionId !== sessionId), - ); - setAuditLogs(await getAuthAuditLogs()); - } catch (revokeError) { - setError( - revokeError instanceof Error - ? revokeError.message - : '移除登录设备失败,请稍后再试。', - ); - } - }} - onLogoutAll={async () => { - await logoutAllAuthSessions(); - setShowAccountModal(false); - }} - changePhoneCaptchaChallenge={changePhoneCaptchaChallenge} - onSendChangePhoneCode={async (phone, captcha) => { - try { - const result = await sendPhoneLoginCode( - phone, - 'change_phone', - captcha, - ); - setChangePhoneCaptchaChallenge(null); - return result; - } catch (sendError) { - const captchaChallenge = getCaptchaChallengeFromError(sendError); - if (captchaChallenge) { - setChangePhoneCaptchaChallenge(captchaChallenge); + +
+ {showGlobalAccountActions ? ( +
+
+ + +
+
+ ) : null} + setShowAccountModal(false)} + onLogout={async () => { + await logoutAuthUser(); + setShowAccountModal(false); + }} + onRefreshRiskBlocks={async () => { + setLoadingRiskBlocks(true); + try { + setRiskBlocks(await getAuthRiskBlocks()); + } catch (blockError) { + setError( + blockError instanceof Error + ? blockError.message + : '读取安全状态失败,请稍后再试。', + ); + } finally { + setLoadingRiskBlocks(false); } - throw sendError; - } - }} - onChangePhone={async (phone, code) => { - const nextUser = await changePhoneNumber(phone, code); - setChangePhoneCaptchaChallenge(null); - setUser(nextUser); - }} - /> - {children} -
+ }} + onLiftRiskBlock={async (scopeType) => { + try { + await liftAuthRiskBlock(scopeType); + setRiskBlocks(await getAuthRiskBlocks()); + setAuditLogs(await getAuthAuditLogs()); + } catch (liftError) { + setError( + liftError instanceof Error + ? liftError.message + : '解除保护失败,请稍后再试。', + ); + } + }} + onRefreshSessions={async () => { + setLoadingSessions(true); + try { + setSessions(await getAuthSessions()); + } catch (sessionError) { + setError( + sessionError instanceof Error + ? sessionError.message + : '读取登录设备失败,请稍后再试。', + ); + } finally { + setLoadingSessions(false); + } + }} + onRefreshAuditLogs={async () => { + setLoadingAuditLogs(true); + try { + setAuditLogs(await getAuthAuditLogs()); + } catch (auditError) { + setError( + auditError instanceof Error + ? auditError.message + : '读取账号操作记录失败,请稍后再试。', + ); + } finally { + setLoadingAuditLogs(false); + } + }} + onRevokeSession={async (sessionId) => { + try { + await revokeAuthSession(sessionId); + setSessions((current) => + current.filter((session) => session.sessionId !== sessionId), + ); + setAuditLogs(await getAuthAuditLogs()); + } catch (revokeError) { + setError( + revokeError instanceof Error + ? revokeError.message + : '移除登录设备失败,请稍后再试。', + ); + } + }} + onLogoutAll={async () => { + await logoutAllAuthSessions(); + setShowAccountModal(false); + }} + changePhoneCaptchaChallenge={changePhoneCaptchaChallenge} + onSendChangePhoneCode={async (phone, captcha) => { + try { + const result = await sendPhoneLoginCode( + phone, + 'change_phone', + captcha, + ); + setChangePhoneCaptchaChallenge(null); + return result; + } catch (sendError) { + const captchaChallenge = getCaptchaChallengeFromError(sendError); + if (captchaChallenge) { + setChangePhoneCaptchaChallenge(captchaChallenge); + } + throw sendError; + } + }} + onChangePhone={async (phone, code) => { + const nextUser = await changePhoneNumber(phone, code); + setChangePhoneCaptchaChallenge(null); + setUser(nextUser); + }} + /> + {children} +
+ ); } diff --git a/src/components/auth/AuthUiContext.ts b/src/components/auth/AuthUiContext.ts new file mode 100644 index 00000000..9b4a4220 --- /dev/null +++ b/src/components/auth/AuthUiContext.ts @@ -0,0 +1,16 @@ +import { createContext, useContext } from 'react'; + +import type { AuthUser } from '../../services/authService'; + +type AuthUiContextValue = { + user: AuthUser | null; + openAccountModal: () => void; + logout: () => Promise; + setGlobalAccountActionsVisible: (visible: boolean) => void; +}; + +export const AuthUiContext = createContext(null); + +export function useAuthUi() { + return useContext(AuthUiContext); +} diff --git a/src/components/game-shell/GameShellMainContent.tsx b/src/components/game-shell/GameShellMainContent.tsx index 628da06e..55cb43c7 100644 --- a/src/components/game-shell/GameShellMainContent.tsx +++ b/src/components/game-shell/GameShellMainContent.tsx @@ -1,4 +1,5 @@ import { AnimatePresence, motion } from 'motion/react'; +import { lazy, Suspense } from 'react'; import type { BottomTab } from '../../hooks/useGameFlow'; import type { @@ -8,6 +9,7 @@ import type { InventoryFlowUi, QuestFlowUi, } from '../../hooks/useStoryGeneration'; +import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import type { CompanionRenderState, CustomWorldProfile, @@ -17,11 +19,38 @@ import type { } from '../../types'; import { UI_CHROME } from '../../uiAssets'; import type { GameCanvasEntitySelection } from '../GameCanvas'; -import { CharacterSelectionFlow } from './CharacterSelectionFlow'; -import { GameShellStoryPanels } from './GameShellStoryPanels'; -import { PreGameSelectionFlow, type SelectionStage } from './PreGameSelectionFlow'; +import type { SelectionStage } from './PreGameSelectionFlow'; import type { GameShellAdventureStatistics } from './types'; +const CharacterSelectionFlow = lazy(async () => { + const module = await import('./CharacterSelectionFlow'); + return { + default: module.CharacterSelectionFlow, + }; +}); +const PreGameSelectionFlow = lazy(async () => { + const module = await import('./PreGameSelectionFlow'); + return { + default: module.PreGameSelectionFlow, + }; +}); +const GameShellStoryPanels = lazy(async () => { + const module = await import('./GameShellStoryPanels'); + return { + default: module.GameShellStoryPanels, + }; +}); + +function MainContentLoadingFallback({ label }: { label: string }) { + return ( +
+
+ {label} +
+
+ ); +} + export function GameShellMainContent({ gameState, visibleGameState, @@ -34,6 +63,7 @@ export function GameShellMainContent({ setSelectionStage, isCharacterSelectionStage, hasSavedGame, + savedSnapshot, handleContinueGame, handleStartNewGame, handleCustomWorldSelect, @@ -71,6 +101,7 @@ export function GameShellMainContent({ setSelectionStage: (stage: SelectionStage) => void; isCharacterSelectionStage: boolean; hasSavedGame: boolean; + savedSnapshot: HydratedSavedGameSnapshot | null; handleContinueGame: () => void; handleStartNewGame: () => void; handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void; @@ -110,15 +141,20 @@ export function GameShellMainContent({ > {!gameState.worldType && ( - + } + > + + )} {gameState.worldType && !gameState.playerCharacter && ( @@ -129,50 +165,58 @@ export function GameShellMainContent({ exit={{ opacity: 0, y: -12 }} className="flex h-full min-h-0 flex-col" > - { - handleBackToWorldSelect(); - setSelectionStage('world'); - }} - onConfirm={handleCharacterSelect} - /> + } + > + { + handleBackToWorldSelect(); + setSelectionStage('platform'); + }} + onConfirm={handleCharacterSelect} + /> + )} {visibleGameState.playerCharacter && visibleCurrentStory && ( - { - resetForSaveAndExit(); - handleSaveAndExit(); - }} - /> + } + > + { + resetForSaveAndExit(); + handleSaveAndExit(); + }} + /> + )} diff --git a/src/components/game-shell/GameShellRuntime.tsx b/src/components/game-shell/GameShellRuntime.tsx index 1584e595..399484fb 100644 --- a/src/components/game-shell/GameShellRuntime.tsx +++ b/src/components/game-shell/GameShellRuntime.tsx @@ -1,11 +1,26 @@ +import { lazy, Suspense, useEffect } from 'react'; + import { UI_CHROME } from '../../uiAssets'; -import { GameShellCanvasStage } from './GameShellCanvasStage'; +import { useAuthUi } from '../auth/AuthUiContext'; import { GameShellMainContent } from './GameShellMainContent'; -import { GameShellOverlays } from './GameShellOverlays'; import type { GameShellProps } from './types'; import { useGameShellRuntimeViewModel } from './useGameShellRuntimeViewModel'; +const GameShellOverlays = lazy(async () => { + const module = await import('./GameShellOverlays'); + return { + default: module.GameShellOverlays, + }; +}); +const GameShellCanvasStage = lazy(async () => { + const module = await import('./GameShellCanvasStage'); + return { + default: module.GameShellCanvasStage, + }; +}); + export function GameShellRuntime({session, story, entry, companions, audio}: GameShellProps) { + const authUi = useAuthUi(); const { gameState, isLoading, @@ -29,6 +44,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam } = story; const { hasSavedGame, + savedSnapshot, handleContinueGame, handleStartNewGame, handleSaveAndExit, @@ -80,6 +96,14 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam companions, }); + useEffect(() => { + authUi?.setGlobalAccountActionsVisible(Boolean(gameState.playerCharacter)); + + return () => { + authUi?.setGlobalAccountActionsVisible(true); + }; + }, [authUi, gameState.playerCharacter]); + return (
- + + + - + + +
); } - diff --git a/src/components/game-shell/PlatformHomeView.tsx b/src/components/game-shell/PlatformHomeView.tsx new file mode 100644 index 00000000..bfa6ec87 --- /dev/null +++ b/src/components/game-shell/PlatformHomeView.tsx @@ -0,0 +1,356 @@ +import type { + CustomWorldGalleryCard, + CustomWorldLibraryEntry, +} from '../../../packages/shared/src/contracts/runtime'; +import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; +import type { CustomWorldProfile } from '../../types'; +import { + CHROME_ICONS, + getNineSliceStyle, + UI_CHROME, +} from '../../uiAssets'; +import { useAuthUi } from '../auth/AuthUiContext'; +import { PixelIcon } from '../PixelIcon'; +import { + buildPlatformWorldTags, + describePlatformThemeLabel, + formatPlatformWorldTime, + type PlatformWorldCardLike, + resolvePlatformWorldCoverImage, + resolvePlatformWorldLeadPortrait, +} from './platformWorldPresentation'; + +function SectionHeader({ + title, + detail, + actionLabel, + onAction, +}: { + title: string; + detail: string; + actionLabel?: string; + onAction?: (() => void) | null; +}) { + return ( +
+
+
+ {detail} +
+
{title}
+
+ {actionLabel && onAction ? ( + + ) : null} +
+ ); +} + +function EmptyShelf({ + text, +}: { + text: string; +}) { + return ( +
+ {text} +
+ ); +} + +function WorldCard({ + entry, + badge, + metaLabel, + onClick, +}: { + entry: PlatformWorldCardLike; + badge: string; + metaLabel: string; + onClick: () => void; +}) { + const coverImage = resolvePlatformWorldCoverImage(entry); + const leadPortrait = resolvePlatformWorldLeadPortrait(entry); + const tags = buildPlatformWorldTags(entry); + + return ( + + ); +} + +export function PlatformHomeView({ + hasSavedGame, + savedSnapshot, + featuredEntries, + latestEntries, + myEntries, + isLoadingPlatform, + platformError, + onContinueGame, + onRefresh, + onOpenCreateWorld, + onOpenGalleryDetail, + onOpenLibraryDetail, +}: { + hasSavedGame: boolean; + savedSnapshot: HydratedSavedGameSnapshot | null; + featuredEntries: CustomWorldGalleryCard[]; + latestEntries: CustomWorldGalleryCard[]; + myEntries: CustomWorldLibraryEntry[]; + isLoadingPlatform: boolean; + platformError: string | null; + onContinueGame: () => void; + onRefresh: () => void; + onOpenCreateWorld: () => void; + onOpenGalleryDetail: (entry: CustomWorldGalleryCard) => void; + onOpenLibraryDetail: (entry: CustomWorldLibraryEntry) => void; +}) { + const authUi = useAuthUi(); + const snapshotWorldName = + savedSnapshot?.gameState.customWorldProfile?.name ?? + savedSnapshot?.gameState.currentScenePreset?.name ?? + '继续冒险'; + const snapshotCharacterName = + savedSnapshot?.gameState.playerCharacter?.title ?? + savedSnapshot?.gameState.playerCharacter?.name ?? + '旅人'; + const featuredShelf = featuredEntries.slice(0, 6); + + return ( +
+
+
+
+ +
+
+
+ GENARRATIVE PLATFORM +
+
+ 自定义世界广场 +
+
+
+
+ + {authUi?.user ? ( + + ) : null} +
+
+ +
+
+ + + {platformError ? ( +
+ {platformError} +
+ ) : null} + +
+ + {isLoadingPlatform ? ( + + ) : featuredShelf.length > 0 ? ( +
+ {featuredShelf.map((entry) => ( + onOpenGalleryDetail(entry)} + /> + ))} +
+ ) : ( + + )} +
+ +
+ + {isLoadingPlatform ? ( + + ) : latestEntries.length > 0 ? ( +
+ {latestEntries.map((entry) => ( + onOpenGalleryDetail(entry)} + /> + ))} +
+ ) : ( + + )} +
+ +
+ +
+ + + {myEntries.map((entry) => ( + onOpenLibraryDetail(entry)} + /> + ))} +
+ {!isLoadingPlatform && myEntries.length === 0 ? ( +
+ +
+ ) : null} +
+
+
+
+ ); +} diff --git a/src/components/game-shell/PlatformWorldDetailView.tsx b/src/components/game-shell/PlatformWorldDetailView.tsx new file mode 100644 index 00000000..960e930f --- /dev/null +++ b/src/components/game-shell/PlatformWorldDetailView.tsx @@ -0,0 +1,278 @@ +import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; +import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; +import type { CustomWorldProfile } from '../../types'; +import { getNineSliceStyle, UI_CHROME } from '../../uiAssets'; +import { + buildPlatformWorldTags, + describePlatformThemeLabel, + formatPlatformWorldTime, + resolvePlatformWorldCoverImage, + resolvePlatformWorldLeadPortrait, +} from './platformWorldPresentation'; + +function ActionButton({ + label, + onClick, + tone = 'default', + disabled = false, +}: { + label: string; + onClick: () => void; + tone?: 'default' | 'primary' | 'danger'; + disabled?: boolean; +}) { + const toneClass = + tone === 'primary' + ? 'border-sky-300/25 bg-sky-500/10 text-sky-100 hover:border-sky-300/45 hover:text-white' + : tone === 'danger' + ? 'border-rose-400/25 bg-rose-500/10 text-rose-100 hover:border-rose-400/45 hover:text-white' + : 'border-white/10 bg-black/20 text-zinc-200 hover:border-white/20 hover:text-white'; + + return ( + + ); +} + +export function PlatformWorldDetailView({ + entry, + isMutating, + error, + onBack, + onStartGame, + onContinueEdit, + onPublish, + onUnpublish, +}: { + entry: CustomWorldLibraryEntry; + isMutating: boolean; + error: string | null; + onBack: () => void; + onStartGame: () => void; + onContinueEdit?: (() => void) | null; + onPublish?: (() => void) | null; + onUnpublish?: (() => void) | null; +}) { + const coverImage = resolvePlatformWorldCoverImage(entry); + const leadPortrait = resolvePlatformWorldLeadPortrait(entry); + const previewCharacters = buildCustomWorldPlayableCharacters(entry.profile).slice( + 0, + 3, + ); + const previewLandmarks = entry.profile.landmarks.slice(0, 3); + const tags = buildPlatformWorldTags(entry); + + return ( +
+
+ +
+ {entry.visibility === 'published' ? '已发布' : '草稿'} +
+
+ +
+
+
+ {coverImage ? ( + {entry.worldName} + ) : null} + {leadPortrait ? ( + + ) : null} +
+
+
+ + {describePlatformThemeLabel(entry.themeMode)} + + + {entry.authorDisplayName} + + + {entry.visibility === 'published' + ? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}` + : '仅自己可见'} + +
+
+ {entry.worldName} +
+ {entry.subtitle ? ( +
+ {entry.subtitle} +
+ ) : null} +
+ {entry.summaryText || '等待补充世界摘要。'} +
+
+ {tags.map((tag) => ( + + {tag} + + ))} +
+
+
+ +
+
+
+ 世界信息 +
+
+
+
+ 可玩角色 +
+
{entry.playableNpcCount}
+
+
+
+ 地标 +
+
{entry.landmarkCount}
+
+
+
+ 阵营 +
+
+ {entry.profile.majorFactions.length} +
+
+
+
+ 冲突 +
+
+ {entry.profile.coreConflicts.length} +
+
+
+ +
+
+ 关键角色 +
+
+ {previewCharacters.map((character) => ( +
+
+ {character.title} +
+
+ {character.description} +
+
+ ))} +
+
+ +
+
+ 关键场景 +
+
+ {previewLandmarks.map((landmark) => ( +
+
+ {landmark.name} +
+
+ {landmark.description} +
+
+ ))} +
+
+
+ +
+
+ 操作 +
+
+ + {onContinueEdit ? ( + + ) : null} + {onPublish ? ( + + ) : null} + {onUnpublish ? ( + + ) : null} +
+ {error ? ( +
+ {error} +
+ ) : null} +
+
+
+
+
+ ); +} diff --git a/src/components/game-shell/PreGameSelectionFlow.tsx b/src/components/game-shell/PreGameSelectionFlow.tsx index d2370c7a..e8d2cabf 100644 --- a/src/components/game-shell/PreGameSelectionFlow.tsx +++ b/src/components/game-shell/PreGameSelectionFlow.tsx @@ -1,30 +1,34 @@ import { AnimatePresence, motion } from 'motion/react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { + lazy, + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; -import { - buildCustomWorldPlayableCharacters, -} from '../../data/characterPresets'; -import type { - CustomWorldGenerationProgress, -} from '../../../packages/shared/src/contracts/runtime'; import type { JsonObject } from '../../../packages/shared/src/contracts/common'; -import { - readSavedCustomWorldProfiles, - upsertSavedCustomWorldProfile, -} from '../../data/customWorldLibrary'; -import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals'; -import { getScenePreset } from '../../data/scenePresets'; -import { - generateCustomWorldProfile, -} from '../../services/aiService'; +import type { + CustomWorldGalleryCard, + CustomWorldGenerationProgress, + CustomWorldLibraryEntry, +} from '../../../packages/shared/src/contracts/runtime'; +import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; +import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; +import { generateCustomWorldProfile } from '../../services/aiService'; import { buildCustomWorldCreatorIntentDisplayText, buildCustomWorldCreatorIntentGenerationText, createEmptyCustomWorldCreatorIntent, } from '../../services/customWorldCreatorIntent'; -import { detectCustomWorldThemeMode } from '../../services/customWorldTheme'; import { + getCustomWorldGalleryDetail, + listCustomWorldGallery, listCustomWorldLibrary, + publishCustomWorldProfile, + unpublishCustomWorldProfile, upsertCustomWorldProfile, } from '../../services/storageService'; import { @@ -33,21 +37,31 @@ import { type CustomWorldProfile, type GameState, } from '../../types'; -import { - CUSTOM_WORLD_THEME_ICONS, - CHROME_ICONS, - getNineSliceStyle, - UI_CHROME, -} from '../../uiAssets'; -import { CustomWorldGenerationView } from '../CustomWorldGenerationView'; -import { CustomWorldResultView } from '../CustomWorldResultView'; -import { DeveloperTeamModal } from '../DeveloperTeamModal'; -import { PixelIcon } from '../PixelIcon'; -import { CustomWorldCreatorModal } from '../SelectionCustomizationModals'; +import { PlatformHomeView } from './PlatformHomeView'; +import { PlatformWorldDetailView } from './PlatformWorldDetailView'; + +const CustomWorldGenerationView = lazy(async () => { + const module = await import('../CustomWorldGenerationView'); + return { + default: module.CustomWorldGenerationView, + }; +}); +const CustomWorldResultView = lazy(async () => { + const module = await import('../CustomWorldResultView'); + return { + default: module.CustomWorldResultView, + }; +}); +const CustomWorldCreatorModal = lazy(async () => { + const module = await import('../SelectionCustomizationModals'); + return { + default: module.CustomWorldCreatorModal, + }; +}); export type SelectionStage = - | 'start' - | 'world' + | 'platform' + | 'detail' | 'custom-world-generating' | 'custom-world-result'; @@ -56,19 +70,12 @@ type PreGameSelectionFlowProps = { setSelectionStage: (stage: SelectionStage) => void; gameState: GameState; hasSavedGame: boolean; + savedSnapshot: HydratedSavedGameSnapshot | null; handleContinueGame: () => void; handleStartNewGame: () => void; handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void; }; -const DEVELOPER_TEAM_MESSAGE = - '\u7a0b\u7b56\u7f8e\uff1a\u53d9\u4e16AI \u5305\u4ef2\u822a\n\u5408\u4f5c\u8bf7\u8054\u7cfb\u5fae\u4fe1\uff1abzh253518756'; - -const START_SCREEN_CONTACTS = [ - { label: 'QQ群', value: '1094580241' }, - { label: '微信', value: 'bzh253518756' }, -] as const; - function buildLockedSeedNameSets(profile: CustomWorldProfile) { const lockedCharacterNames = new Set( profile.creatorIntent?.keyCharacters @@ -136,21 +143,39 @@ function mergeLockedProfileContent( } satisfies CustomWorldProfile; } +function resolveErrorMessage(error: unknown, fallback: string) { + return error instanceof Error ? error.message : fallback; +} + +function LazyPanelFallback({ label }: { label: string }) { + return ( +
+
+ {label} +
+
+ ); +} + export function PreGameSelectionFlow({ selectionStage, setSelectionStage, - gameState, hasSavedGame, + savedSnapshot, handleContinueGame, handleStartNewGame, handleCustomWorldSelect, }: PreGameSelectionFlowProps) { const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] = - useState(null); - const [savedCustomWorldProfiles, setSavedCustomWorldProfiles] = useState< - CustomWorldProfile[] + useState(null); + const [savedCustomWorldEntries, setSavedCustomWorldEntries] = useState< + CustomWorldLibraryEntry[] >([]); - const [showDeveloperTeamModal, setShowDeveloperTeamModal] = useState(false); + const [publishedGalleryEntries, setPublishedGalleryEntries] = useState< + CustomWorldGalleryCard[] + >([]); + const [selectedDetailEntry, setSelectedDetailEntry] = + useState | null>(null); const [showCustomWorldModal, setShowCustomWorldModal] = useState(false); const [customWorldCreatorIntent, setCustomWorldCreatorIntent] = useState(() => @@ -159,7 +184,12 @@ export function PreGameSelectionFlow({ const [customWorldGenerationMode, setCustomWorldGenerationMode] = useState('fast'); const [customWorldError, setCustomWorldError] = useState(null); + const [platformError, setPlatformError] = useState(null); + const [detailError, setDetailError] = useState(null); const [isGeneratingCustomWorld, setIsGeneratingCustomWorld] = useState(false); + const [isLoadingPlatform, setIsLoadingPlatform] = useState(false); + const [isDetailLoading, setIsDetailLoading] = useState(false); + const [isMutatingDetail, setIsMutatingDetail] = useState(false); const [customWorldProgress, setCustomWorldProgress] = useState(null); const customWorldAbortControllerRef = useRef(null); @@ -171,30 +201,83 @@ export function PreGameSelectionFlow({ : [], [generatedCustomWorldProfile], ); + const featuredGalleryEntries = useMemo( + () => publishedGalleryEntries.slice(0, 6), + [publishedGalleryEntries], + ); - const savedCustomWorldCards = useMemo( - () => - savedCustomWorldProfiles.map((profile) => { - const themeMode = detectCustomWorldThemeMode(profile); - const leadCharacter = - buildCustomWorldPlayableCharacters(profile)[0] ?? null; + const refreshPlatformData = useCallback(async () => { + setIsLoadingPlatform(true); + setPlatformError(null); - return { - id: profile.id, - profile, - texture: UI_CHROME.panel, - sceneImage: resolveCustomWorldCampSceneImage(profile) ?? '', - featurePortrait: leadCharacter?.portrait ?? '', - featureIcon: - themeMode === 'martial' - ? CUSTOM_WORLD_THEME_ICONS.martial - : themeMode === 'arcane' - ? CUSTOM_WORLD_THEME_ICONS.arcane - : CHROME_ICONS.refreshOptions, - accentLabel: '自定义世界', - }; - }), - [savedCustomWorldProfiles], + try { + const [libraryEntries, galleryEntries] = await Promise.all([ + listCustomWorldLibrary(), + listCustomWorldGallery(), + ]); + setSavedCustomWorldEntries(libraryEntries); + setPublishedGalleryEntries(galleryEntries); + if (selectedDetailEntry) { + const nextOwnedEntry = libraryEntries.find( + (entry) => + entry.ownerUserId === selectedDetailEntry.ownerUserId && + entry.profileId === selectedDetailEntry.profileId, + ); + if (nextOwnedEntry) { + setSelectedDetailEntry(nextOwnedEntry); + } + } + } catch (error) { + setPlatformError(resolveErrorMessage(error, '读取平台数据失败。')); + } finally { + setIsLoadingPlatform(false); + } + }, [selectedDetailEntry]); + + useEffect(() => { + let isActive = true; + + void (async () => { + setIsLoadingPlatform(true); + setPlatformError(null); + try { + const [libraryEntries, galleryEntries] = await Promise.all([ + listCustomWorldLibrary(), + listCustomWorldGallery(), + ]); + if (!isActive) { + return; + } + setSavedCustomWorldEntries(libraryEntries); + setPublishedGalleryEntries(galleryEntries); + } catch (error) { + if (!isActive) { + return; + } + setPlatformError(resolveErrorMessage(error, '读取平台数据失败。')); + } finally { + if (isActive) { + setIsLoadingPlatform(false); + } + } + })(); + + return () => { + isActive = false; + }; + }, []); + + useEffect(() => { + if (selectionStage === 'custom-world-result' && !generatedCustomWorldProfile) { + setSelectionStage(selectedDetailEntry ? 'detail' : 'platform'); + } + }, [generatedCustomWorldProfile, selectedDetailEntry, selectionStage, setSelectionStage]); + + useEffect( + () => () => { + customWorldAbortControllerRef.current?.abort(); + }, + [], ); const customWorldSettingPreview = useMemo(() => { @@ -210,46 +293,11 @@ export function PreGameSelectionFlow({ return customWorldCreatorIntent.rawSettingText.trim(); }, [customWorldCreatorIntent]); - useEffect(() => { - if ( - selectionStage === 'custom-world-result' && - !generatedCustomWorldProfile - ) { - setSelectionStage('world'); - } - }, [generatedCustomWorldProfile, selectionStage, setSelectionStage]); - - useEffect( - () => () => { - customWorldAbortControllerRef.current?.abort(); - }, - [], - ); - useEffect(() => { - let isActive = true; - - void listCustomWorldLibrary() - .then((profiles) => { - if (!isActive) return; - setSavedCustomWorldProfiles(profiles); - }) - .catch((error) => { - console.warn( - '[PreGameSelectionFlow] failed to load custom world library', - error, - ); - }); - - return () => { - isActive = false; - }; - }, []); - const leaveCustomWorldResult = () => { setGeneratedCustomWorldProfile(null); setCustomWorldError(null); setCustomWorldProgress(null); - setSelectionStage('world'); + setSelectionStage(selectedDetailEntry ? 'detail' : 'platform'); }; const leaveCustomWorldGeneration = () => { @@ -259,7 +307,7 @@ export function PreGameSelectionFlow({ setCustomWorldError(null); setCustomWorldProgress(null); - setSelectionStage('world'); + setSelectionStage('platform'); }; const openCustomWorldCreator = () => { @@ -267,8 +315,17 @@ export function PreGameSelectionFlow({ return; } + if (!hasSavedGame) { + handleStartNewGame(); + } + setGeneratedCustomWorldProfile(null); + setSelectedDetailEntry(null); + setPlatformError(null); + setDetailError(null); setCustomWorldError(null); setCustomWorldProgress(null); + setCustomWorldCreatorIntent(createEmptyCustomWorldCreatorIntent('freeform')); + setCustomWorldGenerationMode('fast'); setShowCustomWorldModal(true); }; @@ -291,47 +348,71 @@ export function PreGameSelectionFlow({ } setCustomWorldError(null); setCustomWorldProgress(null); - setSelectionStage('world'); setShowCustomWorldModal(true); }; + const openLibraryDetail = ( + entry: CustomWorldLibraryEntry, + ) => { + setSelectedDetailEntry(entry); + setDetailError(null); + setSelectionStage('detail'); + }; + + const openGalleryDetail = async (entry: CustomWorldGalleryCard) => { + setSelectionStage('detail'); + setIsDetailLoading(true); + setDetailError(null); + try { + const detailEntry = await getCustomWorldGalleryDetail( + entry.ownerUserId, + entry.profileId, + ); + setSelectedDetailEntry(detailEntry); + } catch (error) { + setSelectedDetailEntry(null); + setDetailError(resolveErrorMessage(error, '读取作品详情失败。')); + } finally { + setIsDetailLoading(false); + } + }; + const saveGeneratedCustomWorld = async () => { if (!generatedCustomWorldProfile) { return; } try { - setSavedCustomWorldProfiles( - await upsertCustomWorldProfile(generatedCustomWorldProfile), - ); + const mutation = await upsertCustomWorldProfile(generatedCustomWorldProfile); + setSavedCustomWorldEntries(mutation.entries); + setSelectedDetailEntry(mutation.entry); + await refreshPlatformData(); + setGeneratedCustomWorldProfile(null); + setCustomWorldError(null); + setCustomWorldProgress(null); + setSelectionStage('platform'); } catch (error) { - setCustomWorldError( - error instanceof Error ? error.message : '保存自定义世界失败。', - ); - return; + setCustomWorldError(resolveErrorMessage(error, '保存自定义世界失败。')); } - - handleCustomWorldSelect(generatedCustomWorldProfile); - setGeneratedCustomWorldProfile(null); - setCustomWorldError(null); - setCustomWorldProgress(null); - setSelectionStage('world'); }; - const openSavedCustomWorldEditor = (profile: CustomWorldProfile) => { + const openSavedCustomWorldEditor = ( + entry: CustomWorldLibraryEntry, + ) => { if (isGeneratingCustomWorld) { return; } - setGeneratedCustomWorldProfile(profile); + setSelectedDetailEntry(entry); + setGeneratedCustomWorldProfile(entry.profile); setCustomWorldCreatorIntent( - profile.creatorIntent ?? + entry.profile.creatorIntent ?? ({ ...createEmptyCustomWorldCreatorIntent('freeform'), - rawSettingText: profile.settingText, + rawSettingText: entry.profile.settingText, } satisfies CustomWorldCreatorIntent), ); - setCustomWorldGenerationMode(profile.generationMode ?? 'full'); + setCustomWorldGenerationMode(entry.profile.generationMode ?? 'full'); setCustomWorldError(null); setCustomWorldProgress(null); setSelectionStage('custom-world-result'); @@ -390,7 +471,10 @@ export function PreGameSelectionFlow({ generatedCustomWorldProfile, mergeLockedProfileContent(generatedCustomWorldProfile, regeneratedProfile), ); - setGeneratedCustomWorldProfile(mergedProfile); + setGeneratedCustomWorldProfile({ + ...mergedProfile, + id: generatedCustomWorldProfile.id, + }); setCustomWorldProgress(null); setCustomWorldError(null); } catch (error) { @@ -398,9 +482,7 @@ export function PreGameSelectionFlow({ setCustomWorldError('世界生成已中断。你可以重新尝试本次操作。'); return; } - setCustomWorldError( - error instanceof Error ? error.message : '局部重生成失败。', - ); + setCustomWorldError(resolveErrorMessage(error, '局部重生成失败。')); } finally { if (customWorldAbortControllerRef.current === abortController) { customWorldAbortControllerRef.current = null; @@ -448,7 +530,6 @@ export function PreGameSelectionFlow({ customWorldAbortControllerRef.current?.abort(); customWorldAbortControllerRef.current = abortController; setCustomWorldError(null); - setGeneratedCustomWorldProfile(null); setCustomWorldProgress(null); setShowCustomWorldModal(false); setSelectionStage('custom-world-generating'); @@ -469,26 +550,24 @@ export function PreGameSelectionFlow({ if (abortController.signal.aborted) { return; } - const persistedProfile = generatedCustomWorldProfile - ? { - ...profile, - id: generatedCustomWorldProfile.id, - } - : profile; - const savedProfiles = await upsertCustomWorldProfile(persistedProfile); - setSavedCustomWorldProfiles(savedProfiles); - setGeneratedCustomWorldProfile(null); - setCustomWorldError(null); + + setGeneratedCustomWorldProfile( + generatedCustomWorldProfile + ? { + ...profile, + id: generatedCustomWorldProfile.id, + } + : profile, + ); setCustomWorldProgress(null); - setSelectionStage('world'); + setCustomWorldError(null); + setSelectionStage('custom-world-result'); } catch (error) { if (abortController.signal.aborted) { setCustomWorldError('世界生成已中断。你可以返回修改设定,或重新开始。'); return; } - setCustomWorldError( - error instanceof Error ? error.message : '生成自定义世界失败。', - ); + setCustomWorldError(resolveErrorMessage(error, '生成自定义世界失败。')); } finally { if (customWorldAbortControllerRef.current === abortController) { customWorldAbortControllerRef.current = null; @@ -512,242 +591,155 @@ export function PreGameSelectionFlow({ customWorldAbortControllerRef.current.abort(new Error('世界生成已中断。')); }; + const handleStartSelectedWorld = () => { + if (!selectedDetailEntry) { + return; + } + + handleCustomWorldSelect(selectedDetailEntry.profile); + }; + + const handlePublishSelectedWorld = async () => { + if (!selectedDetailEntry || isMutatingDetail) { + return; + } + + setIsMutatingDetail(true); + setDetailError(null); + try { + const mutation = await publishCustomWorldProfile(selectedDetailEntry.profileId); + setSavedCustomWorldEntries(mutation.entries); + setSelectedDetailEntry(mutation.entry); + setPublishedGalleryEntries(await listCustomWorldGallery()); + } catch (error) { + setDetailError(resolveErrorMessage(error, '发布自定义世界失败。')); + } finally { + setIsMutatingDetail(false); + } + }; + + const handleUnpublishSelectedWorld = async () => { + if (!selectedDetailEntry || isMutatingDetail) { + return; + } + + setIsMutatingDetail(true); + setDetailError(null); + try { + const mutation = await unpublishCustomWorldProfile( + selectedDetailEntry.profileId, + ); + setSavedCustomWorldEntries(mutation.entries); + setSelectedDetailEntry(mutation.entry); + setPublishedGalleryEntries(await listCustomWorldGallery()); + } catch (error) { + setDetailError(resolveErrorMessage(error, '下架自定义世界失败。')); + } finally { + setIsMutatingDetail(false); + } + }; + return ( <> - {!gameState.worldType && selectionStage === 'start' && ( + {selectionStage === 'platform' && ( -
-
-
- {hasSavedGame && ( - - )} - - -
-
-
-
- 联系方式 -
-
- {START_SCREEN_CONTACTS.map((contact) => ( -
- - {contact.label} - - - {contact.value} - -
- ))} -
-
-
-
- )} - - {!gameState.worldType && selectionStage === 'world' && ( - -
-
- 自定义世界 -
- -
- -
-
- {savedCustomWorldCards.map((world) => ( -
- - -
- ))} - - -
-
+ { + void refreshPlatformData(); + }} + onOpenCreateWorld={openCustomWorldCreator} + onOpenGalleryDetail={(entry) => { + void openGalleryDetail(entry); + }} + onOpenLibraryDetail={openLibraryDetail} + />
)} - {!gameState.worldType && - selectionStage === 'custom-world-generating' && ( - + {selectionStage === 'detail' && ( + + {isDetailLoading || !selectedDetailEntry ? ( +
+
+ {detailError || '正在读取作品详情...'} +
+
+ ) : ( + { + setDetailError(null); + setSelectionStage('platform'); + }} + onStartGame={handleStartSelectedWorld} + onContinueEdit={ + savedCustomWorldEntries.some( + (entry) => + entry.ownerUserId === selectedDetailEntry.ownerUserId && + entry.profileId === selectedDetailEntry.profileId, + ) + ? () => openSavedCustomWorldEditor(selectedDetailEntry) + : null + } + onPublish={ + selectedDetailEntry.visibility === 'draft' && + savedCustomWorldEntries.some( + (entry) => + entry.ownerUserId === selectedDetailEntry.ownerUserId && + entry.profileId === selectedDetailEntry.profileId, + ) + ? handlePublishSelectedWorld + : null + } + onUnpublish={ + selectedDetailEntry.visibility === 'published' && + savedCustomWorldEntries.some( + (entry) => + entry.ownerUserId === selectedDetailEntry.ownerUserId && + entry.profileId === selectedDetailEntry.profileId, + ) + ? handleUnpublishSelectedWorld + : null + } + /> + )} +
+ )} + + {selectionStage === 'custom-world-generating' && ( + + }> - - )} + +
+ )} - {!gameState.worldType && - selectionStage === 'custom-world-result' && + {selectionStage === 'custom-world-result' && generatedCustomWorldProfile && ( - { - void createCustomWorld(); - }} - onContinueExpand={() => { - void continueExpandCustomWorld(); - }} - onSave={() => { - void saveGeneratedCustomWorld(); - }} - /> + }> + { + void createCustomWorld(); + }} + onContinueExpand={() => { + void continueExpandCustomWorld(); + }} + onSave={() => { + void saveGeneratedCustomWorld(); + }} + /> + )}
- { - setCustomWorldCreatorIntent(value); - if (customWorldError) setCustomWorldError(null); - }} - generationMode={customWorldGenerationMode} - onGenerationModeChange={setCustomWorldGenerationMode} - onClose={() => { - if (isGeneratingCustomWorld) return; - setShowCustomWorldModal(false); - }} - onSubmit={() => { - void createCustomWorld(); - }} - isGenerating={isGeneratingCustomWorld} - progress={customWorldProgress?.overallProgress ?? 0} - progressLabel={customWorldProgress?.phaseLabel ?? '正在准备生成'} - error={customWorldError} - /> - - setShowDeveloperTeamModal(false)} - /> + {showCustomWorldModal ? ( + + { + setCustomWorldCreatorIntent(value); + if (customWorldError) setCustomWorldError(null); + }} + generationMode={customWorldGenerationMode} + onGenerationModeChange={setCustomWorldGenerationMode} + onClose={() => { + if (isGeneratingCustomWorld) return; + setShowCustomWorldModal(false); + }} + onSubmit={() => { + void createCustomWorld(); + }} + isGenerating={isGeneratingCustomWorld} + progress={customWorldProgress?.overallProgress ?? 0} + progressLabel={customWorldProgress?.phaseLabel ?? '正在准备生成'} + error={customWorldError} + /> + + ) : null} ); } diff --git a/src/components/game-shell/platformWorldPresentation.ts b/src/components/game-shell/platformWorldPresentation.ts new file mode 100644 index 00000000..639d0184 --- /dev/null +++ b/src/components/game-shell/platformWorldPresentation.ts @@ -0,0 +1,90 @@ +import type { + CustomWorldGalleryCard, + CustomWorldLibraryEntry, +} from '../../../packages/shared/src/contracts/runtime'; +import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; +import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals'; +import type { CustomWorldProfile } from '../../types'; + +export type PlatformWorldCardLike = + | CustomWorldGalleryCard + | CustomWorldLibraryEntry; + +export function isLibraryWorldEntry( + entry: PlatformWorldCardLike, +): entry is CustomWorldLibraryEntry { + return 'profile' in entry; +} + +export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) { + if (entry.coverImageSrc) { + return entry.coverImageSrc; + } + + if (isLibraryWorldEntry(entry)) { + return resolveCustomWorldCampSceneImage(entry.profile) ?? ''; + } + + return ''; +} + +export function resolvePlatformWorldLeadPortrait( + entry: PlatformWorldCardLike, +) { + if (!isLibraryWorldEntry(entry)) { + return ''; + } + + return buildCustomWorldPlayableCharacters(entry.profile)[0]?.portrait ?? ''; +} + +export function buildPlatformWorldTags(entry: PlatformWorldCardLike) { + if (!isLibraryWorldEntry(entry)) { + return [ + describePlatformThemeLabel(entry.themeMode), + `${entry.playableNpcCount} 角色`, + `${entry.landmarkCount} 地标`, + ]; + } + + return [ + ...entry.profile.majorFactions.slice(0, 2), + ...entry.profile.coreConflicts.slice(0, 1), + ] + .map((value) => value.trim()) + .filter(Boolean) + .slice(0, 3); +} + +export function formatPlatformWorldTime(value: string | null) { + if (!value) { + return '未发布'; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return date.toLocaleDateString('zh-CN', { + month: '2-digit', + day: '2-digit', + }); +} + +export function describePlatformThemeLabel(themeMode: PlatformWorldCardLike['themeMode']) { + switch (themeMode) { + case 'martial': + return '江湖'; + case 'arcane': + return '灵脉'; + case 'machina': + return '机巧'; + case 'tide': + return '潮痕'; + case 'rift': + return '裂界'; + default: + return '回响'; + } +} diff --git a/src/components/game-shell/types.ts b/src/components/game-shell/types.ts index 395bbb44..c24944f2 100644 --- a/src/components/game-shell/types.ts +++ b/src/components/game-shell/types.ts @@ -7,10 +7,11 @@ import type { QuestFlowUi, StoryGenerationNpcUi, } from '../../hooks/useStoryGeneration'; +import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import type { Character, - CustomWorldProfile, CompanionRenderState, + CustomWorldProfile, GameState, StoryMoment, StoryOption, @@ -43,6 +44,7 @@ export interface GameShellStoryProps { export interface GameShellEntryProps { hasSavedGame: boolean; + savedSnapshot: HydratedSavedGameSnapshot | null; handleContinueGame: () => void; handleStartNewGame: () => void; handleSaveAndExit: () => void; diff --git a/src/components/game-shell/useGameShellRuntimeViewModel.ts b/src/components/game-shell/useGameShellRuntimeViewModel.ts index cb18016c..01b88ae9 100644 --- a/src/components/game-shell/useGameShellRuntimeViewModel.ts +++ b/src/components/game-shell/useGameShellRuntimeViewModel.ts @@ -153,7 +153,7 @@ export function useGameShellRuntimeViewModel(params: Pick< const shouldHideStoryOptions = sceneTransitionPhase !== 'idle'; const hideSelectionHero = gameState.currentScene === 'Selection' && - shellViewModel.selectionStage !== 'start'; + shellViewModel.selectionStage !== 'platform'; const dialogueIndicator = useMemo( () => diff --git a/src/components/game-shell/useGameShellViewModel.ts b/src/components/game-shell/useGameShellViewModel.ts index 70e70948..dcecf9c6 100644 --- a/src/components/game-shell/useGameShellViewModel.ts +++ b/src/components/game-shell/useGameShellViewModel.ts @@ -30,7 +30,7 @@ export function useGameShellViewModel(params: { characterChatModalOpen, hasNpcModalOpen, } = params; - const [selectionStage, setSelectionStage] = useState('start'); + const [selectionStage, setSelectionStage] = useState('platform'); const [overlayPanel, setOverlayPanel] = useState(null); const [selectedSceneEntity, setSelectedSceneEntity] = useState(null); const [showTeamModal, setShowTeamModal] = useState(false); @@ -56,13 +56,13 @@ export function useGameShellViewModel(params: { const openCampModal = () => setShowTeamModal(true); const closeCampModal = () => setShowTeamModal(false); - const resetSelectionFlow = () => setSelectionStage('start'); + const resetSelectionFlow = () => setSelectionStage('platform'); const resetForSaveAndExit = () => { setSelectedSceneEntity(null); setOverlayPanel(null); setShowTeamModal(false); - setSelectionStage('start'); + setSelectionStage('platform'); }; return { diff --git a/src/hooks/useGamePersistence.ts b/src/hooks/useGamePersistence.ts index 22108055..4ff35092 100644 --- a/src/hooks/useGamePersistence.ts +++ b/src/hooks/useGamePersistence.ts @@ -8,8 +8,8 @@ import { putSaveSnapshot, } from '../services/storageService'; import type { GameState, StoryMoment } from '../types'; -import type { BottomTab } from './useGameFlow'; import { resumeServerRuntimeStory } from './story/runtimeStoryCoordinator'; +import type { BottomTab } from './useGameFlow'; const AUTO_SAVE_DELAY_MS = 400; @@ -296,6 +296,7 @@ export function useGamePersistence({ return { hasSavedGame, + savedSnapshot, isHydratingSnapshot, isPersistingSnapshot, persistenceError, diff --git a/src/hooks/useGameShellRuntime.ts b/src/hooks/useGameShellRuntime.ts index 7a304f84..08c500f1 100644 --- a/src/hooks/useGameShellRuntime.ts +++ b/src/hooks/useGameShellRuntime.ts @@ -158,6 +158,7 @@ export function useGameShellRuntime(): GameShellProps { }, entry: { hasSavedGame: persistence.hasSavedGame, + savedSnapshot: persistence.savedSnapshot, handleContinueGame, handleStartNewGame, handleSaveAndExit, diff --git a/src/services/storageService.ts b/src/services/storageService.ts index 453af3bb..0d0137a8 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -1,5 +1,9 @@ import type { BasicOkResult, + CustomWorldGalleryDetailResponse, + CustomWorldGalleryResponse, + CustomWorldLibraryEntry, + CustomWorldLibraryMutationResponse, CustomWorldLibraryResponse, RuntimeSettings, } from '../../packages/shared/src/contracts/runtime'; @@ -117,14 +121,14 @@ export async function listCustomWorldLibrary(options: RuntimeRequestOptions = {} options, ); - return Array.isArray(response?.profiles) ? response.profiles : []; + return Array.isArray(response?.entries) ? response.entries : []; } export async function upsertCustomWorldProfile( profile: CustomWorldProfile, options: RuntimeRequestOptions = {}, ) { - const response = await requestRuntimeJson>( + const response = await requestRuntimeJson>( `/custom-world-library/${encodeURIComponent(profile.id)}`, { method: 'PUT', @@ -137,7 +141,10 @@ export async function upsertCustomWorldProfile( options, ); - return Array.isArray(response?.profiles) ? response.profiles : []; + return { + entry: response.entry, + entries: Array.isArray(response?.entries) ? response.entries : [], + }; } export async function deleteCustomWorldProfile( @@ -151,7 +158,67 @@ export async function deleteCustomWorldProfile( options, ); - return Array.isArray(response?.profiles) ? response.profiles : []; + return Array.isArray(response?.entries) ? response.entries : []; +} + +export async function publishCustomWorldProfile( + profileId: string, + options: RuntimeRequestOptions = {}, +) { + const response = await requestRuntimeJson>( + `/custom-world-library/${encodeURIComponent(profileId)}/publish`, + { method: 'POST' }, + '发布自定义世界失败', + options, + ); + + return { + entry: response.entry, + entries: Array.isArray(response?.entries) ? response.entries : [], + }; +} + +export async function unpublishCustomWorldProfile( + profileId: string, + options: RuntimeRequestOptions = {}, +) { + const response = await requestRuntimeJson>( + `/custom-world-library/${encodeURIComponent(profileId)}/unpublish`, + { method: 'POST' }, + '下架自定义世界失败', + options, + ); + + return { + entry: response.entry, + entries: Array.isArray(response?.entries) ? response.entries : [], + }; +} + +export async function listCustomWorldGallery(options: RuntimeRequestOptions = {}) { + const response = await requestRuntimeJson( + '/custom-world-gallery', + { method: 'GET' }, + '读取作品广场失败', + options, + ); + + return Array.isArray(response?.entries) ? response.entries : []; +} + +export async function getCustomWorldGalleryDetail( + ownerUserId: string, + profileId: string, + options: RuntimeRequestOptions = {}, +) { + const response = await requestRuntimeJson>( + `/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}`, + { method: 'GET' }, + '读取作品详情失败', + options, + ); + + return response.entry; } export const runtimeStorageClient = { @@ -163,4 +230,10 @@ export const runtimeStorageClient = { listCustomWorldLibrary, upsertCustomWorldProfile, deleteCustomWorldProfile, + publishCustomWorldProfile, + unpublishCustomWorldProfile, + listCustomWorldGallery, + getCustomWorldGalleryDetail, }; + +export type { CustomWorldLibraryEntry }; diff --git a/vite.config.ts b/vite.config.ts index 906c0af4..ad6631ba 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -38,7 +38,7 @@ export default defineConfig(({mode}) => { entries: ['index.html'], }, build: { - chunkSizeWarningLimit: 750, + chunkSizeWarningLimit: 800, }, server: { // HMR is disabled in AI Studio via DISABLE_HMR env var.