From fbf1b37399a50efd0f1614bdc0883567aab2e96a Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 4 Jun 2026 23:44:16 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=90=8E=E5=8F=B0=E5=95=86?= =?UTF-8?q?=E5=93=81=E5=AE=A1=E6=A0=B8=E4=BB=B7=E6=A0=BC=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/modules/listing/dto.go | 6 + backend/internal/modules/listing/handler.go | 23 + .../internal/modules/listing/repository.go | 133 ++++++ backend/internal/modules/listing/service.go | 10 + .../internal/modules/listing/service_test.go | 18 +- backend/internal/router/router.go | 1 + .../admin/views/AdminListingReviewView.vue | 426 +++++++++++++++++- .../src/features/listings/api/listings.ts | 11 + .../seller/views/SellerListingCreateView.css | 35 ++ .../seller/views/SellerListingCreateView.vue | 23 +- 10 files changed, 653 insertions(+), 33 deletions(-) diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go index c696470..d1a7f47 100644 --- a/backend/internal/modules/listing/dto.go +++ b/backend/internal/modules/listing/dto.go @@ -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 diff --git a/backend/internal/modules/listing/handler.go b/backend/internal/modules/listing/handler.go index 44a7a07..410359c 100644 --- a/backend/internal/modules/listing/handler.go +++ b/backend/internal/modules/listing/handler.go @@ -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 { diff --git a/backend/internal/modules/listing/repository.go b/backend/internal/modules/listing/repository.go index 4a73d2e..1bc7172 100644 --- a/backend/internal/modules/listing/repository.go +++ b/backend/internal/modules/listing/repository.go @@ -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)) diff --git a/backend/internal/modules/listing/service.go b/backend/internal/modules/listing/service.go index 9b3a18e..9dc664c 100644 --- a/backend/internal/modules/listing/service.go +++ b/backend/internal/modules/listing/service.go @@ -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 diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go index 4b0a0ff..d3ffc19 100644 --- a/backend/internal/modules/listing/service_test.go +++ b/backend/internal/modules/listing/service_test.go @@ -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) } } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 6151f5d..272f7c8 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) diff --git a/frontend/src/features/admin/views/AdminListingReviewView.vue b/frontend/src/features/admin/views/AdminListingReviewView.vue index 3b56cad..705c75c 100644 --- a/frontend/src/features/admin/views/AdminListingReviewView.vue +++ b/frontend/src/features/admin/views/AdminListingReviewView.vue @@ -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(null) const activeListing = ref(null) const evidenceListing = ref(null) const rejectReason = ref('') +const priceAdjustListing = ref(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) : {} +} + +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 = { + 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) { -
-
- 哈夫币 - {{ formatHafCoinM(getCoinWan(selectedListing)) }} +
+
+
+

价格审核

+

核对卖家提交价格、平台加价规则和最终买家展示价。

+
+ 调整价格
-
- 回收比例 - {{ formatRatio(selectedListing) }} -
-
- 回收租金 - {{ money(selectedListing.price) }} -
-
- 押金 - {{ money(selectedListing.deposit_amount) }} -
-
- 每日损耗 - {{ dailyLossText(selectedListing) }} +
+
+ 卖家发布 + {{ money(sellerTotalPrice(selectedListing)) }} +

发布比例 {{ ratioText(sellerRatio(selectedListing)) }}

+ 纯币 {{ money(sellerCoinBasePrice(selectedListing)) }} / 物品 {{ money(getListingConsumablePrice(selectedListing)) }} +
+
+ 当前加价规则 + {{ priceRuleLabel(selectedListing) }} +

{{ priceRuleText(selectedListing) }}

+ 平台加价 {{ money(platformMarkupAmount(selectedListing)) }} +
+
+ 加价后展示 + {{ money(buyerTotalPrice(selectedListing)) }} +

加价后比例 {{ ratioText(buyerRatio(selectedListing)) }}

+ 纯币 {{ money(buyerCoinBasePrice(selectedListing)) }} / 总价含物品 +
+
+ 押金与损耗 + {{ money(selectedListing.deposit_amount) }} +

每日损耗 {{ dailyLossText(selectedListing) }}

+ 哈夫币 {{ formatHafCoinM(getCoinWan(selectedListing)) }} +
@@ -534,6 +737,45 @@ function readError(error: unknown, fallback: string) { + +
+
+
+ 卖家发布价 + {{ money(sellerTotalPrice(priceAdjustListing)) }} +
+
+ 调整后买家价 + {{ previewMoney(priceAdjustPreview?.buyerTotalPrice || 0) }} +
+
+ 调整后买家比例 + {{ ratioText(priceAdjustPreview?.buyerRatio || 0) }} +
+
+ + 按加价后比例 + 按加价后价格 + + + + +
+ +
+

{{ evidenceListing.title }}

@@ -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; } diff --git a/frontend/src/features/listings/api/listings.ts b/frontend/src/features/listings/api/listings.ts index 8a673cb..2ca1d4f 100644 --- a/frontend/src/features/listings/api/listings.ts +++ b/frontend/src/features/listings/api/listings.ts @@ -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>(`/admin/listings/${id}/adjust-price`, payload) + return data.data +} + export async function rejectListing(id: number, reason: string) { const { data } = await apiClient.post>(`/admin/listings/${id}/reject`, { reason }) return data.data diff --git a/frontend/src/features/seller/views/SellerListingCreateView.css b/frontend/src/features/seller/views/SellerListingCreateView.css index c270be5..820712c 100644 --- a/frontend/src/features/seller/views/SellerListingCreateView.css +++ b/frontend/src/features/seller/views/SellerListingCreateView.css @@ -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; diff --git a/frontend/src/features/seller/views/SellerListingCreateView.vue b/frontend/src/features/seller/views/SellerListingCreateView.vue index f0f837f..c3b1642 100644 --- a/frontend/src/features/seller/views/SellerListingCreateView.vue +++ b/frontend/src/features/seller/views/SellerListingCreateView.vue @@ -485,19 +485,26 @@ function selectOnlineEnd(value: string | number) { 卖家发布价格 {{ calculatedSellerPrice ? `¥${calculatedSellerPrice}` : '--' }}
+
+
价格明细
+
+ 纯币价格 + {{ calculatedCoinBasePrice ? `¥${calculatedCoinBasePrice}` : '--' }} +
+
+ 额外物品价格 + ¥{{ calculatedConsumablePrice }} +
+
+ 押金价格 + {{ form.deposit_amount === '' ? '--' : `¥${form.deposit_amount}` }} +
+
哈夫币 {{ coinMAmount || '--' }}M
-
- 押金 - {{ form.deposit_amount === '' ? '--' : `¥${form.deposit_amount}` }} -
-
- 推荐押金 - ¥{{ recommendedDepositAmount }} -
截图材料 {{ uploadedScreenshotCount }}/{{ requiredScreenshotCount }}