diff --git a/frontend/src/app/app.test.tsx b/frontend/src/app/app.test.tsx index c85cfd89..10997bc7 100644 --- a/frontend/src/app/app.test.tsx +++ b/frontend/src/app/app.test.tsx @@ -24,6 +24,7 @@ afterEach(() => { cleanup() window.localStorage.clear() vi.useRealTimers() + vi.restoreAllMocks() }) describe('AppRoutes authentication boundary', () => { diff --git a/frontend/src/app/layout/app-header.test.tsx b/frontend/src/app/layout/app-header.test.tsx index 1fcffa03..779c0671 100644 --- a/frontend/src/app/layout/app-header.test.tsx +++ b/frontend/src/app/layout/app-header.test.tsx @@ -45,12 +45,25 @@ const creditAccount: CreditAccount = { } function createQuotaMock(): QuotaApis & { - getBalance: ReturnType - listTransactions: ReturnType + [K in keyof QuotaApis]: ReturnType } { return { getBalance: vi.fn(async () => creditAccount), listTransactions: vi.fn(async () => ({ items: [], total: 0, page: 1, pageSize: 20 })), + getInviteCode: vi.fn(async () => ({ + code: 'AB23CD45', + usedCount: 0, + expiresAt: '2026-09-16T01:02:03Z', + createdAt: '2026-08-17T01:02:03Z', + updatedAt: '2026-08-17T01:02:03Z', + })), + generateInviteCode: vi.fn(async () => ({ + code: 'XY89KL23', + usedCount: 0, + expiresAt: '2026-09-16T01:02:03Z', + createdAt: '2026-08-17T01:02:03Z', + updatedAt: '2026-08-17T01:02:03Z', + })), } } @@ -204,7 +217,7 @@ describe('AppHeader', () => { it('为访客提供可发现的登录入口并保留完整站内回跳地址', async () => { renderHeader('/quick-start?mode=fast#brief') - const entry = await screen.findByRole('link', { name: '登录' }) + const entry = await screen.findByRole('link', { name: '登录 / 注册' }) expect(entry.getAttribute('href')).toBe( '/?account=login&returnTo=%2Fquick-start%3Fmode%3Dfast%23brief', ) @@ -220,7 +233,7 @@ describe('AppHeader', () => { fireEvent.click(screen.getByRole('button', { name: '退出登录' })) await waitFor(() => expect(screen.getByTestId('location').textContent).toBe('/')) - expect(await screen.findByRole('link', { name: '登录' })).toBeTruthy() + expect(await screen.findByRole('link', { name: '登录 / 注册' })).toBeTruthy() expect(apis.logout).toHaveBeenCalledWith('rotated-refresh-token') }) @@ -240,6 +253,8 @@ describe('AppHeader', () => { renderHeader('/workspace') const hint = await screen.findByRole('status', { name: '邀请奖励提示' }) + expect(screen.getByText('每日前 3 位好友,你各得 200 积分')).toBeTruthy() + expect(screen.getByText('好友注册共得 500 积分')).toBeTruthy() expect(screen.getByRole('link', { name: '去看看邀请奖励' }).getAttribute('href')).toBe( '/account?section=invite', ) @@ -280,7 +295,7 @@ describe('AppHeader', () => { fireEvent.click(screen.getByRole('button', { name: '退出登录' })) await waitFor(() => expect(screen.getByTestId('location').textContent).toBe('/')) - expect(await screen.findByRole('link', { name: '登录' })).toBeTruthy() + expect(await screen.findByRole('link', { name: '登录 / 注册' })).toBeTruthy() }) it('没有昵称时使用邮箱展示账号身份', async () => { diff --git a/frontend/src/app/layout/app-header.tsx b/frontend/src/app/layout/app-header.tsx index b698d69e..afcda21d 100644 --- a/frontend/src/app/layout/app-header.tsx +++ b/frontend/src/app/layout/app-header.tsx @@ -228,11 +228,10 @@ export function AppHeader({ quotaApis = defaultQuotaApis }: AppHeaderProps = {}) ) : session.state.status === 'guest' ? ( - {/* 内测关闭公开注册。重新开放时改回「登录 / 注册」。 */} - 登录 + 登录 / 注册 登录 ) : ( @@ -246,7 +245,10 @@ export function AppHeader({ quotaApis = defaultQuotaApis }: AppHeaderProps = {})

- 邀请好友,双方各得 200 积分 + 每日前 3 位好友,你各得 200 积分 +

+

+ 好友注册共得 500 积分

{ }) }) + it('读取并映射当前用户的邀请码', async () => { + request.mockResolvedValue({ + code: 'AB23CD45', + used_count: 3, + expires_at: '2026-09-11T01:02:03Z', + create_at: '2026-08-12T01:02:03Z', + update_at: '2026-08-17T01:02:03Z', + }) + + await expect(createQuotaApis({ client }).getInviteCode()).resolves.toEqual({ + code: 'AB23CD45', + usedCount: 3, + expiresAt: '2026-09-11T01:02:03Z', + createdAt: '2026-08-12T01:02:03Z', + updatedAt: '2026-08-17T01:02:03Z', + }) + expect(request).toHaveBeenCalledWith('/quota/invite/code') + }) + + it('轮换邀请码并映射新的有效期', async () => { + request.mockResolvedValueOnce({ + code: 'XY89KL23', + used_count: 0, + expires_at: '2026-09-16T03:00:00Z', + create_at: '2026-08-17T03:00:00Z', + update_at: '2026-08-17T03:00:00Z', + }) + + const apis = createQuotaApis({ client }) + await expect(apis.generateInviteCode()).resolves.toMatchObject({ + code: 'XY89KL23', + usedCount: 0, + expiresAt: '2026-09-16T03:00:00Z', + }) + expect(request).toHaveBeenNthCalledWith(1, '/quota/invite/generate', { method: 'POST' }) + }) + it('默认适配器读取环境地址并携带当前登录凭证', async () => { vi.resetModules() const fetchFn = vi.fn(async (input) => { const url = String(input) - const body = url.includes('/quota/transactions') - ? { - code: 200, - message: 'ok', - data: [], - total: 0, - page: 1, - page_size: 20, - } - : { code: 200, message: 'ok', data: accountResponse } + let body: unknown + if (url.includes('/quota/transactions')) { + body = { + code: 200, + message: 'ok', + data: [], + total: 0, + page: 1, + page_size: 20, + } + } else if (url.includes('/quota/invite/')) { + body = { + code: 200, + message: 'ok', + data: { + code: 'AB23CD45', + used_count: 3, + expires_at: '2026-09-11T01:02:03Z', + create_at: '2026-08-12T01:02:03Z', + update_at: '2026-08-17T01:02:03Z', + }, + } + } else { + body = { code: 200, message: 'ok', data: accountResponse } + } return Promise.resolve(new Response(JSON.stringify(body), { status: 200 })) }) vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') @@ -118,6 +170,8 @@ describe('createQuotaApis', () => { items: [], total: 0, }) + await expect(quotaApis.getInviteCode()).resolves.toMatchObject({ code: 'AB23CD45' }) + await expect(quotaApis.generateInviteCode()).resolves.toMatchObject({ code: 'AB23CD45' }) expect(fetchFn).toHaveBeenCalledWith( 'https://api.windup.test/quota/balance', expect.objectContaining({ @@ -129,6 +183,8 @@ describe('createQuotaApis', () => { expect(fetchFn.mock.calls[1]?.[0]).toBe( 'https://api.windup.test/quota/transactions?page=1&page_size=20', ) + expect(fetchFn.mock.calls[2]?.[0]).toBe('https://api.windup.test/quota/invite/code') + expect(fetchFn.mock.calls[3]?.[0]).toBe('https://api.windup.test/quota/invite/generate') } finally { unregister() vi.unstubAllGlobals() diff --git a/frontend/src/entities/quota/api.ts b/frontend/src/entities/quota/api.ts index 6c7c3014..8c9130de 100644 --- a/frontend/src/entities/quota/api.ts +++ b/frontend/src/entities/quota/api.ts @@ -1,6 +1,7 @@ import type { CreditAccount, CreditTransaction, + InviteCode, QuotaApis, QuotaTransactionPageQuery, } from './types' @@ -30,6 +31,14 @@ interface CreditTransactionDto { create_at: string } +interface InviteCodeDto { + code: string + used_count: number + expires_at: string + create_at: string + update_at: string +} + export interface CreateQuotaApisOptions extends ApiClientOptions { client?: ApiClient } @@ -60,6 +69,16 @@ function toCreditTransaction(dto: CreditTransactionDto): CreditTransaction { } } +function toInviteCode(dto: InviteCodeDto): InviteCode { + return { + code: dto.code, + usedCount: dto.used_count, + expiresAt: dto.expires_at, + createdAt: dto.create_at, + updatedAt: dto.update_at, + } +} + export function createQuotaApis(options: CreateQuotaApisOptions = {}): QuotaApis { const { client, ...clientOptions } = options const protectedClient = @@ -82,6 +101,14 @@ export function createQuotaApis(options: CreateQuotaApisOptions = {}): QuotaApis ) return { ...result, items: result.items.map(toCreditTransaction) } }, + async getInviteCode() { + return toInviteCode(await protectedClient.request('/quota/invite/code')) + }, + async generateInviteCode() { + return toInviteCode( + await protectedClient.request('/quota/invite/generate', { method: 'POST' }), + ) + }, } } @@ -96,4 +123,6 @@ function getDefaultApis(): QuotaApis { export const quotaApis: QuotaApis = { getBalance: () => getDefaultApis().getBalance(), listTransactions: (query) => getDefaultApis().listTransactions(query), + getInviteCode: () => getDefaultApis().getInviteCode(), + generateInviteCode: () => getDefaultApis().generateInviteCode(), } diff --git a/frontend/src/entities/quota/index.ts b/frontend/src/entities/quota/index.ts index 851076b0..7bf570a2 100644 --- a/frontend/src/entities/quota/index.ts +++ b/frontend/src/entities/quota/index.ts @@ -3,6 +3,7 @@ export type { CreateQuotaApisOptions } from './api' export type { CreditAccount, CreditTransaction, + InviteCode, QuotaApis, QuotaTransactionPageQuery, } from './types' diff --git a/frontend/src/entities/quota/types.ts b/frontend/src/entities/quota/types.ts index 049b73b5..96a4bbb8 100644 --- a/frontend/src/entities/quota/types.ts +++ b/frontend/src/entities/quota/types.ts @@ -26,7 +26,18 @@ export interface CreditTransaction { export type QuotaTransactionPageQuery = PageQuery +/** 当前登录用户未过期的邀请码;usedCount 只统计当前码的成功注册次数。 */ +export interface InviteCode { + code: string + usedCount: number + expiresAt: string + createdAt: string + updatedAt: string +} + export interface QuotaApis { getBalance(): Promise listTransactions(query?: QuotaTransactionPageQuery): Promise> + getInviteCode(): Promise + generateInviteCode(): Promise } diff --git a/frontend/src/entities/user/api.test.ts b/frontend/src/entities/user/api.test.ts index 5864c352..65c3c2a5 100644 --- a/frontend/src/entities/user/api.test.ts +++ b/frontend/src/entities/user/api.test.ts @@ -52,6 +52,7 @@ describe('createUserApis', () => { password: 'password-123', code: '123456', nickname: 'Reader', + inviteCode: 'AB23CD45', }) await apis.login({ email: 'reader@example.com', @@ -79,6 +80,7 @@ describe('createUserApis', () => { email: 'reader@example.com', password: 'password-123', code: '123456', + invite_code: 'AB23CD45', nickname: 'Reader', }, }, @@ -185,6 +187,7 @@ describe('createUserApis', () => { password: 'password-123', code: '123456', nickname: '', + inviteCode: 'AB23CD45', }) expect(request).toHaveBeenCalledWith('/auth/register', { @@ -193,10 +196,33 @@ describe('createUserApis', () => { email: 'reader@example.com', password: 'password-123', code: '123456', + invite_code: 'AB23CD45', }, }) }) + it('omits the optional invite code from public registration', async () => { + request.mockResolvedValue(tokenResponse) + const apis = createUserApis({ client }) + + await apis.register({ + email: 'reader@example.com', + password: 'password-123', + code: '123456', + }) + + expect(request).toHaveBeenCalledWith('/auth/register', { + method: 'POST', + json: { + email: 'reader@example.com', + password: 'password-123', + code: '123456', + }, + }) + const options = request.mock.calls[0]?.[1] as { json?: object } | undefined + expect(Object.hasOwn(options?.json ?? {}, 'invite_code')).toBe(false) + }) + it('disables global unauthorized recovery for authentication requests', async () => { const recover = vi.fn(async () => true) const unregister = registerApiUnauthorizedRecovery(recover) diff --git a/frontend/src/entities/user/api.ts b/frontend/src/entities/user/api.ts index f89e7809..fc97df1e 100644 --- a/frontend/src/entities/user/api.ts +++ b/frontend/src/entities/user/api.ts @@ -96,6 +96,7 @@ export function createUserApis(options: CreateUserApisOptions = {}): UserApis { email: input.email, password: input.password, code: input.code, + ...(input.inviteCode ? { invite_code: input.inviteCode } : {}), ...(input.nickname ? { nickname: input.nickname } : {}), } return toAuthTokens( diff --git a/frontend/src/entities/user/index.ts b/frontend/src/entities/user/index.ts index f19e0556..57074641 100644 --- a/frontend/src/entities/user/index.ts +++ b/frontend/src/entities/user/index.ts @@ -24,6 +24,7 @@ export interface UserApis { password: string code: string nickname?: string + inviteCode?: string }): Promise /** 密码登录不带验证码;验证码只用于注册、免密登录与重设密码。 */ login(input: { email: string; password: string }): Promise diff --git a/frontend/src/features/account-panel/index.test.tsx b/frontend/src/features/account-panel/index.test.tsx index ddbe36ad..407280ed 100644 --- a/frontend/src/features/account-panel/index.test.tsx +++ b/frontend/src/features/account-panel/index.test.tsx @@ -94,32 +94,24 @@ describe('AccountPanel', () => { expect(screen.getByRole('dialog', { name: '登录 Windup' })).toBeTruthy() expect(screen.getByRole('heading', { name: '欢迎回来。' })).toBeTruthy() - expect(screen.queryByText('未注册的邮箱将在验证后自动创建账号。')).toBeNull() - expect(screen.getByText('内测期间仅支持已有账号登录。')).toBeTruthy() + expect(screen.getByText(/未注册的邮箱将在验证后自动创建账号/)).toBeTruthy() expect(screen.queryByRole('tab', { name: '注册' })).toBeNull() - expect(screen.queryByRole('button', { name: '创建账号' })).toBeNull() - expect(screen.getByRole('link', { name: 'GitHub Issues' }).getAttribute('href')).toBe( - 'https://github.com/1024XEngineer/Windup/issues', - ) + expect(screen.getByRole('button', { name: '创建账号' })).toBeTruthy() await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('邮箱'))) }) - it('opens a closed-registration URL as login and never starts signup', async () => { - const { apis } = renderPanel('/?account=register&returnTo=%2Fworkspace') + it('opens public registration without an invite code', async () => { + renderPanel('/?account=register&returnTo=%2Fworkspace') - expect(screen.getByRole('dialog', { name: '登录 Windup' })).toBeTruthy() - expect(screen.getByRole('heading', { name: '欢迎回来。' })).toBeTruthy() - expect(screen.queryByRole('button', { name: '创建账号' })).toBeNull() - expect(screen.queryByRole('button', { name: '继续' })).toBeNull() - expect(screen.getByRole('button', { name: '登录' })).toBeTruthy() - expect(screen.getByText(/内测期间暂不开放注册/)).toBeTruthy() - expect(apis.register).not.toHaveBeenCalled() + expect(screen.getByRole('dialog', { name: '创建 Windup 账号' })).toBeTruthy() + expect(screen.getByRole('heading', { name: '欢迎来到 Windup' })).toBeTruthy() + expect(screen.queryByLabelText('邀请码')).toBeNull() + expect(screen.getByText('注册即赠 300 积分。')).toBeTruthy() await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('邮箱'))) }) - // 内测关闭公开注册。重新开放时取消 skip,并恢复 AccountPanel 的 register 入口。 - it.skip('opens registration as a centered, progressive form', async () => { - renderPanel('/?account=register&returnTo=%2Fworkspace') + it('delegates invite-link format validation to the backend', async () => { + renderPanel('/?account=register&invite=i0o1&returnTo=%2Fworkspace') const dialog = screen.getByRole('dialog', { name: '创建 Windup 账号' }) expect(screen.getByRole('heading', { name: '欢迎来到 Windup' })).toBeTruthy() @@ -128,13 +120,13 @@ describe('AccountPanel', () => { expect(screen.queryByText('继续搭建,')).toBeNull() expect(screen.getByTestId('register-fields').className).toContain('auth-register-fields') expect(screen.queryByRole('tablist', { name: '账号操作' })).toBeNull() + expect(screen.queryByLabelText('邀请码')).toBeNull() expect(screen.getByLabelText('邮箱')).toBeTruthy() expect(screen.queryByLabelText('密码')).toBeNull() expect(screen.queryByLabelText('昵称(选填)')).toBeNull() expect(screen.queryByLabelText('验证码')).toBeNull() expect(screen.getByTestId('register-fields').querySelector('[aria-live]')).toBeNull() expect(screen.getByRole('button', { name: '继续' })).toBeTruthy() - expect(screen.getByRole('button', { name: '登录' })).toBeTruthy() await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('邮箱'))) }) @@ -154,20 +146,31 @@ describe('AccountPanel', () => { expect(screen.getByTestId('location').textContent).toBe('/?returnTo=%2Fprojects&source=header') }) + it('closes immediately when reduced motion is requested', async () => { + vi.useFakeTimers() + vi.stubGlobal('matchMedia', () => ({ matches: true })) + renderPanel('/?account=login') + + fireEvent.keyDown(document, { key: 'Escape' }) + await act(async () => vi.advanceTimersByTimeAsync(0)) + + expect(screen.queryByRole('dialog')).toBeNull() + }) + it('keeps keyboard focus inside the dialog in both tab directions', () => { renderPanel('/?account=login') const dialog = screen.getByRole('dialog', { name: '登录 Windup' }) const closeButton = screen.getByRole('button', { name: '关闭账号面板' }) - const requestAccess = screen.getByRole('link', { name: 'GitHub Issues' }) + const switchButton = screen.getByRole('button', { name: '创建账号' }) - requestAccess.focus() - fireEvent.keyDown(requestAccess, { key: 'Tab' }) + switchButton.focus() + fireEvent.keyDown(switchButton, { key: 'Tab' }) expect(document.activeElement).toBe(closeButton) closeButton.focus() fireEvent.keyDown(closeButton, { key: 'Tab', shiftKey: true }) - expect(document.activeElement).toBe(requestAccess) + expect(document.activeElement).toBe(switchButton) expect(dialog.contains(document.activeElement)).toBe(true) }) @@ -242,14 +245,18 @@ describe('AccountPanel', () => { expect(screen.getByRole('button', { name: '发送验证码' }).hasAttribute('disabled')).toBe(false) }) - it('validates a numeric six-character code before submitting', async () => { + it('delegates verification-code format validation to the backend', async () => { const { apis } = renderPanel() fillCodeLogin('reader@example.com', '12ab56') fireEvent.submit(screen.getByRole('button', { name: '登录' }).closest('form')!) - expect((await screen.findByRole('alert')).textContent).toContain('验证码需为 6 位数字') - expect(apis.loginByCode).not.toHaveBeenCalled() + await waitFor(() => + expect(apis.loginByCode).toHaveBeenCalledWith({ + email: 'reader@example.com', + code: '12ab56', + }), + ) }) it('shows backend errors inline, preserves input, and prevents repeat submits', async () => { @@ -364,20 +371,22 @@ describe('AccountPanel', () => { expect(apis.sendCode).not.toHaveBeenCalled() }) - it('does not expose a signup switch from the login panel', async () => { + it('switches between login and public registration', async () => { vi.useFakeTimers() renderPanel('/?account=login&returnTo=%2Fworkspace') - expect(screen.queryByRole('button', { name: '创建账号' })).toBeNull() - expect(screen.getByTestId('location').textContent).toBe('/?account=login&returnTo=%2Fworkspace') + fireEvent.click(screen.getByRole('button', { name: '创建账号' })) + expect(screen.getByRole('dialog', { name: '登录 Windup' })).toBeTruthy() await act(async () => vi.advanceTimersByTimeAsync(520)) - expect(screen.getByRole('dialog', { name: '登录 Windup' })).toBeTruthy() - expect(screen.getByTestId('location').textContent).toBe('/?account=login&returnTo=%2Fworkspace') + expect(screen.getByRole('dialog', { name: '创建 Windup 账号' })).toBeTruthy() + expect(screen.getByTestId('location').textContent).toBe( + '/?account=register&returnTo=%2Fworkspace', + ) }) - it.skip('preserves registration input when showing a password and returning a step', async () => { - renderPanel('/?account=register') + it('preserves registration input when showing a password and returning a step', async () => { + renderPanel('/?account=register&invite=AB23CD45') fireEvent.change(screen.getByLabelText('邮箱'), { target: { value: 'new@example.com' } }) fireEvent.submit(screen.getByRole('button', { name: '继续' }).closest('form')!) @@ -393,23 +402,10 @@ describe('AccountPanel', () => { expect(screen.getByTestId('auth-motion-stage').dataset.motionDirection).toBe('backward') }) - it.skip('switches account entry only after the current panel exits', async () => { - vi.useFakeTimers() - renderPanel('/?account=login&returnTo=%2Fworkspace') - - fireEvent.click(screen.getByRole('button', { name: '创建账号' })) - expect(screen.getByRole('dialog', { name: '登录 Windup' })).toBeTruthy() - expect(screen.getByTestId('location').textContent).toBe('/?account=login&returnTo=%2Fworkspace') - - await act(async () => vi.advanceTimersByTimeAsync(520)) - expect(screen.getByRole('dialog', { name: '创建 Windup 账号' })).toBeTruthy() - expect(screen.getByTestId('location').textContent).toBe( - '/?account=register&returnTo=%2Fworkspace', - ) - }) - - it.skip('validates each registration step and reuses the existing register API contract', async () => { - const { apis } = renderPanel('/?account=register&returnTo=%2Fworkspace') + it('submits the invite link code and shows backend expiry errors inline', async () => { + const { apis } = renderPanel('/?account=register&invite=ab23cd45&returnTo=%2Fworkspace') + apis.register.mockRejectedValue(new Error('邀请码已过期')) + expect(screen.queryByLabelText('邀请码')).toBeNull() fireEvent.change(screen.getByLabelText('邮箱'), { target: { value: 'new@example.com' } }) fireEvent.submit(screen.getByRole('button', { name: '继续' }).closest('form')!) @@ -452,9 +448,35 @@ describe('AccountPanel', () => { email: 'new@example.com', password: 'password-123', code: '123456', + inviteCode: 'AB23CD45', nickname: '新用户', }), ) + expect((await screen.findByRole('alert')).textContent).toContain('邀请码已过期') + }) + + it('submits public registration without an invite code', async () => { + const { apis } = renderPanel('/?account=register&returnTo=%2Fworkspace') + fireEvent.change(screen.getByLabelText('邮箱'), { target: { value: 'direct@example.com' } }) + fireEvent.submit(screen.getByRole('button', { name: '继续' }).closest('form')!) + + fireEvent.change(await screen.findByLabelText('密码'), { + target: { value: 'password-123' }, + }) + fireEvent.submit(screen.getByRole('button', { name: '继续' }).closest('form')!) + await screen.findByLabelText('昵称(选填)') + fireEvent.submit(screen.getByRole('button', { name: '继续' }).closest('form')!) + + fireEvent.change(await screen.findByLabelText('验证码'), { target: { value: '123456' } }) + fireEvent.submit(screen.getByRole('button', { name: '创建账号' }).closest('form')!) + + await waitFor(() => + expect(apis.register).toHaveBeenCalledWith({ + email: 'direct@example.com', + password: 'password-123', + code: '123456', + }), + ) }) }) diff --git a/frontend/src/features/account-panel/index.tsx b/frontend/src/features/account-panel/index.tsx index 118043ce..4c2d261f 100644 --- a/frontend/src/features/account-panel/index.tsx +++ b/frontend/src/features/account-panel/index.tsx @@ -37,7 +37,6 @@ type LoginMode = 'code' | 'password' type MotionDirection = 'forward' | 'backward' const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ -const CODE_PATTERN = /^\d{6}$/ const SUCCESS_NAVIGATION_DELAY_MS = 900 const AUTH_EXIT_DURATION_MS = 520 @@ -91,20 +90,11 @@ const loginMotionCopy = [ const REGISTER_STEP_COUNT = 4 const AUTH_ICON_PROPS = { weight: 'light' as const } const AUTH_FIELD_CLASS = 'auth-screen-field w-full outline-none disabled:cursor-not-allowed' -const ACCESS_REQUEST_URL = 'https://github.com/1024XEngineer/Windup/issues' function errorMessage(error: unknown): string { return error instanceof Error && error.message ? error.message : '操作失败,请稍后重试' } -function emailKey(email: string): string { - return email.trim().toLowerCase() -} - -function prefersReducedMotion(): boolean { - return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false -} - function KineticTitle({ id, text, emphasis }: { id: string; text: string; emphasis?: string }) { const emphasisStart = emphasis ? text.indexOf(emphasis) : -1 return ( @@ -198,16 +188,29 @@ function PasswordVisibilityButton({ visible, onClick }: { visible: boolean; onCl /** 查询参数驱动的认证入口,不创建独立登录页面。 */ export function AccountPanel() { const [searchParams] = useSearchParams() - const entry = searchParams.get('account') - if (entry !== 'login' && entry !== 'register') return null + const requestedEntry = searchParams.get('account') + if (requestedEntry !== 'login' && requestedEntry !== 'register') return null + + const inviteCode = searchParams.get('invite')?.trim().toUpperCase() ?? '' + const entry: AccountEntry = requestedEntry - // 内测期间关闭公开注册。重新开放时改回: - // return - return + return ( + + ) } /** 只有面板真正打开时才读取会话,关闭状态不把认证 Context 强加给应用外壳。 */ -function AccountPanelDialog({ entry }: { entry: AccountEntry }) { +function AccountPanelDialog({ + entry, + inviteCode, +}: { + entry: AccountEntry + inviteCode: string | null +}) { const [searchParams, setSearchParams] = useSearchParams() const navigate = useNavigate() const session = useAuthSession() @@ -251,7 +254,7 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { const normalizedEmail = email.trim() const cooldownSeconds = Math.max( 0, - Math.ceil(((cooldowns.get(emailKey(email)) ?? 0) - now) / 1_000), + Math.ceil(((cooldowns.get(email.trim().toLowerCase()) ?? 0) - now) / 1_000), ) const returnTarget = useMemo( @@ -289,7 +292,7 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { setCopyIndex(0) setCopyPhase('entering') if (!shouldShowMotionCopy) return - if (prefersReducedMotion()) return + if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return copyRestTimerRef.current = window.setTimeout(() => setCopyPhase('resting'), 760) const timer = window.setInterval(() => { setCopyPhase('exiting') @@ -325,7 +328,9 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { setCopyPhase('exiting') setIsExiting(true) - const duration = prefersReducedMotion() ? 0 : AUTH_EXIT_DURATION_MS + const duration = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches + ? 0 + : AUTH_EXIT_DURATION_MS exitTimerRef.current = window.setTimeout(action, duration) } @@ -333,6 +338,7 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { leaveWithAnimation(() => { const next = new URLSearchParams(searchParams) next.delete('account') + next.delete('invite') setSearchParams(next, { replace: true }) }) } @@ -347,7 +353,6 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { window.requestAnimationFrame(() => emailInputRef.current?.focus()) } - /* function switchEntry(nextEntry: AccountEntry) { leaveWithAnimation(() => { const next = new URLSearchParams(searchParams) @@ -355,7 +360,6 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { setSearchParams(next, { replace: true }) }) } - */ async function sendCode(): Promise { if (isSendingCode || cooldownSeconds > 0) return false @@ -374,7 +378,9 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { }) const sentAt = Date.now() setNow(sentAt) - setCooldowns((previous) => new Map(previous).set(emailKey(normalizedEmail), sentAt + 60_000)) + setCooldowns((previous) => + new Map(previous).set(normalizedEmail.toLowerCase(), sentAt + 60_000), + ) setSuccess('验证码已发送,请在 5 分钟内使用。') return true } catch (sendError) { @@ -390,7 +396,6 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { if (mode === 'password' && (password.length < 8 || password.length > 128)) { return '密码需为 8–128 位' } - if (mode === 'code' && !CODE_PATTERN.test(code)) return '验证码需为 6 位数字' return null } @@ -398,7 +403,6 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { if (!EMAIL_PATTERN.test(normalizedEmail)) return '请输入有效邮箱地址' if (password.length < 8 || password.length > 128) return '密码需为 8–128 位' if (nickname.length > 50) return '昵称不能超过 50 个字符' - if (!CODE_PATTERN.test(code)) return '验证码需为 6 位数字' return null } @@ -458,13 +462,12 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { email: normalizedEmail, password, code, + ...(inviteCode ? { inviteCode } : {}), ...(nickname.trim() ? { nickname: nickname.trim() } : {}), }) successMessage = '账号已创建,正在继续。' } else if (mode === 'code') { await session.loginByCode({ email: normalizedEmail, code }) - // 内测不自动建号。重新开放注册时改回: - // successMessage = '登录成功。如果这是你首次使用该邮箱,我们已为你创建账号。' successMessage = '登录成功,正在继续。' } else { await session.login({ email: normalizedEmail, password }) @@ -757,8 +760,7 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { {!isRegister && mode === 'code' && (

- {/* 未注册的邮箱将在验证后自动创建账号。 */} - 内测期间仅支持已有账号登录。 + 未注册的邮箱将在验证后自动创建账号,并获得 300 积分。

)}
@@ -784,21 +786,17 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { - {/*

{isRegister ? '已有账号?' : '还没有账号?'}{' '}

- */} -

- 内测期间暂不开放注册。如需开通,请通过{' '} - - GitHub Issues - {' '} - 联系团队申请。 -

+ {isRegister && ( +

+ {inviteCode ? '邀请链接已带入,注册后共得 500 积分。' : '注册即赠 300 积分。'} +

+ )}
diff --git a/frontend/src/features/auth-session/index.test.tsx b/frontend/src/features/auth-session/index.test.tsx index 8f1dca78..38c212dd 100644 --- a/frontend/src/features/auth-session/index.test.tsx +++ b/frontend/src/features/auth-session/index.test.tsx @@ -146,6 +146,7 @@ describe('AuthSessionProvider', () => { email: 'reader@example.com', password: 'password-123', code: '123456', + inviteCode: 'AB23CD45', }) } else if (method === 'login') { await session().login({ diff --git a/frontend/src/features/quota/index.test.ts b/frontend/src/features/quota/index.test.ts index c7c29e72..ad03c02b 100644 --- a/frontend/src/features/quota/index.test.ts +++ b/frontend/src/features/quota/index.test.ts @@ -23,8 +23,7 @@ const account: CreditAccount = { } function createQuotaApis(): QuotaApis & { - getBalance: ReturnType - listTransactions: ReturnType + [K in keyof QuotaApis]: ReturnType } { return { getBalance: vi.fn(async () => account), @@ -45,6 +44,20 @@ function createQuotaApis(): QuotaApis & { page, pageSize, })), + getInviteCode: vi.fn(async () => ({ + code: 'AB23CD45', + usedCount: 0, + expiresAt: '2026-09-16T01:02:03Z', + createdAt: '2026-08-17T01:02:03Z', + updatedAt: '2026-08-17T01:02:03Z', + })), + generateInviteCode: vi.fn(async () => ({ + code: 'XY89KL23', + usedCount: 0, + expiresAt: '2026-09-16T01:02:03Z', + createdAt: '2026-08-17T01:02:03Z', + updatedAt: '2026-08-17T01:02:03Z', + })), } } diff --git a/frontend/src/pages/account/account.css b/frontend/src/pages/account/account.css index 9ff5bd87..5421d93e 100644 --- a/frontend/src/pages/account/account.css +++ b/frontend/src/pages/account/account.css @@ -62,6 +62,23 @@ background: var(--color-app-line-strong); } +.invite-character-artwork { + filter: saturate(0.74) contrast(0.96) brightness(0.98); + opacity: 0.84; + transform: scale(1.025) translateY(2px); + transform-origin: center bottom; + transition: + filter 520ms cubic-bezier(0.16, 1, 0.3, 1), + opacity 420ms cubic-bezier(0.16, 1, 0.3, 1), + transform 560ms cubic-bezier(0.16, 1, 0.3, 1); +} + +@media (prefers-reduced-motion: reduce) { + .invite-character-artwork { + transition: none; + } +} + .account-badge-button { transform: rotate(-3deg); transform-origin: 50% 10%; diff --git a/frontend/src/pages/account/index.test.tsx b/frontend/src/pages/account/index.test.tsx index 286423b0..1c155f6a 100644 --- a/frontend/src/pages/account/index.test.tsx +++ b/frontend/src/pages/account/index.test.tsx @@ -51,13 +51,13 @@ function LocationProbe() { ) } -function renderAccount(apis = createApis()) { +function renderAccount(apis = createApis(), entry = '/account') { window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token') return { apis, ...render( - + @@ -189,6 +189,65 @@ describe('AccountPage', () => { expect(screen.getByText('-12')).toBeTruthy() }) + it('在账号中心直接管理邀请奖励', async () => { + vi.spyOn(quotaApis, 'getBalance').mockResolvedValue({ + id: '11', + userId: '7', + balance: 90, + frozen: 10, + totalEarned: 150, + totalSpent: 50, + createdAt: '2026-08-12T01:02:03Z', + updatedAt: '2026-08-17T01:02:03Z', + }) + vi.spyOn(quotaApis, 'listTransactions').mockResolvedValue({ + items: [], + total: 0, + page: 1, + pageSize: 20, + }) + vi.spyOn(quotaApis, 'getInviteCode').mockResolvedValue({ + code: 'AB23CD45', + usedCount: 2, + expiresAt: '2026-09-16T01:02:03Z', + createdAt: '2026-08-12T01:02:03Z', + updatedAt: '2026-08-17T01:02:03Z', + }) + + renderAccount() + fireEvent.click(await screen.findByRole('button', { name: '邀请奖励' })) + + expect(await screen.findByRole('heading', { name: '邀请奖励' })).toBeTruthy() + expect(await screen.findByText('AB23CD45')).toBeTruthy() + }) + + it('通过账号中心链接直接打开邀请奖励', async () => { + vi.spyOn(quotaApis, 'getBalance').mockResolvedValue({ + id: '11', + userId: '7', + balance: 90, + frozen: 10, + totalEarned: 150, + totalSpent: 50, + createdAt: '2026-08-12T01:02:03Z', + updatedAt: '2026-08-17T01:02:03Z', + }) + vi.spyOn(quotaApis, 'getInviteCode').mockResolvedValue({ + code: 'AB23CD45', + usedCount: 2, + expiresAt: '2026-09-16T01:02:03Z', + createdAt: '2026-08-12T01:02:03Z', + updatedAt: '2026-08-17T01:02:03Z', + }) + + renderAccount(createApis(), '/account?section=invite') + + expect(await screen.findByRole('heading', { name: '邀请奖励' })).toBeTruthy() + expect(screen.getByRole('button', { name: '邀请奖励' }).getAttribute('aria-current')).toBe( + 'page', + ) + }) + it('reports a profile refresh failure without claiming the data is synchronized', async () => { const apis = createApis() apis.me.mockRejectedValue(new Error('资料读取失败')) diff --git a/frontend/src/pages/account/index.tsx b/frontend/src/pages/account/index.tsx index 65e8676f..69fdfb16 100644 --- a/frontend/src/pages/account/index.tsx +++ b/frontend/src/pages/account/index.tsx @@ -1,4 +1,5 @@ import { useEffect, useId, useReducer, useRef, useState, type FormEvent } from 'react' +import { useSearchParams } from 'react-router' import accountBadgeArtwork from '@/assets/account/illustrations/account-badge.webp' import type { User } from '@/entities' @@ -10,6 +11,7 @@ import { useQuotaTransactions, } from '@/features/quota' import { Pagination } from '@/shared/ui' +import { InviteSection } from '@/pages/invite' import './account.css' import { createProfileState, initialSecurityState, profileReducer, securityReducer } from './state' @@ -143,6 +145,8 @@ function QuotaSection() { /** 账号页以 /auth/me 为事实来源;会话层负责把刷新和编辑结果同步给 Header。 */ export function AccountPage() { + const [searchParams] = useSearchParams() + const requestedSection = searchParams.get('section') const session = useAuthSession() const { changePassword: changeSessionPassword, @@ -158,7 +162,9 @@ export function AccountPage() { createProfileState, ) const [security, dispatchSecurity] = useReducer(securityReducer, initialSecurityState) - const [activeSection, setActiveSection] = useState<'profile' | 'security' | 'quota'>('profile') + const [activeSection, setActiveSection] = useState<'profile' | 'security' | 'quota' | 'invite'>( + requestedSection === 'invite' ? 'invite' : 'profile', + ) const nicknameId = useId() const oldPasswordId = useId() const newPasswordId = useId() @@ -181,6 +187,10 @@ export function AccountPage() { } }, [refreshCurrentUser]) + useEffect(() => { + if (requestedSection === 'invite') selectSection('invite') + }, [requestedSection]) + async function saveNickname(event: FormEvent) { event.preventDefault() if (profile.isSaving) return @@ -230,7 +240,7 @@ export function AccountPage() { void logout().catch(() => undefined) } - function selectSection(section: 'profile' | 'security' | 'quota') { + function selectSection(section: 'profile' | 'security' | 'quota' | 'invite') { setActiveSection(section) dispatchProfile({ type: 'sectionChanged' }) dispatchSecurity({ type: 'sectionChanged' }) @@ -292,6 +302,7 @@ export function AccountPage() { ['profile', '个人资料'], ['security', '登录安全'], ['quota', '积分账户'], + ['invite', '邀请奖励'], ] as const ).map(([section, label]) => ( - ) : ( + ) : activeSection === 'quota' ? ( + ) : ( + )} diff --git a/frontend/src/pages/invite/index.test.tsx b/frontend/src/pages/invite/index.test.tsx new file mode 100644 index 00000000..34e58d34 --- /dev/null +++ b/frontend/src/pages/invite/index.test.tsx @@ -0,0 +1,182 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter } from 'react-router' + +import type { QuotaApis } from '@/entities' +import { InviteSection } from './index' + +function createApis(): QuotaApis & Record> { + return { + getBalance: vi.fn(async () => ({ + id: '11', + userId: '7', + balance: 100, + frozen: 0, + totalEarned: 100, + totalSpent: 0, + createdAt: '2026-08-12T01:02:03Z', + updatedAt: '2026-08-17T01:02:03Z', + })), + listTransactions: vi.fn(async () => ({ items: [], total: 0, page: 1, pageSize: 20 })), + getInviteCode: vi.fn(async () => ({ + code: 'AB23CD45', + usedCount: 2, + expiresAt: '2026-09-16T03:00:00Z', + createdAt: '2026-08-12T01:02:03Z', + updatedAt: '2026-08-17T01:02:03Z', + })), + generateInviteCode: vi.fn(async () => ({ + code: 'XY89KL23', + usedCount: 2, + expiresAt: '2026-09-16T03:00:00Z', + createdAt: '2026-08-12T01:02:03Z', + updatedAt: '2026-08-17T03:00:00Z', + })), + } +} + +function renderInvite(apis = createApis()) { + return { + apis, + ...render( + + + , + ), + } +} + +beforeEach(() => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn(async () => undefined) }, + }) +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('InviteSection', () => { + it('把分享邀请链接和人物放进同一个主模块', async () => { + renderInvite() + + const feature = await screen.findByTestId('invite-feature') + const character = screen.getByTestId('invite-character') + expect(feature.contains(character)).toBe(true) + expect(feature.contains(screen.getByRole('button', { name: '复制邀请链接' }))).toBe(true) + expect(feature.className).not.toContain('bg-app-accent-soft') + expect(character.className).toContain('invite-character-artwork') + }) + + it('展示最终奖励规则、当前邀请码使用次数和有效期', async () => { + renderInvite() + + expect(await screen.findByText('AB23CD45')).toBeTruthy() + expect(screen.getByText('2')).toBeTruthy() + expect(screen.getByText('100')).toBeTruthy() + expect(screen.getByText('当前码注册')).toBeTruthy() + expect(screen.getByText(/好友注册共得 500 积分/)).toBeTruthy() + expect(screen.getByText(/每日前 3 位各得 200 积分/)).toBeTruthy() + expect(screen.getByText('有效至 2026年9月16日')).toBeTruthy() + }) + + it('邀请码有效期异常时使用安全提示', async () => { + const apis = createApis() + apis.getInviteCode.mockResolvedValue({ + code: 'AB23CD45', + usedCount: 2, + expiresAt: 'not-a-date', + createdAt: '2026-08-12T01:02:03Z', + updatedAt: '2026-08-17T01:02:03Z', + }) + + renderInvite(apis) + + expect(await screen.findByText('有效期未知')).toBeTruthy() + }) + + it('复制邀请码与注册链接', async () => { + renderInvite() + + expect(await screen.findByText('AB23CD45')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: '复制邀请码' })) + await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith('AB23CD45')) + expect(await screen.findByText('邀请码已复制')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: '复制邀请链接' })) + const expectedLink = `${window.location.origin}/?account=register&invite=AB23CD45&returnTo=%2Fworkspace` + await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith(expectedLink)) + expect(await screen.findByText('邀请链接已复制')).toBeTruthy() + }) + + it('保持邀请码稳定,不提供轮换入口', async () => { + renderInvite() + + expect(await screen.findByText('AB23CD45')).toBeTruthy() + expect(screen.queryByRole('button', { name: /更换邀请码|确认更换/ })).toBeNull() + }) + + it('不提供登录后的补填邀请码入口', async () => { + renderInvite() + expect(await screen.findByText('AB23CD45')).toBeTruthy() + + expect(screen.queryByLabelText('补填邀请码')).toBeNull() + expect(screen.queryByRole('button', { name: '确认补填' })).toBeNull() + }) + + it('复制失败时给出可恢复提示', async () => { + const clipboardError = new Error('clipboard unavailable') + vi.mocked(navigator.clipboard.writeText).mockRejectedValue(clipboardError) + renderInvite() + expect(await screen.findByText('AB23CD45')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: '复制邀请链接' })) + + expect((await screen.findByRole('alert')).textContent).toContain('复制失败,请重试') + }) + + it('复制失败时优先展示当前操作错误', async () => { + const apis = createApis() + apis.getBalance.mockRejectedValue(new Error('积分账户不可用')) + vi.mocked(navigator.clipboard.writeText).mockRejectedValue(new Error('clipboard unavailable')) + renderInvite(apis) + expect(await screen.findByText('AB23CD45')).toBeTruthy() + expect(await screen.findByText('积分账户不可用')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: '复制邀请链接' })) + + await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('复制失败,请重试')) + expect(screen.queryByText('积分账户不可用')).toBeNull() + }) + + it('非标准接口错误使用通用提示', async () => { + const apis = createApis() + apis.getInviteCode.mockRejectedValue('invite unavailable') + renderInvite(apis) + + expect((await screen.findByRole('alert')).textContent).toContain('操作失败,请稍后重试') + }) + + it('邀请信息加载失败后允许原地重试', async () => { + const apis = createApis() + apis.getInviteCode + .mockRejectedValueOnce(new Error('邀请信息暂时不可用')) + .mockResolvedValueOnce({ + code: 'AB23CD45', + usedCount: 2, + expiresAt: '2026-09-16T03:00:00Z', + createdAt: '2026-08-12T01:02:03Z', + updatedAt: '2026-08-17T01:02:03Z', + }) + renderInvite(apis) + + expect((await screen.findByRole('alert')).textContent).toContain('邀请信息暂时不可用') + fireEvent.click(screen.getByRole('button', { name: '重新加载邀请信息' })) + + expect(await screen.findByText('AB23CD45')).toBeTruthy() + }) +}) diff --git a/frontend/src/pages/invite/index.tsx b/frontend/src/pages/invite/index.tsx new file mode 100644 index 00000000..5f2020cd --- /dev/null +++ b/frontend/src/pages/invite/index.tsx @@ -0,0 +1,203 @@ +import { useEffect, useMemo, useState } from 'react' +import { Copy } from '@phosphor-icons/react' + +import inviteCharacterArtwork from '@/assets/account/illustrations/invite-character.webp' +import { quotaApis as defaultQuotaApis } from '@/entities' +import type { InviteCode, QuotaApis } from '@/entities' +import { useQuotaBalance } from '@/features/quota' + +function errorMessage(error: unknown): string { + return error instanceof Error && error.message ? error.message : '操作失败,请稍后重试' +} + +function formatInviteExpiry(value: string): string { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '有效期未知' + return `有效至 ${new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'UTC', + }).format(date)}` +} + +function invitationUrl(code: string): string { + const query = new URLSearchParams({ + account: 'register', + invite: code, + returnTo: '/workspace', + }) + return `${window.location.origin}/?${query}` +} + +export function InviteSection({ apis = defaultQuotaApis }: { apis?: QuotaApis }) { + const balance = useQuotaBalance(true, apis) + const [invite, setInvite] = useState(null) + const [isInviteLoading, setIsInviteLoading] = useState(true) + const [inviteError, setInviteError] = useState(null) + const [inviteRequestVersion, setInviteRequestVersion] = useState(0) + const [notice, setNotice] = useState(null) + const [actionError, setActionError] = useState(null) + + useEffect(() => { + let active = true + setIsInviteLoading(true) + setInviteError(null) + void apis.getInviteCode().then( + (view) => { + if (!active) return + setInvite(view) + setInviteError(null) + setIsInviteLoading(false) + }, + (error: unknown) => { + if (!active) return + setInviteError(errorMessage(error)) + setIsInviteLoading(false) + }, + ) + return () => { + active = false + } + }, [apis, inviteRequestVersion]) + + const shareLink = useMemo(() => (invite ? invitationUrl(invite.code) : ''), [invite]) + + async function copyValue(value: string, successMessage: string) { + setActionError(null) + setNotice(null) + try { + await navigator.clipboard.writeText(value) + setNotice(successMessage) + } catch { + setActionError('复制失败,请重试') + } + } + + return ( +
+
+
+

邀请奖励

+

+ 好友注册共得 500 积分;你每日前 3 位各得 200 积分,之后好友仍可获得奖励。 +

+
+ +
+
+
当前码注册
+
+ {invite?.usedCount ?? '—'} +
+
+
+
当前积分
+
+ {balance.status === 'ready' ? balance.account.balance.toLocaleString('zh-CN') : '—'} +
+
+
+
+ +
+
+

+ 分享专属邀请链接 +

+

+ 邀请码会随链接一起传递,朋友不需要在注册时手工填写。 +

+ +
+ {isInviteLoading ? ( +

+ 正在准备你的邀请链接… +

+ ) : inviteError ? ( +
+

+ {inviteError} +

+ +
+ ) : invite ? ( +
+
+

我的邀请码

+
+ + {invite.code} + + +
+
+ +
+ + + {formatInviteExpiry(invite.expiresAt)} + +
+
+ ) : null} +
+
+ +
+ +
+
+ +
+ {actionError && ( +

+ {actionError} +

+ )} + {notice && ( +

+ {notice} +

+ )} + {balance.status === 'error' && !actionError && ( +

+ {balance.error} +

+ )} +
+
+ ) +}