我的页签恢复邀请好友入口,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
Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

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} 次。`,
};
}