我的页签恢复邀请好友入口,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() {