8 Commits
Author SHA1 Message Date
yml2213 6a99c1121b fix(order): 解决移动端弹窗因 CSS display !important 导致 Vue v-show 隐藏失效无法关闭问题
- 根因定位:.mobile-popup-form 设置了 display: flex !important,该样式在 CSS 特异性中强于 Vue v-show 生成的行内 style="display: none;";当弹窗点击关闭后,Vue 将变量置为 false 并完成退场动画,但由于样式强制 flex,弹窗退场后立刻以 flex 状态重新暴露在屏幕上(即“又弹出一个”),且由于此时 Vue 响应式状态已是 false,再次点击关闭按钮不会触发状态改变(即“第二次无法关闭”)
- 解耦容器与弹窗:移除 .mobile-popup-form 根节点上的 display: flex !important 与 overflow 强覆盖,弹窗内部增加 .popup-inner 容器承载垂直 flex 列布局与吸顶/吸底/滚动分区
- 严格验证:运行全量检查 ./scripts/check.sh,Go测试、MySQL测试、TypeScript检查及Vite生产构建 100% 通过
2026-10-10 18:18:25 +08:00
yml2213 e36bdd28c5 fix(order): 解决移动端弹窗关闭时点击穿透导致弹窗自动重新弹出问题
- 根因定位:移动端点击关闭按钮后弹窗退场,浏览器在~300ms派发合成点击事件,恰好落在底层全宽的「修改结账方案」动作按钮上,导致关闭后立即被再次触发唤起;原关闭按钮触控面积小(12px)也容易引发手抖连点
- 防穿透冷却锁:引入 POPUP_COOLDOWN_MS 冷却机制,弹窗关闭后450ms内拦截底层触发按钮的一切点击唤起请求,彻底隔绝幽灵点击与连击
- 弹窗事件与关闭解耦:关闭按钮添加 @click.stop.prevent 与 type="button",规范监听 van-popup 的 @close 回调以确保遮罩层点击同步触发冷却
- 触控体验优化:将原文字关闭符号升级为 van-icon cross,增大触控靶心至 36px 圆形并添加 :active 反馈与 touch-action: manipulation
- 样式增强:重构数字输入框为带实体边框、聚焦高亮光晕与单位后缀的独立卡片,解决输入框外观类似只读文本的问题
- 验证通过:通过 npm test 与全量门禁 ./scripts/check.sh(Go测试、MySQL测试、TS类型检查、Vite生产构建 100% 通过)
2026-10-10 18:13:09 +08:00
yml2213 c9f6820882 fix(order): 修复移动端iOS Safari修改结账弹窗操作栏被截断及滚动卡死问题
- 架构重构:将修改结账、拒绝结账、发起争议弹窗的操作提交按钮从滚动体抽出,移入固定吸底的 popup-footer,保证任何情况下按钮始终完整可见、随手可点
- 视口适配:弹窗采用 85dvh 动态视口高度,配合 flex 列布局与 overflow: hidden,并在吸底栏集成 safe-area-inset-bottom,解决 iOS Safari 底部浮动地址栏遮挡
- 滚动解耦:弹窗内部 popup-body 独立滚动并开启 -webkit-overflow-scrolling 和 overscroll-behavior: contain,消除 WebKit 双层滚动容器死锁与橡皮筋卡死
- 防缩放优化:弹窗内全量输入框与 textarea 统一声明 16px 字体,杜绝 iOS Safari 聚焦时触发的强制页面缩放畸变
- 验证通过:运行全站门禁 ./scripts/check.sh(Go测试、MySQL测试、ESLint、Vue-TSC、Vite生产构建)全部通过
2026-10-10 17:58:09 +08:00
yml2213 5b2f36ac36 feat(listings): 排序交互升级为双向翻转并对齐双端体验
- PC端交互:HomeView 排序按钮支持点击切换升降序翻转(比例、哈夫币、AWM数量、合并价格为单个价格项),并带有高低方向箭头(▲/▼)高亮指示
- 移动端交互:MobileHomeView 菜单提供清晰的双向文案(如「比例从高到低 / 比例从低到高」),且点击当前选中项时支持自动翻转方向,顶部工具栏标题紧凑对齐
- 后端排序:public_filter 与 public_query 完整支持 ratioDesc/ratioAsc、priceAsc/priceDesc、coinDesc/coinAsc、awmDesc/awmAsc
- 验证方式:新增并运行 TestSortPublicListingsByRatio 包含升序/降序多断言,全量运行 ./scripts/check.sh 门禁全绿通过
2026-10-10 17:44:27 +08:00
yml2213 180a21c048 feat(listings): PC与移动端首页新增比例排序
- 前端选项:在 PC 首页(HomeView)与移动端首页(MobileHomeView)的排序项中,在「综合推荐」后新增「比例排序」(ratioDesc)
- 后端排序:在 sortPublicListings 中实现 ratioDesc/ratio,按折算比例从高到低排序,同比例按哈夫币数量降序次级平局决胜
- 自动化验证:新增 TestSortPublicListingsByRatio 单元测试,全量运行 ./scripts/check.sh 门禁(Go测试、MySQL测试、前端lint/typecheck/build)全部通过
2026-10-10 17:26:28 +08:00
yml2213 9d5d35c7d4 feat(admin): 商品管理支持多选皮肤筛选且要求包含所选全部皮肤
- 后端过滤:将 AdminListQuery.SkinName 筛选逻辑对齐前台,由 OR(交集) 改为 AND(全包含),选多款皮肤时账号必须同时包含所选的全部皮肤
- 前端交互:AdminListingsView 皮肤筛选支持多选交互,点击具体皮肤支持多选高亮,底部提供已选数量提示、清空与确定操作,外层按钮智能展示已选皮肤摘要
- 样式增强:补充管理后台皮肤筛选弹层底部的清空/确定操作栏样式
- 验证方式:新增并运行后端 TestFilterAdminListingsByMultipleSkinsRequiresAll 单元测试,运行全量 ./scripts/check.sh 门禁全绿通过
2026-10-10 17:14:47 +08:00
yml2213 a93c76bcf6 fix(listings): 手机端筛选地区更名为IP地区并对齐发布选项
- 名称统一:将手机端筛选抽屉的「资产地区」更名为「IP地区」,与PC端保持统一
- 选项对齐:地区选项优先对齐发布配置 publishOptions.region_options(常用登录地区),避免脏数据混入
- 验证通过:prettier、eslint、typecheck 及 65 个单测全部通过
2026-10-10 16:59:37 +08:00
yml2213 03d20223c8 refactor(listings): 统一移动端与PC端筛选逻辑并支持日消耗与绝密KD
- 统一预设体系:在 useFilterOptions.ts 集中导出 defaultRangePresets,两端共享档位定义,消除移动端私有硬编码档位
- 统一参数模型:在 useHomeFilters.ts 增加 mobileFiltersToHomeFilters,将移动端字典结构标准化转换为领域层 HomeFilters 模型
- 统一查询构建:在 useListingQuery.ts 抽象纯函数 buildListingQueryFromFilters,双端共用同一个后端发包构建器
- 功能对齐:移动端首页完整补充「日消耗」区间筛选;PC端高级抽屉对齐补充「绝密KD」筛选
- 验证通过:补充双端测试用例,全站门禁 ./scripts/check.sh(SQL/Go/MySQL/Vitest/Vite构建)全部绿灯
2026-10-10 16:48:16 +08:00
17 changed files with 1189 additions and 364 deletions
@@ -33,12 +33,11 @@ func matchesAdminAssetQuery(item ListingDTO, query AdminListQuery) bool {
return false
}
if len(query.SkinName) > 0 {
if len(query.SkinGroup) > 0 {
return skinGroupsContainAny(item.AssetSummary, query.SkinGroup, query.SkinName)
// 皮肤筛选:多选时要求账号必须包含所有选中项(AND),与前台首页一致
if !containsAll(query.SkinName, skinNamesFromSummary(item.AssetSummary)) {
return false
}
return intersects(query.SkinName, skinNamesFromSummary(item.AssetSummary))
}
if len(query.SkinGroup) > 0 {
} else if len(query.SkinGroup) > 0 {
return skinGroupsHaveAny(item.AssetSummary, query.SkinGroup)
}
return true
@@ -98,6 +98,37 @@ func TestFilterAdminListingsBySkinGroupOnly(t *testing.T) {
}
}
func TestFilterAdminListingsByMultipleSkinsRequiresAll(t *testing.T) {
items := []ListingDTO{
{
ID: 1,
AssetSummary: map[string]any{
"skin_groups": map[string]any{
"melee": []any{"坠星者", "暗星"},
"weapon": []any{"M7棱镜攻势S2"},
},
},
},
{
ID: 2,
AssetSummary: map[string]any{
"skin_groups": map[string]any{
"melee": []any{"坠星者"},
},
},
},
}
// 选了 "坠星者" 和 "暗星",只有同时具备两者的 ID=1 才能被筛选出,ID=2 仅有坠星者应被排除
filtered := filterAdminListings(items, AdminListQuery{
SkinName: []string{"坠星者", "暗星"},
})
if len(filtered) != 1 || filtered[0].ID != 1 {
t.Fatalf("expected only listing 1 having both skins, got %#v", filtered)
}
}
func TestApplyAdminListFiltersSmartRouting(t *testing.T) {
db := database.NewDryRunDB()
repo := &Repository{}
@@ -103,6 +103,22 @@ func sortPublicListings(items []ListingDTO, sortKey string) {
return a.PriceCent > b.PriceCent
case "coinDesc":
return a.HafCoinAmount > b.HafCoinAmount
case "coinAsc":
return a.HafCoinAmount < b.HafCoinAmount
case "ratioDesc", "ratio":
aRatio := ratioFromListing(a)
bRatio := ratioFromListing(b)
if aRatio != bRatio {
return aRatio > bRatio
}
return a.HafCoinAmount > b.HafCoinAmount
case "ratioAsc":
aRatio := ratioFromListing(a)
bRatio := ratioFromListing(b)
if aRatio != bRatio {
return aRatio < bRatio
}
return a.HafCoinAmount > b.HafCoinAmount
case "awmDesc":
aAmmo := resourceQuantity(a.AssetSummary, "awmAmmo")
bAmmo := resourceQuantity(b.AssetSummary, "awmAmmo")
@@ -110,6 +126,13 @@ func sortPublicListings(items []ListingDTO, sortKey string) {
return aAmmo > bAmmo
}
return a.HafCoinAmount > b.HafCoinAmount
case "awmAsc":
aAmmo := resourceQuantity(a.AssetSummary, "awmAmmo")
bAmmo := resourceQuantity(b.AssetSummary, "awmAmmo")
if aAmmo != bAmmo {
return aAmmo < bAmmo
}
return a.HafCoinAmount < b.HafCoinAmount
case "published":
return publicRecentLess(a, b)
case "recommended", "comprehensive", "":
@@ -178,6 +178,57 @@ func TestSortPublicListingsDefaultUsesShuffleKey(t *testing.T) {
}
}
func TestSortPublicListingsByRatio(t *testing.T) {
items := []ListingDTO{
{
ID: 1,
HafCoinAmount: 50_000_000,
PriceCent: 5_000,
AssetSummary: map[string]any{"publish_ratio": 20.0},
},
{
ID: 2,
HafCoinAmount: 50_000_000,
PriceCent: 2_500,
AssetSummary: map[string]any{"publish_ratio": 45.0},
},
{
ID: 3,
HafCoinAmount: 80_000_000,
PriceCent: 3_000,
AssetSummary: map[string]any{"publish_ratio": 35.0},
},
{
ID: 4,
HafCoinAmount: 100_000_000,
PriceCent: 4_000,
AssetSummary: map[string]any{"publish_ratio": 35.0},
},
}
sortPublicListings(items, "ratioDesc")
// 预期降序:ID=2 (45.0) > ID=4 (35.0, 100M 币更多) > ID=3 (35.0, 80M) > ID=1 (20.0)
expectedIDs := []uint64{2, 4, 3, 1}
for i, expected := range expectedIDs {
if items[i].ID != expected {
t.Fatalf("at index %d, expected listing %d, got %d (ratio=%.1f)", i, expected, items[i].ID, ratioFromListing(items[i]))
}
}
sortPublicListings(items, "ratioAsc")
// 预期升序:ID=1 (20.0) < ID=3 (35.0) < ID=4 (35.0) < ID=2 (45.0)
if items[0].ID != 1 || items[len(items)-1].ID != 2 {
t.Fatalf("expected ratioAsc items[0].ID=1 and items[last].ID=2, got order: %v", []uint64{items[0].ID, items[1].ID, items[2].ID, items[3].ID})
}
sortPublicListings(items, "coinAsc")
if items[0].HafCoinAmount > items[len(items)-1].HafCoinAmount {
t.Fatalf("expected coinAsc first item coin <= last item coin")
}
}
func TestIsNightAvailableSummaryUsesEarlyMorningResponse(t *testing.T) {
tests := []struct {
name string
@@ -146,7 +146,7 @@ func canListPublicWithSQL(query PublicListQuery) bool {
return false
}
switch query.Sort {
case "", "published", "recommended", "comprehensive", "priceAsc", "priceDesc", "coinDesc":
case "", "published", "recommended", "comprehensive", "priceAsc", "priceDesc", "coinDesc", "coinAsc":
return true
default:
return false
@@ -161,6 +161,8 @@ func applyPublicSQLSort(db *gorm.DB, sortKey string) *gorm.DB {
return db.Order("l.price_cent DESC, l.published_at DESC, l.id DESC")
case "coinDesc":
return db.Order("a.haf_coin_amount DESC, l.published_at DESC, l.id DESC")
case "coinAsc":
return db.Order("a.haf_coin_amount ASC, l.published_at DESC, l.id DESC")
case "published":
return db.Order("l.published_at DESC, l.id DESC")
default:
@@ -107,9 +107,29 @@ const assetFilterLabel = computed(() => {
levelSlotLabel(filters.load || ''),
].join('/')
})
const skinFilterActive = computed(() => Boolean(filters.skin_group || filters.skin_name))
const selectedSkinNames = computed<string[]>({
get() {
if (!filters.skin_name) return []
return filters.skin_name
.split(',')
.map(s => s.trim())
.filter(Boolean)
},
set(val: string[]) {
filters.skin_name = val.filter(Boolean).join(',')
},
})
const skinFilterActive = computed(() =>
Boolean(filters.skin_group || selectedSkinNames.value.length)
)
const skinFilterLabel = computed(() => {
if (filters.skin_name) return filters.skin_name
const names = selectedSkinNames.value
if (names.length === 1) return names[0]
if (names.length > 1) {
const joined = names.join('、')
return joined.length <= 14 ? joined : `${names[0]} 等${names.length}款`
}
if (filters.skin_group) {
return skinFilterGroups.value.find(group => group.key === filters.skin_group)?.title || '皮肤'
}
@@ -299,17 +319,39 @@ function clearAssetFilters(shouldQuery = false) {
if (shouldQuery) void queryListings()
}
function selectSkinFilter(group: string, name = '') {
filters.skin_group = group
filters.skin_name = name
function isSkinNameActive(name: string) {
return selectedSkinNames.value.includes(name)
}
function toggleSkinNameFilter(name: string) {
const current = selectedSkinNames.value
if (current.includes(name)) {
selectedSkinNames.value = current.filter(item => item !== name)
} else {
selectedSkinNames.value = [...current, name]
}
filters.skin_group = ''
}
function selectSkinGroupFilter(groupKey: string) {
selectedSkinNames.value = []
filters.skin_group = filters.skin_group === groupKey ? '' : groupKey
closeFilterPopover()
void queryListings()
}
function confirmSkinFilters() {
closeFilterPopover()
void queryListings()
}
function clearSkinFilters(shouldQuery = false) {
filters.skin_group = ''
filters.skin_name = ''
closeFilterPopover()
if (shouldQuery) void queryListings()
if (shouldQuery) {
closeFilterPopover()
void queryListings()
}
}
function insuranceSlotLabel(value: string) {
@@ -865,8 +907,8 @@ function formatQuantity(value: number) {
<button
class="admin-filter-reset"
type="button"
:class="{ active: !filters.skin_group && !filters.skin_name }"
@click="clearSkinFilters()"
:class="{ active: !filters.skin_group && !selectedSkinNames.length }"
@click="clearSkinFilters(true)"
>
全部皮肤
</button>
@@ -880,8 +922,10 @@ function formatQuantity(value: number) {
<strong>{{ group.title }}</strong>
<button
type="button"
:class="{ active: filters.skin_group === group.key && !filters.skin_name }"
@click="selectSkinFilter(group.key)"
:class="{
active: filters.skin_group === group.key && !selectedSkinNames.length,
}"
@click="selectSkinGroupFilter(group.key)"
>
全部{{ group.title.replace('干员', '') }}
</button>
@@ -892,15 +936,24 @@ function formatQuantity(value: number) {
:key="`${group.key}-${skin}`"
type="button"
:class="{
active: filters.skin_group === group.key && filters.skin_name === skin,
active: isSkinNameActive(skin),
}"
@click="selectSkinFilter(group.key, skin)"
@click="toggleSkinNameFilter(skin)"
>
{{ skin }}
</button>
</div>
</div>
</div>
<div class="admin-skin-filter-footer">
<span class="selected-hint">已选 {{ selectedSkinNames.length }} 款皮肤</span>
<div class="footer-actions">
<el-button size="small" link @click="clearSkinFilters(false)">清空</el-button>
<el-button size="small" type="primary" @click="confirmSkinFilters"
>确定</el-button
>
</div>
</div>
</el-popover>
</el-form-item>
</el-form>
@@ -11,6 +11,7 @@ import {
totalRangeOptions,
fireLevelRangeOptions,
dailyLossRangeOptions,
secretKdRangeOptions,
isRangeSelected,
} from '@/features/listings/composables/useFilterOptions'
import type { ListingPublishOptions } from '@/features/listings/api/listingOptions'
@@ -72,6 +73,7 @@ function toggleRange(
| 'minTotal'
| 'minFireLevel'
| 'minDailyLoss'
| 'minSecretKd'
| 'minAwmAmmo',
maxKey:
| 'maxCoin'
@@ -81,6 +83,7 @@ function toggleRange(
| 'maxTotal'
| 'maxFireLevel'
| 'maxDailyLoss'
| 'maxSecretKd'
| 'maxAwmAmmo',
option: RangeOption
) {
@@ -102,6 +105,7 @@ function isRangeActive(
| 'minTotal'
| 'minFireLevel'
| 'minDailyLoss'
| 'minSecretKd'
| 'minAwmAmmo',
maxKey:
| 'maxCoin'
@@ -111,6 +115,7 @@ function isRangeActive(
| 'maxTotal'
| 'maxFireLevel'
| 'maxDailyLoss'
| 'maxSecretKd'
| 'maxAwmAmmo',
option: RangeOption
) {
@@ -375,6 +380,22 @@ const redSkinGroup = computed(() =>
</div>
</div>
<div class="filter-field">
<label>绝密KD</label>
<div class="pill-row">
<button
v-for="item in secretKdRangeOptions.filter(o => o.label !== '全部')"
:key="`kd-${item.label}`"
type="button"
class="pill-chip"
:class="{ active: isRangeActive('minSecretKd', 'maxSecretKd', item) }"
@click="toggleRange('minSecretKd', 'maxSecretKd', item)"
>
{{ item.label }}
</button>
</div>
</div>
<div class="filter-field">
<label>段位</label>
<div class="pill-row">
@@ -2,10 +2,12 @@ import { describe, it, expect } from 'vitest'
import {
awmRangeOptions,
depositRangeOptions,
defaultRangePresets,
isRangeSelected,
moneyRangeOptions,
rangeLabel,
ratioRangeOptions,
secretKdRangeOptions,
} from '@/features/listings/composables/useFilterOptions'
describe('useFilterOptions', () => {
@@ -65,4 +67,32 @@ describe('useFilterOptions', () => {
max: undefined,
})
})
it('应该包含绝密KD预设选项', () => {
expect(secretKdRangeOptions.map(option => option.label)).toEqual([
'全部',
'1-2',
'2-3',
'3-5',
'5以上',
])
})
it('defaultRangePresets 应该为移动端提供统一的日消耗与绝密KD档位', () => {
expect(defaultRangePresets.dailyLoss).toEqual([
{ label: '≤10', min: '', max: '10' },
{ label: '10-20', min: '10', max: '20' },
{ label: '20-30', min: '20', max: '30' },
{ label: '30-40', min: '30', max: '40' },
{ label: '40以上', min: '40', max: '' },
])
expect(defaultRangePresets.secretKd).toEqual([
{ label: '1-2', min: '1', max: '2' },
{ label: '2-3', min: '2', max: '3' },
{ label: '3-5', min: '3', max: '5' },
{ label: '5以上', min: '5', max: '' },
])
expect(defaultRangePresets.price.length).toBeGreaterThan(0)
expect(defaultRangePresets.coin.length).toBeGreaterThan(0)
})
})
@@ -1,5 +1,9 @@
import { describe, it, expect } from 'vitest'
import { useHomeFilters } from '@/features/listings/composables/useHomeFilters'
import {
useHomeFilters,
mobileFiltersToHomeFilters,
} from '@/features/listings/composables/useHomeFilters'
import { buildListingQueryFromFilters } from '@/features/listings/composables/useListingQuery'
import { ref } from 'vue'
import { emptyListingPublishOptions } from '@/features/listings/api/listingOptions'
@@ -90,4 +94,110 @@ describe('useHomeFilters', () => {
expect(filters.minCoin).toBe(100)
expect(filters.maxCoin).toBe(500)
})
it('应该支持绝密KD与日消耗并正确统计高级筛选数量', () => {
const publishOptions = ref(emptyListingPublishOptions)
const listings = ref([])
const { filters, advancedActiveCount, setDailyLossRange, setSecretKdRange, resetFilters } =
useHomeFilters(publishOptions, listings)
expect(advancedActiveCount.value).toBe(0)
setDailyLossRange(10, 20)
expect(filters.minDailyLoss).toBe(10)
expect(filters.maxDailyLoss).toBe(20)
expect(advancedActiveCount.value).toBe(1)
setSecretKdRange(2, 3)
expect(filters.minSecretKd).toBe(2)
expect(filters.maxSecretKd).toBe(3)
expect(advancedActiveCount.value).toBe(2)
resetFilters()
expect(filters.minDailyLoss).toBeUndefined()
expect(filters.maxDailyLoss).toBeUndefined()
expect(filters.minSecretKd).toBeUndefined()
expect(filters.maxSecretKd).toBeUndefined()
expect(advancedActiveCount.value).toBe(0)
})
describe('mobileFiltersToHomeFilters & buildListingQueryFromFilters', () => {
it('应该正确转换移动端结构为统一 HomeFilters 并生成对齐的查询参数', () => {
const selectedFilters = {
server: ['微信区'],
region: ['北京', '上海'],
login: ['账密'],
rank: ['大师'],
insurance: ['3*3'],
melee: ['小刀'],
}
const rangeFilters = {
price: { min: '50', max: '100' },
coin: { min: '100', max: '300' },
dailyLoss: { min: '10', max: '20' },
secretKd: { min: '2', max: '3' },
resource_awmAmmo: { min: '50', max: '100' },
resource_testItem: { min: '1', max: '5' },
}
const homeFilters = mobileFiltersToHomeFilters(selectedFilters, rangeFilters, '搜索关键字', [
'melee',
])
expect(homeFilters.keyword).toBe('搜索关键字')
expect(homeFilters.server).toBe('微信区')
expect(homeFilters.region).toEqual(['北京', '上海'])
expect(homeFilters.loginMethod).toEqual(['账密'])
expect(homeFilters.rank).toEqual(['大师'])
expect(homeFilters.insurance).toEqual(['3*3'])
expect(homeFilters.skinGroups).toEqual(['melee'])
expect(homeFilters.skinNames).toEqual(['小刀'])
expect(homeFilters.minPrice).toBe(50)
expect(homeFilters.maxPrice).toBe(100)
expect(homeFilters.minCoin).toBe(100)
expect(homeFilters.maxCoin).toBe(300)
expect(homeFilters.minDailyLoss).toBe(10)
expect(homeFilters.maxDailyLoss).toBe(20)
expect(homeFilters.minSecretKd).toBe(2)
expect(homeFilters.maxSecretKd).toBe(3)
expect(homeFilters.minAwmAmmo).toBe(50)
expect(homeFilters.maxAwmAmmo).toBe(100)
expect(homeFilters.resources).toEqual({ testItem: { min: 1, max: 5 } })
const query = buildListingQueryFromFilters(homeFilters, {
page: 1,
pageSize: 10,
sort: 'recommended',
zone: 'all',
})
expect(query).toMatchObject({
page: 1,
page_size: 10,
keyword: '搜索关键字',
sort: 'recommended',
zone: 'all',
server: '微信区',
region: '北京,上海',
login_method: '账密',
rank: '大师',
insurance: '3*3',
skin_group: 'melee',
skin_name: '小刀',
min_price: 50,
max_price: 100,
min_coin: 100,
max_coin: 300,
min_daily_loss: 10,
max_daily_loss: 20,
min_secret_kd: 2,
max_secret_kd: 3,
resource_awmAmmo_min: 50,
resource_awmAmmo_max: 100,
resource_testItem_min: 1,
resource_testItem_max: 5,
})
})
})
})
@@ -71,6 +71,44 @@ export const dailyLossRangeOptions = [
{ label: '40以上', min: 40, max: undefined },
]
export const secretKdRangeOptions = [
{ label: '全部', min: undefined, max: undefined },
{ label: '1-2', min: 1, max: 2 },
{ label: '2-3', min: 2, max: 3 },
{ label: '3-5', min: 3, max: 5 },
{ label: '5以上', min: 5, max: undefined },
]
export interface RangePresetOption {
label: string
min: string
max: string
}
export function toRangePresets(
options: Array<{ label: string; min?: number; max?: number }>
): RangePresetOption[] {
return options
.filter(option => option.label !== '全部')
.map(option => ({
label: option.label,
min: option.min === undefined ? '' : String(option.min),
max: option.max === undefined ? '' : String(option.max),
}))
}
export const defaultRangePresets: Record<string, RangePresetOption[]> = {
price: toRangePresets(moneyRangeOptions),
coin: toRangePresets(coinRangeOptions),
ratio: toRangePresets(ratioRangeOptions),
deposit: toRangePresets(depositRangeOptions),
total: toRangePresets(totalRangeOptions),
fireLevel: toRangePresets(fireLevelRangeOptions),
dailyLoss: toRangePresets(dailyLossRangeOptions),
secretKd: toRangePresets(secretKdRangeOptions),
resource_awmAmmo: toRangePresets(awmRangeOptions),
}
export function rangeLabel(min: number | undefined, max: number | undefined, fallback: string) {
if (min !== undefined && max !== undefined) return `${min}-${max}`
if (min !== undefined) return `${min}+`
@@ -55,6 +55,13 @@ export interface HomeFilters {
/** 日消耗(M/天)区间 */
minDailyLoss: number | undefined
maxDailyLoss: number | undefined
/** 绝密KD区间 */
minSecretKd?: number | undefined
maxSecretKd?: number | undefined
/** 皮肤分类列表 */
skinGroups?: string[] | undefined
/** 动态资源区间 */
resources?: Record<string, { min?: number; max?: number }> | undefined
/** AWM 子弹数量区间 */
minAwmAmmo: number | undefined
maxAwmAmmo: number | undefined
@@ -66,6 +73,107 @@ export interface SkinFilterGroup {
options: string[]
}
function parseOptionalNumber(value: string | undefined): number | undefined {
if (!value || value.trim() === '') return undefined
const num = Number(value.trim())
return Number.isFinite(num) ? num : undefined
}
/** 将移动端筛选抽屉的 (selectedFilters, rangeFilters) 映射为统一的 HomeFilters 模型 */
export function mobileFiltersToHomeFilters(
selectedFilters: Record<string, string[]>,
rangeFilters: Record<string, { min: string; max: string }>,
keyword = '',
knownSkinGroupKeys: string[] = []
): HomeFilters {
const isSkinGroup = (key: string) =>
knownSkinGroupKeys.includes(key) || key === 'melee' || key === 'operatorRed' || key === 'weapon'
const skinGroups: string[] = []
const skinNames: string[] = []
let server = ''
const region: string[] = []
const loginMethod: string[] = []
const rank: string[] = []
const insurance: string[] = []
const stamina: string[] = []
const load: string[] = []
for (const [key, values] of Object.entries(selectedFilters)) {
const validValues = values.map(v => v.trim()).filter(Boolean)
if (!validValues.length) continue
if (key === 'server') {
server = validValues.join(',')
} else if (key === 'region') {
region.push(...validValues)
} else if (key === 'login') {
loginMethod.push(...validValues)
} else if (key === 'rank') {
rank.push(...validValues)
} else if (key === 'insurance') {
insurance.push(...validValues)
} else if (key === 'stamina') {
stamina.push(...validValues)
} else if (key === 'load') {
load.push(...validValues)
} else if (isSkinGroup(key)) {
skinGroups.push(key)
skinNames.push(...validValues)
}
}
const resources: Record<string, { min?: number; max?: number }> = {}
let minAwmAmmo: number | undefined
let maxAwmAmmo: number | undefined
for (const [key, range] of Object.entries(rangeFilters)) {
if (key.startsWith('resource_')) {
const resKey = key.replace('resource_', '')
const min = parseOptionalNumber(range.min)
const max = parseOptionalNumber(range.max)
if (resKey === 'awmAmmo') {
minAwmAmmo = min
maxAwmAmmo = max
} else if (min !== undefined || max !== undefined) {
resources[resKey] = { min, max }
}
}
}
return {
keyword,
server,
region,
loginMethod,
rank,
insurance,
stamina,
load,
skinNames,
skinGroups: skinGroups.length ? skinGroups : undefined,
resources: Object.keys(resources).length ? resources : undefined,
minCoin: parseOptionalNumber(rangeFilters.coin?.min),
maxCoin: parseOptionalNumber(rangeFilters.coin?.max),
minRatio: parseOptionalNumber(rangeFilters.ratio?.min),
maxRatio: parseOptionalNumber(rangeFilters.ratio?.max),
minPrice: parseOptionalNumber(rangeFilters.price?.min),
maxPrice: parseOptionalNumber(rangeFilters.price?.max),
minDeposit: parseOptionalNumber(rangeFilters.deposit?.min),
maxDeposit: parseOptionalNumber(rangeFilters.deposit?.max),
minTotal: parseOptionalNumber(rangeFilters.total?.min),
maxTotal: parseOptionalNumber(rangeFilters.total?.max),
minFireLevel: parseOptionalNumber(rangeFilters.fireLevel?.min),
maxFireLevel: parseOptionalNumber(rangeFilters.fireLevel?.max),
minDailyLoss: parseOptionalNumber(rangeFilters.dailyLoss?.min),
maxDailyLoss: parseOptionalNumber(rangeFilters.dailyLoss?.max),
minSecretKd: parseOptionalNumber(rangeFilters.secretKd?.min),
maxSecretKd: parseOptionalNumber(rangeFilters.secretKd?.max),
minAwmAmmo,
maxAwmAmmo,
}
}
export function useHomeFilters(
publishOptions: Ref<ListingPublishOptions>,
listings: Ref<PublicListingItem[]>
@@ -94,6 +202,8 @@ export function useHomeFilters(
maxFireLevel: undefined,
minDailyLoss: undefined,
maxDailyLoss: undefined,
minSecretKd: undefined,
maxSecretKd: undefined,
minAwmAmmo: undefined,
maxAwmAmmo: undefined,
})
@@ -102,10 +212,11 @@ export function useHomeFilters(
const advancedFiltersOpen = ref(false)
const regionOptions = computed(() =>
uniqueOptions([
...publishOptions.value.region_options,
...listings.value.flatMap(item => assetRegions(item)),
])
uniqueOptions(
publishOptions.value.region_options.length > 0
? publishOptions.value.region_options
: listings.value.flatMap(item => assetRegions(item))
)
)
const loginMethodOptions = computed(() =>
@@ -159,6 +270,7 @@ export function useHomeFilters(
if (filters.minTotal !== undefined || filters.maxTotal !== undefined) count += 1
if (filters.minFireLevel !== undefined || filters.maxFireLevel !== undefined) count += 1
if (filters.minDailyLoss !== undefined || filters.maxDailyLoss !== undefined) count += 1
if (filters.minSecretKd !== undefined || filters.maxSecretKd !== undefined) count += 1
return count
})
@@ -190,6 +302,8 @@ export function useHomeFilters(
filters.maxFireLevel = undefined
filters.minDailyLoss = undefined
filters.maxDailyLoss = undefined
filters.minSecretKd = undefined
filters.maxSecretKd = undefined
filters.minAwmAmmo = undefined
filters.maxAwmAmmo = undefined
}
@@ -249,6 +363,11 @@ export function useHomeFilters(
filters.maxDailyLoss = max
}
function setSecretKdRange(min: number | undefined, max: number | undefined) {
filters.minSecretKd = min
filters.maxSecretKd = max
}
function setAwmAmmoRange(min: number | undefined, max: number | undefined) {
filters.minAwmAmmo = min
filters.maxAwmAmmo = max
@@ -302,6 +421,7 @@ export function useHomeFilters(
setTotalRange,
setFireLevelRange,
setDailyLossRange,
setSecretKdRange,
setAwmAmmoRange,
toggleRange,
setFilterPopover,
@@ -4,6 +4,65 @@ import type { HomeFilters } from './useHomeFilters'
const homePageSize = 12
function joinCSV(values: string[] | undefined) {
if (!values?.length) return undefined
return values.filter(Boolean).join(',') || undefined
}
export function buildListingQueryFromFilters(
filters: HomeFilters,
options: {
page: number
pageSize: number
sort?: string
zone?: string
}
): PublicListingQuery {
const query: PublicListingQuery = {
page: options.page,
page_size: options.pageSize,
keyword: filters.keyword?.trim() || undefined,
sort: options.sort,
zone: options.zone,
server: filters.server || undefined,
region: joinCSV(filters.region),
login_method: joinCSV(filters.loginMethod),
rank: joinCSV(filters.rank),
insurance: joinCSV(filters.insurance),
stamina: joinCSV(filters.stamina),
load: joinCSV(filters.load),
skin_group: joinCSV(filters.skinGroups),
skin_name: joinCSV(filters.skinNames),
min_coin: filters.minCoin,
max_coin: filters.maxCoin,
min_ratio: filters.minRatio,
max_ratio: filters.maxRatio,
min_price: filters.minPrice,
max_price: filters.maxPrice,
min_deposit: filters.minDeposit,
max_deposit: filters.maxDeposit,
min_total: filters.minTotal,
max_total: filters.maxTotal,
min_fire_level: filters.minFireLevel,
max_fire_level: filters.maxFireLevel,
min_daily_loss: filters.minDailyLoss,
max_daily_loss: filters.maxDailyLoss,
min_secret_kd: filters.minSecretKd,
max_secret_kd: filters.maxSecretKd,
resource_awmAmmo_min: filters.minAwmAmmo,
resource_awmAmmo_max: filters.maxAwmAmmo,
}
if (filters.resources) {
for (const [key, range] of Object.entries(filters.resources)) {
if (range.min !== undefined) query[`resource_${key}_min`] = range.min
if (range.max !== undefined) query[`resource_${key}_max`] = range.max
}
}
return query
}
export function useListingQuery(
filters: HomeFilters,
sortBy: Ref<string>,
@@ -18,43 +77,13 @@ export function useListingQuery(
const hasMoreListings = ref(true)
let listingRequestSeq = 0
function joinCSV(values: string[]) {
return values.filter(Boolean).join(',') || undefined
}
function buildListingQuery(page: number): PublicListingQuery {
return {
return buildListingQueryFromFilters(filters, {
page,
page_size: homePageSize,
keyword: filters.keyword.trim(),
pageSize: homePageSize,
sort: sortBy.value,
zone: activeZone.value,
server: filters.server,
region: joinCSV(filters.region),
login_method: joinCSV(filters.loginMethod),
rank: joinCSV(filters.rank),
insurance: joinCSV(filters.insurance),
stamina: joinCSV(filters.stamina),
load: joinCSV(filters.load),
// 多选皮肤名;后端 skin_name CSV 为 AND 匹配(账号需包含所有选中皮肤)
skin_name: joinCSV(filters.skinNames),
min_coin: filters.minCoin,
max_coin: filters.maxCoin,
min_ratio: filters.minRatio,
max_ratio: filters.maxRatio,
min_price: filters.minPrice,
max_price: filters.maxPrice,
min_deposit: filters.minDeposit,
max_deposit: filters.maxDeposit,
min_total: filters.minTotal,
max_total: filters.maxTotal,
min_fire_level: filters.minFireLevel,
max_fire_level: filters.maxFireLevel,
min_daily_loss: filters.minDailyLoss,
max_daily_loss: filters.maxDailyLoss,
resource_awmAmmo_min: filters.minAwmAmmo,
resource_awmAmmo_max: filters.maxAwmAmmo,
}
})
}
function listingQuerySignature() {
+123 -12
View File
@@ -69,14 +69,78 @@ const awRecycleDialogVisible = ref(false)
const cooperationFeedback = ref<CooperationFeedbackConfig>({ ...defaultCooperationFeedbackConfig })
const cooperationFeedbackDialogVisible = ref(false)
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions)
interface SortButtonItem {
key: string
label: string
hasDirection?: boolean
defaultSort?: string
altSort?: string
}
const sortBy = ref('recommended')
const sortOptions = [
{ value: 'recommended', label: '综合推荐' },
{ value: 'coinDesc', label: '哈夫币' },
{ value: 'awmDesc', label: 'AWM数量' },
{ value: 'priceAsc', label: '价格最低' },
{ value: 'priceDesc', label: '价格最高' },
] as const
const sortButtons: SortButtonItem[] = [
{ key: 'recommended', label: '综合推荐' },
{
key: 'ratio',
label: '比例',
hasDirection: true,
defaultSort: 'ratioDesc',
altSort: 'ratioAsc',
},
{
key: 'coin',
label: '哈夫币',
hasDirection: true,
defaultSort: 'coinDesc',
altSort: 'coinAsc',
},
{
key: 'awm',
label: 'AWM数量',
hasDirection: true,
defaultSort: 'awmDesc',
altSort: 'awmAsc',
},
{
key: 'price',
label: '价格',
hasDirection: true,
defaultSort: 'priceAsc',
altSort: 'priceDesc',
},
]
function isSortActive(item: SortButtonItem): boolean {
if (!item.hasDirection) {
return sortBy.value === 'recommended' || !sortBy.value
}
return sortBy.value === item.defaultSort || sortBy.value === item.altSort
}
function getSortDirection(item: SortButtonItem): 'asc' | 'desc' | null {
if (!item.hasDirection) return null
if (sortBy.value === item.defaultSort) {
return item.defaultSort?.endsWith('Asc') ? 'asc' : 'desc'
}
if (sortBy.value === item.altSort) {
return item.altSort?.endsWith('Asc') ? 'asc' : 'desc'
}
return null
}
function handleSortClick(item: SortButtonItem) {
if (!item.hasDirection) {
sortBy.value = 'recommended'
return
}
if (sortBy.value === item.defaultSort) {
sortBy.value = item.altSort!
} else if (sortBy.value === item.altSort) {
sortBy.value = item.defaultSort!
} else {
sortBy.value = item.defaultSort!
}
}
const activeZone = ref('all')
const route = useRoute()
const router = useRouter()
@@ -576,14 +640,18 @@ if (!restored) {
/>
<div class="list-actions sort-bar" role="group" aria-label="排序方式">
<button
v-for="item in sortOptions"
:key="item.value"
v-for="item in sortButtons"
:key="item.key"
type="button"
class="sort-chip"
:class="{ active: sortBy === item.value }"
@click="sortBy = item.value"
:class="{ active: isSortActive(item) }"
@click="handleSortClick(item)"
>
{{ item.label }}
<span>{{ item.label }}</span>
<span v-if="item.hasDirection" class="sort-arrows" aria-hidden="true">
<i class="arrow-up" :class="{ active: getSortDirection(item) === 'asc' }" />
<i class="arrow-down" :class="{ active: getSortDirection(item) === 'desc' }" />
</span>
</button>
</div>
</div>
@@ -760,6 +828,9 @@ if (!restored) {
}
.sort-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 0 12px;
border: none;
@@ -786,6 +857,46 @@ if (!restored) {
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
.sort-arrows {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
margin-left: 4px;
}
.sort-arrows .arrow-up,
.sort-arrows .arrow-down {
display: block;
width: 0;
height: 0;
border-left: 3px solid transparent;
border-right: 3px solid transparent;
transition: border-color 0.15s ease;
}
.sort-arrows .arrow-up {
border-bottom: 3.5px solid #cbd5e1;
}
.sort-arrows .arrow-up.active {
border-bottom-color: #ff6a00;
}
.sort-arrows .arrow-down {
border-top: 3.5px solid #cbd5e1;
}
.sort-arrows .arrow-down.active {
border-top-color: #ff6a00;
}
.sort-chip:hover .sort-arrows .arrow-up:not(.active),
.sort-chip:hover .sort-arrows .arrow-down:not(.active) {
border-color: #94a3b8 transparent;
}
.enhanced-desktop-list {
display: grid;
gap: 16px;
@@ -40,7 +40,9 @@ import {
storeHomeQuery,
type HomeQueryValue,
} from '@/features/listings/composables/useHomeFilterQuery'
import { depositRangeOptions } from '@/features/listings/composables/useFilterOptions'
import { defaultRangePresets } from '@/features/listings/composables/useFilterOptions'
import { mobileFiltersToHomeFilters } from '@/features/listings/composables/useHomeFilters'
import { buildListingQueryFromFilters } from '@/features/listings/composables/useListingQuery'
import {
getListingChips,
getListingDisplayPrice,
@@ -121,13 +123,23 @@ const mobilePageSize = 10
let listingRequestSeq = 0
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
const sortOptions = [
{ key: 'recommended', label: '综合推荐' },
{ key: 'coinDesc', label: '哈夫币' },
{ key: 'awmDesc', label: 'AWM数量' },
{ key: 'priceAsc', label: '价格最低' },
{ key: 'priceDesc', label: '价格最高' },
{ key: 'published', label: '最新发布' },
interface MobileSortOption {
key: string
label: string
shortLabel: string
}
const sortOptions: MobileSortOption[] = [
{ key: 'recommended', label: '综合推荐', shortLabel: '综合推荐' },
{ key: 'ratioDesc', label: '比例从高到低', shortLabel: '比例最高' },
{ key: 'ratioAsc', label: '比例从低到高', shortLabel: '比例最低' },
{ key: 'priceAsc', label: '价格从低到高', shortLabel: '价格最低' },
{ key: 'priceDesc', label: '价格从高到低', shortLabel: '价格最高' },
{ key: 'coinDesc', label: '哈夫币从多到少', shortLabel: '哈夫币最多' },
{ key: 'coinAsc', label: '哈夫币从少到多', shortLabel: '哈夫币最少' },
{ key: 'awmDesc', label: 'AWM数量从多到少', shortLabel: 'AWM最多' },
{ key: 'awmAsc', label: 'AWM数量从少到多', shortLabel: 'AWM最少' },
{ key: 'published', label: '最新发布', shortLabel: '最新发布' },
]
const zoneOptions = [
@@ -139,54 +151,11 @@ const zoneOptions = [
{ key: 'highCoin', label: '高币', hint: '100M以上' },
]
/** 押金档位直接复用首页高级筛选项,保证移动端与桌面端完全一致 */
const depositPresets = depositRangeOptions
.filter(option => option.label !== '全部')
.map(option => ({
label: option.label,
min: option.min === undefined ? '' : String(option.min),
max: option.max === undefined ? '' : String(option.max),
}))
const rangePresets: Record<string, Array<{ label: string; min: string; max: string }>> = {
price: [
{ label: '50-100', min: '50', max: '100' },
{ label: '100-200', min: '100', max: '200' },
{ label: '200以上', min: '200', max: '' },
],
coin: [
{ label: '0-100', min: '0', max: '100' },
{ label: '100-300', min: '100', max: '300' },
{ label: '300-500', min: '300', max: '500' },
{ label: '500以上', min: '500', max: '' },
],
ratio: [
{ label: '30-40', min: '30', max: '40' },
{ label: '40以上', min: '40', max: '' },
],
deposit: depositPresets,
total: [
{ label: '0-500', min: '0', max: '500' },
{ label: '500-1000', min: '500', max: '1000' },
{ label: '1000-2000', min: '1000', max: '2000' },
{ label: '2000-5000', min: '2000', max: '5000' },
],
fireLevel: [
{ label: '38-50', min: '38', max: '50' },
{ label: '50-60', min: '50', max: '60' },
{ label: '60-70', min: '60', max: '70' },
{ label: '70以上', min: '70', max: '' },
],
resource_awmAmmo: [
{ 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 rangePresets = defaultRangePresets
const activeSortLabel = computed(
() => sortOptions.find(option => option.key === activeSort.value)?.label || '综合排序'
() => sortOptions.find(option => option.key === activeSort.value)?.shortLabel || '综合推荐'
)
async function handleSupportClick() {
@@ -216,10 +185,11 @@ const loginMethodFilterOptions = computed(() =>
)
const regionFilterOptions = computed(() =>
uniqueOptions([
...publishOptions.value.region_options,
...listings.value.flatMap(item => assetRegions(item)),
])
uniqueOptions(
publishOptions.value.region_options.length > 0
? publishOptions.value.region_options
: listings.value.flatMap(item => assetRegions(item))
)
)
const filterSections = computed<FilterSection[]>(() => [
@@ -248,7 +218,7 @@ const filterSections = computed<FilterSection[]>(() => [
maxPlaceholder: '最高比例',
},
{ key: 'server', title: '区服', type: 'chips', options: serverFilterOptions.value },
{ key: 'region', title: '资产地区', type: 'chips', options: regionFilterOptions.value },
{ key: 'region', title: 'IP地区', type: 'chips', options: regionFilterOptions.value },
{ key: 'login', title: '上号方式', type: 'chips', options: loginMethodFilterOptions.value },
{
key: 'insurance',
@@ -279,6 +249,14 @@ const filterSections = computed<FilterSection[]>(() => [
minPlaceholder: '最低',
maxPlaceholder: '最高',
},
{
key: 'dailyLoss',
title: '日消耗',
type: 'range',
unit: 'M/天',
minPlaceholder: '最低',
maxPlaceholder: '最高',
},
{
key: 'fireLevel',
title: '等级',
@@ -583,6 +561,23 @@ function toggleSortPanel() {
}
function selectSort(sortKey: string) {
if (activeSort.value === sortKey) {
const flipMap: Record<string, string> = {
ratioDesc: 'ratioAsc',
ratioAsc: 'ratioDesc',
priceAsc: 'priceDesc',
priceDesc: 'priceAsc',
coinDesc: 'coinAsc',
coinAsc: 'coinDesc',
awmDesc: 'awmAsc',
awmAsc: 'awmDesc',
}
if (flipMap[sortKey]) {
activeSort.value = flipMap[sortKey]
sortOpen.value = false
return
}
}
activeSort.value = sortKey
sortOpen.value = false
}
@@ -602,65 +597,18 @@ function clearFilters() {
}
function buildListingQuery(page: number): PublicListingQuery {
const query: PublicListingQuery = {
const homeFilters = mobileFiltersToHomeFilters(
selectedFilters.value,
rangeFilters.value,
searchValue.value,
publishOptions.value.skin_groups.map(group => group.key)
)
return buildListingQueryFromFilters(homeFilters, {
page,
page_size: mobilePageSize,
keyword: searchValue.value.trim(),
pageSize: mobilePageSize,
sort: activeSort.value,
zone: activeZone.value,
}
const skinGroups: string[] = []
const skinNames: string[] = []
for (const [key, values] of Object.entries(selectedFilters.value)) {
const value = values.filter(Boolean).join(',')
if (!value) continue
if (key === 'server') query.server = value
else if (key === 'region') query.region = value
else if (key === 'login') query.login_method = value
else if (key === 'insurance') query.insurance = value
else if (key === 'stamina') query.stamina = value
else if (key === 'load') query.load = value
else if (key === 'rank') query.rank = value
else if (isSkinGroupKey(key)) {
skinGroups.push(key)
skinNames.push(...values)
}
}
if (skinGroups.length) query.skin_group = skinGroups.join(',')
if (skinNames.length) query.skin_name = skinNames.join(',')
for (const [key, range] of Object.entries(rangeFilters.value)) {
if (!range.min && !range.max) continue
const min = parseOptionalNumber(range.min)
const max = parseOptionalNumber(range.max)
if (key === 'price') {
query.min_price = min
query.max_price = max
} else if (key === 'coin') {
query.min_coin = min
query.max_coin = max
} else if (key === 'ratio') {
query.min_ratio = min
query.max_ratio = max
} else if (key === 'secretKd') {
query.min_secret_kd = min
query.max_secret_kd = max
} else if (key === 'deposit') {
query.min_deposit = min
query.max_deposit = max
} else if (key === 'total') {
query.min_total = min
query.max_total = max
} else if (key === 'fireLevel') {
query.min_fire_level = min
query.max_fire_level = max
} else if (key.startsWith('resource_')) {
const resourceKey = key.replace('resource_', '')
query[`resource_${resourceKey}_min`] = min
query[`resource_${resourceKey}_max`] = max
}
}
return query
})
}
function filterQuerySignature() {
@@ -672,12 +620,6 @@ function filterQuerySignature() {
})
}
function parseOptionalNumber(value: string) {
if (value === '') return undefined
const number = Number(value)
return Number.isFinite(number) ? number : undefined
}
function applyRouteQueryToMobileState() {
const storedQuery = readStoredHomeQuery(mobileHomeFilterStorageKey)
const query = hasHomeQueryValues(route.query, mobileHomeFilterQueryKeys)
@@ -776,10 +718,6 @@ function scrollToTop() {
homeCache.saveScrollTop(0)
}
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
@@ -216,16 +216,30 @@
/* ========== Popups ========== */
.mobile-popup-form {
max-height: 85%;
padding-bottom: env(safe-area-inset-bottom);
max-height: 85vh;
max-height: 85dvh;
overflow: hidden;
box-sizing: border-box;
}
.popup-inner {
display: flex;
flex-direction: column;
height: 100%;
max-height: 85vh;
max-height: 85dvh;
overflow: hidden;
box-sizing: border-box;
}
.popup-header {
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #edf2f7;
background: #fff;
}
.popup-header h3 {
@@ -235,16 +249,50 @@
}
.popup-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
margin: -8px -8px -8px 0;
padding: 0;
border: none;
background: transparent;
font-size: 16px;
color: #a0aec0;
color: #94a3b8;
cursor: pointer;
border-radius: 50%;
transition:
background-color 0.15s ease,
color 0.15s ease;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.popup-close:active {
background-color: #f1f5f9;
color: #475569;
}
.popup-body {
flex: 1 1 auto;
min-height: 0;
padding: 16px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.popup-footer {
flex-shrink: 0;
padding: 10px 16px;
padding-bottom: calc(10px + env(safe-area-inset-bottom));
background: #fff;
border-top: 1px solid #edf2f7;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
}
.popup-submit {
margin: 0;
}
.form-hint-info {
@@ -254,10 +302,100 @@
margin: 0 0 12px 0;
}
.counter-input-card {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 14px;
}
.counter-input-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 2px 0;
}
.counter-label {
font-size: 14px;
font-weight: 600;
color: #1e293b;
white-space: nowrap;
flex-shrink: 0;
}
.counter-input-wrapper {
flex: 1;
max-width: 170px;
display: flex;
align-items: center;
border: 1.5px solid #cbd5e1;
border-radius: 8px;
background: #ffffff;
padding: 6px 12px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
transition:
border-color 0.15s ease,
box-shadow 0.15s ease,
background-color 0.15s ease;
}
.counter-input-wrapper:hover {
border-color: #94a3b8;
}
.counter-input-wrapper:focus-within {
border-color: #ff6a00;
background: #ffffff;
box-shadow: 0 0 0 3px rgba(255, 106, 0, 0.15);
}
.counter-input-control {
flex: 1;
min-width: 0;
border: none;
background: transparent;
outline: none;
font-size: 16px;
font-weight: 600;
color: #0f172a;
text-align: right;
padding: 0;
}
.counter-unit {
margin-left: 6px;
font-size: 13px;
color: #64748b;
font-weight: 600;
user-select: none;
}
.popup-field {
background: #f7fafc;
background: #f8fafc;
border: 1.5px solid #e2e8f0;
border-radius: 8px;
margin-bottom: 12px;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease,
background-color 0.15s ease;
}
.popup-field:focus-within {
border-color: #ff6a00;
background: #ffffff;
box-shadow: 0 0 0 3px rgba(255, 106, 0, 0.15);
}
/* iOS Safari:font-size < 16px 时聚焦会自动放大整个页面 */
.mobile-popup-form input,
.mobile-popup-form textarea,
.popup-field :deep(.van-field__control),
.custom-inline-input,
.custom-select {
font-size: 16px !important;
}
.evidence-upload-row {
@@ -269,10 +407,6 @@
margin-bottom: 16px;
}
.popup-submit {
margin-top: 10px;
}
.custom-select {
border: none;
background: transparent;
@@ -114,9 +114,18 @@ const {
ordersPath: '/m/orders',
chatPathPrefix: '/m/chats/',
startCashier: (payment, onPaid) => cashier.openMobilePaymentCashier(payment, onPaid),
onCounterCheckoutSuccess: () => (showCounterPopup.value = false),
onRejectCheckoutSuccess: () => (showRejectPopup.value = false),
onCreateDisputeSuccess: () => (showDisputePopup.value = false),
onCounterCheckoutSuccess: () => {
markPopupClosed()
showCounterPopup.value = false
},
onRejectCheckoutSuccess: () => {
markPopupClosed()
showRejectPopup.value = false
},
onCreateDisputeSuccess: () => {
markPopupClosed()
showDisputePopup.value = false
},
})
const rentalTerms = computed(() => orderRentalTerms(order.value))
@@ -125,6 +134,48 @@ const rentalTerms = computed(() => orderRentalTerms(order.value))
const showDisputePopup = ref(false)
const showCounterPopup = ref(false)
const showRejectPopup = ref(false)
// 弹窗防穿透/防抖冷却:防止移动端/iOS Safari 点击关闭时 300ms 穿透触发底层按钮重新唤起
const POPUP_COOLDOWN_MS = 450
let lastPopupClosedAt = 0
function markPopupClosed() {
lastPopupClosedAt = Date.now()
}
function canOpenPopup(): boolean {
return Date.now() - lastPopupClosedAt >= POPUP_COOLDOWN_MS
}
function openCounterPopup() {
if (!canOpenPopup()) return
showCounterPopup.value = true
}
function closeCounterPopup() {
markPopupClosed()
showCounterPopup.value = false
}
function openRejectPopup() {
if (!canOpenPopup()) return
showRejectPopup.value = true
}
function closeRejectPopup() {
markPopupClosed()
showRejectPopup.value = false
}
function openDisputePopup() {
if (!canOpenPopup()) return
showDisputePopup.value = true
}
function closeDisputePopup() {
markPopupClosed()
showDisputePopup.value = false
}
const isOwnerCheckoutPending = computed(() => {
if (!order.value || !isOwner.value) return false
return (
@@ -443,7 +494,7 @@ async function copyListingCode() {
block
plain
round
@click="showCounterPopup = true"
@click="openCounterPopup"
>
修改结账方案
</van-button>
@@ -481,11 +532,11 @@ async function copyListingCode() {
block
plain
round
@click="showCounterPopup = true"
@click="openCounterPopup"
>
还价修改方案
</van-button>
<van-button type="danger" block plain round @click="showRejectPopup = true">
<van-button type="danger" block plain round @click="openRejectPopup">
发起结账争议
</van-button>
</div>
@@ -493,7 +544,7 @@ async function copyListingCode() {
<!-- Universal Action: File a Dispute -->
<section v-if="canOpenDispute" class="card-section action-card">
<van-button type="warning" block plain round @click="showDisputePopup = true">
<van-button type="warning" block plain round @click="openDisputePopup">
{{ isCheckoutDisputeStage ? '对结账发起申诉争议' : '发起订单申诉争议' }}
</van-button>
</section>
@@ -536,171 +587,235 @@ async function copyListingCode() {
/>
<!-- POPUP: Counter Checkout Adjustments(号主/租客) -->
<van-popup v-model:show="showCounterPopup" position="bottom" round class="mobile-popup-form">
<header class="popup-header">
<h3>修改结账方案</h3>
<button class="popup-close" @click="showCounterPopup = false">✕</button>
</header>
<div class="popup-body">
<p class="form-hint-info">
{{
checkoutRoundLabel
}}。可填实际哈夫币消耗(允许超过发布量),打超按比例从押金扣。提案将交给对方确认。
</p>
<van-cell-group :border="false">
<van-field label="物资/损耗金额">
<template #input>
<input
v-model.number="counterForm.consumableAmountYuan"
type="number"
class="custom-inline-input"
/>
</template>
</van-field>
<van-field label="实际消耗哈夫币 M">
<template #input>
<input
v-model.number="counterForm.coin_consumed_m"
type="number"
class="custom-inline-input"
/>
</template>
</van-field>
<van-field label="押金赔付(损坏等)">
<template #input>
<input
v-model.number="counterForm.depositDeductAmountYuan"
type="number"
class="custom-inline-input"
/>
</template>
</van-field>
</van-cell-group>
<van-popup
v-model:show="showCounterPopup"
position="bottom"
round
class="mobile-popup-form"
@close="markPopupClosed"
>
<div class="popup-inner">
<header class="popup-header">
<h3>修改结账方案</h3>
<button
type="button"
class="popup-close"
aria-label="关闭"
@click.stop.prevent="closeCounterPopup"
>
<van-icon name="cross" :size="18" />
</button>
</header>
<div class="popup-body">
<p class="form-hint-info">
{{
checkoutRoundLabel
}}。可填实际哈夫币消耗(允许超过发布量),打超按比例从押金扣。提案将交给对方确认。
</p>
<div class="counter-input-card">
<div class="counter-input-row">
<label class="counter-label" for="counter-consumable">物资/损耗金额</label>
<div class="counter-input-wrapper">
<input
id="counter-consumable"
v-model.number="counterForm.consumableAmountYuan"
type="number"
placeholder="0"
class="counter-input-control"
/>
<span class="counter-unit">元</span>
</div>
</div>
<div class="counter-input-row">
<label class="counter-label" for="counter-coin">实际消耗哈夫币</label>
<div class="counter-input-wrapper">
<input
id="counter-coin"
v-model.number="counterForm.coin_consumed_m"
type="number"
placeholder="0"
class="counter-input-control"
/>
<span class="counter-unit">M</span>
</div>
</div>
<div class="counter-input-row">
<label class="counter-label" for="counter-deposit-deduct">押金赔付(损坏等)</label>
<div class="counter-input-wrapper">
<input
id="counter-deposit-deduct"
v-model.number="counterForm.depositDeductAmountYuan"
type="number"
placeholder="0"
class="counter-input-control"
/>
<span class="counter-unit">元</span>
</div>
</div>
</div>
<!-- 实时结算试算预览(仅展示当前角色自身收支,完全屏蔽平台服务费) -->
<CheckoutTrialPreview :simulation="checkoutSimulation" variant="mobile" />
<van-field
v-model="counterForm.reason"
rows="2"
autosize
label="修改原因"
type="textarea"
placeholder="请说明调整原因(必填)"
class="popup-field"
/>
<van-field
v-model="counterForm.evidenceText"
rows="2"
autosize
label="扣款证据"
type="textarea"
placeholder="证据链接(每行一个)"
class="popup-field"
/>
<div class="evidence-upload-row">
<span>上传新证据截图:</span>
<input type="file" accept="image/*" @change="handleCounterEvidenceUpload" />
<!-- 实时结算试算预览(仅展示当前角色自身收支,完全屏蔽平台服务费) -->
<CheckoutTrialPreview :simulation="checkoutSimulation" variant="mobile" />
<van-field
v-model="counterForm.reason"
rows="2"
autosize
required
label="修改原因"
type="textarea"
placeholder="请说明调整原因(必填)"
class="popup-field"
/>
<van-field
v-model="counterForm.evidenceText"
rows="2"
autosize
label="扣款证据"
type="textarea"
placeholder="证据链接(每行一个)"
class="popup-field"
/>
<div class="evidence-upload-row">
<span>上传新证据截图:</span>
<input type="file" accept="image/*" @change="handleCounterEvidenceUpload" />
</div>
</div>
<van-button
type="warning"
block
round
:loading="countering"
loading-text="提交中..."
@click="handleCounterCheckout"
class="popup-submit"
>
提交给对方确认
</van-button>
<footer class="popup-footer">
<van-button
type="warning"
block
round
:loading="countering"
loading-text="提交中..."
@click="handleCounterCheckout"
class="popup-submit"
>
提交给对方确认
</van-button>
</footer>
</div>
</van-popup>
<!-- POPUP: Renter Reject Counter Proposal Reason -->
<van-popup v-model:show="showRejectPopup" position="bottom" round class="mobile-popup-form">
<header class="popup-header">
<h3>拒绝修正扣款说明</h3>
<button class="popup-close" @click="showRejectPopup = false">✕</button>
</header>
<div class="popup-body">
<p class="form-hint-info">
拒绝号主的修正提案后,订单将立即自动发起争议,由客服介入根据证据判定。
</p>
<van-field
v-model="rejectReason"
rows="3"
autosize
label="拒绝原因"
type="textarea"
placeholder="请详细叙述拒绝号主扣款的理由,这些信息将被客服作为判定依据"
class="popup-field"
/>
<van-button
type="danger"
block
round
:loading="rejectingCheckout"
loading-text="发起争议中..."
@click="handleRejectCheckout"
class="popup-submit"
>
提交原因并提起争议
</van-button>
<van-popup
v-model:show="showRejectPopup"
position="bottom"
round
class="mobile-popup-form"
@close="markPopupClosed"
>
<div class="popup-inner">
<header class="popup-header">
<h3>拒绝修正扣款说明</h3>
<button
type="button"
class="popup-close"
aria-label="关闭"
@click.stop.prevent="closeRejectPopup"
>
<van-icon name="cross" :size="18" />
</button>
</header>
<div class="popup-body">
<p class="form-hint-info">
拒绝号主的修正提案后,订单将立即自动发起争议,由客服介入根据证据判定。
</p>
<van-field
v-model="rejectReason"
rows="3"
autosize
label="拒绝原因"
type="textarea"
placeholder="请详细叙述拒绝号主扣款的理由,这些信息将被客服作为判定依据"
class="popup-field"
/>
</div>
<footer class="popup-footer">
<van-button
type="danger"
block
round
:loading="rejectingCheckout"
loading-text="发起争议中..."
@click="handleRejectCheckout"
class="popup-submit"
>
提交原因并提起争议
</van-button>
</footer>
</div>
</van-popup>
<!-- POPUP: Create Generic Order Dispute -->
<van-popup v-model:show="showDisputePopup" position="bottom" round class="mobile-popup-form">
<header class="popup-header">
<h3>发起申诉争议</h3>
<button class="popup-close" @click="showDisputePopup = false">✕</button>
</header>
<div class="popup-body">
<van-field v-if="!isCheckoutDisputeStage" label="申诉类型" class="popup-field">
<template #input>
<select v-model="disputeType" class="custom-select">
<option value="cannot_login">无法登录</option>
<option value="false_description">虚假描述</option>
<option value="account_banned">账号被封</option>
<option value="asset_loss">资产损失</option>
<option value="haf_coin_dispute">哈夫币争议</option>
<option value="handoff_timeout">超时未交接</option>
<option value="return_timeout">超时未归还</option>
</select>
</template>
</van-field>
<van-field
v-model="disputeDescription"
rows="3"
autosize
label="经过说明"
type="textarea"
placeholder="请在此描述您的诉求、事件经过、时间点等详细情况"
class="popup-field"
/>
<van-field
v-model="disputeEvidenceText"
rows="2"
autosize
label="证据列表"
type="textarea"
placeholder="证据链接(每行一个)"
class="popup-field"
/>
<div class="evidence-upload-row">
<span>上传证据截图:</span>
<input type="file" accept="image/*" @change="handleEvidenceUpload" />
<van-popup
v-model:show="showDisputePopup"
position="bottom"
round
class="mobile-popup-form"
@close="markPopupClosed"
>
<div class="popup-inner">
<header class="popup-header">
<h3>发起申诉争议</h3>
<button
type="button"
class="popup-close"
aria-label="关闭"
@click.stop.prevent="closeDisputePopup"
>
<van-icon name="cross" :size="18" />
</button>
</header>
<div class="popup-body">
<van-field v-if="!isCheckoutDisputeStage" label="申诉类型" class="popup-field">
<template #input>
<select v-model="disputeType" class="custom-select">
<option value="cannot_login">无法登录</option>
<option value="false_description">虚假描述</option>
<option value="account_banned">账号被封</option>
<option value="asset_loss">资产损失</option>
<option value="haf_coin_dispute">哈夫币争议</option>
<option value="handoff_timeout">超时未交接</option>
<option value="return_timeout">超时未归还</option>
</select>
</template>
</van-field>
<van-field
v-model="disputeDescription"
rows="3"
autosize
label="经过说明"
type="textarea"
placeholder="请在此描述您的诉求、事件经过、时间点等详细情况"
class="popup-field"
/>
<van-field
v-model="disputeEvidenceText"
rows="2"
autosize
label="证据列表"
type="textarea"
placeholder="证据链接(每行一个)"
class="popup-field"
/>
<div class="evidence-upload-row">
<span>上传证据截图:</span>
<input type="file" accept="image/*" @change="handleEvidenceUpload" />
</div>
</div>
<van-button
type="warning"
block
round
:loading="disputing"
loading-text="正在发起申诉..."
@click="handleCreateDispute"
class="popup-submit"
>
提交申诉客服仲裁
</van-button>
<footer class="popup-footer">
<van-button
type="warning"
block
round
:loading="disputing"
loading-text="正在发起申诉..."
@click="handleCreateDispute"
class="popup-submit"
>
提交申诉客服仲裁
</van-button>
</footer>
</div>
</van-popup>
</main>
+20
View File
@@ -1042,6 +1042,26 @@ h1 {
white-space: nowrap;
}
.admin-skin-filter-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 10px;
margin-top: 4px;
border-top: 1px solid #f1f5f9;
}
.admin-skin-filter-footer .selected-hint {
color: #64748b;
font-size: 12px;
}
.admin-skin-filter-footer .footer-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* ========== Dashboard Panels ========== */
.dashboard-panels {
display: grid;