add public work share links
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-27 22:49:13 +08:00
parent 271db02e4a
commit 1348b2e940
23 changed files with 1038 additions and 248 deletions

View File

@@ -19,6 +19,7 @@
- 奖励流水继续复用 `profile_wallet_ledger`,新增来源类型:
- `invite_inviter_reward`
- `invite_invitee_reward`
- API 返回钱包流水时,`sourceType` 必须复用 `server-rs/crates/shared-contracts/src/runtime.rs` 中的常量,避免 SpacetimeDB 枚举映射和前端合同字符串漂移。
## SpacetimeDB 表设计

View File

@@ -14,6 +14,17 @@
6. 作品详情返回必须恢复打开详情前的平台来源 Tab从分类进入回分类从首页进入回首页从创作中心进入回创作中心。
7. 所有入口保持轻量 UI不写规则说明文案不改变发布、下架、进入游戏的后端语义。
## 作品分享路由补充
1. 公开作品入口路由统一使用当前作品页面路径加 `work=作品号`RPG 为 `/worlds/detail?work=CW-00000001`,拼图为 `/gallery/puzzle/detail?work=PZ-00000001`,大鱼玩法为 `/runtime/big-fish?work=BF-00000001`
2. 从公开广场、最近浏览、创作中心打开已发布作品详情或玩法时,若当前作品有公开作品号,地址栏必须同步追加 `work=作品号`;没有作品号的草稿详情仍保持无查询参数路径。
3. 首次进入主应用时若 URL 带 `work` 查询参数,平台入口自动复用现有公开编号搜索逻辑打开对应作品详情,不新增独立详情系统。
4. 详情页必须保留“复制作品号”和“分享作品”两个独立动作:
- 复制作品号只复制 `CW / PZ / BF` 编号。
- 分享作品复制一段邀请好友来玩的中文文本,文本内必须包含作品名、作品号和带 `work` 查询参数的完整网址。
5. 分享复制使用现有剪切板兼容工具Clipboard API 权限失败时走降级复制,并在按钮内短暂反馈 `已复制``复制失败`
6. UI 中只保留按钮级短文案,不写规则说明,不在详情页新增大段分享说明。
## 验收
1. 399px 竖屏首页能直接看到并使用搜索入口。
@@ -23,3 +34,5 @@
5. 桌面右侧趋势列表只显示排序和作品类型,不再显示 `1777110165.990127Z` 这类原始时间字符串,也不直接显示作品号。
6. 在内嵌浏览器 Clipboard API 拒绝写入时,详情页与创作中心作品号复制仍能通过降级路径完成,并显示 `已复制``复制失败`
7. 打开拼图详情后点击返回,不再固定跳到创作中心,而是回到打开详情前的平台 Tab。
8. 打开 `/?work=CW-00000001``/worlds/detail?work=CW-00000001``/gallery/puzzle/detail?work=PZ-00000001``/runtime/big-fish?work=BF-00000001` 后能自动进入对应公开作品详情或玩法。
9. 点击详情页“分享作品”后,剪切板内容包含邀请文本、作品号和当前站点下带 `work=作品号` 的完整网址。

View File

@@ -55,7 +55,9 @@ export type ProfileWalletLedgerEntry = {
| 'snapshot_sync'
| 'invite_inviter_reward'
| 'invite_invitee_reward'
| 'points_recharge';
| 'points_recharge'
| 'asset_generation_consume'
| 'asset_generation_refund';
createdAt: string;
};

View File

