优化前端认证与类型管理
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
|
|
||||||
import { type PaginatedResult } from './orders'
|
import type { ApiResponse, PaginatedResult } from './types'
|
||||||
|
|
||||||
export interface AdminAuditLog {
|
export interface AdminAuditLog {
|
||||||
id: number
|
id: number
|
||||||
@@ -25,12 +25,6 @@ export interface AdminAuditQuery {
|
|||||||
page_size?: number
|
page_size?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
|
||||||
code: string
|
|
||||||
message: string
|
|
||||||
data: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchAdminAuditLogs(query: AdminAuditQuery = {}) {
|
export async function fetchAdminAuditLogs(query: AdminAuditQuery = {}) {
|
||||||
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined))
|
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined))
|
||||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminAuditLog>>>('/admin/audit-logs', { params })
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminAuditLog>>>('/admin/audit-logs', { params })
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
|
import type { ApiResponse } from './types'
|
||||||
|
import { getRefreshToken, setAuthTokens } from '@/utils/authStorage'
|
||||||
|
import type { UserStatus } from '@/types/status'
|
||||||
|
|
||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
nickname: string
|
nickname: string
|
||||||
status: string
|
status: UserStatus
|
||||||
last_login_at?: string
|
last_login_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,12 +31,6 @@ export interface AdminCaptcha {
|
|||||||
expires_in: number
|
expires_in: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
|
||||||
code: string
|
|
||||||
message: string
|
|
||||||
data: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchAdminCaptcha() {
|
export async function fetchAdminCaptcha() {
|
||||||
const { data } = await apiClient.get<ApiResponse<AdminCaptcha>>('/admin/auth/captcha')
|
const { data } = await apiClient.get<ApiResponse<AdminCaptcha>>('/admin/auth/captcha')
|
||||||
return data.data
|
return data.data
|
||||||
@@ -61,8 +58,9 @@ export async function logoutAdmin() {
|
|||||||
|
|
||||||
/** Manually refresh admin token (uses raw axios to avoid interceptor recursion) */
|
/** Manually refresh admin token (uses raw axios to avoid interceptor recursion) */
|
||||||
export async function refreshAdminSession() {
|
export async function refreshAdminSession() {
|
||||||
const refreshToken = localStorage.getItem('admin_refresh_token')
|
const refreshToken = getRefreshToken('admin')
|
||||||
if (!refreshToken) throw new Error('no refresh token')
|
if (!refreshToken) throw new Error('no refresh token')
|
||||||
const { data } = await axios.post('/api/admin/auth/refresh', { refresh_token: refreshToken })
|
const { data } = await axios.post<ApiResponse<AdminTokenPair>>('/api/admin/auth/refresh', { refresh_token: refreshToken })
|
||||||
return data.data as AdminTokenPair
|
setAuthTokens('admin', data.data)
|
||||||
|
return data.data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
|
import type { ApiResponse } from './types'
|
||||||
|
import type { DisputeStatus, OrderStatus } from '@/types/status'
|
||||||
|
|
||||||
export interface DashboardMetrics {
|
export interface DashboardMetrics {
|
||||||
total_users: number
|
total_users: number
|
||||||
@@ -24,7 +26,7 @@ export interface DashboardRecentOrder {
|
|||||||
title: string
|
title: string
|
||||||
renter_id: number
|
renter_id: number
|
||||||
owner_id: number
|
owner_id: number
|
||||||
status: string
|
status: OrderStatus
|
||||||
rent_amount: number
|
rent_amount: number
|
||||||
deposit_amount: number
|
deposit_amount: number
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -36,7 +38,7 @@ export interface DashboardRecentDispute {
|
|||||||
order_no: string
|
order_no: string
|
||||||
title: string
|
title: string
|
||||||
type: string
|
type: string
|
||||||
status: string
|
status: DisputeStatus
|
||||||
initiator_id: number
|
initiator_id: number
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
@@ -49,12 +51,6 @@ export interface AdminDashboard {
|
|||||||
generated_at: string
|
generated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
|
||||||
code: string
|
|
||||||
message: string
|
|
||||||
data: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchAdminDashboard() {
|
export async function fetchAdminDashboard() {
|
||||||
const { data } = await apiClient.get<ApiResponse<AdminDashboard>>('/admin/dashboard')
|
const { data } = await apiClient.get<ApiResponse<AdminDashboard>>('/admin/dashboard')
|
||||||
return data.data
|
return data.data
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
|
|
||||||
import { type PaginatedResult } from './orders'
|
import type { ApiResponse, PaginatedResult } from './types'
|
||||||
|
import type { RealnameStatusValue, RiskStatus, UserStatus } from '@/types/status'
|
||||||
|
|
||||||
export interface AdminUserItem {
|
export interface AdminUserItem {
|
||||||
id: number
|
id: number
|
||||||
phone: string
|
phone: string
|
||||||
nickname: string
|
nickname: string
|
||||||
realname_status: string
|
realname_status: RealnameStatusValue
|
||||||
risk_status: string
|
risk_status: RiskStatus
|
||||||
credit_score: number
|
credit_score: number
|
||||||
status: string
|
status: UserStatus
|
||||||
order_count: number
|
order_count: number
|
||||||
listing_count: number
|
listing_count: number
|
||||||
dispute_count: number
|
dispute_count: number
|
||||||
@@ -18,12 +19,6 @@ export interface AdminUserItem {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
|
||||||
code: string
|
|
||||||
message: string
|
|
||||||
data: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchAdminUsers(page = 1, pageSize = 20) {
|
export async function fetchAdminUsers(page = 1, pageSize = 20) {
|
||||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminUserItem>>>('/admin/users', {
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminUserItem>>>('/admin/users', {
|
||||||
params: { page, page_size: pageSize },
|
params: { page, page_size: pageSize },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
|
|
||||||
import { type PaginatedResult } from './orders'
|
import type { ApiResponse, PaginatedResult } from './types'
|
||||||
|
import type { BalanceType, LedgerDirection } from '@/types/status'
|
||||||
|
|
||||||
export interface AdminWalletLedger {
|
export interface AdminWalletLedger {
|
||||||
id: number
|
id: number
|
||||||
@@ -10,10 +11,10 @@ export interface AdminWalletLedger {
|
|||||||
user_nickname: string
|
user_nickname: string
|
||||||
order_id?: number
|
order_id?: number
|
||||||
order_no: string
|
order_no: string
|
||||||
direction: string
|
direction: LedgerDirection
|
||||||
amount: number
|
amount: number
|
||||||
balance_after: number
|
balance_after: number
|
||||||
balance_type: string
|
balance_type: BalanceType
|
||||||
biz_type: string
|
biz_type: string
|
||||||
biz_no: string
|
biz_no: string
|
||||||
remark: string
|
remark: string
|
||||||
@@ -28,12 +29,6 @@ export interface AdminWalletLedgerQuery {
|
|||||||
page_size?: number
|
page_size?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
|
||||||
code: string
|
|
||||||
message: string
|
|
||||||
data: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchAdminWalletLedger(query: AdminWalletLedgerQuery = {}) {
|
export async function fetchAdminWalletLedger(query: AdminWalletLedgerQuery = {}) {
|
||||||
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined))
|
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined))
|
||||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminWalletLedger>>>('/admin/wallet/ledger', { params })
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminWalletLedger>>>('/admin/wallet/ledger', { params })
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
|
import type { ApiResponse } from './types'
|
||||||
|
import type { RealnameStatusValue, RiskStatus, UserStatus } from '@/types/status'
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: number
|
id: number
|
||||||
phone: string
|
phone: string
|
||||||
nickname: string
|
nickname: string
|
||||||
avatar_url: string
|
avatar_url: string
|
||||||
realname_status: string
|
realname_status: RealnameStatusValue
|
||||||
risk_status: string
|
risk_status: RiskStatus
|
||||||
credit_score: number
|
credit_score: number
|
||||||
status: string
|
status: UserStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenPair {
|
export interface TokenPair {
|
||||||
@@ -23,12 +25,6 @@ export interface LoginData {
|
|||||||
tokens: TokenPair
|
tokens: TokenPair
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
|
||||||
code: string
|
|
||||||
message: string
|
|
||||||
data: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendSmsCode(phone: string) {
|
export async function sendSmsCode(phone: string) {
|
||||||
const { data } = await apiClient.post<ApiResponse<{ phone: string; expires_in: number }>>('/auth/sms/send', {
|
const { data } = await apiClient.post<ApiResponse<{ phone: string; expires_in: number }>>('/auth/sms/send', {
|
||||||
phone,
|
phone,
|
||||||
|
|||||||
+98
-87
@@ -1,131 +1,142 @@
|
|||||||
import axios, { type InternalAxiosRequestConfig } from 'axios'
|
import axios, { type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearAuthStorage,
|
||||||
|
getAccessToken,
|
||||||
|
getLoginPath,
|
||||||
|
getRefreshToken,
|
||||||
|
setAuthTokens,
|
||||||
|
type AuthScope,
|
||||||
|
} from '@/utils/authStorage'
|
||||||
|
import type { ApiResponse } from './types'
|
||||||
|
|
||||||
export const apiClient = axios.create({
|
export const apiClient = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- refresh retry lock ----
|
export async function unwrapData<T>(request: Promise<AxiosResponse<ApiResponse<T>>>) {
|
||||||
let isRefreshing = false
|
const { data } = await request
|
||||||
let pendingRequests: Array<(token: string) => void> = []
|
return data.data
|
||||||
|
|
||||||
function subscribePendingRequests(token: string) {
|
|
||||||
pendingRequests.forEach((cb) => cb(token))
|
|
||||||
pendingRequests.length = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function addPendingRequest(callback: (token: string) => void) {
|
type RetryRequest = {
|
||||||
pendingRequests.push(callback)
|
resolve: (token: string) => void
|
||||||
|
reject: (error: unknown) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshTokenAndRetry(isAdmin = false): Promise<string> {
|
type RefreshState = {
|
||||||
const refreshTokenKey = isAdmin ? 'admin_refresh_token' : 'refresh_token'
|
refreshing: boolean
|
||||||
const refreshToken = localStorage.getItem(refreshTokenKey)
|
pendingRequests: RetryRequest[]
|
||||||
if (!refreshToken) {
|
}
|
||||||
throw new Error('no refresh token')
|
|
||||||
}
|
type RetriableRequestConfig = InternalAxiosRequestConfig & { _retry?: boolean }
|
||||||
const endpoint = isAdmin ? '/api/admin/auth/refresh' : '/api/auth/refresh'
|
|
||||||
// use raw axios (not apiClient) to avoid interceptor recursion
|
const refreshStates: Record<AuthScope, RefreshState> = {
|
||||||
|
user: {
|
||||||
|
refreshing: false,
|
||||||
|
pendingRequests: [],
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
refreshing: false,
|
||||||
|
pendingRequests: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePendingRequests(scope: AuthScope, token: string) {
|
||||||
|
const pendingRequests = refreshStates[scope].pendingRequests.splice(0)
|
||||||
|
pendingRequests.forEach(({ resolve }) => resolve(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectPendingRequests(scope: AuthScope, error: unknown) {
|
||||||
|
const pendingRequests = refreshStates[scope].pendingRequests.splice(0)
|
||||||
|
pendingRequests.forEach(({ reject }) => reject(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshToken(scope: AuthScope): Promise<string> {
|
||||||
|
const refreshToken = getRefreshToken(scope)
|
||||||
|
if (!refreshToken) throw new Error('no refresh token')
|
||||||
|
|
||||||
|
const endpoint = scope === 'admin' ? '/api/admin/auth/refresh' : '/api/auth/refresh'
|
||||||
const { data } = await axios.post(endpoint, { refresh_token: refreshToken })
|
const { data } = await axios.post(endpoint, { refresh_token: refreshToken })
|
||||||
const newAccessToken = data.data.access_token
|
const tokens = {
|
||||||
const newRefreshToken = data.data.refresh_token
|
access_token: data.data.access_token,
|
||||||
const accessKey = isAdmin ? 'admin_access_token' : 'access_token'
|
refresh_token: data.data.refresh_token,
|
||||||
localStorage.setItem(accessKey, newAccessToken)
|
}
|
||||||
localStorage.setItem(refreshTokenKey, newRefreshToken)
|
setAuthTokens(scope, tokens)
|
||||||
return newAccessToken
|
return tokens.access_token
|
||||||
}
|
}
|
||||||
|
|
||||||
// clear user tokens
|
function getRequestScope(url = ''): AuthScope {
|
||||||
function clearUserTokens() {
|
return url.startsWith('/admin') ? 'admin' : 'user'
|
||||||
;['access_token', 'refresh_token', 'user_id', 'phone', 'nickname', 'avatar_url', 'realname_status']
|
|
||||||
.forEach((k) => localStorage.removeItem(k))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// clear admin tokens
|
function redirectToLogin(scope: AuthScope) {
|
||||||
function clearAdminTokens() {
|
clearAuthStorage(scope)
|
||||||
;['admin_access_token', 'admin_refresh_token', 'admin_id', 'admin_username']
|
const currentPath = window.location.pathname + window.location.search
|
||||||
.forEach((k) => localStorage.removeItem(k))
|
const loginPath = getLoginPath(scope, currentPath)
|
||||||
|
if (currentPath.startsWith(loginPath)) return
|
||||||
|
|
||||||
|
if (scope === 'admin') {
|
||||||
|
window.location.assign(loginPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.assign(`${loginPath}?redirect=${encodeURIComponent(currentPath)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRefreshRequest(url = '') {
|
||||||
|
return url.endsWith('/auth/refresh') || url.endsWith('/admin/auth/refresh')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Request interceptor ----
|
|
||||||
apiClient.interceptors.request.use((config) => {
|
apiClient.interceptors.request.use((config) => {
|
||||||
const url = config.url || ''
|
const scope = getRequestScope(config.url || '')
|
||||||
const tokenKey = url.startsWith('/admin') ? 'admin_access_token' : 'access_token'
|
const token = getAccessToken(scope)
|
||||||
const token = localStorage.getItem(tokenKey)
|
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- Response interceptor ----
|
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async (error) => {
|
async (error) => {
|
||||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
const originalRequest = error.config as RetriableRequestConfig | undefined
|
||||||
|
if (!originalRequest || error?.response?.status !== 401 || originalRequest._retry) {
|
||||||
if (error?.response?.status !== 401 || originalRequest._retry) {
|
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestUrl = originalRequest.url || ''
|
const requestUrl = originalRequest.url || ''
|
||||||
const isAdminRequest = requestUrl.startsWith('/admin')
|
const scope = getRequestScope(requestUrl)
|
||||||
|
if (isRefreshRequest(requestUrl)) {
|
||||||
// exclude refresh endpoints themselves to avoid dead loop
|
redirectToLogin(scope)
|
||||||
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)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// if already refreshing, queue up
|
const state = refreshStates[scope]
|
||||||
if (isRefreshing) {
|
if (state.refreshing) {
|
||||||
return new Promise((resolve) => {
|
return new Promise<string>((resolve, reject) => {
|
||||||
addPendingRequest((newToken: string) => {
|
state.pendingRequests.push({ resolve, reject })
|
||||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
}).then((newToken) => {
|
||||||
resolve(apiClient(originalRequest))
|
originalRequest._retry = true
|
||||||
})
|
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||||
|
return apiClient(originalRequest)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// attempt refresh
|
state.refreshing = true
|
||||||
isRefreshing = true
|
|
||||||
try {
|
try {
|
||||||
const newToken = await refreshTokenAndRetry(isAdminRequest)
|
const newToken = await refreshToken(scope)
|
||||||
subscribePendingRequests(newToken)
|
resolvePendingRequests(scope, newToken)
|
||||||
originalRequest._retry = true
|
originalRequest._retry = true
|
||||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||||
return apiClient(originalRequest)
|
return apiClient(originalRequest)
|
||||||
} catch {
|
} catch (refreshError) {
|
||||||
// refresh failed, clear tokens and redirect to login
|
rejectPendingRequests(scope, refreshError)
|
||||||
subscribePendingRequests('') // let queued requests fail
|
redirectToLogin(scope)
|
||||||
if (isAdminRequest) {
|
return Promise.reject(refreshError)
|
||||||
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 {
|
} finally {
|
||||||
isRefreshing = false
|
state.refreshing = false
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
|
|
||||||
import { type PaginatedResult } from './orders'
|
import type { ApiResponse, PaginatedResult } from './types'
|
||||||
|
import type { DisputeStatus } from '@/types/status'
|
||||||
|
|
||||||
export interface Dispute {
|
export interface Dispute {
|
||||||
id: number
|
id: number
|
||||||
@@ -10,7 +11,7 @@ export interface Dispute {
|
|||||||
initiator_id: number
|
initiator_id: number
|
||||||
target_user_id: number
|
target_user_id: number
|
||||||
type: string
|
type: string
|
||||||
status: string
|
status: DisputeStatus
|
||||||
description: string
|
description: string
|
||||||
evidence_urls?: string[]
|
evidence_urls?: string[]
|
||||||
arbitration_result: string
|
arbitration_result: string
|
||||||
@@ -21,12 +22,6 @@ export interface Dispute {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
|
||||||
code: string
|
|
||||||
message: string
|
|
||||||
data: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createDispute(orderId: number, payload: { type: string; description: string; evidence_urls?: string[] }) {
|
export async function createDispute(orderId: number, payload: { type: string; description: string; evidence_urls?: string[] }) {
|
||||||
const { data } = await apiClient.post<ApiResponse<Dispute>>(`/orders/${orderId}/dispute`, payload)
|
const { data } = await apiClient.post<ApiResponse<Dispute>>(`/orders/${orderId}/dispute`, payload)
|
||||||
return data.data
|
return data.data
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
|
import type { ApiResponse } from './types'
|
||||||
|
|
||||||
export interface UploadedFile {
|
export interface UploadedFile {
|
||||||
object_key: string
|
object_key: string
|
||||||
@@ -8,12 +9,6 @@ export interface UploadedFile {
|
|||||||
size: number
|
size: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
|
||||||
code: string
|
|
||||||
message: string
|
|
||||||
data: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uploadFile(file: File, scene: string) {
|
export async function uploadFile(file: File, scene: string) {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file)
|
form.append('file', file)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
|
import type { ApiResponse } from './types'
|
||||||
import {
|
import {
|
||||||
mergeListingPublishOptions,
|
mergeListingPublishOptions,
|
||||||
type ListingPublishOptions,
|
type ListingPublishOptions,
|
||||||
@@ -19,12 +20,6 @@ export interface MobileHomeConfig {
|
|||||||
publish_options: ListingPublishOptions
|
publish_options: ListingPublishOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
|
||||||
code: string
|
|
||||||
message: string
|
|
||||||
data: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export const defaultHomeAnnouncements = [
|
export const defaultHomeAnnouncements = [
|
||||||
'平台担保交易,拒绝私下转账/共享验证码,交接全程留痕。',
|
'平台担保交易,拒绝私下转账/共享验证码,交接全程留痕。',
|
||||||
'优先推荐同地区账号,减少异地登录保护触发。',
|
'优先推荐同地区账号,减少异地登录保护触发。',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
|
import type { ApiResponse } from './types'
|
||||||
|
|
||||||
export type ChargeMode = '赠送' | '收费'
|
export type ChargeMode = '赠送' | '收费'
|
||||||
|
|
||||||
@@ -105,12 +106,6 @@ export interface ListingPublishOptions {
|
|||||||
ratio_config: PublishRatioConfig
|
ratio_config: PublishRatioConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
|
||||||
code: string
|
|
||||||
message: string
|
|
||||||
data: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export const emptyListingPublishOptions: ListingPublishOptions = {
|
export const emptyListingPublishOptions: ListingPublishOptions = {
|
||||||
server_options: [],
|
server_options: [],
|
||||||
face_options: [],
|
face_options: [],
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
|
import type { ApiResponse } from './types'
|
||||||
|
import type { ListingReviewStatus, ListingStatus } from '@/types/status'
|
||||||
|
|
||||||
export interface Listing {
|
export interface Listing {
|
||||||
id: number
|
id: number
|
||||||
@@ -20,8 +22,8 @@ export interface Listing {
|
|||||||
deposit_amount: number
|
deposit_amount: number
|
||||||
is_accelerated_sale?: boolean
|
is_accelerated_sale?: boolean
|
||||||
in_transaction: boolean
|
in_transaction: boolean
|
||||||
status: string
|
status: ListingStatus
|
||||||
review_status: string
|
review_status: ListingReviewStatus
|
||||||
review_reason: string
|
review_reason: string
|
||||||
published_at?: string
|
published_at?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -41,12 +43,6 @@ export interface ListingPayload {
|
|||||||
deposit_amount: number
|
deposit_amount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
|
||||||
code: string
|
|
||||||
message: string
|
|
||||||
data: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchListings() {
|
export async function fetchListings() {
|
||||||
const { data } = await apiClient.get<ApiResponse<{ items: Listing[] }>>('/listings')
|
const { data } = await apiClient.get<ApiResponse<{ items: Listing[] }>>('/listings')
|
||||||
return data.data.items
|
return data.data.items
|
||||||
@@ -84,8 +80,8 @@ export async function fetchPendingReviewListings() {
|
|||||||
|
|
||||||
export interface AdminListingQuery {
|
export interface AdminListingQuery {
|
||||||
owner_id?: string
|
owner_id?: string
|
||||||
status?: string
|
status?: ListingStatus | ''
|
||||||
review_status?: string
|
review_status?: ListingReviewStatus | ''
|
||||||
limit?: number
|
limit?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
|
|
||||||
import { type PaginatedResult } from './orders'
|
import type { ApiResponse, PaginatedResult } from './types'
|
||||||
|
|
||||||
export interface NotificationItem {
|
export interface NotificationItem {
|
||||||
id: number
|
id: number
|
||||||
@@ -14,12 +14,6 @@ export interface NotificationItem {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
|
||||||
code: string
|
|
||||||
message: string
|
|
||||||
data: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchNotifications(page = 1, pageSize = 20) {
|
export async function fetchNotifications(page = 1, pageSize = 20) {
|
||||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<NotificationItem>>>('/notifications', {
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<NotificationItem>>>('/notifications', {
|
||||||
params: { page, page_size: pageSize },
|
params: { page, page_size: pageSize },
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
|
import type { ApiResponse } from './types'
|
||||||
|
import type { HandoffStatus, OrderStatus, SettlementStatus } from '@/types/status'
|
||||||
|
|
||||||
export interface Order {
|
export interface Order {
|
||||||
id: number
|
id: number
|
||||||
@@ -19,9 +21,9 @@ export interface Order {
|
|||||||
deposit_amount: number
|
deposit_amount: number
|
||||||
platform_fee: number
|
platform_fee: number
|
||||||
account_snapshot?: Record<string, unknown>
|
account_snapshot?: Record<string, unknown>
|
||||||
status: string
|
status: OrderStatus
|
||||||
handoff_status: string
|
handoff_status: HandoffStatus
|
||||||
settlement_status: string
|
settlement_status: SettlementStatus
|
||||||
checkout?: Checkout
|
checkout?: Checkout
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
@@ -31,7 +33,7 @@ export interface Checkout {
|
|||||||
id: number
|
id: number
|
||||||
order_id: number
|
order_id: number
|
||||||
initiated_by: number
|
initiated_by: number
|
||||||
status: string
|
status: SettlementStatus
|
||||||
rent_amount: number
|
rent_amount: number
|
||||||
owner_rent_amount: number
|
owner_rent_amount: number
|
||||||
platform_fee: number
|
platform_fee: number
|
||||||
@@ -81,19 +83,6 @@ export interface CounterCheckoutPayload {
|
|||||||
evidence_urls: string[]
|
evidence_urls: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
|
||||||
code: string
|
|
||||||
message: string
|
|
||||||
data: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedResult<T> {
|
|
||||||
items: T[]
|
|
||||||
total: number
|
|
||||||
page: number
|
|
||||||
page_size: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createOrder(listingId: number) {
|
export async function createOrder(listingId: number) {
|
||||||
const { data } = await apiClient.post<ApiResponse<Order>>('/orders', {
|
const { data } = await apiClient.post<ApiResponse<Order>>('/orders', {
|
||||||
listing_id: listingId,
|
listing_id: listingId,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
|
import type { ApiResponse } from './types'
|
||||||
|
import type { RealnameStatusValue } from '@/types/status'
|
||||||
|
|
||||||
export interface RealnameStatus {
|
export interface RealnameStatus {
|
||||||
status: string
|
status: RealnameStatusValue
|
||||||
provider?: string
|
provider?: string
|
||||||
provider_order_no?: string
|
provider_order_no?: string
|
||||||
masked_name?: string
|
masked_name?: string
|
||||||
@@ -10,12 +12,6 @@ export interface RealnameStatus {
|
|||||||
fail_reason?: string
|
fail_reason?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
|
||||||
code: string
|
|
||||||
message: string
|
|
||||||
data: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startRealname(name: string, idNo: string) {
|
export async function startRealname(name: string, idNo: string) {
|
||||||
const { data } = await apiClient.post<ApiResponse<RealnameStatus>>('/realname/start', {
|
const { data } = await apiClient.post<ApiResponse<RealnameStatus>>('/realname/start', {
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
|
import type { ApiResponse } from './types'
|
||||||
|
|
||||||
export interface SystemConfig {
|
export interface SystemConfig {
|
||||||
id: number
|
id: number
|
||||||
@@ -10,12 +11,6 @@ export interface SystemConfig {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
|
||||||
code: string
|
|
||||||
message: string
|
|
||||||
data: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchSystemConfigs() {
|
export async function fetchSystemConfigs() {
|
||||||
const { data } = await apiClient.get<ApiResponse<{ items: SystemConfig[] }>>('/admin/system-configs')
|
const { data } = await apiClient.get<ApiResponse<{ items: SystemConfig[] }>>('/admin/system-configs')
|
||||||
return data.data.items
|
return data.data.items
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export interface ApiResponse<T> {
|
||||||
|
code: string
|
||||||
|
message: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResult<T> {
|
||||||
|
items: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
|
|
||||||
import { type PaginatedResult } from './orders'
|
import type { ApiResponse, PaginatedResult } from './types'
|
||||||
|
import type { BalanceType, LedgerDirection, WalletStatus } from '@/types/status'
|
||||||
|
|
||||||
export interface WalletAccount {
|
export interface WalletAccount {
|
||||||
user_id: number
|
user_id: number
|
||||||
available_balance: number
|
available_balance: number
|
||||||
frozen_balance: number
|
frozen_balance: number
|
||||||
status: string
|
status: WalletStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WalletLedger {
|
export interface WalletLedger {
|
||||||
@@ -14,22 +15,16 @@ export interface WalletLedger {
|
|||||||
ledger_no: string
|
ledger_no: string
|
||||||
user_id: number
|
user_id: number
|
||||||
order_id?: number
|
order_id?: number
|
||||||
direction: string
|
direction: LedgerDirection
|
||||||
amount: number
|
amount: number
|
||||||
balance_after: number
|
balance_after: number
|
||||||
balance_type: string
|
balance_type: BalanceType
|
||||||
biz_type: string
|
biz_type: string
|
||||||
biz_no: string
|
biz_no: string
|
||||||
remark: string
|
remark: string
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
|
||||||
code: string
|
|
||||||
message: string
|
|
||||||
data: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchWalletBalance() {
|
export async function fetchWalletBalance() {
|
||||||
const { data } = await apiClient.get<ApiResponse<WalletAccount>>('/wallet/balance')
|
const { data } = await apiClient.get<ApiResponse<WalletAccount>>('/wallet/balance')
|
||||||
return data.data
|
return data.data
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ export function readPublishDraft(draftKey: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function writePublishDraft(draftKey: string, draft: PublishDraft) {
|
export function writePublishDraft(draftKey: string, draft: PublishDraft) {
|
||||||
localStorage.setItem(draftKey, JSON.stringify(draft))
|
const nextValue = JSON.stringify(draft)
|
||||||
|
if (localStorage.getItem(draftKey) === nextValue) return
|
||||||
|
localStorage.setItem(draftKey, nextValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removePublishDraft(draftKey: string) {
|
export function removePublishDraft(draftKey: string) {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import {
|
|||||||
import type { PublishForm } from '@/types/publish'
|
import type { PublishForm } from '@/types/publish'
|
||||||
import { commonOnlineTimes, dailyLossOptions, formatNumber, roundRatio } from '@/utils/pricing'
|
import { commonOnlineTimes, dailyLossOptions, formatNumber, roundRatio } from '@/utils/pricing'
|
||||||
|
|
||||||
|
const draftSaveDelay = 400
|
||||||
|
|
||||||
interface UsePublishFormOptions {
|
interface UsePublishFormOptions {
|
||||||
draftKey: string
|
draftKey: string
|
||||||
submitSuccessPath: string
|
submitSuccessPath: string
|
||||||
@@ -51,6 +53,7 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
|||||||
const screenshotFiles = reactive<Record<string, string>>({})
|
const screenshotFiles = reactive<Record<string, string>>({})
|
||||||
const screenshotPreviews = reactive<Record<string, string>>({})
|
const screenshotPreviews = reactive<Record<string, string>>({})
|
||||||
const selectedSkins = ref<string[]>([])
|
const selectedSkins = ref<string[]>([])
|
||||||
|
let draftSaveTimer: number | undefined
|
||||||
|
|
||||||
const pricing = usePricingCalculator({
|
const pricing = usePricingCalculator({
|
||||||
form,
|
form,
|
||||||
@@ -124,8 +127,34 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function saveDraft(saveOptions: { force?: boolean } = {}) {
|
function saveDraft(saveOptions: { force?: boolean } = {}) {
|
||||||
if (suppressDraftSave.value) return
|
if (suppressDraftSave.value) {
|
||||||
|
clearDraftSaveTimer()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!saveOptions.force && !draftReady.value) return
|
if (!saveOptions.force && !draftReady.value) return
|
||||||
|
if (!saveOptions.force) {
|
||||||
|
scheduleDraftSave()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearDraftSaveTimer()
|
||||||
|
writeDraft()
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleDraftSave() {
|
||||||
|
clearDraftSaveTimer()
|
||||||
|
draftSaveTimer = window.setTimeout(() => {
|
||||||
|
draftSaveTimer = undefined
|
||||||
|
writeDraft()
|
||||||
|
}, draftSaveDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDraftSaveTimer() {
|
||||||
|
if (draftSaveTimer === undefined) return
|
||||||
|
window.clearTimeout(draftSaveTimer)
|
||||||
|
draftSaveTimer = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeDraft() {
|
||||||
writePublishDraft(
|
writePublishDraft(
|
||||||
options.draftKey,
|
options.draftKey,
|
||||||
buildPublishDraft({
|
buildPublishDraft({
|
||||||
@@ -178,6 +207,7 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
suppressDraftSave.value = true
|
suppressDraftSave.value = true
|
||||||
|
clearDraftSaveTimer()
|
||||||
resetDraftState()
|
resetDraftState()
|
||||||
removePublishDraft(options.draftKey)
|
removePublishDraft(options.draftKey)
|
||||||
options.notifySuccess('已重置')
|
options.notifySuccess('已重置')
|
||||||
@@ -349,6 +379,7 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
|||||||
})
|
})
|
||||||
removePublishDraft(options.draftKey)
|
removePublishDraft(options.draftKey)
|
||||||
suppressDraftSave.value = true
|
suppressDraftSave.value = true
|
||||||
|
clearDraftSaveTimer()
|
||||||
options.notifySuccess(
|
options.notifySuccess(
|
||||||
listing.status === 'published' && listing.review_status === 'approved'
|
listing.status === 'published' && listing.review_status === 'approved'
|
||||||
? '发布成功,已上架'
|
? '发布成功,已上架'
|
||||||
|
|||||||
+11
-1
@@ -8,7 +8,17 @@ import { createApp } from "vue";
|
|||||||
|
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
import router from "./router";
|
import router from "./router";
|
||||||
|
import { useAdminSessionStore } from "./stores/adminSession";
|
||||||
|
import { useSessionStore } from "./stores/session";
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
app.use(createPinia()).use(router).use(ElementPlus);
|
const pinia = createPinia();
|
||||||
|
|
||||||
|
app.use(pinia).use(router).use(ElementPlus);
|
||||||
|
|
||||||
|
window.addEventListener("auth-storage-changed", () => {
|
||||||
|
useSessionStore(pinia).syncFromStorage();
|
||||||
|
useAdminSessionStore(pinia).syncFromStorage();
|
||||||
|
});
|
||||||
|
|
||||||
router.isReady().then(() => app.mount("#app"));
|
router.isReady().then(() => app.mount("#app"));
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
import { getAccessToken, getLoginPath } from '@/utils/authStorage'
|
||||||
import { accountRoutes } from './accountRoutes'
|
import { accountRoutes } from './accountRoutes'
|
||||||
import { adminRoutes } from './adminRoutes'
|
import { adminRoutes } from './adminRoutes'
|
||||||
import { isMobileHost, toMobilePath } from './mobileHost'
|
import { isMobileHost, toMobilePath } from './mobileHost'
|
||||||
@@ -26,16 +28,17 @@ router.beforeEach(async (to) => {
|
|||||||
|
|
||||||
if (to.meta.requiresAuth) {
|
if (to.meta.requiresAuth) {
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
const hasToken = !!localStorage.getItem('access_token')
|
session.syncFromStorage()
|
||||||
|
const hasToken = !!session.token
|
||||||
if (!hasToken) {
|
if (!hasToken) {
|
||||||
const loginPath = to.path.startsWith('/m') ? '/m/login' : '/login'
|
const loginPath = getLoginPath('user', to.path)
|
||||||
return { path: loginPath, query: { redirect: to.fullPath } }
|
return { path: loginPath, query: { redirect: to.fullPath } }
|
||||||
}
|
}
|
||||||
if (!session.phone) {
|
if (!session.phone) {
|
||||||
try {
|
try {
|
||||||
await session.loadMe()
|
await session.loadMe()
|
||||||
} catch {
|
} catch {
|
||||||
const loginPath = to.path.startsWith('/m') ? '/m/login' : '/login'
|
const loginPath = getLoginPath('user', to.path)
|
||||||
return { path: loginPath, query: { redirect: to.fullPath } }
|
return { path: loginPath, query: { redirect: to.fullPath } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,14 +46,15 @@ router.beforeEach(async (to) => {
|
|||||||
|
|
||||||
if (to.meta.requiresRealname) {
|
if (to.meta.requiresRealname) {
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
const loginPath = to.path.startsWith('/m') ? '/m/login' : '/login'
|
session.syncFromStorage()
|
||||||
|
const loginPath = getLoginPath('user', to.path)
|
||||||
const realnamePath = to.path.startsWith('/m') ? '/m/realname' : '/realname'
|
const realnamePath = to.path.startsWith('/m') ? '/m/realname' : '/realname'
|
||||||
|
|
||||||
if (session.realnameStatus !== 'verified') {
|
if (session.realnameStatus !== 'verified') {
|
||||||
try {
|
try {
|
||||||
await session.loadMe()
|
await session.loadMe()
|
||||||
} catch {
|
} catch {
|
||||||
if (!localStorage.getItem('access_token')) {
|
if (!getAccessToken('user')) {
|
||||||
return { path: loginPath, query: { redirect: to.fullPath } }
|
return { path: loginPath, query: { redirect: to.fullPath } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,12 +65,13 @@ router.beforeEach(async (to) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (to.path === '/admin/login' && localStorage.getItem('admin_access_token')) {
|
const adminSession = useAdminSessionStore()
|
||||||
|
adminSession.syncFromStorage()
|
||||||
|
if (to.path === '/admin/login' && adminSession.token) {
|
||||||
return '/admin/dashboard'
|
return '/admin/dashboard'
|
||||||
}
|
}
|
||||||
if (to.meta.requiresAdmin) {
|
if (to.meta.requiresAdmin) {
|
||||||
const token = localStorage.getItem('admin_access_token')
|
if (!adminSession.token) {
|
||||||
if (!token) {
|
|
||||||
return '/admin/login'
|
return '/admin/login'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
import { fetchAdminMe, loginAdmin, type AdminUser } from '@/api/adminAuth'
|
import { fetchAdminMe, loginAdmin, type AdminUser } from '@/api/adminAuth'
|
||||||
|
import { clearAuthStorage, getAccessToken, getRefreshToken, setAuthTokens } from '@/utils/authStorage'
|
||||||
|
|
||||||
export const useAdminSessionStore = defineStore('adminSession', {
|
export const useAdminSessionStore = defineStore('adminSession', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
token: localStorage.getItem('admin_access_token') || '',
|
token: getAccessToken('admin'),
|
||||||
refreshToken: localStorage.getItem('admin_refresh_token') || '',
|
refreshToken: getRefreshToken('admin'),
|
||||||
adminId: Number(localStorage.getItem('admin_id') || 0),
|
adminId: Number(localStorage.getItem('admin_id') || 0),
|
||||||
username: localStorage.getItem('admin_username') || '',
|
username: localStorage.getItem('admin_username') || '',
|
||||||
nickname: '',
|
nickname: '',
|
||||||
@@ -27,16 +28,21 @@ export const useAdminSessionStore = defineStore('adminSession', {
|
|||||||
this.adminId = 0
|
this.adminId = 0
|
||||||
this.username = ''
|
this.username = ''
|
||||||
this.nickname = ''
|
this.nickname = ''
|
||||||
localStorage.removeItem('admin_access_token')
|
clearAuthStorage('admin')
|
||||||
localStorage.removeItem('admin_refresh_token')
|
},
|
||||||
localStorage.removeItem('admin_id')
|
syncFromStorage() {
|
||||||
localStorage.removeItem('admin_username')
|
this.token = getAccessToken('admin')
|
||||||
|
this.refreshToken = getRefreshToken('admin')
|
||||||
|
this.adminId = Number(localStorage.getItem('admin_id') || 0)
|
||||||
|
this.username = localStorage.getItem('admin_username') || ''
|
||||||
},
|
},
|
||||||
applySession(admin: AdminUser, accessToken: string, refreshToken: string) {
|
applySession(admin: AdminUser, accessToken: string, refreshToken: string) {
|
||||||
this.token = accessToken
|
this.token = accessToken
|
||||||
this.refreshToken = refreshToken
|
this.refreshToken = refreshToken
|
||||||
localStorage.setItem('admin_access_token', accessToken)
|
setAuthTokens('admin', {
|
||||||
localStorage.setItem('admin_refresh_token', refreshToken)
|
access_token: accessToken,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
})
|
||||||
this.applyAdmin(admin)
|
this.applyAdmin(admin)
|
||||||
},
|
},
|
||||||
applyAdmin(admin: AdminUser) {
|
applyAdmin(admin: AdminUser) {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
import { fetchMe, loginWithSms, updateMe, type AuthUser } from "@/api/auth";
|
import { fetchMe, loginWithSms, updateMe, type AuthUser } from "@/api/auth";
|
||||||
|
import { clearAuthStorage, getAccessToken, getRefreshToken, setAuthTokens } from "@/utils/authStorage";
|
||||||
|
|
||||||
export const useSessionStore = defineStore("session", {
|
export const useSessionStore = defineStore("session", {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
token: localStorage.getItem("access_token") || "",
|
token: getAccessToken("user"),
|
||||||
refreshToken: localStorage.getItem("refresh_token") || "",
|
refreshToken: getRefreshToken("user"),
|
||||||
userId: Number(localStorage.getItem("user_id") || 0),
|
userId: Number(localStorage.getItem("user_id") || 0),
|
||||||
phone: localStorage.getItem("phone") || "",
|
phone: localStorage.getItem("phone") || "",
|
||||||
nickname: localStorage.getItem("nickname") || "",
|
nickname: localStorage.getItem("nickname") || "",
|
||||||
@@ -46,21 +47,28 @@ export const useSessionStore = defineStore("session", {
|
|||||||
this.refreshToken = "";
|
this.refreshToken = "";
|
||||||
this.userId = 0;
|
this.userId = 0;
|
||||||
this.phone = "";
|
this.phone = "";
|
||||||
|
this.nickname = "";
|
||||||
|
this.avatarUrl = "";
|
||||||
this.realnameStatus = "unknown";
|
this.realnameStatus = "unknown";
|
||||||
localStorage.removeItem("access_token");
|
clearAuthStorage("user");
|
||||||
localStorage.removeItem("refresh_token");
|
},
|
||||||
localStorage.removeItem("user_id");
|
syncFromStorage() {
|
||||||
localStorage.removeItem("phone");
|
this.token = getAccessToken("user");
|
||||||
localStorage.removeItem("nickname");
|
this.refreshToken = getRefreshToken("user");
|
||||||
localStorage.removeItem("avatar_url");
|
this.userId = Number(localStorage.getItem("user_id") || 0);
|
||||||
localStorage.removeItem("realname_status");
|
this.phone = localStorage.getItem("phone") || "";
|
||||||
|
this.nickname = localStorage.getItem("nickname") || "";
|
||||||
|
this.avatarUrl = localStorage.getItem("avatar_url") || "";
|
||||||
|
this.realnameStatus = localStorage.getItem("realname_status") || "unknown";
|
||||||
},
|
},
|
||||||
applySession(user: AuthUser, accessToken: string, refreshToken: string) {
|
applySession(user: AuthUser, accessToken: string, refreshToken: string) {
|
||||||
this.token = accessToken;
|
this.token = accessToken;
|
||||||
this.refreshToken = refreshToken;
|
this.refreshToken = refreshToken;
|
||||||
this.userId = user.id;
|
this.userId = user.id;
|
||||||
localStorage.setItem("access_token", accessToken);
|
setAuthTokens("user", {
|
||||||
localStorage.setItem("refresh_token", refreshToken);
|
access_token: accessToken,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
});
|
||||||
localStorage.setItem("user_id", String(user.id));
|
localStorage.setItem("user_id", String(user.id));
|
||||||
this.applyUser(user);
|
this.applyUser(user);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
export const listingStatuses = ['draft', 'published', 'rented', 'offline', 'abnormal'] as const
|
||||||
|
export type ListingStatus = (typeof listingStatuses)[number]
|
||||||
|
|
||||||
|
export const listingReviewStatuses = ['none', 'pending', 'approved', 'rejected'] as const
|
||||||
|
export type ListingReviewStatus = (typeof listingReviewStatuses)[number]
|
||||||
|
|
||||||
|
export const orderStatuses = [
|
||||||
|
'pending_confirm',
|
||||||
|
'pending_payment',
|
||||||
|
'pending_handoff',
|
||||||
|
'renting',
|
||||||
|
'overdue',
|
||||||
|
'pending_return_confirm',
|
||||||
|
'pending_checkout_confirm',
|
||||||
|
'pending_checkout_accept',
|
||||||
|
'checkout_disputing',
|
||||||
|
'completed',
|
||||||
|
'cancelled',
|
||||||
|
'closed',
|
||||||
|
'disputing',
|
||||||
|
'abnormal',
|
||||||
|
] as const
|
||||||
|
export type OrderStatus = (typeof orderStatuses)[number]
|
||||||
|
|
||||||
|
export const handoffStatuses = [
|
||||||
|
'pending_owner',
|
||||||
|
'pending_renter_confirm',
|
||||||
|
'received',
|
||||||
|
'pending_owner_return_confirm',
|
||||||
|
'pending_owner_checkout',
|
||||||
|
'pending_renter_checkout',
|
||||||
|
'checkout_disputed',
|
||||||
|
'returned',
|
||||||
|
'cancelled',
|
||||||
|
'owner_timeout',
|
||||||
|
'renter_confirm_timeout',
|
||||||
|
'return_overdue',
|
||||||
|
'owner_return_confirm_timeout',
|
||||||
|
'owner_checkout_confirm_timeout',
|
||||||
|
'admin_closed',
|
||||||
|
'admin_abnormal',
|
||||||
|
'arbitrated',
|
||||||
|
] as const
|
||||||
|
export type HandoffStatus = (typeof handoffStatuses)[number]
|
||||||
|
|
||||||
|
export const settlementStatuses = [
|
||||||
|
'unsettled',
|
||||||
|
'pending',
|
||||||
|
'frozen',
|
||||||
|
'settled',
|
||||||
|
'refunded',
|
||||||
|
'cancelled',
|
||||||
|
'closed',
|
||||||
|
'disputed',
|
||||||
|
'arbitrated',
|
||||||
|
] as const
|
||||||
|
export type SettlementStatus = (typeof settlementStatuses)[number]
|
||||||
|
|
||||||
|
export const realnameStatuses = ['unknown', 'unverified', 'pending', 'verified', 'rejected'] as const
|
||||||
|
export type RealnameStatusValue = (typeof realnameStatuses)[number]
|
||||||
|
|
||||||
|
export const userStatuses = ['active', 'frozen', 'disabled'] as const
|
||||||
|
export type UserStatus = (typeof userStatuses)[number]
|
||||||
|
|
||||||
|
export const riskStatuses = ['normal', 'watch', 'restricted', 'blocked'] as const
|
||||||
|
export type RiskStatus = (typeof riskStatuses)[number]
|
||||||
|
|
||||||
|
export const disputeStatuses = ['open', 'processing', 'resolved', 'closed'] as const
|
||||||
|
export type DisputeStatus = (typeof disputeStatuses)[number]
|
||||||
|
|
||||||
|
export const walletStatuses = ['active', 'frozen', 'disabled'] as const
|
||||||
|
export type WalletStatus = (typeof walletStatuses)[number]
|
||||||
|
|
||||||
|
export const ledgerDirections = ['in', 'out', 'freeze', 'unfreeze'] as const
|
||||||
|
export type LedgerDirection = (typeof ledgerDirections)[number]
|
||||||
|
|
||||||
|
export const balanceTypes = ['available', 'frozen'] as const
|
||||||
|
export type BalanceType = (typeof balanceTypes)[number]
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
export type AuthScope = 'user' | 'admin'
|
||||||
|
|
||||||
|
export interface AuthTokenPair {
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const userKeys = {
|
||||||
|
accessToken: 'access_token',
|
||||||
|
refreshToken: 'refresh_token',
|
||||||
|
profile: ['user_id', 'phone', 'nickname', 'avatar_url', 'realname_status'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminKeys = {
|
||||||
|
accessToken: 'admin_access_token',
|
||||||
|
refreshToken: 'admin_refresh_token',
|
||||||
|
profile: ['admin_id', 'admin_username'],
|
||||||
|
}
|
||||||
|
|
||||||
|
function keysFor(scope: AuthScope) {
|
||||||
|
return scope === 'admin' ? adminKeys : userKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccessToken(scope: AuthScope) {
|
||||||
|
return localStorage.getItem(keysFor(scope).accessToken) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRefreshToken(scope: AuthScope) {
|
||||||
|
return localStorage.getItem(keysFor(scope).refreshToken) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAuthTokens(scope: AuthScope, tokens: AuthTokenPair) {
|
||||||
|
const keys = keysFor(scope)
|
||||||
|
localStorage.setItem(keys.accessToken, tokens.access_token)
|
||||||
|
localStorage.setItem(keys.refreshToken, tokens.refresh_token)
|
||||||
|
notifyAuthStorageChanged(scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuthStorage(scope: AuthScope) {
|
||||||
|
const keys = keysFor(scope)
|
||||||
|
localStorage.removeItem(keys.accessToken)
|
||||||
|
localStorage.removeItem(keys.refreshToken)
|
||||||
|
keys.profile.forEach((key) => localStorage.removeItem(key))
|
||||||
|
notifyAuthStorageChanged(scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLoginPath(scope: AuthScope, currentPath: string) {
|
||||||
|
if (scope === 'admin') return '/admin/login'
|
||||||
|
return currentPath.startsWith('/m') ? '/m/login' : '/login'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyAuthStorageChanged(scope: AuthScope) {
|
||||||
|
window.dispatchEvent(new CustomEvent('auth-storage-changed', { detail: { scope } }))
|
||||||
|
}
|
||||||
@@ -1,4 +1,19 @@
|
|||||||
const listingStatusMap: Record<string, string> = {
|
import type {
|
||||||
|
BalanceType,
|
||||||
|
DisputeStatus,
|
||||||
|
HandoffStatus,
|
||||||
|
LedgerDirection,
|
||||||
|
ListingReviewStatus,
|
||||||
|
ListingStatus,
|
||||||
|
OrderStatus,
|
||||||
|
RealnameStatusValue,
|
||||||
|
RiskStatus,
|
||||||
|
SettlementStatus,
|
||||||
|
UserStatus,
|
||||||
|
WalletStatus,
|
||||||
|
} from '@/types/status'
|
||||||
|
|
||||||
|
const listingStatusMap: Record<ListingStatus, string> = {
|
||||||
draft: '草稿',
|
draft: '草稿',
|
||||||
published: '已上架',
|
published: '已上架',
|
||||||
rented: '租用中',
|
rented: '租用中',
|
||||||
@@ -6,14 +21,14 @@ const listingStatusMap: Record<string, string> = {
|
|||||||
abnormal: '异常',
|
abnormal: '异常',
|
||||||
}
|
}
|
||||||
|
|
||||||
const listingReviewStatusMap: Record<string, string> = {
|
const listingReviewStatusMap: Record<ListingReviewStatus, string> = {
|
||||||
none: '未提交',
|
none: '未提交',
|
||||||
pending: '待审核',
|
pending: '待审核',
|
||||||
approved: '已通过',
|
approved: '已通过',
|
||||||
rejected: '已拒绝',
|
rejected: '已拒绝',
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderStatusMap: Record<string, string> = {
|
const orderStatusMap: Record<OrderStatus, string> = {
|
||||||
pending_confirm: '待确认',
|
pending_confirm: '待确认',
|
||||||
pending_payment: '待支付',
|
pending_payment: '待支付',
|
||||||
pending_handoff: '待交接',
|
pending_handoff: '待交接',
|
||||||
@@ -30,7 +45,7 @@ const orderStatusMap: Record<string, string> = {
|
|||||||
abnormal: '异常',
|
abnormal: '异常',
|
||||||
}
|
}
|
||||||
|
|
||||||
const handoffStatusMap: Record<string, string> = {
|
const handoffStatusMap: Record<HandoffStatus, string> = {
|
||||||
pending_owner: '待号主交接',
|
pending_owner: '待号主交接',
|
||||||
pending_renter_confirm: '待租客确认',
|
pending_renter_confirm: '待租客确认',
|
||||||
received: '已确认收号',
|
received: '已确认收号',
|
||||||
@@ -50,7 +65,7 @@ const handoffStatusMap: Record<string, string> = {
|
|||||||
arbitrated: '已仲裁',
|
arbitrated: '已仲裁',
|
||||||
}
|
}
|
||||||
|
|
||||||
const settlementStatusMap: Record<string, string> = {
|
const settlementStatusMap: Record<SettlementStatus, string> = {
|
||||||
unsettled: '未结算',
|
unsettled: '未结算',
|
||||||
pending: '待结算',
|
pending: '待结算',
|
||||||
frozen: '冻结中',
|
frozen: '冻结中',
|
||||||
@@ -62,95 +77,99 @@ const settlementStatusMap: Record<string, string> = {
|
|||||||
arbitrated: '已仲裁',
|
arbitrated: '已仲裁',
|
||||||
}
|
}
|
||||||
|
|
||||||
const realnameStatusMap: Record<string, string> = {
|
const realnameStatusMap: Partial<Record<RealnameStatusValue, string>> = {
|
||||||
unverified: '未认证',
|
unverified: '未认证',
|
||||||
pending: '认证中',
|
pending: '认证中',
|
||||||
verified: '已认证',
|
verified: '已认证',
|
||||||
rejected: '认证失败',
|
rejected: '认证失败',
|
||||||
}
|
}
|
||||||
|
|
||||||
const userStatusMap: Record<string, string> = {
|
const userStatusMap: Record<UserStatus, string> = {
|
||||||
active: '正常',
|
active: '正常',
|
||||||
frozen: '已冻结',
|
frozen: '已冻结',
|
||||||
disabled: '已禁用',
|
disabled: '已禁用',
|
||||||
}
|
}
|
||||||
|
|
||||||
const riskStatusMap: Record<string, string> = {
|
const riskStatusMap: Record<RiskStatus, string> = {
|
||||||
normal: '正常',
|
normal: '正常',
|
||||||
watch: '观察',
|
watch: '观察',
|
||||||
restricted: '受限',
|
restricted: '受限',
|
||||||
blocked: '已拦截',
|
blocked: '已拦截',
|
||||||
}
|
}
|
||||||
|
|
||||||
const disputeStatusMap: Record<string, string> = {
|
const disputeStatusMap: Record<DisputeStatus, string> = {
|
||||||
open: '待处理',
|
open: '待处理',
|
||||||
processing: '处理中',
|
processing: '处理中',
|
||||||
resolved: '已处理',
|
resolved: '已处理',
|
||||||
closed: '已关闭',
|
closed: '已关闭',
|
||||||
}
|
}
|
||||||
|
|
||||||
const walletStatusMap: Record<string, string> = {
|
const walletStatusMap: Record<WalletStatus, string> = {
|
||||||
active: '正常',
|
active: '正常',
|
||||||
frozen: '已冻结',
|
frozen: '已冻结',
|
||||||
disabled: '已禁用',
|
disabled: '已禁用',
|
||||||
}
|
}
|
||||||
|
|
||||||
const ledgerDirectionMap: Record<string, string> = {
|
const ledgerDirectionMap: Record<LedgerDirection, string> = {
|
||||||
in: '收入',
|
in: '收入',
|
||||||
out: '支出',
|
out: '支出',
|
||||||
freeze: '冻结',
|
freeze: '冻结',
|
||||||
unfreeze: '解冻',
|
unfreeze: '解冻',
|
||||||
}
|
}
|
||||||
|
|
||||||
const balanceTypeMap: Record<string, string> = {
|
const balanceTypeMap: Record<BalanceType, string> = {
|
||||||
available: '可用余额',
|
available: '可用余额',
|
||||||
frozen: '冻结余额',
|
frozen: '冻结余额',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readLabel(map: Record<string, string>, value: string) {
|
||||||
|
return map[value] || value || '-'
|
||||||
|
}
|
||||||
|
|
||||||
export function listingStatusLabel(status: string) {
|
export function listingStatusLabel(status: string) {
|
||||||
return listingStatusMap[status] || status || '-'
|
return readLabel(listingStatusMap, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listingReviewStatusLabel(status: string) {
|
export function listingReviewStatusLabel(status: string) {
|
||||||
return listingReviewStatusMap[status] || status || '-'
|
return readLabel(listingReviewStatusMap, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function orderStatusLabel(status: string) {
|
export function orderStatusLabel(status: string) {
|
||||||
return orderStatusMap[status] || status || '-'
|
return readLabel(orderStatusMap, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handoffStatusLabel(status: string) {
|
export function handoffStatusLabel(status: string) {
|
||||||
return handoffStatusMap[status] || status || '-'
|
return readLabel(handoffStatusMap, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function settlementStatusLabel(status: string) {
|
export function settlementStatusLabel(status: string) {
|
||||||
return settlementStatusMap[status] || status || '-'
|
return readLabel(settlementStatusMap, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function realnameStatusLabel(status: string) {
|
export function realnameStatusLabel(status: string) {
|
||||||
return realnameStatusMap[status] || status || '-'
|
return readLabel(realnameStatusMap, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function userStatusLabel(status: string) {
|
export function userStatusLabel(status: string) {
|
||||||
return userStatusMap[status] || status || '-'
|
return readLabel(userStatusMap, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function riskStatusLabel(status: string) {
|
export function riskStatusLabel(status: string) {
|
||||||
return riskStatusMap[status] || status || '-'
|
return readLabel(riskStatusMap, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function disputeStatusLabel(status: string) {
|
export function disputeStatusLabel(status: string) {
|
||||||
return disputeStatusMap[status] || status || '-'
|
return readLabel(disputeStatusMap, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function walletStatusLabel(status: string) {
|
export function walletStatusLabel(status: string) {
|
||||||
return walletStatusMap[status] || status || '-'
|
return readLabel(walletStatusMap, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ledgerDirectionLabel(direction: string) {
|
export function ledgerDirectionLabel(direction: string) {
|
||||||
return ledgerDirectionMap[direction] || direction || '-'
|
return readLabel(ledgerDirectionMap, direction)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function balanceTypeLabel(type: string) {
|
export function balanceTypeLabel(type: string) {
|
||||||
return balanceTypeMap[type] || type || '-'
|
return readLabel(balanceTypeMap, type)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
import { Search } from '@element-plus/icons-vue'
|
import { Search } from '@element-plus/icons-vue'
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
|
||||||
import { fetchAdminListings, type Listing } from '@/api/listings'
|
import { fetchAdminListings, type AdminListingQuery, type Listing } from '@/api/listings'
|
||||||
import { listingReviewStatusLabel, listingStatusLabel } from '@/utils/statusLabels'
|
import { listingReviewStatusLabel, listingStatusLabel } from '@/utils/statusLabels'
|
||||||
import { formatDateTime } from '@/utils/time'
|
import { formatDateTime } from '@/utils/time'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const listings = ref<Listing[]>([])
|
const listings = ref<Listing[]>([])
|
||||||
const filters = reactive({
|
const filters = reactive<AdminListingQuery>({
|
||||||
owner_id: '',
|
owner_id: '',
|
||||||
status: '',
|
status: '',
|
||||||
review_status: '',
|
review_status: '',
|
||||||
|
|||||||
Reference in New Issue
Block a user