From 05898f9441f995591c58f9beb6071646344af3cf Mon Sep 17 00:00:00 2001 From: xiaomai Date: Sun, 3 May 2026 10:27:45 +0800 Subject: [PATCH] feat(auth): add user referral system with invite codes Generate unique referral codes for users Allow new users to register with a referral code Display referral stats and invite link in user profile --- DESIGN.md | 24 ++++ backend/db/schema.sql | 17 ++- backend/src/auth.ts | 154 ++++++++++++++++++++++++- backend/src/server.ts | 12 ++ frontend/src/icons.ts | 2 + frontend/src/services/api.ts | 8 ++ frontend/src/styles/main.css | 71 ++++++++++++ frontend/src/views/RegisterView.vue | 19 ++- frontend/src/views/UserProfileView.vue | 95 ++++++++++++++- system-wordings.ts | 34 +++++- 10 files changed, 422 insertions(+), 14 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index d54b905..90c2538 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -121,6 +121,29 @@ - 更新显示名后,API 仍只返回当前用户必要字段,不返回 session、token/hash、内部审计或调试数据。 - 显示名用于编辑署名、讨论和 Life 内容作者展示。 +## Referral + +- Referral 是账号功能,用于让已注册用户邀请新用户加入 Pokopia Wiki。 +- 每个用户都有一个稳定的 Referral Code: + - 由系统生成。 + - 全局唯一。 + - 只包含大写英文字母和数字。 + - 现有用户在首次读取 Referral 信息或重新注册未验证账号时自动补齐。 +- 登录用户可在 `/profile` 查看自己的 Referral Code、邀请链接和有效邀请数量。 +- 邀请链接使用前端注册页路径:`/register?ref=CODE`。 +- 注册页支持: + - 从 `ref` query 自动填入 Referral Code。 + - 用户手动输入 Referral Code。 + - Referral Code 可为空。 +- 注册提交时后端校验 Referral Code: + - 无效 Referral Code 拒绝注册并返回本地化错误。 + - 用户不能使用自己的 Referral Code;如邮箱已存在且该账号已有 Referral Code,注册时不能将自己设为邀请人。 + - 已存在未验证账号重新注册时,不覆盖已有邀请关系。 +- Referral 只有在被邀请用户完成邮箱验证后才计入有效邀请数量。 +- Referral 不改变现有邮箱验证要求;用户仍必须验证邮箱后才能登录和编辑。 +- 当前版本不提供积分奖励、排行榜、邀请邮件发送、邀请制注册限制、后台统计或公开邀请人资料页。 +- Referral API 对外只返回当前用户自己的 Referral 摘要,不返回被邀请用户邮箱、token/hash、内部审计字段或被邀请用户明细。 + ## Community 编辑与审计 - 已验证用户可以通过前台或管理入口编辑 Wiki 内容。 @@ -600,6 +623,7 @@ API 暴露边界: - `POST /api/auth/reset-password` - `GET /api/auth/me` - `PATCH /api/auth/me`:更新当前用户显示名;需要登录;只接收并返回当前用户必要字段。 +- `GET /api/auth/referral`:读取当前用户 Referral 摘要;需要登录;返回 `referral`,其中只包含 `code`、`url`、`verifiedReferralCount`。 - `POST /api/auth/logout` 已验证用户编辑 API: diff --git a/backend/db/schema.sql b/backend/db/schema.sql index ddc6501..be6f3ad 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -78,13 +78,28 @@ CREATE TABLE IF NOT EXISTS users ( email text NOT NULL UNIQUE, display_name text NOT NULL, password_hash text NOT NULL, + referral_code text, + referred_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, email_verified_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CHECK (email = lower(email)), - CHECK (length(display_name) BETWEEN 1 AND 40) + CHECK (length(display_name) BETWEEN 1 AND 40), + CHECK (referral_code IS NULL OR referral_code ~ '^[A-Z0-9]{8,16}$') ); +ALTER TABLE users ADD COLUMN IF NOT EXISTS referral_code text; +ALTER TABLE users ADD COLUMN IF NOT EXISTS referred_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_referral_code_check; +ALTER TABLE users ADD CONSTRAINT users_referral_code_check CHECK (referral_code IS NULL OR referral_code ~ '^[A-Z0-9]{8,16}$'); + +CREATE UNIQUE INDEX IF NOT EXISTS users_referral_code_idx + ON users(referral_code) + WHERE referral_code IS NOT NULL; + +CREATE INDEX IF NOT EXISTS users_referred_by_user_id_idx + ON users(referred_by_user_id); + CREATE TABLE IF NOT EXISTS system_wording_keys ( key text PRIMARY KEY, module text NOT NULL, diff --git a/backend/src/auth.ts b/backend/src/auth.ts index c57da56..33c689b 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -11,6 +11,9 @@ const passwordResetTokenHours = 1; const rememberedSessionDays = 30; const sessionOnlySessionDays = 1; const defaultLocale = 'en'; +const referralCodeLength = 8; +const referralAlphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +const referralCodePattern = /^[A-Z0-9]{8,16}$/; type DbClient = PoolClient; @@ -27,6 +30,15 @@ type LoginUserRow = UserRow & { password_hash: string; }; +type RegistrationUserRow = UserRow & { + referral_code: string | null; + referred_by_user_id: number | null; +}; + +type ReferralCodeRow = QueryResultRow & { + referral_code: string | null; +}; + type AuthMessageKey = | 'emailRequired' | 'invalidEmail' @@ -42,6 +54,7 @@ type AuthMessageKey = | 'invalidCredentials' | 'verifyEmailFirst' | 'invalidResetToken' + | 'invalidReferralCode' | 'emailSubject' | 'emailHtml' | 'emailText' @@ -58,6 +71,12 @@ export type AuthUser = { emailVerified: boolean; }; +export type ReferralSummary = { + code: string; + url: string; + verifiedReferralCount: number; +}; + function statusError(message: string, statusCode: number): StatusError { const error = new Error(message) as StatusError; error.statusCode = statusCode; @@ -80,6 +99,7 @@ function authMessage(locale: string, key: AuthMessageKey, params: Record { + if (value === undefined || value === null) { + return null; + } + + if (typeof value !== 'string') { + throw statusError(await authMessage(locale, 'invalidReferralCode'), 400); + } + + const referralCode = value.trim().toUpperCase(); + if (!referralCode) { + return null; + } + + if (!referralCodePattern.test(referralCode)) { + throw statusError(await authMessage(locale, 'invalidReferralCode'), 400); + } + + return referralCode; +} + function toPublicUser(user: UserRow): AuthUser { return { id: user.id, @@ -196,6 +237,80 @@ function hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); } +function createReferralCode(): string { + const bytes = randomBytes(referralCodeLength); + return [...bytes].map((byte) => referralAlphabet[byte % referralAlphabet.length]).join(''); +} + +function isUniqueViolation(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && (error as { code?: unknown }).code === '23505'; +} + +async function ensureReferralCode(client: DbClient, userId: number): Promise { + const existing = await clientQueryOne(client, 'SELECT referral_code FROM users WHERE id = $1', [userId]); + if (existing?.referral_code) { + return existing.referral_code; + } + + for (let attempt = 0; attempt < 10; attempt += 1) { + const referralCode = createReferralCode(); + try { + const updated = await clientQueryOne( + client, + ` + UPDATE users + SET referral_code = $1, updated_at = now() + WHERE id = $2 + AND referral_code IS NULL + RETURNING referral_code + `, + [referralCode, userId] + ); + + if (updated?.referral_code) { + return updated.referral_code; + } + + const current = await clientQueryOne(client, 'SELECT referral_code FROM users WHERE id = $1', [ + userId + ]); + if (current?.referral_code) { + return current.referral_code; + } + } catch (error) { + if (!isUniqueViolation(error)) { + throw error; + } + } + } + + throw new Error('Failed to assign referral code'); +} + +async function referralUserId( + client: DbClient, + referralCode: string, + currentUserId: number | null, + locale: string +): Promise { + const row = await clientQueryOne(client, 'SELECT id FROM users WHERE referral_code = $1', [ + referralCode + ]); + + if (!row || (currentUserId !== null && row.id === currentUserId)) { + throw statusError(await authMessage(locale, 'invalidReferralCode'), 400); + } + + return row.id; +} + +function buildReferralUrl(code: string): string { + const origin = process.env.APP_ORIGIN ?? process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000'; + const url = new URL('/register', origin); + url.searchParams.set('ref', code); + return url.toString(); +} + function getEmailConfig() { const apiKey = process.env.RESEND_API_KEY; const from = process.env.EMAIL_FROM; @@ -280,14 +395,15 @@ export async function registerUser(payload: Record, locale = de const email = await cleanEmail(payload.email, locale); const displayName = await cleanDisplayName(payload.displayName, locale); const password = await cleanPassword(payload.password, locale); + const referralCode = await cleanReferralCode(payload.referralCode, locale); const passwordHash = await hashPassword(password); const verificationToken = createPlainToken(); const verificationTokenHash = hashToken(verificationToken); await withTransaction(async (client) => { - const existingUser = await clientQueryOne( + const existingUser = await clientQueryOne( client, - 'SELECT id, email, display_name, email_verified_at FROM users WHERE email = $1', + 'SELECT id, email, display_name, referral_code, referred_by_user_id, email_verified_at FROM users WHERE email = $1', [email] ); @@ -295,23 +411,24 @@ export async function registerUser(payload: Record, locale = de throw statusError(await authMessage(locale, 'emailAlreadyRegistered'), 409); } + const referrerUserId = referralCode ? await referralUserId(client, referralCode, existingUser?.id ?? null, locale) : null; const user = existingUser - ? await clientQueryOne( + ? await clientQueryOne( client, ` UPDATE users SET display_name = $1, password_hash = $2, updated_at = now() WHERE id = $3 - RETURNING id, email, display_name, email_verified_at + RETURNING id, email, display_name, referral_code, referred_by_user_id, email_verified_at `, [displayName, passwordHash, existingUser.id] ) - : await clientQueryOne( + : await clientQueryOne( client, ` INSERT INTO users (email, display_name, password_hash) VALUES ($1, $2, $3) - RETURNING id, email, display_name, email_verified_at + RETURNING id, email, display_name, referral_code, referred_by_user_id, email_verified_at `, [email, displayName, passwordHash] ); @@ -320,6 +437,14 @@ export async function registerUser(payload: Record, locale = de throw new Error('Failed to save user'); } + await ensureReferralCode(client, user.id); + if (referrerUserId && user.referred_by_user_id === null) { + await client.query('UPDATE users SET referred_by_user_id = $1, updated_at = now() WHERE id = $2', [ + referrerUserId, + user.id + ]); + } + await client.query('DELETE FROM email_verification_tokens WHERE user_id = $1 AND used_at IS NULL', [user.id]); await client.query( ` @@ -530,6 +655,23 @@ export async function updateCurrentUser( return toPublicUser(user); } +export async function getReferralSummary(userId: number): Promise { + return withTransaction(async (client) => { + const code = await ensureReferralCode(client, userId); + const countRow = await clientQueryOne( + client, + 'SELECT COUNT(*)::text AS count FROM users WHERE referred_by_user_id = $1 AND email_verified_at IS NOT NULL', + [userId] + ); + + return { + code, + url: buildReferralUrl(code), + verifiedReferralCount: Number(countRow?.count ?? 0) + }; + }); +} + export async function logoutSession(token: string): Promise { if (token.length < 32) { return; diff --git a/backend/src/server.ts b/backend/src/server.ts index 954ecd2..c4bc17d 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -5,6 +5,7 @@ import Fastify from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { mkdir } from 'node:fs/promises'; import { + getReferralSummary, getUserBySessionToken, loginUser, logoutSession, @@ -239,6 +240,17 @@ app.patch('/api/auth/me', async (request, reply) => { return { user: await updateCurrentUser(user.id, payload, requestLocale(request)) }; }); +app.get('/api/auth/referral', async (request, reply) => { + const token = getBearerToken(request.headers.authorization); + const user = token ? await getUserBySessionToken(token) : null; + + if (!user) { + return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') }); + } + + return { referral: await getReferralSummary(user.id) }; +}); + app.post('/api/auth/logout', async (request, reply) => { const token = getBearerToken(request.headers.authorization); if (token) { diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index 304ac96..5d0cbde 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -10,6 +10,7 @@ export const iconChecklist: AppIcon = 'mdi:checkbox-marked-outline'; export const iconChevronDown: AppIcon = 'mdi:chevron-down'; export const iconClose: AppIcon = 'mdi:close'; export const iconComment: AppIcon = 'mdi:comment-outline'; +export const iconCopy: AppIcon = 'mdi:content-copy'; export const iconDelete: AppIcon = 'mdi:trash-can-outline'; export const iconDish: AppIcon = 'mdi:silverware-fork-knife'; export const iconDragHandle: AppIcon = 'mdi:drag'; @@ -32,6 +33,7 @@ export const iconNoRecipe: AppIcon = 'mdi:file-document-remove-outline'; export const iconPokemon: AppIcon = 'mdi:pokeball'; export const iconProfile: AppIcon = 'mdi:account-circle-outline'; export const iconRecipe: AppIcon = 'mdi:book-open-page-variant-outline'; +export const iconReferral: AppIcon = 'mdi:account-multiple-plus-outline'; export const iconRegister: AppIcon = 'mdi:account-plus-outline'; export const iconReply: AppIcon = 'mdi:reply-outline'; export const iconReactionFun: AppIcon = 'mdi:party-popper'; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index d96d4f4..af747f2 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -316,6 +316,12 @@ export interface AuthUser { emailVerified: boolean; } +export interface ReferralSummary { + code: string; + url: string; + verifiedReferralCount: number; +} + export interface UserProfilePayload { displayName: string; } @@ -328,6 +334,7 @@ export interface LoginPayload { export interface RegisterPayload extends LoginPayload { displayName: string; + referralCode?: string; } export interface AuthResponse { @@ -637,6 +644,7 @@ export const api = { sendJson<{ message: string }>('/api/auth/reset-password', 'POST', payload), me: () => getJson<{ user: AuthUser }>('/api/auth/me'), updateMe: (payload: UserProfilePayload) => sendJson<{ user: AuthUser }>('/api/auth/me', 'PATCH', payload), + referral: () => getJson<{ referral: ReferralSummary }>('/api/auth/referral'), logout: () => postEmpty('/api/auth/logout'), options: () => getJson('/api/options'), dailyChecklist: () => getJson('/api/daily-checklist'), diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index d3f2b9c..4844c57 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -4220,6 +4220,12 @@ button:disabled, background: color-mix(in srgb, var(--danger) 10%, var(--surface)); } +.auth-field-note { + color: var(--muted); + font-size: 13px; + font-weight: 750; +} + .profile-page { display: grid; gap: 18px; @@ -4247,6 +4253,10 @@ button:disabled, align-content: start; } +.profile-card--referral { + grid-column: 2; +} + .profile-identity { display: grid; grid-template-columns: auto minmax(0, 1fr); @@ -4317,6 +4327,55 @@ button:disabled, cursor: default; } +.profile-referral { + display: grid; + gap: 14px; +} + +.profile-referral__metric { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + min-height: 58px; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: var(--radius-card); + background: var(--surface-soft); +} + +.profile-referral__metric span { + color: var(--muted); + font-weight: 850; +} + +.profile-referral__metric strong { + color: var(--pokemon-blue-deep); + font-family: var(--font-display); + font-size: 34px; + font-weight: 950; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.profile-code-input { + color: var(--ink-soft); + font-family: var(--font-mono); + font-weight: 900; +} + +.profile-referral-link-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; +} + +.profile-referral-link-row .ui-button { + min-height: 44px; + white-space: nowrap; +} + .admin-layout { display: grid; grid-template-columns: minmax(320px, 420px) minmax(0, 1fr); @@ -4647,6 +4706,10 @@ button:disabled, grid-template-columns: 1fr; } + .profile-card--referral { + grid-column: auto; + } + .system-wording-sidebar { display: flex; overflow-x: auto; @@ -4761,6 +4824,14 @@ button:disabled, grid-template-columns: 1fr; } + .profile-referral-link-row { + grid-template-columns: 1fr; + } + + .profile-referral-link-row .ui-button { + width: 100%; + } + .life-toolbar__actions, .life-toolbar .ui-button { width: 100%; diff --git a/frontend/src/views/RegisterView.vue b/frontend/src/views/RegisterView.vue index 9c9a3ee..bab86b3 100644 --- a/frontend/src/views/RegisterView.vue +++ b/frontend/src/views/RegisterView.vue @@ -2,14 +2,17 @@ import { Icon } from '@iconify/vue'; import { ref } from 'vue'; import { useI18n } from 'vue-i18n'; +import { useRoute } from 'vue-router'; import PageHeader from '../components/PageHeader.vue'; import StatusMessage from '../components/StatusMessage.vue'; import { iconMail } from '../icons'; import { api } from '../services/api'; +const route = useRoute(); const email = ref(''); const displayName = ref(''); const password = ref(''); +const referralCode = ref(typeof route.query.ref === 'string' ? route.query.ref.trim().toUpperCase() : ''); const busy = ref(false); const message = ref(''); const errorMessage = ref(''); @@ -24,7 +27,8 @@ async function submitRegister() { const response = await api.register({ email: email.value, displayName: displayName.value, - password: password.value + password: password.value, + referralCode: referralCode.value }); message.value = response.message; } catch (error) { @@ -65,6 +69,19 @@ async function submitRegister() { /> +
+ + + {{ t('auth.referralCodeHint') }} +
+ {{ message }} {{ errorMessage }} diff --git a/frontend/src/views/UserProfileView.vue b/frontend/src/views/UserProfileView.vue index 561ad98..2faa201 100644 --- a/frontend/src/views/UserProfileView.vue +++ b/frontend/src/views/UserProfileView.vue @@ -6,16 +6,19 @@ import PageHeader from '../components/PageHeader.vue'; import Skeleton from '../components/Skeleton.vue'; import StatusBadge from '../components/StatusBadge.vue'; import StatusMessage from '../components/StatusMessage.vue'; -import { iconProfile, iconSave } from '../icons'; -import { api, notifyAuthChange, type AuthUser } from '../services/api'; +import { iconCopy, iconProfile, iconReferral, iconSave } from '../icons'; +import { api, notifyAuthChange, type AuthUser, type ReferralSummary } from '../services/api'; const { t } = useI18n(); const user = ref(null); +const referral = ref(null); const displayName = ref(''); const loading = ref(true); const busy = ref(false); const message = ref(''); const errorMessage = ref(''); +const referralMessage = ref(''); +const referralErrorMessage = ref(''); const trimmedDisplayName = computed(() => displayName.value.trim()); const hasChanges = computed(() => { @@ -32,11 +35,21 @@ async function loadProfile() { loading.value = true; message.value = ''; errorMessage.value = ''; + referralMessage.value = ''; + referralErrorMessage.value = ''; + referral.value = null; try { const response = await api.me(); user.value = response.user; displayName.value = response.user.displayName; + + try { + const referralResponse = await api.referral(); + referral.value = referralResponse.referral; + } catch { + referralErrorMessage.value = t('pages.profile.referralLoadFailed'); + } } catch (error) { errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed'); } finally { @@ -67,6 +80,40 @@ async function saveProfile() { } } +function writeClipboard(value: string): Promise { + if (navigator.clipboard?.writeText) { + return navigator.clipboard.writeText(value); + } + + const textarea = document.createElement('textarea'); + textarea.value = value; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.append(textarea); + textarea.select(); + const copied = document.execCommand('copy'); + textarea.remove(); + + return copied ? Promise.resolve() : Promise.reject(new Error('Clipboard unavailable')); +} + +async function copyReferralLink() { + if (!referral.value) { + return; + } + + referralMessage.value = ''; + referralErrorMessage.value = ''; + + try { + await writeClipboard(referral.value.url); + referralMessage.value = t('pages.profile.referralCopied'); + } catch { + referralErrorMessage.value = t('pages.profile.referralCopyFailed'); + } +} + onMounted(() => { void loadProfile(); }); @@ -102,6 +149,14 @@ onMounted(() => { +
@@ -153,6 +208,42 @@ onMounted(() => { + +
+
+
+ +
+
+ {{ t('pages.profile.verifiedReferralCount') }} + {{ referral.verifiedReferralCount }} +
+ +
+ + +
+ +
+ + + {{ t('pages.profile.referralHint') }} +
+ + {{ referralMessage }} + {{ referralErrorMessage }} +
+ + {{ referralErrorMessage }} +
{{ errorMessage }} diff --git a/system-wordings.ts b/system-wordings.ts index fa088ec..3e2da80 100644 --- a/system-wordings.ts +++ b/system-wordings.ts @@ -70,6 +70,9 @@ export const systemWordingMessages = { newPassword: 'New password', confirmPassword: 'Confirm password', displayName: 'Display name', + referralCode: 'Referral code', + referralCodePlaceholder: 'Optional code', + referralCodeHint: 'Use an invite code from another trainer.', loginTitle: 'Log in', loginSubtitle: 'Use a verified email to enter Pokopia Wiki.', loggingIn: 'Logging in', @@ -121,7 +124,16 @@ export const systemWordingMessages = { emailVerified: 'Email verified', emailUnverified: 'Email unverified', saved: 'Profile saved', - saveFailed: 'Profile save failed' + saveFailed: 'Profile save failed', + referralTitle: 'Referral', + referralCode: 'Referral code', + referralUrl: 'Invite link', + referralHint: 'Share this link with new editors. Invites count after email verification.', + verifiedReferralCount: 'Verified invites', + copyReferralLink: 'Copy link', + referralCopied: 'Referral link copied', + referralCopyFailed: 'Referral link copy failed', + referralLoadFailed: 'Referral details failed to load' }, pokemon: { title: 'Pokemon', @@ -578,7 +590,8 @@ export const systemWordingMessages = { passwordResetComplete: 'Password updated. You can log in with the new password.', invalidCredentials: 'Email or password is incorrect', verifyEmailFirst: 'Please complete email verification first', - invalidResetToken: 'The password reset link is invalid or expired' + invalidResetToken: 'The password reset link is invalid or expired', + invalidReferralCode: 'Referral code is invalid' }, validation: { nameRequired: 'Name is required', @@ -723,6 +736,9 @@ export const systemWordingMessages = { newPassword: '新密码', confirmPassword: '确认密码', displayName: '显示名', + referralCode: '邀请码', + referralCodePlaceholder: '可选邀请码', + referralCodeHint: '可填写其他训练师分享的邀请码。', loginTitle: '登录', loginSubtitle: '使用已验证邮箱进入 Pokopia Wiki', loggingIn: '登录中', @@ -774,7 +790,16 @@ export const systemWordingMessages = { emailVerified: '邮箱已验证', emailUnverified: '邮箱未验证', saved: '个人资料已保存', - saveFailed: '个人资料保存失败' + saveFailed: '个人资料保存失败', + referralTitle: '邀请', + referralCode: '邀请码', + referralUrl: '邀请链接', + referralHint: '分享给新编辑者,对方完成邮箱验证后会计入有效邀请。', + verifiedReferralCount: '有效邀请', + copyReferralLink: '复制链接', + referralCopied: '邀请链接已复制', + referralCopyFailed: '邀请链接复制失败', + referralLoadFailed: '邀请信息加载失败' }, pokemon: { title: 'Pokemon', @@ -1231,7 +1256,8 @@ export const systemWordingMessages = { passwordResetComplete: '密码已更新,请使用新密码登录。', invalidCredentials: '邮箱或密码不正确', verifyEmailFirst: '请先完成邮箱验证', - invalidResetToken: '密码重置链接无效或已过期' + invalidResetToken: '密码重置链接无效或已过期', + invalidReferralCode: '邀请码无效' }, validation: { nameRequired: '请输入名称',