优化首页筛选来源
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
docker compose -f deploy/docker-compose.dev.yml up -d
|
||||
|
||||
docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < backend/migrations/000001_init.sql
|
||||
docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < backend/seeds/dev_orders.sql
|
||||
|
||||
cd backend
|
||||
cp .env.example .env
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
-- Development seed: refresh demo listings and rental orders for the mobile app.
|
||||
-- Usage:
|
||||
-- docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < backend/seeds/dev_orders.sql
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE TEMPORARY TABLE IF NOT EXISTS tmp_seed_orders AS
|
||||
SELECT id FROM rental_orders;
|
||||
|
||||
DELETE FROM disputes WHERE order_id IN (SELECT id FROM tmp_seed_orders);
|
||||
DELETE FROM handoff_records WHERE order_id IN (SELECT id FROM tmp_seed_orders);
|
||||
DELETE FROM wallet_ledger WHERE order_id IN (SELECT id FROM tmp_seed_orders);
|
||||
DELETE FROM notifications
|
||||
WHERE biz_type = 'order' OR biz_id IN (SELECT id FROM tmp_seed_orders);
|
||||
DELETE FROM rental_orders;
|
||||
DROP TEMPORARY TABLE IF EXISTS tmp_seed_orders;
|
||||
|
||||
CREATE TEMPORARY TABLE IF NOT EXISTS tmp_seed_accounts AS
|
||||
SELECT id
|
||||
FROM game_accounts
|
||||
WHERE title LIKE '测试发布-%' OR title LIKE '测试订单-%';
|
||||
|
||||
DELETE FROM rental_listings WHERE account_id IN (SELECT id FROM tmp_seed_accounts);
|
||||
DELETE FROM game_accounts WHERE id IN (SELECT id FROM tmp_seed_accounts);
|
||||
DROP TEMPORARY TABLE IF EXISTS tmp_seed_accounts;
|
||||
|
||||
INSERT INTO users (phone, nickname, realname_status, risk_status, credit_score, status)
|
||||
VALUES
|
||||
('13800000001', '测试号主A', 'verified', 'normal', 100, 'active'),
|
||||
('13800000002', '测试号主B', 'verified', 'normal', 100, 'active'),
|
||||
('13900000001', '测试租客A', 'verified', 'normal', 100, 'active')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
nickname = VALUES(nickname),
|
||||
realname_status = VALUES(realname_status),
|
||||
risk_status = VALUES(risk_status),
|
||||
credit_score = VALUES(credit_score),
|
||||
status = VALUES(status),
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
|
||||
SELECT id INTO @owner_a FROM users WHERE phone = '13800000001';
|
||||
SELECT id INTO @owner_b FROM users WHERE phone = '13800000002';
|
||||
SELECT id INTO @renter_a FROM users WHERE phone = '13900000001';
|
||||
|
||||
INSERT INTO wallet_accounts (user_id, available_balance, frozen_balance, status)
|
||||
VALUES
|
||||
(@owner_a, 0, 0, 'active'),
|
||||
(@owner_b, 0, 0, 'active'),
|
||||
(@renter_a, 2000, 0, 'active')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
available_balance = VALUES(available_balance),
|
||||
frozen_balance = VALUES(frozen_balance),
|
||||
status = VALUES(status),
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
|
||||
INSERT INTO game_accounts
|
||||
(owner_id, game_name, server_region, login_platform, title, description, rank_level, haf_coin_amount, asset_summary, screenshot_urls, status)
|
||||
VALUES
|
||||
(@owner_a, '三角洲行动', 'QQ', '扫码', '测试发布-QQ 黑鹰 30000万哈夫币',
|
||||
'扫码交接,保险 2*3,30M 日损耗,适合高强度体验。',
|
||||
'黑鹰', 300000000,
|
||||
JSON_OBJECT('haf_coin_wan', 30000, 'season_insurance', '2*3', 'stamina_level', '6级', 'load_level', '6级', 'daily_loss', '30M', 'ratio', 6, 'login_method', '扫码', 'common_regions', JSON_ARRAY('广东', '上海'), 'resources', JSON_ARRAY(JSON_OBJECT('key', 'awmAmmo', 'label', 'AWM子弹', 'quantity', 80, 'mode', '收费'), JSON_OBJECT('key', 'helmet6', 'label', '6头数量', 'quantity', 6, 'mode', '收费'), JSON_OBJECT('key', 'armor6', 'label', '6甲数量', 'quantity', 4, 'mode', '收费')), 'skin_groups', JSON_OBJECT('melee', JSON_ARRAY('龙牙', '信条'), 'operator', JSON_ARRAY('威龙-凌霄戍卫'), 'weapon', JSON_ARRAY('M4A1棱镜攻势', 'K416命运'))),
|
||||
JSON_ARRAY(), 'published'),
|
||||
(@owner_a, '三角洲行动', '微信', '账号密码', '测试发布-微信 钻石 12000万低押号',
|
||||
'账密登录,低押金,保险 2*2,常用登录地江苏。',
|
||||
'钻石', 120000000,
|
||||
JSON_OBJECT('haf_coin_wan', 12000, 'season_insurance', '2*2', 'stamina_level', '5级', 'load_level', '5级', 'daily_loss', '20M', 'ratio', 5, 'login_method', '账号密码', 'common_regions', JSON_ARRAY('江苏'), 'resources', JSON_ARRAY(JSON_OBJECT('key', 'awmAmmo', 'label', 'AWM子弹', 'quantity', 30, 'mode', '收费'), JSON_OBJECT('key', 'helmet6', 'label', '6头数量', 'quantity', 2, 'mode', '收费')), 'skin_groups', JSON_OBJECT('melee', JSON_ARRAY('怜悯'), 'operator', JSON_ARRAY('露娜-黑天际线'), 'weapon', JSON_ARRAY('SCAR-H电玩高手'))),
|
||||
JSON_ARRAY(), 'published'),
|
||||
(@owner_b, '三角洲行动', 'Steam', '扫码', '测试发布-Steam 铂金 6000万包夜号',
|
||||
'可包夜,保险 2*1,适合短租搬砖。',
|
||||
'铂金', 60000000,
|
||||
JSON_OBJECT('haf_coin_wan', 6000, 'season_insurance', '2*1', 'stamina_level', '4级', 'load_level', '4级', 'daily_loss', '10M', 'ratio', 4, 'login_method', '扫码', 'common_regions', JSON_ARRAY('四川', '重庆'), 'resources', JSON_ARRAY(JSON_OBJECT('key', 'awmAmmo', 'label', 'AWM子弹', 'quantity', 12, 'mode', '收费'), JSON_OBJECT('key', 'gridCard9', 'label', '9格体验卡', 'quantity', 1, 'mode', '收费')), 'skin_groups', JSON_OBJECT('melee', JSON_ARRAY('暗星'), 'operator', JSON_ARRAY('赛伊德'), 'weapon', JSON_ARRAY('Vector美杜莎'))),
|
||||
JSON_ARRAY(), 'published'),
|
||||
(@owner_b, '三角洲行动', 'QQ', '账号密码', '测试发布-QQ 三角洲巅峰 50000万成品号',
|
||||
'账密交接,高段位,保险 3*3,皮肤资源较多。',
|
||||
'三角洲巅峰', 500000000,
|
||||
JSON_OBJECT('haf_coin_wan', 50000, 'season_insurance', '3*3', 'stamina_level', '7级', 'load_level', '7级', 'daily_loss', '30M', 'ratio', 7, 'login_method', '账号密码', 'common_regions', JSON_ARRAY('北京', '浙江'), 'resources', JSON_ARRAY(JSON_OBJECT('key', 'awmAmmo', 'label', 'AWM子弹', 'quantity', 120, 'mode', '收费'), JSON_OBJECT('key', 'helmet6', 'label', '6头数量', 'quantity', 10, 'mode', '收费'), JSON_OBJECT('key', 'armor6Ammo', 'label', '6甲子弹', 'quantity', 300, 'mode', '收费')), 'skin_groups', JSON_OBJECT('melee', JSON_ARRAY('坠星者', '处刑者'), 'operator', JSON_ARRAY('威龙-铁面判官', '红狼-蚀金玫瑰'), 'weapon', JSON_ARRAY('M7棱镜攻势S2', 'KC17-造物纪元'))),
|
||||
JSON_ARRAY(), 'published'),
|
||||
(@owner_a, '三角洲行动', '微信', '扫码', '测试订单-待交接 微信 黑鹰 20000万',
|
||||
'测试订单关联账号:待交接。',
|
||||
'黑鹰', 200000000,
|
||||
JSON_OBJECT('haf_coin_wan', 20000, 'season_insurance', '2*3', 'stamina_level', '6级', 'load_level', '5级', 'daily_loss', '20M', 'ratio', 6, 'login_method', '扫码', 'common_regions', JSON_ARRAY('广东'), 'resources', JSON_ARRAY(JSON_OBJECT('key', 'awmAmmo', 'label', 'AWM子弹', 'quantity', 50, 'mode', '收费')), 'skin_groups', JSON_OBJECT('melee', JSON_ARRAY('影锋'), 'operator', JSON_ARRAY('威龙-吴彦祖'), 'weapon', JSON_ARRAY('QBZ95王牌之剑'))),
|
||||
JSON_ARRAY(), 'rented'),
|
||||
(@owner_b, '三角洲行动', 'QQ', '账号密码', '测试订单-租赁中 QQ 钻石 10000万',
|
||||
'测试订单关联账号:租赁中。',
|
||||
'钻石', 100000000,
|
||||
JSON_OBJECT('haf_coin_wan', 10000, 'season_insurance', '2*2', 'stamina_level', '5级', 'load_level', '5级', 'daily_loss', '20M', 'ratio', 5, 'login_method', '账号密码', 'common_regions', JSON_ARRAY('上海'), 'resources', JSON_ARRAY(JSON_OBJECT('key', 'awmAmmo', 'label', 'AWM子弹', 'quantity', 20, 'mode', '收费')), 'skin_groups', JSON_OBJECT('melee', JSON_ARRAY('黑海'), 'operator', JSON_ARRAY('骇爪-水墨云图'), 'weapon', JSON_ARRAY('AUG气象感应'))),
|
||||
JSON_ARRAY(), 'rented'),
|
||||
(@owner_a, '三角洲行动', 'Steam', '扫码', '测试订单-待归还 Steam 黄金 5000万',
|
||||
'测试订单关联账号:待归还确认。',
|
||||
'黄金', 50000000,
|
||||
JSON_OBJECT('haf_coin_wan', 5000, 'season_insurance', '2*1', 'stamina_level', '3级', 'load_level', '3级', 'daily_loss', '10M', 'ratio', 4, 'login_method', '扫码', 'common_regions', JSON_ARRAY('湖北'), 'resources', JSON_ARRAY(JSON_OBJECT('key', 'gridCard9', 'label', '9格体验卡', 'quantity', 1, 'mode', '收费')), 'skin_groups', JSON_OBJECT('melee', JSON_ARRAY('北极星'), 'operator', JSON_ARRAY('蜂医-送葬人'), 'weapon', JSON_ARRAY('MP7电玩高手S2'))),
|
||||
JSON_ARRAY(), 'rented');
|
||||
|
||||
SELECT id INTO @account_pub_1 FROM game_accounts WHERE title = '测试发布-QQ 黑鹰 30000万哈夫币' ORDER BY id DESC LIMIT 1;
|
||||
SELECT id INTO @account_pub_2 FROM game_accounts WHERE title = '测试发布-微信 钻石 12000万低押号' ORDER BY id DESC LIMIT 1;
|
||||
SELECT id INTO @account_pub_3 FROM game_accounts WHERE title = '测试发布-Steam 铂金 6000万包夜号' ORDER BY id DESC LIMIT 1;
|
||||
SELECT id INTO @account_pub_4 FROM game_accounts WHERE title = '测试发布-QQ 三角洲巅峰 50000万成品号' ORDER BY id DESC LIMIT 1;
|
||||
SELECT id INTO @account_order_1 FROM game_accounts WHERE title = '测试订单-待交接 微信 黑鹰 20000万' ORDER BY id DESC LIMIT 1;
|
||||
SELECT id INTO @account_order_2 FROM game_accounts WHERE title = '测试订单-租赁中 QQ 钻石 10000万' ORDER BY id DESC LIMIT 1;
|
||||
SELECT id INTO @account_order_3 FROM game_accounts WHERE title = '测试订单-待归还 Steam 黄金 5000万' ORDER BY id DESC LIMIT 1;
|
||||
|
||||
INSERT INTO rental_listings
|
||||
(account_id, owner_id, price_hourly, price_daily, price_weekly, deposit_amount, min_rent_hours, max_rent_hours, status, review_status, published_at)
|
||||
VALUES
|
||||
(@account_pub_1, @owner_a, 8.80, 88.00, 520.00, 120.00, 24, 168, 'published', 'approved', NOW() - INTERVAL 10 MINUTE),
|
||||
(@account_pub_2, @owner_a, 5.50, 55.00, 330.00, 60.00, 24, 72, 'published', 'approved', NOW() - INTERVAL 20 MINUTE),
|
||||
(@account_pub_3, @owner_b, 3.90, 39.00, 220.00, 80.00, 8, 24, 'published', 'approved', NOW() - INTERVAL 30 MINUTE),
|
||||
(@account_pub_4, @owner_b, 12.80, 128.00, 760.00, 180.00, 24, 168, 'published', 'approved', NOW() - INTERVAL 40 MINUTE),
|
||||
(@account_order_1, @owner_a, 7.00, 70.00, 420.00, 100.00, 24, 72, 'rented', 'approved', NOW() - INTERVAL 50 MINUTE),
|
||||
(@account_order_2, @owner_b, 6.00, 60.00, 360.00, 80.00, 24, 168, 'rented', 'approved', NOW() - INTERVAL 60 MINUTE),
|
||||
(@account_order_3, @owner_a, 3.00, 30.00, 180.00, 50.00, 8, 24, 'rented', 'approved', NOW() - INTERVAL 70 MINUTE);
|
||||
|
||||
SELECT id INTO @listing_order_1 FROM rental_listings WHERE account_id = @account_order_1 ORDER BY id DESC LIMIT 1;
|
||||
SELECT id INTO @listing_order_2 FROM rental_listings WHERE account_id = @account_order_2 ORDER BY id DESC LIMIT 1;
|
||||
SELECT id INTO @listing_order_3 FROM rental_listings WHERE account_id = @account_order_3 ORDER BY id DESC LIMIT 1;
|
||||
|
||||
INSERT INTO rental_orders
|
||||
(order_no, listing_id, account_id, owner_id, renter_id, rent_start_at, rent_end_at, rent_hours, rent_amount, deposit_amount, platform_fee, account_snapshot, status, handoff_status, settlement_status, created_at, updated_at)
|
||||
VALUES
|
||||
('TEST202605230001', @listing_order_1, @account_order_1, @owner_a, @renter_a, NULL, NULL, 24, 70.00, 100.00, 7.00,
|
||||
JSON_OBJECT('title', '测试订单-待交接 微信 黑鹰 20000万', 'server_region', '微信', 'login_platform', '扫码', 'haf_coin_amount', 200000000),
|
||||
'pending_handoff', 'pending_owner', 'unsettled', NOW(), NOW()),
|
||||
('TEST202605230002', @listing_order_2, @account_order_2, @owner_b, @renter_a, NOW() - INTERVAL 2 HOUR, NOW() + INTERVAL 22 HOUR, 24, 60.00, 80.00, 6.00,
|
||||
JSON_OBJECT('title', '测试订单-租赁中 QQ 钻石 10000万', 'server_region', 'QQ', 'login_platform', '账号密码', 'haf_coin_amount', 100000000),
|
||||
'renting', 'received', 'unsettled', NOW() - INTERVAL 2 HOUR, NOW() - INTERVAL 90 MINUTE),
|
||||
('TEST202605230003', @listing_order_3, @account_order_3, @owner_a, @renter_a, NOW() - INTERVAL 10 HOUR, NOW() - INTERVAL 2 HOUR, 8, 30.00, 50.00, 3.00,
|
||||
JSON_OBJECT('title', '测试订单-待归还 Steam 黄金 5000万', 'server_region', 'Steam', 'login_platform', '扫码', 'haf_coin_amount', 50000000),
|
||||
'pending_return_confirm', 'pending_owner_return_confirm', 'unsettled', NOW() - INTERVAL 10 HOUR, NOW() - INTERVAL 15 MINUTE);
|
||||
|
||||
SELECT id INTO @order_1 FROM rental_orders WHERE order_no = 'TEST202605230001';
|
||||
SELECT id INTO @order_2 FROM rental_orders WHERE order_no = 'TEST202605230002';
|
||||
SELECT id INTO @order_3 FROM rental_orders WHERE order_no = 'TEST202605230003';
|
||||
|
||||
INSERT INTO handoff_records
|
||||
(order_id, from_user_id, to_user_id, type, content, attachment_urls, confirmed_by_renter_at, confirmed_by_owner_at)
|
||||
VALUES
|
||||
(@order_2, @owner_b, @renter_a, 'owner_submit', '账密已通过群聊交接,请按约定时间使用。', JSON_ARRAY(), NOW() - INTERVAL 90 MINUTE, NULL),
|
||||
(@order_3, @owner_a, @renter_a, 'owner_submit', '扫码已完成,账号可正常进入游戏。', JSON_ARRAY(), NOW() - INTERVAL 9 HOUR, NULL),
|
||||
(@order_3, @renter_a, @owner_a, 'renter_return', '已退出账号,未消耗保留物资。', JSON_ARRAY(), NULL, NULL);
|
||||
|
||||
INSERT INTO notifications (user_id, type, title, content, biz_type, biz_id, created_at)
|
||||
VALUES
|
||||
(@owner_a, 'order', '收到新的租号订单', '测试订单 TEST202605230001 等待你提交交接说明。', 'order', @order_1, NOW()),
|
||||
(@renter_a, 'order', '订单已创建', '测试订单 TEST202605230001 已创建,请等待号主交接。', 'order', @order_1, NOW()),
|
||||
(@renter_a, 'order', '订单租赁中', '测试订单 TEST202605230002 已进入租赁中。', 'order', @order_2, NOW() - INTERVAL 90 MINUTE),
|
||||
(@owner_a, 'order', '买家已提交归还', '测试订单 TEST202605230003 等待你确认归还。', 'order', @order_3, NOW() - INTERVAL 15 MINUTE);
|
||||
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