This commit is contained in:
2026-05-01 16:08:19 +08:00
parent dce84f677d
commit 14208ccb64
15 changed files with 752 additions and 175 deletions

View File

@@ -29,6 +29,7 @@
- 四项统计需要使用浅色图标底强化识别,但不得追加规则说明类文案。
6. 简介区:展示玩法标签和作品简介;不追加说明类文案。
7. 底部动作:左侧按钮为“作品改造”,右侧主按钮为“启动”;两个按钮必须位于同一行,点击“启动”后进入对应玩法运行态并记录游玩次数。
- 未登录用户可进入并浏览作品详情页,但点击“作品改造”和“启动”都必须先弹出登录入口面板;登录成功后自动继续刚才点击的动作,不直接发起 Remix、启动 run 或本地运行态。
8. 页面配色必须跟随平台明暗主题变量;亮色主题使用平台浅色底、深色文字和主按钮渐变,暗色主题使用平台暗色底、亮色文字和对应主按钮渐变,不在详情页写死独立黑色皮肤。
9. 字号规范跟随平台页面既有节奏:标题/主按钮使用 `1rem` 级别,作品名使用卡片标题同级 `1rem`,辅助信息与简介使用 `0.8125rem` / `0.875rem`,标签与统计标签使用 `0.75rem`,避免在详情页使用随视口放大的独立大字号。
@@ -100,5 +101,6 @@
4. Remix 后原作品改造次数增加,新草稿归当前用户所有,且不会继承源作品统计。
5. 点赞公开作品会走对应后端记录入口,首次点赞后刷新仍能看到递增后的点赞次数,重复点赞不会继续增加。
6. 启动公开作品会走对应后端记录入口,刷新后仍能看到递增后的游玩次数。
7. 移动端首页“推荐”和“今日游戏”列表滚动时,仅中心卡片自动轮播多封面;旧中心卡离开后回到首张封面,新的中心卡接续轮播
8. 修改后运行编码检查、SpacetimeDB 绑定生成、Rust 检查和必要前端测试。
7. 未登录进入作品详情页后,点击“作品改造”和“启动”只打开登录入口面板;登录成功后恢复对应动作,未登录期间不会创建 Remix 草稿、开始拼图 run、记录 RPG 游玩或启动大鱼本地运行态
8. 移动端首页“推荐”和“今日游戏”列表滚动时,仅中心卡片自动轮播多封面;旧中心卡离开后回到首张封面,新的中心卡接续轮播。
9. 修改后运行编码检查、SpacetimeDB 绑定生成、Rust 检查和必要前端测试。

View File

@@ -0,0 +1,63 @@
# 新账号短信登录后置邀请码弹窗设计
日期:`2026-05-01`
## 1. 目标
账号入口不再展示独立注册入口。用户统一从短信登录进入,后端通过 `POST /api/auth/phone/login` 返回的 `created` 字段判断本次是否创建了新账号。
`created=true` 时,前端在登录成功后额外弹出独立邀请码面板:
1. 标题固定为 `请填写邀请码`
2. 标题下方展示邀请码输入框。
3. 输入为空时主按钮显示 `跳过`,点击后关闭面板。
4. 输入非空时主按钮显示 `提交`,点击后提交邀请码。
5. 面板右上角提供取消按钮,点击后关闭面板。
## 2. 入口调整
登录弹窗只保留可用登录方式:
1. 短信登录。
2. 密码登录。
3. 微信登录。
不得再展示 `注册` 页签、注册按钮或注册表单。邀请码不再出现在短信验证码表单中,避免用户把登录和注册理解成两套入口。
## 3. 邀请码提交
后置弹窗提交邀请码时调用已登录接口:
```text
POST /api/profile/referrals/redeem-code
```
请求体:
```json
{
"inviteCode": "SPRING2026"
}
```
后端继续使用 SpacetimeDB 的 `redeem_profile_referral_invite_code` procedure 作为唯一真相源。该 procedure 已负责校验:
1. 每个用户最多只能填写一个邀请码。
2. 邀请码必须存在。
3. 用户不能填写自己的邀请码。
4. 双方奖励与钱包流水在同一事务内落地。
## 4. URL 邀请码
若地址中存在 `inviteCode``invite_code`,前端只将其作为新账号后置弹窗的默认输入值。它不会触发注册页签,也不会在短信登录请求中提前提交。
若用户登录的是已有账号,则不会弹出新账号邀请码面板。
## 5. 完成定义
1. 登录弹窗内不可见注册入口。
2. 短信登录创建新账号后弹出邀请码面板。
3. 邀请码为空时按钮为 `跳过`,非空时按钮为 `提交`
4. 取消按钮可关闭面板。
5. 已登录邀请码接口允许提交,并继续由 SpacetimeDB procedure 兜底业务校验。
6. 前端测试覆盖注册入口删除、新账号弹窗、URL 邀请码预填与提交。

View File

@@ -91,6 +91,7 @@ remainingMs = max(0, timeLimitMs - effectiveElapsedMs)
1. 播放冻结视觉特效。
2. 显示冻结剩余时长。
3. 第一版冻结 `10000ms`
4. 若玩家打开冻结确认弹窗前视觉上仍是 `playing`,但确认扣费期间正式 run 已被服务端计时结算为 `failed`,服务端不得返回“操作不合法”。本次调用视为一次边界同步,已预扣费用必须退款,只返回失败态快照并刷新存档;前端关闭道具确认窗,展示失败面板,不播放冻结特效。
## 计费规则
@@ -106,6 +107,8 @@ remainingMs = max(0, timeLimitMs - effectiveElapsedMs)
若扣费或道具过程失败,确认弹窗保持打开并继续暂停倒计时,在弹窗内展示失败原因;只有成功确认后才关闭弹窗并播放对应反馈。
补充规则:冻结时间的边界同步不属于道具使用成功。服务端若发现 run 已超时失败,应退回本次预扣、把失败态落库并返回最新快照,避免玩家在确认窗内看到“操作不合法”。
## UI 规则
1. 底部只放 3 个道具按钮,不写规则说明文案。

