Compare commits
8
Commits
95435faf46
...
6a99c1121b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a99c1121b | ||
|
|
e36bdd28c5 | ||
|
|
c9f6820882 | ||
|
|
5b2f36ac36 | ||
|
|
180a21c048 | ||
|
|
9d5d35c7d4 | ||
|
|
a93c76bcf6 | ||
|
|
03d20223c8 |
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user