优化后台商品审核价格调整
This commit is contained in:
@@ -55,6 +55,12 @@ type ReviewRequest struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type AdminPriceAdjustRequest struct {
|
||||
BuyerRatio float64 `json:"buyer_ratio"`
|
||||
BuyerTotalPrice float64 `json:"buyer_total_price"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type AdminListQuery struct {
|
||||
OwnerID uint64
|
||||
Status string
|
||||
|
||||
@@ -203,6 +203,29 @@ func (h *Handler) Approve(c *gin.Context) {
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) AdjustReviewPrice(c *gin.Context) {
|
||||
adminID, ok := currentAdminID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少管理员上下文")
|
||||
return
|
||||
}
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req AdminPriceAdjustRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "调价参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.AdjustReviewPrice(adminID, id, req, auditMeta(c))
|
||||
if err != nil {
|
||||
writeListingError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) Reject(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -472,6 +473,97 @@ func (r *Repository) Approve(listingID uint64) (*ListingDTO, error) {
|
||||
return dto, err
|
||||
}
|
||||
|
||||
func (r *Repository) AdjustReviewPrice(adminID uint64, listingID uint64, req AdminPriceAdjustRequest, meta AuditMeta) (*ListingDTO, error) {
|
||||
var dto *ListingDTO
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
listing, account, err := r.findForReviewUpdate(tx, listingID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if listing.Status == "rented" || listing.InTransaction {
|
||||
return ErrListingLocked
|
||||
}
|
||||
summary := decodeAssetSummary(account.AssetSummary)
|
||||
if summary == nil {
|
||||
summary = map[string]any{}
|
||||
}
|
||||
breakdown := ensurePriceBreakdown(summary)
|
||||
coinWan := float64(account.HafCoinAmount) / 10000
|
||||
consumablePrice := readSummaryNumber(breakdown["consumable_price"])
|
||||
if consumablePrice <= 0 {
|
||||
consumablePrice = consumableValue(summary)
|
||||
}
|
||||
sellerTotalPrice := readSummaryNumber(breakdown["seller_total_price"])
|
||||
if sellerTotalPrice <= 0 {
|
||||
sellerTotalPrice = math.Max(0, listing.Price-consumablePrice)
|
||||
}
|
||||
sellerCoinBasePrice := readSummaryNumber(breakdown["seller_coin_base_price"])
|
||||
if sellerCoinBasePrice <= 0 {
|
||||
sellerCoinBasePrice = math.Max(0, sellerTotalPrice-consumablePrice)
|
||||
}
|
||||
sellerRatio := readSummaryNumber(breakdown["seller_ratio"])
|
||||
if sellerRatio <= 0 && sellerCoinBasePrice > 0 {
|
||||
sellerRatio = roundRatio(coinWan / sellerCoinBasePrice)
|
||||
}
|
||||
buyerCoinBasePrice, buyerTotalPrice, buyerRatio := calculateAdminAdjustedPrice(req, coinWan, consumablePrice)
|
||||
if buyerCoinBasePrice <= 0 || buyerTotalPrice <= 0 || buyerRatio <= 0 {
|
||||
return ErrInvalidPrice
|
||||
}
|
||||
|
||||
beforePrice := listing.Price
|
||||
beforeRatio := readSummaryNumber(breakdown["buyer_ratio"])
|
||||
if beforeRatio <= 0 && listing.Price > consumablePrice {
|
||||
beforeRatio = roundRatio(coinWan / (listing.Price - consumablePrice))
|
||||
}
|
||||
|
||||
listing.Price = buyerTotalPrice
|
||||
summary["publish_ratio"] = buyerRatio
|
||||
breakdown["seller_coin_base_price"] = roundMoney(sellerCoinBasePrice)
|
||||
breakdown["seller_total_price"] = roundMoney(sellerTotalPrice)
|
||||
breakdown["seller_ratio"] = sellerRatio
|
||||
breakdown["buyer_coin_base_price"] = buyerCoinBasePrice
|
||||
breakdown["buyer_total_price"] = buyerTotalPrice
|
||||
breakdown["buyer_ratio"] = buyerRatio
|
||||
breakdown["platform_markup_amount"] = roundMoney(buyerTotalPrice - sellerTotalPrice)
|
||||
breakdown["platform_rule_type"] = "admin_adjusted"
|
||||
breakdown["admin_adjust_reason"] = strings.TrimSpace(req.Reason)
|
||||
breakdown["admin_adjusted_at"] = time.Now().Format(time.RFC3339)
|
||||
breakdown["admin_adjusted_by"] = adminID
|
||||
summary["price_breakdown"] = breakdown
|
||||
|
||||
assetSummary, err := marshalAssetSummary(summary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
account.AssetSummary = assetSummary
|
||||
if err := tx.Save(account).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Save(listing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := appendAuditLog(tx, adminID, "listing.adjust_review_price", "listing", listing.ID, meta, map[string]any{
|
||||
"listing_id": listing.ID,
|
||||
"account_id": account.ID,
|
||||
"owner_id": listing.OwnerID,
|
||||
"before_price": beforePrice,
|
||||
"after_price": listing.Price,
|
||||
"before_buyer_ratio": beforeRatio,
|
||||
"after_buyer_ratio": buyerRatio,
|
||||
"platform_markup": breakdown["platform_markup_amount"],
|
||||
"adjust_reason": req.Reason,
|
||||
"buyer_coin_base": buyerCoinBasePrice,
|
||||
"consumable_price": consumablePrice,
|
||||
"seller_total_price": sellerTotalPrice,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
dto = toDTO(*account, *listing)
|
||||
return nil
|
||||
})
|
||||
return dto, err
|
||||
}
|
||||
|
||||
func (r *Repository) Reject(listingID uint64, req ReviewRequest) (*ListingDTO, error) {
|
||||
var dto *ListingDTO
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
@@ -1278,6 +1370,47 @@ func readSummaryNumber(value any) float64 {
|
||||
}
|
||||
}
|
||||
|
||||
func ensurePriceBreakdown(summary map[string]any) map[string]any {
|
||||
if summary == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
breakdown, ok := summary["price_breakdown"].(map[string]any)
|
||||
if ok {
|
||||
return breakdown
|
||||
}
|
||||
breakdown = map[string]any{}
|
||||
if raw, ok := summary["price_breakdown"].(map[string]interface{}); ok {
|
||||
for key, value := range raw {
|
||||
breakdown[key] = value
|
||||
}
|
||||
}
|
||||
return breakdown
|
||||
}
|
||||
|
||||
func calculateAdminAdjustedPrice(req AdminPriceAdjustRequest, coinWan float64, consumablePrice float64) (float64, float64, float64) {
|
||||
if req.BuyerTotalPrice > 0 {
|
||||
buyerTotalPrice := roundMoney(req.BuyerTotalPrice)
|
||||
buyerCoinBasePrice := roundMoney(buyerTotalPrice - consumablePrice)
|
||||
if buyerCoinBasePrice <= 0 || coinWan <= 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
return buyerCoinBasePrice, buyerTotalPrice, roundRatio(coinWan / buyerCoinBasePrice)
|
||||
}
|
||||
if req.BuyerRatio <= 0 || coinWan <= 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
buyerCoinBasePrice := roundMoney(coinWan / req.BuyerRatio)
|
||||
buyerTotalPrice := roundMoney(buyerCoinBasePrice + consumablePrice)
|
||||
return buyerCoinBasePrice, buyerTotalPrice, roundRatio(coinWan / buyerCoinBasePrice)
|
||||
}
|
||||
|
||||
func roundRatio(value float64) float64 {
|
||||
if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return 0
|
||||
}
|
||||
return math.Round(value*10) / 10
|
||||
}
|
||||
|
||||
func cleanScreenshotURLs(urls []string) []string {
|
||||
cleaned := make([]string, 0, len(urls))
|
||||
seen := make(map[string]struct{}, len(urls))
|
||||
|
||||
@@ -242,6 +242,16 @@ func (s *Service) Approve(id uint64) (*ListingDTO, error) {
|
||||
return s.repo.Approve(id)
|
||||
}
|
||||
|
||||
func (s *Service) AdjustReviewPrice(adminID uint64, id uint64, req AdminPriceAdjustRequest, meta AuditMeta) (*ListingDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
if req.BuyerRatio <= 0 && req.BuyerTotalPrice <= 0 {
|
||||
return nil, ErrInvalidPrice
|
||||
}
|
||||
return s.repo.AdjustReviewPrice(adminID, id, req, meta)
|
||||
}
|
||||
|
||||
func (s *Service) Reject(id uint64, req ReviewRequest) (*ListingDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
|
||||
@@ -14,8 +14,22 @@ func TestConsumableValueOnlyCountsChargedResources(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
if value != 2 {
|
||||
t.Fatalf("expected 2, got %.2f", value)
|
||||
if value != 1.5 {
|
||||
t.Fatalf("expected 1.5, got %.2f", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateAdminAdjustedPriceByRatio(t *testing.T) {
|
||||
base, total, ratio := calculateAdminAdjustedPrice(AdminPriceAdjustRequest{BuyerRatio: 25}, 1000, 20)
|
||||
if base != 40 || total != 60 || ratio != 25 {
|
||||
t.Fatalf("unexpected adjusted price: base=%.2f total=%.2f ratio=%.2f", base, total, ratio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateAdminAdjustedPriceByTotalPrice(t *testing.T) {
|
||||
base, total, ratio := calculateAdminAdjustedPrice(AdminPriceAdjustRequest{BuyerTotalPrice: 70}, 1000, 20)
|
||||
if base != 50 || total != 70 || ratio != 20 {
|
||||
t.Fatalf("unexpected adjusted price: base=%.2f total=%.2f ratio=%.2f", base, total, ratio)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -324,6 +324,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
adminRoutes.GET("/listings/pending", requirePerm("listing:approve"), listingHandler.ListPendingReview)
|
||||
adminRoutes.GET("/listings/:id", requirePerm("listing:view"), listingHandler.FindAdmin)
|
||||
adminRoutes.POST("/listings/:id/approve", requirePerm("listing:approve"), listingHandler.Approve)
|
||||
adminRoutes.POST("/listings/:id/adjust-price", requirePerm("listing:approve"), listingHandler.AdjustReviewPrice)
|
||||
adminRoutes.POST("/listings/:id/reject", requirePerm("listing:reject"), listingHandler.Reject)
|
||||
adminRoutes.POST("/listings/:id/offline", requirePerm("listing:offline"), listingHandler.AdminOffline)
|
||||
adminRoutes.POST("/listings/:id/mark-abnormal", requirePerm("listing:offline"), listingHandler.AdminMarkAbnormal)
|
||||
|
||||
@@ -4,11 +4,10 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { fetchAdminFileBlob } from '@/shared/api/files'
|
||||
import { approveListing, fetchPendingReviewListings, rejectListing, type Listing } from '@/features/listings'
|
||||
import { adjustListingReviewPrice, approveListing, fetchPendingReviewListings, rejectListing, type Listing } from '@/features/listings'
|
||||
import {
|
||||
assetRegions,
|
||||
formatHafCoinM,
|
||||
formatRatio,
|
||||
getCoinWan,
|
||||
getListingConsumablePrice,
|
||||
getListingResources,
|
||||
@@ -36,6 +35,14 @@ const selectedID = ref<number | null>(null)
|
||||
const activeListing = ref<Listing | null>(null)
|
||||
const evidenceListing = ref<Listing | null>(null)
|
||||
const rejectReason = ref('')
|
||||
const priceAdjustListing = ref<Listing | null>(null)
|
||||
const adjustingPrice = ref(false)
|
||||
const priceAdjustMode = ref<'ratio' | 'price'>('ratio')
|
||||
const priceAdjustForm = reactive({
|
||||
buyer_ratio: 0,
|
||||
buyer_total_price: 0,
|
||||
reason: '',
|
||||
})
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
risk: 'all',
|
||||
@@ -57,6 +64,7 @@ const filteredListings = computed(() => {
|
||||
const activeRisks = computed(() => (selectedListing.value ? riskItems(selectedListing.value) : []))
|
||||
const selectedResources = computed(() => (selectedListing.value ? getListingResources(selectedListing.value) : []))
|
||||
const selectedSkins = computed(() => (selectedListing.value ? getSkinNames(selectedListing.value) : []))
|
||||
const priceAdjustPreview = computed(() => (priceAdjustListing.value ? calculatePriceAdjustPreview(priceAdjustListing.value) : null))
|
||||
|
||||
watch(
|
||||
() => selectedListing.value,
|
||||
@@ -69,6 +77,16 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(priceAdjustMode, (mode, previousMode) => {
|
||||
if (!priceAdjustListing.value || !previousMode || mode === previousMode) return
|
||||
const preview = calculatePriceAdjustPreview(priceAdjustListing.value, previousMode)
|
||||
if (mode === 'price') {
|
||||
priceAdjustForm.buyer_total_price = preview.buyerTotalPrice || buyerTotalPrice(priceAdjustListing.value)
|
||||
} else {
|
||||
priceAdjustForm.buyer_ratio = preview.buyerRatio || buyerRatio(priceAdjustListing.value)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(loadListings)
|
||||
onBeforeUnmount(() => {
|
||||
createdObjectURLs.forEach((url) => URL.revokeObjectURL(url))
|
||||
@@ -90,6 +108,16 @@ function selectListing(row: Listing) {
|
||||
selectedID.value = row.id
|
||||
}
|
||||
|
||||
function replaceListing(next: Listing) {
|
||||
const index = listings.value.findIndex((item) => item.id === next.id)
|
||||
if (index >= 0) {
|
||||
listings.value.splice(index, 1, next)
|
||||
} else {
|
||||
listings.value.unshift(next)
|
||||
}
|
||||
selectedID.value = next.id
|
||||
}
|
||||
|
||||
async function handleApprove(row: Listing) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认通过「${row.title}」并上架?`, '审核通过确认', {
|
||||
@@ -117,6 +145,37 @@ function openReject(row: Listing) {
|
||||
rejectReason.value = row.review_reason || ''
|
||||
}
|
||||
|
||||
function openPriceAdjust(row: Listing) {
|
||||
priceAdjustListing.value = row
|
||||
priceAdjustMode.value = 'ratio'
|
||||
priceAdjustForm.buyer_ratio = buyerRatio(row)
|
||||
priceAdjustForm.buyer_total_price = buyerTotalPrice(row)
|
||||
priceAdjustForm.reason = ''
|
||||
}
|
||||
|
||||
async function handleSavePriceAdjust() {
|
||||
if (!priceAdjustListing.value) return
|
||||
const payload =
|
||||
priceAdjustMode.value === 'ratio'
|
||||
? { buyer_ratio: Number(priceAdjustForm.buyer_ratio || 0), reason: priceAdjustForm.reason.trim() }
|
||||
: { buyer_total_price: Number(priceAdjustForm.buyer_total_price || 0), reason: priceAdjustForm.reason.trim() }
|
||||
if ((payload.buyer_ratio || payload.buyer_total_price || 0) <= 0) {
|
||||
ElMessage.warning('请填写有效的加价后比例或价格')
|
||||
return
|
||||
}
|
||||
adjustingPrice.value = true
|
||||
try {
|
||||
const updated = await adjustListingReviewPrice(priceAdjustListing.value.id, payload)
|
||||
replaceListing(updated)
|
||||
priceAdjustListing.value = null
|
||||
ElMessage.success('价格已更新')
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '价格调整失败'))
|
||||
} finally {
|
||||
adjustingPrice.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function appendRejectReason(reason: string) {
|
||||
const current = rejectReason.value.trim()
|
||||
if (!current) {
|
||||
@@ -172,6 +231,137 @@ function assetNumberText(row: Listing, key: string) {
|
||||
return value > 0 ? quantity(value) : '-'
|
||||
}
|
||||
|
||||
function priceBreakdown(row: Listing) {
|
||||
const breakdown = row.asset_summary?.price_breakdown
|
||||
return typeof breakdown === 'object' && breakdown !== null ? (breakdown as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
function breakdownNumber(row: Listing, key: string) {
|
||||
const value = priceBreakdown(row)[key]
|
||||
if (typeof value === 'number') return value
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function ratioText(value: number) {
|
||||
return value > 0 ? `1:${quantity(value)}` : '-'
|
||||
}
|
||||
|
||||
function previewMoney(value: number) {
|
||||
return value > 0 ? money(value) : '--'
|
||||
}
|
||||
|
||||
function roundPreviewMoney(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return 0
|
||||
return Math.round(value * 10) / 10
|
||||
}
|
||||
|
||||
function roundPreviewRatio(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return 0
|
||||
return Math.round(value * 10) / 10
|
||||
}
|
||||
|
||||
function sellerTotalPrice(row: Listing) {
|
||||
const value = breakdownNumber(row, 'seller_total_price')
|
||||
if (value > 0) return value
|
||||
const fallback = Number(row.price || 0) - getListingConsumablePrice(row)
|
||||
return fallback > 0 ? fallback : Number(row.price || 0)
|
||||
}
|
||||
|
||||
function sellerCoinBasePrice(row: Listing) {
|
||||
const value = breakdownNumber(row, 'seller_coin_base_price')
|
||||
if (value > 0) return value
|
||||
return Math.max(0, sellerTotalPrice(row) - getListingConsumablePrice(row))
|
||||
}
|
||||
|
||||
function sellerRatio(row: Listing) {
|
||||
const value = breakdownNumber(row, 'seller_ratio') || breakdownNumber(row, 'seller_reference_ratio')
|
||||
if (value > 0) return value
|
||||
const base = sellerCoinBasePrice(row)
|
||||
return base > 0 ? getCoinWan(row) / base : 0
|
||||
}
|
||||
|
||||
function buyerTotalPrice(row: Listing) {
|
||||
const value = breakdownNumber(row, 'buyer_total_price')
|
||||
return value > 0 ? value : Number(row.price || 0)
|
||||
}
|
||||
|
||||
function buyerCoinBasePrice(row: Listing) {
|
||||
const value = breakdownNumber(row, 'buyer_coin_base_price')
|
||||
if (value > 0) return value
|
||||
return Math.max(0, buyerTotalPrice(row) - getListingConsumablePrice(row))
|
||||
}
|
||||
|
||||
function buyerRatio(row: Listing) {
|
||||
const value = breakdownNumber(row, 'buyer_ratio')
|
||||
if (value > 0) return value
|
||||
const base = buyerCoinBasePrice(row)
|
||||
return base > 0 ? getCoinWan(row) / base : 0
|
||||
}
|
||||
|
||||
function calculatePriceAdjustPreview(row: Listing, mode: 'ratio' | 'price' = priceAdjustMode.value) {
|
||||
const consumablePrice = getListingConsumablePrice(row)
|
||||
const coinWan = getCoinWan(row)
|
||||
if (mode === 'price') {
|
||||
const buyerTotalPrice = roundPreviewMoney(Number(priceAdjustForm.buyer_total_price || 0))
|
||||
const buyerCoinBasePrice = roundPreviewMoney(buyerTotalPrice - consumablePrice)
|
||||
const buyerRatio = buyerCoinBasePrice > 0 ? roundPreviewRatio(coinWan / buyerCoinBasePrice) : 0
|
||||
return { buyerCoinBasePrice, buyerTotalPrice, buyerRatio }
|
||||
}
|
||||
const buyerRatioInput = Number(priceAdjustForm.buyer_ratio || 0)
|
||||
const buyerCoinBasePrice = buyerRatioInput > 0 ? roundPreviewMoney(coinWan / buyerRatioInput) : 0
|
||||
const buyerTotalPrice = buyerCoinBasePrice > 0 ? roundPreviewMoney(buyerCoinBasePrice + consumablePrice) : 0
|
||||
const buyerRatio = buyerCoinBasePrice > 0 ? roundPreviewRatio(coinWan / buyerCoinBasePrice) : 0
|
||||
return { buyerCoinBasePrice, buyerTotalPrice, buyerRatio }
|
||||
}
|
||||
|
||||
function platformMarkupAmount(row: Listing) {
|
||||
const value = breakdownNumber(row, 'platform_markup_amount')
|
||||
if (value > 0) return value
|
||||
return Math.max(0, buyerTotalPrice(row) - sellerTotalPrice(row))
|
||||
}
|
||||
|
||||
function priceRuleType(row: Listing) {
|
||||
const value = priceBreakdown(row).platform_rule_type
|
||||
return typeof value === 'string' && value ? value : 'none'
|
||||
}
|
||||
|
||||
function priceRuleLabel(row: Listing) {
|
||||
const labels: Record<string, string> = {
|
||||
fixed_markup: '固定加价',
|
||||
ratio_subtract: '比例加价',
|
||||
external_upload: '外部上传价',
|
||||
admin_adjusted: '后台已调整',
|
||||
none: '无加价',
|
||||
}
|
||||
return labels[priceRuleType(row)] || priceRuleType(row)
|
||||
}
|
||||
|
||||
function breakdownText(row: Listing, key: string) {
|
||||
const value = priceBreakdown(row)[key]
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : ''
|
||||
}
|
||||
|
||||
function adminAdjustReason(row: Listing) {
|
||||
return breakdownText(row, 'admin_adjust_reason')
|
||||
}
|
||||
|
||||
function priceRuleText(row: Listing) {
|
||||
const markup = platformMarkupAmount(row)
|
||||
if (priceRuleType(row) === 'admin_adjusted') {
|
||||
const reason = adminAdjustReason(row)
|
||||
return reason ? `原因:${reason}` : `${priceRuleLabel(row)} · 人工确认`
|
||||
}
|
||||
if (priceRuleType(row) === 'ratio_subtract') {
|
||||
return `${priceRuleLabel(row)} · ${ratioText(sellerRatio(row))} → ${ratioText(buyerRatio(row))}`
|
||||
}
|
||||
if (markup > 0) return `${priceRuleLabel(row)} · +${money(markup)}`
|
||||
return priceRuleLabel(row)
|
||||
}
|
||||
|
||||
function dailyLossText(row: Listing) {
|
||||
const value = readAssetNumber(row, 'daily_loss_m')
|
||||
return value > 0 ? `${quantity(value)}M` : '未上传'
|
||||
@@ -416,26 +606,39 @@ function readError(error: unknown, fallback: string) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="metric-grid review-metrics">
|
||||
<div class="metric-card">
|
||||
<span>哈夫币</span>
|
||||
<strong>{{ formatHafCoinM(getCoinWan(selectedListing)) }}</strong>
|
||||
<section class="review-panel price-review-panel">
|
||||
<div class="panel-title price-panel-title">
|
||||
<div>
|
||||
<h3>价格审核</h3>
|
||||
<p>核对卖家提交价格、平台加价规则和最终买家展示价。</p>
|
||||
</div>
|
||||
<el-button type="primary" plain @click="openPriceAdjust(selectedListing)">调整价格</el-button>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>回收比例</span>
|
||||
<strong>{{ formatRatio(selectedListing) }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>回收租金</span>
|
||||
<strong>{{ money(selectedListing.price) }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>押金</span>
|
||||
<strong>{{ money(selectedListing.deposit_amount) }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>每日损耗</span>
|
||||
<strong>{{ dailyLossText(selectedListing) }}</strong>
|
||||
<div class="price-decision-grid">
|
||||
<div class="price-decision-card seller">
|
||||
<span>卖家发布</span>
|
||||
<strong>{{ money(sellerTotalPrice(selectedListing)) }}</strong>
|
||||
<p>发布比例 {{ ratioText(sellerRatio(selectedListing)) }}</p>
|
||||
<small>纯币 {{ money(sellerCoinBasePrice(selectedListing)) }} / 物品 {{ money(getListingConsumablePrice(selectedListing)) }}</small>
|
||||
</div>
|
||||
<div class="price-decision-card rule">
|
||||
<span>当前加价规则</span>
|
||||
<strong>{{ priceRuleLabel(selectedListing) }}</strong>
|
||||
<p>{{ priceRuleText(selectedListing) }}</p>
|
||||
<small>平台加价 {{ money(platformMarkupAmount(selectedListing)) }}</small>
|
||||
</div>
|
||||
<div class="price-decision-card buyer">
|
||||
<span>加价后展示</span>
|
||||
<strong>{{ money(buyerTotalPrice(selectedListing)) }}</strong>
|
||||
<p>加价后比例 {{ ratioText(buyerRatio(selectedListing)) }}</p>
|
||||
<small>纯币 {{ money(buyerCoinBasePrice(selectedListing)) }} / 总价含物品</small>
|
||||
</div>
|
||||
<div class="price-decision-card deposit">
|
||||
<span>押金与损耗</span>
|
||||
<strong>{{ money(selectedListing.deposit_amount) }}</strong>
|
||||
<p>每日损耗 {{ dailyLossText(selectedListing) }}</p>
|
||||
<small>哈夫币 {{ formatHafCoinM(getCoinWan(selectedListing)) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -534,6 +737,45 @@ function readError(error: unknown, fallback: string) {
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog :model-value="!!priceAdjustListing" title="调整审核价格" width="560px" @update:model-value="priceAdjustListing = null">
|
||||
<div v-if="priceAdjustListing" class="dialog-body price-adjust-dialog">
|
||||
<div class="adjust-current">
|
||||
<div>
|
||||
<span>卖家发布价</span>
|
||||
<strong>{{ money(sellerTotalPrice(priceAdjustListing)) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>调整后买家价</span>
|
||||
<strong class="preview-value">{{ previewMoney(priceAdjustPreview?.buyerTotalPrice || 0) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>调整后买家比例</span>
|
||||
<strong class="preview-value">{{ ratioText(priceAdjustPreview?.buyerRatio || 0) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-radio-group v-model="priceAdjustMode" class="adjust-mode">
|
||||
<el-radio-button label="ratio">按加价后比例</el-radio-button>
|
||||
<el-radio-button label="price">按加价后价格</el-radio-button>
|
||||
</el-radio-group>
|
||||
<label v-if="priceAdjustMode === 'ratio'" class="adjust-field">
|
||||
<span>加价后比例</span>
|
||||
<el-input-number v-model="priceAdjustForm.buyer_ratio" :min="0" :step="0.1" :controls="false" />
|
||||
</label>
|
||||
<label v-else class="adjust-field">
|
||||
<span>加价后价格</span>
|
||||
<el-input-number v-model="priceAdjustForm.buyer_total_price" :min="0" :step="1" :controls="false" />
|
||||
</label>
|
||||
<label class="adjust-field">
|
||||
<span>调价原因</span>
|
||||
<el-input v-model="priceAdjustForm.reason" type="textarea" :rows="3" placeholder="可填写调价原因,便于审计追踪" />
|
||||
</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="priceAdjustListing = null">取消</el-button>
|
||||
<el-button type="primary" :loading="adjustingPrice" @click="handleSavePriceAdjust">保存调整</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog :model-value="!!evidenceListing" title="账号资产截图" width="860px" @update:model-value="evidenceListing = null">
|
||||
<div v-if="evidenceListing" class="dialog-body">
|
||||
<p><strong>{{ evidenceListing.title }}</strong></p>
|
||||
@@ -580,7 +822,10 @@ function readError(error: unknown, fallback: string) {
|
||||
.queue-footer span,
|
||||
.panel-title span,
|
||||
.inventory-grid span,
|
||||
.resource-table span {
|
||||
.resource-table span,
|
||||
.price-decision-card span,
|
||||
.adjust-current span,
|
||||
.adjust-field span {
|
||||
color: #8f9bba;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
@@ -769,6 +1014,73 @@ function readError(error: unknown, fallback: string) {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.price-panel-title p {
|
||||
margin: 5px 0 0;
|
||||
color: #8f9bba;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.price-decision-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.price-decision-card {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
min-height: 128px;
|
||||
border: 1px solid #e8ecf1;
|
||||
border-radius: 8px;
|
||||
background: #f8f9fe;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.price-decision-card strong {
|
||||
color: #1b2559;
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.price-decision-card p,
|
||||
.price-decision-card small {
|
||||
margin: 0;
|
||||
color: #4b5563;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.price-decision-card small {
|
||||
color: #8f9bba;
|
||||
}
|
||||
|
||||
.price-decision-card.buyer {
|
||||
border-color: #bfdbfe;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.price-decision-card.buyer strong {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.price-decision-card.rule {
|
||||
border-color: #fed7aa;
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.price-decision-card.rule strong {
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.price-decision-card.deposit {
|
||||
border-color: #e0e7ff;
|
||||
background: #f5f7ff;
|
||||
}
|
||||
|
||||
.detail-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -919,6 +1231,70 @@ function readError(error: unknown, fallback: string) {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.price-adjust-dialog {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.adjust-current {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.adjust-current div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
border-radius: 8px;
|
||||
background: #f8f9fe;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.adjust-current strong {
|
||||
color: #1b2559;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.adjust-current .preview-value {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.adjust-mode {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.adjust-mode :deep(.el-radio-button) {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.adjust-mode :deep(.el-radio-button__inner) {
|
||||
width: 100%;
|
||||
border-color: #d7deea;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.adjust-mode :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
|
||||
border-color: #2563eb;
|
||||
background: #2563eb;
|
||||
box-shadow: -1px 0 0 0 #2563eb;
|
||||
}
|
||||
|
||||
.adjust-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.adjust-field > span {
|
||||
color: #52607a;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.adjust-field :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.empty-detail {
|
||||
min-height: 420px;
|
||||
display: grid;
|
||||
@@ -943,11 +1319,15 @@ function readError(error: unknown, fallback: string) {
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.review-summary,
|
||||
.review-metrics,
|
||||
.price-decision-grid,
|
||||
.review-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.adjust-current {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -188,6 +188,17 @@ export async function approveListing(id: number) {
|
||||
return data.data
|
||||
}
|
||||
|
||||
export interface AdminListingPriceAdjustPayload {
|
||||
buyer_ratio?: number
|
||||
buyer_total_price?: number
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export async function adjustListingReviewPrice(id: number, payload: AdminListingPriceAdjustPayload) {
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/adjust-price`, payload)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function rejectListing(id: number, reason: string) {
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/reject`, { reason })
|
||||
return data.data
|
||||
|
||||
@@ -708,6 +708,41 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.summary-breakdown {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid #ffe1c7;
|
||||
border-radius: var(--radius-8);
|
||||
background: #fffaf6;
|
||||
}
|
||||
|
||||
.summary-breakdown-title {
|
||||
color: var(--color-orange-dark);
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.summary-breakdown div:not(.summary-breakdown-title) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-breakdown span {
|
||||
color: #6f7a89;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.summary-breakdown strong {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--color-text-dark);
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.summary-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
||||
@@ -485,19 +485,26 @@ function selectOnlineEnd(value: string | number) {
|
||||
<span>卖家发布价格</span>
|
||||
<strong>{{ calculatedSellerPrice ? `¥${calculatedSellerPrice}` : '--' }}</strong>
|
||||
</div>
|
||||
<div class="summary-breakdown">
|
||||
<div class="summary-breakdown-title">价格明细</div>
|
||||
<div>
|
||||
<span>纯币价格</span>
|
||||
<strong>{{ calculatedCoinBasePrice ? `¥${calculatedCoinBasePrice}` : '--' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>额外物品价格</span>
|
||||
<strong>¥{{ calculatedConsumablePrice }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>押金价格</span>
|
||||
<strong>{{ form.deposit_amount === '' ? '--' : `¥${form.deposit_amount}` }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-list">
|
||||
<div>
|
||||
<span>哈夫币</span>
|
||||
<strong>{{ coinMAmount || '--' }}M</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>押金</span>
|
||||
<strong>{{ form.deposit_amount === '' ? '--' : `¥${form.deposit_amount}` }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>推荐押金</span>
|
||||
<strong>¥{{ recommendedDepositAmount }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>截图材料</span>
|
||||
<strong>{{ uploadedScreenshotCount }}/{{ requiredScreenshotCount }}</strong>
|
||||
|
||||
Reference in New Issue
Block a user