我的页签恢复邀请好友入口,AGC 客户端账号菜单接入邀请码与玩家社区
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m50s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m26s
Project CI / Native shell tests (pull_request) Failing after 36s
Project CI / Repository checks (pull_request) Failing after 26s
Project CI / Frontend tests (pull_request) Successful in 2m35s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m25s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 11m17s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 12m41s

- 主站「我的」页签常用功能宫格补回「邀请好友」,邀请码与邀请链接拆成两个复制动作
- 邀请弹层下沉到 packages/shared/src/components/PlatformProfileReferralModal,主站与客户端共用一份实现
- 邀请奖励说明不再写死次数与金额,每日上限由 todayInviterRewardCount 与 todayInviterRewardRemaining 推导
- 奖励说明从面板正文移入右上角帮助按钮,鼠标悬浮展开、触屏与键盘点击开合
- 帮助按钮复用平台 .platform-icon-button 配色,说明气泡复用 PlatformStatusMessage 的 warning token
- AGC 客户端账号菜单新增「邀请码」与「玩家社区」两项,弹层标题与菜单入口文案保持一致
- 新增 Tauri 命令 read_profile_referral_invite_center,用登录 origin 补全 inviteLinkPath 后交给渲染层
- 客户端邀请中心按登录账号隔离,切号重置并作废在飞响应,不本地另存邀请码
- 新命令登记进 apps/ai-game-creator-shell/scripts/check-config.mjs 的 native-only 白名单门禁
- 微信群与 QQ 群二维码从 media/social-media-group 迁入共享组件 assets
- 移除已下线文件的 Tailwind @source 与客户端专用弹层样式,原地删除旧邀请弹层实现
- 同步决策记录与产品基线文档中邀请奖励、跨端复用与文案口径
This commit is contained in:
kdletters
2026-10-01 19:45:27 +08:00
parent 3193182665
commit 41b882e1fe
27 changed files with 1371 additions and 537 deletions
@@ -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, &center.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