完善后台分页与令牌刷新
This commit is contained in:
@@ -56,3 +56,12 @@ export async function logoutAdmin() {
|
||||
const { data } = await apiClient.post<ApiResponse<{ logged_out: boolean }>>('/admin/auth/logout')
|
||||
return data.data
|
||||
}
|
||||
|
||||
/** Manually refresh admin token (uses raw axios to avoid interceptor recursion) */
|
||||
export async function refreshAdminSession() {
|
||||
const refreshToken = localStorage.getItem('admin_refresh_token')
|
||||
if (!refreshToken) throw new Error('no refresh token')
|
||||
const axios = (await import('axios')).default
|
||||
const { data } = await axios.post('/api/admin/auth/refresh', { refresh_token: refreshToken })
|
||||
return data.data as AdminTokenPair
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { apiClient } from './client'
|
||||
|
||||
import { type PaginatedResult } from './orders'
|
||||
|
||||
export interface AdminUserItem {
|
||||
id: number
|
||||
phone: string
|
||||
@@ -22,9 +24,11 @@ interface ApiResponse<T> {
|
||||
data: T
|
||||
}
|
||||
|
||||
export async function fetchAdminUsers() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: AdminUserItem[] }>>('/admin/users')
|
||||
return data.data.items
|
||||
export async function fetchAdminUsers(page = 1, pageSize = 20) {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminUserItem>>>('/admin/users', {
|
||||
params: { page, page_size: pageSize },
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function freezeAdminUser(id: number, reason: string) {
|
||||
|
||||
@@ -50,3 +50,11 @@ export async function updateMe(payload: Pick<AuthUser, 'nickname' | 'avatar_url'
|
||||
const { data } = await apiClient.put<ApiResponse<AuthUser>>('/me', payload)
|
||||
return data.data
|
||||
}
|
||||
|
||||
/** Manually refresh user token (for store to call on app init) */
|
||||
export async function refreshUserToken(refreshToken: string) {
|
||||
const { data } = await apiClient.post<ApiResponse<TokenPair>>('/auth/refresh', {
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
+100
-23
@@ -1,10 +1,53 @@
|
||||
import axios from 'axios'
|
||||
import axios, { type InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
// ---- refresh retry lock ----
|
||||
let isRefreshing = false
|
||||
let pendingRequests: Array<(token: string) => void> = []
|
||||
|
||||
function subscribePendingRequests(token: string) {
|
||||
pendingRequests.forEach((cb) => cb(token))
|
||||
pendingRequests.length = 0
|
||||
}
|
||||
|
||||
function addPendingRequest(callback: (token: string) => void) {
|
||||
pendingRequests.push(callback)
|
||||
}
|
||||
|
||||
async function refreshTokenAndRetry(isAdmin = false): Promise<string> {
|
||||
const refreshTokenKey = isAdmin ? 'admin_refresh_token' : 'refresh_token'
|
||||
const refreshToken = localStorage.getItem(refreshTokenKey)
|
||||
if (!refreshToken) {
|
||||
throw new Error('no refresh token')
|
||||
}
|
||||
const endpoint = isAdmin ? '/api/admin/auth/refresh' : '/api/auth/refresh'
|
||||
// use raw axios (not apiClient) to avoid interceptor recursion
|
||||
const { data } = await axios.post(endpoint, { refresh_token: refreshToken })
|
||||
const newAccessToken = data.data.access_token
|
||||
const newRefreshToken = data.data.refresh_token
|
||||
const accessKey = isAdmin ? 'admin_access_token' : 'access_token'
|
||||
localStorage.setItem(accessKey, newAccessToken)
|
||||
localStorage.setItem(refreshTokenKey, newRefreshToken)
|
||||
return newAccessToken
|
||||
}
|
||||
|
||||
// clear user tokens
|
||||
function clearUserTokens() {
|
||||
;['access_token', 'refresh_token', 'user_id', 'phone', 'nickname', 'avatar_url', 'realname_status']
|
||||
.forEach((k) => localStorage.removeItem(k))
|
||||
}
|
||||
|
||||
// clear admin tokens
|
||||
function clearAdminTokens() {
|
||||
;['admin_access_token', 'admin_refresh_token', 'admin_id', 'admin_username']
|
||||
.forEach((k) => localStorage.removeItem(k))
|
||||
}
|
||||
|
||||
// ---- Request interceptor ----
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const url = config.url || ''
|
||||
const tokenKey = url.startsWith('/admin') ? 'admin_access_token' : 'access_token'
|
||||
@@ -15,40 +58,74 @@ apiClient.interceptors.request.use((config) => {
|
||||
return config
|
||||
})
|
||||
|
||||
// ---- Response interceptor ----
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error?.response?.status !== 401) {
|
||||
async (error) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
if (error?.response?.status !== 401 || originalRequest._retry) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
const requestUrl = error.config?.url || ''
|
||||
const requestUrl = originalRequest.url || ''
|
||||
const isAdminRequest = requestUrl.startsWith('/admin')
|
||||
const currentPath = window.location.pathname + window.location.search
|
||||
|
||||
if (isAdminRequest) {
|
||||
localStorage.removeItem('admin_access_token')
|
||||
localStorage.removeItem('admin_refresh_token')
|
||||
if (!window.location.pathname.startsWith('/admin/login')) {
|
||||
// exclude refresh endpoints themselves to avoid dead loop
|
||||
if (requestUrl.endsWith('/auth/refresh') || requestUrl.endsWith('/admin/auth/refresh')) {
|
||||
if (isAdminRequest) clearAdminTokens()
|
||||
else clearUserTokens()
|
||||
if (isAdminRequest && !window.location.pathname.startsWith('/admin/login')) {
|
||||
window.location.assign('/admin/login')
|
||||
} else if (!isAdminRequest) {
|
||||
const currentPath = window.location.pathname + window.location.search
|
||||
if (!currentPath.startsWith('/m/login') && !currentPath.startsWith('/login')) {
|
||||
const redirect = encodeURIComponent(currentPath)
|
||||
const loginPath = currentPath.startsWith('/m') ? '/m/login' : '/login'
|
||||
window.location.assign(`${loginPath}?redirect=${redirect}`)
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
localStorage.removeItem('user_id')
|
||||
localStorage.removeItem('phone')
|
||||
localStorage.removeItem('nickname')
|
||||
localStorage.removeItem('avatar_url')
|
||||
localStorage.removeItem('realname_status')
|
||||
|
||||
if (!window.location.pathname.startsWith('/m/login') && !window.location.pathname.startsWith('/m/register')) {
|
||||
const redirect = encodeURIComponent(currentPath)
|
||||
const loginPath = window.location.pathname.startsWith('/m') ? '/m/login' : '/login'
|
||||
window.location.assign(`${loginPath}?redirect=${redirect}`)
|
||||
// if already refreshing, queue up
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve) => {
|
||||
addPendingRequest((newToken: string) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
resolve(apiClient(originalRequest))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
},
|
||||
// attempt refresh
|
||||
isRefreshing = true
|
||||
try {
|
||||
const newToken = await refreshTokenAndRetry(isAdminRequest)
|
||||
subscribePendingRequests(newToken)
|
||||
originalRequest._retry = true
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
return apiClient(originalRequest)
|
||||
} catch {
|
||||
// refresh failed, clear tokens and redirect to login
|
||||
subscribePendingRequests('') // let queued requests fail
|
||||
if (isAdminRequest) {
|
||||
clearAdminTokens()
|
||||
if (!window.location.pathname.startsWith('/admin/login')) {
|
||||
window.location.assign('/admin/login')
|
||||
}
|
||||
} else {
|
||||
clearUserTokens()
|
||||
const currentPath = window.location.pathname + window.location.search
|
||||
if (!currentPath.startsWith('/m/login') && !currentPath.startsWith('/login')) {
|
||||
const redirect = encodeURIComponent(currentPath)
|
||||
const loginPath = currentPath.startsWith('/m') ? '/m/login' : '/login'
|
||||
window.location.assign(`${loginPath}?redirect=${redirect}`)
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { apiClient } from './client'
|
||||
|
||||
import { type PaginatedResult } from './orders'
|
||||
|
||||
export interface Dispute {
|
||||
id: number
|
||||
order_id: number
|
||||
@@ -30,14 +32,18 @@ export async function createDispute(orderId: number, payload: { type: string; de
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchDisputes() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: Dispute[] }>>('/disputes')
|
||||
return data.data.items
|
||||
export async function fetchDisputes(page = 1, pageSize = 20) {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Dispute>>>('/disputes', {
|
||||
params: { page, page_size: pageSize },
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchAdminDisputes() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: Dispute[] }>>('/admin/disputes')
|
||||
return data.data.items
|
||||
export async function fetchAdminDisputes(page = 1, pageSize = 20) {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Dispute>>>('/admin/disputes', {
|
||||
params: { page, page_size: pageSize },
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function arbitrateDispute(id: number, payload: { result: string; remark: string; amount?: number }) {
|
||||
|
||||
@@ -61,6 +61,12 @@ const router = createRouter({
|
||||
component: () => import("@/views/mobile/MobileOrdersView.vue"),
|
||||
meta: { layout: "blank", requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/m/orders/:id",
|
||||
name: "mobile-order-detail",
|
||||
component: () => import("@/views/account/OrderDetailView.vue"),
|
||||
meta: { layout: "blank", requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/m/seller/listings/create",
|
||||
name: "mobile-seller-listing-create",
|
||||
@@ -92,51 +98,61 @@ const router = createRouter({
|
||||
path: "/orders/create",
|
||||
name: "order-create",
|
||||
component: () => import("@/views/account/OrderCreateView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/orders",
|
||||
name: "orders",
|
||||
component: () => import("@/views/account/OrdersView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/orders/:id",
|
||||
name: "order-detail",
|
||||
component: () => import("@/views/account/OrderDetailView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/wallet",
|
||||
name: "wallet",
|
||||
component: () => import("@/views/account/WalletView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/notifications",
|
||||
name: "notifications",
|
||||
component: () => import("@/views/account/NotificationsView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/realname",
|
||||
name: "realname",
|
||||
component: () => import("@/views/account/RealnameView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/seller/listings",
|
||||
name: "seller-listings",
|
||||
component: () => import("@/views/seller/SellerListingsView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/seller/listings/create",
|
||||
name: "seller-listing-create",
|
||||
component: () => import("@/views/seller/SellerListingCreateView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/seller/handoffs",
|
||||
name: "seller-handoffs",
|
||||
component: () => import("@/views/seller/SellerHandoffsView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/seller/earnings",
|
||||
name: "seller-earnings",
|
||||
component: () => import("@/views/seller/SellerEarningsView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/admin/login",
|
||||
@@ -220,14 +236,17 @@ router.beforeEach(async (to) => {
|
||||
|
||||
if (to.meta.requiresAuth) {
|
||||
const session = useSessionStore();
|
||||
if (!session.token) {
|
||||
return { path: "/m/login", query: { redirect: to.fullPath } };
|
||||
const hasToken = !!localStorage.getItem("access_token");
|
||||
if (!hasToken) {
|
||||
const loginPath = to.path.startsWith("/m") ? "/m/login" : "/login";
|
||||
return { path: loginPath, query: { redirect: to.fullPath } };
|
||||
}
|
||||
if (!session.phone) {
|
||||
try {
|
||||
await session.loadMe();
|
||||
} catch {
|
||||
return { path: "/m/login", query: { redirect: to.fullPath } };
|
||||
const loginPath = to.path.startsWith("/m") ? "/m/login" : "/login";
|
||||
return { path: loginPath, query: { redirect: to.fullPath } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { onMounted, ref } from 'vue'
|
||||
import { arbitrateDispute, fetchAdminDisputes, type Dispute } from '@/api/disputes'
|
||||
import { fetchAdminFileBlob } from '@/api/files'
|
||||
import { disputeStatusLabel } from '@/utils/statusLabels'
|
||||
import { formatDateTime } from '@/utils/time'
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
@@ -14,18 +15,28 @@ const evidenceDispute = ref<Dispute | null>(null)
|
||||
const result = ref('release_deposit')
|
||||
const remark = ref('')
|
||||
const amount = ref<number | undefined>()
|
||||
const currentPage = ref(1)
|
||||
const currentPageSize = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
onMounted(loadDisputes)
|
||||
|
||||
async function loadDisputes() {
|
||||
loading.value = true
|
||||
try {
|
||||
disputes.value = await fetchAdminDisputes()
|
||||
const res = await fetchAdminDisputes(currentPage.value, currentPageSize.value)
|
||||
disputes.value = res.items
|
||||
total.value = res.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
currentPage.value = 1
|
||||
loadDisputes()
|
||||
}
|
||||
|
||||
function openArbitration(row: Dispute) {
|
||||
activeDispute.value = row
|
||||
result.value = row.arbitration_result || 'release_deposit'
|
||||
@@ -99,7 +110,7 @@ function readError(error: unknown, fallback: string) {
|
||||
<div class="page-header">
|
||||
<p class="eyebrow">Arbitration</p>
|
||||
<h1>仲裁中心</h1>
|
||||
<p>处理无法登录、资产损失、哈夫币争议和超时归还。</p>
|
||||
<p>处理无法登录、资产损失、哈夫币争议和结账争议。</p>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" class="table-panel" :data="disputes">
|
||||
@@ -109,20 +120,30 @@ function readError(error: unknown, fallback: string) {
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">{{ disputeStatusLabel(row.status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="说明" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="arbitration_result" label="结果" width="150" />
|
||||
<el-table-column label="证据" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" :disabled="evidenceItems(row).length === 0" @click="evidenceDispute = row">查看</el-button>
|
||||
</template>
|
||||
<el-table-column label="创建时间" min-width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<el-table-column prop="arbitration_result" label="仲裁结果" width="150" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" :disabled="evidenceItems(row).length === 0" @click="evidenceDispute = row">证据</el-button>
|
||||
<el-button size="small" :disabled="row.status === 'resolved'" @click="openArbitration(row)">仲裁</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrap" v-if="total > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="currentPageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="loadDisputes"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog :model-value="!!activeDispute" title="申诉仲裁" width="560px" @update:model-value="activeDispute = null">
|
||||
<div v-if="activeDispute" class="dialog-body">
|
||||
<p><strong>{{ activeDispute.order_no }}</strong> · {{ activeDispute.title }}</p>
|
||||
@@ -134,6 +155,7 @@ function readError(error: unknown, fallback: string) {
|
||||
<el-option label="释放押金" value="release_deposit" />
|
||||
<el-option label="赔付号主" value="compensate_owner" />
|
||||
<el-option label="关闭订单" value="order_close" />
|
||||
<el-option label="标记异常" value="mark_abnormal" />
|
||||
</el-select>
|
||||
<el-input-number
|
||||
v-if="['partial_refund', 'deduct_deposit', 'compensate_owner'].includes(result)"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ElMessage } from 'element-plus'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { fetchAdminUsers, freezeAdminUser, unfreezeAdminUser, type AdminUserItem } from '@/api/adminUsers'
|
||||
import { realnameStatusLabel, riskStatusLabel, userStatusLabel } from '@/utils/statusLabels'
|
||||
import { userStatusLabel } from '@/utils/statusLabels'
|
||||
import { formatDateTime } from '@/utils/time'
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -11,18 +11,28 @@ const submitting = ref(false)
|
||||
const users = ref<AdminUserItem[]>([])
|
||||
const activeUser = ref<AdminUserItem | null>(null)
|
||||
const freezeReason = ref('')
|
||||
const currentPage = ref(1)
|
||||
const currentPageSize = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
onMounted(loadUsers)
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
users.value = await fetchAdminUsers()
|
||||
const result = await fetchAdminUsers(currentPage.value, currentPageSize.value)
|
||||
users.value = result.items
|
||||
total.value = result.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
currentPage.value = 1
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
function openFreeze(row: AdminUserItem) {
|
||||
activeUser.value = row
|
||||
freezeReason.value = ''
|
||||
@@ -71,30 +81,20 @@ function readError(error: unknown, fallback: string) {
|
||||
<div class="page-header">
|
||||
<p class="eyebrow">Users</p>
|
||||
<h1>用户管理</h1>
|
||||
<p>查看用户实名、风险、信用和业务数量,处理冻结与解冻。</p>
|
||||
<p>查看用户信息和状态,处理冻结与解冻。</p>
|
||||
</div>
|
||||
<el-button @click="loadUsers">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" class="table-panel" :data="users">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="phone" label="手机号" min-width="130" />
|
||||
<el-table-column prop="id" label="用户ID" width="80" />
|
||||
<el-table-column prop="nickname" label="昵称" min-width="130" />
|
||||
<el-table-column label="实名" width="110">
|
||||
<template #default="{ row }">{{ realnameStatusLabel(row.realname_status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="风险" width="110">
|
||||
<template #default="{ row }">{{ riskStatusLabel(row.risk_status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="credit_score" label="信用分" width="100" />
|
||||
<el-table-column prop="phone" label="手机号" min-width="130" />
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">{{ userStatusLabel(row.status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="order_count" label="订单" width="90" />
|
||||
<el-table-column prop="listing_count" label="发布" width="90" />
|
||||
<el-table-column prop="dispute_count" label="申诉" width="90" />
|
||||
<el-table-column label="最近登录" min-width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.last_login_at) }}</template>
|
||||
<el-table-column label="注册时间" min-width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
@@ -106,6 +106,18 @@ function readError(error: unknown, fallback: string) {
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrap" v-if="total > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="currentPageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="loadUsers"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog :model-value="!!activeUser" title="冻结用户" width="560px" @update:model-value="activeUser = null">
|
||||
<div v-if="activeUser" class="dialog-body">
|
||||
<p><strong>{{ activeUser.phone }}</strong> · {{ activeUser.nickname }}</p>
|
||||
|
||||
Reference in New Issue
Block a user