增加前端格式检查配置
This commit is contained in:
@@ -1,9 +1,6 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import type { ApiResponse } from '@/shared/types/types'
|
||||
import {
|
||||
mergeListingPublishOptions,
|
||||
type ListingPublishOptions,
|
||||
} from './listingOptions'
|
||||
import { mergeListingPublishOptions, type ListingPublishOptions } from './listingOptions'
|
||||
|
||||
export interface HomeBannerSlide {
|
||||
eyebrow: string
|
||||
@@ -61,11 +58,11 @@ export async function fetchMobileHomeConfig() {
|
||||
export function mergeHomeConfig(config?: Partial<MobileHomeConfig>): MobileHomeConfig {
|
||||
const announcements =
|
||||
config?.announcements
|
||||
?.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
?.map(item => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean) || []
|
||||
const banners =
|
||||
config?.banners
|
||||
?.map((item) => {
|
||||
?.map(item => {
|
||||
const banner = isBannerLike(item) ? item : ({} as Partial<HomeBannerSlide>)
|
||||
return {
|
||||
eyebrow: banner.eyebrow?.trim() || '',
|
||||
@@ -76,7 +73,7 @@ export function mergeHomeConfig(config?: Partial<MobileHomeConfig>): MobileHomeC
|
||||
image_url: banner.image_url?.trim() || '',
|
||||
}
|
||||
})
|
||||
.filter((item) => item.title || item.image_url) || []
|
||||
.filter(item => item.title || item.image_url) || []
|
||||
|
||||
return {
|
||||
announcements: announcements.length ? announcements : defaultHomeAnnouncements,
|
||||
|
||||
@@ -182,21 +182,29 @@ export const emptyListingPublishAgreements: ListingPublishAgreements = {
|
||||
}
|
||||
|
||||
export async function fetchListingPublishOptions() {
|
||||
const { data } = await apiClient.get<ApiResponse<ListingPublishOptions>>('/listing-publish-options')
|
||||
const { data } = await apiClient.get<ApiResponse<ListingPublishOptions>>(
|
||||
'/listing-publish-options'
|
||||
)
|
||||
return mergeListingPublishOptions(data.data)
|
||||
}
|
||||
|
||||
export async function fetchListingPublishAgreements() {
|
||||
const { data } = await apiClient.get<ApiResponse<ListingPublishAgreements>>('/listing-publish-agreements')
|
||||
const { data } = await apiClient.get<ApiResponse<ListingPublishAgreements>>(
|
||||
'/listing-publish-agreements'
|
||||
)
|
||||
return mergeListingPublishAgreements(data.data)
|
||||
}
|
||||
|
||||
export async function fetchListingSalePriceConfig() {
|
||||
const { data } = await apiClient.get<ApiResponse<PublishSalePriceConfig>>('/listing-sale-price-config')
|
||||
const { data } = await apiClient.get<ApiResponse<PublishSalePriceConfig>>(
|
||||
'/listing-sale-price-config'
|
||||
)
|
||||
return mergeListingSalePriceConfig(data.data)
|
||||
}
|
||||
|
||||
export function mergeListingPublishOptions(options?: Partial<ListingPublishOptions>): ListingPublishOptions {
|
||||
export function mergeListingPublishOptions(
|
||||
options?: Partial<ListingPublishOptions>
|
||||
): ListingPublishOptions {
|
||||
return {
|
||||
server_options: normalizeStringList(options?.server_options),
|
||||
face_options: normalizeStringList(options?.face_options),
|
||||
@@ -217,34 +225,46 @@ export function mergeListingPublishOptions(options?: Partial<ListingPublishOptio
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeListingSalePriceConfig(options?: Partial<PublishSalePriceConfig>): PublishSalePriceConfig {
|
||||
export function mergeListingSalePriceConfig(
|
||||
options?: Partial<PublishSalePriceConfig>
|
||||
): PublishSalePriceConfig {
|
||||
return normalizeSalePriceConfig(options)
|
||||
}
|
||||
|
||||
export function mergeListingPublishAgreements(options?: Partial<ListingPublishAgreements>): ListingPublishAgreements {
|
||||
export function mergeListingPublishAgreements(
|
||||
options?: Partial<ListingPublishAgreements>
|
||||
): ListingPublishAgreements {
|
||||
return {
|
||||
virtual_asset_sale: normalizeAgreementContent(options?.virtual_asset_sale, emptyListingPublishAgreements.virtual_asset_sale),
|
||||
seller_agreement: normalizeAgreementContent(options?.seller_agreement, emptyListingPublishAgreements.seller_agreement),
|
||||
virtual_asset_sale: normalizeAgreementContent(
|
||||
options?.virtual_asset_sale,
|
||||
emptyListingPublishAgreements.virtual_asset_sale
|
||||
),
|
||||
seller_agreement: normalizeAgreementContent(
|
||||
options?.seller_agreement,
|
||||
emptyListingPublishAgreements.seller_agreement
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAgreementContent(value: unknown, fallback: AgreementContent): AgreementContent {
|
||||
const row = isRecord(value) ? value : {}
|
||||
const title = typeof row.title === 'string' && row.title.trim() ? row.title.trim() : fallback.title
|
||||
const content = typeof row.content === 'string' && row.content.trim() ? row.content.trim() : fallback.content
|
||||
const title =
|
||||
typeof row.title === 'string' && row.title.trim() ? row.title.trim() : fallback.title
|
||||
const content =
|
||||
typeof row.content === 'string' && row.content.trim() ? row.content.trim() : fallback.content
|
||||
return { title, content }
|
||||
}
|
||||
|
||||
function normalizeStringList(values?: unknown[]) {
|
||||
return Array.isArray(values)
|
||||
? values.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean)
|
||||
? values.map(item => (typeof item === 'string' ? item.trim() : '')).filter(Boolean)
|
||||
: []
|
||||
}
|
||||
|
||||
function normalizeOptionGroups(values?: unknown[]): PublishOptionGroup[] {
|
||||
if (!Array.isArray(values)) return []
|
||||
return values
|
||||
.map((item) => {
|
||||
.map(item => {
|
||||
const group = isRecord(item) ? item : {}
|
||||
return {
|
||||
key: typeof group.key === 'string' ? group.key.trim() : '',
|
||||
@@ -252,13 +272,13 @@ function normalizeOptionGroups(values?: unknown[]): PublishOptionGroup[] {
|
||||
options: normalizeStringList(Array.isArray(group.options) ? group.options : []),
|
||||
}
|
||||
})
|
||||
.filter((item) => item.key && item.title)
|
||||
.filter(item => item.key && item.title)
|
||||
}
|
||||
|
||||
function normalizeQuantityItems(values?: unknown[]): PublishQuantityItem[] {
|
||||
if (!Array.isArray(values)) return []
|
||||
return values
|
||||
.map((item) => {
|
||||
.map(item => {
|
||||
const row = isRecord(item) ? item : {}
|
||||
return {
|
||||
key: typeof row.key === 'string' ? row.key.trim() : '',
|
||||
@@ -267,13 +287,13 @@ function normalizeQuantityItems(values?: unknown[]): PublishQuantityItem[] {
|
||||
placeholder: typeof row.placeholder === 'string' ? row.placeholder.trim() : undefined,
|
||||
}
|
||||
})
|
||||
.filter((item) => item.key && item.label)
|
||||
.filter(item => item.key && item.label)
|
||||
}
|
||||
|
||||
function normalizeScreenshotSlots(values?: unknown[]): PublishScreenshotSlot[] {
|
||||
if (!Array.isArray(values)) return []
|
||||
return values
|
||||
.map((item) => {
|
||||
.map(item => {
|
||||
const row = isRecord(item) ? item : {}
|
||||
return {
|
||||
key: typeof row.key === 'string' ? row.key.trim() : '',
|
||||
@@ -282,19 +302,22 @@ function normalizeScreenshotSlots(values?: unknown[]): PublishScreenshotSlot[] {
|
||||
hint: typeof row.hint === 'string' ? row.hint.trim() : '',
|
||||
}
|
||||
})
|
||||
.filter((item) => item.key && item.label)
|
||||
.filter(item => item.key && item.label)
|
||||
}
|
||||
|
||||
function normalizePriceConfig(value?: unknown): PublishPriceConfig {
|
||||
const row = isRecord(value) ? value : {}
|
||||
return {
|
||||
deposit_placeholder: typeof row.deposit_placeholder === 'string' ? row.deposit_placeholder.trim() : '',
|
||||
deposit_placeholder:
|
||||
typeof row.deposit_placeholder === 'string' ? row.deposit_placeholder.trim() : '',
|
||||
deposit_hint:
|
||||
typeof row.deposit_hint === 'string' && row.deposit_hint.trim()
|
||||
? row.deposit_hint.trim()
|
||||
: emptyListingPublishOptions.price_config.deposit_hint,
|
||||
price_placeholder: typeof row.price_placeholder === 'string' ? row.price_placeholder.trim() : '',
|
||||
ratio_description: typeof row.ratio_description === 'string' ? row.ratio_description.trim() : '',
|
||||
price_placeholder:
|
||||
typeof row.price_placeholder === 'string' ? row.price_placeholder.trim() : '',
|
||||
ratio_description:
|
||||
typeof row.ratio_description === 'string' ? row.ratio_description.trim() : '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,21 +326,23 @@ function normalizeDepositRecommendConfig(value?: unknown): PublishDepositRecomme
|
||||
const config = {
|
||||
base_amount: readNumber(row.base_amount),
|
||||
skin_group_rules: normalizeDepositSkinGroupRules(
|
||||
Array.isArray(row.skin_group_rules) ? row.skin_group_rules : [],
|
||||
Array.isArray(row.skin_group_rules) ? row.skin_group_rules : []
|
||||
),
|
||||
}
|
||||
if (config.base_amount <= 0) {
|
||||
config.base_amount = emptyListingPublishOptions.deposit_recommend_config.base_amount
|
||||
}
|
||||
if (config.skin_group_rules.length === 0) {
|
||||
config.skin_group_rules = [...emptyListingPublishOptions.deposit_recommend_config.skin_group_rules]
|
||||
config.skin_group_rules = [
|
||||
...emptyListingPublishOptions.deposit_recommend_config.skin_group_rules,
|
||||
]
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
function normalizeDepositSkinGroupRules(values: unknown[]): PublishDepositSkinGroupRule[] {
|
||||
return values
|
||||
.map((item) => {
|
||||
.map(item => {
|
||||
const row = isRecord(item) ? item : {}
|
||||
return {
|
||||
group_key: typeof row.group_key === 'string' ? row.group_key.trim() : '',
|
||||
@@ -325,20 +350,20 @@ function normalizeDepositSkinGroupRules(values: unknown[]): PublishDepositSkinGr
|
||||
amount_per_item: readNumber(row.amount_per_item),
|
||||
}
|
||||
})
|
||||
.filter((item) => item.group_key && item.label && item.amount_per_item >= 0)
|
||||
.filter(item => item.group_key && item.label && item.amount_per_item >= 0)
|
||||
}
|
||||
|
||||
function normalizeRatioConfig(value?: unknown): PublishRatioConfig {
|
||||
const row = isRecord(value) ? value : {}
|
||||
return {
|
||||
insurance_base_ratios: normalizeInsuranceBaseRatios(
|
||||
Array.isArray(row.insurance_base_ratios) ? row.insurance_base_ratios : [],
|
||||
Array.isArray(row.insurance_base_ratios) ? row.insurance_base_ratios : []
|
||||
),
|
||||
config_items: normalizeRatioConfigItems(
|
||||
Array.isArray(row.config_items) ? row.config_items : [],
|
||||
Array.isArray(row.config_items) ? row.config_items : []
|
||||
),
|
||||
coin_corrections: normalizeCoinCorrections(
|
||||
Array.isArray(row.coin_corrections) ? row.coin_corrections : [],
|
||||
Array.isArray(row.coin_corrections) ? row.coin_corrections : []
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -347,10 +372,10 @@ function normalizeSalePriceConfig(value?: unknown): PublishSalePriceConfig {
|
||||
const row = isRecord(value) ? value : {}
|
||||
const config = {
|
||||
fixed_markup_rules: normalizeSaleFixedMarkupRules(
|
||||
Array.isArray(row.fixed_markup_rules) ? row.fixed_markup_rules : [],
|
||||
Array.isArray(row.fixed_markup_rules) ? row.fixed_markup_rules : []
|
||||
),
|
||||
ratio_adjustment_rules: normalizeSaleRatioAdjustmentRules(
|
||||
Array.isArray(row.ratio_adjustment_rules) ? row.ratio_adjustment_rules : [],
|
||||
Array.isArray(row.ratio_adjustment_rules) ? row.ratio_adjustment_rules : []
|
||||
),
|
||||
}
|
||||
if (config.fixed_markup_rules.length === 0 && config.ratio_adjustment_rules.length === 0) {
|
||||
@@ -361,19 +386,19 @@ function normalizeSalePriceConfig(value?: unknown): PublishSalePriceConfig {
|
||||
|
||||
function normalizeInsuranceBaseRatios(values: unknown[]): PublishInsuranceBaseRatio[] {
|
||||
return values
|
||||
.map((item) => {
|
||||
.map(item => {
|
||||
const row = isRecord(item) ? item : {}
|
||||
return {
|
||||
insurance: typeof row.insurance === 'string' ? row.insurance.trim() : '',
|
||||
ratio: readNumber(row.ratio),
|
||||
}
|
||||
})
|
||||
.filter((item) => item.insurance && item.ratio > 0)
|
||||
.filter(item => item.insurance && item.ratio > 0)
|
||||
}
|
||||
|
||||
function normalizeRatioConfigItems(values: unknown[]): PublishRatioConfigItem[] {
|
||||
return values
|
||||
.map((item) => {
|
||||
.map(item => {
|
||||
const row = isRecord(item) ? item : {}
|
||||
return {
|
||||
key: typeof row.key === 'string' ? row.key.trim() : '',
|
||||
@@ -383,24 +408,24 @@ function normalizeRatioConfigItems(values: unknown[]): PublishRatioConfigItem[]
|
||||
missing_penalty: readNumber(row.missing_penalty),
|
||||
}
|
||||
})
|
||||
.filter((item) => item.key && item.label && item.kind)
|
||||
.filter(item => item.key && item.label && item.kind)
|
||||
}
|
||||
|
||||
function normalizeCoinCorrections(values: unknown[]): PublishCoinCorrection[] {
|
||||
return values
|
||||
.map((item) => {
|
||||
.map(item => {
|
||||
const row = isRecord(item) ? item : {}
|
||||
return {
|
||||
threshold_m: readNumber(row.threshold_m),
|
||||
correction: readNumber(row.correction),
|
||||
}
|
||||
})
|
||||
.filter((item) => item.threshold_m >= 0 && item.correction > 0)
|
||||
.filter(item => item.threshold_m >= 0 && item.correction > 0)
|
||||
}
|
||||
|
||||
function normalizeSaleFixedMarkupRules(values: unknown[]): PublishSaleFixedMarkupRule[] {
|
||||
return values
|
||||
.map((item) => {
|
||||
.map(item => {
|
||||
const row = isRecord(item) ? item : {}
|
||||
return {
|
||||
min_m: readNumber(row.min_m),
|
||||
@@ -408,12 +433,12 @@ function normalizeSaleFixedMarkupRules(values: unknown[]): PublishSaleFixedMarku
|
||||
markup_amount: readNumber(row.markup_amount),
|
||||
}
|
||||
})
|
||||
.filter((item) => item.min_m >= 0 && item.max_m >= item.min_m && item.markup_amount >= 0)
|
||||
.filter(item => item.min_m >= 0 && item.max_m >= item.min_m && item.markup_amount >= 0)
|
||||
}
|
||||
|
||||
function normalizeSaleRatioAdjustmentRules(values: unknown[]): PublishSaleRatioAdjustmentRule[] {
|
||||
return values
|
||||
.map((item) => {
|
||||
.map(item => {
|
||||
const row = isRecord(item) ? item : {}
|
||||
return {
|
||||
min_m: readNumber(row.min_m),
|
||||
@@ -422,10 +447,10 @@ function normalizeSaleRatioAdjustmentRules(values: unknown[]): PublishSaleRatioA
|
||||
}
|
||||
})
|
||||
.filter(
|
||||
(item) =>
|
||||
item =>
|
||||
item.min_m >= 0 &&
|
||||
(item.max_m === 0 || item.max_m >= item.min_m) &&
|
||||
item.ratio_subtract >= 0,
|
||||
item.ratio_subtract >= 0
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -91,19 +91,28 @@ export async function fetchListings(query: PublicListingQuery = {}) {
|
||||
|
||||
export async function fetchListingsPage(query: PublicListingQuery = {}) {
|
||||
const params = Object.fromEntries(
|
||||
Object.entries(query).filter(([, value]) => value !== '' && value !== undefined && value !== null)
|
||||
Object.entries(query).filter(
|
||||
([, value]) => value !== '' && value !== undefined && value !== null
|
||||
)
|
||||
)
|
||||
const { data } = await apiClient.get<ApiResponse<Partial<PublicListingPage>>>('/listings', { params })
|
||||
const { data } = await apiClient.get<ApiResponse<Partial<PublicListingPage>>>('/listings', {
|
||||
params,
|
||||
})
|
||||
return normalizePublicListingPage(data.data, query)
|
||||
}
|
||||
|
||||
function normalizePublicListingPage(data: Partial<PublicListingPage>, query: PublicListingQuery): PublicListingPage {
|
||||
function normalizePublicListingPage(
|
||||
data: Partial<PublicListingPage>,
|
||||
query: PublicListingQuery
|
||||
): PublicListingPage {
|
||||
const items = Array.isArray(data.items) ? data.items : []
|
||||
return {
|
||||
items,
|
||||
total: Number.isFinite(Number(data.total)) ? Number(data.total) : items.length,
|
||||
page: Number.isFinite(Number(data.page)) ? Number(data.page) : Number(query.page || 1),
|
||||
page_size: Number.isFinite(Number(data.page_size)) ? Number(data.page_size) : Number(query.page_size || items.length || 20),
|
||||
page_size: Number.isFinite(Number(data.page_size))
|
||||
? Number(data.page_size)
|
||||
: Number(query.page_size || items.length || 20),
|
||||
zone_counts: data.zone_counts && typeof data.zone_counts === 'object' ? data.zone_counts : {},
|
||||
}
|
||||
}
|
||||
@@ -155,18 +164,25 @@ export interface AdminListingPage {
|
||||
}
|
||||
|
||||
export async function fetchAdminListings(query: AdminListingQuery = {}) {
|
||||
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined))
|
||||
const params = Object.fromEntries(
|
||||
Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)
|
||||
)
|
||||
const { data } = await apiClient.get<ApiResponse<AdminListingPage>>('/admin/listings', { params })
|
||||
return normalizeAdminListingPage(data.data, query)
|
||||
}
|
||||
|
||||
function normalizeAdminListingPage(data: Partial<AdminListingPage>, query: AdminListingQuery): AdminListingPage {
|
||||
function normalizeAdminListingPage(
|
||||
data: Partial<AdminListingPage>,
|
||||
query: AdminListingQuery
|
||||
): AdminListingPage {
|
||||
const items = Array.isArray(data.items) ? data.items : []
|
||||
return {
|
||||
items,
|
||||
total: Number.isFinite(Number(data.total)) ? Number(data.total) : items.length,
|
||||
page: Number.isFinite(Number(data.page)) ? Number(data.page) : Number(query.page || 1),
|
||||
page_size: Number.isFinite(Number(data.page_size)) ? Number(data.page_size) : Number(query.page_size || items.length || 10),
|
||||
page_size: Number.isFinite(Number(data.page_size))
|
||||
? Number(data.page_size)
|
||||
: Number(query.page_size || items.length || 10),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,12 +192,17 @@ export async function fetchAdminListing(id: string | number) {
|
||||
}
|
||||
|
||||
export async function adminOfflineListing(id: number, reason: string) {
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/offline`, { reason })
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/offline`, {
|
||||
reason,
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function adminMarkListingAbnormal(id: number, reason: string) {
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/mark-abnormal`, { reason })
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>(
|
||||
`/admin/listings/${id}/mark-abnormal`,
|
||||
{ reason }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -196,12 +217,20 @@ export interface AdminListingPriceAdjustPayload {
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export async function adjustListingReviewPrice(id: number, payload: AdminListingPriceAdjustPayload) {
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/adjust-price`, payload)
|
||||
export async function adjustListingReviewPrice(
|
||||
id: number,
|
||||
payload: AdminListingPriceAdjustPayload
|
||||
) {
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>(
|
||||
`/admin/listings/${id}/adjust-price`,
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function rejectListing(id: number, reason: string) {
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/reject`, { reason })
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/reject`, {
|
||||
reason,
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -19,10 +19,7 @@ defineProps<Props>()
|
||||
<section class="hero-board">
|
||||
<el-carousel height="240px" indicator-position="outside" :interval="3600">
|
||||
<el-carousel-item v-for="slide in banners" :key="slide.title || slide.image_url">
|
||||
<div
|
||||
class="hero-slide"
|
||||
:class="[`tone-${slide.tone}`, { 'has-image': slide.image_url }]"
|
||||
>
|
||||
<div class="hero-slide" :class="[`tone-${slide.tone}`, { 'has-image': slide.image_url }]">
|
||||
<img
|
||||
v-if="slide.image_url"
|
||||
:src="slide.image_url"
|
||||
@@ -95,7 +92,12 @@ defineProps<Props>()
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, rgba(15, 23, 42, 0.74) 0%, rgba(15, 23, 42, 0.24) 62%, transparent 100%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(15, 23, 42, 0.74) 0%,
|
||||
rgba(15, 23, 42, 0.24) 62%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
|
||||
@@ -28,9 +28,9 @@ const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:filters': [filters: Partial<HomeFilters>]
|
||||
'reset': []
|
||||
'setFilterPopover': [key: FilterPopoverKey, visible: boolean]
|
||||
'closeFilterPopover': []
|
||||
reset: []
|
||||
setFilterPopover: [key: FilterPopoverKey, visible: boolean]
|
||||
closeFilterPopover: []
|
||||
}>()
|
||||
|
||||
const filterPopoverBaseProps = {
|
||||
@@ -67,9 +67,7 @@ function closePopover() {
|
||||
<strong>筛选大厅</strong>
|
||||
<span>{{ totalListings }} 个结果</span>
|
||||
</div>
|
||||
<el-button :icon="Refresh" link @click="emit('reset')">
|
||||
重置全部条件
|
||||
</el-button>
|
||||
<el-button :icon="Refresh" link @click="emit('reset')"> 重置全部条件 </el-button>
|
||||
</div>
|
||||
|
||||
<div class="filter-chip-row">
|
||||
|
||||
@@ -65,7 +65,11 @@ const coverURL = computed(() => props.listing.cover_url || props.listing.screens
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<label>体力/负重</label>
|
||||
<span>{{ readAssetString(listing, 'stamina_level') }}/{{ readAssetString(listing, 'load_level') }}</span>
|
||||
<span
|
||||
>{{ readAssetString(listing, 'stamina_level') }}/{{
|
||||
readAssetString(listing, 'load_level')
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,12 +83,16 @@ const coverURL = computed(() => props.listing.cover_url || props.listing.screens
|
||||
<div class="stat-row">
|
||||
<label>六级头甲</label>
|
||||
<span>
|
||||
{{ getResourceQuantity(listing, 'helmet6') }}头 / {{ getResourceQuantity(listing, 'armor6') }}甲
|
||||
{{ getResourceQuantity(listing, 'helmet6') }}头 /
|
||||
{{ getResourceQuantity(listing, 'armor6') }}甲
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<label>其他重器</label>
|
||||
<span>巴雷特 {{ getResourceQuantity(listing, 'barrett') }} / 喷子 {{ getResourceQuantity(listing, 'shotgun') }}</span>
|
||||
<span
|
||||
>巴雷特 {{ getResourceQuantity(listing, 'barrett') }} / 喷子
|
||||
{{ getResourceQuantity(listing, 'shotgun') }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -29,12 +29,12 @@ const emit = defineEmits<{
|
||||
|
||||
const minValue = computed({
|
||||
get: () => props.modelMin,
|
||||
set: (val) => emit('update:modelMin', val),
|
||||
set: val => emit('update:modelMin', val),
|
||||
})
|
||||
|
||||
const maxValue = computed({
|
||||
get: () => props.modelMax,
|
||||
set: (val) => emit('update:modelMax', val),
|
||||
set: val => emit('update:modelMax', val),
|
||||
})
|
||||
|
||||
const isActive = computed(() => {
|
||||
@@ -59,11 +59,7 @@ function isSelected(item: RangeOption) {
|
||||
<template>
|
||||
<el-popover v-bind="popoverProps" :width="350">
|
||||
<template #reference>
|
||||
<button
|
||||
class="filter-chip"
|
||||
:class="{ active: isActive, wide }"
|
||||
type="button"
|
||||
>
|
||||
<button class="filter-chip" :class="{ active: isActive, wide }" type="button">
|
||||
<span>{{ chipLabel }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</button>
|
||||
@@ -79,19 +75,9 @@ function isSelected(item: RangeOption) {
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<div class="range-manual">
|
||||
<el-input-number
|
||||
v-model="minValue"
|
||||
:controls="false"
|
||||
:min="0"
|
||||
placeholder="最小值"
|
||||
/>
|
||||
<el-input-number v-model="minValue" :controls="false" :min="0" placeholder="最小值" />
|
||||
<span>-</span>
|
||||
<el-input-number
|
||||
v-model="maxValue"
|
||||
:controls="false"
|
||||
:min="0"
|
||||
placeholder="最大值"
|
||||
/>
|
||||
<el-input-number v-model="maxValue" :controls="false" :min="0" placeholder="最大值" />
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
|
||||
@@ -31,7 +31,7 @@ const isActive = computed(() => {
|
||||
const chipLabel = computed(() => {
|
||||
if (props.skinName) return props.skinName
|
||||
if (props.skinGroup) {
|
||||
return props.groups.find((g) => g.key === props.skinGroup)?.title || '皮肤'
|
||||
return props.groups.find(g => g.key === props.skinGroup)?.title || '皮肤'
|
||||
}
|
||||
return '皮肤'
|
||||
})
|
||||
|
||||
@@ -36,21 +36,13 @@ function selectOption(value: string) {
|
||||
<template>
|
||||
<el-popover v-bind="popoverProps" :width="220">
|
||||
<template #reference>
|
||||
<button
|
||||
class="filter-chip"
|
||||
:class="{ active: isActive, wide }"
|
||||
type="button"
|
||||
>
|
||||
<button class="filter-chip" :class="{ active: isActive, wide }" type="button">
|
||||
<span>{{ displayLabel }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</button>
|
||||
</template>
|
||||
<div class="filter-menu">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: !modelValue }"
|
||||
@click="selectOption('')"
|
||||
>
|
||||
<button type="button" :class="{ active: !modelValue }" @click="selectOption('')">
|
||||
{{ placeholder }}
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -36,7 +36,10 @@ describe('useHomeFilters', () => {
|
||||
const publishOptions = ref(emptyListingPublishOptions)
|
||||
const listings = ref([])
|
||||
|
||||
const { filters, setStringFilter, closeFilterPopover } = useHomeFilters(publishOptions, listings)
|
||||
const { filters, setStringFilter, closeFilterPopover } = useHomeFilters(
|
||||
publishOptions,
|
||||
listings
|
||||
)
|
||||
|
||||
setStringFilter('region', '上海')
|
||||
|
||||
|
||||
@@ -30,11 +30,7 @@ export const fireLevelRangeOptions = [
|
||||
{ label: '70+', min: 70, max: undefined },
|
||||
]
|
||||
|
||||
export function rangeLabel(
|
||||
min: number | undefined,
|
||||
max: number | undefined,
|
||||
fallback: string
|
||||
) {
|
||||
export function rangeLabel(min: number | undefined, max: number | undefined, fallback: string) {
|
||||
if (min !== undefined && max !== undefined) return `${min}-${max}`
|
||||
if (min !== undefined) return `${min}+`
|
||||
if (max !== undefined) return `≤${max}`
|
||||
|
||||
@@ -17,13 +17,7 @@ export type FilterPopoverKey =
|
||||
| 'fireLevel'
|
||||
| 'loginMethod'
|
||||
|
||||
export type StringFilterKey =
|
||||
| 'insurance'
|
||||
| 'stamina'
|
||||
| 'load'
|
||||
| 'region'
|
||||
| 'rank'
|
||||
| 'loginMethod'
|
||||
export type StringFilterKey = 'insurance' | 'stamina' | 'load' | 'region' | 'rank' | 'loginMethod'
|
||||
|
||||
export interface HomeFilters {
|
||||
keyword: string
|
||||
@@ -80,35 +74,33 @@ export function useHomeFilters(
|
||||
const regionOptions = computed(() =>
|
||||
uniqueOptions([
|
||||
...publishOptions.value.region_options,
|
||||
...listings.value.flatMap((item) => assetRegions(item)),
|
||||
...listings.value.flatMap(item => assetRegions(item)),
|
||||
])
|
||||
)
|
||||
|
||||
const loginMethodOptions = computed(() =>
|
||||
uniqueOptions(
|
||||
publishOptions.value.login_method_options
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
publishOptions.value.login_method_options.map(item => item.trim()).filter(Boolean)
|
||||
)
|
||||
)
|
||||
|
||||
const skinFilterGroups = computed(() => {
|
||||
const preferred = ['operatorRed', 'operatorGold']
|
||||
return preferred
|
||||
.map((key) => publishOptions.value.skin_groups.find((group) => group.key === key))
|
||||
.map(key => publishOptions.value.skin_groups.find(group => group.key === key))
|
||||
.filter((group): group is ListingPublishOptions['skin_groups'][number] => Boolean(group))
|
||||
})
|
||||
|
||||
const skinChipLabel = computed(() => {
|
||||
if (filters.skinName) return filters.skinName
|
||||
if (filters.skinGroup) {
|
||||
return skinFilterGroups.value.find((group) => group.key === filters.skinGroup)?.title || '皮肤'
|
||||
return skinFilterGroups.value.find(group => group.key === filters.skinGroup)?.title || '皮肤'
|
||||
}
|
||||
return '皮肤'
|
||||
})
|
||||
|
||||
function uniqueOptions(values: string[]) {
|
||||
return [...new Set(values.map((item) => item.trim()).filter(Boolean))]
|
||||
return [...new Set(values.map(item => item.trim()).filter(Boolean))]
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
|
||||
@@ -102,12 +102,15 @@ export function useListingQuery(
|
||||
|
||||
// 使用防抖优化搜索
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
watch(() => listingQuerySignature(), () => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
loadListingsPage(true)
|
||||
}, 300)
|
||||
})
|
||||
watch(
|
||||
() => listingQuerySignature(),
|
||||
() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
loadListingsPage(true)
|
||||
}, 300)
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
loading,
|
||||
|
||||
@@ -35,7 +35,10 @@ const {
|
||||
resetFilters,
|
||||
setFilterPopover,
|
||||
closeFilterPopover,
|
||||
} = useHomeFilters(publishOptions, computed(() => listings.value))
|
||||
} = useHomeFilters(
|
||||
publishOptions,
|
||||
computed(() => listings.value)
|
||||
)
|
||||
|
||||
const {
|
||||
loading,
|
||||
@@ -104,10 +107,7 @@ const zoneOptions = computed(() => [
|
||||
async function loadHome() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [, config] = await Promise.all([
|
||||
loadListingsPage(true),
|
||||
fetchMobileHomeConfig(),
|
||||
])
|
||||
const [, config] = await Promise.all([loadListingsPage(true), fetchMobileHomeConfig()])
|
||||
announcements.value = config.announcements
|
||||
banners.value = config.banners
|
||||
publishOptions.value = config.publish_options
|
||||
@@ -179,11 +179,7 @@ loadHome()
|
||||
<button type="button" @click="handleResetFilters">重置条件</button>
|
||||
</div>
|
||||
<div v-else v-loading="loading" class="enhanced-desktop-list">
|
||||
<ListingCard
|
||||
v-for="item in listings"
|
||||
:key="item.id"
|
||||
:listing="item"
|
||||
/>
|
||||
<ListingCard v-for="item in listings" :key="item.id" :listing="item" />
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && listings.length" class="infinite-load-state">
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { fetchListing, type Listing } from "@/features/listings/api/listings";
|
||||
import { createOrder, fetchOrderAgreements, type OrderAgreements } from "@/features/orders/api/orders";
|
||||
import { useSessionStore } from "@/stores/session";
|
||||
import { roundMoney, formatMoney } from "@/shared/utils/money";
|
||||
import { fetchListing, type Listing } from '@/features/listings/api/listings'
|
||||
import {
|
||||
createOrder,
|
||||
fetchOrderAgreements,
|
||||
type OrderAgreements,
|
||||
} from '@/features/orders/api/orders'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { roundMoney, formatMoney } from '@/shared/utils/money'
|
||||
import {
|
||||
assetRegions,
|
||||
formatEstimatedRentalDuration,
|
||||
@@ -26,45 +30,45 @@ import {
|
||||
getServerRegion,
|
||||
readAssetNumber,
|
||||
readAssetString,
|
||||
} from "@/utils/listingDisplay";
|
||||
} from '@/utils/listingDisplay'
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const session = useSessionStore();
|
||||
const loading = ref(false);
|
||||
const ordering = ref(false);
|
||||
const agreementsLoading = ref(false);
|
||||
const agreementVisible = ref(false);
|
||||
const agreements = ref<OrderAgreements | null>(null);
|
||||
const virtualAgreementRead = ref(false);
|
||||
const renterAgreementRead = ref(false);
|
||||
const virtualAgreementChecked = ref(false);
|
||||
const renterAgreementChecked = ref(false);
|
||||
const virtualAgreementRef = ref<HTMLElement | null>(null);
|
||||
const renterAgreementRef = ref<HTMLElement | null>(null);
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const session = useSessionStore()
|
||||
const loading = ref(false)
|
||||
const ordering = ref(false)
|
||||
const agreementsLoading = ref(false)
|
||||
const agreementVisible = ref(false)
|
||||
const agreements = ref<OrderAgreements | null>(null)
|
||||
const virtualAgreementRead = ref(false)
|
||||
const renterAgreementRead = ref(false)
|
||||
const virtualAgreementChecked = ref(false)
|
||||
const renterAgreementChecked = ref(false)
|
||||
const virtualAgreementRef = ref<HTMLElement | null>(null)
|
||||
const renterAgreementRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const canCreateOrderAfterAgreement = computed(
|
||||
() =>
|
||||
virtualAgreementRead.value &&
|
||||
renterAgreementRead.value &&
|
||||
virtualAgreementChecked.value &&
|
||||
renterAgreementChecked.value,
|
||||
);
|
||||
const listing = ref<Listing | null>(null);
|
||||
renterAgreementChecked.value
|
||||
)
|
||||
const listing = ref<Listing | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
loading.value = true
|
||||
try {
|
||||
listing.value = await fetchListing(String(route.params.id));
|
||||
listing.value = await fetchListing(String(route.params.id))
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loading.value = false
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
const orderTotal = computed(() => {
|
||||
if (!listing.value) return "0";
|
||||
return formatMoney(getListingDisplayPrice(listing.value));
|
||||
});
|
||||
if (!listing.value) return '0'
|
||||
return formatMoney(getListingDisplayPrice(listing.value))
|
||||
})
|
||||
|
||||
const orderPriceBreakdown = computed(() => {
|
||||
if (!listing.value) {
|
||||
@@ -72,260 +76,253 @@ const orderPriceBreakdown = computed(() => {
|
||||
rent: 0,
|
||||
consumable: 0,
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
rent: getListingRentPrice(listing.value),
|
||||
consumable: getListingConsumablePrice(listing.value),
|
||||
total: roundMoney(getListingDisplayPrice(listing.value)),
|
||||
};
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
const coverURL = computed(() => {
|
||||
if (!listing.value) return "";
|
||||
return listing.value.cover_url || listing.value.screenshot_urls?.[0] || "";
|
||||
});
|
||||
if (!listing.value) return ''
|
||||
return listing.value.cover_url || listing.value.screenshot_urls?.[0] || ''
|
||||
})
|
||||
|
||||
const detailMetrics = computed(() => {
|
||||
if (!listing.value) return [];
|
||||
const dailyLoss = getDailyLoss(listing.value);
|
||||
if (!listing.value) return []
|
||||
const dailyLoss = getDailyLoss(listing.value)
|
||||
return [
|
||||
{ label: "纯币", value: formatHafCoinM(getCoinWan(listing.value)), tone: "coin" },
|
||||
{ label: '纯币', value: formatHafCoinM(getCoinWan(listing.value)), tone: 'coin' },
|
||||
{
|
||||
label: "日损耗",
|
||||
value: dailyLoss ? `${dailyLoss}/天` : "--",
|
||||
tone: "coin",
|
||||
label: '日损耗',
|
||||
value: dailyLoss ? `${dailyLoss}/天` : '--',
|
||||
tone: 'coin',
|
||||
},
|
||||
{ label: "价格", value: `¥${orderTotal.value}`, tone: "price" },
|
||||
{ label: "押金", value: `¥${listing.value.deposit_amount}`, tone: "" },
|
||||
];
|
||||
});
|
||||
{ label: '价格', value: `¥${orderTotal.value}`, tone: 'price' },
|
||||
{ label: '押金', value: `¥${listing.value.deposit_amount}`, tone: '' },
|
||||
]
|
||||
})
|
||||
|
||||
const detailScreenshots = computed(() => {
|
||||
if (!listing.value) return [];
|
||||
const groupedScreenshots = readGroupedScreenshots(listing.value);
|
||||
if (groupedScreenshots.length) return groupedScreenshots;
|
||||
const labels = ["纯币截图", "游戏ID截图", "总资产截图", "腾讯安全中心截图", "皮肤截图"];
|
||||
if (!listing.value) return []
|
||||
const groupedScreenshots = readGroupedScreenshots(listing.value)
|
||||
if (groupedScreenshots.length) return groupedScreenshots
|
||||
const labels = ['纯币截图', '游戏ID截图', '总资产截图', '腾讯安全中心截图', '皮肤截图']
|
||||
return (listing.value.screenshot_urls || []).map((url, index) => ({
|
||||
label: labels[index] || `账号截图${index + 1}`,
|
||||
url,
|
||||
}));
|
||||
});
|
||||
}))
|
||||
})
|
||||
|
||||
function readGroupedScreenshots(item: Listing) {
|
||||
const groups = item.asset_summary?.screenshot_groups;
|
||||
if (typeof groups !== "object" || groups === null) return [];
|
||||
const groups = item.asset_summary?.screenshot_groups
|
||||
if (typeof groups !== 'object' || groups === null) return []
|
||||
const slots = [
|
||||
{ key: "coin", label: "纯币截图" },
|
||||
{ key: "gameId", label: "游戏ID截图" },
|
||||
{ key: "totalAsset", label: "总资产截图" },
|
||||
{ key: "tencentSecurity", label: "腾讯安全中心截图" },
|
||||
{ key: "skin", label: "皮肤截图" },
|
||||
];
|
||||
return slots.flatMap((slot) => {
|
||||
const urls = (groups as Record<string, unknown>)[slot.key];
|
||||
if (!Array.isArray(urls)) return [];
|
||||
const validUrls = urls.filter((url): url is string => typeof url === "string" && Boolean(url));
|
||||
{ key: 'coin', label: '纯币截图' },
|
||||
{ key: 'gameId', label: '游戏ID截图' },
|
||||
{ key: 'totalAsset', label: '总资产截图' },
|
||||
{ key: 'tencentSecurity', label: '腾讯安全中心截图' },
|
||||
{ key: 'skin', label: '皮肤截图' },
|
||||
]
|
||||
return slots.flatMap(slot => {
|
||||
const urls = (groups as Record<string, unknown>)[slot.key]
|
||||
if (!Array.isArray(urls)) return []
|
||||
const validUrls = urls.filter((url): url is string => typeof url === 'string' && Boolean(url))
|
||||
return validUrls.map((url, index) => ({
|
||||
label: validUrls.length > 1 ? `${slot.label}${index + 1}` : slot.label,
|
||||
url,
|
||||
}));
|
||||
});
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
const detailSkinGroups = computed(() => {
|
||||
if (!listing.value) return [];
|
||||
const groups = listing.value.asset_summary?.skin_groups;
|
||||
if (typeof groups !== "object" || groups === null) return [];
|
||||
if (!listing.value) return []
|
||||
const groups = listing.value.asset_summary?.skin_groups
|
||||
if (typeof groups !== 'object' || groups === null) return []
|
||||
const titles: Record<string, string> = {
|
||||
melee: "近战皮肤",
|
||||
operator: "干员皮肤",
|
||||
operatorGold: "干员金皮",
|
||||
operatorRed: "干员红皮",
|
||||
weapon: "武器皮肤",
|
||||
};
|
||||
melee: '近战皮肤',
|
||||
operator: '干员皮肤',
|
||||
operatorGold: '干员金皮',
|
||||
operatorRed: '干员红皮',
|
||||
weapon: '武器皮肤',
|
||||
}
|
||||
return Object.entries(groups as Record<string, unknown>)
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
title: titles[key] || key,
|
||||
options: Array.isArray(value)
|
||||
? value.filter((skin): skin is string => typeof skin === "string")
|
||||
? value.filter((skin): skin is string => typeof skin === 'string')
|
||||
: [],
|
||||
}))
|
||||
.filter((group) => group.options.length);
|
||||
});
|
||||
.filter(group => group.options.length)
|
||||
})
|
||||
|
||||
const accountRows = computed(() => {
|
||||
if (!listing.value) return [];
|
||||
const regions = assetRegions(listing.value);
|
||||
if (!listing.value) return []
|
||||
const regions = assetRegions(listing.value)
|
||||
return [
|
||||
{ label: "所属区服", value: getServerRegion(listing.value) || "--" },
|
||||
{ label: "上号方式", value: getLoginMethod(listing.value) || "--" },
|
||||
{ label: "游戏段位", value: listing.value.rank_level || "--" },
|
||||
{ label: "M单价", value: formatRatio(listing.value) },
|
||||
{ label: "方便上号", value: getOnlineTimeText(listing.value) || "--" },
|
||||
{ label: "预计可租", value: formatEstimatedRentalDuration(listing.value) },
|
||||
{ label: "常用登录地", value: regions.length ? regions.join("、") : "--" },
|
||||
{ label: "封禁记录", value: readAssetString(listing.value, "ban_record") || "无" },
|
||||
];
|
||||
});
|
||||
{ label: '所属区服', value: getServerRegion(listing.value) || '--' },
|
||||
{ label: '上号方式', value: getLoginMethod(listing.value) || '--' },
|
||||
{ label: '游戏段位', value: listing.value.rank_level || '--' },
|
||||
{ label: 'M单价', value: formatRatio(listing.value) },
|
||||
{ label: '方便上号', value: getOnlineTimeText(listing.value) || '--' },
|
||||
{ label: '预计可租', value: formatEstimatedRentalDuration(listing.value) },
|
||||
{ label: '常用登录地', value: regions.length ? regions.join('、') : '--' },
|
||||
{ label: '封禁记录', value: readAssetString(listing.value, 'ban_record') || '无' },
|
||||
]
|
||||
})
|
||||
|
||||
async function handleCreateOrder() {
|
||||
if (!listing.value) return;
|
||||
if (!listing.value) return
|
||||
if (!session.token) {
|
||||
await router.push({ path: "/login", query: { redirect: route.fullPath } });
|
||||
return;
|
||||
await router.push({ path: '/login', query: { redirect: route.fullPath } })
|
||||
return
|
||||
}
|
||||
|
||||
if (session.realnameStatus !== "verified") {
|
||||
if (session.realnameStatus !== 'verified') {
|
||||
try {
|
||||
await session.loadMe();
|
||||
await session.loadMe()
|
||||
} catch {
|
||||
// 登录态失效时由全局请求拦截处理。
|
||||
}
|
||||
}
|
||||
|
||||
if (session.realnameStatus !== "verified") {
|
||||
if (session.realnameStatus !== 'verified') {
|
||||
try {
|
||||
await ElMessageBox.confirm("租号下单前需要完成实名认证。", "请先实名认证", {
|
||||
confirmButtonText: "去认证",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
});
|
||||
await router.push({ path: "/realname", query: { redirect: route.fullPath } });
|
||||
await ElMessageBox.confirm('租号下单前需要完成实名认证。', '请先实名认证', {
|
||||
confirmButtonText: '去认证',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
await router.push({ path: '/realname', query: { redirect: route.fullPath } })
|
||||
} catch {
|
||||
// 用户取消认证时停留在当前页面。
|
||||
}
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
await openAgreementBeforeOrder();
|
||||
await openAgreementBeforeOrder()
|
||||
}
|
||||
|
||||
async function openAgreementBeforeOrder() {
|
||||
agreementsLoading.value = true;
|
||||
agreementsLoading.value = true
|
||||
try {
|
||||
agreements.value = await fetchOrderAgreements();
|
||||
virtualAgreementRead.value = false;
|
||||
renterAgreementRead.value = false;
|
||||
virtualAgreementChecked.value = false;
|
||||
renterAgreementChecked.value = false;
|
||||
agreementVisible.value = true;
|
||||
await nextTick();
|
||||
updateAgreementReadState("virtual");
|
||||
updateAgreementReadState("renter");
|
||||
agreements.value = await fetchOrderAgreements()
|
||||
virtualAgreementRead.value = false
|
||||
renterAgreementRead.value = false
|
||||
virtualAgreementChecked.value = false
|
||||
renterAgreementChecked.value = false
|
||||
agreementVisible.value = true
|
||||
await nextTick()
|
||||
updateAgreementReadState('virtual')
|
||||
updateAgreementReadState('renter')
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, "协议加载失败"));
|
||||
ElMessage.error(readError(error, '协议加载失败'))
|
||||
} finally {
|
||||
agreementsLoading.value = false;
|
||||
agreementsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmAgreementAndCreateOrder() {
|
||||
if (!canCreateOrderAfterAgreement.value) {
|
||||
ElMessage.warning("请先阅读并勾选两份协议");
|
||||
return;
|
||||
ElMessage.warning('请先阅读并勾选两份协议')
|
||||
return
|
||||
}
|
||||
agreementVisible.value = false;
|
||||
await submitOrder();
|
||||
agreementVisible.value = false
|
||||
await submitOrder()
|
||||
}
|
||||
|
||||
async function submitOrder() {
|
||||
if (!listing.value) return;
|
||||
ordering.value = true;
|
||||
if (!listing.value) return
|
||||
ordering.value = true
|
||||
try {
|
||||
const order = await createOrder(listing.value.id);
|
||||
ElMessage.success("订单已创建,请完成支付");
|
||||
await router.push(`/orders/${order.id}`);
|
||||
const order = await createOrder(listing.value.id)
|
||||
ElMessage.success('订单已创建,请完成支付')
|
||||
await router.push(`/orders/${order.id}`)
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, "下单失败"));
|
||||
ElMessage.error(readError(error, '下单失败'))
|
||||
} finally {
|
||||
ordering.value = false;
|
||||
ordering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleAgreementScroll(type: "virtual" | "renter") {
|
||||
updateAgreementReadState(type);
|
||||
function handleAgreementScroll(type: 'virtual' | 'renter') {
|
||||
updateAgreementReadState(type)
|
||||
}
|
||||
|
||||
function updateAgreementReadState(type: "virtual" | "renter") {
|
||||
const el = type === "virtual" ? virtualAgreementRef.value : renterAgreementRef.value;
|
||||
if (!el) return;
|
||||
const read = el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
|
||||
if (type === "virtual") {
|
||||
virtualAgreementRead.value = read;
|
||||
function updateAgreementReadState(type: 'virtual' | 'renter') {
|
||||
const el = type === 'virtual' ? virtualAgreementRef.value : renterAgreementRef.value
|
||||
if (!el) return
|
||||
const read = el.scrollTop + el.clientHeight >= el.scrollHeight - 8
|
||||
if (type === 'virtual') {
|
||||
virtualAgreementRead.value = read
|
||||
} else {
|
||||
renterAgreementRead.value = read;
|
||||
renterAgreementRead.value = read
|
||||
}
|
||||
}
|
||||
|
||||
function readError(error: unknown, fallback: string) {
|
||||
if (typeof error === "object" && error && "response" in error) {
|
||||
const response = (error as { response?: { data?: { message?: string } } })
|
||||
.response;
|
||||
return response?.data?.message || fallback;
|
||||
if (typeof error === 'object' && error && 'response' in error) {
|
||||
const response = (error as { response?: { data?: { message?: string } } }).response
|
||||
return response?.data?.message || fallback
|
||||
}
|
||||
return fallback;
|
||||
return fallback
|
||||
}
|
||||
|
||||
// 手动锁定/解锁背景滚动
|
||||
watch(agreementVisible, (visible) => {
|
||||
watch(agreementVisible, visible => {
|
||||
// 只锁定 .pc-main 容器,不影响 body 和 sticky 导航栏
|
||||
const pcMain = document.querySelector('.pc-main') as HTMLElement;
|
||||
if (!pcMain) return;
|
||||
const pcMain = document.querySelector('.pc-main') as HTMLElement
|
||||
if (!pcMain) return
|
||||
|
||||
if (visible) {
|
||||
// 保存当前滚动位置
|
||||
const scrollTop = pcMain.scrollTop || 0;
|
||||
const scrollTop = pcMain.scrollTop || 0
|
||||
|
||||
// 锁定主容器滚动
|
||||
pcMain.style.overflow = 'hidden';
|
||||
pcMain.style.position = 'fixed';
|
||||
pcMain.style.top = `-${scrollTop}px`;
|
||||
pcMain.style.left = '0';
|
||||
pcMain.style.right = '0';
|
||||
pcMain.style.width = '100%';
|
||||
pcMain.style.overflow = 'hidden'
|
||||
pcMain.style.position = 'fixed'
|
||||
pcMain.style.top = `-${scrollTop}px`
|
||||
pcMain.style.left = '0'
|
||||
pcMain.style.right = '0'
|
||||
pcMain.style.width = '100%'
|
||||
|
||||
// 保存滚动位置供恢复使用
|
||||
pcMain.dataset.scrollTop = String(scrollTop);
|
||||
pcMain.dataset.scrollTop = String(scrollTop)
|
||||
} else {
|
||||
// 恢复主容器滚动
|
||||
const scrollTop = parseInt(pcMain.dataset.scrollTop || '0', 10);
|
||||
const scrollTop = parseInt(pcMain.dataset.scrollTop || '0', 10)
|
||||
|
||||
pcMain.style.overflow = '';
|
||||
pcMain.style.position = '';
|
||||
pcMain.style.top = '';
|
||||
pcMain.style.left = '';
|
||||
pcMain.style.right = '';
|
||||
pcMain.style.width = '';
|
||||
pcMain.style.overflow = ''
|
||||
pcMain.style.position = ''
|
||||
pcMain.style.top = ''
|
||||
pcMain.style.left = ''
|
||||
pcMain.style.right = ''
|
||||
pcMain.style.width = ''
|
||||
|
||||
// 恢复滚动位置
|
||||
pcMain.scrollTop = scrollTop;
|
||||
delete pcMain.dataset.scrollTop;
|
||||
pcMain.scrollTop = scrollTop
|
||||
delete pcMain.dataset.scrollTop
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
function listingPrice(item: Listing) {
|
||||
return formatMoney(getListingDisplayPrice(item));
|
||||
return formatMoney(getListingDisplayPrice(item))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pc-detail page" v-loading="loading">
|
||||
<div class="anti-fraud-strip compact">
|
||||
<span
|
||||
>防骗提示:下单后请按平台交接流程确认收号与归还,不要私下交易。</span
|
||||
>
|
||||
<span>防骗提示:下单后请按平台交接流程确认收号与归还,不要私下交易。</span>
|
||||
</div>
|
||||
|
||||
<div v-if="listing" class="pc-detail-layout">
|
||||
<section class="pc-detail-main">
|
||||
<div class="detail-hero-card">
|
||||
<img
|
||||
v-if="coverURL"
|
||||
:src="coverURL"
|
||||
:alt="getListingTitle(listing)"
|
||||
/>
|
||||
<img v-if="coverURL" :src="coverURL" :alt="getListingTitle(listing)" />
|
||||
<span v-else>HFB ACCOUNT</span>
|
||||
<div class="detail-hero-overlay">
|
||||
<div class="detail-tags">
|
||||
@@ -340,7 +337,12 @@ function listingPrice(item: Listing) {
|
||||
|
||||
<div class="detail-body">
|
||||
<div class="detail-summary-row">
|
||||
<div v-for="metric in detailMetrics" :key="metric.label" class="detail-metric" :class="`is-${metric.tone}`">
|
||||
<div
|
||||
v-for="metric in detailMetrics"
|
||||
:key="metric.label"
|
||||
class="detail-metric"
|
||||
:class="`is-${metric.tone}`"
|
||||
>
|
||||
<span>{{ metric.label }}</span>
|
||||
<strong>{{ metric.value }}</strong>
|
||||
</div>
|
||||
@@ -349,7 +351,7 @@ function listingPrice(item: Listing) {
|
||||
<section class="detail-section">
|
||||
<div class="detail-section-head">
|
||||
<h2>账号资料</h2>
|
||||
<span>{{ listing.game_name || "三角洲行动" }}</span>
|
||||
<span>{{ listing.game_name || '三角洲行动' }}</span>
|
||||
</div>
|
||||
<div class="detail-chip-row">
|
||||
<span v-for="chip in getListingChips(listing)" :key="chip.label">
|
||||
@@ -370,13 +372,17 @@ function listingPrice(item: Listing) {
|
||||
<span>{{ getListingResources(listing).length }} 项</span>
|
||||
</div>
|
||||
<div class="detail-resource-grid">
|
||||
<div v-for="resource in getListingResources(listing)" :key="resource.key" class="detail-resource-card">
|
||||
<div
|
||||
v-for="resource in getListingResources(listing)"
|
||||
:key="resource.key"
|
||||
class="detail-resource-card"
|
||||
>
|
||||
<span>{{ resource.label }}</span>
|
||||
<strong>{{ resource.quantity }}</strong>
|
||||
<em>
|
||||
<b>{{ resource.mode || "--" }}</b>
|
||||
<b>{{ resource.mode || '--' }}</b>
|
||||
<small v-if="resource.amount > 0">¥{{ resource.amount }}</small>
|
||||
<small v-else-if="resource.mode === '收费'">{{ resource.price || "¥0" }}</small>
|
||||
<small v-else-if="resource.mode === '收费'">{{ resource.price || '¥0' }}</small>
|
||||
<small v-else>无额外收费</small>
|
||||
</em>
|
||||
</div>
|
||||
@@ -402,7 +408,7 @@ function listingPrice(item: Listing) {
|
||||
<div class="detail-section-head">
|
||||
<h2>号主备注</h2>
|
||||
</div>
|
||||
<p class="detail-description">{{ listing.description || "号主暂未填写详细说明。" }}</p>
|
||||
<p class="detail-description">{{ listing.description || '号主暂未填写详细说明。' }}</p>
|
||||
</section>
|
||||
|
||||
<section v-if="detailScreenshots.length" class="detail-section">
|
||||
@@ -444,11 +450,11 @@ function listingPrice(item: Listing) {
|
||||
<dl class="order-check-list">
|
||||
<div>
|
||||
<dt>账号区服</dt>
|
||||
<dd>{{ getServerRegion(listing) || "--" }}</dd>
|
||||
<dd>{{ getServerRegion(listing) || '--' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>上号方式</dt>
|
||||
<dd>{{ getLoginMethod(listing) || "--" }}</dd>
|
||||
<dd>{{ getLoginMethod(listing) || '--' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>预计可租</dt>
|
||||
@@ -463,7 +469,7 @@ function listingPrice(item: Listing) {
|
||||
class="full-control"
|
||||
@click="handleCreateOrder"
|
||||
>
|
||||
{{ listing.in_transaction ? "交易中" : "立即下单" }}
|
||||
{{ listing.in_transaction ? '交易中' : '立即下单' }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</aside>
|
||||
@@ -560,7 +566,7 @@ function listingPrice(item: Listing) {
|
||||
.detail-hero-card::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: "";
|
||||
content: '';
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 23, 42, 0.06), rgba(15, 23, 42, 0.58)),
|
||||
linear-gradient(90deg, rgba(15, 23, 42, 0.76), rgba(15, 23, 42, 0.08) 62%);
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
|
||||
import {
|
||||
emptyListingPublishOptions,
|
||||
type ListingPublishOptions,
|
||||
} from "@/features/listings/api/listingOptions";
|
||||
import { fetchListings, type Listing } from "@/features/listings/api/listings";
|
||||
} from '@/features/listings/api/listingOptions'
|
||||
import { fetchListings, type Listing } from '@/features/listings/api/listings'
|
||||
import {
|
||||
defaultHomeAnnouncements,
|
||||
defaultHomeBanners,
|
||||
fetchMobileHomeConfig,
|
||||
type HomeBannerSlide,
|
||||
} from "@/features/listings/api/homeConfig";
|
||||
} from '@/features/listings/api/homeConfig'
|
||||
import {
|
||||
assetRegions,
|
||||
formatHafCoinM,
|
||||
@@ -29,296 +29,285 @@ import {
|
||||
hasGiftResources,
|
||||
readAssetNumber,
|
||||
readAssetString,
|
||||
} from "@/utils/listingDisplay";
|
||||
import { listingStatusLabel } from "@/utils/statusLabels";
|
||||
} from '@/utils/listingDisplay'
|
||||
import { listingStatusLabel } from '@/utils/statusLabels'
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 数据加载 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const loading = ref(false);
|
||||
const loadFailed = ref(false);
|
||||
const listings = ref<Listing[]>([]);
|
||||
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions);
|
||||
const announcements = ref<string[]>(defaultHomeAnnouncements);
|
||||
const bannerSlides = ref<HomeBannerSlide[]>(defaultHomeBanners);
|
||||
const loading = ref(false)
|
||||
const loadFailed = ref(false)
|
||||
const listings = ref<Listing[]>([])
|
||||
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions)
|
||||
const announcements = ref<string[]>(defaultHomeAnnouncements)
|
||||
const bannerSlides = ref<HomeBannerSlide[]>(defaultHomeBanners)
|
||||
|
||||
onMounted(() => {
|
||||
loadListings();
|
||||
loadHomeConfig();
|
||||
});
|
||||
loadListings()
|
||||
loadHomeConfig()
|
||||
})
|
||||
|
||||
async function loadListings() {
|
||||
loading.value = true;
|
||||
loadFailed.value = false;
|
||||
loading.value = true
|
||||
loadFailed.value = false
|
||||
try {
|
||||
listings.value = await fetchListings();
|
||||
listings.value = await fetchListings()
|
||||
} catch {
|
||||
loadFailed.value = true;
|
||||
loadFailed.value = true
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHomeConfig() {
|
||||
try {
|
||||
const config = await fetchMobileHomeConfig();
|
||||
announcements.value = config.announcements;
|
||||
bannerSlides.value = config.banners;
|
||||
publishOptions.value = config.publish_options;
|
||||
const config = await fetchMobileHomeConfig()
|
||||
announcements.value = config.announcements
|
||||
bannerSlides.value = config.banners
|
||||
publishOptions.value = config.publish_options
|
||||
} catch {
|
||||
announcements.value = defaultHomeAnnouncements;
|
||||
bannerSlides.value = defaultHomeBanners;
|
||||
publishOptions.value = emptyListingPublishOptions;
|
||||
announcements.value = defaultHomeAnnouncements
|
||||
bannerSlides.value = defaultHomeBanners
|
||||
publishOptions.value = emptyListingPublishOptions
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 排序 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const sortBy = ref("comprehensive");
|
||||
const sortBy = ref('comprehensive')
|
||||
|
||||
const sortOptions = [
|
||||
{ key: "comprehensive", label: "综合排序" },
|
||||
{ key: "published", label: "发布时间" },
|
||||
{ key: "awmDesc", label: "AWM数量" },
|
||||
{ key: "priceAsc", label: "价格最低" },
|
||||
{ key: "priceDesc", label: "价格最高" },
|
||||
];
|
||||
{ key: 'comprehensive', label: '综合排序' },
|
||||
{ key: 'published', label: '发布时间' },
|
||||
{ key: 'awmDesc', label: 'AWM数量' },
|
||||
{ key: 'priceAsc', label: '价格最低' },
|
||||
{ key: 'priceDesc', label: '价格最高' },
|
||||
]
|
||||
|
||||
const activeSortLabel = computed(
|
||||
() => sortOptions.find((o) => o.key === sortBy.value)?.label || "综合排序"
|
||||
);
|
||||
() => sortOptions.find(o => o.key === sortBy.value)?.label || '综合排序'
|
||||
)
|
||||
|
||||
function selectSort(key: string) {
|
||||
sortBy.value = key;
|
||||
sortOpen.value = false;
|
||||
sortBy.value = key
|
||||
sortOpen.value = false
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 筛选 — 复用移动端 FilterSection 体系 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
type SelectedFilters = Record<string, string[]>;
|
||||
type RangeFilters = Record<string, { min: string; max: string }>;
|
||||
type RangePresets = Record<
|
||||
string,
|
||||
Array<{ label: string; min: string; max: string }>
|
||||
>;
|
||||
type SelectedFilters = Record<string, string[]>
|
||||
type RangeFilters = Record<string, { min: string; max: string }>
|
||||
type RangePresets = Record<string, Array<{ label: string; min: string; max: string }>>
|
||||
|
||||
const selectedFilters = ref<SelectedFilters>({});
|
||||
const rangeFilters = ref<RangeFilters>({});
|
||||
const searchValue = ref("");
|
||||
const sortOpen = ref(false);
|
||||
const filterOpen = ref(false);
|
||||
const selectedFilters = ref<SelectedFilters>({})
|
||||
const rangeFilters = ref<RangeFilters>({})
|
||||
const searchValue = ref('')
|
||||
const sortOpen = ref(false)
|
||||
const filterOpen = ref(false)
|
||||
|
||||
const rangePresets: RangePresets = {
|
||||
coin: [
|
||||
{ label: "50-100", min: "50", max: "100" },
|
||||
{ label: "100-200", min: "100", max: "200" },
|
||||
{ label: "200-300", min: "200", max: "300" },
|
||||
{ label: "300-500", min: "300", max: "500" },
|
||||
{ label: "500以上", min: "500", max: "" },
|
||||
{ label: '50-100', min: '50', max: '100' },
|
||||
{ label: '100-200', min: '100', max: '200' },
|
||||
{ label: '200-300', min: '200', max: '300' },
|
||||
{ label: '300-500', min: '300', max: '500' },
|
||||
{ label: '500以上', min: '500', max: '' },
|
||||
],
|
||||
resource_awmAmmo: [
|
||||
{ label: "0-20", min: "0", max: "20" },
|
||||
{ label: "20-50", min: "20", max: "50" },
|
||||
{ label: "50-100", min: "50", max: "100" },
|
||||
{ label: "100-200", min: "100", max: "200" },
|
||||
{ label: "200以上", min: "200", max: "" },
|
||||
{ label: '0-20', min: '0', max: '20' },
|
||||
{ label: '20-50', min: '20', max: '50' },
|
||||
{ label: '50-100', min: '50', max: '100' },
|
||||
{ label: '100-200', min: '100', max: '200' },
|
||||
{ label: '200以上', min: '200', max: '' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const serverFilterOptions = computed(() =>
|
||||
uniqueOptions(
|
||||
publishOptions.value.server_options
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
uniqueOptions(publishOptions.value.server_options.map(item => item.trim()).filter(Boolean))
|
||||
)
|
||||
|
||||
const loginMethodFilterOptions = computed(() =>
|
||||
uniqueOptions(
|
||||
publishOptions.value.login_method_options
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
uniqueOptions(publishOptions.value.login_method_options.map(item => item.trim()).filter(Boolean))
|
||||
)
|
||||
|
||||
type FilterSection =
|
||||
| {
|
||||
key: string;
|
||||
title: string;
|
||||
type: "range";
|
||||
unit?: string;
|
||||
minPlaceholder?: string;
|
||||
maxPlaceholder?: string;
|
||||
key: string
|
||||
title: string
|
||||
type: 'range'
|
||||
unit?: string
|
||||
minPlaceholder?: string
|
||||
maxPlaceholder?: string
|
||||
}
|
||||
| { key: string; title: string; type: "chips"; options: string[] };
|
||||
| { key: string; title: string; type: 'chips'; options: string[] }
|
||||
|
||||
const filterSections = computed<FilterSection[]>(() => [
|
||||
{
|
||||
key: "price",
|
||||
title: "价格区间",
|
||||
type: "range",
|
||||
unit: "元",
|
||||
minPlaceholder: "最低价",
|
||||
maxPlaceholder: "最高价",
|
||||
key: 'price',
|
||||
title: '价格区间',
|
||||
type: 'range',
|
||||
unit: '元',
|
||||
minPlaceholder: '最低价',
|
||||
maxPlaceholder: '最高价',
|
||||
},
|
||||
{
|
||||
key: "coin",
|
||||
title: "哈夫币数量",
|
||||
type: "range",
|
||||
unit: "M",
|
||||
minPlaceholder: "最低",
|
||||
maxPlaceholder: "最高",
|
||||
key: 'coin',
|
||||
title: '哈夫币数量',
|
||||
type: 'range',
|
||||
unit: 'M',
|
||||
minPlaceholder: '最低',
|
||||
maxPlaceholder: '最高',
|
||||
},
|
||||
{
|
||||
key: "server",
|
||||
title: "区服",
|
||||
type: "chips",
|
||||
key: 'server',
|
||||
title: '区服',
|
||||
type: 'chips',
|
||||
options: serverFilterOptions.value,
|
||||
},
|
||||
{
|
||||
key: "login",
|
||||
title: "上号方式",
|
||||
type: "chips",
|
||||
key: 'login',
|
||||
title: '上号方式',
|
||||
type: 'chips',
|
||||
options: loginMethodFilterOptions.value,
|
||||
},
|
||||
{
|
||||
key: "insurance",
|
||||
title: "保险",
|
||||
type: "chips",
|
||||
key: 'insurance',
|
||||
title: '保险',
|
||||
type: 'chips',
|
||||
options: publishOptions.value.insurance_options,
|
||||
},
|
||||
{
|
||||
key: "stamina",
|
||||
title: "体力",
|
||||
type: "chips",
|
||||
key: 'stamina',
|
||||
title: '体力',
|
||||
type: 'chips',
|
||||
options: publishOptions.value.level_options,
|
||||
},
|
||||
{
|
||||
key: "load",
|
||||
title: "负重",
|
||||
type: "chips",
|
||||
key: 'load',
|
||||
title: '负重',
|
||||
type: 'chips',
|
||||
options: publishOptions.value.level_options,
|
||||
},
|
||||
...publishOptions.value.quantity_items.map((item) => ({
|
||||
...publishOptions.value.quantity_items.map(item => ({
|
||||
key: `resource_${item.key}`,
|
||||
title: item.label,
|
||||
type: "range" as const,
|
||||
type: 'range' as const,
|
||||
unit: parseQuantityUnit(item.price),
|
||||
minPlaceholder: "最低",
|
||||
maxPlaceholder: "最高",
|
||||
minPlaceholder: '最低',
|
||||
maxPlaceholder: '最高',
|
||||
})),
|
||||
...publishOptions.value.skin_groups.map((group) => ({
|
||||
...publishOptions.value.skin_groups.map(group => ({
|
||||
key: group.key,
|
||||
title: group.title,
|
||||
type: "chips" as const,
|
||||
type: 'chips' as const,
|
||||
options: group.options,
|
||||
})),
|
||||
{
|
||||
key: "secretKd",
|
||||
title: "绝密KD",
|
||||
type: "range",
|
||||
minPlaceholder: "最低",
|
||||
maxPlaceholder: "最高",
|
||||
key: 'secretKd',
|
||||
title: '绝密KD',
|
||||
type: 'range',
|
||||
minPlaceholder: '最低',
|
||||
maxPlaceholder: '最高',
|
||||
},
|
||||
{
|
||||
key: "rank",
|
||||
title: "段位",
|
||||
type: "chips",
|
||||
key: 'rank',
|
||||
title: '段位',
|
||||
type: 'chips',
|
||||
options: publishOptions.value.rank_options,
|
||||
},
|
||||
{
|
||||
key: "deposit",
|
||||
title: "押金",
|
||||
type: "range",
|
||||
unit: "元",
|
||||
minPlaceholder: "最低",
|
||||
maxPlaceholder: "最高",
|
||||
key: 'deposit',
|
||||
title: '押金',
|
||||
type: 'range',
|
||||
unit: '元',
|
||||
minPlaceholder: '最低',
|
||||
maxPlaceholder: '最高',
|
||||
},
|
||||
]);
|
||||
])
|
||||
|
||||
/* 筛选交互 */
|
||||
const activeFilterSection = ref("price");
|
||||
const filterContentRef = ref<HTMLElement | null>(null);
|
||||
const filterSectionRefs = new Map<string, HTMLElement>();
|
||||
const filterTabRefs = new Map<string, HTMLElement>();
|
||||
const activeFilterSection = ref('price')
|
||||
const filterContentRef = ref<HTMLElement | null>(null)
|
||||
const filterSectionRefs = new Map<string, HTMLElement>()
|
||||
const filterTabRefs = new Map<string, HTMLElement>()
|
||||
|
||||
function toggleChip(sectionKey: string, value: string) {
|
||||
const selected = selectedFilters.value[sectionKey] || [];
|
||||
const selected = selectedFilters.value[sectionKey] || []
|
||||
const next = selected.includes(value)
|
||||
? selected.filter((item) => item !== value)
|
||||
: [...selected, value];
|
||||
selectedFilters.value = { ...selectedFilters.value, [sectionKey]: next };
|
||||
? selected.filter(item => item !== value)
|
||||
: [...selected, value]
|
||||
selectedFilters.value = { ...selectedFilters.value, [sectionKey]: next }
|
||||
}
|
||||
|
||||
function updateRange(sectionKey: string, side: "min" | "max", value: string) {
|
||||
const current = rangeFilters.value[sectionKey] || { min: "", max: "" };
|
||||
function updateRange(sectionKey: string, side: 'min' | 'max', value: string) {
|
||||
const current = rangeFilters.value[sectionKey] || { min: '', max: '' }
|
||||
rangeFilters.value = {
|
||||
...rangeFilters.value,
|
||||
[sectionKey]: { ...current, [side]: value },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function applyRangePreset(sectionKey: string, min: string, max: string) {
|
||||
rangeFilters.value = {
|
||||
...rangeFilters.value,
|
||||
[sectionKey]: { min, max },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function isRangePresetActive(sectionKey: string, min: string, max: string) {
|
||||
const range = rangeFilters.value[sectionKey];
|
||||
return range?.min === min && range?.max === max;
|
||||
const range = rangeFilters.value[sectionKey]
|
||||
return range?.min === min && range?.max === max
|
||||
}
|
||||
|
||||
function isSkinGroupKey(key: string) {
|
||||
return publishOptions.value.skin_groups.some((group) => group.key === key);
|
||||
return publishOptions.value.skin_groups.some(group => group.key === key)
|
||||
}
|
||||
|
||||
function setFilterSectionRef(key: string, el: Element | null) {
|
||||
if (el instanceof HTMLElement) filterSectionRefs.set(key, el);
|
||||
else filterSectionRefs.delete(key);
|
||||
if (el instanceof HTMLElement) filterSectionRefs.set(key, el)
|
||||
else filterSectionRefs.delete(key)
|
||||
}
|
||||
|
||||
function setFilterTabRef(key: string, el: Element | null) {
|
||||
if (el instanceof HTMLElement) filterTabRefs.set(key, el);
|
||||
else filterTabRefs.delete(key);
|
||||
if (el instanceof HTMLElement) filterTabRefs.set(key, el)
|
||||
else filterTabRefs.delete(key)
|
||||
}
|
||||
|
||||
function scrollToFilterSection(key: string) {
|
||||
const container = filterContentRef.value;
|
||||
const target = filterSectionRefs.get(key);
|
||||
if (!container || !target) return;
|
||||
activeFilterSection.value = key;
|
||||
const container = filterContentRef.value
|
||||
const target = filterSectionRefs.get(key)
|
||||
if (!container || !target) return
|
||||
activeFilterSection.value = key
|
||||
container.scrollTo({
|
||||
top: Math.max(target.offsetTop - 8, 0),
|
||||
behavior: "smooth",
|
||||
});
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
function handleFilterScroll() {
|
||||
const container = filterContentRef.value;
|
||||
if (!container) return;
|
||||
const anchorTop = container.scrollTop + 16;
|
||||
let activeKey = filterSections.value[0]?.key || "";
|
||||
const container = filterContentRef.value
|
||||
if (!container) return
|
||||
const anchorTop = container.scrollTop + 16
|
||||
let activeKey = filterSections.value[0]?.key || ''
|
||||
for (const section of filterSections.value) {
|
||||
const el = filterSectionRefs.get(section.key);
|
||||
if (el && el.offsetTop <= anchorTop) activeKey = section.key;
|
||||
const el = filterSectionRefs.get(section.key)
|
||||
if (el && el.offsetTop <= anchorTop) activeKey = section.key
|
||||
}
|
||||
if (activeKey && activeKey !== activeFilterSection.value) {
|
||||
activeFilterSection.value = activeKey;
|
||||
activeFilterSection.value = activeKey
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSortPanel() {
|
||||
sortOpen.value = !sortOpen.value;
|
||||
sortOpen.value = !sortOpen.value
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
selectedFilters.value = {};
|
||||
rangeFilters.value = {};
|
||||
searchValue.value = "";
|
||||
selectedFilters.value = {}
|
||||
rangeFilters.value = {}
|
||||
searchValue.value = ''
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -328,64 +317,59 @@ const activeFilterCount = computed(() => {
|
||||
const chipCount = Object.values(selectedFilters.value).reduce(
|
||||
(sum, values) => sum + values.length,
|
||||
0
|
||||
);
|
||||
)
|
||||
const rangeCount = Object.values(rangeFilters.value).filter(
|
||||
(range) => range.min || range.max
|
||||
).length;
|
||||
return chipCount + rangeCount;
|
||||
});
|
||||
range => range.min || range.max
|
||||
).length
|
||||
return chipCount + rangeCount
|
||||
})
|
||||
|
||||
const filteredListings = computed(() => {
|
||||
const keyword = searchValue.value.trim().toLowerCase();
|
||||
const filtered = listings.value.filter((item) => {
|
||||
if (!matchesFilters(item)) return false;
|
||||
if (!keyword) return true;
|
||||
return searchText(item).includes(keyword);
|
||||
});
|
||||
return sortListings(filtered);
|
||||
});
|
||||
const keyword = searchValue.value.trim().toLowerCase()
|
||||
const filtered = listings.value.filter(item => {
|
||||
if (!matchesFilters(item)) return false
|
||||
if (!keyword) return true
|
||||
return searchText(item).includes(keyword)
|
||||
})
|
||||
return sortListings(filtered)
|
||||
})
|
||||
|
||||
function matchesFilters(item: Listing) {
|
||||
const chipOk = Object.entries(selectedFilters.value).every(
|
||||
([key, values]) => {
|
||||
if (!values.length) return true;
|
||||
if (key === "server") return values.includes(getServerRegion(item));
|
||||
if (key === "login") return values.includes(getLoginMethod(item));
|
||||
if (key === "insurance")
|
||||
return values.includes(readAssetString(item, "season_insurance"));
|
||||
if (key === "stamina")
|
||||
return values.includes(readAssetString(item, "stamina_level"));
|
||||
if (key === "load")
|
||||
return values.includes(readAssetString(item, "load_level"));
|
||||
if (key === "rank") return values.includes(item.rank_level);
|
||||
if (isSkinGroupKey(key)) {
|
||||
return getSkinGroup(item, key).some((skin) => values.includes(skin));
|
||||
}
|
||||
return true;
|
||||
const chipOk = Object.entries(selectedFilters.value).every(([key, values]) => {
|
||||
if (!values.length) return true
|
||||
if (key === 'server') return values.includes(getServerRegion(item))
|
||||
if (key === 'login') return values.includes(getLoginMethod(item))
|
||||
if (key === 'insurance') return values.includes(readAssetString(item, 'season_insurance'))
|
||||
if (key === 'stamina') return values.includes(readAssetString(item, 'stamina_level'))
|
||||
if (key === 'load') return values.includes(readAssetString(item, 'load_level'))
|
||||
if (key === 'rank') return values.includes(item.rank_level)
|
||||
if (isSkinGroupKey(key)) {
|
||||
return getSkinGroup(item, key).some(skin => values.includes(skin))
|
||||
}
|
||||
);
|
||||
if (!chipOk) return false;
|
||||
return true
|
||||
})
|
||||
if (!chipOk) return false
|
||||
|
||||
return Object.entries(rangeFilters.value).every(([key, range]) => {
|
||||
if (!range.min && !range.max) return true;
|
||||
let value = 0;
|
||||
if (key === "price") value = getListingDisplayPrice(item);
|
||||
if (key === "coin") value = getCoinM(item);
|
||||
if (key === "secretKd") value = readAssetNumber(item, "secret_kd");
|
||||
if (key === "deposit") value = Number(item.deposit_amount || 0);
|
||||
if (key.startsWith("resource_")) {
|
||||
value = getResourceQuantity(item, key.replace("resource_", ""));
|
||||
if (!range.min && !range.max) return true
|
||||
let value = 0
|
||||
if (key === 'price') value = getListingDisplayPrice(item)
|
||||
if (key === 'coin') value = getCoinM(item)
|
||||
if (key === 'secretKd') value = readAssetNumber(item, 'secret_kd')
|
||||
if (key === 'deposit') value = Number(item.deposit_amount || 0)
|
||||
if (key.startsWith('resource_')) {
|
||||
value = getResourceQuantity(item, key.replace('resource_', ''))
|
||||
}
|
||||
return inRange(value, range);
|
||||
});
|
||||
return inRange(value, range)
|
||||
})
|
||||
}
|
||||
|
||||
function inRange(value: number, range: { min: string; max: string }) {
|
||||
const min = range.min === "" ? undefined : Number(range.min);
|
||||
const max = range.max === "" ? undefined : Number(range.max);
|
||||
if (min !== undefined && Number.isFinite(min) && value < min) return false;
|
||||
if (max !== undefined && Number.isFinite(max) && value > max) return false;
|
||||
return true;
|
||||
const min = range.min === '' ? undefined : Number(range.min)
|
||||
const max = range.max === '' ? undefined : Number(range.max)
|
||||
if (min !== undefined && Number.isFinite(min) && value < min) return false
|
||||
if (max !== undefined && Number.isFinite(max) && value > max) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function searchText(item: Listing) {
|
||||
@@ -395,53 +379,47 @@ function searchText(item: Listing) {
|
||||
getServerRegion(item),
|
||||
getLoginMethod(item),
|
||||
item.rank_level,
|
||||
readAssetString(item, "season_insurance"),
|
||||
readAssetString(item, "stamina_level"),
|
||||
readAssetString(item, "load_level"),
|
||||
readAssetString(item, 'season_insurance'),
|
||||
readAssetString(item, 'stamina_level'),
|
||||
readAssetString(item, 'load_level'),
|
||||
formatHafCoinM(getCoinWan(item)),
|
||||
assetRegions(item).join(" "),
|
||||
...publishOptions.value.skin_groups.map((group) =>
|
||||
getSkinGroup(item, group.key).join(" ")
|
||||
),
|
||||
assetRegions(item).join(' '),
|
||||
...publishOptions.value.skin_groups.map(group => getSkinGroup(item, group.key).join(' ')),
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function sortListings(items: Listing[]) {
|
||||
const sorted = [...items];
|
||||
if (sortBy.value === "comprehensive") return sorted;
|
||||
if (sortBy.value === "priceAsc") {
|
||||
return sorted.sort(
|
||||
(a, b) => getListingDisplayPrice(a) - getListingDisplayPrice(b)
|
||||
);
|
||||
const sorted = [...items]
|
||||
if (sortBy.value === 'comprehensive') return sorted
|
||||
if (sortBy.value === 'priceAsc') {
|
||||
return sorted.sort((a, b) => getListingDisplayPrice(a) - getListingDisplayPrice(b))
|
||||
}
|
||||
if (sortBy.value === "priceDesc") {
|
||||
return sorted.sort(
|
||||
(a, b) => getListingDisplayPrice(b) - getListingDisplayPrice(a)
|
||||
);
|
||||
if (sortBy.value === 'priceDesc') {
|
||||
return sorted.sort((a, b) => getListingDisplayPrice(b) - getListingDisplayPrice(a))
|
||||
}
|
||||
if (sortBy.value === "awmDesc") {
|
||||
if (sortBy.value === 'awmDesc') {
|
||||
return sorted.sort(
|
||||
(a, b) =>
|
||||
getResourceQuantity(b, "awmAmmo") - getResourceQuantity(a, "awmAmmo") ||
|
||||
getResourceQuantity(b, 'awmAmmo') - getResourceQuantity(a, 'awmAmmo') ||
|
||||
getCoinWan(b) - getCoinWan(a)
|
||||
);
|
||||
)
|
||||
}
|
||||
return sorted.sort((a, b) => {
|
||||
const bTime = Date.parse(b.published_at || b.created_at || "") || 0;
|
||||
const aTime = Date.parse(a.published_at || a.created_at || "") || 0;
|
||||
return bTime - aTime || b.id - a.id;
|
||||
});
|
||||
const bTime = Date.parse(b.published_at || b.created_at || '') || 0
|
||||
const aTime = Date.parse(a.published_at || a.created_at || '') || 0
|
||||
return bTime - aTime || b.id - a.id
|
||||
})
|
||||
}
|
||||
|
||||
function uniqueOptions(values: string[]) {
|
||||
return [...new Set(values.map((item) => item.trim()).filter(Boolean))];
|
||||
return [...new Set(values.map(item => item.trim()).filter(Boolean))]
|
||||
}
|
||||
|
||||
function parseQuantityUnit(price: string) {
|
||||
const unit = price.split("/")[1]?.trim();
|
||||
return unit || undefined;
|
||||
const unit = price.split('/')[1]?.trim()
|
||||
return unit || undefined
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -450,12 +428,7 @@ function parseQuantityUnit(price: string) {
|
||||
<!-- 防骗提示 -->
|
||||
<div class="fraud-tip">
|
||||
<span class="fraud-dot"></span>
|
||||
<span
|
||||
v-for="(text, idx) in announcements"
|
||||
:key="idx"
|
||||
class="fraud-text"
|
||||
>{{ text }}</span
|
||||
>
|
||||
<span v-for="(text, idx) in announcements" :key="idx" class="fraud-text">{{ text }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 页头 -->
|
||||
@@ -479,11 +452,7 @@ function parseQuantityUnit(price: string) {
|
||||
<input v-model="searchValue" placeholder="搜区服 / 段位 / 哈夫币数量" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn sort-btn"
|
||||
@click="toggleSortPanel"
|
||||
>
|
||||
<button type="button" class="toolbar-btn sort-btn" @click="toggleSortPanel">
|
||||
<span>{{ activeSortLabel }}</span>
|
||||
<svg
|
||||
:class="{ rotated: sortOpen }"
|
||||
@@ -500,11 +469,7 @@ function parseQuantityUnit(price: string) {
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn filter-btn"
|
||||
@click="filterOpen = !filterOpen"
|
||||
>
|
||||
<button type="button" class="toolbar-btn filter-btn" @click="filterOpen = !filterOpen">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
@@ -550,13 +515,7 @@ function parseQuantityUnit(price: string) {
|
||||
<aside v-if="filterOpen" class="filter-sidebar">
|
||||
<header class="filter-sidebar-header">
|
||||
<h2>筛选</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="filter-close-btn"
|
||||
@click="filterOpen = false"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<button type="button" class="filter-close-btn" @click="filterOpen = false">✕</button>
|
||||
</header>
|
||||
|
||||
<div class="filter-sidebar-body">
|
||||
@@ -564,7 +523,7 @@ function parseQuantityUnit(price: string) {
|
||||
<button
|
||||
v-for="section in filterSections"
|
||||
:key="section.key"
|
||||
:ref="(el) => setFilterTabRef(section.key, el as Element | null)"
|
||||
:ref="el => setFilterTabRef(section.key, el as Element | null)"
|
||||
type="button"
|
||||
:class="{ active: activeFilterSection === section.key }"
|
||||
@click="scrollToFilterSection(section.key)"
|
||||
@@ -581,14 +540,11 @@ function parseQuantityUnit(price: string) {
|
||||
<div
|
||||
v-for="section in filterSections"
|
||||
:key="section.key"
|
||||
:ref="(el) => setFilterSectionRef(section.key, el as Element | null)"
|
||||
:ref="el => setFilterSectionRef(section.key, el as Element | null)"
|
||||
class="filter-block"
|
||||
>
|
||||
<h3>{{ section.title }}</h3>
|
||||
<p
|
||||
v-if="section.type === 'range' && section.unit"
|
||||
class="filter-unit"
|
||||
>
|
||||
<p v-if="section.type === 'range' && section.unit" class="filter-unit">
|
||||
单位:{{ section.unit }}
|
||||
</p>
|
||||
|
||||
@@ -600,11 +556,7 @@ function parseQuantityUnit(price: string) {
|
||||
inputmode="decimal"
|
||||
:placeholder="section.minPlaceholder || '最低'"
|
||||
@input="
|
||||
updateRange(
|
||||
section.key,
|
||||
'min',
|
||||
($event.target as HTMLInputElement).value
|
||||
)
|
||||
updateRange(section.key, 'min', ($event.target as HTMLInputElement).value)
|
||||
"
|
||||
/>
|
||||
<span class="range-sep">—</span>
|
||||
@@ -614,32 +566,19 @@ function parseQuantityUnit(price: string) {
|
||||
inputmode="decimal"
|
||||
:placeholder="section.maxPlaceholder || '最高'"
|
||||
@input="
|
||||
updateRange(
|
||||
section.key,
|
||||
'max',
|
||||
($event.target as HTMLInputElement).value
|
||||
)
|
||||
updateRange(section.key, 'max', ($event.target as HTMLInputElement).value)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="rangePresets[section.key]?.length"
|
||||
class="chip-grid range-preset-grid"
|
||||
>
|
||||
<div v-if="rangePresets[section.key]?.length" class="chip-grid range-preset-grid">
|
||||
<button
|
||||
v-for="preset in rangePresets[section.key]"
|
||||
:key="`${section.key}-${preset.label}`"
|
||||
type="button"
|
||||
:class="{
|
||||
active: isRangePresetActive(
|
||||
section.key,
|
||||
preset.min,
|
||||
preset.max
|
||||
),
|
||||
active: isRangePresetActive(section.key, preset.min, preset.max),
|
||||
}"
|
||||
@click="
|
||||
applyRangePreset(section.key, preset.min, preset.max)
|
||||
"
|
||||
@click="applyRangePreset(section.key, preset.min, preset.max)"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
@@ -664,35 +603,23 @@ function parseQuantityUnit(price: string) {
|
||||
</div>
|
||||
|
||||
<footer class="filter-sidebar-footer">
|
||||
<button type="button" class="reset-btn" @click="clearFilters">
|
||||
重置
|
||||
</button>
|
||||
<button type="button" class="confirm-btn" @click="filterOpen = false">
|
||||
确定
|
||||
</button>
|
||||
<button type="button" class="reset-btn" @click="clearFilters">重置</button>
|
||||
<button type="button" class="confirm-btn" @click="filterOpen = false">确定</button>
|
||||
</footer>
|
||||
</aside>
|
||||
</Transition>
|
||||
|
||||
<!-- 筛选遮罩 -->
|
||||
<Transition name="overlay">
|
||||
<div
|
||||
v-if="filterOpen"
|
||||
class="filter-overlay"
|
||||
@click="filterOpen = false"
|
||||
></div>
|
||||
<div v-if="filterOpen" class="filter-overlay" @click="filterOpen = false"></div>
|
||||
</Transition>
|
||||
|
||||
<!-- 加载 / 错误 / 空状态 -->
|
||||
<div v-if="loading" class="state-loading">正在加载优质账号...</div>
|
||||
<div v-else-if="loadFailed" class="state-error">
|
||||
接口暂不可用,请稍后刷新。
|
||||
</div>
|
||||
<div v-else-if="loadFailed" class="state-error">接口暂不可用,请稍后刷新。</div>
|
||||
<div v-else-if="filteredListings.length === 0" class="state-empty">
|
||||
<p>没有符合条件的账号</p>
|
||||
<button type="button" class="reset-btn" @click="clearFilters">
|
||||
重置条件
|
||||
</button>
|
||||
<button type="button" class="reset-btn" @click="clearFilters">重置条件</button>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
@@ -712,10 +639,7 @@ function parseQuantityUnit(price: string) {
|
||||
decoding="async"
|
||||
/>
|
||||
<span v-else>图</span>
|
||||
<div
|
||||
v-if="hasGiftResources(item) || hasAcceleratedSaleRatio(item)"
|
||||
class="cover-labels"
|
||||
>
|
||||
<div v-if="hasGiftResources(item) || hasAcceleratedSaleRatio(item)" class="cover-labels">
|
||||
<em v-if="hasGiftResources(item)">有赠送</em>
|
||||
<em v-if="hasAcceleratedSaleRatio(item)">特惠</em>
|
||||
</div>
|
||||
@@ -726,14 +650,10 @@ function parseQuantityUnit(price: string) {
|
||||
<div class="resource-badge-row">
|
||||
<span class="trust-badge">押金秒退</span>
|
||||
<span class="server-badge">{{ getServerRegion(item) }}</span>
|
||||
<span v-if="getLoginMethod(item)" class="server-badge">{{
|
||||
getLoginMethod(item)
|
||||
}}</span>
|
||||
<span v-if="getLoginMethod(item)" class="server-badge">{{ getLoginMethod(item) }}</span>
|
||||
</div>
|
||||
<div class="card-chip-row">
|
||||
<span
|
||||
v-for="chip in getListingChips(item)"
|
||||
:key="`${item.id}-${chip.label}`"
|
||||
<span v-for="chip in getListingChips(item)" :key="`${item.id}-${chip.label}`"
|
||||
>{{ chip.label }}:{{ chip.value }}</span
|
||||
>
|
||||
</div>
|
||||
@@ -797,7 +717,7 @@ function parseQuantityUnit(price: string) {
|
||||
}
|
||||
|
||||
.fraud-text + .fraud-text::before {
|
||||
content: " | ";
|
||||
content: ' | ';
|
||||
color: #d4c47a;
|
||||
}
|
||||
|
||||
@@ -848,7 +768,9 @@ function parseQuantityUnit(price: string) {
|
||||
border-radius: 10px;
|
||||
background: #f4f6f8;
|
||||
border: 1px solid transparent;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.search-box:focus-within {
|
||||
@@ -890,7 +812,9 @@ function parseQuantityUnit(price: string) {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover {
|
||||
@@ -950,7 +874,9 @@ function parseQuantityUnit(price: string) {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
transition:
|
||||
background 0.12s,
|
||||
color 0.12s;
|
||||
}
|
||||
|
||||
.sort-panel button:hover {
|
||||
@@ -972,7 +898,9 @@ function parseQuantityUnit(price: string) {
|
||||
|
||||
.sort-panel-enter-active,
|
||||
.sort-panel-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
|
||||
.sort-panel-enter-from,
|
||||
@@ -1051,7 +979,9 @@ function parseQuantityUnit(price: string) {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
transition:
|
||||
background 0.12s,
|
||||
color 0.12s;
|
||||
}
|
||||
|
||||
.filter-tabs button:hover {
|
||||
@@ -1114,7 +1044,9 @@ function parseQuantityUnit(price: string) {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.range-editor input:focus {
|
||||
@@ -1139,7 +1071,10 @@ function parseQuantityUnit(price: string) {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
|
||||
transition:
|
||||
background 0.12s,
|
||||
color 0.12s,
|
||||
box-shadow 0.12s;
|
||||
}
|
||||
|
||||
.chip-grid button:hover {
|
||||
@@ -1255,7 +1190,9 @@ function parseQuantityUnit(price: string) {
|
||||
border: 1px solid #eef1f5;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: box-shadow 0.15s, border-color 0.15s;
|
||||
transition:
|
||||
box-shadow 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.resource-card:hover {
|
||||
|
||||
@@ -36,14 +36,21 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const activeCount = computed(() => {
|
||||
const chipCount = Object.values(props.selectedFilters).reduce((sum, values) => sum + values.length, 0)
|
||||
const rangeCount = Object.values(props.rangeFilters).filter((range) => range.min || range.max).length
|
||||
const chipCount = Object.values(props.selectedFilters).reduce(
|
||||
(sum, values) => sum + values.length,
|
||||
0
|
||||
)
|
||||
const rangeCount = Object.values(props.rangeFilters).filter(
|
||||
range => range.min || range.max
|
||||
).length
|
||||
return chipCount + rangeCount
|
||||
})
|
||||
|
||||
function toggleChip(sectionKey: string, value: string) {
|
||||
const selected = props.selectedFilters[sectionKey] || []
|
||||
const next = selected.includes(value) ? selected.filter((item) => item !== value) : [...selected, value]
|
||||
const next = selected.includes(value)
|
||||
? selected.filter(item => item !== value)
|
||||
: [...selected, value]
|
||||
emit('update:selectedFilters', { ...props.selectedFilters, [sectionKey]: next })
|
||||
}
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
.mobile-banner.has-image::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: "";
|
||||
content: '';
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0.05), rgba(15, 23, 42, 0.72));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { RouterLink, useRouter } from "vue-router";
|
||||
import { showToast } from "vant";
|
||||
import MobileBottomNav from "@/components/MobileBottomNav.vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { showToast } from 'vant'
|
||||
import MobileBottomNav from '@/components/MobileBottomNav.vue'
|
||||
|
||||
import { ensureSupportChat } from "@/features/chats/api/chats";
|
||||
import { ensureSupportChat } from '@/features/chats/api/chats'
|
||||
import {
|
||||
emptyListingPublishOptions,
|
||||
type ListingPublishOptions,
|
||||
} from "@/features/listings/api/listingOptions";
|
||||
import { fetchListingsPage, type Listing, type PublicListingQuery } from "@/features/listings/api/listings";
|
||||
} from '@/features/listings/api/listingOptions'
|
||||
import {
|
||||
fetchListingsPage,
|
||||
type Listing,
|
||||
type PublicListingQuery,
|
||||
} from '@/features/listings/api/listings'
|
||||
import {
|
||||
defaultHomeAnnouncements,
|
||||
defaultHomeBanners,
|
||||
fetchMobileHomeConfig,
|
||||
type HomeBannerSlide,
|
||||
} from "@/features/listings/api/homeConfig";
|
||||
import MobileHomeFilterSheet, {
|
||||
type FilterSection,
|
||||
} from "./MobileHomeFilterSheet.vue";
|
||||
} from '@/features/listings/api/homeConfig'
|
||||
import MobileHomeFilterSheet, { type FilterSection } from './MobileHomeFilterSheet.vue'
|
||||
import {
|
||||
getListingChips,
|
||||
getListingDisplayPrice,
|
||||
@@ -28,237 +30,256 @@ import {
|
||||
getServerRegion,
|
||||
hasAcceleratedSaleRatio,
|
||||
hasGiftResources,
|
||||
} from "@/utils/listingDisplay";
|
||||
import { useSessionStore } from "@/stores/session";
|
||||
} from '@/utils/listingDisplay'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const router = useRouter();
|
||||
const session = useSessionStore();
|
||||
const loading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const loadFailed = ref(false);
|
||||
const listings = ref<Listing[]>([]);
|
||||
const totalListings = ref(0);
|
||||
const currentPage = ref(1);
|
||||
const hasMoreListings = ref(true);
|
||||
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions);
|
||||
const sortOpen = ref(false);
|
||||
const activeSort = ref("comprehensive");
|
||||
const filterOpen = ref(false);
|
||||
const selectedFilters = ref<Record<string, string[]>>({});
|
||||
const rangeFilters = ref<Record<string, { min: string; max: string }>>({});
|
||||
const refreshing = ref(false);
|
||||
const searchValue = ref("");
|
||||
const announcements = ref<string[]>(defaultHomeAnnouncements);
|
||||
const bannerSlides = ref<HomeBannerSlide[]>(defaultHomeBanners);
|
||||
const supportLoading = ref(false);
|
||||
const mobilePageSize = 10;
|
||||
let listingRequestSeq = 0;
|
||||
const router = useRouter()
|
||||
const session = useSessionStore()
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const loadFailed = ref(false)
|
||||
const listings = ref<Listing[]>([])
|
||||
const totalListings = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const hasMoreListings = ref(true)
|
||||
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions)
|
||||
const sortOpen = ref(false)
|
||||
const activeSort = ref('comprehensive')
|
||||
const filterOpen = ref(false)
|
||||
const selectedFilters = ref<Record<string, string[]>>({})
|
||||
const rangeFilters = ref<Record<string, { min: string; max: string }>>({})
|
||||
const refreshing = ref(false)
|
||||
const searchValue = ref('')
|
||||
const announcements = ref<string[]>(defaultHomeAnnouncements)
|
||||
const bannerSlides = ref<HomeBannerSlide[]>(defaultHomeBanners)
|
||||
const supportLoading = ref(false)
|
||||
const mobilePageSize = 10
|
||||
let listingRequestSeq = 0
|
||||
|
||||
const sortOptions = [
|
||||
{ key: "comprehensive", label: "综合排序" },
|
||||
{ key: "published", label: "发布时间" },
|
||||
{ key: "awmDesc", label: "AWM数量" },
|
||||
{ key: "priceAsc", label: "价格最低" },
|
||||
{ key: "priceDesc", label: "价格最高" },
|
||||
];
|
||||
{ key: 'comprehensive', label: '综合排序' },
|
||||
{ key: 'published', label: '发布时间' },
|
||||
{ key: 'awmDesc', label: 'AWM数量' },
|
||||
{ key: 'priceAsc', label: '价格最低' },
|
||||
{ key: 'priceDesc', label: '价格最高' },
|
||||
]
|
||||
|
||||
const rangePresets: Record<string, Array<{ label: string; min: string; max: string }>> = {
|
||||
coin: [
|
||||
{ label: "50-100", min: "50", max: "100" },
|
||||
{ label: "100-200", min: "100", max: "200" },
|
||||
{ label: "200-300", min: "200", max: "300" },
|
||||
{ label: "300-500", min: "300", max: "500" },
|
||||
{ label: "500以上", min: "500", max: "" },
|
||||
{ label: '50-100', min: '50', max: '100' },
|
||||
{ label: '100-200', min: '100', max: '200' },
|
||||
{ label: '200-300', min: '200', max: '300' },
|
||||
{ label: '300-500', min: '300', max: '500' },
|
||||
{ label: '500以上', min: '500', max: '' },
|
||||
],
|
||||
resource_awmAmmo: [
|
||||
{ label: "0-20", min: "0", max: "20" },
|
||||
{ label: "20-50", min: "20", max: "50" },
|
||||
{ label: "50-100", min: "50", max: "100" },
|
||||
{ label: "100-200", min: "100", max: "200" },
|
||||
{ label: "200以上", min: "200", max: "" },
|
||||
{ label: '0-20', min: '0', max: '20' },
|
||||
{ label: '20-50', min: '20', max: '50' },
|
||||
{ label: '50-100', min: '50', max: '100' },
|
||||
{ label: '100-200', min: '100', max: '200' },
|
||||
{ label: '200以上', min: '200', max: '' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const activeSortLabel = computed(
|
||||
() =>
|
||||
sortOptions.find((option) => option.key === activeSort.value)?.label ||
|
||||
"综合排序"
|
||||
);
|
||||
() => sortOptions.find(option => option.key === activeSort.value)?.label || '综合排序'
|
||||
)
|
||||
|
||||
async function handleSupportClick() {
|
||||
if (!session.isLoggedIn) {
|
||||
router.push({ path: "/m/login", query: { redirect: router.currentRoute.value.fullPath } });
|
||||
return;
|
||||
router.push({ path: '/m/login', query: { redirect: router.currentRoute.value.fullPath } })
|
||||
return
|
||||
}
|
||||
if (supportLoading.value) return;
|
||||
supportLoading.value = true;
|
||||
if (supportLoading.value) return
|
||||
supportLoading.value = true
|
||||
try {
|
||||
const chat = await ensureSupportChat();
|
||||
router.push(`/m/chats/${chat.id}`);
|
||||
const chat = await ensureSupportChat()
|
||||
router.push(`/m/chats/${chat.id}`)
|
||||
} catch {
|
||||
showToast({ message: "联系客服失败,请稍后重试", icon: "cross" });
|
||||
showToast({ message: '联系客服失败,请稍后重试', icon: 'cross' })
|
||||
} finally {
|
||||
supportLoading.value = false;
|
||||
supportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const serverFilterOptions = computed(() =>
|
||||
uniqueOptions(
|
||||
publishOptions.value.server_options
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
uniqueOptions(publishOptions.value.server_options.map(item => item.trim()).filter(Boolean))
|
||||
)
|
||||
|
||||
const loginMethodFilterOptions = computed(() =>
|
||||
uniqueOptions(
|
||||
publishOptions.value.login_method_options
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
uniqueOptions(publishOptions.value.login_method_options.map(item => item.trim()).filter(Boolean))
|
||||
)
|
||||
|
||||
const filterSections = computed<FilterSection[]>(() => [
|
||||
{ key: "price", title: "价格区间", type: "range", unit: "元", minPlaceholder: "最低价", maxPlaceholder: "最高价" },
|
||||
{ key: "coin", title: "哈夫币数量", type: "range", unit: "M", minPlaceholder: "最低", maxPlaceholder: "最高" },
|
||||
{ key: "server", title: "区服", type: "chips", options: serverFilterOptions.value },
|
||||
{ key: "login", title: "上号方式", type: "chips", options: loginMethodFilterOptions.value },
|
||||
{ key: "insurance", title: "保险", type: "chips", options: publishOptions.value.insurance_options },
|
||||
{ key: "stamina", title: "体力", type: "chips", options: publishOptions.value.level_options },
|
||||
{ key: "load", title: "负重", type: "chips", options: publishOptions.value.level_options },
|
||||
...publishOptions.value.quantity_items.map((item) => ({
|
||||
{
|
||||
key: 'price',
|
||||
title: '价格区间',
|
||||
type: 'range',
|
||||
unit: '元',
|
||||
minPlaceholder: '最低价',
|
||||
maxPlaceholder: '最高价',
|
||||
},
|
||||
{
|
||||
key: 'coin',
|
||||
title: '哈夫币数量',
|
||||
type: 'range',
|
||||
unit: 'M',
|
||||
minPlaceholder: '最低',
|
||||
maxPlaceholder: '最高',
|
||||
},
|
||||
{ key: 'server', title: '区服', type: 'chips', options: serverFilterOptions.value },
|
||||
{ key: 'login', title: '上号方式', type: 'chips', options: loginMethodFilterOptions.value },
|
||||
{
|
||||
key: 'insurance',
|
||||
title: '保险',
|
||||
type: 'chips',
|
||||
options: publishOptions.value.insurance_options,
|
||||
},
|
||||
{ key: 'stamina', title: '体力', type: 'chips', options: publishOptions.value.level_options },
|
||||
{ key: 'load', title: '负重', type: 'chips', options: publishOptions.value.level_options },
|
||||
...publishOptions.value.quantity_items.map(item => ({
|
||||
key: `resource_${item.key}`,
|
||||
title: item.label,
|
||||
type: "range" as const,
|
||||
type: 'range' as const,
|
||||
unit: parseQuantityUnit(item.price),
|
||||
minPlaceholder: "最低",
|
||||
maxPlaceholder: "最高",
|
||||
minPlaceholder: '最低',
|
||||
maxPlaceholder: '最高',
|
||||
})),
|
||||
...publishOptions.value.skin_groups.map((group) => ({
|
||||
...publishOptions.value.skin_groups.map(group => ({
|
||||
key: group.key,
|
||||
title: group.title,
|
||||
type: "chips" as const,
|
||||
type: 'chips' as const,
|
||||
options: group.options,
|
||||
})),
|
||||
{ key: "secretKd", title: "绝密KD", type: "range", minPlaceholder: "最低", maxPlaceholder: "最高" },
|
||||
{ key: "rank", title: "段位", type: "chips", options: publishOptions.value.rank_options },
|
||||
{ key: "deposit", title: "押金", type: "range", unit: "元", minPlaceholder: "最低", maxPlaceholder: "最高" },
|
||||
]);
|
||||
{
|
||||
key: 'secretKd',
|
||||
title: '绝密KD',
|
||||
type: 'range',
|
||||
minPlaceholder: '最低',
|
||||
maxPlaceholder: '最高',
|
||||
},
|
||||
{ key: 'rank', title: '段位', type: 'chips', options: publishOptions.value.rank_options },
|
||||
{
|
||||
key: 'deposit',
|
||||
title: '押金',
|
||||
type: 'range',
|
||||
unit: '元',
|
||||
minPlaceholder: '最低',
|
||||
maxPlaceholder: '最高',
|
||||
},
|
||||
])
|
||||
|
||||
const activeFilterCount = computed(() => {
|
||||
const chipCount = Object.values(selectedFilters.value).reduce(
|
||||
(sum, values) => sum + values.length,
|
||||
0
|
||||
);
|
||||
)
|
||||
const rangeCount = Object.values(rangeFilters.value).filter(
|
||||
(range) => range.min || range.max
|
||||
).length;
|
||||
return chipCount + rangeCount;
|
||||
});
|
||||
range => range.min || range.max
|
||||
).length
|
||||
return chipCount + rangeCount
|
||||
})
|
||||
|
||||
const displayListings = computed(() => {
|
||||
return listings.value;
|
||||
});
|
||||
return listings.value
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadListings();
|
||||
loadHomeConfig();
|
||||
window.addEventListener("scroll", handleWindowScroll, { passive: true });
|
||||
});
|
||||
loadListings()
|
||||
loadHomeConfig()
|
||||
window.addEventListener('scroll', handleWindowScroll, { passive: true })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("scroll", handleWindowScroll);
|
||||
});
|
||||
window.removeEventListener('scroll', handleWindowScroll)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => listingQuerySignature(),
|
||||
() => {
|
||||
loadListings(true);
|
||||
loadListings(true)
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
async function loadListings(reset = true) {
|
||||
if ((loadingMore.value && !reset) || (!hasMoreListings.value && !reset)) return;
|
||||
const requestSeq = ++listingRequestSeq;
|
||||
if ((loadingMore.value && !reset) || (!hasMoreListings.value && !reset)) return
|
||||
const requestSeq = ++listingRequestSeq
|
||||
if (reset) {
|
||||
loading.value = true;
|
||||
currentPage.value = 1;
|
||||
hasMoreListings.value = true;
|
||||
loading.value = true
|
||||
currentPage.value = 1
|
||||
hasMoreListings.value = true
|
||||
}
|
||||
loadingMore.value = true;
|
||||
loadFailed.value = false;
|
||||
loadingMore.value = true
|
||||
loadFailed.value = false
|
||||
try {
|
||||
const page = await fetchListingsPage(buildListingQuery(currentPage.value));
|
||||
if (requestSeq !== listingRequestSeq) return;
|
||||
listings.value = reset ? page.items : [...listings.value, ...page.items];
|
||||
totalListings.value = page.total;
|
||||
hasMoreListings.value = listings.value.length < page.total;
|
||||
currentPage.value = page.page + 1;
|
||||
requestAnimationFrame(handleWindowScroll);
|
||||
const page = await fetchListingsPage(buildListingQuery(currentPage.value))
|
||||
if (requestSeq !== listingRequestSeq) return
|
||||
listings.value = reset ? page.items : [...listings.value, ...page.items]
|
||||
totalListings.value = page.total
|
||||
hasMoreListings.value = listings.value.length < page.total
|
||||
currentPage.value = page.page + 1
|
||||
requestAnimationFrame(handleWindowScroll)
|
||||
} catch {
|
||||
if (reset) {
|
||||
listings.value = [];
|
||||
totalListings.value = 0;
|
||||
hasMoreListings.value = false;
|
||||
loadFailed.value = true;
|
||||
listings.value = []
|
||||
totalListings.value = 0
|
||||
hasMoreListings.value = false
|
||||
loadFailed.value = true
|
||||
}
|
||||
} finally {
|
||||
if (requestSeq === listingRequestSeq) {
|
||||
loading.value = false;
|
||||
loadingMore.value = false;
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHomeConfig() {
|
||||
try {
|
||||
const config = await fetchMobileHomeConfig();
|
||||
announcements.value = config.announcements;
|
||||
bannerSlides.value = config.banners;
|
||||
publishOptions.value = config.publish_options;
|
||||
const config = await fetchMobileHomeConfig()
|
||||
announcements.value = config.announcements
|
||||
bannerSlides.value = config.banners
|
||||
publishOptions.value = config.publish_options
|
||||
} catch {
|
||||
announcements.value = defaultHomeAnnouncements;
|
||||
bannerSlides.value = defaultHomeBanners;
|
||||
publishOptions.value = emptyListingPublishOptions;
|
||||
announcements.value = defaultHomeAnnouncements
|
||||
bannerSlides.value = defaultHomeBanners
|
||||
publishOptions.value = emptyListingPublishOptions
|
||||
}
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
refreshing.value = true;
|
||||
refreshing.value = true
|
||||
try {
|
||||
const [, nextHomeConfig] = await Promise.all([
|
||||
loadListings(true),
|
||||
fetchMobileHomeConfig(),
|
||||
]);
|
||||
announcements.value = nextHomeConfig.announcements;
|
||||
bannerSlides.value = nextHomeConfig.banners;
|
||||
publishOptions.value = nextHomeConfig.publish_options;
|
||||
showToast({ message: "刷新成功", icon: "passed" });
|
||||
const [, nextHomeConfig] = await Promise.all([loadListings(true), fetchMobileHomeConfig()])
|
||||
announcements.value = nextHomeConfig.announcements
|
||||
bannerSlides.value = nextHomeConfig.banners
|
||||
publishOptions.value = nextHomeConfig.publish_options
|
||||
showToast({ message: '刷新成功', icon: 'passed' })
|
||||
} catch {
|
||||
// 静默处理
|
||||
} finally {
|
||||
refreshing.value = false;
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openFilters() {
|
||||
sortOpen.value = false;
|
||||
filterOpen.value = true;
|
||||
sortOpen.value = false
|
||||
filterOpen.value = true
|
||||
}
|
||||
|
||||
function toggleSortPanel() {
|
||||
sortOpen.value = !sortOpen.value;
|
||||
sortOpen.value = !sortOpen.value
|
||||
}
|
||||
|
||||
function selectSort(sortKey: string) {
|
||||
activeSort.value = sortKey;
|
||||
sortOpen.value = false;
|
||||
activeSort.value = sortKey
|
||||
sortOpen.value = false
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
selectedFilters.value = {};
|
||||
rangeFilters.value = {};
|
||||
searchValue.value = "";
|
||||
selectedFilters.value = {}
|
||||
rangeFilters.value = {}
|
||||
searchValue.value = ''
|
||||
}
|
||||
|
||||
function buildListingQuery(page: number): PublicListingQuery {
|
||||
@@ -267,79 +288,78 @@ function buildListingQuery(page: number): PublicListingQuery {
|
||||
page_size: mobilePageSize,
|
||||
keyword: searchValue.value.trim(),
|
||||
sort: activeSort.value,
|
||||
};
|
||||
const skinGroups: string[] = [];
|
||||
const skinNames: string[] = [];
|
||||
}
|
||||
const skinGroups: string[] = []
|
||||
const skinNames: string[] = []
|
||||
for (const [key, values] of Object.entries(selectedFilters.value)) {
|
||||
const value = values.filter(Boolean).join(",");
|
||||
if (!value) continue;
|
||||
if (key === "server") query.server = value;
|
||||
else if (key === "login") query.login_method = value;
|
||||
else if (key === "insurance") query.insurance = value;
|
||||
else if (key === "stamina") query.stamina = value;
|
||||
else if (key === "load") query.load = value;
|
||||
else if (key === "rank") query.rank = value;
|
||||
const value = values.filter(Boolean).join(',')
|
||||
if (!value) continue
|
||||
if (key === 'server') query.server = value
|
||||
else if (key === 'login') query.login_method = value
|
||||
else if (key === 'insurance') query.insurance = value
|
||||
else if (key === 'stamina') query.stamina = value
|
||||
else if (key === 'load') query.load = value
|
||||
else if (key === 'rank') query.rank = value
|
||||
else if (isSkinGroupKey(key)) {
|
||||
skinGroups.push(key);
|
||||
skinNames.push(...values);
|
||||
skinGroups.push(key)
|
||||
skinNames.push(...values)
|
||||
}
|
||||
}
|
||||
if (skinGroups.length) query.skin_group = skinGroups.join(",");
|
||||
if (skinNames.length) query.skin_name = skinNames.join(",");
|
||||
if (skinGroups.length) query.skin_group = skinGroups.join(',')
|
||||
if (skinNames.length) query.skin_name = skinNames.join(',')
|
||||
|
||||
for (const [key, range] of Object.entries(rangeFilters.value)) {
|
||||
if (!range.min && !range.max) continue;
|
||||
const min = parseOptionalNumber(range.min);
|
||||
const max = parseOptionalNumber(range.max);
|
||||
if (key === "price") {
|
||||
query.min_price = min;
|
||||
query.max_price = max;
|
||||
} else if (key === "coin") {
|
||||
query.min_coin = min;
|
||||
query.max_coin = max;
|
||||
} else if (key === "secretKd") {
|
||||
query.min_secret_kd = min;
|
||||
query.max_secret_kd = max;
|
||||
} else if (key === "deposit") {
|
||||
query.min_deposit = min;
|
||||
query.max_deposit = max;
|
||||
} else if (key.startsWith("resource_")) {
|
||||
const resourceKey = key.replace("resource_", "");
|
||||
query[`resource_${resourceKey}_min`] = min;
|
||||
query[`resource_${resourceKey}_max`] = max;
|
||||
if (!range.min && !range.max) continue
|
||||
const min = parseOptionalNumber(range.min)
|
||||
const max = parseOptionalNumber(range.max)
|
||||
if (key === 'price') {
|
||||
query.min_price = min
|
||||
query.max_price = max
|
||||
} else if (key === 'coin') {
|
||||
query.min_coin = min
|
||||
query.max_coin = max
|
||||
} else if (key === 'secretKd') {
|
||||
query.min_secret_kd = min
|
||||
query.max_secret_kd = max
|
||||
} else if (key === 'deposit') {
|
||||
query.min_deposit = min
|
||||
query.max_deposit = max
|
||||
} else if (key.startsWith('resource_')) {
|
||||
const resourceKey = key.replace('resource_', '')
|
||||
query[`resource_${resourceKey}_min`] = min
|
||||
query[`resource_${resourceKey}_max`] = max
|
||||
}
|
||||
}
|
||||
return query;
|
||||
return query
|
||||
}
|
||||
|
||||
function listingQuerySignature() {
|
||||
return JSON.stringify(buildListingQuery(1));
|
||||
return JSON.stringify(buildListingQuery(1))
|
||||
}
|
||||
|
||||
function parseOptionalNumber(value: string) {
|
||||
if (value === "") return undefined;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : undefined;
|
||||
if (value === '') return undefined
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? number : undefined
|
||||
}
|
||||
|
||||
function handleWindowScroll() {
|
||||
if (window.innerHeight + window.scrollY < document.documentElement.scrollHeight - 360) return;
|
||||
loadListings(false);
|
||||
if (window.innerHeight + window.scrollY < document.documentElement.scrollHeight - 360) return
|
||||
loadListings(false)
|
||||
}
|
||||
|
||||
function isSkinGroupKey(key: string) {
|
||||
return publishOptions.value.skin_groups.some((group) => group.key === key);
|
||||
return publishOptions.value.skin_groups.some(group => group.key === key)
|
||||
}
|
||||
|
||||
function parseQuantityUnit(price: string) {
|
||||
const unit = price.split("/")[1]?.trim();
|
||||
return unit || undefined;
|
||||
const unit = price.split('/')[1]?.trim()
|
||||
return unit || undefined
|
||||
}
|
||||
|
||||
function uniqueOptions(values: string[]) {
|
||||
return [...new Set(values.map((item) => item.trim()).filter(Boolean))];
|
||||
return [...new Set(values.map(item => item.trim()).filter(Boolean))]
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -360,8 +380,13 @@ function uniqueOptions(values: string[]) {
|
||||
placeholder="搜区服 / 段位"
|
||||
class="home-search"
|
||||
/>
|
||||
<button class="mobile-service" type="button" :disabled="supportLoading" @click="handleSupportClick">
|
||||
{{ supportLoading ? "接入中" : "客服" }}
|
||||
<button
|
||||
class="mobile-service"
|
||||
type="button"
|
||||
:disabled="supportLoading"
|
||||
@click="handleSupportClick"
|
||||
>
|
||||
{{ supportLoading ? '接入中' : '客服' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -388,10 +413,7 @@ function uniqueOptions(values: string[]) {
|
||||
<section class="mobile-content">
|
||||
<!-- Banner 轮播 -->
|
||||
<van-swipe class="banner-swipe" :autoplay="3600" lazy-render>
|
||||
<van-swipe-item
|
||||
v-for="slide in bannerSlides"
|
||||
:key="slide.title || slide.image_url"
|
||||
>
|
||||
<van-swipe-item v-for="slide in bannerSlides" :key="slide.title || slide.image_url">
|
||||
<div
|
||||
class="mobile-banner"
|
||||
:class="[`tone-${slide.tone}`, { 'has-image': slide.image_url }]"
|
||||
@@ -432,11 +454,7 @@ function uniqueOptions(values: string[]) {
|
||||
@click="selectSort(option.key)"
|
||||
>
|
||||
<span>{{ option.label }}</span>
|
||||
<van-icon
|
||||
v-if="activeSort === option.key"
|
||||
name="success"
|
||||
:size="18"
|
||||
/>
|
||||
<van-icon v-if="activeSort === option.key" name="success" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="result-count">
|
||||
@@ -460,9 +478,7 @@ function uniqueOptions(values: string[]) {
|
||||
image="search"
|
||||
description="没有符合条件的账号"
|
||||
>
|
||||
<van-button size="small" type="primary" @click="clearFilters">
|
||||
重置条件
|
||||
</van-button>
|
||||
<van-button size="small" type="primary" @click="clearFilters"> 重置条件 </van-button>
|
||||
</van-empty>
|
||||
|
||||
<!-- 列表卡片:全宽上下布局 -->
|
||||
@@ -510,10 +526,7 @@ function uniqueOptions(values: string[]) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-chip-row">
|
||||
<span
|
||||
v-for="chip in getListingChips(item)"
|
||||
:key="`${item.id}-${chip.label}`"
|
||||
>
|
||||
<span v-for="chip in getListingChips(item)" :key="`${item.id}-${chip.label}`">
|
||||
{{ chip.label }}:{{ chip.value }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { showToast, showDialog } from "vant";
|
||||
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 { createOrder, fetchOrderAgreements, type OrderAgreements } from "@/features/orders/api/orders";
|
||||
import { useSessionStore } from "@/stores/session";
|
||||
import { formatMoney } from "@/shared/utils/money";
|
||||
import { fetchListing, type Listing } from '@/features/listings/api/listings'
|
||||
import {
|
||||
createOrder,
|
||||
fetchOrderAgreements,
|
||||
type OrderAgreements,
|
||||
} from '@/features/orders/api/orders'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { formatMoney } from '@/shared/utils/money'
|
||||
import {
|
||||
assetRegions,
|
||||
formatHafCoinM,
|
||||
@@ -23,241 +27,240 @@ import {
|
||||
getLoginMethod,
|
||||
getServerRegion,
|
||||
readAssetString,
|
||||
} from "@/utils/listingDisplay";
|
||||
} from '@/utils/listingDisplay'
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const session = useSessionStore();
|
||||
const loading = ref(false);
|
||||
const ordering = ref(false);
|
||||
const agreementsLoading = ref(false);
|
||||
const agreementVisible = ref(false);
|
||||
const listing = ref<Listing | null>(null);
|
||||
const agreements = ref<OrderAgreements | null>(null);
|
||||
const virtualAgreementRead = ref(false);
|
||||
const renterAgreementRead = ref(false);
|
||||
const virtualAgreementChecked = ref(false);
|
||||
const renterAgreementChecked = ref(false);
|
||||
const virtualAgreementRef = ref<HTMLElement | null>(null);
|
||||
const renterAgreementRef = ref<HTMLElement | null>(null);
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const session = useSessionStore()
|
||||
const loading = ref(false)
|
||||
const ordering = ref(false)
|
||||
const agreementsLoading = ref(false)
|
||||
const agreementVisible = ref(false)
|
||||
const listing = ref<Listing | null>(null)
|
||||
const agreements = ref<OrderAgreements | null>(null)
|
||||
const virtualAgreementRead = ref(false)
|
||||
const renterAgreementRead = ref(false)
|
||||
const virtualAgreementChecked = ref(false)
|
||||
const renterAgreementChecked = ref(false)
|
||||
const virtualAgreementRef = ref<HTMLElement | null>(null)
|
||||
const renterAgreementRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const canCreateOrderAfterAgreement = computed(
|
||||
() =>
|
||||
virtualAgreementRead.value &&
|
||||
renterAgreementRead.value &&
|
||||
virtualAgreementChecked.value &&
|
||||
renterAgreementChecked.value,
|
||||
);
|
||||
renterAgreementChecked.value
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
loading.value = true
|
||||
try {
|
||||
listing.value = await fetchListing(String(route.params.id));
|
||||
listing.value = await fetchListing(String(route.params.id))
|
||||
} catch {
|
||||
showToast({ message: "加载失败", icon: "warning-o" });
|
||||
showToast({ message: '加载失败', icon: 'warning-o' })
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loading.value = false
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
const orderTotal = computed(() => {
|
||||
if (!listing.value) return "0.0";
|
||||
return formatMoney(getListingDisplayPrice(listing.value));
|
||||
});
|
||||
if (!listing.value) return '0.0'
|
||||
return formatMoney(getListingDisplayPrice(listing.value))
|
||||
})
|
||||
|
||||
const orderPriceBreakdown = computed(() => {
|
||||
if (!listing.value) {
|
||||
return {
|
||||
rent: 0,
|
||||
consumable: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
rent: getListingRentPrice(listing.value),
|
||||
consumable: getListingConsumablePrice(listing.value),
|
||||
};
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
const detailMetrics = computed(() => {
|
||||
if (!listing.value) return [];
|
||||
const dailyLoss = getDailyLoss(listing.value);
|
||||
if (!listing.value) return []
|
||||
const dailyLoss = getDailyLoss(listing.value)
|
||||
return [
|
||||
{ label: "纯币", value: formatHafCoinM(getCoinWan(listing.value)), tone: "coin" },
|
||||
{ label: '纯币', value: formatHafCoinM(getCoinWan(listing.value)), tone: 'coin' },
|
||||
{
|
||||
label: "日损耗",
|
||||
value: dailyLoss ? `${dailyLoss}/天` : "--",
|
||||
tone: "coin",
|
||||
label: '日损耗',
|
||||
value: dailyLoss ? `${dailyLoss}/天` : '--',
|
||||
tone: 'coin',
|
||||
},
|
||||
{ label: "价格", value: `¥${getListingDisplayPrice(listing.value)}`, tone: "price" },
|
||||
{ label: "押金", value: `¥${listing.value.deposit_amount}`, tone: "" },
|
||||
];
|
||||
});
|
||||
{ label: '价格', value: `¥${getListingDisplayPrice(listing.value)}`, tone: 'price' },
|
||||
{ label: '押金', value: `¥${listing.value.deposit_amount}`, tone: '' },
|
||||
]
|
||||
})
|
||||
|
||||
const detailScreenshots = computed(() => {
|
||||
if (!listing.value) return [];
|
||||
const groupedScreenshots = readGroupedScreenshots(listing.value);
|
||||
if (groupedScreenshots.length) return groupedScreenshots;
|
||||
const labels = ["纯币截图", "游戏ID截图", "总资产截图", "腾讯安全中心截图", "皮肤截图"];
|
||||
if (!listing.value) return []
|
||||
const groupedScreenshots = readGroupedScreenshots(listing.value)
|
||||
if (groupedScreenshots.length) return groupedScreenshots
|
||||
const labels = ['纯币截图', '游戏ID截图', '总资产截图', '腾讯安全中心截图', '皮肤截图']
|
||||
return (listing.value.screenshot_urls || []).map((url, index) => ({
|
||||
label: labels[index] || `账号截图${index + 1}`,
|
||||
url,
|
||||
}));
|
||||
});
|
||||
}))
|
||||
})
|
||||
|
||||
function readGroupedScreenshots(item: Listing) {
|
||||
const groups = item.asset_summary?.screenshot_groups;
|
||||
if (typeof groups !== "object" || groups === null) return [];
|
||||
const groups = item.asset_summary?.screenshot_groups
|
||||
if (typeof groups !== 'object' || groups === null) return []
|
||||
const slots = [
|
||||
{ key: "coin", label: "纯币截图" },
|
||||
{ key: "gameId", label: "游戏ID截图" },
|
||||
{ key: "totalAsset", label: "总资产截图" },
|
||||
{ key: "tencentSecurity", label: "腾讯安全中心截图" },
|
||||
{ key: "skin", label: "皮肤截图" },
|
||||
];
|
||||
return slots.flatMap((slot) => {
|
||||
const urls = (groups as Record<string, unknown>)[slot.key];
|
||||
if (!Array.isArray(urls)) return [];
|
||||
const validUrls = urls.filter((url): url is string => typeof url === "string" && Boolean(url));
|
||||
{ key: 'coin', label: '纯币截图' },
|
||||
{ key: 'gameId', label: '游戏ID截图' },
|
||||
{ key: 'totalAsset', label: '总资产截图' },
|
||||
{ key: 'tencentSecurity', label: '腾讯安全中心截图' },
|
||||
{ key: 'skin', label: '皮肤截图' },
|
||||
]
|
||||
return slots.flatMap(slot => {
|
||||
const urls = (groups as Record<string, unknown>)[slot.key]
|
||||
if (!Array.isArray(urls)) return []
|
||||
const validUrls = urls.filter((url): url is string => typeof url === 'string' && Boolean(url))
|
||||
return validUrls.map((url, index) => ({
|
||||
label: validUrls.length > 1 ? `${slot.label}${index + 1}` : slot.label,
|
||||
url,
|
||||
}));
|
||||
});
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
const detailSkinGroups = computed(() => {
|
||||
if (!listing.value) return [];
|
||||
const groups = listing.value.asset_summary?.skin_groups;
|
||||
if (typeof groups !== "object" || groups === null) return [];
|
||||
if (!listing.value) return []
|
||||
const groups = listing.value.asset_summary?.skin_groups
|
||||
if (typeof groups !== 'object' || groups === null) return []
|
||||
const titles: Record<string, string> = {
|
||||
melee: "近战皮肤",
|
||||
operator: "干员皮肤",
|
||||
operatorGold: "干员金皮",
|
||||
operatorRed: "干员红皮",
|
||||
weapon: "武器皮肤",
|
||||
};
|
||||
melee: '近战皮肤',
|
||||
operator: '干员皮肤',
|
||||
operatorGold: '干员金皮',
|
||||
operatorRed: '干员红皮',
|
||||
weapon: '武器皮肤',
|
||||
}
|
||||
return Object.entries(groups as Record<string, unknown>)
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
title: titles[key] || key,
|
||||
options: Array.isArray(value)
|
||||
? value.filter((skin): skin is string => typeof skin === "string")
|
||||
? value.filter((skin): skin is string => typeof skin === 'string')
|
||||
: [],
|
||||
}))
|
||||
.filter((group) => group.options.length);
|
||||
});
|
||||
.filter(group => group.options.length)
|
||||
})
|
||||
|
||||
/* 下单 */
|
||||
async function handleCreateOrder() {
|
||||
if (!listing.value) return;
|
||||
if (!listing.value) return
|
||||
|
||||
if (!session.token) {
|
||||
showDialog({
|
||||
title: "请先登录",
|
||||
message: "下单需要登录账号,是否前往登录?",
|
||||
confirmButtonText: "去登录",
|
||||
cancelButtonText: "取消",
|
||||
title: '请先登录',
|
||||
message: '下单需要登录账号,是否前往登录?',
|
||||
confirmButtonText: '去登录',
|
||||
cancelButtonText: '取消',
|
||||
showCancelButton: true,
|
||||
}).then(() => {
|
||||
router.push({ path: "/m/login", query: { redirect: route.fullPath } });
|
||||
});
|
||||
return;
|
||||
router.push({ path: '/m/login', query: { redirect: route.fullPath } })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (session.realnameStatus !== "verified") {
|
||||
if (session.realnameStatus !== 'verified') {
|
||||
try {
|
||||
await session.loadMe();
|
||||
await session.loadMe()
|
||||
} catch {
|
||||
// 401 会由全局拦截器处理。
|
||||
}
|
||||
}
|
||||
|
||||
if (session.realnameStatus !== "verified") {
|
||||
if (session.realnameStatus !== 'verified') {
|
||||
showDialog({
|
||||
title: "请先实名认证",
|
||||
message: "租号下单前需要完成实名认证。",
|
||||
confirmButtonText: "去认证",
|
||||
cancelButtonText: "取消",
|
||||
title: '请先实名认证',
|
||||
message: '租号下单前需要完成实名认证。',
|
||||
confirmButtonText: '去认证',
|
||||
cancelButtonText: '取消',
|
||||
showCancelButton: true,
|
||||
}).then(() => {
|
||||
router.push({ path: "/m/realname", query: { redirect: route.fullPath } });
|
||||
});
|
||||
return;
|
||||
router.push({ path: '/m/realname', query: { redirect: route.fullPath } })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await openAgreementBeforeOrder();
|
||||
await openAgreementBeforeOrder()
|
||||
}
|
||||
|
||||
async function openAgreementBeforeOrder() {
|
||||
agreementsLoading.value = true;
|
||||
agreementsLoading.value = true
|
||||
try {
|
||||
agreements.value = await fetchOrderAgreements();
|
||||
virtualAgreementRead.value = false;
|
||||
renterAgreementRead.value = false;
|
||||
virtualAgreementChecked.value = false;
|
||||
renterAgreementChecked.value = false;
|
||||
agreementVisible.value = true;
|
||||
await nextTick();
|
||||
updateAgreementReadState("virtual");
|
||||
updateAgreementReadState("renter");
|
||||
agreements.value = await fetchOrderAgreements()
|
||||
virtualAgreementRead.value = false
|
||||
renterAgreementRead.value = false
|
||||
virtualAgreementChecked.value = false
|
||||
renterAgreementChecked.value = false
|
||||
agreementVisible.value = true
|
||||
await nextTick()
|
||||
updateAgreementReadState('virtual')
|
||||
updateAgreementReadState('renter')
|
||||
} catch (error) {
|
||||
showToast({ message: readError(error, "协议加载失败"), icon: "cross" });
|
||||
showToast({ message: readError(error, '协议加载失败'), icon: 'cross' })
|
||||
} finally {
|
||||
agreementsLoading.value = false;
|
||||
agreementsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmAgreementAndCreateOrder() {
|
||||
if (!canCreateOrderAfterAgreement.value) {
|
||||
showToast("请先阅读并勾选两份协议");
|
||||
return;
|
||||
showToast('请先阅读并勾选两份协议')
|
||||
return
|
||||
}
|
||||
agreementVisible.value = false;
|
||||
await submitOrder();
|
||||
agreementVisible.value = false
|
||||
await submitOrder()
|
||||
}
|
||||
|
||||
async function submitOrder() {
|
||||
if (!listing.value) return;
|
||||
ordering.value = true;
|
||||
if (!listing.value) return
|
||||
ordering.value = true
|
||||
try {
|
||||
await createOrder(listing.value.id);
|
||||
showToast({ message: "订单已创建,请完成支付", icon: "passed" });
|
||||
await router.push(`/m/orders`);
|
||||
await createOrder(listing.value.id)
|
||||
showToast({ message: '订单已创建,请完成支付', icon: 'passed' })
|
||||
await router.push(`/m/orders`)
|
||||
} catch (error) {
|
||||
showToast({ message: readError(error, "下单失败"), icon: "cross" });
|
||||
showToast({ message: readError(error, '下单失败'), icon: 'cross' })
|
||||
} finally {
|
||||
ordering.value = false;
|
||||
ordering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleAgreementScroll(type: "virtual" | "renter") {
|
||||
updateAgreementReadState(type);
|
||||
function handleAgreementScroll(type: 'virtual' | 'renter') {
|
||||
updateAgreementReadState(type)
|
||||
}
|
||||
|
||||
function updateAgreementReadState(type: "virtual" | "renter") {
|
||||
const el = type === "virtual" ? virtualAgreementRef.value : renterAgreementRef.value;
|
||||
if (!el) return;
|
||||
const read = el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
|
||||
if (type === "virtual") {
|
||||
virtualAgreementRead.value = read;
|
||||
function updateAgreementReadState(type: 'virtual' | 'renter') {
|
||||
const el = type === 'virtual' ? virtualAgreementRef.value : renterAgreementRef.value
|
||||
if (!el) return
|
||||
const read = el.scrollTop + el.clientHeight >= el.scrollHeight - 8
|
||||
if (type === 'virtual') {
|
||||
virtualAgreementRead.value = read
|
||||
} else {
|
||||
renterAgreementRead.value = read;
|
||||
renterAgreementRead.value = read
|
||||
}
|
||||
}
|
||||
|
||||
function readError(error: unknown, fallback: string) {
|
||||
if (typeof error === "object" && error && "response" in error) {
|
||||
const response = (error as { response?: { data?: { message?: string } } })
|
||||
.response;
|
||||
return response?.data?.message || fallback;
|
||||
if (typeof error === 'object' && error && 'response' in error) {
|
||||
const response = (error as { response?: { data?: { message?: string } } }).response
|
||||
return response?.data?.message || fallback
|
||||
}
|
||||
return fallback;
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** 判断当前底部导航是否激活 */
|
||||
function isNavActive(path: string) {
|
||||
if (path === "/m") return route.path === "/m";
|
||||
return route.path.startsWith(path);
|
||||
if (path === '/m') return route.path === '/m'
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -280,9 +283,7 @@ function isNavActive(path: string) {
|
||||
<!-- 防骗提示 -->
|
||||
<div class="fraud-tip">
|
||||
<van-icon name="shield-o" :size="14" color="#ff9800" />
|
||||
<span
|
||||
>防骗提示:下单后请按平台交接流程确认收号与归还,不要私下交易。</span
|
||||
>
|
||||
<span>防骗提示:下单后请按平台交接流程确认收号与归还,不要私下交易。</span>
|
||||
</div>
|
||||
|
||||
<!-- 封面图 -->
|
||||
@@ -299,10 +300,7 @@ function isNavActive(path: string) {
|
||||
<span>暂无截图</span>
|
||||
</div>
|
||||
<!-- 截图指示器 -->
|
||||
<div
|
||||
v-if="detailScreenshots.length > 1"
|
||||
class="cover-count"
|
||||
>
|
||||
<div v-if="detailScreenshots.length > 1" class="cover-count">
|
||||
{{ detailScreenshots.length }}张
|
||||
</div>
|
||||
</div>
|
||||
@@ -310,15 +308,11 @@ function isNavActive(path: string) {
|
||||
<!-- 标题信息 -->
|
||||
<div class="info-card">
|
||||
<div class="info-tag-row">
|
||||
<van-tag plain type="primary" size="medium">{{
|
||||
getServerRegion(listing)
|
||||
}}</van-tag>
|
||||
<van-tag plain type="primary" size="medium">{{ getServerRegion(listing) }}</van-tag>
|
||||
<van-tag v-if="getLoginMethod(listing)" plain type="primary" size="medium">
|
||||
{{ getLoginMethod(listing) }}
|
||||
</van-tag>
|
||||
<van-tag v-if="listing.rank_level" plain size="medium">{{
|
||||
listing.rank_level
|
||||
}}</van-tag>
|
||||
<van-tag v-if="listing.rank_level" plain size="medium">{{ listing.rank_level }}</van-tag>
|
||||
</div>
|
||||
<h2 class="detail-title">{{ getListingTitle(listing) }}</h2>
|
||||
<p class="detail-desc">{{ getListingSubtitle(listing) }}</p>
|
||||
@@ -336,10 +330,7 @@ function isNavActive(path: string) {
|
||||
<div class="info-card">
|
||||
<h3 class="card-subtitle">账号资料</h3>
|
||||
<div class="detail-chip-row">
|
||||
<span
|
||||
v-for="chip in getListingChips(listing)"
|
||||
:key="chip.label"
|
||||
>
|
||||
<span v-for="chip in getListingChips(listing)" :key="chip.label">
|
||||
{{ chip.label }}:{{ chip.value }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -349,11 +340,11 @@ function isNavActive(path: string) {
|
||||
</div>
|
||||
<div class="info-line">
|
||||
<span class="info-label">常用登录地</span>
|
||||
<span class="info-text">{{ assetRegions(listing).join("、") || "--" }}</span>
|
||||
<span class="info-text">{{ assetRegions(listing).join('、') || '--' }}</span>
|
||||
</div>
|
||||
<div v-if="readAssetString(listing, 'ban_record')" class="info-line">
|
||||
<span class="info-label">封禁记录</span>
|
||||
<span class="info-text">{{ readAssetString(listing, "ban_record") }}</span>
|
||||
<span class="info-text">{{ readAssetString(listing, 'ban_record') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -368,9 +359,9 @@ function isNavActive(path: string) {
|
||||
<span>{{ resource.label }}</span>
|
||||
<strong>{{ resource.quantity }}</strong>
|
||||
<em>
|
||||
<b>{{ resource.mode || "--" }}</b>
|
||||
<b>{{ resource.mode || '--' }}</b>
|
||||
<small v-if="resource.amount > 0">¥{{ resource.amount }}</small>
|
||||
<small v-else-if="resource.mode === '收费'">{{ resource.price || "¥0" }}</small>
|
||||
<small v-else-if="resource.mode === '收费'">{{ resource.price || '¥0' }}</small>
|
||||
<small v-else>无额外收费</small>
|
||||
</em>
|
||||
</div>
|
||||
@@ -427,11 +418,18 @@ function isNavActive(path: string) {
|
||||
loading-text="下单中..."
|
||||
@click="handleCreateOrder"
|
||||
>
|
||||
{{ listing.in_transaction ? "交易中" : "立即下单" }}
|
||||
{{ listing.in_transaction ? '交易中' : '立即下单' }}
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<van-popup v-model:show="agreementVisible" round closeable position="bottom" lock-scroll class="agreement-popup">
|
||||
<van-popup
|
||||
v-model:show="agreementVisible"
|
||||
round
|
||||
closeable
|
||||
position="bottom"
|
||||
lock-scroll
|
||||
class="agreement-popup"
|
||||
>
|
||||
<div v-if="agreements" class="agreement-popup-body">
|
||||
<h3>下单协议确认</h3>
|
||||
<p>请完整阅读并勾选以下两份协议后继续下单。</p>
|
||||
@@ -444,7 +442,11 @@ function isNavActive(path: string) {
|
||||
>
|
||||
{{ agreements.virtual_asset_purchase.content }}
|
||||
</div>
|
||||
<van-checkbox v-model="virtualAgreementChecked" :disabled="!virtualAgreementRead" icon-size="18px">
|
||||
<van-checkbox
|
||||
v-model="virtualAgreementChecked"
|
||||
:disabled="!virtualAgreementRead"
|
||||
icon-size="18px"
|
||||
>
|
||||
我已阅读并同意《{{ agreements.virtual_asset_purchase.title }}》
|
||||
</van-checkbox>
|
||||
<span v-if="!virtualAgreementRead" class="agreement-read-hint">请下拉阅读至底部</span>
|
||||
@@ -458,7 +460,11 @@ function isNavActive(path: string) {
|
||||
>
|
||||
{{ agreements.renter_agreement.content }}
|
||||
</div>
|
||||
<van-checkbox v-model="renterAgreementChecked" :disabled="!renterAgreementRead" icon-size="18px">
|
||||
<van-checkbox
|
||||
v-model="renterAgreementChecked"
|
||||
:disabled="!renterAgreementRead"
|
||||
icon-size="18px"
|
||||
>
|
||||
我已阅读并同意《{{ agreements.renter_agreement.title }}》
|
||||
</van-checkbox>
|
||||
<span v-if="!renterAgreementRead" class="agreement-read-hint">请下拉阅读至底部</span>
|
||||
|
||||
Reference in New Issue
Block a user