优化商品管理筛选功能

This commit is contained in:
yml2213
2026-07-09 23:55:19 +08:00
parent 0a5448b3f5
commit ef67375132
9 changed files with 252 additions and 56 deletions
+4
View File
@@ -81,6 +81,10 @@ type AdminListQuery struct {
Load []string Load []string
SkinGroup []string SkinGroup []string
SkinName []string SkinName []string
MinCoin *float64
MaxCoin *float64
MinDeposit *float64
MaxDeposit *float64
Limit int Limit int
Page int Page int
PageSize int PageSize int
@@ -79,6 +79,10 @@ func parseAdminListQuery(c *gin.Context) (AdminListQuery, bool) {
query.Load = parseCSVQuery(c.Query("load")) query.Load = parseCSVQuery(c.Query("load"))
query.SkinGroup = parseCSVQuery(c.Query("skin_group")) query.SkinGroup = parseCSVQuery(c.Query("skin_group"))
query.SkinName = parseCSVQuery(firstNonEmpty(c.Query("skin_name"), c.Query("skin"))) query.SkinName = parseCSVQuery(firstNonEmpty(c.Query("skin_name"), c.Query("skin")))
query.MinCoin = parseOptionalFloat(c.Query("min_coin"))
query.MaxCoin = parseOptionalFloat(c.Query("max_coin"))
query.MinDeposit = parseOptionalFloat(c.Query("min_deposit"))
query.MaxDeposit = parseOptionalFloat(c.Query("max_deposit"))
return query, true return query, true
} }
+33
View File
@@ -3,6 +3,7 @@ package listing
import ( import (
"context" "context"
"fmt" "fmt"
"math"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -113,9 +114,41 @@ func (r *Repository) applyAdminListFilters(db *gorm.DB, query AdminListQuery) *g
if query.ReviewStatus != "" { if query.ReviewStatus != "" {
db = db.Where("l.review_status = ?", query.ReviewStatus) db = db.Where("l.review_status = ?", query.ReviewStatus)
} }
if query.MinCoin != nil {
db = db.Where(
"EXISTS (SELECT 1 FROM game_accounts AS coin_account WHERE coin_account.id = l.account_id AND coin_account.haf_coin_amount >= ?)",
coinMToAdminAmount(*query.MinCoin),
)
}
if query.MaxCoin != nil {
db = db.Where(
"EXISTS (SELECT 1 FROM game_accounts AS coin_account WHERE coin_account.id = l.account_id AND coin_account.haf_coin_amount <= ?)",
coinMToAdminAmount(*query.MaxCoin),
)
}
if query.MinDeposit != nil {
db = db.Where("l.deposit_amount_cent >= ?", yuanToAdminCent(*query.MinDeposit))
}
if query.MaxDeposit != nil {
db = db.Where("l.deposit_amount_cent <= ?", yuanToAdminCent(*query.MaxDeposit))
}
return db return db
} }
func coinMToAdminAmount(value float64) int64 {
if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) {
return 0
}
return int64(math.Round(value * 1000000))
}
func yuanToAdminCent(value float64) int64 {
if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) {
return 0
}
return int64(math.Round(value * 100))
}
func (r *Repository) FindAdmin(ctx context.Context, listingID uint64) (*ListingDTO, error) { func (r *Repository) FindAdmin(ctx context.Context, listingID uint64) (*ListingDTO, error) {
return r.findDTO(ctx, "l.id = ?", listingID) return r.findDTO(ctx, "l.id = ?", listingID)
} }
@@ -36,6 +36,10 @@ const filters = reactive<AdminListingQuery>({
load: '', load: '',
skin_group: '', skin_group: '',
skin_name: '', skin_name: '',
min_coin: undefined,
max_coin: undefined,
min_deposit: undefined,
max_deposit: undefined,
}) })
type FilterPopover = 'asset' | 'skin' type FilterPopover = 'asset' | 'skin'
@@ -148,6 +152,10 @@ function resetFilters() {
filters.owner_id = '' filters.owner_id = ''
filters.status = '' filters.status = ''
filters.review_status = '' filters.review_status = ''
filters.min_coin = undefined
filters.max_coin = undefined
filters.min_deposit = undefined
filters.max_deposit = undefined
clearAssetFilters(false) clearAssetFilters(false)
clearSkinFilters(false) clearSkinFilters(false)
void queryListings() void queryListings()
@@ -551,32 +559,63 @@ function formatQuantity(value: number) {
<!-- 筛选和操作行 --> <!-- 筛选和操作行 -->
<div class="listing-filter-row"> <div class="listing-filter-row">
<el-form class="listing-filter-bar" inline> <el-form class="listing-filter-bar" label-position="top">
<el-form-item label="编号"> <el-form-item class="filter-keyword" label="编号">
<el-input <el-input
v-model="filters.keyword" v-model="filters.keyword"
clearable clearable
placeholder="商品编号 / 订单编号" placeholder="商品编号 / 订单编号"
style="width: 210px"
@keyup.enter="queryListings" @keyup.enter="queryListings"
@clear="queryListings" @clear="queryListings"
/> />
</el-form-item> </el-form-item>
<el-form-item label="号主 ID"> <el-form-item class="filter-owner" label="号主 ID">
<el-input <el-input v-model="filters.owner_id" clearable placeholder="筛选号主" />
v-model="filters.owner_id"
clearable
placeholder="筛选号主"
style="width: 160px"
/>
</el-form-item> </el-form-item>
<el-form-item label="商品状态"> <el-form-item class="filter-range" label="纯币(M)">
<el-select <div class="admin-range-filter">
v-model="filters.status" <el-input-number
clearable v-model="filters.min_coin"
placeholder="全部状态" :min="0"
style="width: 140px" :precision="1"
> :step="10"
:controls="false"
placeholder="最低"
/>
<span class="admin-range-separator">-</span>
<el-input-number
v-model="filters.max_coin"
:min="0"
:precision="1"
:step="10"
:controls="false"
placeholder="最高"
/>
</div>
</el-form-item>
<el-form-item class="filter-range" label="押金(元)">
<div class="admin-range-filter">
<el-input-number
v-model="filters.min_deposit"
:min="0"
:precision="1"
:step="100"
:controls="false"
placeholder="最低"
/>
<span class="admin-range-separator">-</span>
<el-input-number
v-model="filters.max_deposit"
:min="0"
:precision="1"
:step="100"
:controls="false"
placeholder="最高"
/>
</div>
</el-form-item>
<el-form-item class="filter-select" label="商品状态">
<el-select v-model="filters.status" clearable placeholder="全部状态">
<el-option label="草稿" value="draft" /> <el-option label="草稿" value="draft" />
<el-option label="已上架" value="published" /> <el-option label="已上架" value="published" />
<el-option label="已锁定" value="rented" /> <el-option label="已锁定" value="rented" />
@@ -585,20 +624,15 @@ function formatQuantity(value: number) {
<el-option label="异常" value="abnormal" /> <el-option label="异常" value="abnormal" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="审核状态"> <el-form-item class="filter-select" label="审核状态">
<el-select <el-select v-model="filters.review_status" clearable placeholder="全部审核">
v-model="filters.review_status"
clearable
placeholder="全部审核"
style="width: 140px"
>
<el-option label="未提交" value="none" /> <el-option label="未提交" value="none" />
<el-option label="待审核" value="pending" /> <el-option label="待审核" value="pending" />
<el-option label="已通过" value="approved" /> <el-option label="已通过" value="approved" />
<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-form-item class="filter-select" label="资产">
<el-popover <el-popover
:visible="activeFilterPopover === 'asset'" :visible="activeFilterPopover === 'asset'"
trigger="click" trigger="click"
@@ -692,7 +726,7 @@ function formatQuantity(value: number) {
</div> </div>
</el-popover> </el-popover>
</el-form-item> </el-form-item>
<el-form-item label="皮肤"> <el-form-item class="filter-skin" label="皮肤">
<el-popover <el-popover
:visible="activeFilterPopover === 'skin'" :visible="activeFilterPopover === 'skin'"
trigger="click" trigger="click"
@@ -171,6 +171,10 @@ export interface AdminListingQuery {
load?: string load?: string
skin_group?: string skin_group?: string
skin_name?: string skin_name?: string
min_coin?: number
max_coin?: number
min_deposit?: number
max_deposit?: number
limit?: number limit?: number
page?: number page?: number
page_size?: number page_size?: number
@@ -185,7 +189,9 @@ export interface AdminListingPage {
export async function fetchAdminListings(query: AdminListingQuery = {}) { export async function fetchAdminListings(query: AdminListingQuery = {}) {
const params = Object.fromEntries( const params = Object.fromEntries(
Object.entries(query).filter(([, value]) => value !== '' && value !== undefined) Object.entries(query).filter(
([, value]) => value !== '' && value !== undefined && value !== null
)
) )
const { data } = await apiClient.get<ApiResponse<AdminListingPage>>('/admin/listings', { params }) const { data } = await apiClient.get<ApiResponse<AdminListingPage>>('/admin/listings', { params })
return normalizeAdminListingPage(data.data, query) return normalizeAdminListingPage(data.data, query)
@@ -131,6 +131,7 @@ const detailScreenshots = computed(() => {
url, url,
})) }))
}) })
const detailScreenshotUrls = computed(() => detailScreenshots.value.map(shot => shot.url))
function readGroupedScreenshots(item: Listing) { function readGroupedScreenshots(item: Listing) {
const groups = item.asset_summary?.screenshot_groups const groups = item.asset_summary?.screenshot_groups
@@ -462,8 +463,20 @@ function handleToggleFavorite() {
<span>{{ detailScreenshots.length }} </span> <span>{{ detailScreenshots.length }} </span>
</div> </div>
<div class="detail-screenshot-grid"> <div class="detail-screenshot-grid">
<figure v-for="shot in detailScreenshots" :key="shot.url" class="detail-screenshot"> <figure
<AuthImage :source="shot.url" :alt="shot.label" /> v-for="(shot, index) in detailScreenshots"
:key="shot.url"
class="detail-screenshot"
>
<AuthImage
:source="shot.url"
:alt="shot.label"
image-class="detail-screenshot-image"
fallback-class="detail-screenshot-image"
fit="cover"
:preview-src-list="detailScreenshotUrls"
:preview-initial-index="index"
/>
<figcaption>{{ shot.label }}</figcaption> <figcaption>{{ shot.label }}</figcaption>
</figure> </figure>
</div> </div>
@@ -908,12 +921,20 @@ function handleToggleFavorite() {
margin: 0; margin: 0;
} }
.detail-screenshot img { .detail-screenshot-image {
width: 100%; width: 100%;
aspect-ratio: 16 / 10; aspect-ratio: 16 / 10;
object-fit: cover; overflow: hidden;
border-radius: 12px; border-radius: 12px;
border: 1px solid #edf0f4; border: 1px solid #edf0f4;
background: #f8fafc;
cursor: zoom-in;
}
.detail-screenshot-image :deep(.el-image__inner) {
width: 100%;
height: 100%;
object-fit: cover;
} }
.detail-screenshot figcaption { .detail-screenshot figcaption {
@@ -996,8 +1017,7 @@ function handleToggleFavorite() {
margin-bottom: 0; margin-bottom: 0;
border: 1px solid rgba(245, 158, 11, 0.2); border: 1px solid rgba(245, 158, 11, 0.2);
background: background:
linear-gradient(135deg, rgba(255, 251, 235, 0.96), rgba(255, 247, 237, 0.92)), linear-gradient(135deg, rgba(255, 251, 235, 0.96), rgba(255, 247, 237, 0.92)), #fffbeb;
#fffbeb;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.86); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.86);
} }
@@ -133,6 +133,7 @@ const detailScreenshots = computed(() => {
url, url,
})) }))
}) })
const detailScreenshotUrls = computed(() => detailScreenshots.value.map(shot => shot.url))
function readGroupedScreenshots(item: Listing) { function readGroupedScreenshots(item: Listing) {
const groups = item.asset_summary?.screenshot_groups const groups = item.asset_summary?.screenshot_groups
@@ -328,7 +329,9 @@ function handleToggleFavorite() {
:source="detailScreenshots[0].url" :source="detailScreenshots[0].url"
:alt="getListingTitle(listing)" :alt="getListingTitle(listing)"
image-class="cover-img" image-class="cover-img"
fit="cover"
loading="eager" loading="eager"
:preview-src-list="detailScreenshotUrls"
/> />
<div v-else class="cover-placeholder"> <div v-else class="cover-placeholder">
<van-icon name="photo-o" :size="40" color="#ccc" /> <van-icon name="photo-o" :size="40" color="#ccc" />
@@ -449,8 +452,20 @@ function handleToggleFavorite() {
<div v-if="detailScreenshots.length" class="info-card"> <div v-if="detailScreenshots.length" class="info-card">
<h3 class="card-subtitle">账号截图</h3> <h3 class="card-subtitle">账号截图</h3>
<div class="screenshot-grid"> <div class="screenshot-grid">
<figure v-for="shot in detailScreenshots" :key="shot.url" class="screenshot-item"> <figure
<AuthImage :source="shot.url" :alt="shot.label" image-class="screenshot-thumb" /> v-for="(shot, index) in detailScreenshots"
:key="shot.url"
class="screenshot-item"
>
<AuthImage
:source="shot.url"
:alt="shot.label"
image-class="screenshot-thumb"
fallback-class="screenshot-thumb"
fit="cover"
:preview-src-list="detailScreenshotUrls"
:preview-initial-index="index"
/>
<figcaption>{{ shot.label }}</figcaption> <figcaption>{{ shot.label }}</figcaption>
</figure> </figure>
</div> </div>
@@ -630,6 +645,12 @@ function handleToggleFavorite() {
} }
.cover-img { .cover-img {
width: 100%;
height: 100%;
cursor: zoom-in;
}
.cover-img :deep(.el-image__inner) {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
@@ -876,11 +897,18 @@ function handleToggleFavorite() {
.screenshot-thumb { .screenshot-thumb {
width: 100%; width: 100%;
aspect-ratio: 4 / 3; aspect-ratio: 4 / 3;
object-fit: cover; overflow: hidden;
border-radius: 8px; border-radius: 8px;
background: #f8fafc;
cursor: pointer; cursor: pointer;
} }
.screenshot-thumb :deep(.el-image__inner) {
width: 100%;
height: 100%;
object-fit: cover;
}
.screenshot-item figcaption { .screenshot-item figcaption {
margin-top: 4px; margin-top: 4px;
color: #6b7280; color: #6b7280;
@@ -14,6 +14,7 @@ const props = withDefaults(
decoding?: 'async' | 'sync' | 'auto' decoding?: 'async' | 'sync' | 'auto'
previewSrcList?: string[] previewSrcList?: string[]
previewTeleported?: boolean previewTeleported?: boolean
previewInitialIndex?: number
fit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down' fit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down'
imageStyle?: any imageStyle?: any
}>(), }>(),
@@ -25,6 +26,7 @@ const props = withDefaults(
loading: 'lazy', loading: 'lazy',
decoding: 'async', decoding: 'async',
previewTeleported: true, previewTeleported: true,
previewInitialIndex: 0,
} }
) )
@@ -105,11 +107,14 @@ onBeforeUnmount(cleanup)
<template> <template>
<el-image <el-image
v-if="imageURL && usePreview" v-if="imageURL && usePreview"
:class="imageClass"
:src="imageURL" :src="imageURL"
:alt="alt"
:fit="fit" :fit="fit"
:style="imageStyle" :style="imageStyle"
:preview-src-list="previewURLs" :preview-src-list="previewURLs"
:preview-teleported="previewTeleported" :preview-teleported="previewTeleported"
:initial-index="previewInitialIndex"
/> />
<img <img
v-else-if="imageURL" v-else-if="imageURL"
+82 -20
View File
@@ -523,53 +523,97 @@ h1 {
/* 筛选和操作行 */ /* 筛选和操作行 */
.listing-filter-row { .listing-filter-row {
display: flex; display: grid;
align-items: center; grid-template-columns: minmax(0, 1fr) auto;
justify-content: space-between; align-items: end;
gap: 16px; gap: 16px 24px;
padding-top: 4px; padding-top: 16px;
border-top: 1px solid #f0f3f7; border-top: 1px solid #f0f3f7;
} }
.listing-filter-bar { .listing-filter-bar {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: minmax(220px, 1.2fr) minmax(150px, 0.78fr) minmax(220px, 1fr) minmax(
align-items: center; 220px,
gap: 0; 1fr
);
align-items: end;
gap: 12px 16px;
margin: 0; margin: 0;
row-gap: 10px; min-width: 0;
} }
.listing-filter-bar .el-form-item { .listing-filter-bar .el-form-item {
margin-bottom: 0; margin-bottom: 0;
margin-right: 20px; min-width: 0;
}
.listing-filter-bar .el-form-item:last-child {
margin-right: 0;
} }
.listing-filter-bar .el-form-item__label { .listing-filter-bar .el-form-item__label {
display: block;
height: 16px;
padding: 0;
margin-bottom: 6px;
color: #6b7785; color: #6b7785;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
margin-bottom: 6px;
line-height: 1; line-height: 1;
} }
.listing-filter-bar .el-form-item__content {
width: 100%;
min-width: 0;
}
.listing-filter-bar .el-input,
.listing-filter-bar .el-select,
.listing-filter-bar .admin-filter-chip,
.listing-filter-bar .admin-range-filter {
width: 100%;
}
.listing-filter-bar .el-input__wrapper,
.listing-filter-bar .el-select__wrapper {
min-height: 34px;
}
.filter-skin {
min-width: 0;
}
.admin-range-filter {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 6px;
}
.admin-range-filter .el-input-number {
width: 100%;
}
.admin-range-filter .el-input__wrapper {
padding-right: 8px;
padding-left: 8px;
}
.admin-range-separator {
color: #94a3b8;
font-size: 12px;
font-weight: 700;
}
.listing-action-strip { .listing-action-strip {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex-shrink: 0; flex-shrink: 0;
padding-bottom: 1px; padding-bottom: 0;
white-space: nowrap; white-space: nowrap;
} }
.admin-filter-chip { .admin-filter-chip {
display: inline-flex; display: inline-flex;
width: 104px; height: 34px;
height: 32px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 6px; gap: 6px;
@@ -588,7 +632,7 @@ h1 {
} }
.admin-filter-chip.wide { .admin-filter-chip.wide {
width: 150px; width: 100%;
} }
.admin-filter-chip span { .admin-filter-chip span {
@@ -937,12 +981,30 @@ h1 {
margin-left: 0; margin-left: 0;
} }
@media (max-width: 1280px) {
.listing-filter-row {
grid-template-columns: 1fr;
}
.listing-filter-bar {
grid-template-columns: repeat(2, minmax(180px, 1fr));
}
.listing-action-strip {
justify-content: flex-end;
}
}
@media (max-width: 980px) { @media (max-width: 980px) {
.listing-control-panel { .listing-control-panel {
grid-template-columns: 1fr; grid-template-columns: 1fr;
align-items: stretch; align-items: stretch;
} }
.listing-filter-row {
grid-template-columns: 1fr;
}
.listing-filter-bar { .listing-filter-bar {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }