我的页签恢复邀请好友入口,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
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:
Binary file not shown.
|
After Width: | Height: | Size: 296 KiB |
BIN
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} 次。`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user