@@ -42,6 +42,8 @@ use crate::{
state::AppState,
};
const ASSET_GENERATION_POINTS_COST: u64 = 1;
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CustomWorldEntityRequest {
@@ -440,108 +442,121 @@ pub async fn generate_custom_world_scene_image(
let owner_user_id = authenticated.claims().user_id().to_string();
let normalized = normalize_scene_image_request(payload)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let settings = require_dashscope_settings(&state)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let http_client = build_dashscope_http_client(&settings)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let reference_image =
if let Some(reference_image_src) = normalized.reference_image_src.as_deref() {
Some(
resolve_reference_image_as_data_url(
&state,
&http_client,
reference_image_src,
"referenceImageSrc",
let asset_id = format!("custom-scene-{}", current_utc_millis());
consume_asset_generation_points(
&state,
&owner_user_id,
"scene_image",
asset_id.as_str(),
&request_context,
)
.await?;
let asset_result = async {
let settings = require_dashscope_settings(&state)?;
let http_client = build_dashscope_http_client(&settings)?;
let reference_image =
if let Some(reference_image_src) = normalized.reference_image_src.as_deref() {
Some(
resolve_reference_image_as_data_url(
&state,
&http_client,
reference_image_src,
"referenceImageSrc",
)
.await?,
)
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?,
} else {
None
};
let generated = if let Some(reference_image) = reference_image.as_deref() {
create_reference_image_generation(
&http_client,
&settings,
state.config.dashscope_reference_image_model.as_str(),
normalized.prompt.as_str(),
normalized.size.as_str(),
&[reference_image.to_string()],
Some(normalized.negative_prompt.as_str()),
"创建参考图场景编辑任务失败",
"参考图场景编辑未返回图片地址",
"scene-edit",
)
.await
} else {
None
create_text_to_image_generation(
&http_client,
&settings,
state.config.dashscope_scene_image_model.as_str(),
normalized.prompt.as_str(),
Some(normalized.negative_prompt.as_str()),
normalized.size.as_str(),
"创建场景图片生成任务失败",
"查询场景图片任务失败",
"场景图片生成任务失败",
"场景图片生成超时或未返回图片地址",
)
.await
}?;
let scene_model = if reference_image.is_some() {
state.config.dashscope_reference_image_model.clone()
} else {
state.config.dashscope_scene_image_model.clone()
};
let generated = if let Some(reference_image) = reference_image.as_deref() {
create_reference_image_generation(
let downloaded = download_remote_image(
&http_client,
&settings,
state.config.dashscope_reference_image_model.as_str(),
normalized.prompt.as_str(),
normalized.size.as_str(),
&[reference_image.to_string()],
Some(normalized.negative_prompt.as_str()),
"创建参考图场景编辑任务失败",
"参考图场景编辑未返回图片地址",
"scene-edit",
generated.image_url.as_str(),
"下载生成图片失败",
)
.await
} else {
create_text_to_image_generation(
&http_client,
&settings,
state.config.dashscope_scene_image_model.as_str(),
normalized.prompt.as_str(),
Some(normalized.negative_prompt.as_str()),
normalized.size.as_str(),
"创建场景图片生成任务失败",
"查询场景图片任务失败",
"场景图片生成任务失败",
"场景图片生成超时或未返回图片地址",
.await?;
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldScenes,
path_segments: vec![
sanitize_storage_segment(
normalized
.profile_id
.as_deref()
.unwrap_or(normalized.world_name.as_str()),
"world",
),
sanitize_storage_segment(normalized.entity_id.as_str(), "scene"),
asset_id.clone(),
],
file_name: format!("scene.{}", downloaded.extension),
content_type: downloaded.mime_type,
body: downloaded.bytes,
asset_kind: "scene_image",
entity_kind: "custom_world_landmark",
entity_id: normalized.entity_id.clone(),
profile_id: normalized.profile_id.clone(),
slot: "scene_image",
source_job_id: Some(generated.task_id.clone()),
};
persist_custom_world_asset(
&state,
&owner_user_id,
upload,
GeneratedAssetResponse {
image_src: String::new(),
asset_id: asset_id.clone(),
source_type: "generated".to_string(),
model: Some(scene_model),
size: Some(normalized.size),
task_id: Some(generated.task_id),
prompt: Some(normalized.prompt),
actual_prompt: generated.actual_prompt,
},
)
.await
}
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let scene_model = if reference_image.is_some() {
state.config.dashscope_reference_image_model.clone()
} else {
state.config.dashscope_scene_image_model.clone()
.await;
let asset = match asset_result {
Ok(asset) => asset,
Err(error) => {
refund_asset_generation_points(&state, &owner_user_id, "scene_image", &asset_id).await;
return Err(custom_world_ai_error_response(&request_context, error));
}
};
let downloaded = download_remote_image(
&http_client,
generated.image_url.as_str(),
"下载生成图片失败",
)
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let asset_id = format!("custom-scene-{}", current_utc_millis());
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldScenes,
path_segments: vec![
sanitize_storage_segment(
normalized
.profile_id
.as_deref()
.unwrap_or(normalized.world_name.as_str()),
"world",
),
sanitize_storage_segment(normalized.entity_id.as_str(), "scene"),
asset_id.clone(),
],
file_name: format!("scene.{}", downloaded.extension),
content_type: downloaded.mime_type,
body: downloaded.bytes,
asset_kind: "scene_image",
entity_kind: "custom_world_landmark",
entity_id: normalized.entity_id.clone(),
profile_id: normalized.profile_id.clone(),
slot: "scene_image",
source_job_id: Some(generated.task_id.clone()),
};
let asset = persist_custom_world_asset(
&state,
&owner_user_id,
upload,
GeneratedAssetResponse {
image_src: String::new(),
asset_id: asset_id.clone(),
source_type: "generated".to_string(),
model: Some(scene_model),
size: Some(normalized.size),
task_id: Some(generated.task_id),
prompt: Some(normalized.prompt),
actual_prompt: generated.actual_prompt,
},
)
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
Ok(json_success_body(Some(&request_context), asset))
}
@@ -697,109 +712,123 @@ pub async fn generate_custom_world_cover_image(
trim_to_option(payload.profile.name.as_deref()).unwrap_or_else(|| "world".to_string());
let entity_id = profile_id.clone().unwrap_or_else(|| world_name.clone());
let size = trim_to_option(payload.size.as_deref()).unwrap_or_else(|| "1600*900".to_string());
let settings = require_dashscope_settings(&state)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let http_client = build_dashscope_http_client(&settings)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let reference_sources = collect_cover_reference_image_sources(
&payload.profile,
&payload.character_role_ids,
payload.reference_image_src.as_deref().unwrap_or_default(),
);
let prompt = build_custom_world_cover_image_prompt(
&payload.profile,
&payload.character_role_ids,
payload.user_prompt.as_deref().unwrap_or_default(),
!reference_sources.is_empty(),
);
let mut reference_images = Vec::with_capacity(reference_sources.len());
for source in &reference_sources {
reference_images.push(
resolve_reference_image_as_data_url(
&state,
&http_client,
source.as_str(),
"referenceImageSrc",
)
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?,
);
}
let generated = if reference_images.is_empty() {
create_text_to_image_generation(
&http_client,
&settings,
state.config.dashscope_cover_image_model.as_str(),
prompt.as_str(),
None,
size.as_str(),
"创建作品封面生成任务失败",
"查询作品封面任务失败",
"作品封面生成任务失败",
"作品封面生成超时或未返回图片地址",
)
.await
} else {
create_reference_image_generation(
&http_client,
&settings,
state.config.dashscope_reference_image_model.as_str(),
prompt.as_str(),
size.as_str(),
&reference_images,
None,
"创建参考图封面任务失败",
"封面生成未返回图片地址",
"cover-edit",
)
.await
}
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let downloaded = download_remote_image(
&http_client,
generated.image_url.as_str(),
"下载作品封面失败",
)
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let asset_id = format!("custom-cover-{}", current_utc_millis());
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldCovers,
path_segments: vec![
sanitize_storage_segment(entity_id.as_str(), "world"),
asset_id.clone(),
],
file_name: format!("cover.{}", downloaded.extension),
content_type: downloaded.mime_type,
body: downloaded.bytes,
asset_kind: "custom_world_cover",
entity_kind: "custom_world_profile",
entity_id,
profile_id,
slot: "cover",
source_job_id: Some(generated.task_id.clone()),
};
let asset = persist_custom_world_asset(
consume_asset_generation_points(
&state,
&owner_user_id,
upload,
GeneratedAssetResponse {
image_src: String::new(),
asset_id: asset_id.clone(),
source_type: "generated".to_string(),
model: Some(if reference_images.is_empty() {
state.config.dashscope_cover_image_model.clone()
} else {
state.config.dashscope_reference_image_model.clone()
}),
size: Some(size),
task_id: Some(generated.task_id),
prompt: Some(prompt),
actual_prompt: generated.actual_prompt,
},
"custom_world_cover",
asset_id.as_str(),
&request_context,
)
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
.await?;
let asset_result = async {
let settings = require_dashscope_settings(&state)?;
let http_client = build_dashscope_http_client(&settings)?;
let reference_sources = collect_cover_reference_image_sources(
&payload.profile,
&payload.character_role_ids,
payload.reference_image_src.as_deref().unwrap_or_default(),
);
let prompt = build_custom_world_cover_image_prompt(
&payload.profile,
&payload.character_role_ids,
payload.user_prompt.as_deref().unwrap_or_default(),
!reference_sources.is_empty(),
);
let mut reference_images = Vec::with_capacity(reference_sources.len());
for source in &reference_sources {
reference_images.push(
resolve_reference_image_as_data_url(
&state,
&http_client,
source.as_str(),
"referenceImageSrc",
)
.await?,
);
}
let generated = if reference_images.is_empty() {
create_text_to_image_generation(
&http_client,
&settings,
state.config.dashscope_cover_image_model.clone().as_str(),
prompt.as_str(),
None,
size.as_str(),
"创建作品封面生成任务失败",
"查询作品封面任务失败",
"作品封面生成任务失败",
"作品封面生成超时或未返回图片地址",
)
.await
} else {
create_reference_image_generation(
&http_client,
&settings,
state.config.dashscope_reference_image_model.as_str(),
prompt.as_str(),
size.as_str(),
&reference_images,
None,
"创建参考图封面任务失败",
"封面生成未返回图片地址",
"cover-edit",
)
.await
}?;
let downloaded = download_remote_image(
&http_client,
generated.image_url.as_str(),
"下载作品封面失败",
)
.await?;
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldCovers,
path_segments: vec![
sanitize_storage_segment(entity_id.as_str(), "world"),
asset_id.clone(),
],
file_name: format!("cover.{}", downloaded.extension),
content_type: downloaded.mime_type,
body: downloaded.bytes,
asset_kind: "custom_world_cover",
entity_kind: "custom_world_profile",
entity_id,
profile_id,
slot: "cover",
source_job_id: Some(generated.task_id.clone()),
};
persist_custom_world_asset(
&state,
&owner_user_id,
upload,
GeneratedAssetResponse {
image_src: String::new(),
asset_id: asset_id.clone(),
source_type: "generated".to_string(),
model: Some(if reference_images.is_empty() {
state.config.dashscope_cover_image_model.clone()
} else {
state.config.dashscope_reference_image_model.clone()
}),
size: Some(size),
task_id: Some(generated.task_id),
prompt: Some(prompt),
actual_prompt: generated.actual_prompt,
},
)
.await
}
.await;
let asset = match asset_result {
Ok(asset) => asset,
Err(error) => {
refund_asset_generation_points(&state, &owner_user_id, "custom_world_cover", &asset_id)
.await;
return Err(custom_world_ai_error_response(&request_context, error));
}
};
Ok(json_success_body(Some(&request_context), asset))
}
@@ -874,6 +903,81 @@ pub async fn upload_custom_world_cover_image(
Ok(json_success_body(Some(&request_context), asset))
}
async fn consume_asset_generation_points(
state: &AppState,
owner_user_id: &str,
asset_kind: &str,
asset_id: &str,
request_context: &RequestContext,
) -> Result<(), Response> {
let ledger_id = format!(
"asset_generation_consume:{}:{}:{}",
owner_user_id, asset_kind, asset_id
);
let created_at_micros = current_utc_micros();
state
.spacetime_client()
.consume_profile_wallet_points(
owner_user_id.to_string(),
ASSET_GENERATION_POINTS_COST,
ledger_id,
created_at_micros,
)
.await
.map(|_| ())
.map_err(|error| {
custom_world_ai_error_response(
request_context,
map_asset_generation_wallet_error(error),
)
})
}
async fn refund_asset_generation_points(
state: &AppState,
owner_user_id: &str,
asset_kind: &str,
asset_id: &str,
) {
let ledger_id = format!(
"asset_generation_refund:{}:{}:{}",
owner_user_id, asset_kind, asset_id
);
if let Err(error) = state
.spacetime_client()
.refund_profile_wallet_points(
owner_user_id.to_string(),
ASSET_GENERATION_POINTS_COST,
ledger_id,
current_utc_micros(),
)
.await
{
tracing::error!(
owner_user_id,
asset_kind,
asset_id,
error = %error,
"资产生成失败后的叙世币退款失败"
);
}
}
fn map_asset_generation_wallet_error(error: SpacetimeClientError) -> AppError {
let status = match &error {
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
SpacetimeClientError::Procedure(message) if message.contains("叙世币余额不足") => {
StatusCode::CONFLICT
}
_ => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "profile-wallet",
"message": error.to_string(),
}))
}
async fn persist_custom_world_asset(
state: &AppState,
owner_user_id: &str,

View File

@@ -7,18 +7,23 @@ use axum::{
use module_runtime::{
PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRechargeProductRecord, RuntimeReferralInviteCenterRecord,
RuntimeReferralRedeemRecord,
RuntimeProfileRechargeProductRecord, RuntimeProfileWalletLedgerSourceType,
RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord,
};
use serde_json::{Value, json};
use shared_contracts::runtime::{
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
ProfileDashboardSummaryResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse,
ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse,
ProfileRechargeOrderResponse, ProfileRechargeProductResponse,
ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse,
ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest,
RedeemProfileReferralInviteCodeResponse,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse,
ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse,
ProfileRechargeProductResponse, ProfileReferralInviteCenterResponse,
ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse,
RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse,
};
use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime;
@@ -82,7 +87,8 @@ pub async fn get_profile_wallet_ledger(
id: entry.wallet_ledger_id,
amount_delta: entry.amount_delta,
balance_after: entry.balance_after,
source_type: entry.source_type.as_str().to_string(),
source_type: format_profile_wallet_ledger_source_type(entry.source_type)
.to_string(),
created_at: entry.created_at,
})
.collect(),
@@ -90,6 +96,31 @@ pub async fn get_profile_wallet_ledger(
))
}
fn format_profile_wallet_ledger_source_type(
source_type: RuntimeProfileWalletLedgerSourceType,
) -> &'static str {
match source_type {
RuntimeProfileWalletLedgerSourceType::SnapshotSync => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC
}
RuntimeProfileWalletLedgerSourceType::InviteInviterReward => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD
}
RuntimeProfileWalletLedgerSourceType::InviteInviteeReward => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD
}
RuntimeProfileWalletLedgerSourceType::PointsRecharge => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE
}
RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME
}
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND
}
}
}
pub async fn get_profile_recharge_center(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -367,6 +398,10 @@ fn build_redeem_profile_referral_invite_code_response(
#[cfg(test)]
mod tests {
use module_runtime::RuntimeProfileWalletLedgerSourceType;
use super::format_profile_wallet_ledger_source_type;
use axum::{
body::Body,
http::{Request, StatusCode},
@@ -381,6 +416,22 @@ mod tests {
use crate::{app::build_router, config::AppConfig, state::AppState};
#[test]
fn profile_wallet_ledger_source_type_formats_asset_generation_values() {
assert_eq!(
format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume
),
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME
);
assert_eq!(
format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund
),
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND
);
}
#[tokio::test]
async fn profile_dashboard_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));

View File

@@ -259,6 +259,8 @@ pub enum RuntimeProfileWalletLedgerSourceType {
InviteInviterReward,
InviteInviteeReward,
PointsRecharge,
AssetGenerationConsume,
AssetGenerationRefund,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -399,12 +401,29 @@ pub struct RuntimeProfileWalletLedgerProcedureResult {
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileWalletAdjustmentProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileDashboardSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileWalletLedgerListInput {
pub user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileWalletAdjustmentInput {
pub user_id: String,
pub amount: u64,
pub ledger_id: String,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeReferralInviteCenterSnapshot {
@@ -515,6 +534,8 @@ pub enum RuntimeBrowseHistoryFieldError {
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RuntimeProfileFieldError {
MissingUserId,
MissingLedgerId,
InvalidWalletAmount,
MissingInviteCode,
MissingProductId,
MissingWorldKey,
@@ -877,6 +898,26 @@ pub fn build_runtime_profile_wallet_ledger_list_input(
Ok(RuntimeProfileWalletLedgerListInput { user_id })
}
pub fn build_runtime_profile_wallet_adjustment_input(
user_id: String,
amount: u64,
ledger_id: String,
created_at_micros: i64,
) -> Result<RuntimeProfileWalletAdjustmentInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
let ledger_id =
normalize_required_string(ledger_id).ok_or(RuntimeProfileFieldError::MissingLedgerId)?;
if amount == 0 || amount > i64::MAX as u64 {
return Err(RuntimeProfileFieldError::InvalidWalletAmount);
}
Ok(RuntimeProfileWalletAdjustmentInput {
user_id,
amount,
ledger_id,
created_at_micros,
})
}
pub fn build_runtime_profile_recharge_center_get_input(
user_id: String,
) -> Result<RuntimeProfileRechargeCenterGetInput, RuntimeProfileFieldError> {
@@ -1465,6 +1506,8 @@ impl RuntimeProfileWalletLedgerSourceType {
Self::InviteInviterReward => "invite_inviter_reward",
Self::InviteInviteeReward => "invite_invitee_reward",
Self::PointsRecharge => "points_recharge",
Self::AssetGenerationConsume => "asset_generation_consume",
Self::AssetGenerationRefund => "asset_generation_refund",
}
}
}
@@ -1697,6 +1740,8 @@ impl std::fmt::Display for RuntimeProfileFieldError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingUserId => f.write_str("profile.user_id 不能为空"),
Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"),
Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"),
Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"),
Self::MissingProductId => f.write_str("recharge.product_id 不能为空"),
Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"),
@@ -1962,6 +2007,14 @@ mod tests {
RuntimeProfileWalletLedgerSourceType::PointsRecharge.as_str(),
"points_recharge"
);
assert_eq!(
RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume.as_str(),
"asset_generation_consume"
);
assert_eq!(
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund.as_str(),
"asset_generation_refund"
);
}
#[test]

View File

@@ -7,6 +7,10 @@ pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC: &str = "snapshot_sync
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE: &str = "points_recharge";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD: &str = "invite_inviter_reward";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD: &str = "invite_invitee_reward";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME: &str =
"asset_generation_consume";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND: &str =
"asset_generation_refund";
pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial";
pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane";
pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina";
@@ -752,19 +756,83 @@ mod tests {
#[test]
fn profile_wallet_ledger_response_uses_camel_case_fields() {
let payload = serde_json::to_value(ProfileWalletLedgerResponse {
entries: vec![ProfileWalletLedgerEntryResponse {
id: "ledger-1".to_string(),
amount_delta: 12,
balance_after: 80,
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC.to_string(),
created_at: "2026-04-22T10:00:00Z".to_string(),
}],
entries: vec![
ProfileWalletLedgerEntryResponse {
id: "ledger-1".to_string(),
amount_delta: 12,
balance_after: 80,
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC.to_string(),
created_at: "2026-04-22T10:00:00Z".to_string(),
},
ProfileWalletLedgerEntryResponse {
id: "ledger-2".to_string(),
amount_delta: 30,
balance_after: 110,
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD
.to_string(),
created_at: "2026-04-22T10:01:00Z".to_string(),
},
ProfileWalletLedgerEntryResponse {
id: "ledger-3".to_string(),
amount_delta: 30,
balance_after: 140,
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD
.to_string(),
created_at: "2026-04-22T10:02:00Z".to_string(),
},
ProfileWalletLedgerEntryResponse {
id: "ledger-4".to_string(),
amount_delta: 60,
balance_after: 200,
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE.to_string(),
created_at: "2026-04-22T10:03:00Z".to_string(),
},
ProfileWalletLedgerEntryResponse {
id: "ledger-5".to_string(),
amount_delta: -1,
balance_after: 199,
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME
.to_string(),
created_at: "2026-04-22T10:04:00Z".to_string(),
},
ProfileWalletLedgerEntryResponse {
id: "ledger-6".to_string(),
amount_delta: 1,
balance_after: 200,
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND
.to_string(),
created_at: "2026-04-22T10:05:00Z".to_string(),
},
],
})
.expect("payload should serialize");
assert_eq!(payload["entries"][0]["amountDelta"], json!(12));
assert_eq!(payload["entries"][0]["balanceAfter"], json!(80));
assert_eq!(payload["entries"][0]["sourceType"], json!("snapshot_sync"));
assert_eq!(
payload["entries"][0]["sourceType"],
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC)
);
assert_eq!(
payload["entries"][1]["sourceType"],
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD)
);
assert_eq!(
payload["entries"][2]["sourceType"],
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD)
);
assert_eq!(
payload["entries"][3]["sourceType"],
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE)
);
assert_eq!(
payload["entries"][4]["sourceType"],
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME)
);
assert_eq!(
payload["entries"][5]["sourceType"],
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND)
);
assert_eq!(
payload["entries"][0]["createdAt"],
json!("2026-04-22T10:00:00Z")

View File

@@ -250,5 +250,4 @@ impl SpacetimeClient {
})
.await
}
}

