diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index e5421862..4ddb3658 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -1,4 +1,6 @@ import type { + AdminUpsertCreationEntryTypeConfigRequest, + AdminCreationEntryConfigResponse, AdminDebugHttpRequest, AdminDebugHttpResponse, AdminDisableProfileRedeemCodeRequest, @@ -167,6 +169,28 @@ export function listAdminTrackingEvents( ); } + +export function getAdminCreationEntryConfig(token: string) { + return request( + '/admin/api/creation-entry/config', + {token}, + ); +} + +export function upsertAdminCreationEntryConfig( + token: string, + payload: AdminUpsertCreationEntryTypeConfigRequest, +) { + return request( + '/admin/api/creation-entry/config', + { + method: 'POST', + token, + body: payload, + }, + ); +} + export function listProfileRedeemCodes(token: string) { return request( '/admin/api/profile/redeem-codes', diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index b39ca305..6d37d80e 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -141,6 +141,34 @@ export interface AdminTrackingEventListQuery { limit?: number; } + +export interface AdminCreationEntryConfigResponse { + entries: AdminCreationEntryTypeConfigPayload[]; +} + +export interface AdminCreationEntryTypeConfigPayload { + id: string; + title: string; + subtitle: string; + badge: string; + imageSrc: string; + visible: boolean; + open: boolean; + sortOrder: number; + updatedAtMicros: number; +} + +export interface AdminUpsertCreationEntryTypeConfigRequest { + id: string; + title: string; + subtitle: string; + badge: string; + imageSrc: string; + visible: boolean; + open: boolean; + sortOrder: number; +} + export interface AdminUpsertProfileRedeemCodeRequest { code: string; mode: ProfileRedeemCodeMode; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 9e24bd0d..5658fb60 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -17,6 +17,7 @@ import { getStoredAdminToken, setStoredAdminToken, } from '../auth/adminAuthStore'; +import {AdminCreationEntrySwitchPage} from '../pages/AdminCreationEntrySwitchPage'; import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage'; import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage'; import {AdminInviteCodePage} from '../pages/AdminInviteCodePage'; @@ -192,6 +193,12 @@ export function AdminApp() { onResultChange={setInviteResult} /> ) : null} + {routeId === 'creation-entry' ? ( + + ) : null} {routeId === 'tasks' ? ( ; export function AdminShell({ diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index 4296d9c6..f5257934 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -5,7 +5,8 @@ export type AdminRouteId = | 'tracking' | 'redeem' | 'invite' - | 'tasks'; + | 'tasks' + | 'creation-entry'; export interface AdminRouteDefinition { id: AdminRouteId; @@ -21,6 +22,7 @@ export const adminRoutes: AdminRouteDefinition[] = [ {id: 'redeem', label: '兑换码', hash: '#redeem'}, {id: 'invite', label: '邀请码', hash: '#invite'}, {id: 'tasks', label: '任务配置', hash: '#tasks'}, + {id: 'creation-entry', label: '入口开关', hash: '#creation-entry'}, ]; export function resolveAdminRoute(hash: string): AdminRouteId { diff --git a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx new file mode 100644 index 00000000..2b1b2995 --- /dev/null +++ b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx @@ -0,0 +1,260 @@ +import {RefreshCcw, Save} from 'lucide-react'; +import {FormEvent, useEffect, useState} from 'react'; + +import { + getAdminCreationEntryConfig, + upsertAdminCreationEntryConfig, +} from '../api/adminApiClient'; +import type {AdminCreationEntryTypeConfigPayload} from '../api/adminApiTypes'; +import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm'; +import {handlePageError} from './pageUtils'; + +interface AdminCreationEntrySwitchPageProps { + token: string; + onUnauthorized: (message?: string) => void; +} + +export function AdminCreationEntrySwitchPage({ + token, + onUnauthorized, +}: AdminCreationEntrySwitchPageProps) { + const [entries, setEntries] = useState([]); + const [selectedId, setSelectedId] = useState('puzzle'); + const [title, setTitle] = useState(''); + const [subtitle, setSubtitle] = useState(''); + const [badge, setBadge] = useState(''); + const [imageSrc, setImageSrc] = useState(''); + const [visible, setVisible] = useState(true); + const [open, setOpen] = useState(true); + const [sortOrder, setSortOrder] = useState('30'); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [listErrorMessage, setListErrorMessage] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const {confirmWrite, confirmDialog} = useAdminWriteConfirm(); + + useEffect(() => { + void refreshEntries(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); + + async function refreshEntries() { + setIsLoading(true); + setListErrorMessage(''); + try { + const response = await getAdminCreationEntryConfig(token); + const nextEntries = sortEntries(response.entries); + setEntries(nextEntries); + fillForm( + nextEntries.find((entry) => entry.id === selectedId) ?? nextEntries[0] ?? null, + ); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setListErrorMessage); + } finally { + setIsLoading(false); + } + } + + async function handleSave(event: FormEvent) { + event.preventDefault(); + if (isSaving) { + return; + } + + const targetId = selectedId.trim(); + setErrorMessage(''); + const confirmed = await confirmWrite({ + action: '保存创作入口开关', + target: targetId, + }); + if (!confirmed) { + return; + } + + setIsSaving(true); + try { + const response = await upsertAdminCreationEntryConfig(token, { + id: targetId, + title: title.trim(), + subtitle: subtitle.trim(), + badge: badge.trim(), + imageSrc: imageSrc.trim(), + visible, + open, + sortOrder: parseInteger(sortOrder), + }); + const nextEntries = sortEntries(response.entries); + setEntries(nextEntries); + fillForm(nextEntries.find((entry) => entry.id === targetId) ?? null); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsSaving(false); + } + } + + function fillForm(entry: AdminCreationEntryTypeConfigPayload | null) { + if (!entry) { + return; + } + setSelectedId(entry.id); + setTitle(entry.title); + setSubtitle(entry.subtitle); + setBadge(entry.badge); + setImageSrc(entry.imageSrc); + setVisible(entry.visible); + setOpen(entry.open); + setSortOrder(String(entry.sortOrder)); + } + + return ( +
+
+
+

创作入口开关

+

控制创作中心入口展示与运行态路由可用性

+
+ +
+ + {listErrorMessage ? ( +
+ {listErrorMessage} +
+ ) : null} + +
+
+
+ + + +
+ +
+ + +
+ + + + + + + + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+ +
+
+ +
+
+ + + + + + + + + + + {entries.map((entry) => ( + + + + + + + ))} + +
入口展示开放排序
+ + {entry.visible ? '是' : '否'}{entry.open ? '是' : '否'}{entry.sortOrder}
+
+
+
+ + {confirmDialog} +
+ ); +} + +function sortEntries(entries: AdminCreationEntryTypeConfigPayload[]) { + return [...entries].sort((left, right) => { + if (left.sortOrder !== right.sortOrder) { + return left.sortOrder - right.sortOrder; + } + return left.id.localeCompare(right.id); + }); +} + +function parseInteger(value: string) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + return 0; + } + return parsed; +} diff --git a/docs/technical/ADMIN_CREATION_ENTRY_SWITCH_CONFIG_2026-05-11.md b/docs/technical/ADMIN_CREATION_ENTRY_SWITCH_CONFIG_2026-05-11.md new file mode 100644 index 00000000..7f9d4a27 --- /dev/null +++ b/docs/technical/ADMIN_CREATION_ENTRY_SWITCH_CONFIG_2026-05-11.md @@ -0,0 +1,74 @@ +# 后台创作入口开关操作入口 + +日期:2026-05-11 + +## 背景 + +创作中心入口配置已经迁移到 SpacetimeDB,前端创作中心与 api-server 路由熔断共用同一份入口配置。为了让运营/管理员可以调整这张开关表,需要在后台提供显式操作入口,而不是只通过数据库表查询或手工 reducer 操作。 + +## 后台入口 + +后台新增导航: + +- 名称:入口开关 +- hash:`#creation-entry` +- 页面:`apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx` + +页面能力: + +- 读取当前入口开关表。 +- 编辑单个入口: + - `id` + - `title` + - `subtitle` + - `badge` + - `imageSrc` + - `visible` + - `open` + - `sortOrder` +- 保存前复用后台写操作确认弹窗。 +- 保存后重新用后端返回的配置刷新列表。 + +## API + +后台新增受管理员鉴权保护的接口: + +```text +GET /admin/api/creation-entry/config +POST /admin/api/creation-entry/config +``` + +POST body: + +```json +{ + "id": "puzzle", + "title": "拼图", + "subtitle": "拼图关卡创作", + "badge": "可创建", + "imageSrc": "/creation-type-references/puzzle.webp", + "visible": true, + "open": true, + "sortOrder": 30 +} +``` + +响应统一返回当前全量入口列表,方便后台页面直接刷新本地状态。 + +## 入库链路 + +```text +Admin Web + -> api-server /admin/api/creation-entry/config + -> AppState::upsert_creation_entry_type_config + -> spacetime-client procedure upsert_creation_entry_type_config + -> spacetime-module creation_entry_type_config 表 +``` + +`visible=false` 会让创作中心不展示对应入口;`open=false` 会让前端展示锁定态;api-server 的运行态熔断继续以 `visible && open` 判断路由是否可用。 + +## 注意 + +- 前端后台页面只做管理表单,不成为配置事实源。 +- `src/config/newWorkEntryConfig.ts` 不应恢复。 +- SpacetimeDB client bindings 当前新增了对应临时 binding 文件;后续执行标准 bindings regenerate 时应覆盖并保持同名 procedure/type。 diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index bdcd9b20..29f0ec59 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -18,6 +18,8 @@ use reqwest::Client; use serde::Deserialize; use serde_json::{Map, Value}; use shared_contracts::admin::{ + AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload, + AdminUpsertCreationEntryTypeConfigRequest, AdminDatabaseOverviewPayload, AdminDatabaseTableListResponse, AdminDatabaseTableRowPayload, AdminDatabaseTableRowsQuery, AdminDatabaseTableRowsResponse, AdminDatabaseTableStatPayload, AdminDebugHeaderInput, AdminDebugHttpRequest, AdminDebugHttpResponse, AdminLoginRequest, @@ -194,6 +196,94 @@ pub async fn admin_list_database_table_rows( Ok(json_success_body(Some(&request_context), response)) } + +pub async fn admin_get_creation_entry_config( + State(state): State, + Extension(request_context): Extension, + Extension(_admin): Extension, +) -> Result, AppError> { + let config = state.get_creation_entry_config().await.map_err(map_admin_spacetime_error)?; + Ok(json_success_body( + Some(&request_context), + AdminCreationEntryConfigResponse { + entries: config + .creation_types + .into_iter() + .map(map_admin_creation_entry_type_config) + .collect(), + }, + )) +} + +pub async fn admin_upsert_creation_entry_config( + State(state): State, + Extension(request_context): Extension, + Extension(_admin): Extension, + Json(payload): Json, +) -> Result, AppError> { + let entry = validate_admin_creation_entry_config(payload)?; + let config = state + .upsert_creation_entry_type_config(entry) + .await + .map_err(map_admin_spacetime_error)?; + Ok(json_success_body( + Some(&request_context), + AdminCreationEntryConfigResponse { + entries: config + .creation_types + .into_iter() + .map(map_admin_creation_entry_type_config) + .collect(), + }, + )) +} + +fn map_admin_creation_entry_type_config( + entry: shared_contracts::creation_entry_config::CreationEntryTypeResponse, +) -> AdminCreationEntryTypeConfigPayload { + AdminCreationEntryTypeConfigPayload { + id: entry.id, + title: entry.title, + subtitle: entry.subtitle, + badge: entry.badge, + image_src: entry.image_src, + visible: entry.visible, + open: entry.open, + sort_order: entry.sort_order, + updated_at_micros: entry.updated_at_micros, + } +} + +fn validate_admin_creation_entry_config( + payload: AdminUpsertCreationEntryTypeConfigRequest, +) -> Result { + let id = payload.id.trim().to_string(); + if id.is_empty() { + return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("入口 ID 不能为空")); + } + let title = payload.title.trim().to_string(); + if title.is_empty() { + return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("入口标题不能为空")); + } + Ok(module_runtime::CreationEntryTypeAdminUpsertInput { + id, + title, + subtitle: payload.subtitle.trim().to_string(), + badge: payload.badge.trim().to_string(), + image_src: payload.image_src.trim().to_string(), + visible: payload.visible, + open: payload.open, + sort_order: payload.sort_order, + }) +} + +fn map_admin_spacetime_error(error: spacetime_client::SpacetimeClientError) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(serde_json::json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) +} + pub async fn require_admin_auth( State(state): State, mut request: Request, @@ -1237,7 +1327,9 @@ mod tests { }; use axum::{http::StatusCode, response::IntoResponse}; use serde_json::json; - use shared_contracts::admin::{AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery}; + use shared_contracts::admin::{ + AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload, + AdminUpsertCreationEntryTypeConfigRequest,AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery}; #[test] fn normalize_debug_path_rejects_absolute_url() { diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index a726e76d..972f8432 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -15,8 +15,9 @@ use tracing::{Level, Span, error, info, info_span, warn}; use crate::{ admin::{ - admin_debug_http, admin_list_database_table_rows, admin_list_database_tables, - admin_list_tracking_events, admin_login, admin_me, admin_overview, require_admin_auth, + admin_debug_http, admin_get_creation_entry_config, admin_list_database_table_rows, + admin_list_database_tables, admin_list_tracking_events, admin_login, admin_me, admin_overview, + admin_upsert_creation_entry_config, require_admin_auth, }, ai_tasks::{ append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage, @@ -225,6 +226,15 @@ pub fn build_router(state: AppState) -> Router { require_admin_auth, )), ) + .route( + "/admin/api/creation-entry/config", + get(admin_get_creation_entry_config) + .post(admin_upsert_creation_entry_config) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_auth, + )), + ) .route( "/admin/api/profile/redeem-codes", get(admin_list_profile_redeem_codes) diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index ab5366d5..d73d0651 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -228,6 +228,27 @@ impl AppState { &self.refresh_cookie_config } + pub async fn upsert_creation_entry_type_config( + &self, + input: module_runtime::CreationEntryTypeAdminUpsertInput, + ) -> Result { + match self + .spacetime_client + .upsert_creation_entry_type_config(input) + .await + { + Ok(config) => { + #[cfg(test)] + self.cache_test_creation_entry_config(config.clone()); + Ok(config) + } + #[cfg(test)] + Err(_) => Ok(self.read_test_creation_entry_config()), + #[cfg(not(test))] + Err(error) => Err(error), + } + } + pub async fn get_creation_entry_config( &self, ) -> Result { diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 08b085dd..9bbde479 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -88,6 +88,19 @@ pub struct CreationEntryConfigSnapshot { pub updated_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreationEntryTypeAdminUpsertInput { + pub id: String, + pub title: String, + pub subtitle: String, + pub badge: String, + pub image_src: String, + pub visible: bool, + pub open: bool, + pub sort_order: i32, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CreationEntryConfigProcedureResult { diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 122e0761..b4444b02 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -10,6 +10,44 @@ pub struct AdminLoginRequest { } // 登录成功后返回管理员访问令牌与基础会话信息。 + + +/// 后台创作入口开关列表响应。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminCreationEntryConfigResponse { + pub entries: Vec, +} + +/// 后台单个创作入口开关配置。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminCreationEntryTypeConfigPayload { + pub id: String, + pub title: String, + pub subtitle: String, + pub badge: String, + pub image_src: String, + pub visible: bool, + pub open: bool, + pub sort_order: i32, + pub updated_at_micros: i64, +} + +/// 后台保存创作入口开关配置请求。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminUpsertCreationEntryTypeConfigRequest { + pub id: String, + pub title: String, + pub subtitle: String, + pub badge: String, + pub image_src: String, + pub visible: bool, + pub open: bool, + pub sort_order: i32, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AdminLoginResponse { diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 1c2ceaf7..ccbb99f9 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -46,6 +46,23 @@ impl From for AssetHistoryListInput { } } +impl From + for CreationEntryTypeAdminUpsertInput +{ + fn from(input: module_runtime::CreationEntryTypeAdminUpsertInput) -> Self { + Self { + id: input.id, + title: input.title, + subtitle: input.subtitle, + badge: input.badge, + image_src: input.image_src, + visible: input.visible, + open: input.open, + sort_order: input.sort_order, + } + } +} + impl From for RuntimeSettingGetInput { fn from(input: module_runtime::RuntimeSettingGetInput) -> Self { Self { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_admin_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_admin_upsert_input_type.rs new file mode 100644 index 00000000..b2e7eccc --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_admin_upsert_input_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CreationEntryTypeAdminUpsertInput { + pub id: String, + pub title: String, + pub subtitle: String, + pub badge: String, + pub image_src: String, + pub visible: bool, + pub open: bool, + pub sort_order: i32, +} + +impl __sdk::InModule for CreationEntryTypeAdminUpsertInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index add33a16..b74d08ca 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -252,6 +252,7 @@ pub mod custom_world_works_list_result_type; pub mod creation_entry_config_procedure_result_type; pub mod creation_entry_config_snapshot_type; pub mod creation_entry_start_card_snapshot_type; +pub mod creation_entry_type_admin_upsert_input_type; pub mod creation_entry_type_config_snapshot_type; pub mod creation_entry_type_modal_snapshot_type; pub mod database_migration_authorize_operator_input_type; @@ -758,6 +759,7 @@ pub mod update_square_hole_work_procedure; pub mod update_visual_novel_work_procedure; pub mod upsert_auth_store_snapshot_procedure; pub mod upsert_chapter_progression_and_return_procedure; +pub mod upsert_creation_entry_type_config_procedure; pub mod upsert_chapter_progression_reducer; pub mod upsert_custom_world_agent_operation_progress_procedure; pub mod upsert_custom_world_profile_and_return_procedure; @@ -1054,6 +1056,7 @@ pub use custom_world_works_list_result_type::CustomWorldWorksListResult; pub use creation_entry_config_procedure_result_type::CreationEntryConfigProcedureResult; pub use creation_entry_config_snapshot_type::CreationEntryConfigSnapshot; pub use creation_entry_start_card_snapshot_type::CreationEntryStartCardSnapshot; +pub use creation_entry_type_admin_upsert_input_type::CreationEntryTypeAdminUpsertInput; pub use creation_entry_type_config_snapshot_type::CreationEntryTypeSnapshot; pub use creation_entry_type_modal_snapshot_type::CreationEntryTypeModalSnapshot; pub use database_migration_authorize_operator_input_type::DatabaseMigrationAuthorizeOperatorInput; @@ -1560,6 +1563,7 @@ pub use update_square_hole_work_procedure::update_square_hole_work; pub use update_visual_novel_work_procedure::update_visual_novel_work; pub use upsert_auth_store_snapshot_procedure::upsert_auth_store_snapshot; pub use upsert_chapter_progression_and_return_procedure::upsert_chapter_progression_and_return; +pub use upsert_creation_entry_type_config_procedure::upsert_creation_entry_type_config; pub use upsert_chapter_progression_reducer::upsert_chapter_progression; pub use upsert_custom_world_agent_operation_progress_procedure::upsert_custom_world_agent_operation_progress; pub use upsert_custom_world_profile_and_return_procedure::upsert_custom_world_profile_and_return; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/upsert_creation_entry_type_config_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/upsert_creation_entry_type_config_procedure.rs new file mode 100644 index 00000000..bca10bf8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/upsert_creation_entry_type_config_procedure.rs @@ -0,0 +1,57 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::creation_entry_config_procedure_result_type::CreationEntryConfigProcedureResult; +use super::creation_entry_type_admin_upsert_input_type::CreationEntryTypeAdminUpsertInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UpsertCreationEntryTypeConfigArgs { + pub input: CreationEntryTypeAdminUpsertInput, +} + +impl __sdk::InModule for UpsertCreationEntryTypeConfigArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `upsert_creation_entry_type_config`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait upsert_creation_entry_type_config { + fn upsert_creation_entry_type_config(&self, input: CreationEntryTypeAdminUpsertInput) { + self.upsert_creation_entry_type_config_then(input, |_, _| {}); + } + + fn upsert_creation_entry_type_config_then( + &self, + input: CreationEntryTypeAdminUpsertInput, + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl upsert_creation_entry_type_config for super::RemoteProcedures { + fn upsert_creation_entry_type_config_then( + &self, + input: CreationEntryTypeAdminUpsertInput, + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CreationEntryConfigProcedureResult>( + "upsert_creation_entry_type_config", + UpsertCreationEntryTypeConfigArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index 8b8eb220..870274c6 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -17,6 +17,25 @@ impl SpacetimeClient { .await } + pub async fn upsert_creation_entry_type_config( + &self, + input: module_runtime::CreationEntryTypeAdminUpsertInput, + ) -> Result { + let procedure_input: CreationEntryTypeAdminUpsertInput = input.into(); + self.call_after_connect(move |connection, sender| { + connection.procedures().upsert_creation_entry_type_config_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_creation_entry_config_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + pub async fn get_runtime_settings( &self, user_id: String, diff --git a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs index a23ad025..a5f2abfd 100644 --- a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs +++ b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs @@ -48,6 +48,57 @@ pub fn get_creation_entry_config( } } +#[spacetimedb::procedure] +pub fn upsert_creation_entry_type_config( + ctx: &mut ProcedureContext, + input: CreationEntryTypeAdminUpsertInput, +) -> CreationEntryConfigProcedureResult { + match ctx.try_with_tx(|tx| upsert_creation_entry_type_config_in_tx(tx, input.clone())) { + Ok(record) => CreationEntryConfigProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => CreationEntryConfigProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +fn upsert_creation_entry_type_config_in_tx( + ctx: &ReducerContext, + input: CreationEntryTypeAdminUpsertInput, +) -> Result { + seed_creation_entry_config_if_missing(ctx); + let now = ctx.timestamp; + let id = input.id.trim().to_string(); + if id.is_empty() { + return Err("入口 ID 不能为空".to_string()); + } + if input.title.trim().is_empty() { + return Err("入口标题不能为空".to_string()); + } + let row = CreationEntryTypeConfig { + id: id.clone(), + title: input.title.trim().to_string(), + subtitle: input.subtitle.trim().to_string(), + badge: input.badge.trim().to_string(), + image_src: input.image_src.trim().to_string(), + visible: input.visible, + open: input.open, + sort_order: input.sort_order, + updated_at: now, + }; + if ctx.db.creation_entry_type_config().id().find(&id).is_some() { + ctx.db.creation_entry_type_config().id().update(row); + } else { + ctx.db.creation_entry_type_config().insert(row); + } + get_or_seed_creation_entry_config_snapshot(ctx) +} + fn get_or_seed_creation_entry_config_snapshot( ctx: &ReducerContext, ) -> Result {