feat: 优化web
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
import { getOAuthClientId, getOAuthRedirectUri, getPublicApiOrigin } from '@/lib/env';
|
||||
import { createPKCEPair } from '@/lib/oauth/pkce';
|
||||
import type { ApiEnvelope } from '@/lib/api/types';
|
||||
|
||||
export type TokenPair = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
};
|
||||
|
||||
/** 用户名密码 + PKCE 换 authorization_code,再 POST /oauth/token。 */
|
||||
export async function loginWithPassword(params: {
|
||||
userName: string;
|
||||
password: string;
|
||||
tenantId?: string;
|
||||
state?: string;
|
||||
}): Promise<TokenPair> {
|
||||
const origin = getPublicApiOrigin();
|
||||
const clientId = getOAuthClientId();
|
||||
const redirectUri = getOAuthRedirectUri();
|
||||
const { verifier, challenge } = await createPKCEPair();
|
||||
|
||||
const res = await fetch(`${origin}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
tenant_id: params.tenantId ?? '',
|
||||
user_name: params.userName,
|
||||
password: params.password,
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
state: params.state ?? '',
|
||||
scope: 'openid',
|
||||
}),
|
||||
});
|
||||
|
||||
const json = (await res.json()) as ApiEnvelope<{
|
||||
authorization_code?: string;
|
||||
state?: string;
|
||||
}>;
|
||||
|
||||
if (res.status === 401) {
|
||||
throw new Error(json.msg || '用户名或密码错误');
|
||||
}
|
||||
if (res.status === 403) {
|
||||
throw new Error(json.msg || '无权限');
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(json.msg || `HTTP ${res.status}`);
|
||||
}
|
||||
if (json.code !== 200 || !json.data?.authorization_code) {
|
||||
throw new Error(json.msg || '登录失败');
|
||||
}
|
||||
|
||||
return exchangeCodeForTokens({
|
||||
code: json.data.authorization_code,
|
||||
codeVerifier: verifier,
|
||||
clientId,
|
||||
redirectUri,
|
||||
});
|
||||
}
|
||||
|
||||
export async function exchangeCodeForTokens(params: {
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
}): Promise<TokenPair> {
|
||||
const origin = getPublicApiOrigin();
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: params.code,
|
||||
redirect_uri: params.redirectUri,
|
||||
client_id: params.clientId,
|
||||
code_verifier: params.codeVerifier,
|
||||
});
|
||||
|
||||
const res = await fetch(`${origin}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
const data = (await res.json()) as {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
};
|
||||
|
||||
if (!res.ok || !data.access_token) {
|
||||
throw new Error(data.error_description || data.error || '换取 access_token 失败');
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token ?? '',
|
||||
expiresIn: typeof data.expires_in === 'number' ? data.expires_in : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(refreshToken: string): Promise<TokenPair> {
|
||||
const origin = getPublicApiOrigin();
|
||||
const clientId = getOAuthClientId();
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: clientId,
|
||||
});
|
||||
|
||||
const res = await fetch(`${origin}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
const data = (await res.json()) as {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
};
|
||||
|
||||
if (!res.ok || !data.access_token) {
|
||||
throw new Error(data.error_description || data.error || '刷新 token 失败');
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token ?? refreshToken,
|
||||
expiresIn: typeof data.expires_in === 'number' ? data.expires_in : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function logoutRequest(): Promise<void> {
|
||||
const origin = getPublicApiOrigin();
|
||||
const res = await fetch(`${origin}/api/v1/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = (await res.json().catch(() => ({}))) as { msg?: string };
|
||||
throw new Error(j.msg || '登出失败');
|
||||
}
|
||||
}
|
||||
|
||||
/** RFC 7662 风格,用于解析 opaque access_token 的 `sub`(用户 id) */
|
||||
export async function introspectAccessToken(accessToken: string): Promise<{
|
||||
active: boolean;
|
||||
sub?: string;
|
||||
scope?: string;
|
||||
exp?: number;
|
||||
client_id?: string;
|
||||
}> {
|
||||
const origin = getPublicApiOrigin();
|
||||
const body = new URLSearchParams({
|
||||
token: accessToken,
|
||||
token_type_hint: 'access_token',
|
||||
});
|
||||
const res = await fetch(`${origin}/oauth/introspect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
const data = (await res.json()) as {
|
||||
active?: boolean;
|
||||
sub?: string;
|
||||
scope?: string;
|
||||
exp?: number;
|
||||
client_id?: string;
|
||||
};
|
||||
return {
|
||||
active: Boolean(data.active),
|
||||
sub: data.sub,
|
||||
scope: data.scope,
|
||||
exp: data.exp,
|
||||
client_id: data.client_id,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { getPublicApiOrigin } from '@/lib/env';
|
||||
import { emit401Unauthorized, emit403Forbidden } from '@/lib/notify/auth-events';
|
||||
import { refreshTokensShared } from '@/lib/api/refresh-flight';
|
||||
import { ApiError, type ApiEnvelope } from '@/lib/api/types';
|
||||
|
||||
type GetTokens = () => { accessToken: string | null; refreshToken: string | null };
|
||||
type SetTokens = (access: string | null, refresh: string | null) => void;
|
||||
|
||||
let tokenBridge: { get: GetTokens; set: SetTokens } | null = null;
|
||||
|
||||
/** 由 auth store 在客户端挂载时注册,避免循环依赖。 */
|
||||
export function registerTokenBridge(get: GetTokens, set: SetTokens) {
|
||||
tokenBridge = { get, set };
|
||||
}
|
||||
|
||||
let tenantIdGetter: () => string | null = () => null;
|
||||
|
||||
/** 由 tenant store 注册,请求 `/api/v1` 时附加 `X-Tenant-ID`(可选)。 */
|
||||
export function registerTenantHeaderBridge(get: () => string | null) {
|
||||
tenantIdGetter = get;
|
||||
}
|
||||
|
||||
function authHeader(): HeadersInit {
|
||||
const t = tokenBridge?.get().accessToken;
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
|
||||
function tenantHeader(): HeadersInit {
|
||||
const tid = tenantIdGetter?.();
|
||||
return tid ? { 'X-Tenant-ID': tid } : {};
|
||||
}
|
||||
|
||||
/** 业务 JSON:HTTP 200 时解析 envelope;401 时共享 refresh 后重试一次(登录请求勿走此逻辑)。 */
|
||||
export async function apiJson<T>(
|
||||
path: string,
|
||||
init: RequestInit & { skipRefreshRetry?: boolean } = {}
|
||||
): Promise<T> {
|
||||
const { skipRefreshRetry, ...req } = init;
|
||||
const url = `${getPublicApiOrigin()}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
const method = (req.method || 'GET').toUpperCase();
|
||||
const hasBody = req.body != null && req.body !== '';
|
||||
const jsonHeaders: HeadersInit =
|
||||
method !== 'GET' && method !== 'HEAD' && hasBody ? { 'Content-Type': 'application/json' } : {};
|
||||
const baseHeaders = {
|
||||
...jsonHeaders,
|
||||
...authHeader(),
|
||||
...tenantHeader(),
|
||||
...(req.headers || {}),
|
||||
};
|
||||
|
||||
const first = await fetch(url, {
|
||||
...req,
|
||||
headers: baseHeaders,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (first.status === 401 && !skipRefreshRetry && tokenBridge) {
|
||||
const ok = await refreshTokensShared(
|
||||
() => tokenBridge!.get().refreshToken,
|
||||
(a, r) => tokenBridge!.set(a, r)
|
||||
);
|
||||
if (ok) {
|
||||
const second = await fetch(url, {
|
||||
...req,
|
||||
headers: {
|
||||
...jsonHeaders,
|
||||
...authHeader(),
|
||||
...tenantHeader(),
|
||||
...(req.headers || {}),
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleEnvelope<T>(second);
|
||||
}
|
||||
}
|
||||
|
||||
return handleEnvelope<T>(first);
|
||||
}
|
||||
|
||||
async function handleEnvelope<T>(res: Response): Promise<T> {
|
||||
const text = await res.text();
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
throw new ApiError('响应不是合法 JSON', res.status, text);
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
const msg = (body as { msg?: string })?.msg || '未授权';
|
||||
emit401Unauthorized(msg);
|
||||
throw new ApiError(msg, 401, body);
|
||||
}
|
||||
if (res.status === 403) {
|
||||
const msg = (body as { msg?: string })?.msg || '禁止访问';
|
||||
emit403Forbidden(msg);
|
||||
throw new ApiError(msg, 403, body);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new ApiError(`HTTP ${res.status}`, res.status, body);
|
||||
}
|
||||
|
||||
const env = body as ApiEnvelope<T>;
|
||||
if (typeof env?.code !== 'number') {
|
||||
return body as T;
|
||||
}
|
||||
if (env.code !== 200) {
|
||||
throw new ApiError(env.msg || '业务失败', res.status, env);
|
||||
}
|
||||
return env.data as T;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { apiJson } from '@/lib/api/client';
|
||||
import { API_V1 } from '@/lib/api/paths';
|
||||
import type { DeptNode, IamDept } from '@/lib/api/types/dept';
|
||||
import type { MenuNode } from '@/lib/api/types/menu';
|
||||
import type { IamRole } from '@/lib/api/types/role';
|
||||
import type { IamTenant } from '@/lib/api/types/tenant';
|
||||
import type { IamUser } from '@/lib/api/types/user';
|
||||
|
||||
const B = `${API_V1}/iam`;
|
||||
|
||||
/** 列表响应信封 */
|
||||
type ListEnvelope<T> = { items: T[]; total: number };
|
||||
|
||||
/** 租户 */
|
||||
export const iamTenant = {
|
||||
create: (body: Partial<IamTenant>) =>
|
||||
apiJson<IamTenant>(`${B}/tenant/create`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
update: (id: string, body: Partial<IamTenant>) =>
|
||||
apiJson<IamTenant>(`${B}/tenant/update/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
deleteBatch: (ids: string[]) =>
|
||||
apiJson<void>(`${B}/tenant/delete-batch`, { method: 'DELETE', body: JSON.stringify(ids) }),
|
||||
get: (id: string) => apiJson<IamTenant>(`${B}/tenant/get/${encodeURIComponent(id)}`),
|
||||
list: (query?: Record<string, string>) => {
|
||||
const q = query ? new URLSearchParams(query).toString() : 'page=1&page_size=200';
|
||||
return apiJson<ListEnvelope<IamTenant>>(`${B}/tenant/list?${q}`);
|
||||
},
|
||||
};
|
||||
|
||||
/** 部门 */
|
||||
export const iamDept = {
|
||||
create: (body: Partial<IamDept>) =>
|
||||
apiJson<IamDept>(`${B}/dept/create`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
update: (id: string, body: Partial<IamDept>) =>
|
||||
apiJson<IamDept>(`${B}/dept/update/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
deleteBatch: (ids: string[]) =>
|
||||
apiJson<void>(`${B}/dept/delete-batch`, { method: 'DELETE', body: JSON.stringify(ids) }),
|
||||
get: (id: string) => apiJson<IamDept>(`${B}/dept/get/${encodeURIComponent(id)}`),
|
||||
tree: () => apiJson<DeptNode[]>(`${B}/dept/tree`),
|
||||
};
|
||||
|
||||
/** 角色 */
|
||||
export const iamRole = {
|
||||
create: (body: Partial<IamRole>) =>
|
||||
apiJson<IamRole>(`${B}/role/create`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
update: (id: string, body: Partial<IamRole>) =>
|
||||
apiJson<IamRole>(`${B}/role/update/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
deleteBatch: (ids: string[]) =>
|
||||
apiJson<void>(`${B}/role/delete-batch`, { method: 'DELETE', body: JSON.stringify(ids) }),
|
||||
get: (id: string) => apiJson<IamRole>(`${B}/role/get/${encodeURIComponent(id)}`),
|
||||
list: (query?: Record<string, string>) => {
|
||||
const q = query ? new URLSearchParams(query).toString() : 'page=1&page_size=50';
|
||||
return apiJson<ListEnvelope<IamRole>>(`${B}/role/list?${q}`);
|
||||
},
|
||||
};
|
||||
|
||||
/** 用户 */
|
||||
export const iamUser = {
|
||||
create: (body: Partial<IamUser>) =>
|
||||
apiJson<IamUser>(`${B}/user/create`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
update: (id: string, body: Partial<IamUser>) =>
|
||||
apiJson<IamUser>(`${B}/user/update/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
deleteBatch: (ids: string[]) =>
|
||||
apiJson<void>(`${B}/user/delete-batch`, { method: 'DELETE', body: JSON.stringify(ids) }),
|
||||
get: (id: string) => apiJson<IamUser>(`${B}/user/get/${encodeURIComponent(id)}`),
|
||||
list: (query?: Record<string, string>) => {
|
||||
const q = query ? new URLSearchParams(query).toString() : 'page=1&page_size=20';
|
||||
return apiJson<ListEnvelope<IamUser>>(`${B}/user/list?${q}`);
|
||||
},
|
||||
};
|
||||
|
||||
/** 菜单 */
|
||||
export const iamMenu = {
|
||||
create: (body: Partial<MenuNode>) =>
|
||||
apiJson<MenuNode>(`${B}/menu/create`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
update: (id: string, body: Partial<MenuNode>) =>
|
||||
apiJson<MenuNode>(`${B}/menu/update/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
deleteBatch: (ids: string[]) =>
|
||||
apiJson<void>(`${B}/menu/delete-batch`, { method: 'DELETE', body: JSON.stringify(ids) }),
|
||||
get: (id: string) => apiJson<MenuNode>(`${B}/menu/get/${encodeURIComponent(id)}`),
|
||||
tree: () => apiJson<MenuNode[]>(`${B}/menu/tree`),
|
||||
/** 当前用户可见导航树(需 Bearer,后端从 token 解析 user_id) */
|
||||
nav: () => apiJson<MenuNode[]>(`${B}/menu/nav`),
|
||||
perms: () => apiJson<{ perms: string[] }>(`${B}/menu/perms`),
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
/** 与 Go `apiGroup` 前缀一致 */
|
||||
export const API_V1 = '/api/v1';
|
||||
@@ -0,0 +1,33 @@
|
||||
import { refreshAccessToken } from '@/lib/api/auth';
|
||||
|
||||
let inFlight: Promise<boolean> | null = null;
|
||||
|
||||
/**
|
||||
* 并发 401 时共享同一次 refresh,避免重复 POST /oauth/token。
|
||||
* refresh 失败会清空 token(由调用方传入的 setTokens 执行)。
|
||||
*/
|
||||
export function refreshTokensShared(
|
||||
getRefreshToken: () => string | null,
|
||||
setTokens: (access: string | null, refresh: string | null) => void
|
||||
): Promise<boolean> {
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
const rt = getRefreshToken();
|
||||
if (!rt) {
|
||||
return false;
|
||||
}
|
||||
const pair = await refreshAccessToken(rt);
|
||||
setTokens(pair.accessToken, pair.refreshToken);
|
||||
return true;
|
||||
} catch {
|
||||
setTokens(null, null);
|
||||
return false;
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
return inFlight;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { apiJson } from '@/lib/api/client';
|
||||
import { API_V1 } from '@/lib/api/paths';
|
||||
import type { SystemParam } from '@/lib/api/types/system-param';
|
||||
|
||||
const B = `${API_V1}/system/param`;
|
||||
|
||||
type ListEnvelope<T> = { items: T[]; total: number };
|
||||
|
||||
export const systemParam = {
|
||||
create: (body: Partial<SystemParam>) =>
|
||||
apiJson<SystemParam>(`${B}/create`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
update: (id: string, body: Partial<SystemParam>) =>
|
||||
apiJson<SystemParam>(`${B}/update/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
deleteBatch: (ids: string[]) =>
|
||||
apiJson<void>(`${B}/delete-batch`, { method: 'DELETE', body: JSON.stringify(ids) }),
|
||||
get: (query: Record<string, string>) => {
|
||||
const q = new URLSearchParams(query).toString();
|
||||
return apiJson<SystemParam>(`${B}/get?${q}`);
|
||||
},
|
||||
list: (query?: Record<string, string>) => {
|
||||
const q = query ? new URLSearchParams(query).toString() : '';
|
||||
return apiJson<ListEnvelope<SystemParam>>(q ? `${B}/list?${q}` : `${B}/list`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/** 与 Go `/api/v1` JSON 信封一致(见 docs/auth-api.md) */
|
||||
export type ApiEnvelope<T> = {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T | null;
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly httpStatus: number,
|
||||
public readonly body?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** 与 Go `iam/entity.Dept` JSON 对齐 */
|
||||
export type IamDept = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
parent_id: string;
|
||||
dept_name: string;
|
||||
dept_path: string;
|
||||
leader_id?: string | null;
|
||||
sort_order: number;
|
||||
status: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
/** 与 Go `iam/service.DeptNode` JSON 对齐(递归树) */
|
||||
export type DeptNode = IamDept & {
|
||||
children?: DeptNode[];
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
/** 与 Go `iam/service.MenuNode` / `entity.Menu` JSON 对齐 */
|
||||
export type MenuNode = {
|
||||
id: string;
|
||||
parent_id: string;
|
||||
menu_name: string;
|
||||
menu_type: number;
|
||||
perms?: string;
|
||||
path: string;
|
||||
component?: string;
|
||||
icon?: string;
|
||||
sort_order?: number;
|
||||
is_visible?: boolean;
|
||||
external_link?: string;
|
||||
children?: MenuNode[];
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
/** 与 Go `iam/entity.Role` JSON 对齐 */
|
||||
export type IamRole = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
role_code: string;
|
||||
role_name: string;
|
||||
data_scope: number;
|
||||
description: string;
|
||||
is_builtin: boolean;
|
||||
status: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
/** 与 Go `system/entity.SystemParam` JSON 对齐 */
|
||||
export type SystemParam = {
|
||||
id: string;
|
||||
param_key: string;
|
||||
param_value: string;
|
||||
param_type: string;
|
||||
param_group: string;
|
||||
param_desc: string;
|
||||
creator_id: string;
|
||||
create_time?: string | null;
|
||||
last_updater_id: string;
|
||||
update_time?: string | null;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
/** 与 Go `iam/entity.Tenant` JSON 对齐 */
|
||||
export type IamTenant = {
|
||||
id: string;
|
||||
tenant_code: string;
|
||||
tenant_name: string;
|
||||
admin_user_id?: string | null;
|
||||
status: number;
|
||||
expire_time?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
/** 与后端 iam_user JSON 对齐(展示用字段子集) */
|
||||
export type IamUser = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
dept_id?: string | null;
|
||||
user_name: string;
|
||||
real_name?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
gender?: number;
|
||||
status?: number;
|
||||
};
|
||||
Reference in New Issue
Block a user