View File

@@ -130,7 +130,7 @@ use module_runtime::{
build_runtime_profile_recharge_center_record,
build_runtime_profile_recharge_order_create_input,
build_runtime_profile_save_archive_list_input, build_runtime_profile_save_archive_record,
build_runtime_profile_save_archive_resume_input,
build_runtime_profile_save_archive_resume_input, build_runtime_profile_wallet_adjustment_input,
build_runtime_profile_wallet_ledger_entry_record,
build_runtime_profile_wallet_ledger_list_input, build_runtime_referral_invite_center_get_input,
build_runtime_referral_invite_center_record, build_runtime_referral_redeem_input,

View File

@@ -125,6 +125,19 @@ impl From<module_runtime::RuntimeProfileWalletLedgerListInput>
}
}
impl From<module_runtime::RuntimeProfileWalletAdjustmentInput>
for RuntimeProfileWalletAdjustmentInput
{
fn from(input: module_runtime::RuntimeProfileWalletAdjustmentInput) -> Self {
Self {
user_id: input.user_id,
amount: input.amount,
ledger_id: input.ledger_id,
created_at_micros: input.created_at_micros,
}
}
}
impl From<module_runtime::RuntimeProfileRechargeCenterGetInput>
for RuntimeProfileRechargeCenterGetInput
{
@@ -663,6 +676,28 @@ pub(crate) fn map_runtime_profile_wallet_ledger_procedure_result(
.collect())
}
pub(crate) fn map_runtime_profile_wallet_adjustment_procedure_result(
result: RuntimeProfileWalletAdjustmentProcedureResult,
) -> Result<RuntimeProfileDashboardRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::Procedure(
result
.error_message
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
));
}
let snapshot = result.record.ok_or_else(|| {
SpacetimeClientError::Procedure(
"SpacetimeDB procedure 未返回 profile dashboard 快照".to_string(),
)
})?;
Ok(build_runtime_profile_dashboard_record(
map_runtime_profile_dashboard_snapshot(snapshot),
))
}
pub(crate) fn map_runtime_profile_recharge_center_procedure_result(
result: RuntimeProfileRechargeCenterProcedureResult,
) -> Result<RuntimeProfileRechargeCenterRecord, SpacetimeClientError> {
@@ -3236,6 +3271,12 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back(
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::PointsRecharge => {
module_runtime::RuntimeProfileWalletLedgerSourceType::PointsRecharge
}
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume => {
module_runtime::RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume
}
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => {
module_runtime::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund
}
}
}

