我的页签恢复邀请好友入口,AGC 客户端账号菜单接入邀请码与玩家社区 #578
@@ -140,6 +140,7 @@ const allowedUncalledTauriCommands = [
|
||||
// 里以字面量出现,静态扫描仍按共享注册表核验。
|
||||
'read_profile_recharge_center',
|
||||
'read_profile_wallet_ledger',
|
||||
'read_profile_referral_invite_center',
|
||||
'create_profile_recharge_order',
|
||||
'confirm_wechat_profile_recharge_order',
|
||||
'redeem_profile_reward_code',
|
||||
|
||||
@@ -8,10 +8,12 @@ use crate::http_client::agc_main_site_client_builder;
|
||||
use crate::platform_session::{current_platform_session, PlatformSessionSnapshot};
|
||||
use reqwest::{Method, StatusCode};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use shared_contracts::runtime::{
|
||||
ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderResponse,
|
||||
ProfileRechargeCenterResponse, ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse,
|
||||
ProfileRechargeCenterResponse, ProfileReferralInviteCenterResponse,
|
||||
ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
@@ -103,6 +105,29 @@ fn unwrap_envelope(body: &str, fallback: &str) -> Result<Value, String> {
|
||||
Ok(value.get("data").cloned().unwrap_or(value))
|
||||
}
|
||||
|
||||
/// 客户端邀请码弹层需要的完整数据。
|
||||
///
|
||||
/// 后端只返回站内相对路径(`inviteLinkPath`),客户端要拿它当分享链接用,
|
||||
/// 所以这里用当前登录站点 origin 补全成完整 URL 再交给渲染层。
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ClientProfileReferralInviteCenter {
|
||||
pub(crate) center: ProfileReferralInviteCenterResponse,
|
||||
pub(crate) invite_url: String,
|
||||
}
|
||||
|
||||
fn resolve_invite_url(api_base_url: &str, invite_link_path: &str) -> String {
|
||||
if invite_link_path.trim().is_empty() {
|
||||
return api_base_url.trim_end_matches('/').to_string();
|
||||
}
|
||||
match Url::parse(&format!("{}/", api_base_url.trim_end_matches('/')))
|
||||
.and_then(|base| base.join(invite_link_path))
|
||||
{
|
||||
Ok(url) => url.to_string(),
|
||||
Err(_) => invite_link_path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_json<T: DeserializeOwned>(
|
||||
client: &reqwest::Client,
|
||||
snapshot: &PlatformSessionSnapshot,
|
||||
@@ -171,6 +196,24 @@ pub(crate) async fn read_profile_wallet_ledger() -> Result<ProfileWalletLedgerRe
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn read_profile_referral_invite_center(
|
||||
) -> Result<ClientProfileReferralInviteCenter, String> {
|
||||
let snapshot = require_session()?;
|
||||
let client = build_client()?;
|
||||
let center: ProfileReferralInviteCenterResponse = request_json(
|
||||
&client,
|
||||
&snapshot,
|
||||
Method::GET,
|
||||
&["api", "profile", "referrals", "invite-center"],
|
||||
None,
|
||||
"读取邀请码失败",
|
||||
)
|
||||
.await?;
|
||||
let invite_url = resolve_invite_url(&snapshot.api_base_url, ¢er.invite_link_path);
|
||||
Ok(ClientProfileReferralInviteCenter { center, invite_url })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn create_profile_recharge_order(
|
||||
product_id: String,
|
||||
@@ -314,4 +357,20 @@ mod tests {
|
||||
assert!(bounded_business_id(&"x".repeat(MAX_BUSINESS_ID_CHARS + 1), "订单").is_err());
|
||||
assert!(bounded_business_id("bad\nid", "订单").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invite_link_resolves_against_login_origin() {
|
||||
assert_eq!(
|
||||
resolve_invite_url("https://dev.genarrative.world", "/?inviteCode=SY12345678"),
|
||||
"https://dev.genarrative.world/?inviteCode=SY12345678"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_invite_url("https://dev.genarrative.world/", "invite/SY12345678"),
|
||||
"https://dev.genarrative.world/invite/SY12345678"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_invite_url("https://dev.genarrative.world", ""),
|
||||
"https://dev.genarrative.world"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,6 +638,7 @@ pub(super) fn run() {
|
||||
set_client_server_selection,
|
||||
read_profile_recharge_center,
|
||||
read_profile_wallet_ledger,
|
||||
read_profile_referral_invite_center,
|
||||
create_profile_recharge_order,
|
||||
confirm_wechat_profile_recharge_order,
|
||||
redeem_profile_reward_code,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { PlatformProfileReferralModal } from '../../../../../packages/shared/src/components/PlatformProfileReferralModal';
|
||||
import type {
|
||||
AccountReferralController,
|
||||
AccountReferralPanel,
|
||||
} from './useAccountReferral';
|
||||
|
||||
/** 客户端账号菜单的入口文案是「邀请码 / 玩家社区」,弹层标题跟着入口走。 */
|
||||
const CLIENT_REFERRAL_PANEL_TITLE: Record<AccountReferralPanel, string> = {
|
||||
invite: '邀请码',
|
||||
community: '玩家社区',
|
||||
};
|
||||
|
||||
/**
|
||||
* 账号菜单的邀请码 / 玩家社区弹层。
|
||||
*
|
||||
* 弹层实现与主站「我的」页签共用 `packages/shared` 里的同一份
|
||||
* `PlatformProfileReferralModal`(与充值、泥点账单同口径);这里只做宿主适配:
|
||||
* 把 Tauri typed command 读到的邀请中心、会话 origin 补全后的邀请链接、宿主剪贴板
|
||||
* 复制回显和面板开关接到共享组件上,不再另写一份客户端专属弹层。
|
||||
*/
|
||||
export function AccountReferralDialogs({
|
||||
controller,
|
||||
}: {
|
||||
controller: AccountReferralController;
|
||||
}) {
|
||||
if (!controller.panel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PlatformProfileReferralModal
|
||||
panel={controller.panel}
|
||||
title={CLIENT_REFERRAL_PANEL_TITLE[controller.panel]}
|
||||
center={controller.center}
|
||||
inviteLinkUrl={controller.inviteUrl}
|
||||
isLoading={controller.loading}
|
||||
isSubmittingRedeem={false}
|
||||
redeemCode=""
|
||||
copyInviteLinkState={controller.copyInviteLinkState}
|
||||
copyInviteCodeState={controller.copyInviteCodeState}
|
||||
error={controller.error}
|
||||
success={null}
|
||||
onClose={controller.closePanel}
|
||||
onCopyInviteLink={controller.copyInviteLink}
|
||||
onCopyInviteCode={controller.copyInviteCode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import { useDirectActiveTurns } from '../agent-runtime/directActiveTurns';
|
||||
import { projectPathsMatchForInvalidation } from '../project-summary/projectPath';
|
||||
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
|
||||
import { useTemplateLibrary } from '../template-library/useTemplateLibrary';
|
||||
import { AccountReferralDialogs } from './AccountReferral';
|
||||
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
|
||||
import {
|
||||
type LauncherNotice,
|
||||
@@ -52,6 +53,7 @@ import type {
|
||||
WorkspaceLauncherShellProps,
|
||||
} from './model';
|
||||
import { NonEmptyProjectDialog, ProjectsPage } from './ProjectCreation';
|
||||
import { useAccountReferral } from './useAccountReferral';
|
||||
import { useAccountWallet } from './useAccountWallet';
|
||||
import {
|
||||
DESIGN_ARTIFACTS_BUILD_PROMPT,
|
||||
@@ -73,6 +75,7 @@ export function WorkspaceLauncherShell({
|
||||
setActiveProjectRuns,
|
||||
} = useWindowChrome();
|
||||
const accountWallet = useAccountWallet(currentUser.id);
|
||||
const accountReferral = useAccountReferral(currentUser.id);
|
||||
const [status, setStatus] = useState('');
|
||||
const recentProjects = useRecentProjects(setStatus);
|
||||
const { recentProjectRows, rememberRecentWorkspace } = recentProjects;
|
||||
@@ -741,6 +744,11 @@ export function WorkspaceLauncherShell({
|
||||
}}
|
||||
onNoticeRequest={showLauncherNotice}
|
||||
onRechargeRequest={accountWallet.openRecharge}
|
||||
onReferralRequest={(panel) =>
|
||||
panel === 'invite'
|
||||
? accountReferral.openInvite()
|
||||
: accountReferral.openCommunity()
|
||||
}
|
||||
onRuntimeConfigOpen={() => setRuntimeConfigOpen(true)}
|
||||
onViewChange={navigateLauncher}
|
||||
/>
|
||||
@@ -963,6 +971,7 @@ export function WorkspaceLauncherShell({
|
||||
/>
|
||||
) : null}
|
||||
<AccountWalletDialogs controller={accountWallet} />
|
||||
<AccountReferralDialogs controller={accountReferral} />
|
||||
{walletSlot ? (
|
||||
createPortal(
|
||||
<AccountWalletBar controller={accountWallet} />,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { PlatformProfileReferralCopyState } from '../../../../../packages/shared/src/components/PlatformProfileReferralModal';
|
||||
import type { ProfileReferralInviteCenterResponse } from '../../../../../packages/shared/src/contracts/runtime';
|
||||
import { getClientProfileReferralInviteCenter } from '../../services/accountHost';
|
||||
|
||||
export type AccountReferralPanel = 'invite' | 'community';
|
||||
|
||||
/**
|
||||
* 客户端账号菜单的邀请码 / 玩家社区弹层控制器。
|
||||
*
|
||||
* 邀请码是账号级数据,和泥点余额一样按登录账号隔离:切换账号就丢弃上一位用户的
|
||||
* 邀请中心和复制回显;读取过程中换账号时,晚到的响应直接作废。
|
||||
*/
|
||||
export function useAccountReferral(currentUserId: string) {
|
||||
const [panel, setPanel] = useState<AccountReferralPanel | null>(null);
|
||||
const [center, setCenter] =
|
||||
useState<ProfileReferralInviteCenterResponse | null>(null);
|
||||
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copyInviteLinkState, setCopyInviteLinkState] =
|
||||
useState<PlatformProfileReferralCopyState>('idle');
|
||||
const [copyInviteCodeState, setCopyInviteCodeState] =
|
||||
useState<PlatformProfileReferralCopyState>('idle');
|
||||
const lifecycleRef = useRef(0);
|
||||
const currentUserIdRef = useRef(currentUserId);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
currentUserIdRef.current = currentUserId;
|
||||
lifecycleRef.current += 1;
|
||||
}, [currentUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
lifecycleRef.current += 1;
|
||||
setPanel(null);
|
||||
setCenter(null);
|
||||
setInviteUrl(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setCopyInviteLinkState('idle');
|
||||
setCopyInviteCodeState('idle');
|
||||
}, [currentUserId]);
|
||||
|
||||
async function loadInviteCenter(lifecycle: number, owner: string) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await getClientProfileReferralInviteCenter();
|
||||
if (
|
||||
lifecycleRef.current !== lifecycle ||
|
||||
currentUserIdRef.current !== owner
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setCenter(response.center);
|
||||
setInviteUrl(response.inviteUrl);
|
||||
} catch (loadError) {
|
||||
if (
|
||||
lifecycleRef.current !== lifecycle ||
|
||||
currentUserIdRef.current !== owner
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setCenter(null);
|
||||
setInviteUrl(null);
|
||||
setError(
|
||||
loadError instanceof Error ? loadError.message : '读取邀请码失败',
|
||||
);
|
||||
} finally {
|
||||
if (
|
||||
lifecycleRef.current === lifecycle &&
|
||||
currentUserIdRef.current === owner
|
||||
) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openPanel(nextPanel: AccountReferralPanel) {
|
||||
const lifecycle = ++lifecycleRef.current;
|
||||
setPanel(nextPanel);
|
||||
setError(null);
|
||||
setCopyInviteLinkState('idle');
|
||||
setCopyInviteCodeState('idle');
|
||||
if (nextPanel !== 'invite') {
|
||||
return;
|
||||
}
|
||||
void loadInviteCenter(lifecycle, currentUserId);
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
lifecycleRef.current += 1;
|
||||
setPanel(null);
|
||||
setLoading(false);
|
||||
setCopyInviteLinkState('idle');
|
||||
setCopyInviteCodeState('idle');
|
||||
}
|
||||
|
||||
async function copyValue(
|
||||
applyState: (state: PlatformProfileReferralCopyState) => void,
|
||||
value: string,
|
||||
) {
|
||||
try {
|
||||
await writeText(value);
|
||||
applyState('copied');
|
||||
} catch {
|
||||
applyState('failed');
|
||||
}
|
||||
}
|
||||
|
||||
function copyInviteCode() {
|
||||
if (!center?.inviteCode) {
|
||||
return;
|
||||
}
|
||||
void copyValue(setCopyInviteCodeState, center.inviteCode);
|
||||
}
|
||||
|
||||
function copyInviteLink() {
|
||||
if (!inviteUrl) {
|
||||
return;
|
||||
}
|
||||
void copyValue(setCopyInviteLinkState, inviteUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
center,
|
||||
copyInviteCodeState,
|
||||
copyInviteCode,
|
||||
copyInviteLink,
|
||||
copyInviteLinkState,
|
||||
error,
|
||||
inviteUrl,
|
||||
loading,
|
||||
panel,
|
||||
openInvite: () => openPanel('invite'),
|
||||
openCommunity: () => openPanel('community'),
|
||||
closePanel,
|
||||
};
|
||||
}
|
||||
|
||||
export type AccountReferralController = ReturnType<typeof useAccountReferral>;
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
ConfirmWechatProfileRechargeOrderResponse,
|
||||
CreateProfileRechargeOrderResponse,
|
||||
ProfileRechargeCenterResponse,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
ProfileWalletLedgerResponse,
|
||||
RedeemProfileRewardCodeResponse,
|
||||
} from '../../../../packages/shared/src/contracts/runtime';
|
||||
@@ -40,6 +41,17 @@ export function getClientProfileWalletLedger() {
|
||||
);
|
||||
}
|
||||
|
||||
export type ClientProfileReferralInviteCenterResponse = {
|
||||
center: ProfileReferralInviteCenterResponse;
|
||||
inviteUrl: string;
|
||||
};
|
||||
|
||||
export function getClientProfileReferralInviteCenter() {
|
||||
return invokeAccount<ClientProfileReferralInviteCenterResponse>(
|
||||
'read_profile_referral_invite_center',
|
||||
);
|
||||
}
|
||||
|
||||
export function createClientProfileRechargeOrder(productId: string) {
|
||||
return invokeAccount<CreateProfileRechargeOrderResponse>(
|
||||
'create_profile_recharge_order',
|
||||
|
||||
@@ -46,6 +46,7 @@ type LauncherSidebarProps = {
|
||||
onViewChange: (view: LauncherView) => void;
|
||||
onNoticeRequest: (title: string) => void;
|
||||
onRechargeRequest: () => void;
|
||||
onReferralRequest: (panel: 'invite' | 'community') => void;
|
||||
onRuntimeConfigOpen: () => void;
|
||||
onLogout: () => void;
|
||||
};
|
||||
@@ -56,6 +57,7 @@ type SidebarAccountMenuProps = {
|
||||
onLogout: () => void;
|
||||
onNoticeRequest: (title: string) => void;
|
||||
onRechargeRequest: () => void;
|
||||
onReferralRequest: (panel: 'invite' | 'community') => void;
|
||||
};
|
||||
|
||||
function formatMudPoints(value: number | null | undefined) {
|
||||
@@ -71,6 +73,7 @@ function SidebarAccountMenu({
|
||||
onLogout,
|
||||
onNoticeRequest,
|
||||
onRechargeRequest,
|
||||
onReferralRequest,
|
||||
}: SidebarAccountMenuProps) {
|
||||
const walletBalance = useWalletStore(
|
||||
(state) => state.mudPointBalance?.totalPoints ?? null,
|
||||
@@ -164,6 +167,28 @@ function SidebarAccountMenu({
|
||||
>
|
||||
使用指南
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={sidebarMenuItemClass}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onReferralRequest('invite');
|
||||
}}
|
||||
>
|
||||
邀请码
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={sidebarMenuItemClass}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onReferralRequest('community');
|
||||
}}
|
||||
>
|
||||
玩家社区
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -198,6 +223,7 @@ export function Sidebar({
|
||||
onViewChange,
|
||||
onNoticeRequest,
|
||||
onRechargeRequest,
|
||||
onReferralRequest,
|
||||
onRuntimeConfigOpen,
|
||||
onLogout,
|
||||
}: LauncherSidebarProps) {
|
||||
@@ -324,6 +350,7 @@ export function Sidebar({
|
||||
onLogout={onLogout}
|
||||
onNoticeRequest={onNoticeRequest}
|
||||
onRechargeRequest={onRechargeRequest}
|
||||
onReferralRequest={onReferralRequest}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
confirmClientWechatProfileRechargeOrder,
|
||||
createClientProfileRechargeOrder,
|
||||
getClientProfileRechargeCenter,
|
||||
getClientProfileReferralInviteCenter,
|
||||
getClientProfileWalletLedger,
|
||||
redeemClientProfileRewardCode,
|
||||
} from '../src/services/accountHost';
|
||||
@@ -47,6 +48,7 @@ test('账户读取只提交结构化参数,不携带 token 或远端地址', a
|
||||
|
||||
await getClientProfileRechargeCenter();
|
||||
await getClientProfileWalletLedger();
|
||||
await getClientProfileReferralInviteCenter();
|
||||
await createClientProfileRechargeOrder('product-1');
|
||||
await confirmClientWechatProfileRechargeOrder('order-1');
|
||||
await redeemClientProfileRewardCode('CODE-1');
|
||||
@@ -54,6 +56,7 @@ test('账户读取只提交结构化参数,不携带 token 或远端地址', a
|
||||
expect(invoke.mock.calls).toEqual([
|
||||
['read_profile_recharge_center', undefined],
|
||||
['read_profile_wallet_ledger', undefined],
|
||||
['read_profile_referral_invite_center', undefined],
|
||||
['create_profile_recharge_order', { productId: 'product-1' }],
|
||||
['confirm_wechat_profile_recharge_order', { orderId: 'order-1' }],
|
||||
['redeem_profile_reward_code', { code: 'CODE-1' }],
|
||||
|
||||
@@ -120,6 +120,9 @@ vi.mock('@tauri-apps/plugin-clipboard-manager', () => ({
|
||||
throw new Error('no native clipboard image');
|
||||
}),
|
||||
readText: vi.fn(async () => nativeClipboardMock.text),
|
||||
writeText: vi.fn(async (text: string) => {
|
||||
nativeClipboardMock.text = text;
|
||||
}),
|
||||
}));
|
||||
|
||||
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
|
||||
|
||||
@@ -1076,6 +1076,8 @@ export function registerClientHomeTests() {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '我的' }));
|
||||
expect(screen.getByRole('menuitem', { name: '使用指南' })).not.toBeNull();
|
||||
expect(screen.getByRole('menuitem', { name: '邀请码' })).not.toBeNull();
|
||||
expect(screen.getByRole('menuitem', { name: '玩家社区' })).not.toBeNull();
|
||||
expect(screen.getByRole('menuitem', { name: '反馈联系' })).not.toBeNull();
|
||||
expect(screen.getByRole('menuitem', { name: '退出登录' })).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '帮助' }));
|
||||
@@ -1090,6 +1092,74 @@ export function registerClientHomeTests() {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '使用指南' }));
|
||||
expect(screen.getByRole('dialog', { name: '使用指南' })).not.toBeNull();
|
||||
});
|
||||
|
||||
it('opens the invite code and community panels from the account menu', async () => {
|
||||
const inviteCenter = {
|
||||
inviteCode: 'SY12345678',
|
||||
inviteLinkPath: '/?inviteCode=SY12345678',
|
||||
invitedCount: 2,
|
||||
rewardedInviteCount: 2,
|
||||
todayInviterRewardCount: 1,
|
||||
todayInviterRewardRemaining: 9,
|
||||
rewardPoints: 30,
|
||||
invitedUsers: [],
|
||||
hasRedeemedCode: false,
|
||||
boundInviterUserId: null,
|
||||
boundAt: null,
|
||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||
};
|
||||
const inviteLink = 'https://dev.genarrative.world/?inviteCode=SY12345678';
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'read_profile_referral_invite_center') {
|
||||
return { center: inviteCenter, inviteUrl: inviteLink };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
renderLauncherAt('/?launcher');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '我的' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '邀请码' }));
|
||||
|
||||
const inviteDialog = await screen.findByRole('dialog', { name: '邀请码' });
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'read_profile_referral_invite_center',
|
||||
undefined,
|
||||
);
|
||||
expect(within(inviteDialog).getByText('SY12345678')).not.toBeNull();
|
||||
expect(within(inviteDialog).getByText(inviteLink)).not.toBeNull();
|
||||
|
||||
fireEvent.click(
|
||||
within(inviteDialog).getByRole('button', { name: '复制邀请链接' }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(nativeClipboardMock.text).toBe(inviteLink);
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
within(inviteDialog).getByRole('button', { name: '复制邀请码' }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(nativeClipboardMock.text).toBe('SY12345678');
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
within(inviteDialog).getByRole('button', { name: '关闭邀请码' }),
|
||||
);
|
||||
expect(screen.queryByRole('dialog', { name: '邀请码' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '我的' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '玩家社区' }));
|
||||
const communityDialog = await screen.findByRole('dialog', {
|
||||
name: '玩家社区',
|
||||
});
|
||||
expect(
|
||||
within(communityDialog).getByAltText('玩家社区微信群二维码'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
within(communityDialog).getByAltText('玩家社区 QQ 群二维码'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
export function registerHomeProjectCreationTests() {
|
||||
|
||||
@@ -59,6 +59,18 @@
|
||||
> 维护:阶段过程、分支合并和当轮测试数字由 Git 追溯;实现依据以当前代码和最新专题文档为准。
|
||||
> 格式:参见[决策记录格式](../README.md#决策记录格式),按需填写。
|
||||
|
||||
## 2026-10-01 邀请好友入口回到“我的”页,客户端账号菜单接入邀请码与玩家社区
|
||||
|
||||
- 背景:主站“我的”页签的常用功能宫格只剩四项,`邀请好友` 入口在平台个人页恢复时漏了;同一份邀请码在 AGC 客户端里没有任何入口,玩家社区二维码也只存在主站弹层里。
|
||||
- 决策(主站):宫格按文档口径恢复五项——泥点充值、邀请好友、兑换码、玩家社区、反馈与建议,`grid-cols-5` 与 `.platform-profile-shortcut-grid` 的五列规则保持一致;邀请好友复用邀请弹层的 `invite` 面板,弹层展示邀请码与完整邀请链接,并拆成“复制邀请码 / 复制邀请链接”两个动作。邀请链接仍由后端 `inviteLinkPath` 加宿主 origin 补全,不在共享组件里拼参数。
|
||||
- 决策(跨端复用,重要):邀请弹层下沉到 `packages/shared/src/components/PlatformProfileReferralModal/`(含 `model.ts`、`index.css`、`index.test.tsx` 与 `assets/` 微信群 / QQ 群二维码),与 `PlatformProfileRechargeModal`、`PlatformProfileWalletLedgerModal` 同口径,由主站 `PlatformEntryActiveFlowShell` 和 AGC 客户端 `AccountReferralDialogs` 共用一份实现。共享组件只吃 props(邀请中心事实、补全后的邀请链接、两个复制回显、宿主剪贴板粘贴能力),自己不发请求、不写剪贴板;宿主能力(网页 HostBridge / 客户端 Tauri clipboard-manager)留在各自宿主。客户端弹层标题由宿主覆盖为“邀请码 / 玩家社区”,与账号菜单入口文案保持一致;主站沿用“邀请好友 / 填邀请码 / 玩家社区”。原地删除 `src/components/platform-entry/PlatformProfileReferralModal.tsx` 与客户端专用弹层实现(含 `launcher-referral-*` 样式)。
|
||||
- 决策(客户端取数):AGC 侧栏账号菜单在“使用指南”之后新增“邀请码”和“玩家社区”两项。Tauri 侧新增 `read_profile_referral_invite_center`,用当前登录 origin 把 `inviteLinkPath` 补全成完整链接后交给渲染层;客户端只展示邀请码 / 邀请链接和复制动作,不本地另存邀请码、不发放奖励。命令名同时登记进 `scripts/check-config.mjs` 的 native-only 白名单(该门禁要求 `generate_handler!` 里的命令必须被前端调用或显式白名单)。客户端邀请中心按登录账号隔离(`useAccountReferral` 按 `currentUserId` 重置并作废在飞响应)。
|
||||
- 决策(奖励说明收进右上角帮助按钮):邀请面板正文不再常驻那段黄色说明,改由标题右侧的帮助按钮(`?`,仅 `invite` 面板)承载悬浮说明;鼠标 `pointerenter` 展开、离开收起,触屏 / 键盘用点击切换(按 `pointerType` 区分,两套触发互不干扰),文案随 `role="note"` 可被无障碍读取。面板正文因此只保留邀请码、邀请链接和成功邀请三段内容。
|
||||
- 决策(每日上限文案改为数据驱动):说明文案不再写死“每日最多获得十次”(旧文案只在网页弹层里硬编码,客户端完全没有这句)。共享弹层用后端字段推导:每日上限 = `todayInviterRewardCount + todayInviterRewardRemaining`(当前 10),说明为“邀请一位好友注册,好友每次都能获得 N 泥点 / 好友完成注册后你也可以获得 N 泥点;每天最多 L 次,今日还剩 R 次”,两端自动一致。后端事实:`PROFILE_REFERRAL_REWARD_POINTS = 30`、`PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT = 10` 在 `module-runtime` 域内强制;被邀请人每次都拿满 30,上限只压邀请人一侧,超限后邀请关系仍成立。
|
||||
- 已知口径问题(未改,待产品确认):邀请上限的“当日”用的是 `runtime_profile_day_start_micros`(UTC 00:00 边界,即北京时间 08:00 重置),而每日任务 / 每日免费泥点用的是 `runtime_profile_beijing_day_key`(北京时间 00:00)。同一个仓库里两套“每日”口径并存,跨零点前后的奖励计数会与用户直觉不一致。
|
||||
- 影响范围:`packages/shared/src/components/PlatformProfileReferralModal/**`、`src/components/platform-entry/PlatformActiveProfileView.tsx`、`PlatformEntryActiveFlowShell.tsx`、`usePlatformProfileCenterController.ts`、`vite.config.ts`(移除已删除文件的 Tailwind `@source`)、`apps/ai-game-creator-shell/src/view/layout.tsx`、`src/features/app-shell/AccountReferral.tsx`、`useAccountReferral.ts`、`WorkspaceLauncher.tsx`、`src/styles.css`、`src-tauri/src/account_api.rs`、`scripts/check-config.mjs`、`src/services/accountHost.ts`、`media/social-media-group/*`(移动到共享组件 `assets/`)、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
|
||||
- 验证方式:`npx vitest run packages/shared/src/components/PlatformProfileReferralModal/index.test.tsx src/components/platform-entry apps/ai-game-creator-shell/tests/appSurface.test.ts apps/ai-game-creator-shell/tests/accountHost.test.ts`、`npm run typecheck`(主站,既有 6 处历史报错不变)、`apps/ai-game-creator-shell` 内 `npm run typecheck`(含 check-config 门禁)、`cargo check --tests` 与 `cargo test --bin … account_api::tests`、`cargo fmt --check`、ESLint / Prettier 改动文件、`npm run check:encoding`、`git diff --check`;并在真实 dev 栈上用浏览器与 CDP 附着客户端 WebView2 复截两端弹层,确认同一份实现渲染一致。
|
||||
|
||||
## 2026-09-29 外壳状态栏退役:`status` 一行改由浮层承载
|
||||
|
||||
- 背景:首页输入框下方、项目组页面头部那一行由 `WorkspaceLauncher` 的 `status` 承接,写入的内容很杂——进行中进度(`正在创建工作区`、`正在选择项目`)、“已取消 / 已创建项目 / 已打开项目目录”这类回显、失败结论(`创建未完成,请重试`、工作区看门狗文案、Provider 报错)以及项目组页面的 `正在检查项目状态`。这一行常驻占页面,用户明确要求整行改成浮层提示,而不是只把「已取消」挑出来。
|
||||
|
||||
@@ -98,7 +98,7 @@ server-rs + Axum + SpacetimeDB
|
||||
7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。
|
||||
8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。
|
||||
9. 现役项目、素材等列表的筛选必须打开独立 dialog / drawer / modal;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。
|
||||
10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、三张统计卡、五项常用功能宫格、通用设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位。页面不再提供会员购买 / 升级横幅、每日任务卡片或任务中心入口,也不提供独立存档按钮入口,不在底部保留旧的填邀请码次级入口;主题设置、账号与安全只作为通用设置弹窗下一级入口,不在“我的”页外层单独占行。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮。
|
||||
10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、三张统计卡、五项常用功能宫格、通用设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议五项,按五列铺满整行;入口收缩到四项时按四列铺满整行,不保留五列网格导致左对齐空位。邀请弹层(主站「我的」页签与 AGC 客户端账号菜单共用 `packages/shared` 的 `PlatformProfileReferralModal`)展示邀请码和完整邀请链接,提供“复制邀请码 / 复制邀请链接”两个动作;奖励说明必须从后端 `rewardPoints` 与 `todayInviterRewardCount + todayInviterRewardRemaining` 推导(当前为 30 泥点、每日 10 次、今日剩余 N 次),不在前端写死次数或金额。同一份邀请数据在网页和客户端显示一致,客户端只允许覆盖弹层标题以匹配各自入口文案。页面不再提供会员购买 / 升级横幅、每日任务卡片或任务中心入口,也不提供独立存档按钮入口,不在底部保留旧的填邀请码次级入口;主题设置、账号与安全只作为通用设置弹窗下一级入口,不在“我的”页外层单独占行。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮。
|
||||
11. 每日免费泥点由后端独立余额桶承载,基础额度固定为 `20`,按北京时间每日 `00:00` 重置。跨业务日退款时,原消费中的每日免费泥点部分叠加到退款当日每日免费桶,当日余额允许超过 `20`;到下一业务日仍统一失效并重置为 `20`。主站不得把已隐藏的每日任务入口或 `daily_task_reward` 文案继续当作每日免费泥点入口。
|
||||
12. “我的”页泥点余额、累计游玩、已玩游戏三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、常用功能和法律信息也应保持 `10px` 到 `14px` 的普通 UI 字号区间,避免展示级字号挤压内容。
|
||||
13. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、五项常用功能和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。
|
||||
|
||||
|
Before Width: | Height: | Size: 296 KiB After Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
@@ -0,0 +1,55 @@
|
||||
.platform-profile-referral-modal-backdrop {
|
||||
background: rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
|
||||
.platform-profile-referral-modal {
|
||||
border: 1px solid var(--platform-modal-border);
|
||||
background: #fff;
|
||||
color: #18181b;
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.platform-profile-referral-modal__close {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #ff4056;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.platform-profile-referral-modal__close:hover {
|
||||
background: rgba(255, 64, 86, 0.08);
|
||||
}
|
||||
|
||||
/*
|
||||
* 帮助按钮复用平台通用图标按钮 `.platform-icon-button` 的配色(--platform-icon-*),
|
||||
* 这里只把它收到和关闭按钮同高,避免弹层标题栏出现两种尺寸。
|
||||
*/
|
||||
.platform-icon-button.platform-profile-referral-modal__help {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.platform-profile-referral-modal__help-panel {
|
||||
/* 悬浮层必须不透明:PlatformStatusMessage 的 warning 底是半透明色,
|
||||
直接悬浮会透出下面的邀请码卡片。底色沿用弹层主体的白底,amber 边框 / 文字
|
||||
继续走平台 warning token。 */
|
||||
border-radius: 0.75rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 30px rgba(112, 57, 30, 0.16);
|
||||
}
|
||||
|
||||
.platform-profile-referral-modal__avatar {
|
||||
background: #ff4056;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.platform-profile-referral-modal__link {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.platform-profile-referral-modal__skeleton-item {
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(228, 228, 231, 0.6);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { ProfileReferralInviteCenterResponse } from '../../contracts/runtime';
|
||||
import { PlatformProfileReferralModal } from './index';
|
||||
|
||||
function buildCenter(
|
||||
overrides: Partial<ProfileReferralInviteCenterResponse> = {},
|
||||
): ProfileReferralInviteCenterResponse {
|
||||
return {
|
||||
inviteCode: 'ABCD1234',
|
||||
inviteLinkPath: '/invite/ABCD1234',
|
||||
invitedCount: 1,
|
||||
rewardedInviteCount: 1,
|
||||
todayInviterRewardCount: 1,
|
||||
todayInviterRewardRemaining: 9,
|
||||
rewardPoints: 30,
|
||||
invitedUsers: [
|
||||
{
|
||||
userId: 'user-2',
|
||||
displayName: '海盐',
|
||||
avatarUrl: null,
|
||||
boundAt: '2026-06-10T08:00:00.000Z',
|
||||
},
|
||||
],
|
||||
hasRedeemedCode: false,
|
||||
boundInviterUserId: null,
|
||||
boundAt: null,
|
||||
updatedAt: '2026-06-10T08:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPanel(
|
||||
panel: 'invite' | 'redeem' | 'community',
|
||||
overrides: Partial<ComponentProps<typeof PlatformProfileReferralModal>> = {},
|
||||
) {
|
||||
return render(
|
||||
<PlatformProfileReferralModal
|
||||
panel={panel}
|
||||
center={buildCenter()}
|
||||
inviteLinkUrl="https://www.genarrative.world/invite/ABCD1234"
|
||||
isLoading={false}
|
||||
isSubmittingRedeem={false}
|
||||
redeemCode=""
|
||||
copyInviteLinkState="idle"
|
||||
copyInviteCodeState="idle"
|
||||
error={null}
|
||||
success={null}
|
||||
onClose={vi.fn()}
|
||||
onCopyInviteLink={vi.fn()}
|
||||
onCopyInviteCode={vi.fn()}
|
||||
onRedeemCodeChange={vi.fn()}
|
||||
onSubmitRedeemCode={vi.fn()}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('PlatformProfileReferralModal', () => {
|
||||
test('renders invite panel with invite code, invite link and both copy actions', () => {
|
||||
const onCopyInviteLink = vi.fn();
|
||||
const onCopyInviteCode = vi.fn();
|
||||
renderPanel('invite', { onCopyInviteLink, onCopyInviteCode });
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '邀请好友' });
|
||||
|
||||
expect(within(dialog).getByText('邀请码')).toBeTruthy();
|
||||
expect(within(dialog).getByText('ABCD1234')).toBeTruthy();
|
||||
expect(within(dialog).getByText('海盐')).toBeTruthy();
|
||||
expect(within(dialog).getByText('成功邀请')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByText('https://www.genarrative.world/invite/ABCD1234'),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '复制邀请链接' }),
|
||||
);
|
||||
expect(onCopyInviteLink).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '复制邀请码' }));
|
||||
expect(onCopyInviteCode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('奖励说明收进右上角帮助按钮,hover 展开并派生每日上限', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel('invite', {
|
||||
center: buildCenter({
|
||||
todayInviterRewardCount: 4,
|
||||
todayInviterRewardRemaining: 6,
|
||||
rewardPoints: 66,
|
||||
}),
|
||||
});
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '邀请好友' });
|
||||
const inviteeLine = '邀请一位好友注册,好友每次都能获得 66 泥点。';
|
||||
const inviterLine =
|
||||
'好友完成注册后你也可以获得 66 泥点;每天最多 10 次,今日还剩 6 次。';
|
||||
|
||||
// 说明不再常驻面板正文,只在悬浮 / 展开时出现。
|
||||
expect(within(dialog).queryByText(inviteeLine)).toBeNull();
|
||||
expect(within(dialog).queryByText(inviterLine)).toBeNull();
|
||||
|
||||
const helpButton = within(dialog).getByRole('button', {
|
||||
name: '邀请奖励说明',
|
||||
});
|
||||
await user.hover(helpButton);
|
||||
|
||||
const helpPanel = within(dialog).getByRole('note', {
|
||||
name: '邀请奖励说明',
|
||||
});
|
||||
expect(within(helpPanel).getByText(inviteeLine)).toBeTruthy();
|
||||
expect(within(helpPanel).getByText(inviterLine)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('触屏 / 键盘可以点开帮助说明(无 hover 事件)', () => {
|
||||
renderPanel('invite');
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '邀请好友' });
|
||||
const helpButton = within(dialog).getByRole('button', {
|
||||
name: '邀请奖励说明',
|
||||
});
|
||||
|
||||
fireEvent.click(helpButton);
|
||||
expect(
|
||||
within(dialog).getByRole('note', { name: '邀请奖励说明' }),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(helpButton);
|
||||
expect(
|
||||
within(dialog).queryByRole('note', { name: '邀请奖励说明' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('玩家社区面板不显示邀请奖励帮助', () => {
|
||||
renderPanel('community');
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '玩家社区' });
|
||||
expect(
|
||||
within(dialog).queryByRole('button', { name: '邀请奖励说明' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('复制状态按动作各自回显', () => {
|
||||
renderPanel('invite', {
|
||||
copyInviteCodeState: 'copied',
|
||||
copyInviteLinkState: 'failed',
|
||||
});
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '邀请好友' });
|
||||
|
||||
expect(within(dialog).getByText('已复制')).toBeTruthy();
|
||||
expect(within(dialog).getByText('复制失败')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('submits redeem panel with the shared form shell', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRedeemCodeChange = vi.fn();
|
||||
const onSubmitRedeemCode = vi.fn();
|
||||
|
||||
renderPanel('redeem', {
|
||||
redeemCode: 'ab12',
|
||||
onRedeemCodeChange,
|
||||
onSubmitRedeemCode,
|
||||
});
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '填邀请码' });
|
||||
const input = within(dialog).getByRole('textbox', { name: '邀请码' });
|
||||
|
||||
await user.type(input, ' c');
|
||||
expect(onRedeemCodeChange).toHaveBeenCalled();
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: '提交' }));
|
||||
expect(onSubmitRedeemCode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('redeem panel offers paste when the host declares the capability', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPasteRedeemCode = vi.fn();
|
||||
|
||||
renderPanel('redeem', { canPasteRedeemCode: true, onPasteRedeemCode });
|
||||
await user.click(screen.getByRole('button', { name: '粘贴' }));
|
||||
expect(onPasteRedeemCode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('redeem panel hides paste when the host cannot read the clipboard', () => {
|
||||
renderPanel('redeem', { canPasteRedeemCode: false });
|
||||
expect(screen.queryByRole('button', { name: '粘贴' })).toBeNull();
|
||||
});
|
||||
|
||||
test('renders community QR panels', () => {
|
||||
renderPanel('community');
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '玩家社区' });
|
||||
|
||||
expect(within(dialog).getByAltText('玩家社区微信群二维码')).toBeTruthy();
|
||||
expect(within(dialog).getByAltText('玩家社区 QQ 群二维码')).toBeTruthy();
|
||||
expect(within(dialog).getByText('微信群')).toBeTruthy();
|
||||
expect(within(dialog).getByText('QQ群')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
import type { ProfileReferralInviteCenterResponse } from '../../contracts/runtime';
|
||||
|
||||
export type PlatformProfileReferralPanel = 'invite' | 'redeem' | 'community';
|
||||
export type PlatformProfileReferralCopyState = 'idle' | 'copied' | 'failed';
|
||||
|
||||
export function normalizePlatformProfileReferralRedeemCode(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/[^0-9a-z]/gi, '')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 邀请奖励的当日额度由后端字段推导。
|
||||
*
|
||||
* 后端只返回「今日已获得次数」和「今日剩余次数」,两者相加就是当前生效的每日上限
|
||||
* (当前 `PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT = 10`)。前端不再另存一份写死的
|
||||
* `10`:上限调整时两端文案自动跟着走。
|
||||
*/
|
||||
export function resolvePlatformProfileReferralDailyLimit(
|
||||
center: ProfileReferralInviteCenterResponse | null,
|
||||
) {
|
||||
if (!center) {
|
||||
return null;
|
||||
}
|
||||
return center.todayInviterRewardCount + center.todayInviterRewardRemaining;
|
||||
}
|
||||
|
||||
/**
|
||||
* 邀请面板的奖励说明。
|
||||
*
|
||||
* 语义边界(后端 `redeem_profile_referral_invite_code`):被邀请人每次都拿满
|
||||
* `rewardPoints`;每日上限只压邀请人一侧,超过上限后邀请人当日不再获得奖励,
|
||||
* 邀请关系本身仍然成立。
|
||||
*/
|
||||
export function buildPlatformProfileReferralRewardCopy(
|
||||
center: ProfileReferralInviteCenterResponse | null,
|
||||
) {
|
||||
if (!center) {
|
||||
return null;
|
||||
}
|
||||
const dailyLimit = resolvePlatformProfileReferralDailyLimit(center);
|
||||
return {
|
||||
rewardPoints: center.rewardPoints,
|
||||
dailyLimit,
|
||||
remainingToday: center.todayInviterRewardRemaining,
|
||||
inviteeRewardLine: `邀请一位好友注册,好友每次都能获得 ${center.rewardPoints} 泥点。`,
|
||||
inviterRewardLine:
|
||||
dailyLimit === null
|
||||
? `好友完成注册后你也可以获得 ${center.rewardPoints} 泥点。`
|
||||
: `好友完成注册后你也可以获得 ${center.rewardPoints} 泥点;每天最多 ${dailyLimit} 次,今日还剩 ${center.todayInviterRewardRemaining} 次。`,
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,7 @@ const callbacks = {
|
||||
onOpenApiKeys: vi.fn(),
|
||||
onOpenCommunity: vi.fn(),
|
||||
onOpenFeedback: vi.fn(),
|
||||
onOpenInvite: vi.fn(),
|
||||
onOpenRecharge: vi.fn(),
|
||||
onOpenRewardCode: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
@@ -90,6 +91,7 @@ describe('PlatformActiveProfileView', () => {
|
||||
expect(screen.getByText(/陶泥号:\s*100001/u)).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '泥点余额 108' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /泥点充值/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /邀请好友/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /兑换码/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /玩家社区/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /反馈与建议/u })).toBeTruthy();
|
||||
@@ -98,6 +100,9 @@ describe('PlatformActiveProfileView', () => {
|
||||
screen.getByRole('button', { name: /开发者 API Key/u }),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /邀请好友/u }));
|
||||
expect(callbacks.onOpenInvite).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '用户协议' }));
|
||||
const legalDialog = screen.getByRole('dialog', { name: '用户协议' });
|
||||
expect(legalDialog.parentElement?.className).toContain(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Pencil,
|
||||
Settings,
|
||||
Ticket,
|
||||
UserPlus,
|
||||
UserRound,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
@@ -21,6 +22,7 @@ import profileClockImage from '../../../media/profile/_Image (1).png';
|
||||
import profileGamepadImage from '../../../media/profile/_Image (2).png';
|
||||
import profileStillLifeImage from '../../../media/profile/_Image (3).png';
|
||||
import profileCoinsImage from '../../../media/profile/_Image (4).png';
|
||||
import profileInviteImage from '../../../media/profile/_Image (5).png';
|
||||
import profileGiftImage from '../../../media/profile/_Image (6).png';
|
||||
import profileCommunityImage from '../../../media/profile/_Image (7).png';
|
||||
import profileFeedbackImage from '../../../media/profile/_Image (8).png';
|
||||
@@ -69,6 +71,7 @@ type PlatformActiveProfileViewProps = {
|
||||
onOpenApiKeys: () => void;
|
||||
onOpenCommunity: () => void;
|
||||
onOpenFeedback: () => void;
|
||||
onOpenInvite: () => void;
|
||||
onOpenRecharge: () => void;
|
||||
onOpenRewardCode: () => void;
|
||||
onOpenSettings: () => void;
|
||||
@@ -278,6 +281,7 @@ export function PlatformActiveProfileView({
|
||||
onOpenApiKeys,
|
||||
onOpenCommunity,
|
||||
onOpenFeedback,
|
||||
onOpenInvite,
|
||||
onOpenRecharge,
|
||||
onOpenRewardCode,
|
||||
onOpenSettings,
|
||||
@@ -606,7 +610,7 @@ export function PlatformActiveProfileView({
|
||||
className="platform-profile-shortcut-panel"
|
||||
aria-label="常用功能"
|
||||
>
|
||||
<div className="platform-profile-shortcut-grid grid w-full grid-cols-4">
|
||||
<div className="platform-profile-shortcut-grid grid w-full grid-cols-5">
|
||||
<ProfileShortcutButton
|
||||
label="泥点充值"
|
||||
subLabel="充值泥点"
|
||||
@@ -614,6 +618,13 @@ export function PlatformActiveProfileView({
|
||||
imageSrc={profileCoinsImage}
|
||||
onClick={onOpenRecharge}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="邀请好友"
|
||||
subLabel="双方得泥点"
|
||||
icon={UserPlus}
|
||||
imageSrc={profileInviteImage}
|
||||
onClick={onOpenInvite}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="兑换码"
|
||||
subLabel="领取福利"
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
|
||||
import { PlatformMudPointWalletEntry } from '../../../packages/shared/src/components/PlatformMudPointWalletEntry';
|
||||
import { PlatformProfileRechargeModal } from '../../../packages/shared/src/components/PlatformProfileRechargeModal';
|
||||
import { PlatformProfileReferralModal } from '../../../packages/shared/src/components/PlatformProfileReferralModal';
|
||||
import { PlatformProfileWalletLedgerModal } from '../../../packages/shared/src/components/PlatformProfileWalletLedgerModal';
|
||||
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
@@ -46,7 +47,10 @@ import { PlatformActiveProfileView } from './PlatformActiveProfileView';
|
||||
import type { PlatformEntryFlowShellProps } from './platformEntryActiveTypes';
|
||||
import { usePlatformDesktopLayout } from './platformEntryResponsive';
|
||||
import { PlatformProfileApiKeysModal } from './PlatformProfileApiKeysModal';
|
||||
import { PlatformProfileReferralModal } from './PlatformProfileReferralModal';
|
||||
import {
|
||||
canPasteFromNativeHostClipboard,
|
||||
readNativeHostClipboardText,
|
||||
} from './platformProfileHostClipboard';
|
||||
import { PlatformProfileRewardCodeRedeemModal } from './PlatformProfileRewardCodeRedeemModal';
|
||||
import {
|
||||
PlatformRechargePaymentConfirmationMask,
|
||||
@@ -511,6 +515,20 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSelectionStage('project', { path: '/project' });
|
||||
}, [setSelectionStage]);
|
||||
|
||||
/**
|
||||
* 「填邀请码」的粘贴动作。
|
||||
*
|
||||
* 宿主能力留在宿主:共享弹层只按 `canPasteRedeemCode` 决定是否渲染按钮,读取宿主
|
||||
* 剪贴板由主站自己完成,读到文本只填进输入框,不自动提交。
|
||||
*/
|
||||
const pasteReferralRedeemCode = useCallback(() => {
|
||||
void readNativeHostClipboardText().then((text) => {
|
||||
if (text) {
|
||||
profileCenter.setReferralRedeemCode(text);
|
||||
}
|
||||
});
|
||||
}, [profileCenter]);
|
||||
|
||||
const isMobileToolStage =
|
||||
!isDesktopLayout &&
|
||||
(selectionStage === 'creation-home' ||
|
||||
@@ -766,6 +784,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
profileCenter.openProfilePopupPanel('community')
|
||||
}
|
||||
onOpenFeedback={openFeedback}
|
||||
onOpenInvite={() =>
|
||||
profileCenter.openProfilePopupPanel('invite')
|
||||
}
|
||||
onOpenRecharge={openRecharge}
|
||||
onOpenRewardCode={profileCenter.openRewardCodeModal}
|
||||
onOpenSettings={() => authUi?.openSettingsModal()}
|
||||
@@ -870,14 +891,19 @@ export function PlatformEntryFlowShellImpl({
|
||||
<PlatformProfileReferralModal
|
||||
panel={profileCenter.profilePopupPanel}
|
||||
center={profileCenter.referralCenter}
|
||||
inviteLinkUrl={profileCenter.inviteLinkUrl}
|
||||
isLoading={profileCenter.isLoadingReferral}
|
||||
isSubmittingRedeem={profileCenter.isSubmittingReferralRedeem}
|
||||
redeemCode={profileCenter.referralRedeemCode}
|
||||
copyInviteState={profileCenter.inviteCopyState}
|
||||
copyInviteLinkState={profileCenter.inviteLinkCopyState}
|
||||
copyInviteCodeState={profileCenter.inviteCodeCopyState}
|
||||
error={profileCenter.referralError}
|
||||
success={profileCenter.referralSuccess}
|
||||
canPasteRedeemCode={canPasteFromNativeHostClipboard()}
|
||||
onPasteRedeemCode={pasteReferralRedeemCode}
|
||||
onClose={profileCenter.closeProfilePopupPanel}
|
||||
onCopyInvite={profileCenter.copyInviteInfo}
|
||||
onCopyInviteLink={profileCenter.copyInviteLink}
|
||||
onCopyInviteCode={profileCenter.copyInviteCode}
|
||||
onRedeemCodeChange={profileCenter.setReferralRedeemCode}
|
||||
onSubmitRedeemCode={profileCenter.submitReferralRedeemCode}
|
||||
/>
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { ProfileReferralInviteCenterResponse } from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
canPasteFromNativeHostClipboard,
|
||||
readNativeHostClipboardText,
|
||||
} from './platformProfileHostClipboard';
|
||||
import { PlatformProfileReferralModal } from './PlatformProfileReferralModal';
|
||||
|
||||
vi.mock('./platformProfileHostClipboard', () => ({
|
||||
canPasteFromNativeHostClipboard: vi.fn(() => false),
|
||||
readNativeHostClipboardText: vi.fn(),
|
||||
}));
|
||||
|
||||
const canPasteFromNativeHostClipboardMock = vi.mocked(
|
||||
canPasteFromNativeHostClipboard,
|
||||
);
|
||||
const readNativeHostClipboardTextMock = vi.mocked(readNativeHostClipboardText);
|
||||
|
||||
function buildCenter(
|
||||
overrides: Partial<ProfileReferralInviteCenterResponse> = {},
|
||||
): ProfileReferralInviteCenterResponse {
|
||||
return {
|
||||
inviteCode: 'ABCD1234',
|
||||
inviteLinkPath: '/invite/ABCD1234',
|
||||
invitedCount: 1,
|
||||
rewardedInviteCount: 1,
|
||||
todayInviterRewardCount: 1,
|
||||
todayInviterRewardRemaining: 9,
|
||||
rewardPoints: 66,
|
||||
invitedUsers: [
|
||||
{
|
||||
userId: 'user-2',
|
||||
displayName: '海盐',
|
||||
avatarUrl: null,
|
||||
boundAt: '2026-06-10T08:00:00.000Z',
|
||||
},
|
||||
],
|
||||
hasRedeemedCode: false,
|
||||
boundInviterUserId: null,
|
||||
boundAt: null,
|
||||
updatedAt: '2026-06-10T08:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PlatformProfileReferralModal', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
canPasteFromNativeHostClipboardMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
test('renders invite panel with shared profile content', () => {
|
||||
render(
|
||||
<PlatformProfileReferralModal
|
||||
panel="invite"
|
||||
center={buildCenter()}
|
||||
isLoading={false}
|
||||
isSubmittingRedeem={false}
|
||||
redeemCode=""
|
||||
copyInviteState="idle"
|
||||
error={null}
|
||||
success={null}
|
||||
onClose={vi.fn()}
|
||||
onCopyInvite={vi.fn()}
|
||||
onRedeemCodeChange={vi.fn()}
|
||||
onSubmitRedeemCode={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '邀请好友' });
|
||||
|
||||
expect(within(dialog).getByText('邀请码')).toBeTruthy();
|
||||
expect(within(dialog).getByText('ABCD1234')).toBeTruthy();
|
||||
expect(within(dialog).getByText('海盐')).toBeTruthy();
|
||||
expect(within(dialog).getByText('成功邀请')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: /复制邀请/u }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('submits redeem panel with the shared form shell', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRedeemCodeChange = vi.fn();
|
||||
const onSubmitRedeemCode = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformProfileReferralModal
|
||||
panel="redeem"
|
||||
center={buildCenter()}
|
||||
isLoading={false}
|
||||
isSubmittingRedeem={false}
|
||||
redeemCode="ab12"
|
||||
copyInviteState="idle"
|
||||
error={null}
|
||||
success={null}
|
||||
onClose={vi.fn()}
|
||||
onCopyInvite={vi.fn()}
|
||||
onRedeemCodeChange={onRedeemCodeChange}
|
||||
onSubmitRedeemCode={onSubmitRedeemCode}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '填邀请码' });
|
||||
const input = within(dialog).getByRole('textbox', { name: '邀请码' });
|
||||
|
||||
await user.type(input, ' c');
|
||||
expect(onRedeemCodeChange).toHaveBeenCalled();
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: '提交' }));
|
||||
expect(onSubmitRedeemCode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('redeem panel can paste invite code from native host clipboard', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRedeemCodeChange = vi.fn();
|
||||
canPasteFromNativeHostClipboardMock.mockReturnValue(true);
|
||||
readNativeHostClipboardTextMock.mockResolvedValue('spring-2026');
|
||||
|
||||
render(
|
||||
<PlatformProfileReferralModal
|
||||
panel="redeem"
|
||||
center={buildCenter()}
|
||||
isLoading={false}
|
||||
isSubmittingRedeem={false}
|
||||
redeemCode=""
|
||||
copyInviteState="idle"
|
||||
error={null}
|
||||
success={null}
|
||||
onClose={vi.fn()}
|
||||
onCopyInvite={vi.fn()}
|
||||
onRedeemCodeChange={onRedeemCodeChange}
|
||||
onSubmitRedeemCode={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '粘贴' }));
|
||||
|
||||
expect(readNativeHostClipboardTextMock).toHaveBeenCalledTimes(1);
|
||||
expect(onRedeemCodeChange).toHaveBeenCalledWith('spring-2026');
|
||||
});
|
||||
|
||||
test('renders community QR panels', () => {
|
||||
render(
|
||||
<PlatformProfileReferralModal
|
||||
panel="community"
|
||||
center={buildCenter()}
|
||||
isLoading={false}
|
||||
isSubmittingRedeem={false}
|
||||
redeemCode=""
|
||||
copyInviteState="idle"
|
||||
error={null}
|
||||
success={null}
|
||||
onClose={vi.fn()}
|
||||
onCopyInvite={vi.fn()}
|
||||
onRedeemCodeChange={vi.fn()}
|
||||
onSubmitRedeemCode={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '玩家社区' });
|
||||
|
||||
expect(within(dialog).getByAltText('玩家社区微信群二维码')).toBeTruthy();
|
||||
expect(within(dialog).getByAltText('玩家社区 QQ 群二维码')).toBeTruthy();
|
||||
expect(within(dialog).getByText('微信群')).toBeTruthy();
|
||||
expect(within(dialog).getByText('QQ群')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,343 +0,0 @@
|
||||
import { ClipboardPaste, Copy } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import communityQqQrImage from '../../../media/social-media-group/qq.png';
|
||||
import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
|
||||
import type { ProfileReferralInviteCenterResponse } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
|
||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import { PlatformProfileContentRow } from '../common/PlatformProfileContentRow';
|
||||
import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import type { CopyFeedbackState } from '../common/useCopyFeedback';
|
||||
import {
|
||||
canPasteFromNativeHostClipboard,
|
||||
readNativeHostClipboardText,
|
||||
} from './platformProfileHostClipboard';
|
||||
import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell';
|
||||
import type { ProfileReferralPanel } from './usePlatformProfileCenterController';
|
||||
|
||||
type PlatformProfileReferralModalProps = {
|
||||
panel: ProfileReferralPanel;
|
||||
center: ProfileReferralInviteCenterResponse | null;
|
||||
isLoading: boolean;
|
||||
isSubmittingRedeem: boolean;
|
||||
redeemCode: string;
|
||||
copyInviteState: CopyFeedbackState;
|
||||
error: string | null;
|
||||
success: string | null;
|
||||
onClose: () => void;
|
||||
onCopyInvite: () => void;
|
||||
onRedeemCodeChange: (value: string) => void;
|
||||
onSubmitRedeemCode: () => void;
|
||||
};
|
||||
|
||||
const COMMUNITY_QR_CODES = [
|
||||
{
|
||||
label: '微信群',
|
||||
src: communityWechatQrImage,
|
||||
alt: '玩家社区微信群二维码',
|
||||
},
|
||||
{
|
||||
label: 'QQ群',
|
||||
src: communityQqQrImage,
|
||||
alt: '玩家社区 QQ 群二维码',
|
||||
},
|
||||
] as const;
|
||||
|
||||
function ProfileReferralUserAvatar({
|
||||
name,
|
||||
avatarUrl,
|
||||
}: {
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
}) {
|
||||
const avatarLabel = (name.trim() || '玩').slice(0, 1).toUpperCase();
|
||||
|
||||
return (
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#ff4056] text-xs font-black text-white">
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
avatarLabel
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePanelTitle(panel: ProfileReferralPanel) {
|
||||
if (panel === 'invite') {
|
||||
return '邀请好友';
|
||||
}
|
||||
if (panel === 'redeem') {
|
||||
return '填邀请码';
|
||||
}
|
||||
return '玩家社区';
|
||||
}
|
||||
|
||||
/**
|
||||
* 个人中心邀请能力统一弹层。
|
||||
* 承接邀请码、填码和社区二维码三种 profile panel,避免首页继续内联重复白底浮层实现。
|
||||
*/
|
||||
export function PlatformProfileReferralModal({
|
||||
panel,
|
||||
center,
|
||||
isLoading,
|
||||
isSubmittingRedeem,
|
||||
redeemCode,
|
||||
copyInviteState,
|
||||
error,
|
||||
success,
|
||||
onClose,
|
||||
onCopyInvite,
|
||||
onRedeemCodeChange,
|
||||
onSubmitRedeemCode,
|
||||
}: PlatformProfileReferralModalProps) {
|
||||
const title = resolvePanelTitle(panel);
|
||||
const normalizedRedeemCode = redeemCode
|
||||
.trim()
|
||||
.replace(/[^0-9a-z]/gi, '')
|
||||
.toUpperCase();
|
||||
const canPasteRedeemCode = canPasteFromNativeHostClipboard();
|
||||
const pasteRedeemCode = () => {
|
||||
void readNativeHostClipboardText().then((text) => {
|
||||
if (text) {
|
||||
onRedeemCodeChange(text);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let content: ReactNode;
|
||||
|
||||
if (panel === 'community') {
|
||||
content = (
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
{COMMUNITY_QR_CODES.map((qrCode) => (
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
key={qrCode.label}
|
||||
surface="flat"
|
||||
radius="xs"
|
||||
padding="xs"
|
||||
className="text-center"
|
||||
>
|
||||
<div className="aspect-square overflow-hidden rounded-lg border border-zinc-200 bg-white p-1.5">
|
||||
<img
|
||||
src={qrCode.src}
|
||||
alt={qrCode.alt}
|
||||
className="h-full w-full object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-bold text-zinc-700">
|
||||
{qrCode.label}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} else if (panel === 'redeem') {
|
||||
content = (
|
||||
<PlatformAsyncStatePanel
|
||||
isLoading={isLoading}
|
||||
loadingState={
|
||||
<PlatformProfileSkeletonList
|
||||
count={2}
|
||||
containerClassName="mt-5 space-y-3"
|
||||
itemClassName="h-12 even:h-11"
|
||||
/>
|
||||
}
|
||||
isEmpty={Boolean(center?.hasRedeemedCode)}
|
||||
emptyState={
|
||||
<PlatformEmptyState
|
||||
surface="subpanel"
|
||||
size="inline"
|
||||
tone="base"
|
||||
className="mt-5"
|
||||
>
|
||||
已填写邀请码
|
||||
</PlatformEmptyState>
|
||||
}
|
||||
>
|
||||
<form
|
||||
className="mt-5 space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onSubmitRedeemCode();
|
||||
}}
|
||||
>
|
||||
<PlatformTextField
|
||||
value={redeemCode}
|
||||
onChange={(event) => onRedeemCodeChange(event.target.value)}
|
||||
size="lg"
|
||||
density="roomy"
|
||||
tone="rose"
|
||||
className="rounded-xl text-center font-black uppercase tracking-[0.16em]"
|
||||
placeholder="邀请码"
|
||||
aria-label="邀请码"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
{canPasteRedeemCode ? (
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
surface="profile"
|
||||
tone="ghost"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="w-full"
|
||||
onClick={pasteRedeemCode}
|
||||
>
|
||||
<ClipboardPaste className="h-3.5 w-3.5" />
|
||||
粘贴
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
surface="profile"
|
||||
fullWidth
|
||||
size="md"
|
||||
className="rounded-xl"
|
||||
disabled={isSubmittingRedeem || !normalizedRedeemCode}
|
||||
>
|
||||
{isSubmittingRedeem ? '提交中' : '提交'}
|
||||
</PlatformActionButton>
|
||||
</form>
|
||||
</PlatformAsyncStatePanel>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<PlatformAsyncStatePanel
|
||||
isLoading={isLoading}
|
||||
loadingState={
|
||||
<PlatformProfileSkeletonList
|
||||
count={2}
|
||||
containerClassName="mt-5 space-y-3"
|
||||
itemClassName="h-20 odd:h-20 even:h-10"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="mt-5 space-y-3">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="flat"
|
||||
radius="xs"
|
||||
padding="md"
|
||||
className="text-center"
|
||||
>
|
||||
<PlatformFieldLabel
|
||||
variant="section"
|
||||
className="block text-[11px] text-zinc-500"
|
||||
>
|
||||
邀请码
|
||||
</PlatformFieldLabel>
|
||||
<div className="mt-1 text-3xl font-black tracking-[0.16em] text-[#ff4056]">
|
||||
{center?.inviteCode ?? '--------'}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
<PlatformStatusMessage
|
||||
tone="warning"
|
||||
surface="profile"
|
||||
size="md"
|
||||
className="space-y-0.5 px-3.5 font-semibold"
|
||||
>
|
||||
<div>
|
||||
{`邀请一个用户注册,双方都可以获得${center?.rewardPoints ?? 30}泥点。`}
|
||||
</div>
|
||||
<div>每日最多获得十次邀请奖励。</div>
|
||||
</PlatformStatusMessage>
|
||||
<CopyFeedbackButton
|
||||
state={copyInviteState}
|
||||
onClick={onCopyInvite}
|
||||
disabled={!center?.inviteCode}
|
||||
idleLabel="复制邀请"
|
||||
copiedLabel="已复制"
|
||||
failedLabel="复制失败"
|
||||
idleIcon={<Copy className="h-4 w-4" />}
|
||||
actionSurface="profile"
|
||||
actionSize="md"
|
||||
actionFullWidth
|
||||
className="gap-2 rounded-xl"
|
||||
/>
|
||||
<PlatformSubpanel as="div" surface="flat" radius="xs" padding="sm">
|
||||
<PlatformFieldLabel
|
||||
variant="section"
|
||||
className="block text-zinc-900"
|
||||
>
|
||||
成功邀请
|
||||
</PlatformFieldLabel>
|
||||
{center?.invitedUsers?.length ? (
|
||||
<div className="mt-3 max-h-44 space-y-2 overflow-y-auto pr-1">
|
||||
{center.invitedUsers.map((user) => (
|
||||
<PlatformProfileContentRow
|
||||
key={`${user.userId}-${user.boundAt}`}
|
||||
surface="soft"
|
||||
radius="xs"
|
||||
padding="row"
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<ProfileReferralUserAvatar
|
||||
name={user.displayName}
|
||||
avatarUrl={user.avatarUrl}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-bold text-zinc-900">
|
||||
{user.displayName || '玩家'}
|
||||
</div>
|
||||
</div>
|
||||
</PlatformProfileContentRow>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<PlatformEmptyState
|
||||
surface="subpanel"
|
||||
size="compact"
|
||||
className="mt-3 text-center text-xs font-semibold leading-normal"
|
||||
>
|
||||
暂无成功邀请
|
||||
</PlatformEmptyState>
|
||||
)}
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
</PlatformAsyncStatePanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PlatformProfileSecondaryModalShell
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
closeVariant="floatingPlain"
|
||||
closeIcon="×"
|
||||
overlayTone="soft"
|
||||
panelClassName="relative !max-w-[24rem] bg-white text-zinc-950 shadow-2xl !rounded-[1.35rem] sm:!rounded-[1.35rem]"
|
||||
contentClassName="relative px-5 pb-5 pt-4"
|
||||
>
|
||||
<div className="text-center text-xl font-black">{title}</div>
|
||||
{content}
|
||||
|
||||
{error ? (
|
||||
<PlatformStatusMessage tone="error" className="mt-4">
|
||||
{error}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
{success ? (
|
||||
<PlatformStatusMessage tone="success" className="mt-4">
|
||||
{success}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
</PlatformProfileSecondaryModalShell>
|
||||
);
|
||||
}
|
||||
@@ -11,11 +11,62 @@ import {
|
||||
userB,
|
||||
} from './usePlatformProfileCenterController.testSupport';
|
||||
|
||||
const clipboardMock = vi.hoisted(() => ({
|
||||
copyTextToClipboard: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/clipboard', () => clipboardMock);
|
||||
|
||||
describe('usePlatformProfileCenterController redemption lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, '', '/profile');
|
||||
usePlatformWalletStore.getState().resetWalletBalance();
|
||||
vi.clearAllMocks();
|
||||
clipboardMock.copyTextToClipboard.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
test('copies the invite link and the invite code as separate actions', async () => {
|
||||
profileClientMocks.getPlatformProfileReferralInviteCenter.mockResolvedValue(
|
||||
{
|
||||
inviteCode: 'USER-A',
|
||||
inviteLinkPath: '/?inviteCode=USER-A',
|
||||
invitedCount: 0,
|
||||
rewardedInviteCount: 0,
|
||||
todayInviterRewardCount: 0,
|
||||
todayInviterRewardRemaining: 10,
|
||||
rewardPoints: 30,
|
||||
invitedUsers: [],
|
||||
hasRedeemedCode: false,
|
||||
boundInviterUserId: null,
|
||||
boundAt: null,
|
||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||
},
|
||||
);
|
||||
const expectedInviteLink = `${window.location.origin}/?inviteCode=USER-A`;
|
||||
const { result } = renderController(userA);
|
||||
|
||||
await act(async () => {
|
||||
result.current.openProfilePopupPanel('invite');
|
||||
});
|
||||
|
||||
expect(result.current.profilePopupPanel).toBe('invite');
|
||||
expect(result.current.inviteLinkUrl).toBe(expectedInviteLink);
|
||||
|
||||
await act(async () => {
|
||||
result.current.copyInviteLink();
|
||||
});
|
||||
expect(clipboardMock.copyTextToClipboard).toHaveBeenLastCalledWith(
|
||||
expectedInviteLink,
|
||||
);
|
||||
expect(result.current.referralSuccess).toBe('邀请链接已复制');
|
||||
|
||||
await act(async () => {
|
||||
result.current.copyInviteCode();
|
||||
});
|
||||
expect(clipboardMock.copyTextToClipboard).toHaveBeenLastCalledWith(
|
||||
'USER-A',
|
||||
);
|
||||
expect(result.current.referralSuccess).toBe('邀请码已复制');
|
||||
});
|
||||
|
||||
test('ignores a reward-code response after the account changes', async () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user