优化首页筛选来源

This commit is contained in:
yml
2026-05-23 00:43:23 +08:00
parent 0394fcb2b8
commit 0b87754ab3
6 changed files with 1270 additions and 261 deletions
@@ -0,0 +1,448 @@
<script setup lang="ts">
import { nextTick, ref } from "vue";
export type FilterSection =
| {
key: string;
title: string;
type: "range";
unit?: string;
minPlaceholder?: string;
maxPlaceholder?: string;
}
| { key: string; title: string; type: "chips"; options: string[] };
type RangeFilters = Record<string, { min: string; max: string }>;
type SelectedFilters = Record<string, string[]>;
type RangePresets = Record<
string,
Array<{ label: string; min: string; max: string }>
>;
const props = defineProps<{
show: boolean;
sections: FilterSection[];
selectedFilters: SelectedFilters;
rangeFilters: RangeFilters;
rangePresets: RangePresets;
}>();
const emit = defineEmits<{
"update:show": [value: boolean];
"update:selectedFilters": [value: SelectedFilters];
"update:rangeFilters": [value: RangeFilters];
reset: [];
}>();
const activeFilterSection = ref("price");
const filterContentRef = ref<HTMLElement | null>(null);
const filterTabsRef = ref<HTMLElement | null>(null);
const filterSectionRefs = new Map<string, HTMLElement>();
const filterTabRefs = new Map<string, HTMLElement>();
function handleShowChange(value: boolean) {
emit("update:show", value);
if (value) {
activeFilterSection.value = props.sections[0]?.key || "price";
nextTick(() => {
filterContentRef.value?.scrollTo({ top: 0 });
});
}
}
function close() {
emit("update:show", false);
}
function confirm() {
close();
}
function reset() {
emit("reset");
}
function toggleChip(sectionKey: string, value: string) {
const selected = props.selectedFilters[sectionKey] || [];
const next = selected.includes(value)
? selected.filter((item) => item !== value)
: [...selected, value];
emit("update:selectedFilters", {
...props.selectedFilters,
[sectionKey]: next,
});
}
function updateRange(sectionKey: string, side: "min" | "max", value: string) {
const current = props.rangeFilters[sectionKey] || { min: "", max: "" };
emit("update:rangeFilters", {
...props.rangeFilters,
[sectionKey]: {
...current,
[side]: value,
},
});
}
function applyRangePreset(sectionKey: string, min: string, max: string) {
emit("update:rangeFilters", {
...props.rangeFilters,
[sectionKey]: { min, max },
});
}
function isRangePresetActive(sectionKey: string, min: string, max: string) {
const range = props.rangeFilters[sectionKey];
return range?.min === min && range?.max === max;
}
function setFilterSectionRef(key: string, el: Element | null) {
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);
}
}
function scrollToFilterSection(key: string) {
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",
});
keepActiveTabVisible(key);
}
function handleFilterScroll() {
const container = filterContentRef.value;
if (!container) return;
const anchorTop = container.scrollTop + 16;
let activeKey = props.sections[0]?.key || "";
for (const section of props.sections) {
const el = filterSectionRefs.get(section.key);
if (el && el.offsetTop <= anchorTop) {
activeKey = section.key;
}
}
if (activeKey && activeKey !== activeFilterSection.value) {
activeFilterSection.value = activeKey;
keepActiveTabVisible(activeKey);
}
}
function keepActiveTabVisible(key: string) {
const container = filterTabsRef.value;
const tab = filterTabRefs.get(key);
if (!container || !tab) return;
const tabTop = tab.offsetTop;
const tabBottom = tabTop + tab.offsetHeight;
const visibleTop = container.scrollTop;
const visibleBottom = visibleTop + container.clientHeight;
if (tabTop < visibleTop) {
container.scrollTo({ top: tabTop, behavior: "smooth" });
} else if (tabBottom > visibleBottom) {
container.scrollTo({
top: tabBottom - container.clientHeight,
behavior: "smooth",
});
}
}
</script>
<template>
<van-popup
:show="show"
position="right"
class="filter-popup"
teleport="body"
@update:show="handleShowChange"
>
<div class="filter-sheet">
<header class="filter-header">
<button type="button" class="filter-close" @click="close">
<van-icon name="cross" :size="18" />
</button>
<h2>筛选</h2>
<span></span>
</header>
<div class="filter-body">
<aside ref="filterTabsRef" class="filter-tabs">
<button
v-for="section in sections"
:key="section.key"
:ref="(el) => setFilterTabRef(section.key, el as Element | null)"
type="button"
:class="{ active: activeFilterSection === section.key }"
@click="scrollToFilterSection(section.key)"
>
{{ section.title }}
</button>
</aside>
<section
ref="filterContentRef"
class="filter-content"
@scroll.passive="handleFilterScroll"
>
<div
v-for="section in sections"
:key="section.key"
:ref="(el) => setFilterSectionRef(section.key, el as Element | null)"
class="filter-block"
>
<h3>{{ section.title }}</h3>
<p v-if="section.type === 'range' && section.unit">
单位{{ section.unit }}
</p>
<template v-if="section.type === 'range'">
<div class="range-editor">
<input
:value="rangeFilters[section.key]?.min || ''"
type="number"
inputmode="decimal"
:placeholder="section.minPlaceholder || '最低'"
@input="updateRange(section.key, 'min', ($event.target as HTMLInputElement).value)"
/>
<span></span>
<input
:value="rangeFilters[section.key]?.max || ''"
type="number"
inputmode="decimal"
:placeholder="section.maxPlaceholder || '最高'"
@input="updateRange(section.key, 'max', ($event.target as HTMLInputElement).value)"
/>
</div>
<div
v-if="rangePresets[section.key]?.length"
class="sheet-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),
}"
@click="applyRangePreset(section.key, preset.min, preset.max)"
>
{{ preset.label }}
</button>
</div>
</template>
<div v-else class="sheet-chip-grid">
<button
v-for="option in section.options"
:key="`${section.key}-${option}`"
type="button"
:class="{
active: selectedFilters[section.key]?.includes(option),
}"
@click="toggleChip(section.key, option)"
>
{{ option }}
</button>
</div>
</div>
</section>
</div>
<footer class="filter-actions">
<button type="button" class="reset-btn" @click="reset">重置</button>
<button type="button" class="confirm-btn" @click="confirm">确定</button>
</footer>
</div>
</van-popup>
</template>
<style scoped>
.filter-popup {
width: min(86vw, 390px);
height: 100dvh;
background: #fff;
}
.filter-sheet {
display: grid;
grid-template-rows: 48px minmax(0, 1fr) 64px;
height: 100%;
background: #fff;
}
.filter-header {
position: relative;
display: grid;
grid-template-columns: 40px 1fr 40px;
align-items: center;
border-bottom: 1px solid #eef1f5;
}
.filter-header h2 {
margin: 0;
text-align: center;
font-size: 15px;
font-weight: 800;
}
.filter-close {
display: grid;
width: 40px;
height: 40px;
place-items: center;
border: 0;
background: transparent;
color: #17233d;
}
.filter-body {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
min-height: 0;
}
.filter-tabs {
overflow-y: auto;
background: #f6f7f9;
-webkit-overflow-scrolling: touch;
}
.filter-tabs button {
display: block;
width: 100%;
min-height: 42px;
border: 0;
background: transparent;
color: #2d3748;
padding: 0 8px;
text-align: center;
font-size: 12px;
}
.filter-tabs button.active {
background: #fff;
color: #ff7a00;
font-weight: 800;
}
.filter-content {
overflow-y: auto;
min-width: 0;
padding: 18px 14px 28px;
-webkit-overflow-scrolling: touch;
}
.filter-block {
scroll-margin-top: 8px;
}
.filter-block + .filter-block {
margin-top: 26px;
}
.filter-block h3 {
margin: 0 0 12px;
font-size: 17px;
font-weight: 800;
color: #17233d;
}
.filter-block p {
margin: -6px 0 12px;
color: #8a96a8;
font-size: 11px;
}
.range-editor {
display: grid;
grid-template-columns: minmax(0, 1fr) 18px minmax(0, 1fr);
align-items: center;
gap: 8px;
}
.range-editor span {
height: 1px;
background: #cfd6df;
}
.range-editor input {
min-width: 0;
height: 42px;
border: 1px solid #edf0f4;
border-radius: 8px;
background: #fff;
color: #17233d;
padding: 0 10px;
text-align: center;
font-size: 13px;
}
.sheet-chip-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 9px;
}
.range-preset-grid {
margin-top: 10px;
}
.sheet-chip-grid button {
min-width: 0;
min-height: 38px;
border: 0;
border-radius: 7px;
background: #f4f5f7;
color: #2d3748;
padding: 6px 8px;
font-size: 12px;
line-height: 1.25;
}
.sheet-chip-grid button.active {
background: #fff3d1;
color: #cc7a00;
font-weight: 800;
box-shadow: inset 0 0 0 1px #ffd15c;
}
.filter-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
align-items: center;
border-top: 1px solid #eef1f5;
padding: 10px 12px;
}
.filter-actions button {
height: 42px;
border-radius: 8px;
font-size: 14px;
font-weight: 800;
}
.reset-btn {
border: 1px solid #e6eaf0;
background: #fff;
color: #2d3748;
}
.confirm-btn {
border: 0;
background: #ffd21e;
color: #17233d;
}
</style>
File diff suppressed because it is too large Load Diff
+14 -8
View File
@@ -33,9 +33,9 @@ async function loadOrders() {
/* 状态筛选 */
const statusTabs = [
{ key: "all", label: "全部" },
{ key: "pending", label: "待交接" },
{ key: "pending_handoff", label: "待交接" },
{ key: "renting", label: "租赁中" },
{ key: "returning", label: "待归还" },
{ key: "pending_return_confirm", label: "待归还" },
{ key: "completed", label: "已完成" },
];
@@ -47,24 +47,30 @@ const displayOrders = computed(() => {
/* 状态颜色映射 */
function statusColor(status: string) {
const map: Record<string, string> = {
pending: "#ff9800",
pending_handoff: "#ff9800",
renting: "#1477ff",
returning: "#e91e63",
overdue: "#e91e63",
pending_return_confirm: "#e91e63",
completed: "#4caf50",
cancelled: "#999",
dispute: "#f44336",
disputing: "#f44336",
abnormal: "#f44336",
closed: "#666",
};
return map[status] || "#999";
}
function statusLabel(status: string) {
const map: Record<string, string> = {
pending: "待交接",
pending_handoff: "待交接",
renting: "租赁中",
returning: "待归还",
overdue: "已逾期",
pending_return_confirm: "待归还",
completed: "已完成",
cancelled: "已取消",
dispute: "申诉中",
disputing: "申诉中",
abnormal: "异常",
closed: "已关闭",
};
return map[status] || status;
}