View File

@@ -118,6 +118,7 @@ pub mod complete_ai_task_and_return_procedure;
pub mod confirm_asset_object_and_return_procedure;
pub mod confirm_asset_object_reducer;
pub mod consume_inventory_item_input_type;
pub mod consume_profile_wallet_points_and_return_procedure;
pub mod continue_story_and_return_procedure;
pub mod continue_story_reducer;
pub mod create_ai_task_and_return_procedure;
@@ -343,6 +344,7 @@ pub mod quest_step_snapshot_type;
pub mod quest_treasure_inspected_signal_type;
pub mod quest_turn_in_input_type;
pub mod redeem_profile_referral_invite_code_procedure;
pub mod refund_profile_wallet_points_and_return_procedure;
pub mod refresh_session_type;
pub mod resolve_combat_action_and_return_procedure;
pub mod resolve_combat_action_input_type;
@@ -405,6 +407,8 @@ pub mod runtime_profile_save_archive_list_input_type;
pub mod runtime_profile_save_archive_procedure_result_type;
pub mod runtime_profile_save_archive_resume_input_type;
pub mod runtime_profile_save_archive_snapshot_type;
pub mod runtime_profile_wallet_adjustment_input_type;
pub mod runtime_profile_wallet_adjustment_procedure_result_type;
pub mod runtime_profile_wallet_ledger_entry_snapshot_type;
pub mod runtime_profile_wallet_ledger_list_input_type;
pub mod runtime_profile_wallet_ledger_procedure_result_type;
@@ -583,6 +587,7 @@ pub use complete_ai_task_and_return_procedure::complete_ai_task_and_return;
pub use confirm_asset_object_and_return_procedure::confirm_asset_object_and_return;
pub use confirm_asset_object_reducer::confirm_asset_object;
pub use consume_inventory_item_input_type::ConsumeInventoryItemInput;
pub use consume_profile_wallet_points_and_return_procedure::consume_profile_wallet_points_and_return;
pub use continue_story_and_return_procedure::continue_story_and_return;
pub use continue_story_reducer::continue_story;
pub use create_ai_task_and_return_procedure::create_ai_task_and_return;
@@ -808,6 +813,7 @@ pub use quest_step_snapshot_type::QuestStepSnapshot;
pub use quest_treasure_inspected_signal_type::QuestTreasureInspectedSignal;
pub use quest_turn_in_input_type::QuestTurnInInput;
pub use redeem_profile_referral_invite_code_procedure::redeem_profile_referral_invite_code;
pub use refund_profile_wallet_points_and_return_procedure::refund_profile_wallet_points_and_return;
pub use refresh_session_type::RefreshSession;
pub use resolve_combat_action_and_return_procedure::resolve_combat_action_and_return;
pub use resolve_combat_action_input_type::ResolveCombatActionInput;
@@ -870,6 +876,8 @@ pub use runtime_profile_save_archive_list_input_type::RuntimeProfileSaveArchiveL
pub use runtime_profile_save_archive_procedure_result_type::RuntimeProfileSaveArchiveProcedureResult;
pub use runtime_profile_save_archive_resume_input_type::RuntimeProfileSaveArchiveResumeInput;
pub use runtime_profile_save_archive_snapshot_type::RuntimeProfileSaveArchiveSnapshot;
pub use runtime_profile_wallet_adjustment_input_type::RuntimeProfileWalletAdjustmentInput;
pub use runtime_profile_wallet_adjustment_procedure_result_type::RuntimeProfileWalletAdjustmentProcedureResult;
pub use runtime_profile_wallet_ledger_entry_snapshot_type::RuntimeProfileWalletLedgerEntrySnapshot;
pub use runtime_profile_wallet_ledger_list_input_type::RuntimeProfileWalletLedgerListInput;
pub use runtime_profile_wallet_ledger_procedure_result_type::RuntimeProfileWalletLedgerProcedureResult;

