From 41b882e1fe60c4b8cf1f7183d40ae46191daf47f Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:45:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=88=91=E7=9A=84=E9=A1=B5=E7=AD=BE=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E9=82=80=E8=AF=B7=E5=A5=BD=E5=8F=8B=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=EF=BC=8CAGC=20=E5=AE=A2=E6=88=B7=E7=AB=AF=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E6=8E=A5=E5=85=A5=E9=82=80=E8=AF=B7=E7=A0=81?= =?UTF-8?q?=E4=B8=8E=E7=8E=A9=E5=AE=B6=E7=A4=BE=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 主站「我的」页签常用功能宫格补回「邀请好友」,邀请码与邀请链接拆成两个复制动作 - 邀请弹层下沉到 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 与客户端专用弹层样式,原地删除旧邀请弹层实现 - 同步决策记录与产品基线文档中邀请奖励、跨端复用与文案口径 --- .../scripts/check-config.mjs | 1 + .../src-tauri/src/account_api.rs | 61 +- .../src-tauri/src/desktop.rs | 1 + .../features/app-shell/AccountReferral.tsx | 48 ++ .../features/app-shell/WorkspaceLauncher.tsx | 9 + .../features/app-shell/useAccountReferral.ts | 143 +++++ .../src/services/accountHost.ts | 12 + .../ai-game-creator-shell/src/view/layout.tsx | 27 + .../tests/accountHost.test.ts | 3 + .../tests/appSurface/harness.ts | 3 + .../tests/appSurface/home.suite.ts | 70 +++ .../shared-memory/decision-log.md | 12 + ...�项目基线】当前产品与工程约束-2026-05-15.md | 2 +- .../assets/community-qq.png | Bin .../assets/community-wechat.png | Bin .../PlatformProfileReferralModal/index.css | 55 ++ .../index.test.tsx | 205 +++++++ .../PlatformProfileReferralModal/index.tsx | 529 ++++++++++++++++++ .../PlatformProfileReferralModal/model.ts | 53 ++ .../PlatformActiveProfileView.test.tsx | 5 + .../PlatformActiveProfileView.tsx | 13 +- .../PlatformEntryActiveFlowShell.tsx | 32 +- .../PlatformProfileReferralModal.test.tsx | 172 ------ .../PlatformProfileReferralModal.tsx | 343 ------------ ...rofileCenterController.redemption.test.tsx | 51 ++ .../usePlatformProfileCenterController.ts | 57 +- vite.config.ts | 1 - 27 files changed, 1371 insertions(+), 537 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/app-shell/AccountReferral.tsx create mode 100644 apps/ai-game-creator-shell/src/features/app-shell/useAccountReferral.ts rename media/social-media-group/qq.png => packages/shared/src/components/PlatformProfileReferralModal/assets/community-qq.png (100%) rename media/social-media-group/wechat.png => packages/shared/src/components/PlatformProfileReferralModal/assets/community-wechat.png (100%) create mode 100644 packages/shared/src/components/PlatformProfileReferralModal/index.css create mode 100644 packages/shared/src/components/PlatformProfileReferralModal/index.test.tsx create mode 100644 packages/shared/src/components/PlatformProfileReferralModal/index.tsx create mode 100644 packages/shared/src/components/PlatformProfileReferralModal/model.ts delete mode 100644 src/components/platform-entry/PlatformProfileReferralModal.test.tsx delete mode 100644 src/components/platform-entry/PlatformProfileReferralModal.tsx diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 1b964573f..a7dda892c 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -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', diff --git a/apps/ai-game-creator-shell/src-tauri/src/account_api.rs b/apps/ai-game-creator-shell/src-tauri/src/account_api.rs index 3f882e163..2cdc1b40d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/account_api.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/account_api.rs @@ -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 { 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( client: &reqwest::Client, snapshot: &PlatformSessionSnapshot, @@ -171,6 +196,24 @@ pub(crate) async fn read_profile_wallet_ledger() -> Result Result { + 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" + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs index 29268e088..0c479c168 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src/features/app-shell/AccountReferral.tsx b/apps/ai-game-creator-shell/src/features/app-shell/AccountReferral.tsx new file mode 100644 index 000000000..1ab5f56b7 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/app-shell/AccountReferral.tsx @@ -0,0 +1,48 @@ +import { PlatformProfileReferralModal } from '../../../../../packages/shared/src/components/PlatformProfileReferralModal'; +import type { + AccountReferralController, + AccountReferralPanel, +} from './useAccountReferral'; + +/** 客户端账号菜单的入口文案是「邀请码 / 玩家社区」,弹层标题跟着入口走。 */ +const CLIENT_REFERRAL_PANEL_TITLE: Record = { + invite: '邀请码', + community: '玩家社区', +}; + +/** + * 账号菜单的邀请码 / 玩家社区弹层。 + * + * 弹层实现与主站「我的」页签共用 `packages/shared` 里的同一份 + * `PlatformProfileReferralModal`(与充值、泥点账单同口径);这里只做宿主适配: + * 把 Tauri typed command 读到的邀请中心、会话 origin 补全后的邀请链接、宿主剪贴板 + * 复制回显和面板开关接到共享组件上,不再另写一份客户端专属弹层。 + */ +export function AccountReferralDialogs({ + controller, +}: { + controller: AccountReferralController; +}) { + if (!controller.panel) { + return null; + } + + return ( + + ); +} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 145e33445..3af7db68f 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -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} + {walletSlot ? ( createPortal( , diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountReferral.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountReferral.ts new file mode 100644 index 000000000..76c99d35c --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountReferral.ts @@ -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(null); + const [center, setCenter] = + useState(null); + const [inviteUrl, setInviteUrl] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [copyInviteLinkState, setCopyInviteLinkState] = + useState('idle'); + const [copyInviteCodeState, setCopyInviteCodeState] = + useState('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; diff --git a/apps/ai-game-creator-shell/src/services/accountHost.ts b/apps/ai-game-creator-shell/src/services/accountHost.ts index 479874be9..da82f4168 100644 --- a/apps/ai-game-creator-shell/src/services/accountHost.ts +++ b/apps/ai-game-creator-shell/src/services/accountHost.ts @@ -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( + 'read_profile_referral_invite_center', + ); +} + export function createClientProfileRechargeOrder(productId: string) { return invokeAccount( 'create_profile_recharge_order', diff --git a/apps/ai-game-creator-shell/src/view/layout.tsx b/apps/ai-game-creator-shell/src/view/layout.tsx index 642cd7a87..621c98708 100644 --- a/apps/ai-game-creator-shell/src/view/layout.tsx +++ b/apps/ai-game-creator-shell/src/view/layout.tsx @@ -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({ > 使用指南 + + +
+ {title} +
+ {content} + + {error ? ( + + {error} + + ) : null} + {success ? ( + + {success} + + ) : null} + + + ); +} + +export default PlatformProfileReferralModal; diff --git a/packages/shared/src/components/PlatformProfileReferralModal/model.ts b/packages/shared/src/components/PlatformProfileReferralModal/model.ts new file mode 100644 index 000000000..dfa3b47f2 --- /dev/null +++ b/packages/shared/src/components/PlatformProfileReferralModal/model.ts @@ -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} 次。`, + }; +} diff --git a/src/components/platform-entry/PlatformActiveProfileView.test.tsx b/src/components/platform-entry/PlatformActiveProfileView.test.tsx index 080996f2d..acf9d4934 100644 --- a/src/components/platform-entry/PlatformActiveProfileView.test.tsx +++ b/src/components/platform-entry/PlatformActiveProfileView.test.tsx @@ -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( diff --git a/src/components/platform-entry/PlatformActiveProfileView.tsx b/src/components/platform-entry/PlatformActiveProfileView.tsx index 8c5c39e74..8b431a2aa 100644 --- a/src/components/platform-entry/PlatformActiveProfileView.tsx +++ b/src/components/platform-entry/PlatformActiveProfileView.tsx @@ -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="常用功能" > -
+
+ { + 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({ diff --git a/src/components/platform-entry/PlatformProfileReferralModal.test.tsx b/src/components/platform-entry/PlatformProfileReferralModal.test.tsx deleted file mode 100644 index d674c2b88..000000000 --- a/src/components/platform-entry/PlatformProfileReferralModal.test.tsx +++ /dev/null @@ -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 { - 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( - , - ); - - 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( - , - ); - - 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( - , - ); - - await user.click(screen.getByRole('button', { name: '粘贴' })); - - expect(readNativeHostClipboardTextMock).toHaveBeenCalledTimes(1); - expect(onRedeemCodeChange).toHaveBeenCalledWith('spring-2026'); - }); - - test('renders community QR panels', () => { - render( - , - ); - - 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(); - }); -}); diff --git a/src/components/platform-entry/PlatformProfileReferralModal.tsx b/src/components/platform-entry/PlatformProfileReferralModal.tsx deleted file mode 100644 index 0f5151148..000000000 --- a/src/components/platform-entry/PlatformProfileReferralModal.tsx +++ /dev/null @@ -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 ( - - {avatarUrl ? ( - - ) : ( - avatarLabel - )} - - ); -} - -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 = ( -
- {COMMUNITY_QR_CODES.map((qrCode) => ( - -
- {qrCode.alt} -
-
- {qrCode.label} -
-
- ))} -
- ); - } else if (panel === 'redeem') { - content = ( - - } - isEmpty={Boolean(center?.hasRedeemedCode)} - emptyState={ - - 已填写邀请码 - - } - > -
{ - event.preventDefault(); - onSubmitRedeemCode(); - }} - > - 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 ? ( - - - 粘贴 - - ) : null} - - {isSubmittingRedeem ? '提交中' : '提交'} - - -
- ); - } else { - content = ( - - } - > -
- - - 邀请码 - -
- {center?.inviteCode ?? '--------'} -
-
- -
- {`邀请一个用户注册,双方都可以获得${center?.rewardPoints ?? 30}泥点。`} -
-
每日最多获得十次邀请奖励。
-
- } - actionSurface="profile" - actionSize="md" - actionFullWidth - className="gap-2 rounded-xl" - /> - - - 成功邀请 - - {center?.invitedUsers?.length ? ( -
- {center.invitedUsers.map((user) => ( - - -
-
- {user.displayName || '玩家'} -
-
-
- ))} -
- ) : ( - - 暂无成功邀请 - - )} -
-
-
- ); - } - - return ( - -
{title}
- {content} - - {error ? ( - - {error} - - ) : null} - {success ? ( - - {success} - - ) : null} -
- ); -} diff --git a/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx b/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx index dbbb10029..2ec27ed59 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx +++ b/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx @@ -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 () => { diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index 03a5f423d..208eaccd7 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -422,7 +422,9 @@ export function usePlatformProfileCenterController({ useState(false); const [referralError, setReferralError] = useState(null); const [referralSuccess, setReferralSuccess] = useState(null); - const { copyState: inviteCopyState, copyText: copyInviteText } = + const { copyState: inviteLinkCopyState, copyText: copyInviteLinkText } = + useCopyFeedback(); + const { copyState: inviteCodeCopyState, copyText: copyInviteCodeText } = useCopyFeedback(); const pendingWechatRechargeOrderRef = useRef(null); @@ -1701,21 +1703,43 @@ export function usePlatformProfileCenterController({ setProfilePopupPanel(null); }, []); - const copyInviteInfo = useCallback(() => { - if (!referralCenter?.inviteCode) { + /** + * 邀请链接是分享给好友的正式入口(好友打开后自动带上邀请码), + * 因此这里只用站点 origin 补全后端返回的相对路径。 + */ + const inviteLinkUrl = useMemo(() => { + const inviteLinkPath = referralCenter?.inviteLinkPath; + if (!inviteLinkPath) { + return null; + } + if (typeof window === 'undefined') { + return inviteLinkPath; + } + try { + return new URL(inviteLinkPath, window.location.origin).href; + } catch { + return inviteLinkPath; + } + }, [referralCenter]); + + const copyInviteLink = useCallback(() => { + if (!inviteLinkUrl) { return; } + void copyInviteLinkText(inviteLinkUrl).then((copied) => { + setReferralSuccess(copied ? '邀请链接已复制' : '复制失败'); + }); + }, [copyInviteLinkText, inviteLinkUrl]); - const inviteUrl = - typeof window === 'undefined' - ? referralCenter.inviteLinkPath - : new URL(referralCenter.inviteLinkPath, window.location.origin).href; - void copyInviteText(`${referralCenter.inviteCode} ${inviteUrl}`).then( - (copied) => { - setReferralSuccess(copied ? '已复制' : '复制失败'); - }, - ); - }, [copyInviteText, referralCenter]); + const copyInviteCode = useCallback(() => { + const inviteCode = referralCenter?.inviteCode; + if (!inviteCode) { + return; + } + void copyInviteCodeText(inviteCode).then((copied) => { + setReferralSuccess(copied ? '邀请码已复制' : '复制失败'); + }); + }, [copyInviteCodeText, referralCenter]); const submitReferralRedeemCode = useCallback(() => { const inviteCode = referralRedeemCode @@ -1850,7 +1874,10 @@ export function usePlatformProfileCenterController({ closeNativeWechatPayment, closeProfilePopupPanel, confirmNativeWechatPayment, - inviteCopyState: inviteCopyState as CopyFeedbackState, + copyInviteCode, + inviteCodeCopyState: inviteCodeCopyState as CopyFeedbackState, + inviteLinkCopyState: inviteLinkCopyState as CopyFeedbackState, + inviteLinkUrl, isLoadingRechargeCenter, isLoadingReferral, isLoadingWalletLedger, @@ -1892,6 +1919,6 @@ export function usePlatformProfileCenterController({ walletLedgerError, wechatRechargeOrderConfirmationState, buyRechargeProduct, - copyInviteInfo, + copyInviteLink, }; } diff --git a/vite.config.ts b/vite.config.ts index 1210009a3..142f05851 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -190,7 +190,6 @@ const ACTIVE_TAILWIND_SOURCES = `@import 'tailwindcss' source(none); @source "./components/platform-entry/PlatformProfileApiKeysModal.tsx"; @source "./components/platform-entry/PlatformProfileModalShell.tsx"; @source "./components/platform-entry/PlatformProfileRechargeModal.tsx"; -@source "./components/platform-entry/PlatformProfileReferralModal.tsx"; @source "./components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx"; @source "./components/platform-entry/PlatformProfileWalletLedgerModal.tsx"; @source "./components/platform-entry/PlatformRechargePaymentStatusDialogs.tsx"; -- 2.52.0