实现外部商品调价

This commit is contained in:
yml2213
2026-07-26 19:17:09 +08:00
parent 6b44ab599d
commit 99e8eb28af
14 changed files with 1163 additions and 17 deletions
@@ -1,22 +1,34 @@
<script setup lang="ts">
import { readError } from '@/shared/utils/error'
import { CopyDocument, Search } from '@element-plus/icons-vue'
import { CopyDocument, EditPen, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchAdminUsers, type AdminUserItem } from '@/features/admin/api/adminUsers'
import { fetchAdminFileBlob } from '@/shared/api/files'
import AuthImage from '@/shared/components/business/AuthImage.vue'
import { adminPath } from '@/shared/utils/adminPath'
import { formatCentWithSymbol, formatMoneyWithSymbol, roundMoney } from '@/shared/utils/money'
import {
centToYuan,
formatCentWithSymbol,
formatMoneyWithSymbol,
roundMoney,
yuanToCent,
} from '@/shared/utils/money'
import {
adjustExternalListing,
adminMarkListingAbnormal,
adminOfflineListing,
adminTransferListingOwner,
emptyListingSalePriceConfig,
fetchAdminListing,
fetchListingSalePriceConfig,
type AdminExternalListingAdjustMode,
type PublishSalePriceConfig,
type Listing,
} from '@/features/listings'
import { useAdminSessionStore } from '@/stores/adminSession'
import {
listingReviewStatusLabel,
listingStatusLabel,
@@ -42,6 +54,9 @@ import {
readAssetNumber,
readAssetString,
} from '@/shared/utils/listingDisplay'
import { calculatePlatformPricing, roundRatio as roundPricingRatio } from '@/shared/utils/pricing'
const adminSession = useAdminSessionStore()
const route = useRoute()
const loading = ref(false)
@@ -55,6 +70,24 @@ const transferReason = ref('')
const userKeyword = ref('')
const userOptions = ref<AdminUserItem[]>([])
const selectedUser = ref<AdminUserItem | null>(null)
const externalAdjustVisible = ref(false)
const externalAdjustSaving = ref(false)
const salePriceConfig = ref<PublishSalePriceConfig>(emptyListingSalePriceConfig)
const externalAdjustModes: Array<{ label: string; value: AdminExternalListingAdjustMode }> = [
{ label: '按降价金额', value: 'discount_amount' },
{ label: '按发布比例', value: 'sale_ratio' },
]
const externalAdjustForm = reactive({
haf_coin_m: 0,
awm_bullets: 0,
level6_helmets: 0,
level6_armor: 0,
deposit_yuan: 0,
adjust_mode: 'discount_amount' as AdminExternalListingAdjustMode,
discount_amount_yuan: 0,
sale_ratio: 0,
reason: '',
})
const actionTitle = computed(() =>
actionType.value === 'offline' ? '强制下架商品' : '标记商品异常'
@@ -66,6 +99,15 @@ const canOperate = computed(
!['offline', 'abnormal'].includes(listing.value.status)
)
const canTransfer = computed(() => !!listing.value && listing.value.status !== 'rented')
const canAdjustExternal = computed(
() => !!listing.value?.is_external_upload && adminSession.hasPermission('listing:edit_external')
)
const externalAdjustmentLocked = computed(
() =>
!listing.value ||
listing.value.in_transaction ||
!['draft', 'published', 'offline', 'abnormal'].includes(listing.value.status)
)
const selectedUserAvailable = computed(
() => selectedUser.value?.status === 'active' && selectedUser.value.realname_status === 'verified'
)
@@ -109,6 +151,14 @@ const ownerRows = computed(() => {
{ label: '号主损耗', value: moneyYuan(ownerLoss) },
{ label: '号主租金合计', value: moneyYuan(ownerTotal) },
{ label: '平台费用', value: moneyYuan(platformFee) },
...(row.is_external_upload
? [
{ label: 'API参考比例', value: ratioText(externalReferenceRatio.value) },
{ label: '号主发布比例', value: ratioText(sellerSaleRatio(row)) },
{ label: '租客售卖比例', value: ratioText(buyerSaleRatio(row)) },
{ label: '价格标签', value: row.is_accelerated_sale ? '特惠' : '常规' },
]
: []),
{ label: '押金', value: moneyCent(row.deposit_amount_cent) },
{ label: '哈夫币', value: formatHafCoinM(getCoinWan(row)) },
{ label: '每日损耗', value: getDailyLoss(row) || '-' },
@@ -133,6 +183,68 @@ const hiddenSkinCount = computed(() => {
if (!listing.value) return 0
return Math.max(0, getSkinNames(listing.value).length - skinNames.value.length)
})
const externalReferenceRatio = computed(() => {
if (!listing.value) return 0
return (
breakdownNumber(listing.value, 'seller_reference_ratio') ||
breakdownNumber(listing.value, 'seller_ratio')
)
})
const externalAdjustmentPreview = computed(() => {
const coinM = Math.max(Number(externalAdjustForm.haf_coin_m || 0), 0)
const coinWan = coinM * 100
const referenceRatio = externalReferenceRatio.value
const referenceCoinPrice =
coinWan > 0 && referenceRatio > 0 ? roundMoney(coinWan / referenceRatio) : 0
let saleRatio = roundPricingRatio(Number(externalAdjustForm.sale_ratio || 0))
if (externalAdjustForm.adjust_mode === 'discount_amount') {
const desiredPrice = referenceCoinPrice - Number(externalAdjustForm.discount_amount_yuan || 0)
saleRatio = desiredPrice > 0 ? roundPricingRatio(coinWan / desiredPrice) : 0
}
const sellerCoinPrice = saleRatio > 0 ? roundMoney(coinWan / saleRatio) : 0
const actualDiscountAmount = Math.max(0, roundMoney(referenceCoinPrice - sellerCoinPrice))
const consumablePrice = roundMoney(
normalizedQuantity(externalAdjustForm.awm_bullets) * 0.6 +
normalizedQuantity(externalAdjustForm.level6_helmets) * 1.5 +
normalizedQuantity(externalAdjustForm.level6_armor) * 2.5
)
const sellerTotalPrice = roundMoney(sellerCoinPrice + consumablePrice)
const platformPricing = calculatePlatformPricing({
coinMAmount: coinM,
coinWanAmount: coinWan,
sellerRatio: saleRatio,
sellerCoinBasePrice: sellerCoinPrice,
sellerTotalPrice,
consumablePrice,
salePriceConfig: salePriceConfig.value,
})
const referencePlatformPricing = calculatePlatformPricing({
coinMAmount: coinM,
coinWanAmount: coinWan,
sellerRatio: referenceRatio,
sellerCoinBasePrice: referenceCoinPrice,
sellerTotalPrice: roundMoney(referenceCoinPrice + consumablePrice),
consumablePrice,
salePriceConfig: salePriceConfig.value,
})
return {
referenceRatio,
maxSaleRatio: roundPricingRatio(referenceRatio + 10),
referenceCoinPrice,
saleRatio,
sellerCoinPrice,
sellerTotalPrice,
consumablePrice,
actualDiscountAmount,
buyerRatio: platformPricing.buyerRatio,
buyerTotalPrice: platformPricing.buyerTotalPrice,
buyerDiscountAmount: Math.max(
0,
roundMoney(referencePlatformPricing.buyerTotalPrice - platformPricing.buyerTotalPrice)
),
isSale: referenceRatio > 0 && saleRatio > referenceRatio,
}
})
const statusTone = computed(() => {
if (!listing.value) return 'info'
if (listing.value.status === 'published') return 'success'
@@ -159,6 +271,96 @@ async function loadListing() {
}
}
async function openExternalAdjustment() {
if (!listing.value || !canAdjustExternal.value || externalAdjustmentLocked.value) return
const row = listing.value
const referenceRatio = externalReferenceRatio.value
const currentSaleRatio = sellerSaleRatio(row) || referenceRatio
const referenceCoinPrice = referenceRatio > 0 ? roundMoney(getCoinWan(row) / referenceRatio) : 0
const currentCoinPrice = sellerCoinBasePrice(row)
externalAdjustForm.haf_coin_m = Number((row.haf_coin_amount / 1000000).toFixed(1))
externalAdjustForm.awm_bullets = getResourceQuantity(row, 'awmAmmo')
externalAdjustForm.level6_helmets = getResourceQuantity(row, 'helmet6')
externalAdjustForm.level6_armor = getResourceQuantity(row, 'armor6')
externalAdjustForm.deposit_yuan = centToYuan(row.deposit_amount_cent)
externalAdjustForm.adjust_mode = 'discount_amount'
externalAdjustForm.discount_amount_yuan = Math.max(
0,
roundMoney(referenceCoinPrice - currentCoinPrice)
)
externalAdjustForm.sale_ratio = currentSaleRatio
externalAdjustForm.reason = ''
externalAdjustVisible.value = true
try {
salePriceConfig.value = await fetchListingSalePriceConfig()
} catch {
salePriceConfig.value = emptyListingSalePriceConfig
}
}
async function submitExternalAdjustment() {
if (!listing.value) return
const preview = externalAdjustmentPreview.value
if (externalAdjustForm.haf_coin_m <= 0) {
ElMessage.warning('请输入有效的纯币数量')
return
}
if (
[
externalAdjustForm.awm_bullets,
externalAdjustForm.level6_helmets,
externalAdjustForm.level6_armor,
].some(value => !Number.isInteger(Number(value)) || Number(value) < 0)
) {
ElMessage.warning('额外物品数量必须是非负整数')
return
}
if (
preview.saleRatio < preview.referenceRatio ||
preview.saleRatio > preview.maxSaleRatio ||
preview.sellerCoinPrice <= 0
) {
ElMessage.warning(
`发布比例应在 1:${ratioNumber(preview.referenceRatio)} 至 1:${ratioNumber(preview.maxSaleRatio)} 之间`
)
return
}
if (preview.consumablePrice > 0 && externalAdjustForm.deposit_yuan <= preview.consumablePrice) {
ElMessage.warning('押金必须大于额外物品总价值')
return
}
if (!externalAdjustForm.reason.trim()) {
ElMessage.warning('请填写调整原因')
return
}
externalAdjustSaving.value = true
try {
listing.value = await adjustExternalListing(listing.value.id, {
haf_coin_amount: Math.round(externalAdjustForm.haf_coin_m * 1000000),
awm_bullets: normalizedQuantity(externalAdjustForm.awm_bullets),
level6_helmets: normalizedQuantity(externalAdjustForm.level6_helmets),
level6_armor: normalizedQuantity(externalAdjustForm.level6_armor),
deposit_amount_cent: yuanToCent(externalAdjustForm.deposit_yuan),
adjust_mode: externalAdjustForm.adjust_mode,
discount_amount_cent:
externalAdjustForm.adjust_mode === 'discount_amount'
? yuanToCent(externalAdjustForm.discount_amount_yuan)
: undefined,
sale_ratio:
externalAdjustForm.adjust_mode === 'sale_ratio'
? Number(externalAdjustForm.sale_ratio)
: undefined,
reason: externalAdjustForm.reason.trim(),
})
externalAdjustVisible.value = false
ElMessage.success('外部商品已调整')
} catch (error) {
ElMessage.error(readError(error, '调整失败'))
} finally {
externalAdjustSaving.value = false
}
}
async function copyListingCode() {
if (!listing.value) return
try {
@@ -302,6 +504,34 @@ function platformMarkupAmount(row: Listing) {
return Math.max(0, roundMoney(buyerTotalPrice(row) - sellerTotalPrice(row)))
}
function sellerSaleRatio(row: Listing) {
const value = breakdownNumber(row, 'seller_ratio')
if (value > 0) return value
const price = sellerCoinBasePrice(row)
return price > 0 ? roundPricingRatio(getCoinWan(row) / price) : 0
}
function buyerSaleRatio(row: Listing) {
const value = breakdownNumber(row, 'buyer_ratio')
if (value > 0) return value
const price = buyerTotalPrice(row) - getListingConsumablePrice(row)
return price > 0 ? roundPricingRatio(getCoinWan(row) / price) : 0
}
function ratioNumber(value: number) {
const rounded = roundPricingRatio(value)
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
}
function ratioText(value: number) {
return value > 0 ? `1:${ratioNumber(value)}` : '-'
}
function normalizedQuantity(value: number) {
const quantity = Number(value || 0)
return Number.isFinite(quantity) ? Math.max(0, Math.trunc(quantity)) : 0
}
function assetText(row: Listing, key: string) {
const value = readAssetString(row, key)
if (value) return value
@@ -359,6 +589,9 @@ async function openScreenshot(url: string) {
<span>{{ listing.rank_level || '-' }}</span>
<span>{{ listing.login_platform || '-' }}</span>
<span>账号 {{ listing.account_id }}</span>
<span v-if="listing.is_external_upload"
>外部 API · {{ listing.source_channel || '未填写' }}</span
>
</div>
</div>
<div class="toolbar-actions">
@@ -366,6 +599,15 @@ async function openScreenshot(url: string) {
<RouterLink :to="adminPath('listings')">
<el-button>返回列表</el-button>
</RouterLink>
<el-button
v-if="canAdjustExternal"
type="primary"
:icon="EditPen"
:disabled="externalAdjustmentLocked"
@click="openExternalAdjustment"
>
调整商品
</el-button>
<el-button
class="transfer-owner-trigger"
type="success"
@@ -382,6 +624,152 @@ async function openScreenshot(url: string) {
</div>
</div>
<el-dialog
v-model="externalAdjustVisible"
title="调整外部商品"
width="760px"
class="external-adjust-dialog"
>
<el-form label-width="112px" class="external-adjust-form">
<div class="external-adjust-grid">
<el-form-item label="纯币数量(M)" required>
<el-input-number
v-model="externalAdjustForm.haf_coin_m"
:min="0.1"
:step="1"
:precision="1"
controls-position="right"
/>
</el-form-item>
<el-form-item label="押金(元)" required>
<el-input-number
v-model="externalAdjustForm.deposit_yuan"
:min="0"
:step="10"
:precision="1"
controls-position="right"
/>
</el-form-item>
<el-form-item label="AWM子弹">
<el-input-number
v-model="externalAdjustForm.awm_bullets"
:min="0"
:step="1"
:precision="0"
controls-position="right"
/>
</el-form-item>
<el-form-item label="六级头">
<el-input-number
v-model="externalAdjustForm.level6_helmets"
:min="0"
:step="1"
:precision="0"
controls-position="right"
/>
</el-form-item>
<el-form-item label="六级甲">
<el-input-number
v-model="externalAdjustForm.level6_armor"
:min="0"
:step="1"
:precision="0"
controls-position="right"
/>
</el-form-item>
</div>
<el-form-item label="调整方式" required>
<el-segmented v-model="externalAdjustForm.adjust_mode" :options="externalAdjustModes" />
</el-form-item>
<el-form-item
v-if="externalAdjustForm.adjust_mode === 'discount_amount'"
label="参考价降价(元)"
required
>
<el-input-number
v-model="externalAdjustForm.discount_amount_yuan"
:min="0"
:step="1"
:precision="1"
controls-position="right"
/>
</el-form-item>
<el-form-item v-else label="目标发布比例" required>
<el-input-number
v-model="externalAdjustForm.sale_ratio"
:min="externalAdjustmentPreview.referenceRatio"
:max="externalAdjustmentPreview.maxSaleRatio"
:step="0.1"
:precision="1"
controls-position="right"
/>
</el-form-item>
<div class="external-adjust-preview">
<div>
<span>API参考比例</span>
<strong>{{ ratioText(externalAdjustmentPreview.referenceRatio) }}</strong>
</div>
<div>
<span>调整后比例</span>
<strong>{{ ratioText(externalAdjustmentPreview.saleRatio) }}</strong>
</div>
<div>
<span>参考纯币价</span>
<strong>{{ moneyYuan(externalAdjustmentPreview.referenceCoinPrice) }}</strong>
</div>
<div>
<span>调整后纯币价</span>
<strong>{{ moneyYuan(externalAdjustmentPreview.sellerCoinPrice) }}</strong>
</div>
<div>
<span>号主实际让利</span>
<strong>{{ moneyYuan(externalAdjustmentPreview.actualDiscountAmount) }}</strong>
</div>
<div>
<span>额外物品</span>
<strong>{{ moneyYuan(externalAdjustmentPreview.consumablePrice) }}</strong>
</div>
<div>
<span>号主合计</span>
<strong>{{ moneyYuan(externalAdjustmentPreview.sellerTotalPrice) }}</strong>
</div>
<div>
<span>租客售价</span>
<strong>{{ moneyYuan(externalAdjustmentPreview.buyerTotalPrice) }}</strong>
</div>
<div>
<span>租客实际降价</span>
<strong>{{ moneyYuan(externalAdjustmentPreview.buyerDiscountAmount) }}</strong>
</div>
<div>
<span>展示状态</span>
<el-tag :type="externalAdjustmentPreview.isSale ? 'danger' : 'info'" effect="light">
{{ externalAdjustmentPreview.isSale ? '特惠' : '常规' }}
</el-tag>
</div>
</div>
<el-form-item label="调整原因" required>
<el-input
v-model="externalAdjustForm.reason"
type="textarea"
:rows="3"
maxlength="255"
show-word-limit
placeholder="填写用户申请内容或资产修正原因"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="externalAdjustVisible = false">取消</el-button>
<el-button type="primary" :loading="externalAdjustSaving" @click="submitExternalAdjustment">
保存调整
</el-button>
</template>
</el-dialog>
<div v-if="listing" class="listing-summary-bar">
<div>
<span>商品状态</span>
@@ -885,6 +1273,62 @@ async function openScreenshot(url: string) {
font-weight: 700;
}
.external-adjust-form {
display: grid;
gap: 4px;
}
:global(.external-adjust-dialog) {
max-width: calc(100vw - 32px);
}
:global(.external-adjust-dialog .el-dialog__body) {
max-height: calc(100vh - 180px);
overflow-y: auto;
}
.external-adjust-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 18px;
}
.external-adjust-grid :deep(.el-input-number),
.external-adjust-form :deep(.el-input-number) {
width: 100%;
}
.external-adjust-preview {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
margin: 4px 0 18px 112px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #e5e7eb;
}
.external-adjust-preview > div {
display: grid;
min-height: 70px;
align-content: center;
gap: 7px;
padding: 10px;
background: #f8fafc;
}
.external-adjust-preview span {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.external-adjust-preview strong {
color: #0f172a;
font-size: 16px;
}
.transfer-dialog {
display: grid;
gap: 18px;
@@ -1046,6 +1490,15 @@ async function openScreenshot(url: string) {
grid-template-columns: 1fr;
}
.external-adjust-grid,
.external-adjust-preview {
grid-template-columns: 1fr;
}
.external-adjust-preview {
margin-left: 0;
}
.transfer-arrow {
transform: rotate(90deg);
}