优化首页分页和图片上传

This commit is contained in:
yml
2026-06-02 13:24:04 +08:00
parent a094901b72
commit 6f4c8b59f9
23 changed files with 1232 additions and 275 deletions
+113 -104
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "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";
@@ -9,7 +9,7 @@ import {
emptyListingPublishOptions,
type ListingPublishOptions,
} from "@/api/listingOptions";
import { fetchListings, type Listing } from "@/api/listings";
import { fetchListingsPage, type Listing, type PublicListingQuery } from "@/api/listings";
import {
defaultHomeAnnouncements,
defaultHomeBanners,
@@ -20,30 +20,26 @@ import MobileHomeFilterSheet, {
type FilterSection,
} from "./MobileHomeFilterSheet.vue";
import {
assetRegions,
formatHafCoinM,
getCoinM,
getCoinWan,
getListingChips,
getListingDisplayPrice,
getListingSubtitle,
getListingTitle,
getLoginMethod,
getResourceQuantity,
getServerRegion,
getSkinGroup,
hasAcceleratedSaleRatio,
hasGiftResources,
readAssetNumber,
readAssetString,
} 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");
@@ -55,6 +51,8 @@ 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: "综合排序" },
@@ -159,29 +157,56 @@ const activeFilterCount = computed(() => {
});
const displayListings = 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);
return listings.value;
});
onMounted(() => {
loadListings();
loadHomeConfig();
window.addEventListener("scroll", handleWindowScroll, { passive: true });
});
async function loadListings() {
loading.value = true;
onBeforeUnmount(() => {
window.removeEventListener("scroll", handleWindowScroll);
});
watch(
() => listingQuerySignature(),
() => {
loadListings(true);
}
);
async function loadListings(reset = true) {
if ((loadingMore.value && !reset) || (!hasMoreListings.value && !reset)) return;
const requestSeq = ++listingRequestSeq;
if (reset) {
loading.value = true;
currentPage.value = 1;
hasMoreListings.value = true;
}
loadingMore.value = true;
loadFailed.value = false;
try {
listings.value = await fetchListings();
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 {
loadFailed.value = true;
if (reset) {
listings.value = [];
totalListings.value = 0;
hasMoreListings.value = false;
loadFailed.value = true;
}
} finally {
loading.value = false;
if (requestSeq === listingRequestSeq) {
loading.value = false;
loadingMore.value = false;
}
}
}
@@ -201,11 +226,10 @@ async function loadHomeConfig() {
async function onRefresh() {
refreshing.value = true;
try {
const [nextListings, nextHomeConfig] = await Promise.all([
fetchListings(),
const [, nextHomeConfig] = await Promise.all([
loadListings(true),
fetchMobileHomeConfig(),
]);
listings.value = nextListings;
announcements.value = nextHomeConfig.announcements;
bannerSlides.value = nextHomeConfig.banners;
publishOptions.value = nextHomeConfig.publish_options;
@@ -237,91 +261,70 @@ function clearFilters() {
searchValue.value = "";
}
function sortListings(items: Listing[]) {
const sorted = [...items];
if (activeSort.value === "comprehensive") {
return sorted;
}
if (activeSort.value === "priceAsc") {
return sorted.sort(
(a, b) => getListingDisplayPrice(a) - getListingDisplayPrice(b)
);
}
if (activeSort.value === "priceDesc") {
return sorted.sort(
(a, b) => getListingDisplayPrice(b) - getListingDisplayPrice(a)
);
}
if (activeSort.value === "awmDesc") {
return sorted.sort(
(a, b) =>
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;
});
}
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));
function buildListingQuery(page: number): PublicListingQuery {
const query: PublicListingQuery = {
page,
page_size: mobilePageSize,
keyword: searchValue.value.trim(),
sort: activeSort.value,
};
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;
else if (isSkinGroupKey(key)) {
skinGroups.push(key);
skinNames.push(...values);
}
return true;
});
if (!chipOk) return false;
}
if (skinGroups.length) query.skin_group = skinGroups.join(",");
if (skinNames.length) query.skin_name = skinNames.join(",");
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_", ""));
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;
}
return inRange(value, range);
});
}
return query;
}
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;
function listingQuerySignature() {
return JSON.stringify(buildListingQuery(1));
}
function searchText(item: Listing) {
return [
item.title,
item.description,
getServerRegion(item),
getLoginMethod(item),
item.rank_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(" ")
),
]
.join(" ")
.toLowerCase();
function parseOptionalNumber(value: string) {
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);
}
function isSkinGroupKey(key: string) {
@@ -437,7 +440,7 @@ function uniqueOptions(values: string[]) {
</button>
</div>
<div class="result-count">
<strong>{{ displayListings.length }}</strong>
<strong>{{ totalListings }}</strong>
<span>个可租账号</span>
</div>
@@ -475,6 +478,8 @@ function uniqueOptions(values: string[]) {
v-if="item.cover_url"
:src="item.cover_url"
:alt="getListingTitle(item)"
loading="lazy"
decoding="async"
/>
<span v-else></span>
<div
@@ -514,6 +519,10 @@ function uniqueOptions(values: string[]) {
</div>
</RouterLink>
</div>
<div v-if="!loading && displayListings.length" class="mobile-load-state">
<span v-if="loadingMore">正在加载更多账号...</span>
<span v-else-if="!hasMoreListings">已经到底了</span>
</div>
</section>
</van-pull-refresh>