View File

@@ -1396,9 +1396,13 @@ pub async fn use_puzzle_runtime_prop(
));
}
};
let should_sync_freeze_boundary = matches!(prop_kind.as_str(), "freezeTime" | "freeze_time");
let billing_asset_id = format!("{}:{}:{}", run_id, prop_kind, current_utc_micros());
let reducer_owner_user_id = owner_user_id.clone();
let run = execute_billable_asset_operation(
let reducer_run_id = run_id.clone();
let fallback_run_id = run_id.clone();
let fallback_owner_user_id = owner_user_id.clone();
let run_result = execute_billable_asset_operation(
&state,
&owner_user_id,
billing_asset_kind,
@@ -1407,7 +1411,7 @@ pub async fn use_puzzle_runtime_prop(
state
.spacetime_client()
.use_puzzle_runtime_prop(PuzzleRunPropRecordInput {
run_id,
run_id: reducer_run_id,
owner_user_id: reducer_owner_user_id,
prop_kind,
used_at_micros: current_utc_micros(),
@@ -1417,8 +1421,30 @@ pub async fn use_puzzle_runtime_prop(
.map_err(map_puzzle_client_error)
},
)
.await
.map_err(|error| puzzle_error_response(&request_context, PUZZLE_RUNTIME_PROVIDER, error))?;
.await;
let run = match run_result {
Ok(run) => run,
Err(error) if should_sync_puzzle_freeze_boundary(&error, should_sync_freeze_boundary) => {
// 中文注释:冻结确认窗打开时前端会暂停视觉计时,但正式 run 仍可能在服务端边界帧先结算失败。
// 这类情况已由扣费包装器退款,此处只同步失败态快照,避免玩家看到“操作不合法”。
state
.spacetime_client()
.get_puzzle_run(fallback_run_id, fallback_owner_user_id)
.await
.map_err(map_puzzle_client_error)
.map_err(|error| {
puzzle_error_response(&request_context, PUZZLE_RUNTIME_PROVIDER, error)
})?
}
Err(error) => {
return Err(puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
error,
));
}
};
Ok(json_success_body(
Some(&request_context),
@@ -2503,6 +2529,10 @@ fn map_puzzle_client_error(error: SpacetimeClientError) -> AppError {
}))
}
fn should_sync_puzzle_freeze_boundary(error: &AppError, is_freeze_time: bool) -> bool {
is_freeze_time && error.body_text().contains("操作不合法")
}
fn is_missing_puzzle_form_draft_procedure_error(error: &SpacetimeClientError) -> bool {
matches!(error, SpacetimeClientError::Procedure(message) if
message.contains("save_puzzle_form_draft")
@@ -3580,6 +3610,26 @@ mod tests {
let response = error.into_response();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
}
#[test]
fn freeze_boundary_sync_only_matches_freeze_invalid_operation() {
let invalid_operation =
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": "操作不合法",
}));
let other_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": "陶泥币余额不足",
}));
assert!(should_sync_puzzle_freeze_boundary(&invalid_operation, true));
assert!(!should_sync_puzzle_freeze_boundary(
&invalid_operation,
false
));
assert!(!should_sync_puzzle_freeze_boundary(&other_error, true));
}
}
struct PuzzleDashScopeSettings {

View File

@@ -29,7 +29,8 @@ use shared_contracts::runtime::{
ProfileRechargeOrderResponse, ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse,
ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse,
ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest,
RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse,
RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest,
RedeemProfileRewardCodeResponse,
};
use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime;
@@ -216,14 +217,27 @@ pub async fn get_profile_referral_invite_center(
}
pub async fn redeem_profile_referral_invite_code(
State(_state): State<AppState>,
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Json(_payload): Json<RedeemProfileReferralInviteCodeRequest>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<RedeemProfileReferralInviteCodeRequest>,
) -> Result<Json<Value>, Response> {
Err(runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_message("邀请码仅注册时填写"),
let user_id = authenticated.claims().user_id().to_string();
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
.redeem_profile_referral_invite_code(user_id, payload.invite_code, updated_at_micros as i64)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_redeem_profile_referral_invite_code_response(record),
))
}
@@ -507,6 +521,18 @@ fn build_profile_referral_invite_center_response(
}
}
fn build_redeem_profile_referral_invite_code_response(
record: module_runtime::RuntimeReferralRedeemRecord,
) -> RedeemProfileReferralInviteCodeResponse {
RedeemProfileReferralInviteCodeResponse {
center: build_profile_referral_invite_center_response(record.center),
invitee_reward_granted: record.invitee_reward_granted,
inviter_reward_granted: record.inviter_reward_granted,
invitee_balance_after: record.invitee_balance_after,
inviter_balance_after: record.inviter_balance_after,
}
}
fn build_redeem_profile_reward_code_response(
record: RuntimeProfileRewardCodeRedeemRecord,
) -> RedeemProfileRewardCodeResponse {
@@ -603,6 +629,7 @@ mod tests {
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use serde_json::Value;
use std::time::Duration;
use time::OffsetDateTime;
use tower::ServiceExt;
@@ -759,7 +786,7 @@ mod tests {
}
#[tokio::test]
async fn profile_referral_redeem_code_rejects_authenticated_manual_fill() {
async fn profile_referral_redeem_code_calls_spacetime_for_authenticated_user() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
@@ -777,7 +804,7 @@ mod tests {
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response
.into_body()
.collect()
@@ -787,8 +814,8 @@ mod tests {
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["message"],
Value::String("邀请码仅注册时填写".to_string())
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string())
);
}
@@ -900,7 +927,7 @@ mod tests {
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
let state = AppState::new(fast_spacetime_timeout_config()).expect("state should build");
state
.seed_test_phone_user_with_password("13800138104", "secret123")
.await
@@ -908,6 +935,13 @@ mod tests {
state
}
fn fast_spacetime_timeout_config() -> AppConfig {
AppConfig {
spacetime_procedure_timeout: Duration::from_secs(1),
..AppConfig::default()
}
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {

View File

@@ -18,6 +18,7 @@ const authMocks = vi.hoisted(() => ({
loginWithPhoneCode: vi.fn(),
logoutAllAuthSessions: vi.fn(),
logoutAuthUser: vi.fn(),
redeemRegistrationInviteCode: vi.fn(),
resetPassword: vi.fn(),
sendPhoneLoginCode: vi.fn(),
startWechatLogin: vi.fn(),
@@ -46,6 +47,7 @@ vi.mock('../../services/authService', () => ({
loginWithPhoneCode: authMocks.loginWithPhoneCode,
logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
logoutAuthUser: authMocks.logoutAuthUser,
redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode,
resetPassword: authMocks.resetPassword,
revokeAuthSession: vi.fn(),
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
@@ -105,6 +107,25 @@ beforeEach(() => {
authMocks.changePassword.mockResolvedValue(mockUser);
authMocks.logoutAllAuthSessions.mockResolvedValue(undefined);
authMocks.logoutAuthUser.mockResolvedValue(undefined);
authMocks.redeemRegistrationInviteCode.mockResolvedValue({
center: {
inviteCode: 'SY12345678',
inviteLinkPath: '/?inviteCode=SY12345678',
invitedCount: 1,
rewardedInviteCount: 1,
todayInviterRewardCount: 0,
todayInviterRewardRemaining: 3,
rewardPoints: 30,
hasRedeemedCode: true,
boundInviterUserId: 'user_inviter',
boundAt: '2026-05-01T00:00:00Z',
updatedAt: '2026-05-01T00:00:00Z',
},
inviteeRewardGranted: true,
inviterRewardGranted: true,
inviteeBalanceAfter: 30,
inviterBalanceAfter: 30,
});
authMocks.resetPassword.mockResolvedValue(mockUser);
authMocks.sendPhoneLoginCode.mockResolvedValue({
cooldownSeconds: 60,
@@ -250,7 +271,9 @@ test('auth gate keeps password entry available when login options are empty', as
test('auth gate falls back to password entry when login options request fails', async () => {
const user = userEvent.setup();
authMocks.getAuthLoginOptions.mockRejectedValue(new Error('读取登录方式失败'));
authMocks.getAuthLoginOptions.mockRejectedValue(
new Error('读取登录方式失败'),
);
render(
<AuthGate>
@@ -293,7 +316,6 @@ test('auth gate opens a login modal for protected actions and resumes after logi
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
'13800000000',
'123456',
undefined,
);
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
expect(onAuthenticated).toHaveBeenCalledTimes(1);
@@ -302,44 +324,98 @@ test('auth gate opens a login modal for protected actions and resumes after logi
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
});
test('auth gate opens register tab and preloads invite code from url', async () => {
test('auth gate hides register entry and opens invite modal for new sms account', async () => {
const user = userEvent.setup();
window.history.replaceState(null, '', '/?inviteCode=spring-2026');
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
authMocks.loginWithPhoneCode.mockResolvedValueOnce({
token: 'jwt-phone-new',
user: mockUser,
created: true,
referral: null,
});
render(
<AuthGate>
<div></div>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
const dialog = await screen.findByRole('dialog', { name: '账号入口' });
await waitFor(() => {
expect(
within(dialog)
.getByRole('tab', { name: '注册' })
.getAttribute('aria-selected'),
).toBe('true');
});
expect(
(within(dialog).getByLabelText('邀请码') as HTMLInputElement).value,
).toBe('SPRING2026');
expect(await screen.findByText('公开内容')).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
await user.click(screen.getByRole('button', { name: '进入作品' }));
const dialog = await screen.findByRole('dialog', { name: '账号入口' });
expect(within(dialog).queryByRole('tab', { name: '注册' })).toBeNull();
expect(within(dialog).queryByLabelText('邀请码')).toBeNull();
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await user.click(within(dialog).getByRole('button', { name: '注册' }));
await user.click(within(dialog).getByRole('button', { name: '登录' }));
await waitFor(() => {
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
'13800000000',
'123456',
);
});
const inviteDialog = await screen.findByRole('dialog', {
name: '请填写邀请码',
});
expect(
(within(inviteDialog).getByLabelText('邀请码') as HTMLInputElement).value,
).toBe('SPRING2026');
expect(
within(inviteDialog).getByRole('button', { name: '提交' }),
).toBeTruthy();
await user.click(within(inviteDialog).getByRole('button', { name: '提交' }));
await waitFor(() => {
expect(authMocks.redeemRegistrationInviteCode).toHaveBeenCalledWith(
'SPRING2026',
);
});
});
test('registration invite modal can skip when invite code is empty', async () => {
const user = userEvent.setup();
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
authMocks.loginWithPhoneCode.mockResolvedValueOnce({
token: 'jwt-phone-new',
user: mockUser,
created: true,
referral: null,
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await user.click(within(dialog).getByRole('button', { name: '登录' }));
const inviteDialog = await screen.findByRole('dialog', {
name: '请填写邀请码',
});
await user.click(within(inviteDialog).getByRole('button', { name: '跳过' }));
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '请填写邀请码' })).toBeNull();
});
expect(authMocks.redeemRegistrationInviteCode).not.toHaveBeenCalled();
});
test('auth state refresh keeps mounted platform content and local tab state', async () => {
const user = userEvent.setup();
authMocks.getCurrentAuthUser.mockResolvedValue({

View File

@@ -34,6 +34,7 @@ import {
loginWithPhoneCode,
logoutAllAuthSessions,
logoutAuthUser,
redeemRegistrationInviteCode,
resetPassword,
revokeAuthSession,
sendPhoneLoginCode,
@@ -44,6 +45,7 @@ import { AccountModal } from './AccountModal';
import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext';
import { BindPhoneScreen } from './BindPhoneScreen';
import { LoginScreen } from './LoginScreen';
import { RegistrationInviteModal } from './RegistrationInviteModal';
type AuthGateProps = {
children: ReactNode;
@@ -91,10 +93,12 @@ export function AuthGate({ children }: AuthGateProps) {
const [bindingPhone, setBindingPhone] = useState(false);
const [wechatLoading, setWechatLoading] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
const [loginInitialMode, setLoginInitialMode] = useState<
'login' | 'register'
>('login');
const [pendingInviteCode, setPendingInviteCode] = useState('');
const [showRegistrationInviteModal, setShowRegistrationInviteModal] =
useState(false);
const [submittingRegistrationInvite, setSubmittingRegistrationInvite] =
useState(false);
const [registrationInviteError, setRegistrationInviteError] = useState('');
const [showSettingsModal, setShowSettingsModal] = useState(false);
const [settingsEntryMode, setSettingsEntryMode] = useState<
'settings' | 'account'
@@ -141,6 +145,7 @@ export function AuthGate({ children }: AuthGateProps) {
setUser(null);
setStatus('unauthenticated');
setShowLoginModal(false);
setShowRegistrationInviteModal(false);
setShowSettingsModal(false);
setSettingsEntryMode('settings');
setInitialSettingsSection(null);
@@ -150,6 +155,8 @@ export function AuthGate({ children }: AuthGateProps) {
setLoginCaptchaChallenge(null);
setBindCaptchaChallenge(null);
setChangePhoneCaptchaChallenge(null);
setPendingInviteCode('');
setRegistrationInviteError('');
setError('');
}, []);
@@ -182,12 +189,16 @@ export function AuthGate({ children }: AuthGateProps) {
const closeLoginModal = useCallback(() => {
pendingProtectedActionRef.current = null;
setShowLoginModal(false);
setLoginInitialMode('login');
setPendingInviteCode('');
setLoginCaptchaChallenge(null);
setError('');
}, []);
const closeRegistrationInviteModal = useCallback(() => {
setShowRegistrationInviteModal(false);
setRegistrationInviteError('');
setPendingInviteCode('');
}, []);
const closeSettingsModal = useCallback(() => {
setShowSettingsModal(false);
setSettingsEntryMode('settings');
@@ -202,8 +213,6 @@ export function AuthGate({ children }: AuthGateProps) {
}
pendingProtectedActionRef.current = postLoginAction ?? null;
setLoginInitialMode('login');
setPendingInviteCode('');
setShowLoginModal(true);
},
[readyUser],
@@ -253,10 +262,7 @@ export function AuthGate({ children }: AuthGateProps) {
return;
}
autoOpenedInviteCodeRef.current = inviteCode;
pendingProtectedActionRef.current = null;
setPendingInviteCode(inviteCode);
setLoginInitialMode('register');
setShowLoginModal(true);
}, [readyUser, showLoginModal, status]);
useEffect(() => {
@@ -738,8 +744,6 @@ export function AuthGate({ children }: AuthGateProps) {
wechatLoading={wechatLoading}
error={error}
captchaChallenge={loginCaptchaChallenge}
initialMode={loginInitialMode}
initialInviteCode={pendingInviteCode}
onClose={closeLoginModal}
onSendCode={async (phone, scene, captcha) => {
setSendingCode(true);
@@ -764,20 +768,15 @@ export function AuthGate({ children }: AuthGateProps) {
setSendingCode(false);
}
}}
onPhoneSubmit={async (phone, code, inviteCode) => {
onPhoneSubmit={async (phone, code) => {
setLoggingIn(true);
setError('');
try {
const response = await loginWithPhoneCode(
phone,
code,
inviteCode,
);
const response = await loginWithPhoneCode(phone, code);
setStoredLastLoginPhone(phone);
setLoginCaptchaChallenge(null);
if (response.referral && !response.referral.ok) {
setError(response.referral.message || '邀请码未绑定');
}
setShowRegistrationInviteModal(response.created);
setRegistrationInviteError('');
activateReadyUser(response.user);
} catch (loginError) {
setError(
@@ -839,6 +838,30 @@ export function AuthGate({ children }: AuthGateProps) {
}
}}
/>
<RegistrationInviteModal
isOpen={showRegistrationInviteModal}
platformTheme={settings.platformTheme}
initialInviteCode={pendingInviteCode}
submitting={submittingRegistrationInvite}
error={registrationInviteError}
onClose={closeRegistrationInviteModal}
onSubmit={async (inviteCode) => {
setSubmittingRegistrationInvite(true);
setRegistrationInviteError('');
try {
await redeemRegistrationInviteCode(inviteCode);
closeRegistrationInviteModal();
} catch (inviteError) {
setRegistrationInviteError(
inviteError instanceof Error
? inviteError.message
: '填写邀请码失败,请稍后再试。',
);
} finally {
setSubmittingRegistrationInvite(false);
}
}}
/>
</div>
{children}
</div>

View File

@@ -10,7 +10,7 @@ import { getStoredLastLoginPhone } from '../../services/authService';
import { CaptchaChallengeField } from './CaptchaChallengeField';
type SmsScene = 'login' | 'reset_password';
type LoginTab = 'phone' | 'password' | 'register';
type LoginTab = 'phone' | 'password';
type LoginScreenProps = {
isOpen: boolean;
@@ -21,8 +21,6 @@ type LoginScreenProps = {
wechatLoading: boolean;
error: string;
captchaChallenge: AuthCaptchaChallenge | null;
initialMode?: 'login' | 'register';
initialInviteCode?: string;
onClose: () => void;
onSendCode: (
phone: string,
@@ -35,11 +33,7 @@ type LoginScreenProps = {
cooldownSeconds: number;
expiresInSeconds: number;
}>;
onPhoneSubmit: (
phone: string,
code: string,
inviteCode?: string,
) => Promise<void>;
onPhoneSubmit: (phone: string, code: string) => Promise<void>;
onPasswordSubmit: (phone: string, password: string) => Promise<void>;
onResetPassword: (
phone: string,
@@ -58,8 +52,6 @@ export function LoginScreen({
wechatLoading,
error,
captchaChallenge,
initialMode = 'login',
initialInviteCode = '',
onClose,
onSendCode,
onPhoneSubmit,
@@ -74,7 +66,6 @@ export function LoginScreen({
const [resetPhone, setResetPhone] = useState('');
const [resetCode, setResetCode] = useState('');
const [resetPasswordValue, setResetPasswordValue] = useState('');
const [inviteCode, setInviteCode] = useState(initialInviteCode);
const [captchaAnswer, setCaptchaAnswer] = useState('');
const [cooldownSeconds, setCooldownSeconds] = useState(0);
const [resetCooldownSeconds, setResetCooldownSeconds] = useState(0);
@@ -97,23 +88,16 @@ export function LoginScreen({
setResetPhone('');
setResetCode('');
setResetPasswordValue('');
setInviteCode(initialInviteCode);
setCaptchaAnswer('');
setCooldownSeconds(0);
setResetCooldownSeconds(0);
setHint('');
setActiveLoginTab(
initialMode === 'register' && phoneLoginEnabled
? 'register'
: phoneLoginEnabled
? 'phone'
: 'password',
);
}, [initialInviteCode, initialMode, isOpen, phoneLoginEnabled]);
setActiveLoginTab(phoneLoginEnabled ? 'phone' : 'password');
}, [isOpen, phoneLoginEnabled]);
useEffect(() => {
if (
(activeLoginTab === 'phone' || activeLoginTab === 'register') &&
activeLoginTab === 'phone' &&
!phoneLoginEnabled &&
passwordLoginEnabled
) {
@@ -215,7 +199,7 @@ export function LoginScreen({
{phoneLoginEnabled ? (
<div
className={`grid gap-2 ${
passwordLoginEnabled ? 'grid-cols-3' : 'grid-cols-2'
passwordLoginEnabled ? 'grid-cols-2' : 'grid-cols-1'
}`}
role="tablist"
aria-label="登录方式"
@@ -234,12 +218,6 @@ export function LoginScreen({
</LoginTabButton>
) : null}
<LoginTabButton
active={activeLoginTab === 'register'}
onClick={() => setActiveLoginTab('register')}
>
</LoginTabButton>
</div>
) : null}
@@ -338,42 +316,6 @@ export function LoginScreen({
/>
) : null}
{phoneLoginEnabled && activeLoginTab === 'register' ? (
<PhoneCodeForm
phone={phone}
code={code}
inviteCode={inviteCode}
captchaAnswer={captchaAnswer}
captchaChallenge={captchaChallenge}
cooldownSeconds={cooldownSeconds}
sendingCode={sendingCode}
loggingIn={loggingIn}
error={error}
hint={hint}
submitLabel="注册"
enabled={phoneLoginEnabled}
showPhoneField
showInviteCodeField
onPhoneChange={setPhone}
onCodeChange={setCode}
onInviteCodeChange={setInviteCode}
onCaptchaAnswerChange={setCaptchaAnswer}
onSendCode={async () => {
setHint('');
const result = await onSendCode(phone, 'login', {
challengeId: captchaChallenge?.challengeId,
answer: captchaAnswer,
});
setCooldownSeconds(result.cooldownSeconds);
setHint(
`短信请求已提交,验证码有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
);
setCaptchaAnswer('');
}}
onSubmit={() => onPhoneSubmit(phone, code, inviteCode)}
/>
) : null}
{!passwordLoginEnabled &&
!phoneLoginEnabled &&
!wechatLoginEnabled ? (
@@ -420,7 +362,6 @@ function LoginTabButton({
function PhoneCodeForm({
phone,
code,
inviteCode = '',
captchaAnswer,
captchaChallenge,
cooldownSeconds,
@@ -431,17 +372,14 @@ function PhoneCodeForm({
submitLabel,
enabled,
showPhoneField,
showInviteCodeField = false,
onPhoneChange,
onCodeChange,
onInviteCodeChange,
onCaptchaAnswerChange,
onSendCode,
onSubmit,
}: {
phone: string;
code: string;
inviteCode?: string;
captchaAnswer: string;
captchaChallenge: AuthCaptchaChallenge | null;
cooldownSeconds: number;
@@ -452,10 +390,8 @@ function PhoneCodeForm({
submitLabel: string;
enabled: boolean;
showPhoneField: boolean;
showInviteCodeField?: boolean;
onPhoneChange: (value: string) => void;
onCodeChange: (value: string) => void;
onInviteCodeChange?: (value: string) => void;
onCaptchaAnswerChange: (value: string) => void;
onSendCode: () => Promise<void>;
onSubmit: () => Promise<void>;
@@ -486,19 +422,6 @@ function PhoneCodeForm({
</label>
) : null}
{showInviteCodeField ? (
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="off"
value={inviteCode}
onChange={(event) => onInviteCodeChange?.(event.target.value)}
placeholder="邀请码"
/>
</label>
) : null}
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<div className="flex gap-3">

View File

@@ -0,0 +1,115 @@
import { X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
type RegistrationInviteModalProps = {
isOpen: boolean;
platformTheme: PlatformTheme;
initialInviteCode: string;
submitting: boolean;
error: string;
onClose: () => void;
onSubmit: (inviteCode: string) => Promise<void>;
};
export function RegistrationInviteModal({
isOpen,
platformTheme,
initialInviteCode,
submitting,
error,
onClose,
onSubmit,
}: RegistrationInviteModalProps) {
const [inviteCode, setInviteCode] = useState(initialInviteCode);
const normalizedInviteCode = useMemo(
() =>
inviteCode
.trim()
.replace(/[^0-9a-z]/gi, '')
.toUpperCase(),
[inviteCode],
);
useEffect(() => {
if (!isOpen) {
return;
}
setInviteCode(initialInviteCode);
}, [initialInviteCode, isOpen]);
if (!isOpen) {
return null;
}
return (
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[130] flex items-end justify-center px-3 py-4 text-[var(--platform-text-strong)] backdrop-blur-sm sm:items-center sm:p-4`}
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="registration-invite-dialog-title"
className="platform-auth-card w-full max-w-sm overflow-hidden rounded-[2rem]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<div
id="registration-invite-dialog-title"
className="text-lg font-semibold text-[var(--platform-text-strong)]"
>
</div>
<button
type="button"
onClick={onClose}
className="platform-icon-button p-2"
aria-label="取消填写邀请码"
>
<X className="h-4 w-4" />
</button>
</div>
<form
className="flex flex-col gap-4 px-5 py-5"
onSubmit={(event) => {
event.preventDefault();
if (!normalizedInviteCode) {
onClose();
return;
}
void onSubmit(normalizedInviteCode);
}}
>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="off"
value={inviteCode}
onChange={(event) => setInviteCode(event.target.value)}
placeholder="邀请码"
/>
</label>
{error ? (
<div className="platform-banner platform-banner--danger text-sm">
{error}
</div>
) : null}
<button
type="submit"
disabled={submitting}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting ? '提交中' : normalizedInviteCode ? '提交' : '跳过'}
</button>
</form>
</div>
</div>
);
}

View File

@@ -3026,37 +3026,42 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (isBigFishGalleryEntry(selectedPublicWorkDetail)) {
const work = mapPublicWorkDetailToBigFishWork(selectedPublicWorkDetail);
if (!work) {
setPublicWorkDetailError('当前作品缺少会话信息,暂时无法进入玩法。');
return;
}
startBigFishRunFromWork(work);
return;
}
if (isPuzzleGalleryEntry(selectedPublicWorkDetail)) {
const work = mapPublicWorkDetailToPuzzleWork(selectedPublicWorkDetail);
if (!work) {
setPublicWorkDetailError('当前拼图作品信息不完整,暂时无法进入玩法。');
return;
}
setPublicWorkDetailError(null);
void startPuzzleRunFromProfile(work.profileId, 'work-detail', work, true);
return;
}
const launchEntry =
selectedDetailEntry?.profileId === selectedPublicWorkDetail.profileId
? selectedDetailEntry
: null;
if (!launchEntry) {
setPublicWorkDetailError('作品详情尚未读取完成。');
return;
}
runProtectedAction(() => {
if (isBigFishGalleryEntry(selectedPublicWorkDetail)) {
const work = mapPublicWorkDetailToBigFishWork(selectedPublicWorkDetail);
if (!work) {
setPublicWorkDetailError('当前作品缺少会话信息,暂时无法进入玩法。');
return;
}
startBigFishRunFromWork(work);
return;
}
if (isPuzzleGalleryEntry(selectedPublicWorkDetail)) {
const work = mapPublicWorkDetailToPuzzleWork(selectedPublicWorkDetail);
if (!work) {
setPublicWorkDetailError('当前拼图作品信息不完整,暂时无法进入玩法。');
return;
}
setPublicWorkDetailError(null);
void startPuzzleRunFromProfile(
work.profileId,
'work-detail',
work,
true,
);
return;
}
const launchEntry =
selectedDetailEntry?.profileId === selectedPublicWorkDetail.profileId
? selectedDetailEntry
: null;
if (!launchEntry) {
setPublicWorkDetailError('作品详情尚未读取完成。');
return;
}
setIsPublicWorkDetailBusy(true);
void recordRpgEntryWorldGalleryPlay(
launchEntry.ownerUserId,

View File

@@ -688,6 +688,76 @@ test('道具使用失败时保留确认弹窗和暂停态', async () => {
expect(onPauseChange).toHaveBeenLastCalledWith(true);
});
test('冻结确认期间后端同步失败态时关闭确认窗并展示失败面板', async () => {
const onUseProp = vi.fn().mockResolvedValue({
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'failed',
elapsedMs: 180_000,
remainingMs: 0,
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
},
},
});
const playingRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
startedAtMs: Date.now(),
remainingMs: 180_000,
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
},
},
};
const { rerender } = renderPuzzleRuntime(
<PuzzleRuntimeShell
run={playingRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
onUseProp={onUseProp}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '冻结' }));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '确定' }));
});
rerender(
<AuthUiContext.Provider value={createAuthValue()}>
<PuzzleRuntimeShell
run={{
...playingRun,
currentLevel: {
...playingRun.currentLevel!,
status: 'failed',
elapsedMs: 180_000,
remainingMs: 0,
},
}}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
onUseProp={onUseProp}
/>
</AuthUiContext.Provider>,
);
expect(screen.queryByRole('dialog', { name: '冻结时间' })).toBeNull();
expect(screen.getByRole('dialog', { name: '关卡失败' })).toBeTruthy();
expect(screen.queryByTestId('puzzle-freeze-effect')).toBeNull();
});
test('倒计时归零时通知父层同步失败态', () => {
vi.useFakeTimers();
const onTimeExpired = vi.fn();

View File

@@ -430,6 +430,7 @@ export function PuzzleRuntimeShell({
const mergeFlashTimeoutRef = useRef<number | null>(null);
const boardRef = useRef<HTMLDivElement | null>(null);
const currentLevel = run?.currentLevel ?? null;
const currentLevelRef = useRef(currentLevel);
const board = currentLevel?.board ?? null;
const displayRemainingMs = currentLevel
? resolveRuntimeRemainingMs(currentLevel, timerNowMs, uiPauseStartedAtMs)
@@ -448,6 +449,10 @@ export function PuzzleRuntimeShell({
currentLevel?.coverImageSrc ?? null,
);
useEffect(() => {
currentLevelRef.current = currentLevel;
}, [currentLevel]);
const pieces = useMemo<PuzzleBoardPieceViewModel[]>(() => {
if (!board) {
return [];
@@ -1068,9 +1073,10 @@ export function PuzzleRuntimeShell({
const propKind = propDialog.propKind;
setIsPropConfirming(true);
setPropConfirmError(null);
let useResult: PuzzleRunSnapshot | null | void = null;
try {
await pauseChangePromiseRef.current;
const useResult = await onUseProp?.(propKind);
useResult = await onUseProp?.(propKind);
if (useResult === null) {
return;
}
@@ -1090,10 +1096,15 @@ export function PuzzleRuntimeShell({
setIsOriginalOverlayVisible(true);
}
if (propKind === 'freezeTime') {
setIsFreezeEffectVisible(true);
window.setTimeout(() => {
setIsFreezeEffectVisible(false);
}, 900);
// 中文注释:正式 run 可能在冻结确认期间已被服务端结算为失败态;
// 这种边界同步只关闭确认窗,不再播放冻结成功反馈。
const resultLevel = (useResult ?? null)?.currentLevel ?? currentLevelRef.current;
if (resultLevel?.status === 'playing') {
setIsFreezeEffectVisible(true);
window.setTimeout(() => {
setIsFreezeEffectVisible(false);
}, 900);
}
}
if (propKind === 'extendTime') {
setTimerNowMs(Date.now());

View File

@@ -25,7 +25,10 @@ import {
getBigFishCreationSession,
} from '../../services/big-fish-creation';
import { listBigFishGallery } from '../../services/big-fish-gallery';
import { startLocalBigFishRuntimeRun } from '../../services/big-fish-runtime';
import {
recordBigFishPlay,
startLocalBigFishRuntimeRun,
} from '../../services/big-fish-runtime';
import { listBigFishWorks } from '../../services/big-fish-works';
import {
createPuzzleAgentSession,
@@ -34,12 +37,16 @@ import {
import {
getPuzzleGalleryDetail,
listPuzzleGallery,
remixPuzzleGalleryWork,
} from '../../services/puzzle-gallery';
import {
advanceLocalPuzzleNextLevel,
advancePuzzleNextLevel,
getPuzzleRun,
startPuzzleRun,
submitPuzzleLeaderboard,
updatePuzzleRunPause,
usePuzzleRuntimeProp,
} from '../../services/puzzle-runtime';
import {
dragLocalPuzzlePiece,
@@ -78,6 +85,7 @@ import {
AuthUiContext,
type PlatformSettingsSection,
} from '../auth/AuthUiContext';
import { type CustomWorldProfile, WorldType } from '../../types';
import {
RpgEntryFlowShell,
type RpgEntryFlowShellProps,
@@ -199,6 +207,7 @@ vi.mock('../../services/puzzle-works', () => ({
vi.mock('../../services/puzzle-gallery', () => ({
getPuzzleGalleryDetail: vi.fn(),
listPuzzleGallery: vi.fn(),
remixPuzzleGalleryWork: vi.fn(),
}));
vi.mock('../../services/puzzle-runtime', () => ({
@@ -233,6 +242,7 @@ vi.mock('../../services/big-fish-gallery', () => ({
vi.mock('../../services/big-fish-runtime', () => ({
advanceLocalBigFishRuntimeRun: vi.fn((run) => run),
recordBigFishPlay: vi.fn(),
startLocalBigFishRuntimeRun: vi.fn(),
}));
@@ -591,22 +601,38 @@ function buildClearedPuzzleRun(params: {
function buildMockRpgGalleryDetail(
entry: CustomWorldGalleryCard,
): CustomWorldLibraryEntry {
): CustomWorldLibraryEntry<CustomWorldProfile> {
return {
...entry,
profile: {
id: entry.profileId,
settingText: entry.summaryText,
name: entry.worldName,
subtitle: entry.subtitle,
summary: entry.summaryText,
tone: '压抑、潮湿、悬疑',
playerGoal: '查清旧案。',
templateWorldType: WorldType.WUXIA,
attributeSchema: {
id: `${entry.profileId}-attribute-schema`,
worldId: entry.profileId,
schemaVersion: 1,
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: entry.worldName,
settingSummary: entry.summaryText,
tone: '压抑、潮湿、悬疑',
conflictCore: '雾潮正在逼近港口',
},
slots: [],
},
majorFactions: ['守灯会'],
coreConflicts: ['雾潮正在逼近港口'],
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [],
} as never,
},
};
}
@@ -1474,6 +1500,9 @@ beforeEach(() => {
vi.mocked(listBigFishGallery).mockResolvedValue({
items: [],
});
vi.mocked(recordBigFishPlay).mockResolvedValue({
session: {} as never,
});
vi.mocked(startLocalBigFishRuntimeRun).mockReturnValue({
runId: 'big-fish-run-1',
sessionId: 'big-fish-session-public-1',
@@ -1503,9 +1532,21 @@ beforeEach(() => {
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [],
});
vi.mocked(remixPuzzleGalleryWork).mockRejectedValue(
new Error('未启用拼图 remix'),
);
vi.mocked(advancePuzzleNextLevel).mockImplementation(async (runId) => ({
run: buildMockPuzzleRun(`${runId}-next-profile`, '后端推荐下一关'),
}));
vi.mocked(getPuzzleRun).mockImplementation(async (runId) => ({
run: buildMockPuzzleRun(`${runId}-profile`, '后端同步关卡'),
}));
vi.mocked(updatePuzzleRunPause).mockImplementation(async (runId) => ({
run: buildMockPuzzleRun(`${runId}-profile`, '后端同步关卡'),
}));
vi.mocked(usePuzzleRuntimeProp).mockImplementation(async (runId) => ({
run: buildMockPuzzleRun(`${runId}-profile`, '后端同步关卡'),
}));
vi.mocked(submitPuzzleLeaderboard).mockImplementation(
async (runId, payload) => ({
run: {
@@ -1921,6 +1962,114 @@ test('clicking a public work while logged out opens public detail without starti
expect(recordRpgEntryWorldGalleryPlay).not.toHaveBeenCalled();
});
test('logged out public detail gates puzzle start and remix before real actions', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
const publishedPuzzleWork = {
workId: 'puzzle-work-public-1',
profileId: 'puzzle-profile-public-1',
ownerUserId: 'user-2',
sourceSessionId: null,
authorDisplayName: '拼图作者',
levelName: '星桥机关',
summary: '旋转碎片并接通星桥机关。',
themeTags: ['机关', '星桥'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T09:00:00.000Z',
publishedAt: '2026-04-25T09:00:00.000Z',
playCount: 3,
remixCount: 0,
likeCount: 0,
publishReady: true,
} satisfies PuzzleWorkSummary;
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [publishedPuzzleWork],
});
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: publishedPuzzleWork,
});
render(
<TestWrapper
authValue={createAuthValue({
user: null,
canAccessProtectedData: false,
openLoginModal: () => {},
requireAuth,
})}
/>,
);
await waitFor(() => {
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0);
});
const workCards = screen.getAllByRole('button', { name: //u });
await user.click(workCards[0]!);
expect(await screen.findByText('详情')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '启动' }));
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(startPuzzleRun).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: '作品改造' }));
expect(requireAuth).toHaveBeenCalledTimes(2);
expect(remixPuzzleGalleryWork).not.toHaveBeenCalled();
});
test('logged out public detail gates big fish start before local runtime', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
const bigFishWork: BigFishWorkSummary = {
workId: 'big-fish-work-public-1',
sourceSessionId: 'big-fish-session-public-1',
ownerUserId: 'user-2',
authorDisplayName: '大鱼作者',
title: '机械深海 大鱼吃小鱼',
subtitle: '机械微生物吞并进化',
summary: '从微光孢子一路吞并成长到深海巨鲲。',
coverImageSrc: null,
status: 'published',
updatedAt: '2026-04-25T10:30:00.000Z',
publishReady: true,
levelCount: 8,
levelMainImageReadyCount: 8,
levelMotionReadyCount: 16,
backgroundReady: true,
};
vi.mocked(listBigFishGallery).mockResolvedValue({
items: [bigFishWork],
});
render(
<TestWrapper
authValue={createAuthValue({
user: null,
canAccessProtectedData: false,
openLoginModal: () => {},
requireAuth,
})}
/>,
);
const searchInput = await screen.findByPlaceholderText(
'输入 SY / CW / BF / PZ 编号',
);
await user.type(searchInput, 'BF-NPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
expect(await screen.findByText('详情')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '启动' }));
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(startLocalBigFishRuntimeRun).not.toHaveBeenCalled();
expect(recordBigFishPlay).not.toHaveBeenCalled();
});
test('creation hub clears all private work shelves immediately after logout state', async () => {
const user = userEvent.setup();
const loggedInAuth = createAuthValue();
@@ -2593,6 +2742,7 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
await user.click(await screen.findByRole('button', { name: '启动' }));
await waitFor(() => {
expect(startPuzzleRun).toHaveBeenCalledWith({
levelId: null,
profileId: 'puzzle-profile-public-1',
});
});

View File

@@ -32,6 +32,7 @@ import {
liftAuthRiskBlock,
loginWithPhoneCode,
logoutAllAuthSessions,
redeemRegistrationInviteCode,
sendPhoneLoginCode,
startWechatLogin,
updateAuthProfile,
@@ -245,6 +246,42 @@ describe('authService', () => {
expect(window.dispatchEvent).not.toHaveBeenCalled();
});
it('redeems registration invite code after authenticated new account login', async () => {
apiClientMocks.requestJson.mockResolvedValue({
center: {
inviteCode: 'SY12345678',
inviteLinkPath: '/?inviteCode=SY12345678',
invitedCount: 1,
rewardedInviteCount: 1,
todayInviterRewardCount: 0,
todayInviterRewardRemaining: 3,
rewardPoints: 30,
hasRedeemedCode: true,
boundInviterUserId: 'user_inviter',
boundAt: '2026-05-01T00:00:00Z',
updatedAt: '2026-05-01T00:00:00Z',
},
inviteeRewardGranted: true,
inviterRewardGranted: true,
inviteeBalanceAfter: 30,
inviterBalanceAfter: 30,
});
const response = await redeemRegistrationInviteCode(' spring-2026 ');
expect(response.inviteeRewardGranted).toBe(true);
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/profile/referrals/redeem-code',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
inviteCode: 'SPRING2026',
}),
}),
'填写邀请码失败',
);
});
it('stores renewed access token after wechat bind activation', async () => {
apiClientMocks.requestJson.mockResolvedValue({
token: 'jwt-wechat-bind-token',

View File

@@ -25,6 +25,7 @@ import type {
LogoutResponse,
PublicUserSearchResponse,
} from '../../packages/shared/src/contracts/auth';
import type { RedeemProfileReferralInviteCodeResponse } from '../../packages/shared/src/contracts/runtime';
import {
ApiClientError,
type ApiRequestOptions,
@@ -177,6 +178,20 @@ export async function loginWithPhoneCode(
return response;
}
export async function redeemRegistrationInviteCode(inviteCode: string) {
return requestJson<RedeemProfileReferralInviteCodeResponse>(
'/api/profile/referrals/redeem-code',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
inviteCode: normalizeInviteCodeInput(inviteCode),
}),
},
'填写邀请码失败',
);
}
export async function bindWechatPhone(phone: string, code: string) {
const response = await requestJson<AuthWechatBindPhoneResponse>(
'/api/auth/wechat/bind-phone',