订单接口最小化与私有文件访问加固
- 订单列表使用独立最小 DTO 并分页,号主待办提供独立接口与统计 - 用户 token 增加版本控制,冻结/改密/退出即时撤销会话 - 移除 URL token 传参,SSE 与接口统一使用 HttpOnly Cookie - 私有文件按上传归属与业务关联授权,收款凭证转私有访问并校验归属 - 公开商品接口返回最小字段,隐藏号主身份与内部状态 - 每日清理超过 30 天未关联业务的上传归属,上传归属失败时补偿删除对象
This commit is contained in:
@@ -3,7 +3,7 @@ import { ArrowLeft, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { fetchAdminOrders, type AdminOrderQuery, type Order } from '@/features/orders'
|
||||
import { fetchAdminOrders, type AdminOrderListItem, type AdminOrderQuery } from '@/features/orders'
|
||||
import {
|
||||
orderHandoffStatusLabel,
|
||||
orderStatusLabel,
|
||||
@@ -24,7 +24,7 @@ const filters = reactive<AdminOrderQuery>({
|
||||
offline_settlement_status: '',
|
||||
})
|
||||
const loading = ref(false)
|
||||
const orders = ref<Order[]>([])
|
||||
const orders = ref<AdminOrderListItem[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const currentPageSize = ref(20)
|
||||
@@ -153,7 +153,7 @@ function userText(value: string | number | undefined) {
|
||||
return value || '-'
|
||||
}
|
||||
|
||||
function isPlatformManaged(row: Order) {
|
||||
function isPlatformManaged(row: AdminOrderListItem) {
|
||||
return row.handoff_mode === 'platform' || row.settlement_mode === 'platform_managed'
|
||||
}
|
||||
</script>
|
||||
@@ -298,9 +298,9 @@ function isPlatformManaged(row: Order) {
|
||||
<el-table-column label="用户" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<div class="stacked-cell compact">
|
||||
<span>租客 {{ userText(row.renter_phone || row.renter_id) }}</span>
|
||||
<span>租客 {{ userText(row.renter_phone) }}</span>
|
||||
<span v-if="isPlatformManaged(row)" class="platform-managed-owner">号主 平台代管</span>
|
||||
<span v-else>号主 {{ userText(row.owner_phone || row.owner_id) }}</span>
|
||||
<span v-else>号主 {{ userText(row.owner_phone) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -80,6 +80,14 @@ export async function resetPassword(phone: string, code: string, newPassword: st
|
||||
await apiClient.post('/auth/password/reset', { phone, code, new_password: newPassword })
|
||||
}
|
||||
|
||||
export async function logoutUser() {
|
||||
await apiClient.post('/auth/logout', undefined, {
|
||||
silent: true,
|
||||
skipErrorHandler: true,
|
||||
skipAuthRefresh: true,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchMe() {
|
||||
const { data } = await apiClient.get<ApiResponse<AuthUser>>('/me')
|
||||
return data.data
|
||||
|
||||
@@ -267,8 +267,8 @@ function confirmLogout() {
|
||||
confirmButtonColor: '#ee0a24',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
.then(() => {
|
||||
session.logout()
|
||||
.then(async () => {
|
||||
await session.logout()
|
||||
router.replace('/m')
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { onBeforeUnmount, ref, type Ref } from 'vue'
|
||||
import { refreshAccessToken } from '@/shared/api/client'
|
||||
import { getAccessToken, type AuthScope } from '@/shared/utils/authStorage'
|
||||
import type { AuthScope } from '@/shared/utils/authStorage'
|
||||
|
||||
export interface SSEMessage {
|
||||
id: number
|
||||
@@ -42,13 +42,8 @@ export function useChatSSE(scope: AuthScope, endpoint: string) {
|
||||
|
||||
function connect() {
|
||||
if (stopped || source) return
|
||||
const token = getAccessToken(scope)
|
||||
// 用户端通过 query token 鉴权;后台 token 存于 httpOnly cookie,
|
||||
// localStorage 无 token,连接时依赖浏览器自动携带的 cookie,不能因空 token 提前返回。
|
||||
if (scope !== 'admin' && !token) return
|
||||
|
||||
const url = scope === 'admin' ? endpoint : `${endpoint}?token=${encodeURIComponent(token)}`
|
||||
source = new EventSource(url, { withCredentials: true })
|
||||
// SSE 与普通 API 一样使用同源 HttpOnly Cookie 鉴权,避免令牌进入 URL 和代理日志。
|
||||
source = new EventSource(endpoint, { withCredentials: true })
|
||||
|
||||
source.addEventListener('connected', () => {
|
||||
connected.value = true
|
||||
|
||||
@@ -34,6 +34,32 @@ export interface Listing {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 公开商品卡片的最小字段,对应后端 PublicListingListItemDTO。
|
||||
export interface PublicListingItem {
|
||||
id: number
|
||||
listing_no: string
|
||||
title: string
|
||||
game_name: string
|
||||
server_region: string
|
||||
login_platform: string
|
||||
rank_level: string
|
||||
haf_coin_amount: number
|
||||
asset_summary?: Record<string, unknown>
|
||||
screenshot_urls?: string[]
|
||||
cover_url: string
|
||||
price_cent: number
|
||||
deposit_amount_cent: number
|
||||
is_accelerated_sale?: boolean
|
||||
published_at?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 公开商品详情,对应后端 PublicListingDetailDTO。
|
||||
export interface PublicListingDetail extends PublicListingItem {
|
||||
description: string
|
||||
screenshot_urls: string[]
|
||||
}
|
||||
|
||||
export interface ListingPayload {
|
||||
title: string
|
||||
description: string
|
||||
@@ -85,7 +111,7 @@ export interface PublicListingQuery {
|
||||
}
|
||||
|
||||
export interface PublicListingPage {
|
||||
items: Listing[]
|
||||
items: PublicListingItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
@@ -126,7 +152,7 @@ function normalizePublicListingPage(
|
||||
}
|
||||
|
||||
export async function fetchListing(id: string | number) {
|
||||
const { data } = await apiClient.get<ApiResponse<Listing>>(`/listings/${id}`)
|
||||
const { data } = await apiClient.get<ApiResponse<PublicListingDetail>>(`/listings/${id}`)
|
||||
return data.data
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import type { Listing } from '@/features/listings'
|
||||
import type { PublicListingItem } from '@/features/listings'
|
||||
import { formatCent, formatMoney } from '@/shared/utils/money'
|
||||
import {
|
||||
formatEstimatedRentalDuration,
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from '@/shared/utils/listingDisplay'
|
||||
|
||||
interface Props {
|
||||
listing: Listing
|
||||
listing: PublicListingItem
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { reactive, ref, computed, type Ref } from 'vue'
|
||||
import type { ListingPublishOptions } from '../api/listingOptions'
|
||||
import type { Listing } from '../api/listings'
|
||||
import type { PublicListingItem } from '../api/listings'
|
||||
import { assetRegions } from '@/shared/utils/listingDisplay'
|
||||
|
||||
/** PC 默认区优先展示的皮肤组:刀皮 / 红皮 */
|
||||
@@ -68,7 +68,7 @@ export interface SkinFilterGroup {
|
||||
|
||||
export function useHomeFilters(
|
||||
publishOptions: Ref<ListingPublishOptions>,
|
||||
listings: Ref<Listing[]>
|
||||
listings: Ref<PublicListingItem[]>
|
||||
) {
|
||||
const filters = reactive<HomeFilters>({
|
||||
keyword: '',
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { Listing } from '@/features/listings/api/listings'
|
||||
import type { PublicListingItem } from '@/features/listings/api/listings'
|
||||
|
||||
const historyKey = 'hfb.listing.view.history'
|
||||
const favoriteKey = 'hfb.listing.favorites'
|
||||
const maxHistoryItems = 80
|
||||
|
||||
export interface ListingCollectionItem {
|
||||
listing: Listing
|
||||
listing: PublicListingItem
|
||||
viewed_at?: string
|
||||
favorited_at?: string
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export function useListingCollections() {
|
||||
const favorites = computed(() => favoriteItems.value)
|
||||
const favoriteIDs = computed(() => new Set(favoriteItems.value.map(item => item.listing.id)))
|
||||
|
||||
function recordListingView(listing: Listing) {
|
||||
function recordListingView(listing: PublicListingItem) {
|
||||
const nextItem: ListingCollectionItem = {
|
||||
listing: snapshotListing(listing),
|
||||
viewed_at: new Date().toISOString(),
|
||||
@@ -36,7 +36,7 @@ export function useListingCollections() {
|
||||
return favoriteIDs.value.has(listingID)
|
||||
}
|
||||
|
||||
function toggleFavorite(listing: Listing) {
|
||||
function toggleFavorite(listing: PublicListingItem) {
|
||||
if (isFavorite(listing.id)) {
|
||||
favoriteItems.value = favoriteItems.value.filter(item => item.listing.id !== listing.id)
|
||||
writeItems(favoriteKey, favoriteItems.value)
|
||||
@@ -100,7 +100,7 @@ function normalizeItem(value: unknown): ListingCollectionItem | null {
|
||||
const id = Number(value.listing.id)
|
||||
if (!Number.isFinite(id) || id <= 0) return null
|
||||
return {
|
||||
listing: value.listing as unknown as Listing,
|
||||
listing: value.listing as unknown as PublicListingItem,
|
||||
viewed_at: typeof value.viewed_at === 'string' ? value.viewed_at : undefined,
|
||||
favorited_at: typeof value.favorited_at === 'string' ? value.favorited_at : undefined,
|
||||
}
|
||||
@@ -111,8 +111,8 @@ function writeItems(key: string, items: ListingCollectionItem[]) {
|
||||
localStorage.setItem(key, JSON.stringify(items))
|
||||
}
|
||||
|
||||
function snapshotListing(listing: Listing): Listing {
|
||||
return JSON.parse(JSON.stringify(listing)) as Listing
|
||||
function snapshotListing(listing: PublicListingItem): PublicListingItem {
|
||||
return JSON.parse(JSON.stringify(listing)) as PublicListingItem
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref, onMounted, onBeforeUnmount, watch, type Ref } from 'vue'
|
||||
import { fetchListingsPage, type Listing, type PublicListingQuery } from '../api/listings'
|
||||
import { fetchListingsPage, type PublicListingItem, type PublicListingQuery } from '../api/listings'
|
||||
import type { HomeFilters } from './useHomeFilters'
|
||||
|
||||
const homePageSize = 12
|
||||
@@ -11,7 +11,7 @@ export function useListingQuery(
|
||||
) {
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const listings = ref<Listing[]>([])
|
||||
const listings = ref<PublicListingItem[]>([])
|
||||
const totalListings = ref(0)
|
||||
const zoneCounts = ref<Record<string, number>>({})
|
||||
const currentPage = ref(1)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { ShoppingCart, Star, StarFilled, ArrowLeft } from '@element-plus/icons-vue'
|
||||
import { goBackOrHome } from '@/shared/utils/routerBack'
|
||||
|
||||
import { fetchListing, type Listing } from '@/features/listings/api/listings'
|
||||
import { fetchListing, type PublicListingDetail } from '@/features/listings/api/listings'
|
||||
import { useListingCollections } from '@/features/listings/composables/useListingCollections'
|
||||
import {
|
||||
createOrder,
|
||||
@@ -63,7 +63,7 @@ const canCreateOrderAfterAgreement = computed(
|
||||
virtualAgreementChecked.value &&
|
||||
renterAgreementChecked.value
|
||||
)
|
||||
const listing = ref<Listing | null>(null)
|
||||
const listing = ref<PublicListingDetail | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
@@ -135,7 +135,7 @@ const detailScreenshots = computed(() => {
|
||||
})
|
||||
const detailScreenshotUrls = computed(() => detailScreenshots.value.map(shot => shot.url))
|
||||
|
||||
function readGroupedScreenshots(item: Listing) {
|
||||
function readGroupedScreenshots(item: PublicListingDetail) {
|
||||
const groups = item.asset_summary?.screenshot_groups
|
||||
if (typeof groups !== 'object' || groups === null) return []
|
||||
const slots = [
|
||||
@@ -337,7 +337,7 @@ watch(agreementVisible, visible => {
|
||||
}
|
||||
})
|
||||
|
||||
function listingPrice(item: Listing) {
|
||||
function listingPrice(item: PublicListingDetail) {
|
||||
return formatMoney(getListingDisplayPrice(item))
|
||||
}
|
||||
|
||||
@@ -505,9 +505,7 @@ function goBack() {
|
||||
<span class="order-eyebrow">平台担保</span>
|
||||
<h2>立即下单</h2>
|
||||
</div>
|
||||
<span class="order-state" :class="{ 'is-busy': listing.in_transaction }">
|
||||
{{ listing.in_transaction ? '交易中' : '可租' }}
|
||||
</span>
|
||||
<span class="order-state">可租</span>
|
||||
</div>
|
||||
<p class="order-safe-text">平台托管订单与押金,按平台交接流程完成账号使用。</p>
|
||||
|
||||
@@ -553,12 +551,11 @@ function goBack() {
|
||||
type="warning"
|
||||
size="large"
|
||||
:loading="ordering || agreementsLoading"
|
||||
:disabled="listing.in_transaction"
|
||||
class="full-control order-primary-btn"
|
||||
@click="handleCreateOrder"
|
||||
>
|
||||
<el-icon><ShoppingCart /></el-icon>
|
||||
{{ listing.in_transaction ? '交易中' : '立即下单' }}
|
||||
立即下单
|
||||
</el-button>
|
||||
<el-button
|
||||
class="full-control favorite-order-btn"
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '@/features/listings/api/listingOptions'
|
||||
import {
|
||||
fetchListingsPage,
|
||||
type Listing,
|
||||
type PublicListingItem,
|
||||
type PublicListingQuery,
|
||||
} from '@/features/listings/api/listings'
|
||||
import {
|
||||
@@ -65,7 +65,7 @@ const scrollEl = ref<HTMLElement | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const loadFailed = ref(false)
|
||||
const listings = ref<Listing[]>([])
|
||||
const listings = ref<PublicListingItem[]>([])
|
||||
const totalListings = ref(0)
|
||||
const zoneCounts = ref<Record<string, number>>({})
|
||||
const currentPage = ref(1)
|
||||
@@ -772,7 +772,7 @@ function getAccessBadgeMeta(value: string) {
|
||||
return { icon: 'bookmark-o', tone: 'default' }
|
||||
}
|
||||
|
||||
function getMobileRatioText(item: Listing) {
|
||||
function getMobileRatioText(item: PublicListingItem) {
|
||||
const ratio = getRatioValue(item)
|
||||
if (ratio <= 0) return ''
|
||||
const formatted = Number.isInteger(ratio) ? String(ratio) : ratio.toFixed(1)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { showToast, showDialog } from 'vant'
|
||||
|
||||
import { fetchListing, type Listing } from '@/features/listings/api/listings'
|
||||
import { fetchListing, type PublicListingDetail } from '@/features/listings/api/listings'
|
||||
import { useListingCollections } from '@/features/listings/composables/useListingCollections'
|
||||
import {
|
||||
createOrder,
|
||||
@@ -43,7 +43,7 @@ const loading = ref(false)
|
||||
const ordering = ref(false)
|
||||
const agreementsLoading = ref(false)
|
||||
const agreementVisible = ref(false)
|
||||
const listing = ref<Listing | null>(null)
|
||||
const listing = ref<PublicListingDetail | null>(null)
|
||||
const agreements = ref<OrderAgreements | null>(null)
|
||||
const virtualAgreementRead = ref(false)
|
||||
const renterAgreementRead = ref(false)
|
||||
@@ -135,7 +135,7 @@ const detailScreenshots = computed(() => {
|
||||
})
|
||||
const detailScreenshotUrls = computed(() => detailScreenshots.value.map(shot => shot.url))
|
||||
|
||||
function readGroupedScreenshots(item: Listing) {
|
||||
function readGroupedScreenshots(item: PublicListingDetail) {
|
||||
const groups = item.asset_summary?.screenshot_groups
|
||||
if (typeof groups !== 'object' || groups === null) return []
|
||||
const slots = [
|
||||
@@ -493,11 +493,10 @@ function handleToggleFavorite() {
|
||||
round
|
||||
class="order-btn"
|
||||
:loading="ordering || agreementsLoading"
|
||||
:disabled="listing.in_transaction"
|
||||
loading-text="下单中..."
|
||||
@click="handleCreateOrder"
|
||||
>
|
||||
{{ listing.in_transaction ? '交易中' : '立即下单' }}
|
||||
立即下单
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -75,6 +75,56 @@ export interface Order {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 订单列表只承载卡片和表格所需字段;完整订单信息由详情接口返回。
|
||||
export interface UserOrderListItem {
|
||||
id: number
|
||||
order_no: string
|
||||
listing_id: number
|
||||
listing_no: string
|
||||
role: 'owner' | 'renter'
|
||||
title: string
|
||||
server_region: string
|
||||
login_platform: string
|
||||
display_amount_cent: number
|
||||
deposit_amount_cent: number
|
||||
deposit_waived_amount_cent: number
|
||||
status: OrderStatus
|
||||
handoff_status: HandoffStatus
|
||||
payment_deadline_at?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 后台订单列表使用脱敏后的表格数据,敏感字段仅在详情接口中按权限返回。
|
||||
export interface AdminOrderListItem {
|
||||
id: number
|
||||
order_no: string
|
||||
listing_id: number
|
||||
listing_no: string
|
||||
owner_phone?: string
|
||||
renter_phone?: string
|
||||
title: string
|
||||
server_region: string
|
||||
login_platform: string
|
||||
rent_amount_cent: number
|
||||
deposit_amount_cent: number
|
||||
status: OrderStatus
|
||||
handoff_status: HandoffStatus
|
||||
handoff_mode: string
|
||||
settlement_mode: string
|
||||
settlement_status: SettlementStatus
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface SellerHandoffMetrics {
|
||||
pending_handoff: number
|
||||
pending_checkout: number
|
||||
abnormal: number
|
||||
}
|
||||
|
||||
export interface SellerHandoffResult extends PaginatedResult<UserOrderListItem> {
|
||||
metrics: SellerHandoffMetrics
|
||||
}
|
||||
|
||||
export interface AdminActions {
|
||||
reset_handoff?: AdminAction
|
||||
platform_handoff?: AdminAction
|
||||
@@ -246,9 +296,28 @@ export async function fetchPostRentalNotice() {
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchOrders() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: Order[] }>>('/orders')
|
||||
return Array.isArray(data.data?.items) ? data.data.items : []
|
||||
export async function fetchOrders(page = 1, pageSize = 20) {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<UserOrderListItem>>>('/orders', {
|
||||
params: { page, page_size: pageSize },
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchSellerHandoffs(
|
||||
params: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
status?: string
|
||||
} = {}
|
||||
) {
|
||||
const { data } = await apiClient.get<ApiResponse<SellerHandoffResult>>('/orders/handoffs', {
|
||||
params: {
|
||||
page: params.page || 1,
|
||||
page_size: params.pageSize || 20,
|
||||
status: params.status || undefined,
|
||||
},
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchOrder(id: string | number) {
|
||||
@@ -363,9 +432,12 @@ export async function counterCheckout(id: number, payload: SubmitCheckoutPayload
|
||||
}
|
||||
|
||||
export async function fetchAdminOrders(page = 1, pageSize = 20, query: AdminOrderQuery = {}) {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Order>>>('/admin/orders', {
|
||||
params: { page, page_size: pageSize, ...query },
|
||||
})
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminOrderListItem>>>(
|
||||
'/admin/orders',
|
||||
{
|
||||
params: { page, page_size: pageSize, ...query },
|
||||
}
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { showToast } from 'vant'
|
||||
import MobileBottomNav from '@/components/MobileBottomNav.vue'
|
||||
|
||||
import { fetchOrders, startOrderPayment, type Order } from '@/features/orders/api/orders'
|
||||
import {
|
||||
fetchOrders,
|
||||
startOrderPayment,
|
||||
type UserOrderListItem,
|
||||
} from '@/features/orders/api/orders'
|
||||
import MobilePaymentCashierPopup from '@/features/orders/components/MobilePaymentCashierPopup.vue'
|
||||
import MobilePayWaySelectPopup from '@/features/orders/components/MobilePayWaySelectPopup.vue'
|
||||
import { useMobilePaymentCashier } from '@/features/orders/composables/useMobilePaymentCashier'
|
||||
@@ -17,7 +21,6 @@ import {
|
||||
type MohongOrder,
|
||||
} from '@/features/mohong/api/mohong'
|
||||
import { centToYuan, formatCent, formatMoney } from '@/shared/utils/money'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { formatDateMinute } from '@/shared/utils/time'
|
||||
import { formatListingNo } from '@/shared/utils/listingDisplay'
|
||||
|
||||
@@ -35,18 +38,20 @@ interface UnifiedOrderItem {
|
||||
quantity?: number
|
||||
unit?: string
|
||||
amountText: string
|
||||
rental?: Order
|
||||
rental?: UserOrderListItem
|
||||
crash?: MohongOrder
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const session = useSessionStore()
|
||||
const loading = ref(false)
|
||||
const rentalOrders = ref<Order[]>([])
|
||||
const rentalOrders = ref<UserOrderListItem[]>([])
|
||||
const crashOrders = ref<MohongOrder[]>([])
|
||||
const activeTab = ref('all')
|
||||
const payingOrderId = ref<number | null>(null)
|
||||
const rentalTotal = ref(0)
|
||||
const rentalPage = ref(1)
|
||||
const rentalPageSize = 20
|
||||
const {
|
||||
paymentPopupVisible,
|
||||
activePayment,
|
||||
@@ -139,14 +144,16 @@ async function loadOrders() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [rentalResult, crashResult] = await Promise.allSettled([
|
||||
fetchOrders(),
|
||||
fetchOrders(rentalPage.value, rentalPageSize),
|
||||
fetchMyMohongOrders({ page: 1, page_size: 50 }),
|
||||
])
|
||||
|
||||
if (rentalResult.status === 'fulfilled') {
|
||||
rentalOrders.value = Array.isArray(rentalResult.value) ? rentalResult.value : []
|
||||
rentalOrders.value = rentalResult.value?.items || []
|
||||
rentalTotal.value = rentalResult.value?.total || 0
|
||||
} else {
|
||||
rentalOrders.value = []
|
||||
rentalTotal.value = 0
|
||||
showToast({ message: '租赁订单加载失败', icon: 'warning-o' })
|
||||
}
|
||||
|
||||
@@ -164,6 +171,11 @@ async function loadOrders() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleRentalPageChange(page: number) {
|
||||
rentalPage.value = page
|
||||
void loadOrders()
|
||||
}
|
||||
|
||||
function statusLabel(item: UnifiedOrderItem) {
|
||||
if (item.bizType === 'crash') return mohongOrderStatusLabel(item.status)
|
||||
const map: Record<string, string> = {
|
||||
@@ -196,7 +208,7 @@ function goDetail(item: UnifiedOrderItem) {
|
||||
router.push(`/m/orders/${item.id}`)
|
||||
}
|
||||
|
||||
async function handlePay(order: Order) {
|
||||
async function handlePay(order: UserOrderListItem) {
|
||||
const payWay = await selectPayWay()
|
||||
if (!payWay) return
|
||||
payingOrderId.value = order.id
|
||||
@@ -215,7 +227,7 @@ async function handlePay(order: Order) {
|
||||
}
|
||||
}
|
||||
|
||||
async function openOrderChatAfterPayment(order: Order) {
|
||||
async function openOrderChatAfterPayment(order: UserOrderListItem) {
|
||||
try {
|
||||
const chat = await fetchOrderChat(order.id)
|
||||
await router.push(`/m/chats/${chat.id}`)
|
||||
@@ -229,11 +241,11 @@ function money(value: unknown) {
|
||||
return formatMoney(Number(value || 0))
|
||||
}
|
||||
|
||||
function isOwner(order: Order) {
|
||||
return order.owner_id === session.userId
|
||||
function isOwner(order: UserOrderListItem) {
|
||||
return order.role === 'owner'
|
||||
}
|
||||
|
||||
function amountLabel(order: Order) {
|
||||
function amountLabel(order: UserOrderListItem) {
|
||||
return isOwner(order) ? '预计租金' : '支付租金'
|
||||
}
|
||||
|
||||
@@ -242,23 +254,15 @@ function amountYuan(cent: unknown) {
|
||||
return 0
|
||||
}
|
||||
|
||||
function orderRentAmount(order: Order) {
|
||||
if (isOwner(order)) return amountYuan(order.owner_rent_amount_cent)
|
||||
return amountYuan(order.rent_amount_cent)
|
||||
function orderRentAmount(order: UserOrderListItem) {
|
||||
return amountYuan(order.display_amount_cent)
|
||||
}
|
||||
|
||||
function ownerActualIncome(order: Order) {
|
||||
if (!isOwner(order)) return null
|
||||
const value = order.checkout?.owner_income_amount_cent
|
||||
if (typeof value === 'number') return centToYuan(value)
|
||||
return null
|
||||
}
|
||||
|
||||
function formatListingCode(order: Order) {
|
||||
function formatListingCode(order: UserOrderListItem) {
|
||||
return formatListingNo(order.listing_no, order.listing_id)
|
||||
}
|
||||
|
||||
async function copyListingCode(order: Order) {
|
||||
async function copyListingCode(order: UserOrderListItem) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(formatListingCode(order))
|
||||
showToast({ message: '商品编号已复制', icon: 'passed' })
|
||||
@@ -340,9 +344,6 @@ async function copyListingCode(order: Order) {
|
||||
<div class="price-item">
|
||||
<span class="price-label">{{ amountLabel(item.rental) }}</span>
|
||||
<span class="price-val">¥{{ money(orderRentAmount(item.rental)) }}</span>
|
||||
<span v-if="ownerActualIncome(item.rental) !== null" class="price-sub"
|
||||
>实际到手 ¥{{ money(ownerActualIncome(item.rental)) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="price-item">
|
||||
<span class="price-label">押金金额</span>
|
||||
@@ -363,10 +364,7 @@ async function copyListingCode(order: Order) {
|
||||
</div>
|
||||
<div class="footer-action">
|
||||
<van-button
|
||||
v-if="
|
||||
item.rental.status === 'pending_payment' &&
|
||||
item.rental.renter_id === session.userId
|
||||
"
|
||||
v-if="item.rental.status === 'pending_payment' && item.rental.role === 'renter'"
|
||||
size="small"
|
||||
type="warning"
|
||||
round
|
||||
@@ -421,6 +419,15 @@ async function copyListingCode(order: Order) {
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<van-pagination
|
||||
v-if="rentalTotal > rentalPageSize"
|
||||
v-model="rentalPage"
|
||||
class="orders-pagination"
|
||||
mode="simple"
|
||||
:total-items="rentalTotal"
|
||||
:items-per-page="rentalPageSize"
|
||||
@change="handleRentalPageChange"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<MobilePayWaySelectPopup
|
||||
|
||||
@@ -5,14 +5,13 @@ import { ElMessage } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { CopyDocument, Search } from '@element-plus/icons-vue'
|
||||
|
||||
import { fetchOrders, type Order } from '@/features/orders'
|
||||
import { fetchOrders, type UserOrderListItem } from '@/features/orders'
|
||||
import {
|
||||
fetchMyMohongOrders,
|
||||
mohongOrderStatusLabel,
|
||||
type MohongOrder,
|
||||
} from '@/features/mohong/api/mohong'
|
||||
import { centToYuan, formatCent, formatMoney } from '@/shared/utils/money'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { orderStatusLabel } from '@/shared/utils/statusLabels'
|
||||
import { formatDateTime } from '@/shared/utils/time'
|
||||
import { formatListingNo } from '@/shared/utils/listingDisplay'
|
||||
@@ -31,19 +30,21 @@ interface UnifiedOrderItem {
|
||||
coverUrl?: string
|
||||
quantity?: number
|
||||
unit?: string
|
||||
rental?: Order
|
||||
rental?: UserOrderListItem
|
||||
crash?: MohongOrder
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const rentalOrders = ref<Order[]>([])
|
||||
const rentalOrders = ref<UserOrderListItem[]>([])
|
||||
const crashOrders = ref<MohongOrder[]>([])
|
||||
const payingOrderId = ref<number | null>(null)
|
||||
const session = useSessionStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const searchKeyword = ref('')
|
||||
const fallbackPendingPaymentMinutes = 15
|
||||
const rentalTotal = ref(0)
|
||||
const rentalPage = ref(1)
|
||||
const rentalPageSize = 20
|
||||
|
||||
const statusTabs = [
|
||||
{ key: 'all', label: '全部' },
|
||||
@@ -107,8 +108,7 @@ const displayOrders = computed(() => {
|
||||
if (order.bizType === 'rental' && order.rental) {
|
||||
return (
|
||||
formatListingCode(order.rental).toLowerCase().includes(keyword) ||
|
||||
String(order.rental.listing_id).includes(keyword) ||
|
||||
String(order.rental.account_id).includes(keyword)
|
||||
String(order.rental.listing_id).includes(keyword)
|
||||
)
|
||||
}
|
||||
return false
|
||||
@@ -152,14 +152,16 @@ async function loadOrders() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [rentalResult, crashResult] = await Promise.allSettled([
|
||||
fetchOrders(),
|
||||
fetchOrders(rentalPage.value, rentalPageSize),
|
||||
fetchMyMohongOrders({ page: 1, page_size: 50 }),
|
||||
])
|
||||
|
||||
if (rentalResult.status === 'fulfilled') {
|
||||
rentalOrders.value = Array.isArray(rentalResult.value) ? rentalResult.value : []
|
||||
rentalOrders.value = rentalResult.value?.items || []
|
||||
rentalTotal.value = rentalResult.value?.total || 0
|
||||
} else {
|
||||
rentalOrders.value = []
|
||||
rentalTotal.value = 0
|
||||
ElMessage.error('租赁订单加载失败')
|
||||
}
|
||||
|
||||
@@ -174,6 +176,11 @@ async function loadOrders() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleRentalPageChange(page: number) {
|
||||
rentalPage.value = page
|
||||
void loadOrders()
|
||||
}
|
||||
|
||||
function goDetail(item: UnifiedOrderItem) {
|
||||
if (item.bizType === 'crash') {
|
||||
router.push(`/crash/orders/${item.id}`)
|
||||
@@ -182,7 +189,7 @@ function goDetail(item: UnifiedOrderItem) {
|
||||
router.push(`/orders/${item.id}`)
|
||||
}
|
||||
|
||||
async function handlePay(order: Order) {
|
||||
async function handlePay(order: UserOrderListItem) {
|
||||
payingOrderId.value = order.id
|
||||
try {
|
||||
await router.push(`/orders/${order.id}?pay=1`)
|
||||
@@ -193,21 +200,15 @@ async function handlePay(order: Order) {
|
||||
}
|
||||
}
|
||||
|
||||
function orderRole(order: Order) {
|
||||
if (order.renter_id === session.userId) return '租客'
|
||||
if (order.owner_id === session.userId) return '号主'
|
||||
return '-'
|
||||
function orderRole(order: UserOrderListItem) {
|
||||
return order.role === 'owner' ? '号主' : '租客'
|
||||
}
|
||||
|
||||
function isRenter(order: Order) {
|
||||
return order.renter_id === session.userId
|
||||
function isRenter(order: UserOrderListItem) {
|
||||
return order.role === 'renter'
|
||||
}
|
||||
|
||||
function isOwner(order: Order) {
|
||||
return order.owner_id === session.userId
|
||||
}
|
||||
|
||||
function amountLabel(order: Order) {
|
||||
function amountLabel(order: UserOrderListItem) {
|
||||
return isRenter(order) ? '支付租金' : '预计租金'
|
||||
}
|
||||
|
||||
@@ -216,19 +217,10 @@ function amountYuan(cent: unknown) {
|
||||
return 0
|
||||
}
|
||||
|
||||
function orderRentAmount(order: Order) {
|
||||
if (isOwner(order)) return amountYuan(order.owner_rent_amount_cent)
|
||||
if (isRenter(order)) return amountYuan(order.rent_amount_cent)
|
||||
function orderRentAmount(order: UserOrderListItem) {
|
||||
return amountYuan(order.display_amount_cent)
|
||||
}
|
||||
|
||||
function ownerActualIncome(order: Order) {
|
||||
if (!isOwner(order)) return null
|
||||
const value = order.checkout?.owner_income_amount_cent
|
||||
if (typeof value === 'number') return centToYuan(value)
|
||||
return null
|
||||
}
|
||||
|
||||
function money(value: unknown) {
|
||||
return formatMoney(Number(value || 0))
|
||||
}
|
||||
@@ -262,11 +254,11 @@ async function copyOrderNo(orderNo: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatListingCode(order: Order) {
|
||||
function formatListingCode(order: UserOrderListItem) {
|
||||
return formatListingNo(order.listing_no, order.listing_id)
|
||||
}
|
||||
|
||||
async function copyListingCode(order: Order) {
|
||||
async function copyListingCode(order: UserOrderListItem) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(formatListingCode(order))
|
||||
ElMessage.success('商品编号已复制')
|
||||
@@ -275,7 +267,7 @@ async function copyListingCode(order: Order) {
|
||||
}
|
||||
}
|
||||
|
||||
function getPaymentDeadline(order: Order) {
|
||||
function getPaymentDeadline(order: UserOrderListItem) {
|
||||
if (order.status !== 'pending_payment' || !order.created_at) return null
|
||||
if (order.payment_deadline_at) {
|
||||
const serverDeadline = new Date(order.payment_deadline_at)
|
||||
@@ -285,7 +277,7 @@ function getPaymentDeadline(order: Order) {
|
||||
return new Date(created.getTime() + fallbackPendingPaymentMinutes * 60 * 1000)
|
||||
}
|
||||
|
||||
function getCountdownMinutes(order: Order) {
|
||||
function getCountdownMinutes(order: UserOrderListItem) {
|
||||
const deadline = getPaymentDeadline(order)
|
||||
if (!deadline) return 0
|
||||
const now = new Date()
|
||||
@@ -366,9 +358,7 @@ function bizTypeLabel(bizType: BizType) {
|
||||
</div>
|
||||
<div class="crash-product-info">
|
||||
<strong>{{ row.title }}</strong>
|
||||
<span
|
||||
>x{{ row.quantity || 1 }}{{ row.unit ? ` · ${row.unit}` : '' }}</span
|
||||
>
|
||||
<span>x{{ row.quantity || 1 }}{{ row.unit ? ` · ${row.unit}` : '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="rental-product-cell">
|
||||
@@ -390,9 +380,6 @@ function bizTypeLabel(bizType: BizType) {
|
||||
<div v-if="row.bizType === 'rental' && row.rental" class="amount-cell">
|
||||
<span class="amount-value">¥{{ money(orderRentAmount(row.rental)) }}</span>
|
||||
<span class="amount-label">{{ amountLabel(row.rental) }}</span>
|
||||
<span v-if="ownerActualIncome(row.rental) !== null" class="amount-sub"
|
||||
>实际到手 ¥{{ money(ownerActualIncome(row.rental)) }}</span
|
||||
>
|
||||
</div>
|
||||
<div v-else class="amount-cell">
|
||||
<span class="amount-value">¥{{ row.amountText }}</span>
|
||||
@@ -403,8 +390,12 @@ function bizTypeLabel(bizType: BizType) {
|
||||
<el-table-column label="押金 / 数量" width="110">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.bizType === 'rental' && row.rental" class="amount-cell">
|
||||
<span class="amount-value">¥{{ money(amountYuan(row.rental.deposit_amount_cent)) }}</span>
|
||||
<span v-if="amountYuan(row.rental.deposit_waived_amount_cent) > 0" class="amount-label"
|
||||
<span class="amount-value"
|
||||
>¥{{ money(amountYuan(row.rental.deposit_amount_cent)) }}</span
|
||||
>
|
||||
<span
|
||||
v-if="amountYuan(row.rental.deposit_waived_amount_cent) > 0"
|
||||
class="amount-label"
|
||||
>免 ¥{{ money(amountYuan(row.rental.deposit_waived_amount_cent)) }}</span
|
||||
>
|
||||
</div>
|
||||
@@ -451,13 +442,11 @@ function bizTypeLabel(bizType: BizType) {
|
||||
<div class="action-buttons">
|
||||
<el-button
|
||||
v-if="
|
||||
row.bizType === 'rental' &&
|
||||
row.rental &&
|
||||
row.rental.status === 'pending_payment'
|
||||
row.bizType === 'rental' && row.rental && row.rental.status === 'pending_payment'
|
||||
"
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="row.rental.renter_id !== session.userId"
|
||||
:disabled="!isRenter(row.rental)"
|
||||
:loading="payingOrderId === row.rental.id"
|
||||
@click="handlePay(row.rental)"
|
||||
>
|
||||
@@ -511,10 +500,6 @@ function bizTypeLabel(bizType: BizType) {
|
||||
<span class="meta-label">{{ amountLabel(item.rental) }}</span>
|
||||
<span class="meta-value amount">¥{{ money(orderRentAmount(item.rental)) }}</span>
|
||||
</div>
|
||||
<div v-if="ownerActualIncome(item.rental) !== null" class="meta-row">
|
||||
<span class="meta-label">实际到手</span>
|
||||
<span class="meta-value amount">¥{{ money(ownerActualIncome(item.rental)) }}</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">押金</span>
|
||||
<span class="meta-value">
|
||||
@@ -582,6 +567,15 @@ function bizTypeLabel(bizType: BizType) {
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<el-pagination
|
||||
v-if="rentalTotal > rentalPageSize"
|
||||
v-model:current-page="rentalPage"
|
||||
class="orders-pagination"
|
||||
layout="prev, pager, next"
|
||||
:page-size="rentalPageSize"
|
||||
:total="rentalTotal"
|
||||
@current-change="handleRentalPageChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,66 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import { fetchOrders, type Order } from '@/features/orders'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import {
|
||||
fetchSellerHandoffs,
|
||||
type SellerHandoffMetrics,
|
||||
type UserOrderListItem,
|
||||
} from '@/features/orders'
|
||||
import { handoffStatusLabel, orderStatusLabel } from '@/shared/utils/statusLabels'
|
||||
import { formatDateTime } from '@/shared/utils/time'
|
||||
import { centToYuan, formatMoney } from '@/shared/utils/money'
|
||||
import { formatListingNo } from '@/shared/utils/listingDisplay'
|
||||
|
||||
const session = useSessionStore()
|
||||
const loading = ref(false)
|
||||
const orders = ref<Order[]>([])
|
||||
const orders = ref<UserOrderListItem[]>([])
|
||||
const status = ref('')
|
||||
|
||||
const sellerOrders = computed(() => orders.value.filter(order => order.owner_id === session.userId))
|
||||
const todoOrders = computed(() =>
|
||||
sellerOrders.value.filter(order =>
|
||||
[
|
||||
'pending_handoff',
|
||||
'renting',
|
||||
'overdue',
|
||||
'pending_checkout_confirm',
|
||||
'pending_checkout_accept',
|
||||
'checkout_disputing',
|
||||
'disputing',
|
||||
'abnormal',
|
||||
].includes(order.status)
|
||||
)
|
||||
)
|
||||
const displayOrders = computed(() => {
|
||||
const source = todoOrders.value
|
||||
if (!status.value) return source
|
||||
return source.filter(order => order.status === status.value)
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const metrics = ref<SellerHandoffMetrics>({
|
||||
pending_handoff: 0,
|
||||
pending_checkout: 0,
|
||||
abnormal: 0,
|
||||
})
|
||||
const pendingHandoffCount = computed(
|
||||
() =>
|
||||
sellerOrders.value.filter(
|
||||
order => order.status === 'pending_handoff' && order.handoff_status === 'pending_owner'
|
||||
).length
|
||||
)
|
||||
const pendingCheckoutCount = computed(
|
||||
() => sellerOrders.value.filter(order => order.status === 'pending_checkout_confirm').length
|
||||
)
|
||||
const abnormalCount = computed(
|
||||
() =>
|
||||
sellerOrders.value.filter(order =>
|
||||
['overdue', 'checkout_disputing', 'disputing', 'abnormal'].includes(order.status)
|
||||
).length
|
||||
)
|
||||
|
||||
onMounted(loadOrders)
|
||||
|
||||
async function loadOrders() {
|
||||
loading.value = true
|
||||
try {
|
||||
orders.value = await fetchOrders()
|
||||
const result = await fetchSellerHandoffs({ page: page.value, pageSize, status: status.value })
|
||||
orders.value = result.items
|
||||
total.value = result.total
|
||||
metrics.value = result.metrics
|
||||
} catch {
|
||||
orders.value = []
|
||||
total.value = 0
|
||||
ElMessage.error('交接待办加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function actionText(order: Order) {
|
||||
function handleStatusChange() {
|
||||
page.value = 1
|
||||
void loadOrders()
|
||||
}
|
||||
|
||||
function handlePageChange(nextPage: number) {
|
||||
page.value = nextPage
|
||||
void loadOrders()
|
||||
}
|
||||
|
||||
function actionText(order: UserOrderListItem) {
|
||||
if (order.status === 'pending_handoff' && order.handoff_status === 'pending_owner')
|
||||
return '提交交接'
|
||||
if (order.status === 'pending_checkout_confirm') return '处理结账'
|
||||
@@ -74,8 +66,8 @@ function money(value: unknown) {
|
||||
return formatMoney(Number(value || 0))
|
||||
}
|
||||
|
||||
function sellerAmount(order: Order) {
|
||||
return centToYuan(order.owner_rent_amount_cent ?? order.display_amount_cent)
|
||||
function sellerAmount(order: UserOrderListItem) {
|
||||
return centToYuan(order.display_amount_cent)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -88,7 +80,13 @@ function sellerAmount(order: Order) {
|
||||
<p>处理待交接、租赁中、待结账确认和异常订单。</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-select v-model="status" clearable placeholder="待办状态" style="width: 190px">
|
||||
<el-select
|
||||
v-model="status"
|
||||
clearable
|
||||
placeholder="待办状态"
|
||||
style="width: 190px"
|
||||
@change="handleStatusChange"
|
||||
>
|
||||
<el-option label="待交接" value="pending_handoff" />
|
||||
<el-option label="使用中" value="renting" />
|
||||
<el-option label="逾期中" value="overdue" />
|
||||
@@ -105,19 +103,19 @@ function sellerAmount(order: Order) {
|
||||
<div class="metric-grid">
|
||||
<div class="metric-card">
|
||||
<span>待交接</span>
|
||||
<strong>{{ pendingHandoffCount }} 单</strong>
|
||||
<strong>{{ metrics.pending_handoff }} 单</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>待结账</span>
|
||||
<strong>{{ pendingCheckoutCount }} 单</strong>
|
||||
<strong>{{ metrics.pending_checkout }} 单</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>异常/争议</span>
|
||||
<strong>{{ abnormalCount }} 单</strong>
|
||||
<strong>{{ metrics.abnormal }} 单</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" class="table-panel" :data="displayOrders">
|
||||
<el-table v-loading="loading" class="table-panel" :data="orders">
|
||||
<el-table-column label="商品编号" width="140">
|
||||
<template #default="{ row }">{{
|
||||
formatListingNo(row.listing_no, row.listing_id)
|
||||
@@ -145,5 +143,16 @@ function sellerAmount(order: Order) {
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-if="total > pageSize"
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -158,9 +158,7 @@ async function handleUpload(event: Event) {
|
||||
if (!form.value.certificate_urls) {
|
||||
form.value.certificate_urls = []
|
||||
}
|
||||
// 使用公开访问的URL,不需要认证
|
||||
const publicUrl = uploaded.url.replace('/api/files/object', '/api/public/files/object')
|
||||
form.value.certificate_urls.push(publicUrl)
|
||||
form.value.certificate_urls.push(uploaded.url)
|
||||
ElMessage.success('上传成功')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.response?.data?.message || '上传失败')
|
||||
|
||||
@@ -323,8 +323,7 @@ async function handleCertificateUpload(event: Event) {
|
||||
uploadingCertificate.value = true
|
||||
try {
|
||||
const uploaded = await uploadFile(file, 'payment-cert')
|
||||
const publicURL = uploaded.url.replace('/api/files/object', '/api/public/files/object')
|
||||
accountForm.certificate_urls.push(publicURL)
|
||||
accountForm.certificate_urls.push(uploaded.url)
|
||||
showToast({ message: '图片已上传', icon: 'passed' })
|
||||
} catch (error) {
|
||||
showToast({ message: readError(error, '上传失败'), icon: 'cross' })
|
||||
|
||||
@@ -110,8 +110,8 @@ function handleTopSearchInput(event: Event) {
|
||||
})
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
session.logout()
|
||||
async function handleLogout() {
|
||||
await session.logout()
|
||||
showUserDropdown.value = false
|
||||
router.replace('/')
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ declare module 'axios' {
|
||||
export interface AxiosRequestConfig {
|
||||
silent?: boolean
|
||||
skipErrorHandler?: boolean
|
||||
skipAuthRefresh?: boolean
|
||||
showLoading?: boolean
|
||||
_startTime?: number
|
||||
}
|
||||
@@ -222,7 +223,12 @@ apiClient.interceptors.response.use(
|
||||
logError(error)
|
||||
|
||||
const originalRequest = error.config as RetriableRequestConfig | undefined
|
||||
if (!originalRequest || error?.response?.status !== 401 || originalRequest._retry) {
|
||||
if (
|
||||
!originalRequest ||
|
||||
error?.response?.status !== 401 ||
|
||||
originalRequest._retry ||
|
||||
originalRequest.skipAuthRefresh
|
||||
) {
|
||||
// 统一错误处理,支持 skipErrorHandler 跳过
|
||||
if (originalRequest && !originalRequest.silent && !originalRequest.skipErrorHandler) {
|
||||
const msg = error.response?.data?.message || error.message || '网络连接异常,请稍后重试'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Listing } from '@/features/listings/api/listings'
|
||||
import type { PublicListingItem } from '@/features/listings/api/listings'
|
||||
import { centToYuan } from '@/shared/utils/money'
|
||||
|
||||
export interface ListingDisplayChip {
|
||||
@@ -21,15 +21,15 @@ export interface ListingDisplaySkinGroup {
|
||||
options: string[]
|
||||
}
|
||||
|
||||
export function getCoinWan(item: Listing) {
|
||||
export function getCoinWan(item: PublicListingItem) {
|
||||
return Math.round(Number(item.haf_coin_amount || 0) / 10000)
|
||||
}
|
||||
|
||||
export function getCoinM(item: Listing) {
|
||||
export function getCoinM(item: PublicListingItem) {
|
||||
return getCoinWan(item) / 100
|
||||
}
|
||||
|
||||
export function formatListingCode(item: Listing) {
|
||||
export function formatListingCode(item: PublicListingItem) {
|
||||
return formatListingNo(item.listing_no, item.id)
|
||||
}
|
||||
|
||||
@@ -52,23 +52,23 @@ export function formatAssetNumber(value: number) {
|
||||
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
|
||||
}
|
||||
|
||||
export function getListingDisplayPrice(item: Listing) {
|
||||
export function getListingDisplayPrice(item: PublicListingItem) {
|
||||
return centToYuan(item.price_cent)
|
||||
}
|
||||
|
||||
export function getListingRentPrice(item: Listing) {
|
||||
export function getListingRentPrice(item: PublicListingItem) {
|
||||
const buyerCoinBasePrice = readPriceBreakdownNumber(item, 'buyer_coin_base_price')
|
||||
if (buyerCoinBasePrice > 0) return roundMoney(buyerCoinBasePrice)
|
||||
return Math.max(0, roundMoney(getListingDisplayPrice(item) - getListingConsumablePrice(item)))
|
||||
}
|
||||
|
||||
export function getListingConsumablePrice(item: Listing) {
|
||||
export function getListingConsumablePrice(item: PublicListingItem) {
|
||||
const consumablePrice = readPriceBreakdownNumber(item, 'consumable_price')
|
||||
if (consumablePrice > 0) return roundMoney(consumablePrice)
|
||||
return getListingResources(item).reduce((sum, resource) => sum + resource.amount, 0)
|
||||
}
|
||||
|
||||
export function getListingSellerPrice(item: Listing) {
|
||||
export function getListingSellerPrice(item: PublicListingItem) {
|
||||
const priceBreakdown = item.asset_summary?.price_breakdown
|
||||
if (typeof priceBreakdown === 'object' && priceBreakdown !== null) {
|
||||
const price = readUnknownNumber((priceBreakdown as Record<string, unknown>).seller_total_price)
|
||||
@@ -77,7 +77,7 @@ export function getListingSellerPrice(item: Listing) {
|
||||
return getListingDisplayPrice(item)
|
||||
}
|
||||
|
||||
export function getRatioValue(item: Listing) {
|
||||
export function getRatioValue(item: PublicListingItem) {
|
||||
const ratio = readAssetNumber(item, 'publish_ratio')
|
||||
if (ratio > 0) return ratio
|
||||
const price = getListingDisplayPrice(item)
|
||||
@@ -85,20 +85,20 @@ export function getRatioValue(item: Listing) {
|
||||
return getCoinWan(item) / price
|
||||
}
|
||||
|
||||
export function formatRatio(item: Listing) {
|
||||
export function formatRatio(item: PublicListingItem) {
|
||||
const ratio = getRatioValue(item)
|
||||
return ratio > 0 ? `1:${formatRatioNumber(ratio)}` : '--'
|
||||
}
|
||||
|
||||
export function getValuePerYuanText(item: Listing) {
|
||||
export function getValuePerYuanText(item: PublicListingItem) {
|
||||
return formatRatio(item)
|
||||
}
|
||||
|
||||
export function getLoginMethod(item: Listing) {
|
||||
export function getLoginMethod(item: PublicListingItem) {
|
||||
return item.login_platform.trim()
|
||||
}
|
||||
|
||||
export function getServerRegion(item: Listing) {
|
||||
export function getServerRegion(item: PublicListingItem) {
|
||||
return item.server_region.trim()
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ export function formatGameName(value: unknown, fallback = '-') {
|
||||
return text
|
||||
}
|
||||
|
||||
export function getListingTitle(item: Listing) {
|
||||
export function getListingTitle(item: PublicListingItem) {
|
||||
const parts = [
|
||||
`纯币${formatHafCoinM(getCoinWan(item))}`,
|
||||
formatInsuranceSlotText(readAssetString(item, 'season_insurance')),
|
||||
@@ -126,11 +126,11 @@ export function getListingTitle(item: Listing) {
|
||||
return parts.join('/')
|
||||
}
|
||||
|
||||
export function getListingSubtitle(item: Listing) {
|
||||
export function getListingSubtitle(item: PublicListingItem) {
|
||||
return getValuePerYuanText(item)
|
||||
}
|
||||
|
||||
export function getListingChips(item: Listing): ListingDisplayChip[] {
|
||||
export function getListingChips(item: PublicListingItem): ListingDisplayChip[] {
|
||||
const totalAsset = readAssetNumber(item, 'total_asset_wan')
|
||||
const chips: ListingDisplayChip[] = [
|
||||
{ label: '哈夫币', value: formatHafCoinM(getCoinWan(item)) },
|
||||
@@ -161,7 +161,7 @@ export function getListingChips(item: Listing): ListingDisplayChip[] {
|
||||
return chips.filter(chip => chip.value)
|
||||
}
|
||||
|
||||
export function getListingResources(item: Listing): ListingDisplayResource[] {
|
||||
export function getListingResources(item: PublicListingItem): ListingDisplayResource[] {
|
||||
const resources = item.asset_summary?.resources
|
||||
if (!Array.isArray(resources)) return []
|
||||
return resources
|
||||
@@ -188,15 +188,15 @@ export function getListingResources(item: Listing): ListingDisplayResource[] {
|
||||
})
|
||||
}
|
||||
|
||||
export function getResourceQuantity(item: Listing, resourceKey: string) {
|
||||
export function getResourceQuantity(item: PublicListingItem, resourceKey: string) {
|
||||
return getListingResources(item).find(resource => resource.key === resourceKey)?.quantity || 0
|
||||
}
|
||||
|
||||
export function hasGiftResources(item: Listing) {
|
||||
export function hasGiftResources(item: PublicListingItem) {
|
||||
return getListingResources(item).some(resource => resource.mode === '赠送')
|
||||
}
|
||||
|
||||
export function hasAcceleratedSaleRatio(item: Listing) {
|
||||
export function hasAcceleratedSaleRatio(item: PublicListingItem) {
|
||||
if (item.is_accelerated_sale) return true
|
||||
|
||||
const priceBreakdown = item.asset_summary?.price_breakdown
|
||||
@@ -211,7 +211,7 @@ export function hasAcceleratedSaleRatio(item: Listing) {
|
||||
return sellerRatio > referenceRatio || acceleratedRatio > referenceRatio
|
||||
}
|
||||
|
||||
export function getSkinGroup(item: Listing, groupKey: string) {
|
||||
export function getSkinGroup(item: PublicListingItem, groupKey: string) {
|
||||
const skinGroups = item.asset_summary?.skin_groups
|
||||
if (
|
||||
typeof skinGroups !== 'object' ||
|
||||
@@ -225,7 +225,7 @@ export function getSkinGroup(item: Listing, groupKey: string) {
|
||||
)
|
||||
}
|
||||
|
||||
export function getListingSkinGroups(item: Listing): ListingDisplaySkinGroup[] {
|
||||
export function getListingSkinGroups(item: PublicListingItem): ListingDisplaySkinGroup[] {
|
||||
const skinGroups = item.asset_summary?.skin_groups
|
||||
if (typeof skinGroups !== 'object' || skinGroups === null) return []
|
||||
const titles: Record<string, string> = {
|
||||
@@ -248,11 +248,11 @@ export function getListingSkinGroups(item: Listing): ListingDisplaySkinGroup[] {
|
||||
.filter(group => group.options.length)
|
||||
}
|
||||
|
||||
export function getSkinNames(item: Listing) {
|
||||
export function getSkinNames(item: PublicListingItem) {
|
||||
return getListingSkinGroups(item).flatMap(group => group.options)
|
||||
}
|
||||
|
||||
export function assetRegions(item: Listing) {
|
||||
export function assetRegions(item: PublicListingItem) {
|
||||
const regions = item.asset_summary?.common_regions
|
||||
return Array.isArray(regions)
|
||||
? regions.filter((region): region is string => typeof region === 'string')
|
||||
@@ -281,7 +281,7 @@ export function formatOnlineTimeRange(start: string, end: string) {
|
||||
return `${startLabel}-${endLabel}点`
|
||||
}
|
||||
|
||||
export function getOnlineTimeText(item: Listing) {
|
||||
export function getOnlineTimeText(item: PublicListingItem) {
|
||||
const onlineTime = item.asset_summary?.online_time
|
||||
if (typeof onlineTime !== 'object' || onlineTime === null) return ''
|
||||
const start = (onlineTime as Record<string, unknown>).start
|
||||
@@ -303,37 +303,37 @@ function formatClockLabel(value: string) {
|
||||
return value.endsWith(':00') ? value.slice(0, -3) : value
|
||||
}
|
||||
|
||||
export function getDailyLoss(item: Listing) {
|
||||
export function getDailyLoss(item: PublicListingItem) {
|
||||
const dailyLossM = getDailyLossM(item)
|
||||
return dailyLossM > 0 ? `${formatCompactNumber(dailyLossM)}M` : ''
|
||||
}
|
||||
|
||||
export function getDailyLossM(item: Listing) {
|
||||
export function getDailyLossM(item: PublicListingItem) {
|
||||
const configuredLoss = readAssetNumber(item, 'daily_loss_m')
|
||||
if (configuredLoss > 0) return configuredLoss
|
||||
return 0
|
||||
}
|
||||
|
||||
export function getEstimatedRentalDays(item: Listing) {
|
||||
export function getEstimatedRentalDays(item: PublicListingItem) {
|
||||
const coinM = getCoinM(item)
|
||||
const dailyLossM = getDailyLossM(item)
|
||||
if (coinM <= 0 || dailyLossM <= 0) return 0
|
||||
return coinM / dailyLossM
|
||||
}
|
||||
|
||||
export function formatEstimatedRentalDuration(item: Listing) {
|
||||
export function formatEstimatedRentalDuration(item: PublicListingItem) {
|
||||
const days = getEstimatedRentalDays(item)
|
||||
if (days <= 0) return '--'
|
||||
// 不足整天按整天向上取整,与后端 estimateOrderDurationHours 一致
|
||||
return `${Math.max(1, Math.ceil(days))}天`
|
||||
}
|
||||
|
||||
export function readAssetString(item: Listing, key: string) {
|
||||
export function readAssetString(item: PublicListingItem, key: string) {
|
||||
const value = item.asset_summary?.[key]
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
export function readAssetNumber(item: Listing, key: string) {
|
||||
export function readAssetNumber(item: PublicListingItem, key: string) {
|
||||
return readUnknownNumber(item.asset_summary?.[key])
|
||||
}
|
||||
|
||||
@@ -346,7 +346,7 @@ function readUnknownNumber(value: unknown) {
|
||||
return 0
|
||||
}
|
||||
|
||||
function readPriceBreakdownNumber(item: Listing, key: string) {
|
||||
function readPriceBreakdownNumber(item: PublicListingItem, key: string) {
|
||||
const priceBreakdown = item.asset_summary?.price_breakdown
|
||||
if (typeof priceBreakdown !== 'object' || priceBreakdown === null) return 0
|
||||
return readUnknownNumber((priceBreakdown as Record<string, unknown>)[key])
|
||||
@@ -379,7 +379,7 @@ function formatLevelShort(value: string, suffix: string) {
|
||||
return level ? `${level}${suffix}` : value
|
||||
}
|
||||
|
||||
function formatResourceShort(item: Listing, key: string, label: string) {
|
||||
function formatResourceShort(item: PublicListingItem, key: string, label: string) {
|
||||
const quantity = getResourceQuantity(item, key)
|
||||
return quantity > 0 ? `${quantity}${label}` : ''
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
emptyListingPublishOptions,
|
||||
type ListingPublishOptions,
|
||||
} from '@/features/listings/api/listingOptions'
|
||||
import type { Listing } from '@/features/listings/api/listings'
|
||||
import type { PublicListingItem } from '@/features/listings/api/listings'
|
||||
|
||||
export type MobileHomeRangeFilters = Record<string, { min: string; max: string }>
|
||||
|
||||
@@ -34,7 +34,7 @@ export const useMobileHomeCacheStore = defineStore('mobileHomeCache', {
|
||||
state: () => ({
|
||||
/** 与当前 listings 数据对应的筛选签名;空字符串表示尚无可用快照 */
|
||||
listSignature: '',
|
||||
listings: [] as Listing[],
|
||||
listings: [] as PublicListingItem[],
|
||||
totalListings: 0,
|
||||
zoneCounts: {} as Record<string, number>,
|
||||
/** 下一页页码(与 MobileHomeView.currentPage 语义一致) */
|
||||
@@ -58,7 +58,7 @@ export const useMobileHomeCacheStore = defineStore('mobileHomeCache', {
|
||||
},
|
||||
saveList(payload: {
|
||||
signature: string
|
||||
listings: Listing[]
|
||||
listings: PublicListingItem[]
|
||||
totalListings: number
|
||||
zoneCounts: Record<string, number>
|
||||
currentPage: number
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { fetchMe, loginWithSms, updateMe, type AuthUser } from '@/features/auth/api/auth'
|
||||
import {
|
||||
fetchMe,
|
||||
loginWithSms,
|
||||
logoutUser,
|
||||
updateMe,
|
||||
type AuthUser,
|
||||
} from '@/features/auth/api/auth'
|
||||
import {
|
||||
clearAuthStorage,
|
||||
getAccessToken,
|
||||
@@ -83,7 +89,12 @@ export const useSessionStore = defineStore('session', {
|
||||
this.applyUser(user)
|
||||
return user
|
||||
},
|
||||
logout() {
|
||||
async logout() {
|
||||
try {
|
||||
await logoutUser()
|
||||
} catch {
|
||||
// 本地退出不依赖网络成功,避免用户无法离开当前账号。
|
||||
}
|
||||
this.token = ''
|
||||
this.refreshToken = ''
|
||||
this.userId = 0
|
||||
|
||||
Reference in New Issue
Block a user