diff --git a/frontend/src/api/adminAudit.ts b/frontend/src/api/adminAudit.ts index d7d4897..3876ce9 100644 --- a/frontend/src/api/adminAudit.ts +++ b/frontend/src/api/adminAudit.ts @@ -1,6 +1,6 @@ import { apiClient } from './client' -import { type PaginatedResult } from './orders' +import type { ApiResponse, PaginatedResult } from './types' export interface AdminAuditLog { id: number @@ -25,12 +25,6 @@ export interface AdminAuditQuery { page_size?: number } -interface ApiResponse { - code: string - message: string - data: T -} - export async function fetchAdminAuditLogs(query: AdminAuditQuery = {}) { const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)) const { data } = await apiClient.get>>('/admin/audit-logs', { params }) diff --git a/frontend/src/api/adminAuth.ts b/frontend/src/api/adminAuth.ts index ffdf426..c3af849 100644 --- a/frontend/src/api/adminAuth.ts +++ b/frontend/src/api/adminAuth.ts @@ -1,12 +1,15 @@ import axios from 'axios' import { apiClient } from './client' +import type { ApiResponse } from './types' +import { getRefreshToken, setAuthTokens } from '@/utils/authStorage' +import type { UserStatus } from '@/types/status' export interface AdminUser { id: number username: string nickname: string - status: string + status: UserStatus last_login_at?: string } @@ -28,12 +31,6 @@ export interface AdminCaptcha { expires_in: number } -interface ApiResponse { - code: string - message: string - data: T -} - export async function fetchAdminCaptcha() { const { data } = await apiClient.get>('/admin/auth/captcha') return data.data @@ -61,8 +58,9 @@ export async function logoutAdmin() { /** Manually refresh admin token (uses raw axios to avoid interceptor recursion) */ export async function refreshAdminSession() { - const refreshToken = localStorage.getItem('admin_refresh_token') + const refreshToken = getRefreshToken('admin') if (!refreshToken) throw new Error('no refresh token') - const { data } = await axios.post('/api/admin/auth/refresh', { refresh_token: refreshToken }) - return data.data as AdminTokenPair + const { data } = await axios.post>('/api/admin/auth/refresh', { refresh_token: refreshToken }) + setAuthTokens('admin', data.data) + return data.data } diff --git a/frontend/src/api/adminDashboard.ts b/frontend/src/api/adminDashboard.ts index 8f66b79..1bf7857 100644 --- a/frontend/src/api/adminDashboard.ts +++ b/frontend/src/api/adminDashboard.ts @@ -1,4 +1,6 @@ import { apiClient } from './client' +import type { ApiResponse } from './types' +import type { DisputeStatus, OrderStatus } from '@/types/status' export interface DashboardMetrics { total_users: number @@ -24,7 +26,7 @@ export interface DashboardRecentOrder { title: string renter_id: number owner_id: number - status: string + status: OrderStatus rent_amount: number deposit_amount: number created_at: string @@ -36,7 +38,7 @@ export interface DashboardRecentDispute { order_no: string title: string type: string - status: string + status: DisputeStatus initiator_id: number created_at: string } @@ -49,12 +51,6 @@ export interface AdminDashboard { generated_at: string } -interface ApiResponse { - code: string - message: string - data: T -} - export async function fetchAdminDashboard() { const { data } = await apiClient.get>('/admin/dashboard') return data.data diff --git a/frontend/src/api/adminUsers.ts b/frontend/src/api/adminUsers.ts index 3975837..dbc396c 100644 --- a/frontend/src/api/adminUsers.ts +++ b/frontend/src/api/adminUsers.ts @@ -1,15 +1,16 @@ 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 { id: number phone: string nickname: string - realname_status: string - risk_status: string + realname_status: RealnameStatusValue + risk_status: RiskStatus credit_score: number - status: string + status: UserStatus order_count: number listing_count: number dispute_count: number @@ -18,12 +19,6 @@ export interface AdminUserItem { updated_at: string } -interface ApiResponse { - code: string - message: string - data: T -} - export async function fetchAdminUsers(page = 1, pageSize = 20) { const { data } = await apiClient.get>>('/admin/users', { params: { page, page_size: pageSize }, diff --git a/frontend/src/api/adminWallet.ts b/frontend/src/api/adminWallet.ts index 07be54f..ff02d08 100644 --- a/frontend/src/api/adminWallet.ts +++ b/frontend/src/api/adminWallet.ts @@ -1,6 +1,7 @@ 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 { id: number @@ -10,10 +11,10 @@ export interface AdminWalletLedger { user_nickname: string order_id?: number order_no: string - direction: string + direction: LedgerDirection amount: number balance_after: number - balance_type: string + balance_type: BalanceType biz_type: string biz_no: string remark: string @@ -28,12 +29,6 @@ export interface AdminWalletLedgerQuery { page_size?: number } -interface ApiResponse { - code: string - message: string - data: T -} - export async function fetchAdminWalletLedger(query: AdminWalletLedgerQuery = {}) { const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)) const { data } = await apiClient.get>>('/admin/wallet/ledger', { params }) diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 2227d3f..a70ee69 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,14 +1,16 @@ import { apiClient } from './client' +import type { ApiResponse } from './types' +import type { RealnameStatusValue, RiskStatus, UserStatus } from '@/types/status' export interface AuthUser { id: number phone: string nickname: string avatar_url: string - realname_status: string - risk_status: string + realname_status: RealnameStatusValue + risk_status: RiskStatus credit_score: number - status: string + status: UserStatus } export interface TokenPair { @@ -23,12 +25,6 @@ export interface LoginData { tokens: TokenPair } -interface ApiResponse { - code: string - message: string - data: T -} - export async function sendSmsCode(phone: string) { const { data } = await apiClient.post>('/auth/sms/send', { phone, @@ -57,4 +53,4 @@ export async function refreshUserToken(refreshToken: string) { refresh_token: refreshToken, }) return data.data -} \ No newline at end of file +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5a8eda8..9550d07 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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({ baseURL: '/api', timeout: 10000, }) -// ---- refresh retry lock ---- -let isRefreshing = false -let pendingRequests: Array<(token: string) => void> = [] - -function subscribePendingRequests(token: string) { - pendingRequests.forEach((cb) => cb(token)) - pendingRequests.length = 0 +export async function unwrapData(request: Promise>>) { + const { data } = await request + return data.data } -function addPendingRequest(callback: (token: string) => void) { - pendingRequests.push(callback) +type RetryRequest = { + resolve: (token: string) => void + reject: (error: unknown) => void } -async function refreshTokenAndRetry(isAdmin = false): Promise { - const refreshTokenKey = isAdmin ? 'admin_refresh_token' : 'refresh_token' - const refreshToken = localStorage.getItem(refreshTokenKey) - if (!refreshToken) { - throw new Error('no refresh token') - } - const endpoint = isAdmin ? '/api/admin/auth/refresh' : '/api/auth/refresh' - // use raw axios (not apiClient) to avoid interceptor recursion +type RefreshState = { + refreshing: boolean + pendingRequests: RetryRequest[] +} + +type RetriableRequestConfig = InternalAxiosRequestConfig & { _retry?: boolean } + +const refreshStates: Record = { + 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 { + 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 newAccessToken = data.data.access_token - const newRefreshToken = data.data.refresh_token - const accessKey = isAdmin ? 'admin_access_token' : 'access_token' - localStorage.setItem(accessKey, newAccessToken) - localStorage.setItem(refreshTokenKey, newRefreshToken) - return newAccessToken + const tokens = { + access_token: data.data.access_token, + refresh_token: data.data.refresh_token, + } + setAuthTokens(scope, tokens) + return tokens.access_token } -// clear user tokens -function clearUserTokens() { - ;['access_token', 'refresh_token', 'user_id', 'phone', 'nickname', 'avatar_url', 'realname_status'] - .forEach((k) => localStorage.removeItem(k)) +function getRequestScope(url = ''): AuthScope { + return url.startsWith('/admin') ? 'admin' : 'user' } -// clear admin tokens -function clearAdminTokens() { - ;['admin_access_token', 'admin_refresh_token', 'admin_id', 'admin_username'] - .forEach((k) => localStorage.removeItem(k)) +function redirectToLogin(scope: AuthScope) { + clearAuthStorage(scope) + const currentPath = window.location.pathname + window.location.search + 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) => { - const url = config.url || '' - const tokenKey = url.startsWith('/admin') ? 'admin_access_token' : 'access_token' - const token = localStorage.getItem(tokenKey) + const scope = getRequestScope(config.url || '') + const token = getAccessToken(scope) if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) -// ---- Response interceptor ---- apiClient.interceptors.response.use( (response) => response, async (error) => { - const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean } - - if (error?.response?.status !== 401 || originalRequest._retry) { + const originalRequest = error.config as RetriableRequestConfig | undefined + if (!originalRequest || error?.response?.status !== 401 || originalRequest._retry) { return Promise.reject(error) } const requestUrl = originalRequest.url || '' - const isAdminRequest = requestUrl.startsWith('/admin') - - // exclude refresh endpoints themselves to avoid dead loop - if (requestUrl.endsWith('/auth/refresh') || requestUrl.endsWith('/admin/auth/refresh')) { - if (isAdminRequest) clearAdminTokens() - else clearUserTokens() - if (isAdminRequest && !window.location.pathname.startsWith('/admin/login')) { - window.location.assign('/admin/login') - } else if (!isAdminRequest) { - const currentPath = window.location.pathname + window.location.search - if (!currentPath.startsWith('/m/login') && !currentPath.startsWith('/login')) { - const redirect = encodeURIComponent(currentPath) - const loginPath = currentPath.startsWith('/m') ? '/m/login' : '/login' - window.location.assign(`${loginPath}?redirect=${redirect}`) - } - } + const scope = getRequestScope(requestUrl) + if (isRefreshRequest(requestUrl)) { + redirectToLogin(scope) return Promise.reject(error) } - // if already refreshing, queue up - if (isRefreshing) { - return new Promise((resolve) => { - addPendingRequest((newToken: string) => { - originalRequest.headers.Authorization = `Bearer ${newToken}` - resolve(apiClient(originalRequest)) - }) + const state = refreshStates[scope] + if (state.refreshing) { + return new Promise((resolve, reject) => { + state.pendingRequests.push({ resolve, reject }) + }).then((newToken) => { + originalRequest._retry = true + originalRequest.headers.Authorization = `Bearer ${newToken}` + return apiClient(originalRequest) }) } - // attempt refresh - isRefreshing = true + state.refreshing = true try { - const newToken = await refreshTokenAndRetry(isAdminRequest) - subscribePendingRequests(newToken) + const newToken = await refreshToken(scope) + resolvePendingRequests(scope, newToken) originalRequest._retry = true originalRequest.headers.Authorization = `Bearer ${newToken}` return apiClient(originalRequest) - } catch { - // refresh failed, clear tokens and redirect to login - subscribePendingRequests('') // let queued requests fail - if (isAdminRequest) { - clearAdminTokens() - if (!window.location.pathname.startsWith('/admin/login')) { - window.location.assign('/admin/login') - } - } else { - clearUserTokens() - const currentPath = window.location.pathname + window.location.search - if (!currentPath.startsWith('/m/login') && !currentPath.startsWith('/login')) { - const redirect = encodeURIComponent(currentPath) - const loginPath = currentPath.startsWith('/m') ? '/m/login' : '/login' - window.location.assign(`${loginPath}?redirect=${redirect}`) - } - } - return Promise.reject(error) + } catch (refreshError) { + rejectPendingRequests(scope, refreshError) + redirectToLogin(scope) + return Promise.reject(refreshError) } finally { - isRefreshing = false + state.refreshing = false } - } + }, ) diff --git a/frontend/src/api/disputes.ts b/frontend/src/api/disputes.ts index 8a2b775..99297f5 100644 --- a/frontend/src/api/disputes.ts +++ b/frontend/src/api/disputes.ts @@ -1,6 +1,7 @@ import { apiClient } from './client' -import { type PaginatedResult } from './orders' +import type { ApiResponse, PaginatedResult } from './types' +import type { DisputeStatus } from '@/types/status' export interface Dispute { id: number @@ -10,7 +11,7 @@ export interface Dispute { initiator_id: number target_user_id: number type: string - status: string + status: DisputeStatus description: string evidence_urls?: string[] arbitration_result: string @@ -21,12 +22,6 @@ export interface Dispute { updated_at: string } -interface ApiResponse { - code: string - message: string - data: T -} - export async function createDispute(orderId: number, payload: { type: string; description: string; evidence_urls?: string[] }) { const { data } = await apiClient.post>(`/orders/${orderId}/dispute`, payload) return data.data diff --git a/frontend/src/api/files.ts b/frontend/src/api/files.ts index 4fe9ff6..6a01691 100644 --- a/frontend/src/api/files.ts +++ b/frontend/src/api/files.ts @@ -1,4 +1,5 @@ import { apiClient } from './client' +import type { ApiResponse } from './types' export interface UploadedFile { object_key: string @@ -8,12 +9,6 @@ export interface UploadedFile { size: number } -interface ApiResponse { - code: string - message: string - data: T -} - export async function uploadFile(file: File, scene: string) { const form = new FormData() form.append('file', file) diff --git a/frontend/src/api/homeConfig.ts b/frontend/src/api/homeConfig.ts index 9470c1e..c6bb1b6 100644 --- a/frontend/src/api/homeConfig.ts +++ b/frontend/src/api/homeConfig.ts @@ -1,4 +1,5 @@ import { apiClient } from './client' +import type { ApiResponse } from './types' import { mergeListingPublishOptions, type ListingPublishOptions, @@ -19,12 +20,6 @@ export interface MobileHomeConfig { publish_options: ListingPublishOptions } -interface ApiResponse { - code: string - message: string - data: T -} - export const defaultHomeAnnouncements = [ '平台担保交易,拒绝私下转账/共享验证码,交接全程留痕。', '优先推荐同地区账号,减少异地登录保护触发。', diff --git a/frontend/src/api/listingOptions.ts b/frontend/src/api/listingOptions.ts index c6cc6da..c504c87 100644 --- a/frontend/src/api/listingOptions.ts +++ b/frontend/src/api/listingOptions.ts @@ -1,4 +1,5 @@ import { apiClient } from './client' +import type { ApiResponse } from './types' export type ChargeMode = '赠送' | '收费' @@ -105,12 +106,6 @@ export interface ListingPublishOptions { ratio_config: PublishRatioConfig } -interface ApiResponse { - code: string - message: string - data: T -} - export const emptyListingPublishOptions: ListingPublishOptions = { server_options: [], face_options: [], diff --git a/frontend/src/api/listings.ts b/frontend/src/api/listings.ts index 9d42198..62592a8 100644 --- a/frontend/src/api/listings.ts +++ b/frontend/src/api/listings.ts @@ -1,4 +1,6 @@ import { apiClient } from './client' +import type { ApiResponse } from './types' +import type { ListingReviewStatus, ListingStatus } from '@/types/status' export interface Listing { id: number @@ -20,8 +22,8 @@ export interface Listing { deposit_amount: number is_accelerated_sale?: boolean in_transaction: boolean - status: string - review_status: string + status: ListingStatus + review_status: ListingReviewStatus review_reason: string published_at?: string created_at: string @@ -41,12 +43,6 @@ export interface ListingPayload { deposit_amount: number } -interface ApiResponse { - code: string - message: string - data: T -} - export async function fetchListings() { const { data } = await apiClient.get>('/listings') return data.data.items @@ -84,8 +80,8 @@ export async function fetchPendingReviewListings() { export interface AdminListingQuery { owner_id?: string - status?: string - review_status?: string + status?: ListingStatus | '' + review_status?: ListingReviewStatus | '' limit?: number } diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts index 2a5c53c..91e3c57 100644 --- a/frontend/src/api/notifications.ts +++ b/frontend/src/api/notifications.ts @@ -1,6 +1,6 @@ import { apiClient } from './client' -import { type PaginatedResult } from './orders' +import type { ApiResponse, PaginatedResult } from './types' export interface NotificationItem { id: number @@ -14,12 +14,6 @@ export interface NotificationItem { created_at: string } -interface ApiResponse { - code: string - message: string - data: T -} - export async function fetchNotifications(page = 1, pageSize = 20) { const { data } = await apiClient.get>>('/notifications', { params: { page, page_size: pageSize }, diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts index 0cf5b21..99b9d5d 100644 --- a/frontend/src/api/orders.ts +++ b/frontend/src/api/orders.ts @@ -1,4 +1,6 @@ import { apiClient } from './client' +import type { ApiResponse } from './types' +import type { HandoffStatus, OrderStatus, SettlementStatus } from '@/types/status' export interface Order { id: number @@ -19,9 +21,9 @@ export interface Order { deposit_amount: number platform_fee: number account_snapshot?: Record - status: string - handoff_status: string - settlement_status: string + status: OrderStatus + handoff_status: HandoffStatus + settlement_status: SettlementStatus checkout?: Checkout created_at: string updated_at: string @@ -31,7 +33,7 @@ export interface Checkout { id: number order_id: number initiated_by: number - status: string + status: SettlementStatus rent_amount: number owner_rent_amount: number platform_fee: number @@ -81,19 +83,6 @@ export interface CounterCheckoutPayload { evidence_urls: string[] } -interface ApiResponse { - code: string - message: string - data: T -} - -export interface PaginatedResult { - items: T[] - total: number - page: number - page_size: number -} - export async function createOrder(listingId: number) { const { data } = await apiClient.post>('/orders', { listing_id: listingId, diff --git a/frontend/src/api/realname.ts b/frontend/src/api/realname.ts index daff440..e6a49e7 100644 --- a/frontend/src/api/realname.ts +++ b/frontend/src/api/realname.ts @@ -1,7 +1,9 @@ import { apiClient } from './client' +import type { ApiResponse } from './types' +import type { RealnameStatusValue } from '@/types/status' export interface RealnameStatus { - status: string + status: RealnameStatusValue provider?: string provider_order_no?: string masked_name?: string @@ -10,12 +12,6 @@ export interface RealnameStatus { fail_reason?: string } -interface ApiResponse { - code: string - message: string - data: T -} - export async function startRealname(name: string, idNo: string) { const { data } = await apiClient.post>('/realname/start', { name, diff --git a/frontend/src/api/systemConfigs.ts b/frontend/src/api/systemConfigs.ts index 5bebd0f..45b6086 100644 --- a/frontend/src/api/systemConfigs.ts +++ b/frontend/src/api/systemConfigs.ts @@ -1,4 +1,5 @@ import { apiClient } from './client' +import type { ApiResponse } from './types' export interface SystemConfig { id: number @@ -10,12 +11,6 @@ export interface SystemConfig { updated_at: string } -interface ApiResponse { - code: string - message: string - data: T -} - export async function fetchSystemConfigs() { const { data } = await apiClient.get>('/admin/system-configs') return data.data.items diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..1c66d49 --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,12 @@ +export interface ApiResponse { + code: string + message: string + data: T +} + +export interface PaginatedResult { + items: T[] + total: number + page: number + page_size: number +} diff --git a/frontend/src/api/wallet.ts b/frontend/src/api/wallet.ts index 77c40da..6e69820 100644 --- a/frontend/src/api/wallet.ts +++ b/frontend/src/api/wallet.ts @@ -1,12 +1,13 @@ 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 { user_id: number available_balance: number frozen_balance: number - status: string + status: WalletStatus } export interface WalletLedger { @@ -14,22 +15,16 @@ export interface WalletLedger { ledger_no: string user_id: number order_id?: number - direction: string + direction: LedgerDirection amount: number balance_after: number - balance_type: string + balance_type: BalanceType biz_type: string biz_no: string remark: string created_at: string } -interface ApiResponse { - code: string - message: string - data: T -} - export async function fetchWalletBalance() { const { data } = await apiClient.get>('/wallet/balance') return data.data diff --git a/frontend/src/composables/usePublishDraft.ts b/frontend/src/composables/usePublishDraft.ts index 334c646..a71a540 100644 --- a/frontend/src/composables/usePublishDraft.ts +++ b/frontend/src/composables/usePublishDraft.ts @@ -64,7 +64,9 @@ export function readPublishDraft(draftKey: string) { } 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) { diff --git a/frontend/src/composables/usePublishForm.ts b/frontend/src/composables/usePublishForm.ts index da08d17..dd730fb 100644 --- a/frontend/src/composables/usePublishForm.ts +++ b/frontend/src/composables/usePublishForm.ts @@ -25,6 +25,8 @@ import { import type { PublishForm } from '@/types/publish' import { commonOnlineTimes, dailyLossOptions, formatNumber, roundRatio } from '@/utils/pricing' +const draftSaveDelay = 400 + interface UsePublishFormOptions { draftKey: string submitSuccessPath: string @@ -51,6 +53,7 @@ export function usePublishForm(options: UsePublishFormOptions) { const screenshotFiles = reactive>({}) const screenshotPreviews = reactive>({}) const selectedSkins = ref([]) + let draftSaveTimer: number | undefined const pricing = usePricingCalculator({ form, @@ -124,8 +127,34 @@ export function usePublishForm(options: UsePublishFormOptions) { } function saveDraft(saveOptions: { force?: boolean } = {}) { - if (suppressDraftSave.value) return + if (suppressDraftSave.value) { + clearDraftSaveTimer() + 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( options.draftKey, buildPublishDraft({ @@ -178,6 +207,7 @@ export function usePublishForm(options: UsePublishFormOptions) { return } suppressDraftSave.value = true + clearDraftSaveTimer() resetDraftState() removePublishDraft(options.draftKey) options.notifySuccess('已重置') @@ -349,6 +379,7 @@ export function usePublishForm(options: UsePublishFormOptions) { }) removePublishDraft(options.draftKey) suppressDraftSave.value = true + clearDraftSaveTimer() options.notifySuccess( listing.status === 'published' && listing.review_status === 'approved' ? '发布成功,已上架' diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 7e0cf5b..3a172da 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -8,7 +8,17 @@ import { createApp } from "vue"; import App from "./App.vue"; import router from "./router"; +import { useAdminSessionStore } from "./stores/adminSession"; +import { useSessionStore } from "./stores/session"; 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")); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index b71807f..c0d34c4 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,6 +1,8 @@ import { createRouter, createWebHistory } from 'vue-router' +import { useAdminSessionStore } from '@/stores/adminSession' import { useSessionStore } from '@/stores/session' +import { getAccessToken, getLoginPath } from '@/utils/authStorage' import { accountRoutes } from './accountRoutes' import { adminRoutes } from './adminRoutes' import { isMobileHost, toMobilePath } from './mobileHost' @@ -26,16 +28,17 @@ router.beforeEach(async (to) => { if (to.meta.requiresAuth) { const session = useSessionStore() - const hasToken = !!localStorage.getItem('access_token') + session.syncFromStorage() + const hasToken = !!session.token if (!hasToken) { - const loginPath = to.path.startsWith('/m') ? '/m/login' : '/login' + const loginPath = getLoginPath('user', to.path) return { path: loginPath, query: { redirect: to.fullPath } } } if (!session.phone) { try { await session.loadMe() } catch { - const loginPath = to.path.startsWith('/m') ? '/m/login' : '/login' + const loginPath = getLoginPath('user', to.path) return { path: loginPath, query: { redirect: to.fullPath } } } } @@ -43,14 +46,15 @@ router.beforeEach(async (to) => { if (to.meta.requiresRealname) { 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' if (session.realnameStatus !== 'verified') { try { await session.loadMe() } catch { - if (!localStorage.getItem('access_token')) { + if (!getAccessToken('user')) { 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' } if (to.meta.requiresAdmin) { - const token = localStorage.getItem('admin_access_token') - if (!token) { + if (!adminSession.token) { return '/admin/login' } } diff --git a/frontend/src/stores/adminSession.ts b/frontend/src/stores/adminSession.ts index 5b707bb..cc32545 100644 --- a/frontend/src/stores/adminSession.ts +++ b/frontend/src/stores/adminSession.ts @@ -1,11 +1,12 @@ import { defineStore } from 'pinia' import { fetchAdminMe, loginAdmin, type AdminUser } from '@/api/adminAuth' +import { clearAuthStorage, getAccessToken, getRefreshToken, setAuthTokens } from '@/utils/authStorage' export const useAdminSessionStore = defineStore('adminSession', { state: () => ({ - token: localStorage.getItem('admin_access_token') || '', - refreshToken: localStorage.getItem('admin_refresh_token') || '', + token: getAccessToken('admin'), + refreshToken: getRefreshToken('admin'), adminId: Number(localStorage.getItem('admin_id') || 0), username: localStorage.getItem('admin_username') || '', nickname: '', @@ -27,16 +28,21 @@ export const useAdminSessionStore = defineStore('adminSession', { this.adminId = 0 this.username = '' this.nickname = '' - localStorage.removeItem('admin_access_token') - localStorage.removeItem('admin_refresh_token') - localStorage.removeItem('admin_id') - localStorage.removeItem('admin_username') + clearAuthStorage('admin') + }, + syncFromStorage() { + 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) { this.token = accessToken this.refreshToken = refreshToken - localStorage.setItem('admin_access_token', accessToken) - localStorage.setItem('admin_refresh_token', refreshToken) + setAuthTokens('admin', { + access_token: accessToken, + refresh_token: refreshToken, + }) this.applyAdmin(admin) }, applyAdmin(admin: AdminUser) { diff --git a/frontend/src/stores/session.ts b/frontend/src/stores/session.ts index 14b5071..5cbc373 100644 --- a/frontend/src/stores/session.ts +++ b/frontend/src/stores/session.ts @@ -1,11 +1,12 @@ import { defineStore } from "pinia"; import { fetchMe, loginWithSms, updateMe, type AuthUser } from "@/api/auth"; +import { clearAuthStorage, getAccessToken, getRefreshToken, setAuthTokens } from "@/utils/authStorage"; export const useSessionStore = defineStore("session", { state: () => ({ - token: localStorage.getItem("access_token") || "", - refreshToken: localStorage.getItem("refresh_token") || "", + token: getAccessToken("user"), + refreshToken: getRefreshToken("user"), userId: Number(localStorage.getItem("user_id") || 0), phone: localStorage.getItem("phone") || "", nickname: localStorage.getItem("nickname") || "", @@ -46,21 +47,28 @@ export const useSessionStore = defineStore("session", { this.refreshToken = ""; this.userId = 0; this.phone = ""; + this.nickname = ""; + this.avatarUrl = ""; this.realnameStatus = "unknown"; - localStorage.removeItem("access_token"); - localStorage.removeItem("refresh_token"); - localStorage.removeItem("user_id"); - localStorage.removeItem("phone"); - localStorage.removeItem("nickname"); - localStorage.removeItem("avatar_url"); - localStorage.removeItem("realname_status"); + clearAuthStorage("user"); + }, + syncFromStorage() { + this.token = getAccessToken("user"); + this.refreshToken = getRefreshToken("user"); + this.userId = Number(localStorage.getItem("user_id") || 0); + 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) { this.token = accessToken; this.refreshToken = refreshToken; this.userId = user.id; - localStorage.setItem("access_token", accessToken); - localStorage.setItem("refresh_token", refreshToken); + setAuthTokens("user", { + access_token: accessToken, + refresh_token: refreshToken, + }); localStorage.setItem("user_id", String(user.id)); this.applyUser(user); }, diff --git a/frontend/src/types/status.ts b/frontend/src/types/status.ts new file mode 100644 index 0000000..cd3178f --- /dev/null +++ b/frontend/src/types/status.ts @@ -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] diff --git a/frontend/src/utils/authStorage.ts b/frontend/src/utils/authStorage.ts new file mode 100644 index 0000000..9f748fa --- /dev/null +++ b/frontend/src/utils/authStorage.ts @@ -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 } })) +} diff --git a/frontend/src/utils/statusLabels.ts b/frontend/src/utils/statusLabels.ts index 2bb1bf1..adfa8d9 100644 --- a/frontend/src/utils/statusLabels.ts +++ b/frontend/src/utils/statusLabels.ts @@ -1,4 +1,19 @@ -const listingStatusMap: Record = { +import type { + BalanceType, + DisputeStatus, + HandoffStatus, + LedgerDirection, + ListingReviewStatus, + ListingStatus, + OrderStatus, + RealnameStatusValue, + RiskStatus, + SettlementStatus, + UserStatus, + WalletStatus, +} from '@/types/status' + +const listingStatusMap: Record = { draft: '草稿', published: '已上架', rented: '租用中', @@ -6,14 +21,14 @@ const listingStatusMap: Record = { abnormal: '异常', } -const listingReviewStatusMap: Record = { +const listingReviewStatusMap: Record = { none: '未提交', pending: '待审核', approved: '已通过', rejected: '已拒绝', } -const orderStatusMap: Record = { +const orderStatusMap: Record = { pending_confirm: '待确认', pending_payment: '待支付', pending_handoff: '待交接', @@ -30,7 +45,7 @@ const orderStatusMap: Record = { abnormal: '异常', } -const handoffStatusMap: Record = { +const handoffStatusMap: Record = { pending_owner: '待号主交接', pending_renter_confirm: '待租客确认', received: '已确认收号', @@ -50,7 +65,7 @@ const handoffStatusMap: Record = { arbitrated: '已仲裁', } -const settlementStatusMap: Record = { +const settlementStatusMap: Record = { unsettled: '未结算', pending: '待结算', frozen: '冻结中', @@ -62,95 +77,99 @@ const settlementStatusMap: Record = { arbitrated: '已仲裁', } -const realnameStatusMap: Record = { +const realnameStatusMap: Partial> = { unverified: '未认证', pending: '认证中', verified: '已认证', rejected: '认证失败', } -const userStatusMap: Record = { +const userStatusMap: Record = { active: '正常', frozen: '已冻结', disabled: '已禁用', } -const riskStatusMap: Record = { +const riskStatusMap: Record = { normal: '正常', watch: '观察', restricted: '受限', blocked: '已拦截', } -const disputeStatusMap: Record = { +const disputeStatusMap: Record = { open: '待处理', processing: '处理中', resolved: '已处理', closed: '已关闭', } -const walletStatusMap: Record = { +const walletStatusMap: Record = { active: '正常', frozen: '已冻结', disabled: '已禁用', } -const ledgerDirectionMap: Record = { +const ledgerDirectionMap: Record = { in: '收入', out: '支出', freeze: '冻结', unfreeze: '解冻', } -const balanceTypeMap: Record = { +const balanceTypeMap: Record = { available: '可用余额', frozen: '冻结余额', } +function readLabel(map: Record, value: string) { + return map[value] || value || '-' +} + export function listingStatusLabel(status: string) { - return listingStatusMap[status] || status || '-' + return readLabel(listingStatusMap, status) } export function listingReviewStatusLabel(status: string) { - return listingReviewStatusMap[status] || status || '-' + return readLabel(listingReviewStatusMap, status) } export function orderStatusLabel(status: string) { - return orderStatusMap[status] || status || '-' + return readLabel(orderStatusMap, status) } export function handoffStatusLabel(status: string) { - return handoffStatusMap[status] || status || '-' + return readLabel(handoffStatusMap, status) } export function settlementStatusLabel(status: string) { - return settlementStatusMap[status] || status || '-' + return readLabel(settlementStatusMap, status) } export function realnameStatusLabel(status: string) { - return realnameStatusMap[status] || status || '-' + return readLabel(realnameStatusMap, status) } export function userStatusLabel(status: string) { - return userStatusMap[status] || status || '-' + return readLabel(userStatusMap, status) } export function riskStatusLabel(status: string) { - return riskStatusMap[status] || status || '-' + return readLabel(riskStatusMap, status) } export function disputeStatusLabel(status: string) { - return disputeStatusMap[status] || status || '-' + return readLabel(disputeStatusMap, status) } export function walletStatusLabel(status: string) { - return walletStatusMap[status] || status || '-' + return readLabel(walletStatusMap, status) } export function ledgerDirectionLabel(direction: string) { - return ledgerDirectionMap[direction] || direction || '-' + return readLabel(ledgerDirectionMap, direction) } export function balanceTypeLabel(type: string) { - return balanceTypeMap[type] || type || '-' + return readLabel(balanceTypeMap, type) } diff --git a/frontend/src/views/admin/AdminListingsView.vue b/frontend/src/views/admin/AdminListingsView.vue index e0e5fdb..09cf79c 100644 --- a/frontend/src/views/admin/AdminListingsView.vue +++ b/frontend/src/views/admin/AdminListingsView.vue @@ -2,13 +2,13 @@ import { Search } from '@element-plus/icons-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 { formatDateTime } from '@/utils/time' const loading = ref(false) const listings = ref([]) -const filters = reactive({ +const filters = reactive({ owner_id: '', status: '', review_status: '',