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
This commit is contained in:
24
DESIGN.md
24
DESIGN.md
@@ -121,6 +121,29 @@
|
|||||||
- 更新显示名后,API 仍只返回当前用户必要字段,不返回 session、token/hash、内部审计或调试数据。
|
- 更新显示名后,API 仍只返回当前用户必要字段,不返回 session、token/hash、内部审计或调试数据。
|
||||||
- 显示名用于编辑署名、讨论和 Life 内容作者展示。
|
- 显示名用于编辑署名、讨论和 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 编辑与审计
|
## Community 编辑与审计
|
||||||
|
|
||||||
- 已验证用户可以通过前台或管理入口编辑 Wiki 内容。
|
- 已验证用户可以通过前台或管理入口编辑 Wiki 内容。
|
||||||
@@ -600,6 +623,7 @@ API 暴露边界:
|
|||||||
- `POST /api/auth/reset-password`
|
- `POST /api/auth/reset-password`
|
||||||
- `GET /api/auth/me`
|
- `GET /api/auth/me`
|
||||||
- `PATCH /api/auth/me`:更新当前用户显示名;需要登录;只接收并返回当前用户必要字段。
|
- `PATCH /api/auth/me`:更新当前用户显示名;需要登录;只接收并返回当前用户必要字段。
|
||||||
|
- `GET /api/auth/referral`:读取当前用户 Referral 摘要;需要登录;返回 `referral`,其中只包含 `code`、`url`、`verifiedReferralCount`。
|
||||||
- `POST /api/auth/logout`
|
- `POST /api/auth/logout`
|
||||||
|
|
||||||
已验证用户编辑 API:
|
已验证用户编辑 API:
|
||||||
|
|||||||
@@ -78,13 +78,28 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
email text NOT NULL UNIQUE,
|
email text NOT NULL UNIQUE,
|
||||||
display_name text NOT NULL,
|
display_name text NOT NULL,
|
||||||
password_hash 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,
|
email_verified_at timestamptz,
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
CHECK (email = lower(email)),
|
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 (
|
CREATE TABLE IF NOT EXISTS system_wording_keys (
|
||||||
key text PRIMARY KEY,
|
key text PRIMARY KEY,
|
||||||
module text NOT NULL,
|
module text NOT NULL,
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ const passwordResetTokenHours = 1;
|
|||||||
const rememberedSessionDays = 30;
|
const rememberedSessionDays = 30;
|
||||||
const sessionOnlySessionDays = 1;
|
const sessionOnlySessionDays = 1;
|
||||||
const defaultLocale = 'en';
|
const defaultLocale = 'en';
|
||||||
|
const referralCodeLength = 8;
|
||||||
|
const referralAlphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||||
|
const referralCodePattern = /^[A-Z0-9]{8,16}$/;
|
||||||
|
|
||||||
type DbClient = PoolClient;
|
type DbClient = PoolClient;
|
||||||
|
|
||||||
@@ -27,6 +30,15 @@ type LoginUserRow = UserRow & {
|
|||||||
password_hash: string;
|
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 =
|
type AuthMessageKey =
|
||||||
| 'emailRequired'
|
| 'emailRequired'
|
||||||
| 'invalidEmail'
|
| 'invalidEmail'
|
||||||
@@ -42,6 +54,7 @@ type AuthMessageKey =
|
|||||||
| 'invalidCredentials'
|
| 'invalidCredentials'
|
||||||
| 'verifyEmailFirst'
|
| 'verifyEmailFirst'
|
||||||
| 'invalidResetToken'
|
| 'invalidResetToken'
|
||||||
|
| 'invalidReferralCode'
|
||||||
| 'emailSubject'
|
| 'emailSubject'
|
||||||
| 'emailHtml'
|
| 'emailHtml'
|
||||||
| 'emailText'
|
| 'emailText'
|
||||||
@@ -58,6 +71,12 @@ export type AuthUser = {
|
|||||||
emailVerified: boolean;
|
emailVerified: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ReferralSummary = {
|
||||||
|
code: string;
|
||||||
|
url: string;
|
||||||
|
verifiedReferralCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
function statusError(message: string, statusCode: number): StatusError {
|
function statusError(message: string, statusCode: number): StatusError {
|
||||||
const error = new Error(message) as StatusError;
|
const error = new Error(message) as StatusError;
|
||||||
error.statusCode = statusCode;
|
error.statusCode = statusCode;
|
||||||
@@ -80,6 +99,7 @@ function authMessage(locale: string, key: AuthMessageKey, params: Record<string,
|
|||||||
invalidCredentials: 'server.auth.invalidCredentials',
|
invalidCredentials: 'server.auth.invalidCredentials',
|
||||||
verifyEmailFirst: 'server.auth.verifyEmailFirst',
|
verifyEmailFirst: 'server.auth.verifyEmailFirst',
|
||||||
invalidResetToken: 'server.auth.invalidResetToken',
|
invalidResetToken: 'server.auth.invalidResetToken',
|
||||||
|
invalidReferralCode: 'server.auth.invalidReferralCode',
|
||||||
emailSubject: 'email.auth.verificationSubject',
|
emailSubject: 'email.auth.verificationSubject',
|
||||||
emailHtml: 'email.auth.verificationHtml',
|
emailHtml: 'email.auth.verificationHtml',
|
||||||
emailText: 'email.auth.verificationText',
|
emailText: 'email.auth.verificationText',
|
||||||
@@ -137,6 +157,27 @@ async function cleanToken(
|
|||||||
return value.trim();
|
return value.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cleanReferralCode(value: unknown, locale: string): Promise<string | null> {
|
||||||
|
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 {
|
function toPublicUser(user: UserRow): AuthUser {
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -196,6 +237,80 @@ function hashToken(token: string): string {
|
|||||||
return createHash('sha256').update(token).digest('hex');
|
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<string> {
|
||||||
|
const existing = await clientQueryOne<ReferralCodeRow>(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<ReferralCodeRow>(
|
||||||
|
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<ReferralCodeRow>(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<number> {
|
||||||
|
const row = await clientQueryOne<QueryResultRow & { id: number }>(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() {
|
function getEmailConfig() {
|
||||||
const apiKey = process.env.RESEND_API_KEY;
|
const apiKey = process.env.RESEND_API_KEY;
|
||||||
const from = process.env.EMAIL_FROM;
|
const from = process.env.EMAIL_FROM;
|
||||||
@@ -280,14 +395,15 @@ export async function registerUser(payload: Record<string, unknown>, locale = de
|
|||||||
const email = await cleanEmail(payload.email, locale);
|
const email = await cleanEmail(payload.email, locale);
|
||||||
const displayName = await cleanDisplayName(payload.displayName, locale);
|
const displayName = await cleanDisplayName(payload.displayName, locale);
|
||||||
const password = await cleanPassword(payload.password, locale);
|
const password = await cleanPassword(payload.password, locale);
|
||||||
|
const referralCode = await cleanReferralCode(payload.referralCode, locale);
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
const verificationToken = createPlainToken();
|
const verificationToken = createPlainToken();
|
||||||
const verificationTokenHash = hashToken(verificationToken);
|
const verificationTokenHash = hashToken(verificationToken);
|
||||||
|
|
||||||
await withTransaction(async (client) => {
|
await withTransaction(async (client) => {
|
||||||
const existingUser = await clientQueryOne<UserRow>(
|
const existingUser = await clientQueryOne<RegistrationUserRow>(
|
||||||
client,
|
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]
|
[email]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -295,23 +411,24 @@ export async function registerUser(payload: Record<string, unknown>, locale = de
|
|||||||
throw statusError(await authMessage(locale, 'emailAlreadyRegistered'), 409);
|
throw statusError(await authMessage(locale, 'emailAlreadyRegistered'), 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const referrerUserId = referralCode ? await referralUserId(client, referralCode, existingUser?.id ?? null, locale) : null;
|
||||||
const user = existingUser
|
const user = existingUser
|
||||||
? await clientQueryOne<UserRow>(
|
? await clientQueryOne<RegistrationUserRow>(
|
||||||
client,
|
client,
|
||||||
`
|
`
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET display_name = $1, password_hash = $2, updated_at = now()
|
SET display_name = $1, password_hash = $2, updated_at = now()
|
||||||
WHERE id = $3
|
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]
|
[displayName, passwordHash, existingUser.id]
|
||||||
)
|
)
|
||||||
: await clientQueryOne<UserRow>(
|
: await clientQueryOne<RegistrationUserRow>(
|
||||||
client,
|
client,
|
||||||
`
|
`
|
||||||
INSERT INTO users (email, display_name, password_hash)
|
INSERT INTO users (email, display_name, password_hash)
|
||||||
VALUES ($1, $2, $3)
|
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]
|
[email, displayName, passwordHash]
|
||||||
);
|
);
|
||||||
@@ -320,6 +437,14 @@ export async function registerUser(payload: Record<string, unknown>, locale = de
|
|||||||
throw new Error('Failed to save user');
|
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('DELETE FROM email_verification_tokens WHERE user_id = $1 AND used_at IS NULL', [user.id]);
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
@@ -530,6 +655,23 @@ export async function updateCurrentUser(
|
|||||||
return toPublicUser(user);
|
return toPublicUser(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getReferralSummary(userId: number): Promise<ReferralSummary> {
|
||||||
|
return withTransaction(async (client) => {
|
||||||
|
const code = await ensureReferralCode(client, userId);
|
||||||
|
const countRow = await clientQueryOne<QueryResultRow & { count: string }>(
|
||||||
|
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<void> {
|
export async function logoutSession(token: string): Promise<void> {
|
||||||
if (token.length < 32) {
|
if (token.length < 32) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Fastify from 'fastify';
|
|||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { mkdir } from 'node:fs/promises';
|
import { mkdir } from 'node:fs/promises';
|
||||||
import {
|
import {
|
||||||
|
getReferralSummary,
|
||||||
getUserBySessionToken,
|
getUserBySessionToken,
|
||||||
loginUser,
|
loginUser,
|
||||||
logoutSession,
|
logoutSession,
|
||||||
@@ -239,6 +240,17 @@ app.patch('/api/auth/me', async (request, reply) => {
|
|||||||
return { user: await updateCurrentUser(user.id, payload, requestLocale(request)) };
|
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) => {
|
app.post('/api/auth/logout', async (request, reply) => {
|
||||||
const token = getBearerToken(request.headers.authorization);
|
const token = getBearerToken(request.headers.authorization);
|
||||||
if (token) {
|
if (token) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const iconChecklist: AppIcon = 'mdi:checkbox-marked-outline';
|
|||||||
export const iconChevronDown: AppIcon = 'mdi:chevron-down';
|
export const iconChevronDown: AppIcon = 'mdi:chevron-down';
|
||||||
export const iconClose: AppIcon = 'mdi:close';
|
export const iconClose: AppIcon = 'mdi:close';
|
||||||
export const iconComment: AppIcon = 'mdi:comment-outline';
|
export const iconComment: AppIcon = 'mdi:comment-outline';
|
||||||
|
export const iconCopy: AppIcon = 'mdi:content-copy';
|
||||||
export const iconDelete: AppIcon = 'mdi:trash-can-outline';
|
export const iconDelete: AppIcon = 'mdi:trash-can-outline';
|
||||||
export const iconDish: AppIcon = 'mdi:silverware-fork-knife';
|
export const iconDish: AppIcon = 'mdi:silverware-fork-knife';
|
||||||
export const iconDragHandle: AppIcon = 'mdi:drag';
|
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 iconPokemon: AppIcon = 'mdi:pokeball';
|
||||||
export const iconProfile: AppIcon = 'mdi:account-circle-outline';
|
export const iconProfile: AppIcon = 'mdi:account-circle-outline';
|
||||||
export const iconRecipe: AppIcon = 'mdi:book-open-page-variant-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 iconRegister: AppIcon = 'mdi:account-plus-outline';
|
||||||
export const iconReply: AppIcon = 'mdi:reply-outline';
|
export const iconReply: AppIcon = 'mdi:reply-outline';
|
||||||
export const iconReactionFun: AppIcon = 'mdi:party-popper';
|
export const iconReactionFun: AppIcon = 'mdi:party-popper';
|
||||||
|
|||||||
@@ -316,6 +316,12 @@ export interface AuthUser {
|
|||||||
emailVerified: boolean;
|
emailVerified: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReferralSummary {
|
||||||
|
code: string;
|
||||||
|
url: string;
|
||||||
|
verifiedReferralCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserProfilePayload {
|
export interface UserProfilePayload {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
}
|
}
|
||||||
@@ -328,6 +334,7 @@ export interface LoginPayload {
|
|||||||
|
|
||||||
export interface RegisterPayload extends LoginPayload {
|
export interface RegisterPayload extends LoginPayload {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
referralCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
@@ -637,6 +644,7 @@ export const api = {
|
|||||||
sendJson<{ message: string }>('/api/auth/reset-password', 'POST', payload),
|
sendJson<{ message: string }>('/api/auth/reset-password', 'POST', payload),
|
||||||
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
|
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
|
||||||
updateMe: (payload: UserProfilePayload) => sendJson<{ user: AuthUser }>('/api/auth/me', 'PATCH', payload),
|
updateMe: (payload: UserProfilePayload) => sendJson<{ user: AuthUser }>('/api/auth/me', 'PATCH', payload),
|
||||||
|
referral: () => getJson<{ referral: ReferralSummary }>('/api/auth/referral'),
|
||||||
logout: () => postEmpty('/api/auth/logout'),
|
logout: () => postEmpty('/api/auth/logout'),
|
||||||
options: () => getJson<Options>('/api/options'),
|
options: () => getJson<Options>('/api/options'),
|
||||||
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
|
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
|
||||||
|
|||||||
@@ -4220,6 +4220,12 @@ button:disabled,
|
|||||||
background: color-mix(in srgb, var(--danger) 10%, var(--surface));
|
background: color-mix(in srgb, var(--danger) 10%, var(--surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-field-note {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-page {
|
.profile-page {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
@@ -4247,6 +4253,10 @@ button:disabled,
|
|||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-card--referral {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-identity {
|
.profile-identity {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
@@ -4317,6 +4327,55 @@ button:disabled,
|
|||||||
cursor: default;
|
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 {
|
.admin-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
|
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
|
||||||
@@ -4647,6 +4706,10 @@ button:disabled,
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-card--referral {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.system-wording-sidebar {
|
.system-wording-sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -4761,6 +4824,14 @@ button:disabled,
|
|||||||
grid-template-columns: 1fr;
|
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__actions,
|
||||||
.life-toolbar .ui-button {
|
.life-toolbar .ui-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -2,14 +2,17 @@
|
|||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import StatusMessage from '../components/StatusMessage.vue';
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
import { iconMail } from '../icons';
|
import { iconMail } from '../icons';
|
||||||
import { api } from '../services/api';
|
import { api } from '../services/api';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
const email = ref('');
|
const email = ref('');
|
||||||
const displayName = ref('');
|
const displayName = ref('');
|
||||||
const password = ref('');
|
const password = ref('');
|
||||||
|
const referralCode = ref(typeof route.query.ref === 'string' ? route.query.ref.trim().toUpperCase() : '');
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
@@ -24,7 +27,8 @@ async function submitRegister() {
|
|||||||
const response = await api.register({
|
const response = await api.register({
|
||||||
email: email.value,
|
email: email.value,
|
||||||
displayName: displayName.value,
|
displayName: displayName.value,
|
||||||
password: password.value
|
password: password.value,
|
||||||
|
referralCode: referralCode.value
|
||||||
});
|
});
|
||||||
message.value = response.message;
|
message.value = response.message;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -65,6 +69,19 @@ async function submitRegister() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="register-referral-code">{{ t('auth.referralCode') }}</label>
|
||||||
|
<input
|
||||||
|
id="register-referral-code"
|
||||||
|
v-model="referralCode"
|
||||||
|
autocomplete="off"
|
||||||
|
inputmode="text"
|
||||||
|
maxlength="16"
|
||||||
|
:placeholder="t('auth.referralCodePlaceholder')"
|
||||||
|
/>
|
||||||
|
<small class="auth-field-note">{{ t('auth.referralCodeHint') }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<StatusMessage v-if="message" variant="success">{{ message }}</StatusMessage>
|
<StatusMessage v-if="message" variant="success">{{ message }}</StatusMessage>
|
||||||
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
|
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,19 @@ import PageHeader from '../components/PageHeader.vue';
|
|||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import StatusBadge from '../components/StatusBadge.vue';
|
import StatusBadge from '../components/StatusBadge.vue';
|
||||||
import StatusMessage from '../components/StatusMessage.vue';
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
import { iconProfile, iconSave } from '../icons';
|
import { iconCopy, iconProfile, iconReferral, iconSave } from '../icons';
|
||||||
import { api, notifyAuthChange, type AuthUser } from '../services/api';
|
import { api, notifyAuthChange, type AuthUser, type ReferralSummary } from '../services/api';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const user = ref<AuthUser | null>(null);
|
const user = ref<AuthUser | null>(null);
|
||||||
|
const referral = ref<ReferralSummary | null>(null);
|
||||||
const displayName = ref('');
|
const displayName = ref('');
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
|
const referralMessage = ref('');
|
||||||
|
const referralErrorMessage = ref('');
|
||||||
|
|
||||||
const trimmedDisplayName = computed(() => displayName.value.trim());
|
const trimmedDisplayName = computed(() => displayName.value.trim());
|
||||||
const hasChanges = computed(() => {
|
const hasChanges = computed(() => {
|
||||||
@@ -32,11 +35,21 @@ async function loadProfile() {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
message.value = '';
|
message.value = '';
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
|
referralMessage.value = '';
|
||||||
|
referralErrorMessage.value = '';
|
||||||
|
referral.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.me();
|
const response = await api.me();
|
||||||
user.value = response.user;
|
user.value = response.user;
|
||||||
displayName.value = response.user.displayName;
|
displayName.value = response.user.displayName;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const referralResponse = await api.referral();
|
||||||
|
referral.value = referralResponse.referral;
|
||||||
|
} catch {
|
||||||
|
referralErrorMessage.value = t('pages.profile.referralLoadFailed');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -67,6 +80,40 @@ async function saveProfile() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeClipboard(value: string): Promise<void> {
|
||||||
|
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(() => {
|
onMounted(() => {
|
||||||
void loadProfile();
|
void loadProfile();
|
||||||
});
|
});
|
||||||
@@ -102,6 +149,14 @@ onMounted(() => {
|
|||||||
<Skeleton variant="box" width="120px" height="42px" />
|
<Skeleton variant="box" width="120px" height="42px" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="profile-card profile-card--referral" aria-hidden="true">
|
||||||
|
<Skeleton width="180px" height="28px" />
|
||||||
|
<div class="auth-form">
|
||||||
|
<Skeleton variant="box" height="58px" />
|
||||||
|
<Skeleton variant="box" height="44px" />
|
||||||
|
<Skeleton variant="box" width="120px" height="42px" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="user" class="profile-layout">
|
<div v-else-if="user" class="profile-layout">
|
||||||
@@ -153,6 +208,42 @@ onMounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="profile-card profile-card--referral" :aria-label="t('pages.profile.referralTitle')">
|
||||||
|
<div class="profile-card__header">
|
||||||
|
<Icon :icon="iconReferral" class="profile-card__icon" aria-hidden="true" />
|
||||||
|
<h2>{{ t('pages.profile.referralTitle') }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="referral" class="profile-referral">
|
||||||
|
<div class="profile-referral__metric">
|
||||||
|
<span>{{ t('pages.profile.verifiedReferralCount') }}</span>
|
||||||
|
<strong>{{ referral.verifiedReferralCount }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="profile-referral-code">{{ t('pages.profile.referralCode') }}</label>
|
||||||
|
<input id="profile-referral-code" class="profile-readonly-input profile-code-input" :value="referral.code" readonly />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="profile-referral-url">{{ t('pages.profile.referralUrl') }}</label>
|
||||||
|
<div class="profile-referral-link-row">
|
||||||
|
<input id="profile-referral-url" class="profile-readonly-input" :value="referral.url" readonly />
|
||||||
|
<button class="ui-button ui-button--blue" type="button" @click="copyReferralLink">
|
||||||
|
<Icon :icon="iconCopy" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.profile.copyReferralLink') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small class="profile-field-note">{{ t('pages.profile.referralHint') }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StatusMessage v-if="referralMessage" variant="success">{{ referralMessage }}</StatusMessage>
|
||||||
|
<StatusMessage v-if="referralErrorMessage" variant="danger">{{ referralErrorMessage }}</StatusMessage>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StatusMessage v-else-if="referralErrorMessage" variant="danger" :duration="0">{{ referralErrorMessage }}</StatusMessage>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StatusMessage v-else-if="errorMessage" variant="danger" :duration="0">{{ errorMessage }}</StatusMessage>
|
<StatusMessage v-else-if="errorMessage" variant="danger" :duration="0">{{ errorMessage }}</StatusMessage>
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ export const systemWordingMessages = {
|
|||||||
newPassword: 'New password',
|
newPassword: 'New password',
|
||||||
confirmPassword: 'Confirm password',
|
confirmPassword: 'Confirm password',
|
||||||
displayName: 'Display name',
|
displayName: 'Display name',
|
||||||
|
referralCode: 'Referral code',
|
||||||
|
referralCodePlaceholder: 'Optional code',
|
||||||
|
referralCodeHint: 'Use an invite code from another trainer.',
|
||||||
loginTitle: 'Log in',
|
loginTitle: 'Log in',
|
||||||
loginSubtitle: 'Use a verified email to enter Pokopia Wiki.',
|
loginSubtitle: 'Use a verified email to enter Pokopia Wiki.',
|
||||||
loggingIn: 'Logging in',
|
loggingIn: 'Logging in',
|
||||||
@@ -121,7 +124,16 @@ export const systemWordingMessages = {
|
|||||||
emailVerified: 'Email verified',
|
emailVerified: 'Email verified',
|
||||||
emailUnverified: 'Email unverified',
|
emailUnverified: 'Email unverified',
|
||||||
saved: 'Profile saved',
|
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: {
|
pokemon: {
|
||||||
title: 'Pokemon',
|
title: 'Pokemon',
|
||||||
@@ -578,7 +590,8 @@ export const systemWordingMessages = {
|
|||||||
passwordResetComplete: 'Password updated. You can log in with the new password.',
|
passwordResetComplete: 'Password updated. You can log in with the new password.',
|
||||||
invalidCredentials: 'Email or password is incorrect',
|
invalidCredentials: 'Email or password is incorrect',
|
||||||
verifyEmailFirst: 'Please complete email verification first',
|
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: {
|
validation: {
|
||||||
nameRequired: 'Name is required',
|
nameRequired: 'Name is required',
|
||||||
@@ -723,6 +736,9 @@ export const systemWordingMessages = {
|
|||||||
newPassword: '新密码',
|
newPassword: '新密码',
|
||||||
confirmPassword: '确认密码',
|
confirmPassword: '确认密码',
|
||||||
displayName: '显示名',
|
displayName: '显示名',
|
||||||
|
referralCode: '邀请码',
|
||||||
|
referralCodePlaceholder: '可选邀请码',
|
||||||
|
referralCodeHint: '可填写其他训练师分享的邀请码。',
|
||||||
loginTitle: '登录',
|
loginTitle: '登录',
|
||||||
loginSubtitle: '使用已验证邮箱进入 Pokopia Wiki',
|
loginSubtitle: '使用已验证邮箱进入 Pokopia Wiki',
|
||||||
loggingIn: '登录中',
|
loggingIn: '登录中',
|
||||||
@@ -774,7 +790,16 @@ export const systemWordingMessages = {
|
|||||||
emailVerified: '邮箱已验证',
|
emailVerified: '邮箱已验证',
|
||||||
emailUnverified: '邮箱未验证',
|
emailUnverified: '邮箱未验证',
|
||||||
saved: '个人资料已保存',
|
saved: '个人资料已保存',
|
||||||
saveFailed: '个人资料保存失败'
|
saveFailed: '个人资料保存失败',
|
||||||
|
referralTitle: '邀请',
|
||||||
|
referralCode: '邀请码',
|
||||||
|
referralUrl: '邀请链接',
|
||||||
|
referralHint: '分享给新编辑者,对方完成邮箱验证后会计入有效邀请。',
|
||||||
|
verifiedReferralCount: '有效邀请',
|
||||||
|
copyReferralLink: '复制链接',
|
||||||
|
referralCopied: '邀请链接已复制',
|
||||||
|
referralCopyFailed: '邀请链接复制失败',
|
||||||
|
referralLoadFailed: '邀请信息加载失败'
|
||||||
},
|
},
|
||||||
pokemon: {
|
pokemon: {
|
||||||
title: 'Pokemon',
|
title: 'Pokemon',
|
||||||
@@ -1231,7 +1256,8 @@ export const systemWordingMessages = {
|
|||||||
passwordResetComplete: '密码已更新,请使用新密码登录。',
|
passwordResetComplete: '密码已更新,请使用新密码登录。',
|
||||||
invalidCredentials: '邮箱或密码不正确',
|
invalidCredentials: '邮箱或密码不正确',
|
||||||
verifyEmailFirst: '请先完成邮箱验证',
|
verifyEmailFirst: '请先完成邮箱验证',
|
||||||
invalidResetToken: '密码重置链接无效或已过期'
|
invalidResetToken: '密码重置链接无效或已过期',
|
||||||
|
invalidReferralCode: '邀请码无效'
|
||||||
},
|
},
|
||||||
validation: {
|
validation: {
|
||||||
nameRequired: '请输入名称',
|
nameRequired: '请输入名称',
|
||||||
|
|||||||
Reference in New Issue
Block a user