diff --git a/README.md b/README.md index b9a3595..27d01f3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/seeds/dev_orders.sql b/backend/seeds/dev_orders.sql new file mode 100644 index 0000000..d582f89 --- /dev/null +++ b/backend/seeds/dev_orders.sql @@ -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); diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 3785345..e2383c8 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -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'] } } diff --git a/frontend/src/views/mobile/MobileHomeFilterSheet.vue b/frontend/src/views/mobile/MobileHomeFilterSheet.vue new file mode 100644 index 0000000..2078433 --- /dev/null +++ b/frontend/src/views/mobile/MobileHomeFilterSheet.vue @@ -0,0 +1,448 @@ + + + + + diff --git a/frontend/src/views/mobile/MobileHomeView.vue b/frontend/src/views/mobile/MobileHomeView.vue index 07c9cda..0228822 100644 --- a/frontend/src/views/mobile/MobileHomeView.vue +++ b/frontend/src/views/mobile/MobileHomeView.vue @@ -3,36 +3,76 @@ import { computed, onMounted, ref } from "vue"; import { RouterLink, useRoute } from "vue-router"; import { showToast } from "vant"; +import { + defaultListingPublishOptions, + fetchListingPublishOptions, + type ListingPublishOptions, +} from "@/api/listingOptions"; import { fetchListings, type Listing } from "@/api/listings"; +import MobileHomeFilterSheet, { + type FilterSection, +} from "./MobileHomeFilterSheet.vue"; const route = useRoute(); const loading = ref(false); const loadFailed = ref(false); const listings = ref([]); -const activeSort = ref(0); -const activeFilter = ref([]); -const activeChannel = ref(0); +const publishOptions = ref(defaultListingPublishOptions); +const sortOpen = ref(false); +const activeSort = ref("comprehensive"); +const filterOpen = ref(false); +const selectedFilters = ref>({}); +const rangeFilters = ref>({}); const refreshing = ref(false); const searchValue = ref(""); -const channels = [ - { label: "全部", icon: "🔥" }, - { label: "高哈夫币", icon: "💰" }, - { label: "低押金", icon: "🛡️" }, - { label: "扫码号", icon: "📱" }, - { label: "包夜", icon: "🌙" }, -]; const sortOptions = [ - { text: "默认", value: "default" }, - { text: "价格", value: "price" }, - { text: "比例", value: "ratio" }, - { text: "哈夫币", value: "coin" }, + { key: "comprehensive", label: "综合排序" }, + { key: "published", label: "发布时间" }, + { key: "priceAsc", label: "价格最低" }, + { key: "priceDesc", label: "价格最高" }, ]; -const quickFilters = [ - { text: "三角洲行动", value: "a" }, - { text: "安卓/扫码", value: "b" }, - { text: "高段位", value: "c" }, - { text: "可包夜", value: "d" }, + +const rangePresets: Record> = { + coin: [ + { label: "10-50", min: "10", max: "50" }, + { label: "50-100", min: "50", max: "100" }, + { label: "100-150", min: "100", max: "150" }, + { label: "150-200", min: "150", max: "200" }, + { label: "200-300", min: "200", max: "300" }, + { label: "300以上", min: "300", max: "" }, + ], + resource_awmAmmo: [ + { label: "0-20", min: "0", max: "20" }, + { label: "20-50", min: "20", max: "50" }, + { label: "50-100", min: "50", max: "100" }, + { label: "100-200", min: "100", max: "200" }, + { label: "200以上", min: "200", max: "" }, + ], +}; + +const bannerSlides = [ + { + eyebrow: "三角洲行动账号专区", + title: "高哈夫币 · 安全交接 · 随租随玩", + badge: "HOT", + pill: "新人领券最高减 20 元", + tone: "blue", + }, + { + eyebrow: "平台担保交易", + title: "交接全程留痕,拒绝私下转账", + badge: "SAFE", + pill: "租号前先验实名与资料", + tone: "green", + }, + { + eyebrow: "高效筛选", + title: "按区服、段位、哈夫币快速找号", + badge: "FAST", + pill: "支持扫码号与账密号", + tone: "orange", + }, ]; const fallbackListings: Listing[] = [ @@ -40,19 +80,39 @@ const fallbackListings: Listing[] = [ id: 9001, account_id: 0, owner_id: 0, - title: "烽火地带 · 80W 哈夫币安全号", - description: "支持扫码上号,号主在线交接,适合快速体验高资产账号。", + title: "QQ区 黑鹰 30000万哈夫币账号", + description: "支持扫码上号,号主在线交接,保险 2*3,适合高强度体验。", game_name: "三角洲行动", - server_region: "微信区", - login_platform: "安卓扫码", - rank_level: "机密行动 王牌", - haf_coin_amount: 800000, + server_region: "QQ", + login_platform: "扫码", + rank_level: "黑鹰", + haf_coin_amount: 300000000, + asset_summary: { + haf_coin_wan: 30000, + season_insurance: "2*3", + stamina_level: "6级", + load_level: "6级", + daily_loss: "30M", + ratio: 6, + login_method: "扫码", + common_regions: ["广东", "上海"], + resources: [ + { key: "awmAmmo", label: "AWM子弹", quantity: 80, mode: "收费" }, + { key: "helmet6", label: "6头数量", quantity: 6, mode: "收费" }, + { key: "armor6", label: "6甲数量", quantity: 4, mode: "收费" }, + ], + skin_groups: { + melee: ["龙牙", "信条"], + operator: ["威龙-凌霄戍卫"], + weapon: ["M4A1棱镜攻势", "K416命运"], + }, + }, screenshot_urls: [], - price_hourly: 6.8, - price_daily: 58, - price_weekly: 288, - deposit_amount: 200, - min_rent_hours: 2, + price_hourly: 8.8, + price_daily: 88, + price_weekly: 520, + deposit_amount: 120, + min_rent_hours: 24, max_rent_hours: 168, status: "published", review_status: "approved", @@ -64,19 +124,38 @@ const fallbackListings: Listing[] = [ id: 9002, account_id: 0, owner_id: 0, - title: "航天基地 · 高配皮肤成品号", - description: "热门枪皮齐全,低押金,新手友好。", + title: "微信 钻石 12000万哈夫币低押号", + description: "账密登录,低押金,皮肤截图齐全,常用登录地江苏。", game_name: "三角洲行动", - server_region: "QQ区", - login_platform: "iOS扫码", + server_region: "微信", + login_platform: "账号密码", rank_level: "钻石", - haf_coin_amount: 520000, + haf_coin_amount: 120000000, + asset_summary: { + haf_coin_wan: 12000, + season_insurance: "2*2", + stamina_level: "5级", + load_level: "5级", + daily_loss: "20M", + ratio: 5, + login_method: "账号密码", + common_regions: ["江苏"], + resources: [ + { key: "awmAmmo", label: "AWM子弹", quantity: 30, mode: "收费" }, + { key: "helmet6", label: "6头数量", quantity: 2, mode: "收费" }, + ], + skin_groups: { + melee: ["怜悯"], + operator: ["露娜-黑天际线"], + weapon: ["SCAR-H电玩高手"], + }, + }, screenshot_urls: [], - price_hourly: 4.5, - price_daily: 39, - price_weekly: 198, - deposit_amount: 120, - min_rent_hours: 1, + price_hourly: 5.5, + price_daily: 55, + price_weekly: 330, + deposit_amount: 60, + min_rent_hours: 24, max_rent_hours: 72, status: "published", review_status: "approved", @@ -88,20 +167,39 @@ const fallbackListings: Listing[] = [ id: 9003, account_id: 0, owner_id: 0, - title: "零号大坝 · 搬砖专用稳定号", - description: "可长租,支持包周,交接说明完整。", + title: "Steam 铂金 6000万哈夫币包夜号", + description: "可包夜,保险 2*1,适合短租搬砖体验。", game_name: "三角洲行动", - server_region: "微信区", - login_platform: "安卓/模拟器", + server_region: "Steam", + login_platform: "扫码", rank_level: "铂金", - haf_coin_amount: 360000, + haf_coin_amount: 60000000, + asset_summary: { + haf_coin_wan: 6000, + season_insurance: "2*1", + stamina_level: "4级", + load_level: "4级", + daily_loss: "10M", + ratio: 4, + login_method: "扫码", + common_regions: ["四川", "重庆"], + resources: [ + { key: "awmAmmo", label: "AWM子弹", quantity: 12, mode: "收费" }, + { key: "gridCard9", label: "9格体验卡", quantity: 1, mode: "收费" }, + ], + skin_groups: { + melee: ["暗星"], + operator: ["赛伊德"], + weapon: ["Vector美杜莎"], + }, + }, screenshot_urls: [], price_hourly: 3.9, - price_daily: 32, - price_weekly: 168, + price_daily: 39, + price_weekly: 220, deposit_amount: 80, - min_rent_hours: 3, - max_rent_hours: 168, + min_rent_hours: 8, + max_rent_hours: 24, status: "published", review_status: "approved", review_reason: "", @@ -110,25 +208,78 @@ const fallbackListings: Listing[] = [ }, ]; -const displayListings = computed(() => - listings.value.length > 0 ? listings.value : fallbackListings +const sourceListings = computed(() => + loadFailed.value ? fallbackListings : listings.value ); -const platformStats = computed(() => { - const items = displayListings.value; - const totalCoin = items.reduce( - (sum, item) => sum + Number(item.haf_coin_amount || 0), +const activeSortLabel = computed( + () => + sortOptions.find((option) => option.key === activeSort.value)?.label || + "综合排序" +); + +const serverFilterOptions = computed(() => + uniqueOptions( + publishOptions.value.server_options + .map((item) => normalizeServerRegion(item)) + .filter(Boolean) + ) +); + +const loginMethodFilterOptions = computed(() => + uniqueOptions( + publishOptions.value.login_method_options + .map((item) => normalizeLoginMethod(item)) + .filter(Boolean) + ) +); + +const filterSections = computed(() => [ + { key: "price", title: "价格区间", type: "range", unit: "元/小时", minPlaceholder: "最低价", maxPlaceholder: "最高价" }, + { key: "coin", title: "哈夫币数量", type: "range", unit: "万", minPlaceholder: "最低", maxPlaceholder: "最高" }, + { key: "server", title: "区服", type: "chips", options: serverFilterOptions.value }, + { key: "login", title: "上号方式", type: "chips", options: loginMethodFilterOptions.value }, + { key: "insurance", title: "保险", type: "chips", options: publishOptions.value.insurance_options }, + { key: "stamina", title: "体力", type: "chips", options: publishOptions.value.level_options }, + { key: "load", title: "负重", type: "chips", options: publishOptions.value.level_options }, + ...publishOptions.value.quantity_items.map((item) => ({ + key: `resource_${item.key}`, + title: item.label, + type: "range" as const, + unit: parseQuantityUnit(item.price), + minPlaceholder: "最低", + maxPlaceholder: "最高", + })), + ...publishOptions.value.skin_groups.map((group) => ({ + key: group.key, + title: group.title, + type: "chips" as const, + options: group.options, + })), + { key: "secretKd", title: "绝密KD", type: "range", minPlaceholder: "最低", maxPlaceholder: "最高" }, + { key: "rank", title: "段位", type: "chips", options: publishOptions.value.rank_options }, + { key: "deposit", title: "押金", type: "range", unit: "元", minPlaceholder: "最低", maxPlaceholder: "最高" }, +]); + +const activeFilterCount = computed(() => { + const chipCount = Object.values(selectedFilters.value).reduce( + (sum, values) => sum + values.length, 0 ); - const lowDeposit = items.filter( - (item) => Number(item.deposit_amount || 0) <= 120 + const rangeCount = Object.values(rangeFilters.value).filter( + (range) => range.min || range.max ).length; + return chipCount + rangeCount; +}); - return [ - { value: `${items.length}+`, label: "在线租号", icon: "👥" }, - { value: formatCoin(totalCoin), label: "哈夫币总量", icon: "💰" }, - { value: `${lowDeposit}个`, label: "低押账号", icon: "🛡️" }, - ]; +const displayListings = computed(() => { + const keyword = searchValue.value.trim().toLowerCase(); + const filtered = sourceListings.value.filter((item) => { + if (!matchesFilters(item)) return false; + if (!keyword) return true; + return searchText(item).includes(keyword); + }); + return sortListings(filtered); }); /** 判断当前底部导航是否激活 */ @@ -137,7 +288,10 @@ function isNavActive(path: string) { return route.path.startsWith(path); } -onMounted(loadListings); +onMounted(() => { + loadListings(); + loadPublishOptions(); +}); async function loadListings() { loading.value = true; @@ -151,10 +305,23 @@ async function loadListings() { } } +async function loadPublishOptions() { + try { + publishOptions.value = await fetchListingPublishOptions(); + } catch { + publishOptions.value = defaultListingPublishOptions; + } +} + async function onRefresh() { refreshing.value = true; try { - listings.value = await fetchListings(); + const [nextListings, nextOptions] = await Promise.all([ + fetchListings(), + fetchListingPublishOptions(), + ]); + listings.value = nextListings; + publishOptions.value = nextOptions; showToast({ message: "刷新成功", icon: "passed" }); } catch { // 静默处理 @@ -163,28 +330,226 @@ async function onRefresh() { } } -function toggleFilter(value: string) { - const idx = activeFilter.value.indexOf(value); - if (idx >= 0) { - activeFilter.value.splice(idx, 1); - } else { - activeFilter.value.push(value); - } +function openFilters() { + sortOpen.value = false; + filterOpen.value = true; } -function formatCoin(amount: number) { - if (amount >= 10000) { - return `${Math.round(amount / 10000)}W`; +function toggleSortPanel() { + sortOpen.value = !sortOpen.value; +} + +function selectSort(sortKey: string) { + activeSort.value = sortKey; + sortOpen.value = false; +} + +function clearFilters() { + selectedFilters.value = {}; + rangeFilters.value = {}; + searchValue.value = ""; +} + +function sortListings(items: Listing[]) { + const sorted = [...items]; + if (activeSort.value === "comprehensive") { + return sorted; } - return `${amount}`; + if (activeSort.value === "priceAsc") { + return sorted.sort( + (a, b) => Number(a.price_hourly || 0) - Number(b.price_hourly || 0) + ); + } + if (activeSort.value === "priceDesc") { + return sorted.sort( + (a, b) => Number(b.price_hourly || 0) - Number(a.price_hourly || 0) + ); + } + 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)); + } + return true; + }); + if (!chipOk) return false; + + return Object.entries(rangeFilters.value).every(([key, range]) => { + if (!range.min && !range.max) return true; + let value = 0; + if (key === "price") value = Number(item.price_hourly || 0); + if (key === "coin") value = getCoinWan(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_", "")); + } + return inRange(value, range); + }); +} + +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 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"), + formatCoinWan(getCoinWan(item)), + assetRegions(item).join(" "), + ...publishOptions.value.skin_groups.map((group) => + getSkinGroup(item, group.key).join(" ") + ), + ] + .join(" ") + .toLowerCase(); +} + +function getCoinWan(item: Listing) { + const assetCoin = readAssetNumber(item, "haf_coin_wan"); + if (assetCoin > 0) return assetCoin; + return Math.round(Number(item.haf_coin_amount || 0) / 10000); +} + +function getLoginMethod(item: Listing) { + return normalizeLoginMethod( + readAssetString(item, "login_method") || item.login_platform + ); +} + +function getServerRegion(item: Listing) { + return normalizeServerRegion(item.server_region); +} + +function normalizeServerRegion(value: string) { + const text = value.trim(); + const upper = text.toUpperCase(); + if (upper.includes("STEAM")) return "Steam"; + if (upper.includes("QQ")) return "QQ"; + if (text.includes("微信")) return "微信"; + return ""; +} + +function normalizeLoginMethod(value: string) { + const text = value.trim(); + if (!text) return ""; + if (text.includes("扫码")) return "扫码"; + if (text.includes("账密") || text.includes("账号密码")) return "账号密码"; + return ""; +} + +function isSkinGroupKey(key: string) { + return publishOptions.value.skin_groups.some((group) => group.key === key); +} + +function parseQuantityUnit(price: string) { + const unit = price.split("/")[1]?.trim(); + return unit || undefined; +} + +function uniqueOptions(values: string[]) { + return [...new Set(values.map((item) => item.trim()).filter(Boolean))]; +} + +function getRatio(item: Listing) { + const ratio = readAssetNumber(item, "ratio"); + if (ratio > 0) return ratio; + const price = Number(item.price_hourly || 0); + if (price <= 0) return 0; + return getCoinWan(item) / price; +} + +function assetRegions(item: Listing) { + const regions = item.asset_summary?.common_regions; + return Array.isArray(regions) + ? regions.filter((region): region is string => typeof region === "string") + : []; +} + +function getResourceQuantity(item: Listing, resourceKey: string) { + const resources = item.asset_summary?.resources; + if (!Array.isArray(resources)) return 0; + const found = resources.find((resource) => { + return ( + typeof resource === "object" && + resource !== null && + "key" in resource && + (resource as { key?: unknown }).key === resourceKey + ); + }) as { quantity?: unknown } | undefined; + const quantity = found?.quantity; + if (typeof quantity === "number") return quantity; + if (typeof quantity === "string" && quantity.trim()) return Number(quantity) || 0; + return 0; +} + +function getSkinGroup(item: Listing, groupKey: string) { + const skinGroups = item.asset_summary?.skin_groups; + if ( + typeof skinGroups !== "object" || + skinGroups === null || + !Array.isArray((skinGroups as Record)[groupKey]) + ) { + return []; + } + return ((skinGroups as Record)[groupKey] as unknown[]).filter( + (skin): skin is string => typeof skin === "string" + ); +} + +function readAssetString(item: Listing, key: string) { + const value = item.asset_summary?.[key]; + return typeof value === "string" ? value : ""; +} + +function readAssetNumber(item: Listing, key: string) { + const value = item.asset_summary?.[key]; + if (typeof value === "number") return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} + +function formatCoinWan(amountWan: number) { + if (amountWan >= 10000) { + const yi = amountWan / 10000; + return `${Number.isInteger(yi) ? yi : yi.toFixed(1)}亿`; + } + return `${amountWan}万`; } function formatRatio(item: Listing) { - if (!item.price_hourly) { - return "--"; - } - return `${Math.round(item.haf_coin_amount / item.price_hourly / 10000)}W/元`; + const ratio = getRatio(item); + return ratio > 0 ? `1:${Math.round(ratio)}` : "--"; } +