View File

@@ -15,6 +15,10 @@ pub enum RuntimeProfileWalletLedgerSourceType {
InviteInviteeReward,
PointsRecharge,
AssetGenerationConsume,
AssetGenerationRefund,
}
impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType {

View File

@@ -89,6 +89,67 @@ impl SpacetimeClient {
.await
}
pub async fn consume_profile_wallet_points(
&self,
user_id: String,
amount: u64,
ledger_id: String,
created_at_micros: i64,
) -> Result<RuntimeProfileDashboardRecord, SpacetimeClientError> {
let procedure_input = build_runtime_profile_wallet_adjustment_input(
user_id,
amount,
ledger_id,
created_at_micros,
)
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.consume_profile_wallet_points_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_runtime_profile_wallet_adjustment_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn refund_profile_wallet_points(
&self,
user_id: String,
amount: u64,
ledger_id: String,
created_at_micros: i64,
) -> Result<RuntimeProfileDashboardRecord, SpacetimeClientError> {
let procedure_input = build_runtime_profile_wallet_adjustment_input(
user_id,
amount,
ledger_id,
created_at_micros,
)
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.refund_profile_wallet_points_and_return_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_runtime_profile_wallet_adjustment_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn get_profile_recharge_center(
&self,
user_id: String,

View File

@@ -238,6 +238,60 @@ pub fn list_profile_wallet_ledger(
}
}
// 资产生成由 Axum 调用外部模型,钱包扣费必须先在 SpacetimeDB 内原子落账。
#[spacetimedb::procedure]
pub fn consume_profile_wallet_points_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileWalletAdjustmentInput,
) -> RuntimeProfileWalletAdjustmentProcedureResult {
match ctx.try_with_tx(|tx| {
apply_profile_wallet_adjustment(
tx,
input.clone(),
RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume,
true,
)
}) {
Ok(record) => RuntimeProfileWalletAdjustmentProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileWalletAdjustmentProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 生成链路失败时由 Axum 调用退款ledger_id 幂等保证重复补偿不会重复加钱。
#[spacetimedb::procedure]
pub fn refund_profile_wallet_points_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileWalletAdjustmentInput,
) -> RuntimeProfileWalletAdjustmentProcedureResult {
match ctx.try_with_tx(|tx| {
apply_profile_wallet_adjustment(
tx,
input.clone(),
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund,
false,
)
}) {
Ok(record) => RuntimeProfileWalletAdjustmentProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileWalletAdjustmentProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// play stats 与 dashboard 共用 dashboard projection 的 total_play_time / updated_at避免 Axum 侧拼装。
#[spacetimedb::procedure]
pub fn get_profile_play_stats(
@@ -1370,15 +1424,91 @@ fn apply_profile_wallet_delta(
ledger_id: &str,
created_at: Timestamp,
) -> Result<u64, String> {
let amount_delta =
i64::try_from(amount_delta).map_err(|_| "profile.wallet_amount 超出上限".to_string())?;
apply_profile_wallet_signed_delta(
ctx,
user_id,
amount_delta,
source_type,
ledger_id,
created_at,
false,
)
}
fn apply_profile_wallet_adjustment(
ctx: &ReducerContext,
input: RuntimeProfileWalletAdjustmentInput,
source_type: RuntimeProfileWalletLedgerSourceType,
consume: bool,
) -> Result<RuntimeProfileDashboardSnapshot, String> {
let validated_input = build_runtime_profile_wallet_adjustment_input(
input.user_id,
input.amount,
input.ledger_id,
input.created_at_micros,
)
.map_err(|error| error.to_string())?;
let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros);
let amount_delta = if consume {
-(validated_input.amount as i64)
} else {
validated_input.amount as i64
};
apply_profile_wallet_signed_delta(
ctx,
&validated_input.user_id,
amount_delta,
source_type,
&validated_input.ledger_id,
created_at,
true,
)?;
get_profile_dashboard_snapshot(
ctx,
RuntimeProfileDashboardGetInput {
user_id: validated_input.user_id,
},
)
}
fn apply_profile_wallet_signed_delta(
ctx: &ReducerContext,
user_id: &str,
amount_delta: i64,
source_type: RuntimeProfileWalletLedgerSourceType,
ledger_id: &str,
created_at: Timestamp,
idempotent: bool,
) -> Result<u64, String> {
if idempotent
&& ctx
.db
.profile_wallet_ledger()
.wallet_ledger_id()
.find(&ledger_id.to_string())
.is_some()
{
return Ok(profile_wallet_balance(ctx, user_id));
}
let current = ctx
.db
.profile_dashboard_state()
.user_id()
.find(&user_id.to_string());
let previous_balance = current.as_ref().map(|row| row.wallet_balance).unwrap_or(0);
let next_balance = previous_balance
.checked_add(amount_delta)
.ok_or_else(|| "profile.wallet_balance 超出上限".to_string())?;
let next_balance = if amount_delta >= 0 {
previous_balance
.checked_add(amount_delta as u64)
.ok_or_else(|| "profile.wallet_balance 超出上限".to_string())?
} else {
previous_balance
.checked_sub(amount_delta.unsigned_abs())
.ok_or_else(|| "叙世币余额不足".to_string())?
};
let created_state_at = current
.as_ref()
.map(|row| row.created_at)
@@ -1413,7 +1543,7 @@ fn apply_profile_wallet_delta(
ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger {
wallet_ledger_id: ledger_id.to_string(),
user_id: user_id.to_string(),
amount_delta: amount_delta as i64,
amount_delta,
balance_after: next_balance,
source_type,
created_at,

View File

@@ -11,6 +11,7 @@ import {
APP_RUNTIME_ROUTES,
normalizeAppPath,
pushAppHistoryPath,
readPublicWorkCodeFromLocationSearch,
resolvePathForSelectionStage,
resolveSelectionStageFromPath,
} from './routing/appPageRoutes';
@@ -45,6 +46,9 @@ export default function App() {
);
const [runtimeReturnStage, setRuntimeReturnStage] =
useState<SelectionStage>('platform');
const [initialPublicWorkCode] = useState(() =>
readPublicWorkCodeFromLocationSearch(window.location.search),
);
const setSelectionStage = useCallback((stage: SelectionStage) => {
setRawSelectionStage(stage);
@@ -132,6 +136,7 @@ export default function App() {
<PlatformEntryFlowShell
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
initialPublicWorkCode={initialPublicWorkCode}
hasSavedGame={false}
savedSnapshot={null}
handleContinueGame={handleContinueGame}

View File

@@ -1,4 +1,4 @@
import { ArrowLeft, CircleHelp, Loader2, RotateCcw } from 'lucide-react';
import { ArrowLeft, CircleHelp, Loader2, RotateCcw, Share2 } from 'lucide-react';
import { type PointerEvent, useEffect, useRef, useState } from 'react';
import type {
@@ -7,6 +7,8 @@ import type {
BigFishRuntimeSnapshotResponse,
SubmitBigFishInputRequest,
} from '../../../packages/shared/src/contracts/bigFish';
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import { copyTextToClipboard } from '../../services/clipboard';
import { UnifiedModal } from '../common/UnifiedModal';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
@@ -21,6 +23,8 @@ type TouchSample = TouchOrigin;
type BigFishRuntimeShellProps = {
run: BigFishRuntimeSnapshotResponse | null;
assetSlots?: BigFishAssetSlotResponse[];
shareTitle?: string | null;
sharePublicWorkCode?: string | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
@@ -219,6 +223,8 @@ function BigFishEntityDot({
export function BigFishRuntimeShell({
run,
assetSlots = [],
shareTitle = null,
sharePublicWorkCode = null,
isBusy = false,
error = null,
onBack,
@@ -230,6 +236,9 @@ export function BigFishRuntimeShell({
const currentTouchRef = useRef<TouchSample | null>(null);
const lastTouchSampleRef = useRef<TouchSample | null>(null);
const [isRuleModalOpen, setIsRuleModalOpen] = useState(false);
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const [stick, setStick] = useState({ x: 0, y: 0 });
const stickRef = useRef(stick);
@@ -282,6 +291,28 @@ export function BigFishRuntimeShell({
setStick(direction);
onSubmitInput(direction);
};
const sharePublicWork = () => {
const publicWorkCode = sharePublicWorkCode?.trim();
if (!publicWorkCode) {
return;
}
const sharePath = buildPublicWorkStagePath(
'big-fish-runtime',
publicWorkCode,
);
const shareUrl =
typeof window === 'undefined'
? sharePath
: new URL(sharePath, window.location.origin).href;
const title = shareTitle?.trim() || '大鱼吃小鱼';
const shareText = `邀请你来玩《${title}\n作品号${publicWorkCode}\n${shareUrl}`;
void copyTextToClipboard(shareText).then((copied) => {
setShareState(copied ? 'copied' : 'failed');
window.setTimeout(() => setShareState('idle'), 1400);
});
};
const beginTouchControl = (event: PointerEvent<HTMLDivElement>) => {
if (event.target instanceof HTMLElement && event.target.closest('button')) {
@@ -373,6 +404,29 @@ export function BigFishRuntimeShell({
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex items-center gap-2">
{sharePublicWorkCode?.trim() ? (
<button
type="button"
aria-label={
shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享作品'
}
title={
shareState === 'copied'
? '已复制'
: shareState === 'failed'
? '复制失败'
: '分享作品'
}
onClick={sharePublicWork}
className="pointer-events-auto inline-flex h-10 w-10 items-center justify-center rounded-full bg-black/28 text-white backdrop-blur"
>
<Share2 className="h-4 w-4" />
</button>
) : null}
<button
type="button"
aria-label="查看规则"

View File

@@ -38,6 +38,10 @@ import type {
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import {
buildPublicWorkStagePath,
pushAppHistoryPath,
} from '../../routing/appPageRoutes';
import {
getPublicAuthUserByCode,
getPublicAuthUserById,
@@ -48,11 +52,11 @@ import {
getBigFishCreationSession,
streamBigFishCreationMessage,
} from '../../services/big-fish-creation';
import { listBigFishGallery } from '../../services/big-fish-gallery';
import {
advanceLocalBigFishRuntimeRun,
startLocalBigFishRuntimeRun,
} from '../../services/big-fish-runtime';
import { listBigFishGallery } from '../../services/big-fish-gallery';
import {
deleteBigFishWork,
listBigFishWorks,
@@ -70,6 +74,8 @@ import {
} from '../../services/miniGameDraftGenerationProgress';
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
import {
buildBigFishPublicWorkCode,
buildPuzzlePublicWorkCode,
isSameBigFishPublicWorkCode,
isSamePuzzlePublicWorkCode,
} from '../../services/publicWorkCode';
@@ -407,6 +413,7 @@ export function PlatformEntryFlowShellImpl({
handleContinueGame,
handleStartNewGame,
handleCustomWorldSelect,
initialPublicWorkCode,
}: PlatformEntryFlowShellProps) {
const authUi = useAuthUi();
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
@@ -418,6 +425,10 @@ export function PlatformEntryFlowShellImpl({
>([]);
const [bigFishRun, setBigFishRun] =
useState<BigFishRuntimeSnapshotResponse | null>(null);
const [bigFishRuntimeShare, setBigFishRuntimeShare] = useState<{
title: string;
publicWorkCode: string;
} | null>(null);
const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false);
const [bigFishGenerationState, setBigFishGenerationState] =
useState<MiniGameDraftGenerationState | null>(null);
@@ -455,6 +466,7 @@ export function PlatformEntryFlowShellImpl({
readCustomWorldAgentUiState().activeSessionId &&
shouldRestoreCustomWorldAgentUiState(),
);
const handledInitialPublicWorkCodeRef = useRef<string | null>(null);
const platformBootstrap = usePlatformEntryBootstrap({
user: authUi?.user,
@@ -926,6 +938,12 @@ export function PlatformEntryFlowShellImpl({
);
setSelectedPuzzleDetail(galleryDetail.item);
setSelectionStage('puzzle-gallery-detail');
pushAppHistoryPath(
buildPublicWorkStagePath(
'puzzle-gallery-detail',
buildPuzzlePublicWorkCode(galleryDetail.item.profileId),
),
);
}
},
beforeExecuteAction: ({ payload }) => {
@@ -1002,6 +1020,7 @@ export function PlatformEntryFlowShellImpl({
setSelectedDetailEntry(null);
setBigFishWorks([]);
setBigFishRun(null);
setBigFishRuntimeShare(null);
setBigFishGenerationState(null);
setBigFishError(null);
setPuzzleOperation(null);
@@ -1118,6 +1137,7 @@ export function PlatformEntryFlowShellImpl({
}
setBigFishError(null);
setBigFishRuntimeShare(null);
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
setSelectionStage('big-fish-runtime');
}, [bigFishSession, setSelectionStage]);
@@ -1128,6 +1148,7 @@ export function PlatformEntryFlowShellImpl({
}
setBigFishError(null);
setBigFishRuntimeShare(null);
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
setSelectionStage('big-fish-runtime');
}, [bigFishRun, bigFishSession, setSelectionStage]);
@@ -1147,6 +1168,12 @@ export function PlatformEntryFlowShellImpl({
setPuzzleRun(startLocalPuzzleRun(item));
setPuzzleRuntimeReturnStage('puzzle-gallery-detail');
setSelectionStage('puzzle-runtime');
pushAppHistoryPath(
buildPublicWorkStagePath(
'puzzle-runtime',
buildPuzzlePublicWorkCode(item.profileId),
),
);
} catch (error) {
setPuzzleError(resolvePuzzleErrorMessage(error, '启动拼图玩法失败。'));
} finally {
@@ -1600,6 +1627,12 @@ export function PlatformEntryFlowShellImpl({
setSelectedPuzzleDetail(item);
setPuzzleDetailReturnTarget(returnTarget);
setSelectionStage('puzzle-gallery-detail');
pushAppHistoryPath(
buildPublicWorkStagePath(
'puzzle-gallery-detail',
buildPuzzlePublicWorkCode(item.profileId),
),
);
} catch (error) {
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图详情失败。'));
} finally {
@@ -1640,17 +1673,25 @@ export function PlatformEntryFlowShellImpl({
const startBigFishRunFromWork = useCallback(
(item: BigFishWorkSummary) => {
const sessionId = item.sourceSessionId?.trim();
if (!sessionId) {
setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。');
return;
}
const sessionId = item.sourceSessionId?.trim();
if (!sessionId) {
setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。');
return;
}
setBigFishError(null);
bigFishFlow.setSession(null);
setBigFishRun(startLocalBigFishRuntimeRun({ work: item }));
setSelectionStage('big-fish-runtime');
},
const publicWorkCode = buildBigFishPublicWorkCode(item.sourceSessionId);
setBigFishError(null);
bigFishFlow.setSession(null);
setBigFishRuntimeShare({
title: item.title,
publicWorkCode,
});
setBigFishRun(startLocalBigFishRuntimeRun({ work: item }));
setSelectionStage('big-fish-runtime');
pushAppHistoryPath(
buildPublicWorkStagePath('big-fish-runtime', publicWorkCode),
);
},
[bigFishFlow, setSelectionStage],
);
@@ -1800,6 +1841,19 @@ export function PlatformEntryFlowShellImpl({
],
);
useEffect(() => {
const publicWorkCode = initialPublicWorkCode?.trim();
if (
!publicWorkCode ||
handledInitialPublicWorkCodeRef.current === publicWorkCode
) {
return;
}
handledInitialPublicWorkCodeRef.current = publicWorkCode;
void handlePublicCodeSearch(publicWorkCode);
}, [handlePublicCodeSearch, initialPublicWorkCode]);
const openBigFishDraft = useCallback(
async (item: BigFishWorkSummary) => {
setBigFishRun(null);
@@ -2288,6 +2342,10 @@ export function PlatformEntryFlowShellImpl({
<BigFishRuntimeShell
run={bigFishRun}
assetSlots={bigFishSession?.assetSlots ?? []}
shareTitle={bigFishRuntimeShare?.title ?? null}
sharePublicWorkCode={
bigFishRuntimeShare?.publicWorkCode ?? null
}
isBusy={isBigFishBusy}
error={bigFishError}
onBack={() => {

View File

@@ -41,6 +41,7 @@ export type SyncedAgentDraftResult = {
export type PlatformEntryFlowShellProps = {
selectionStage: SelectionStage;
setSelectionStage: (stage: SelectionStage) => void;
initialPublicWorkCode?: string | null;
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;

View File

@@ -1,7 +1,8 @@
import { ArrowLeft, Copy, Pencil, Play, UserRound } from 'lucide-react';
import { ArrowLeft, Copy, Pencil, Play, Share2, UserRound } from 'lucide-react';
import { useState } from 'react';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import { copyTextToClipboard } from '../../services/clipboard';
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
@@ -31,12 +32,31 @@ export function PuzzleGalleryDetailView({
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const copyPublicWorkCode = () => {
void copyTextToClipboard(publicWorkCode).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
window.setTimeout(() => setCopyState('idle'), 1400);
});
};
const sharePublicWork = () => {
const sharePath = buildPublicWorkStagePath(
'puzzle-gallery-detail',
publicWorkCode,
);
const shareUrl =
typeof window === 'undefined'
? sharePath
: new URL(sharePath, window.location.origin).href;
const shareText = `邀请你来玩《${item.levelName}\n作品号${publicWorkCode}\n${shareUrl}`;
void copyTextToClipboard(shareText).then((copied) => {
setShareState(copied ? 'copied' : 'failed');
window.setTimeout(() => setShareState('idle'), 1400);
});
};
return (
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
@@ -62,6 +82,19 @@ export function PuzzleGalleryDetailView({
</button>
) : null}
<button
type="button"
disabled={isBusy}
onClick={sharePublicWork}
className="inline-flex items-center gap-2 rounded-full bg-white/12 px-4 py-2 text-sm font-bold text-white disabled:opacity-45"
>
<Share2 className="h-4 w-4" />
{shareState === 'copied'
? '已复制'
: shareState === 'failed'
? '复制失败'
: '分享作品'}
</button>
<button
type="button"
disabled={isBusy}

View File

@@ -1,8 +1,9 @@
import { ArrowLeft, Copy } from 'lucide-react';
import { ArrowLeft, Copy, Share2 } from 'lucide-react';
import { useState } from 'react';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
import { copyTextToClipboard } from '../../services/clipboard';
import type { CustomWorldProfile } from '../../types';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
@@ -74,6 +75,9 @@ export function RpgEntryWorldDetailView({
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const canStartGame = entry.visibility === 'published';
const previewCharacters = buildCustomWorldPlayableCharacters(
entry.profile,
@@ -96,6 +100,19 @@ export function RpgEntryWorldDetailView({
window.setTimeout(() => setCopyState('idle'), 1400);
});
};
const sharePublicWork = () => {
if (!publicWorkCode) {
return;
}
const shareUrl = buildPublicWorkDetailUrl(publicWorkCode);
const shareText = `邀请你来玩《${entry.worldName}\n作品号${publicWorkCode}\n${shareUrl}`;
void copyTextToClipboard(shareText).then((copied) => {
setShareState(copied ? 'copied' : 'failed');
window.setTimeout(() => setShareState('idle'), 1400);
});
};
return (
<div className="flex h-full min-h-0 flex-col">
@@ -146,21 +163,38 @@ export function RpgEntryWorldDetailView({
: '仅自己可见'}
</span>
{publicWorkCode ? (
<button
type="button"
onClick={copyPublicWorkCode}
className="platform-pill platform-pill--neutral flex items-center gap-1 px-3"
aria-label={`复制作品号 ${publicWorkCode}`}
title="复制作品号"
>
<span> {publicWorkCode}</span>
<Copy className="h-3 w-3" />
{copyState !== 'idle' ? (
<span className="text-xs">
{copyState === 'copied' ? '已复制' : '复制失败'}
</span>
) : null}
</button>
<>
<button
type="button"
onClick={copyPublicWorkCode}
className="platform-pill platform-pill--neutral flex items-center gap-1 px-3"
aria-label={`复制作品号 ${publicWorkCode}`}
title="复制作品号"
>
<span> {publicWorkCode}</span>
<Copy className="h-3 w-3" />
{copyState !== 'idle' ? (
<span className="text-xs">
{copyState === 'copied' ? '已复制' : '复制失败'}
</span>
) : null}
</button>
<button
type="button"
onClick={sharePublicWork}
className="platform-pill platform-pill--neutral flex items-center gap-1 px-3"
aria-label={`分享作品 ${entry.worldName}`}
title="分享作品"
>
<Share2 className="h-3 w-3" />
<span></span>
{shareState !== 'idle' ? (
<span className="text-xs">
{shareState === 'copied' ? '已复制' : '复制失败'}
</span>
) : null}
</button>
</>
) : null}
</div>
<div className="mt-4 text-3xl font-black text-white">

View File

@@ -9,6 +9,11 @@ import type {
CustomWorldLibraryEntry,
PlatformBrowseHistoryWriteEntry,
} from '../../../packages/shared/src/contracts/runtime';
import {
buildPublicWorkDetailPath,
pushAppHistoryPath,
} from '../../routing/appPageRoutes';
import { ApiClientError } from '../../services/apiClient';
import {
deleteRpgEntryWorldProfile,
getRpgEntryWorldGalleryDetail,
@@ -16,7 +21,6 @@ import {
publishRpgEntryWorldProfile,
unpublishRpgEntryWorldProfile,
} from '../../services/rpg-entry/rpgEntryLibraryClient';
import { ApiClientError } from '../../services/apiClient';
import type { CustomWorldProfile } from '../../types';
import {
normalizeRpgEntryAgentBackedProfile,
@@ -167,6 +171,9 @@ export function useRpgEntryLibraryDetail(
setSelectedDetailEntry(entry);
setDetailError(null);
setSelectionStage('detail');
if (entry.publicWorkCode?.trim()) {
pushAppHistoryPath(buildPublicWorkDetailPath(entry.publicWorkCode));
}
},
[appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage],
);
@@ -183,6 +190,11 @@ export function useRpgEntryLibraryDetail(
entry.profileId,
);
setSelectedDetailEntry(detailEntry);
if (detailEntry.publicWorkCode?.trim()) {
pushAppHistoryPath(
buildPublicWorkDetailPath(detailEntry.publicWorkCode),
);
}
void appendBrowseHistoryEntry({
ownerUserId: detailEntry.ownerUserId,
profileId: detailEntry.profileId,

View File

@@ -2,8 +2,12 @@ import { describe, expect, it } from 'vitest';
import {
APP_RUNTIME_ROUTES,
buildPublicWorkDetailPath,
buildPublicWorkDetailUrl,
buildPublicWorkStagePath,
isKnownMainAppPagePath,
normalizeAppPath,
readPublicWorkCodeFromLocationSearch,
resolvePathForSelectionStage,
resolveSelectionStageFromPath,
} from './appPageRoutes';
@@ -45,4 +49,22 @@ describe('appPageRoutes', () => {
).toBe(true);
expect(isKnownMainAppPagePath('/runtime/rpg/adventure/')).toBe(true);
});
it('builds and reads public work detail query routes', () => {
expect(buildPublicWorkDetailPath('CW-00000001')).toBe(
'/worlds/detail?work=CW-00000001',
);
expect(buildPublicWorkDetailUrl('CW-00000001', 'https://example.test')).toBe(
'https://example.test/worlds/detail?work=CW-00000001',
);
expect(readPublicWorkCodeFromLocationSearch('?work=CW-00000001')).toBe(
'CW-00000001',
);
expect(
buildPublicWorkStagePath('puzzle-gallery-detail', 'PZ-00000002'),
).toBe('/gallery/puzzle/detail?work=PZ-00000002');
expect(buildPublicWorkStagePath('big-fish-runtime', 'BF-00000003')).toBe(
'/runtime/big-fish?work=BF-00000003',
);
});
});

View File

@@ -2,6 +2,8 @@ import type { SelectionStage } from '../components/platform-entry';
export type RuntimePageRoute = 'rpg-character-select' | 'rpg-adventure';
export const PUBLIC_WORK_QUERY_PARAM = 'work';
const STAGE_ROUTE_ENTRIES = [
['platform', '/'],
['detail', '/worlds/detail'],
@@ -49,6 +51,37 @@ export function resolvePathForSelectionStage(stage: SelectionStage) {
return APP_STAGE_ROUTES[stage] ?? APP_STAGE_ROUTES.platform;
}
export function readPublicWorkCodeFromLocationSearch(search: string) {
const params = new URLSearchParams(search);
return params.get(PUBLIC_WORK_QUERY_PARAM)?.trim() || null;
}
export function buildPublicWorkDetailPath(publicWorkCode: string) {
return buildPublicWorkStagePath('detail', publicWorkCode);
}
export function buildPublicWorkStagePath(
stage: SelectionStage,
publicWorkCode: string,
) {
const code = publicWorkCode.trim();
const stagePath = resolvePathForSelectionStage(stage);
if (!code) {
return stagePath;
}
const params = new URLSearchParams();
params.set(PUBLIC_WORK_QUERY_PARAM, code);
return `${stagePath}?${params.toString()}`;
}
export function buildPublicWorkDetailUrl(
publicWorkCode: string,
origin = window.location.origin,
) {
return new URL(buildPublicWorkDetailPath(publicWorkCode), origin).href;
}
export function isKnownMainAppPagePath(pathname: string) {
const normalizedPath = normalizeAppPath(pathname);
const runtimePaths: readonly string[] = Object.values(APP_RUNTIME_ROUTES);
@@ -59,11 +92,14 @@ export function isKnownMainAppPagePath(pathname: string) {
}
export function pushAppHistoryPath(path: string) {
const normalizedPath = normalizeAppPath(path);
if (normalizeAppPath(window.location.pathname) === normalizedPath) {
const nextUrl = new URL(path, window.location.origin);
const normalizedPath = normalizeAppPath(nextUrl.pathname);
const nextRelativeUrl = `${normalizedPath}${nextUrl.search}`;
const currentRelativeUrl = `${normalizeAppPath(window.location.pathname)}${window.location.search}`;
if (currentRelativeUrl === nextRelativeUrl) {
return;
}
// 页面阶段变化是用户可感知导航,写入 history 以支持前进后退。
window.history.pushState(null, '', normalizedPath);
window.history.pushState(null, '', nextRelativeUrl);
}