feat(admin): 商品管理支持多选皮肤筛选且要求包含所选全部皮肤
- 后端过滤:将 AdminListQuery.SkinName 筛选逻辑对齐前台,由 OR(交集) 改为 AND(全包含),选多款皮肤时账号必须同时包含所选的全部皮肤 - 前端交互:AdminListingsView 皮肤筛选支持多选交互,点击具体皮肤支持多选高亮,底部提供已选数量提示、清空与确定操作,外层按钮智能展示已选皮肤摘要 - 样式增强:补充管理后台皮肤筛选弹层底部的清空/确定操作栏样式 - 验证方式:新增并运行后端 TestFilterAdminListingsByMultipleSkinsRequiresAll 单元测试,运行全量 ./scripts/check.sh 门禁全绿通过
This commit is contained in:
@@ -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{}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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