添加商品管理资产皮肤筛选

This commit is contained in:
yml2213
2026-07-05 20:21:18 +08:00
parent 090a90a268
commit 1984e07e91
8 changed files with 612 additions and 2 deletions
@@ -0,0 +1,45 @@
package listing
func hasAdminAssetFilters(query AdminListQuery) bool {
return len(query.Insurance) > 0 ||
len(query.Stamina) > 0 ||
len(query.Load) > 0 ||
len(query.SkinGroup) > 0 ||
len(query.SkinName) > 0
}
func filterAdminListings(items []ListingDTO, query AdminListQuery) []ListingDTO {
if !hasAdminAssetFilters(query) {
return items
}
filtered := make([]ListingDTO, 0, len(items))
for _, item := range items {
if !matchesAdminAssetQuery(item, query) {
continue
}
filtered = append(filtered, item)
}
return filtered
}
func matchesAdminAssetQuery(item ListingDTO, query AdminListQuery) bool {
if !matchesAny(query.Insurance, readAssetString(item.AssetSummary, "season_insurance")) {
return false
}
if !matchesAny(query.Stamina, readAssetString(item.AssetSummary, "stamina_level")) {
return false
}
if !matchesAny(query.Load, readAssetString(item.AssetSummary, "load_level")) {
return false
}
if len(query.SkinName) > 0 {
if len(query.SkinGroup) > 0 {
return skinGroupsContainAny(item.AssetSummary, query.SkinGroup, query.SkinName)
}
return intersects(query.SkinName, skinNamesFromSummary(item.AssetSummary))
}
if len(query.SkinGroup) > 0 {
return skinGroupsHaveAny(item.AssetSummary, query.SkinGroup)
}
return true
}
@@ -0,0 +1,94 @@
package listing
import "testing"
func TestFilterAdminListingsByAssetLevels(t *testing.T) {
items := []ListingDTO{
{
ID: 1,
AssetSummary: map[string]any{
"season_insurance": "3*3",
"stamina_level": "7级",
"load_level": "7级",
},
},
{
ID: 2,
AssetSummary: map[string]any{
"season_insurance": "2*3",
"stamina_level": "7级",
"load_level": "6级",
},
},
}
filtered := filterAdminListings(items, AdminListQuery{
Insurance: []string{"3*3"},
Stamina: []string{"7级"},
Load: []string{"7级"},
})
if len(filtered) != 1 || filtered[0].ID != 1 {
t.Fatalf("expected only listing 1 in 9/7/7 asset filter, got %#v", filtered)
}
}
func TestFilterAdminListingsBySkinGroupAndName(t *testing.T) {
items := []ListingDTO{
{
ID: 1,
AssetSummary: map[string]any{
"skin_groups": map[string]any{
"operatorRed": []any{"凌霄成卫"},
"weapon": []any{"K416命运"},
},
},
},
{
ID: 2,
AssetSummary: map[string]any{
"skin_groups": map[string]any{
"operatorGold": []any{"露娜-金牌射手"},
},
},
},
}
filtered := filterAdminListings(items, AdminListQuery{
SkinGroup: []string{"operatorRed"},
SkinName: []string{"凌霄成卫"},
})
if len(filtered) != 1 || filtered[0].ID != 1 {
t.Fatalf("expected only listing 1 by red skin name, got %#v", filtered)
}
}
func TestFilterAdminListingsBySkinGroupOnly(t *testing.T) {
items := []ListingDTO{
{
ID: 1,
AssetSummary: map[string]any{
"skin_groups": map[string]any{
"operatorRed": []any{"凌霄成卫"},
},
},
},
{
ID: 2,
AssetSummary: map[string]any{
"skin_groups": map[string]any{
"operatorGold": []any{"露娜-金牌射手"},
},
},
},
}
filtered := filterAdminListings(items, AdminListQuery{
SkinGroup: []string{"operatorGold"},
})
if len(filtered) != 1 || filtered[0].ID != 2 {
t.Fatalf("expected only listing 2 by gold skin group, got %#v", filtered)
}
}
+5
View File
@@ -76,6 +76,11 @@ type AdminListQuery struct {
Keyword string Keyword string
Status string Status string
ReviewStatus string ReviewStatus string
Insurance []string
Stamina []string
Load []string
SkinGroup []string
SkinName []string
Limit int Limit int
Page int Page int
PageSize int PageSize int
@@ -74,6 +74,11 @@ func parseAdminListQuery(c *gin.Context) (AdminListQuery, bool) {
query.Keyword = strings.TrimSpace(c.Query("keyword")) query.Keyword = strings.TrimSpace(c.Query("keyword"))
query.Status = c.Query("status") query.Status = c.Query("status")
query.ReviewStatus = c.Query("review_status") query.ReviewStatus = c.Query("review_status")
query.Insurance = parseCSVQuery(c.Query("insurance"))
query.Stamina = parseCSVQuery(c.Query("stamina"))
query.Load = parseCSVQuery(c.Query("load"))
query.SkinGroup = parseCSVQuery(c.Query("skin_group"))
query.SkinName = parseCSVQuery(firstNonEmpty(c.Query("skin_name"), c.Query("skin")))
return query, true return query, true
} }
+31
View File
@@ -29,6 +29,37 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*Admi
pageSize = 100 pageSize = 100
} }
if hasAdminAssetFilters(query) {
var rows []listingRow
err := r.applyAdminListFilters(r.baseQuery(ctx), query).
Order("l.id DESC").
Scan(&rows).Error
if err != nil {
return nil, err
}
items := filterAdminListings(rowsToDTO(rows), query)
total := int64(len(items))
start := (page - 1) * pageSize
if start < 0 {
start = 0
}
if start >= len(items) {
items = []ListingDTO{}
} else {
end := start + pageSize
if end > len(items) {
end = len(items)
}
items = items[start:end]
}
return &AdminListResult{
Items: items,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
}
countDB := r.applyAdminListFilters(r.db.WithContext(ctx).Table("rental_listings AS l"), query) countDB := r.applyAdminListFilters(r.db.WithContext(ctx).Table("rental_listings AS l"), query)
var total int64 var total int64
if err := countDB.Count(&total).Error; err != nil { if err := countDB.Count(&total).Error; err != nil {
@@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { Search } from '@element-plus/icons-vue' import { ArrowDown, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { computed, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { import {
emptyListingPublishOptions,
fetchAdminListings, fetchAdminListings,
fetchListingPublishOptions,
type AdminListingPage, type AdminListingPage,
type AdminListingQuery, type AdminListingQuery,
type Listing, type Listing,
@@ -29,10 +31,23 @@ const filters = reactive<AdminListingQuery>({
owner_id: '', owner_id: '',
status: '', status: '',
review_status: '', review_status: '',
insurance: '',
stamina: '',
load: '',
skin_group: '',
skin_name: '',
}) })
type FilterPopover = 'asset' | 'skin'
type AssetFilterKey = 'insurance' | 'stamina' | 'load'
const fallbackInsuranceOptions = ['2*1', '2*2', '2*3', '3*3']
const fallbackLevelOptions = ['1级', '2级', '3级', '4级', '5级', '6级', '7级']
const pageSize = ref(10) const pageSize = ref(10)
const currentPage = ref(1) const currentPage = ref(1)
const activeFilterPopover = ref<FilterPopover | ''>('')
const publishOptions = ref(emptyListingPublishOptions)
const { const {
loading, loading,
@@ -63,6 +78,36 @@ const pendingCount = computed(
() => listings.value.filter(item => item.review_status === 'pending').length () => listings.value.filter(item => item.review_status === 'pending').length
) )
const totalPages = computed(() => Math.max(1, Math.ceil(totalListings.value / pageSize.value))) const totalPages = computed(() => Math.max(1, Math.ceil(totalListings.value / pageSize.value)))
const insuranceOptions = computed(() =>
publishOptions.value.insurance_options.length
? publishOptions.value.insurance_options
: fallbackInsuranceOptions
)
const levelOptions = computed(() =>
publishOptions.value.level_options.length
? publishOptions.value.level_options
: fallbackLevelOptions
)
const skinFilterGroups = computed(() => publishOptions.value.skin_groups)
const assetFilterActive = computed(() =>
Boolean(filters.insurance || filters.stamina || filters.load)
)
const assetFilterLabel = computed(() => {
if (!assetFilterActive.value) return '全部资产'
return [
insuranceSlotLabel(filters.insurance || ''),
levelSlotLabel(filters.stamina || ''),
levelSlotLabel(filters.load || ''),
].join('/')
})
const skinFilterActive = computed(() => Boolean(filters.skin_group || filters.skin_name))
const skinFilterLabel = computed(() => {
if (filters.skin_name) return filters.skin_name
if (filters.skin_group) {
return skinFilterGroups.value.find(group => group.key === filters.skin_group)?.title || '皮肤'
}
return '全部皮肤'
})
const screenshotColumns = [ const screenshotColumns = [
{ label: '商品编码', width: 88, read: (row: Listing) => formatListingCode(row) }, { label: '商品编码', width: 88, read: (row: Listing) => formatListingCode(row) },
@@ -93,6 +138,7 @@ const screenshotColumns = [
] as const ] as const
async function queryListings() { async function queryListings() {
closeFilterPopover()
currentPage.value = 1 currentPage.value = 1
await loadListings() await loadListings()
} }
@@ -102,9 +148,71 @@ function resetFilters() {
filters.owner_id = '' filters.owner_id = ''
filters.status = '' filters.status = ''
filters.review_status = '' filters.review_status = ''
clearAssetFilters(false)
clearSkinFilters(false)
void queryListings() void queryListings()
} }
onMounted(loadPublishOptions)
async function loadPublishOptions() {
try {
publishOptions.value = await fetchListingPublishOptions()
} catch {
publishOptions.value = emptyListingPublishOptions
ElMessage.warning('商品筛选选项加载失败,已使用默认资产选项')
}
}
function setFilterPopover(key: FilterPopover, visible: boolean) {
activeFilterPopover.value = visible
? key
: activeFilterPopover.value === key
? ''
: activeFilterPopover.value
}
function closeFilterPopover() {
activeFilterPopover.value = ''
}
function setAssetFilter(key: AssetFilterKey, value: string) {
filters[key] = filters[key] === value ? '' : value
}
function clearAssetFilters(shouldQuery = false) {
filters.insurance = ''
filters.stamina = ''
filters.load = ''
if (shouldQuery) void queryListings()
}
function selectSkinFilter(group: string, name = '') {
filters.skin_group = group
filters.skin_name = name
closeFilterPopover()
}
function clearSkinFilters(shouldQuery = false) {
filters.skin_group = ''
filters.skin_name = ''
closeFilterPopover()
if (shouldQuery) void queryListings()
}
function insuranceSlotLabel(value: string) {
const parts = value.split('*').map(item => Number(item))
if (parts.length === 2 && parts.every(item => Number.isFinite(item) && item > 0)) {
const [rows = 0, columns = 0] = parts
return `${rows * columns}`
}
return value || '-'
}
function levelSlotLabel(value: string) {
return value ? value.replace(/级$/, '') : '-'
}
function setStatusFilter(status: string) { function setStatusFilter(status: string) {
filters.status = (filters.status === status ? '' : status) as AdminListingQuery['status'] filters.status = (filters.status === status ? '' : status) as AdminListingQuery['status']
filters.review_status = '' filters.review_status = ''
@@ -490,6 +598,161 @@ function formatQuantity(value: number) {
<el-option label="已拒绝" value="rejected" /> <el-option label="已拒绝" value="rejected" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="资产">
<el-popover
:visible="activeFilterPopover === 'asset'"
trigger="click"
placement="bottom-start"
popper-class="admin-listing-filter-popover"
:width="360"
@update:visible="setFilterPopover('asset', $event)"
>
<template #reference>
<button
class="admin-filter-chip"
:class="{ active: assetFilterActive }"
type="button"
>
<span>{{ assetFilterLabel }}</span>
<el-icon><ArrowDown /></el-icon>
</button>
</template>
<div class="admin-asset-filter-menu">
<div class="admin-filter-section">
<span class="admin-filter-title">保险</span>
<div class="admin-option-grid compact">
<button
type="button"
:class="{ active: !filters.insurance }"
@click="setAssetFilter('insurance', '')"
>
全部
</button>
<button
v-for="item in insuranceOptions"
:key="item"
type="button"
:class="{ active: filters.insurance === item }"
@click="setAssetFilter('insurance', item)"
>
{{ insuranceSlotLabel(item) }}
</button>
</div>
</div>
<div class="admin-filter-section">
<span class="admin-filter-title">体力</span>
<div class="admin-option-grid">
<button
type="button"
:class="{ active: !filters.stamina }"
@click="setAssetFilter('stamina', '')"
>
全部
</button>
<button
v-for="item in levelOptions"
:key="`stamina-${item}`"
type="button"
:class="{ active: filters.stamina === item }"
@click="setAssetFilter('stamina', item)"
>
{{ levelSlotLabel(item) }}
</button>
</div>
</div>
<div class="admin-filter-section">
<span class="admin-filter-title">负重</span>
<div class="admin-option-grid">
<button
type="button"
:class="{ active: !filters.load }"
@click="setAssetFilter('load', '')"
>
全部
</button>
<button
v-for="item in levelOptions"
:key="`load-${item}`"
type="button"
:class="{ active: filters.load === item }"
@click="setAssetFilter('load', item)"
>
{{ levelSlotLabel(item) }}
</button>
</div>
</div>
<button
v-if="assetFilterActive"
class="admin-filter-reset"
type="button"
@click="clearAssetFilters()"
>
清空资产筛选
</button>
</div>
</el-popover>
</el-form-item>
<el-form-item label="皮肤">
<el-popover
:visible="activeFilterPopover === 'skin'"
trigger="click"
placement="bottom-start"
popper-class="admin-listing-filter-popover"
:width="420"
@update:visible="setFilterPopover('skin', $event)"
>
<template #reference>
<button
class="admin-filter-chip wide"
:class="{ active: skinFilterActive }"
type="button"
>
<span>{{ skinFilterLabel }}</span>
<el-icon><ArrowDown /></el-icon>
</button>
</template>
<div class="admin-skin-filter-menu">
<button
class="admin-filter-reset"
type="button"
:class="{ active: !filters.skin_group && !filters.skin_name }"
@click="clearSkinFilters()"
>
全部皮肤
</button>
<div v-if="!skinFilterGroups.length" class="admin-filter-empty">暂无皮肤选项</div>
<div
v-for="group in skinFilterGroups"
:key="group.key"
class="admin-filter-section"
>
<div class="admin-skin-filter-title">
<strong>{{ group.title }}</strong>
<button
type="button"
:class="{ active: filters.skin_group === group.key && !filters.skin_name }"
@click="selectSkinFilter(group.key)"
>
全部{{ group.title.replace('干员', '') }}
</button>
</div>
<div class="admin-skin-option-grid">
<button
v-for="skin in group.options"
:key="`${group.key}-${skin}`"
type="button"
:class="{
active: filters.skin_group === group.key && filters.skin_name === skin,
}"
@click="selectSkinFilter(group.key, skin)"
>
{{ skin }}
</button>
</div>
</div>
</div>
</el-popover>
</el-form-item>
</el-form> </el-form>
<div class="listing-action-strip"> <div class="listing-action-strip">
<el-button @click="resetFilters">重置</el-button> <el-button @click="resetFilters">重置</el-button>
@@ -166,6 +166,11 @@ export interface AdminListingQuery {
owner_id?: string owner_id?: string
status?: ListingStatus | '' status?: ListingStatus | ''
review_status?: ListingReviewStatus | '' review_status?: ListingReviewStatus | ''
insurance?: string
stamina?: string
load?: string
skin_group?: string
skin_name?: string
limit?: number limit?: number
page?: number page?: number
page_size?: number page_size?: number
+162
View File
@@ -533,9 +533,11 @@ h1 {
.listing-filter-bar { .listing-filter-bar {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 0; gap: 0;
margin: 0; margin: 0;
row-gap: 10px;
} }
.listing-filter-bar .el-form-item { .listing-filter-bar .el-form-item {
@@ -564,6 +566,166 @@ h1 {
white-space: nowrap; white-space: nowrap;
} }
.admin-filter-chip {
display: inline-flex;
width: 104px;
height: 32px;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 0 10px;
border: 1px solid #dcdfe6;
border-radius: 6px;
background: #ffffff;
color: #606266;
cursor: pointer;
font-size: 13px;
line-height: 1;
transition:
border-color 0.15s ease,
color 0.15s ease,
background 0.15s ease;
}
.admin-filter-chip.wide {
width: 150px;
}
.admin-filter-chip span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-filter-chip:hover,
.admin-filter-chip.active {
border-color: #409eff;
background: #ecf5ff;
color: #1677ff;
}
.admin-filter-chip .el-icon {
flex-shrink: 0;
}
.admin-listing-filter-popover {
padding: 10px !important;
border-radius: 8px !important;
}
.admin-asset-filter-menu,
.admin-skin-filter-menu {
display: flex;
max-height: 420px;
flex-direction: column;
gap: 12px;
overflow-y: auto;
}
.admin-filter-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.admin-filter-title {
color: #6b7785;
font-size: 12px;
font-weight: 700;
}
.admin-option-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
}
.admin-option-grid.compact {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.admin-option-grid button,
.admin-skin-option-grid button,
.admin-filter-reset,
.admin-skin-filter-title button {
border: none;
border-radius: 6px;
background: transparent;
color: #334155;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition:
background 0.15s ease,
color 0.15s ease;
}
.admin-option-grid button {
min-height: 30px;
padding: 7px 8px;
}
.admin-option-grid button:hover,
.admin-skin-option-grid button:hover,
.admin-filter-reset:hover,
.admin-skin-filter-title button:hover {
background: #f1f5f9;
}
.admin-option-grid button.active,
.admin-skin-option-grid button.active,
.admin-filter-reset.active,
.admin-skin-filter-title button.active {
background: #ecf5ff;
color: #1677ff;
font-weight: 700;
}
.admin-filter-reset {
min-height: 32px;
padding: 8px 10px;
text-align: left;
}
.admin-filter-empty {
padding: 10px;
color: #8f9bba;
font-size: 12px;
}
.admin-skin-filter-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.admin-skin-filter-title strong {
color: #17233d;
font-size: 13px;
}
.admin-skin-filter-title button {
flex-shrink: 0;
padding: 5px 8px;
}
.admin-skin-option-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.admin-skin-option-grid button {
min-height: 30px;
padding: 8px 10px;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ========== Dashboard Panels ========== */ /* ========== Dashboard Panels ========== */
.dashboard-panels { .dashboard-panels {
display: grid; display: grid;