优化首页筛选来源
This commit is contained in:
Vendored
+2
@@ -25,6 +25,8 @@ declare module 'vue' {
|
||||
VanPopup: typeof import('vant/es')['Popup']
|
||||
VanPullRefresh: typeof import('vant/es')['PullRefresh']
|
||||
VanSearch: typeof import('vant/es')['Search']
|
||||
VanSwipe: typeof import('vant/es')['Swipe']
|
||||
VanSwipeItem: typeof import('vant/es')['SwipeItem']
|
||||
VanTag: typeof import('vant/es')['Tag']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user