优化前端认证与类型管理

This commit is contained in:
yml
2026-05-25 01:59:59 +08:00
parent eafa6f96b2
commit 2cfd3d9422
28 changed files with 435 additions and 280 deletions
+1 -7
View File
@@ -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 })
+8 -10
View File
@@ -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
} }
+4 -8
View File
@@ -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
+5 -10
View File
@@ -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 },
+4 -9
View File
@@ -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 })
+5 -9
View File
@@ -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,
+100 -89
View File
@@ -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')
} }
const endpoint = isAdmin ? '/api/admin/auth/refresh' : '/api/auth/refresh'
// use raw axios (not apiClient) to avoid interceptor recursion type RetriableRequestConfig = InternalAxiosRequestConfig & { _retry?: boolean }
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))
})
})
}
// attempt refresh
isRefreshing = true
try {
const newToken = await refreshTokenAndRetry(isAdminRequest)
subscribePendingRequests(newToken)
originalRequest._retry = true originalRequest._retry = true
originalRequest.headers.Authorization = `Bearer ${newToken}` originalRequest.headers.Authorization = `Bearer ${newToken}`
return apiClient(originalRequest) 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() state.refreshing = true
const currentPath = window.location.pathname + window.location.search try {
if (!currentPath.startsWith('/m/login') && !currentPath.startsWith('/login')) { const newToken = await refreshToken(scope)
const redirect = encodeURIComponent(currentPath) resolvePendingRequests(scope, newToken)
const loginPath = currentPath.startsWith('/m') ? '/m/login' : '/login' originalRequest._retry = true
window.location.assign(`${loginPath}?redirect=${redirect}`) originalRequest.headers.Authorization = `Bearer ${newToken}`
} return apiClient(originalRequest)
} } catch (refreshError) {
return Promise.reject(error) rejectPendingRequests(scope, refreshError)
redirectToLogin(scope)
return Promise.reject(refreshError)
} finally { } finally {
isRefreshing = false state.refreshing = false
}
} }
},
) )
+3 -8
View File
@@ -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 -6
View File
@@ -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 -6
View 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 -6
View File
@@ -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: [],
+6 -10
View File
@@ -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 -7
View File
@@ -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 },
+6 -17
View File
@@ -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,
+3 -7
View File
@@ -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 -6
View File
@@ -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
+12
View File
@@ -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
}
+5 -10
View File
@@ -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
+3 -1
View File
@@ -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) {
+32 -1
View File
@@ -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
View File
@@ -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"));
+13 -8
View File
@@ -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'
} }
} }
+14 -8
View File
@@ -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) {
+19 -11
View File
@@ -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);
}, },
+78
View File
@@ -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]
+54
View File
@@ -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 } }))
}
+43 -24
View File
@@ -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: '',