优化首页筛选来源
This commit is contained in:
@@ -14,6 +14,7 @@
|
|||||||
docker compose -f deploy/docker-compose.dev.yml up -d
|
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/migrations/000001_init.sql
|
||||||
|
docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < backend/seeds/dev_orders.sql
|
||||||
|
|
||||||
cd backend
|
cd backend
|
||||||
cp .env.example .env
|
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']
|
VanPopup: typeof import('vant/es')['Popup']
|
||||||
VanPullRefresh: typeof import('vant/es')['PullRefresh']
|
VanPullRefresh: typeof import('vant/es')['PullRefresh']
|
||||||
VanSearch: typeof import('vant/es')['Search']
|
VanSearch: typeof import('vant/es')['Search']
|
||||||
|
VanSwipe: typeof import('vant/es')['Swipe']
|
||||||
|
VanSwipeItem: typeof import('vant/es')['SwipeItem']
|
||||||
VanTag: typeof import('vant/es')['Tag']
|
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 = [
|
const statusTabs = [
|
||||||
{ key: "all", label: "全部" },
|
{ key: "all", label: "全部" },
|
||||||
{ key: "pending", label: "待交接" },
|
{ key: "pending_handoff", label: "待交接" },
|
||||||
{ key: "renting", label: "租赁中" },
|
{ key: "renting", label: "租赁中" },
|
||||||
{ key: "returning", label: "待归还" },
|
{ key: "pending_return_confirm", label: "待归还" },
|
||||||
{ key: "completed", label: "已完成" },
|
{ key: "completed", label: "已完成" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -47,24 +47,30 @@ const displayOrders = computed(() => {
|
|||||||
/* 状态颜色映射 */
|
/* 状态颜色映射 */
|
||||||
function statusColor(status: string) {
|
function statusColor(status: string) {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
pending: "#ff9800",
|
pending_handoff: "#ff9800",
|
||||||
renting: "#1477ff",
|
renting: "#1477ff",
|
||||||
returning: "#e91e63",
|
overdue: "#e91e63",
|
||||||
|
pending_return_confirm: "#e91e63",
|
||||||
completed: "#4caf50",
|
completed: "#4caf50",
|
||||||
cancelled: "#999",
|
cancelled: "#999",
|
||||||
dispute: "#f44336",
|
disputing: "#f44336",
|
||||||
|
abnormal: "#f44336",
|
||||||
|
closed: "#666",
|
||||||
};
|
};
|
||||||
return map[status] || "#999";
|
return map[status] || "#999";
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(status: string) {
|
function statusLabel(status: string) {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
pending: "待交接",
|
pending_handoff: "待交接",
|
||||||
renting: "租赁中",
|
renting: "租赁中",
|
||||||
returning: "待归还",
|
overdue: "已逾期",
|
||||||
|
pending_return_confirm: "待归还",
|
||||||
completed: "已完成",
|
completed: "已完成",
|
||||||
cancelled: "已取消",
|
cancelled: "已取消",
|
||||||
dispute: "申诉中",
|
disputing: "申诉中",
|
||||||
|
abnormal: "异常",
|
||||||
|
closed: "已关闭",
|
||||||
};
|
};
|
||||||
return map[status] || status;
|
return map[status] || status;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user