Files
hfb_sys/frontend/src/features/seller/views/MobileSellerListingCreateView.vue
T

809 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { showDialog, showToast } from 'vant'
import { computed, ref } from 'vue'
import MobileBottomNav from '@/components/MobileBottomNav.vue'
type OnlineTimeSide = 'start' | 'end'
import { usePublishForm } from '@/features/seller/composables/usePublishForm'
import { formatMoney } from '@/shared/utils/money'
import type { AgreementContent } from '@/features/listings/api/listingOptions'
import GuideImage from '@/components/GuideImage.vue'
import tencentSecurityGuide from '@/assets/tencent-security-guide.webp'
const {
dailyLossOptions,
formatNumber,
router,
loading,
uploading,
isEditMode,
pageTitle,
submitButtonText,
submitLoadingText,
publishAgreements,
virtualAssetSaleAgreementChecked,
sellerAgreementChecked,
passwordAndDeviceConfirmed,
canPublishAfterAgreements,
showBanRecordRiskHint,
fileInput,
activeUploadKey,
form,
quantityValues,
quantityModes,
selectedSkins,
serverOptions,
faceOptions,
rankOptions,
insuranceOptions,
levelOptions,
effectiveLoginMethodOptions,
isScanOnlyServer,
regionOptions,
banRecordOptions,
skinGroups,
quantityItems,
screenshotSlots,
priceConfig,
fireLevelPlaceholder,
dailyLossMAmount,
calculatedDefaultSaleRatio,
maxAcceleratedSaleRatio,
calculatedCoinBasePrice,
calculatedConsumablePrice,
calculatedSellerPrice,
calculatedRatioText,
calculatedDefaultSaleRatioText,
saleRatioRangeText,
acceleratedSaleRatioPlaceholder,
recommendedDepositAmount,
hasAcceleratedSaleRatioInput,
isQuantityItemDisabled,
handleResetDraft,
toggleSkin,
toggleRegion,
triggerUpload,
handleScreenshotUpload,
removeScreenshot,
getScreenshotURLs,
getScreenshotCount,
getScreenshotPreviewURL,
isMultiScreenshotSlot,
canAddScreenshot,
getScreenshotLimitHint,
isAllDayOnline,
onlineTimeRangeHint,
selectAllDayOnline,
handleOnlineStartChange,
handleOnlineEndChange,
handleFireLevelInput,
handleAcceleratedSaleRatioInput,
useRecommendedDeposit,
handleDepositBlur,
setQuantityMode,
clampAcceleratedSaleRatioInput,
useReferenceSaleRatio,
useMaxAcceleratedSaleRatio,
handleSubmit,
isScreenshotRequired,
} = usePublishForm({
draftKey: 'hfb.mobile.publish.draft',
submitSuccessPath: '/m/profile',
listingGroupChatPathPrefix: '/m/chats/',
async confirmReset() {
await showDialog({
title: '重置发布内容',
message: '将清空当前填写内容和本地草稿。',
confirmButtonText: '重置',
cancelButtonText: '取消',
showCancelButton: true,
})
},
notifySuccess: message => showToast({ message, icon: 'passed' }),
notifyWarning: message => showToast({ message, icon: 'warning-o' }),
notifyError: message => showToast({ message, icon: 'cross' }),
})
const agreementPopupVisible = ref(false)
const activeAgreement = ref<AgreementContent | null>(null)
const activeAgreementTitle = computed(() => activeAgreement.value?.title || '协议内容')
const activeAgreementContent = computed(() => activeAgreement.value?.content || '')
function openPublishAgreement(agreement: AgreementContent) {
activeAgreement.value = agreement
agreementPopupVisible.value = true
}
function selectRadio<T extends string>(value: T, setter: (value: T) => void) {
setter(value)
}
const unlockSaidOptions = ['是', '否']
// 在线时间:通栏自定义块 + 底部时间选择,避免 van-field 窄列截断原生 time
const onlineTimePickerVisible = ref(false)
const onlineTimePickerSide = ref<OnlineTimeSide>('start')
const onlineTimePickerValue = ref<string[]>(['00', '00'])
const onlineTimePickerTitle = computed(() =>
onlineTimePickerSide.value === 'start' ? '选择开始时间' : '选择结束时间'
)
function parseTimeToColumns(value: string): string[] {
const match = /^(\d{1,2}):(\d{2})$/.exec(String(value || '').trim())
if (!match?.[1] || !match[2]) return ['00', '00']
return [match[1].padStart(2, '0'), match[2]]
}
function openOnlineTimePicker(side: OnlineTimeSide) {
onlineTimePickerSide.value = side
const current = side === 'start' ? form.online_start : form.online_end
onlineTimePickerValue.value = parseTimeToColumns(current)
onlineTimePickerVisible.value = true
}
function onOnlineTimePickerConfirm(payload: { selectedValues?: Array<string | number> }) {
const values = payload.selectedValues || onlineTimePickerValue.value
const hour = String(values[0] ?? '00').padStart(2, '0')
const minute = String(values[1] ?? '00').padStart(2, '0')
const time = `${hour}:${minute}`
if (onlineTimePickerSide.value === 'start') {
handleOnlineStartChange(time)
} else {
handleOnlineEndChange(time)
}
onlineTimePickerVisible.value = false
}
// 截图槽位的指导示例图(仅展示、点击放大,不占用上传数量)
const screenshotGuides: Record<string, string> = {
tencentSecurity: tencentSecurityGuide,
}
</script>
<template>
<main class="mobile-publish">
<header class="page-header">
<button class="back-btn" type="button" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
</button>
<h1>{{ pageTitle }}</h1>
<span class="header-spacer"></span>
</header>
<section class="form-body">
<div class="form-section">
<h2 class="section-title">基础资料</h2>
<van-field label="区服" required class="publish-field">
<template #input>
<div class="radio-group">
<button
v-for="opt in serverOptions"
:key="opt"
type="button"
class="radio-btn"
:class="{ active: form.server_region === opt }"
@click="selectRadio(opt, value => (form.server_region = value))"
>
{{ opt }}
</button>
</div>
</template>
</van-field>
<van-field label="是否本人人脸" class="publish-field">
<template #input>
<div class="radio-group">
<button
v-for="opt in faceOptions"
:key="opt"
type="button"
class="radio-btn"
:class="{ active: form.face_owner === opt }"
@click="selectRadio(opt, value => (form.face_owner = value))"
>
{{ opt }}
</button>
</div>
</template>
</van-field>
<van-field
v-model="form.haf_coin_amount"
label="哈夫币/M"
type="number"
required
placeholder="只填写仓库右上角纯币数额,总资产不计算在内;100M 写 100"
class="publish-field"
/>
<van-field label="段位" required class="publish-field">
<template #input>
<div class="radio-group">
<button
v-for="opt in rankOptions"
:key="opt"
type="button"
class="radio-btn"
:class="{ active: form.rank_level === opt }"
@click="selectRadio(opt, value => (form.rank_level = value))"
>
{{ opt }}
</button>
</div>
</template>
</van-field>
<van-field
v-model="form.secret_kd"
label="绝密KD"
type="number"
placeholder="填写绝密KD,如果数据对不上买家可以申请退款"
class="publish-field"
/>
<van-field
:model-value="form.fire_level"
label="烽火等级"
type="digit"
required
:placeholder="fireLevelPlaceholder"
class="publish-field"
@update:model-value="handleFireLevelInput"
/>
<van-field label="赛季保险" required class="publish-field">
<template #input>
<div class="radio-group">
<button
v-for="opt in insuranceOptions"
:key="opt"
type="button"
class="radio-btn"
:class="{ active: form.season_insurance === opt }"
@click="selectRadio(opt, value => (form.season_insurance = value))"
>
{{ opt }}
</button>
</div>
</template>
</van-field>
<div class="level-panel">
<div class="level-row">
<div class="level-label"><span>*</span>体力</div>
<div class="level-options">
<button
v-for="opt in levelOptions"
:key="`stamina-${opt}`"
type="button"
class="level-btn"
:class="{ active: form.stamina_level === opt }"
@click="selectRadio(opt, value => (form.stamina_level = value))"
>
{{ opt }}
</button>
</div>
</div>
<div class="level-row">
<div class="level-label"><span>*</span>负重</div>
<div class="level-options">
<button
v-for="opt in levelOptions"
:key="`load-${opt}`"
type="button"
class="level-btn"
:class="{ active: form.load_level === opt }"
@click="selectRadio(opt, value => (form.load_level = value))"
>
{{ opt }}
</button>
</div>
</div>
</div>
</div>
<div class="form-section">
<h2 class="section-title">额外消耗品</h2>
<div
v-for="item in quantityItems"
:key="item.key"
class="quantity-row"
:class="{ disabled: isQuantityItemDisabled(item) }"
>
<div class="quantity-card">
<div class="quantity-meta">
<strong
class="backend-hint-target"
:class="{ 'has-hint': Boolean(item.placeholder) }"
:data-hint="item.placeholder"
:tabindex="item.placeholder ? 0 : -1"
>
<span>*</span>{{ item.label }}
</strong>
<small>{{
isQuantityItemDisabled(item) ? '3*3 已包含,不能填写' : item.price
}}</small>
</div>
<input
v-model="quantityValues[item.key]"
type="number"
inputmode="numeric"
min="0"
placeholder="0"
class="quantity-input"
:disabled="isQuantityItemDisabled(item)"
/>
</div>
<div class="mode-toggle">
<button
type="button"
:class="{ active: quantityModes[item.key] === '赠送' }"
:disabled="isQuantityItemDisabled(item)"
@click="setQuantityMode(item, '赠送')"
>
赠送
</button>
<button
type="button"
:class="{ active: (quantityModes[item.key] || '收费') === '收费' }"
:disabled="isQuantityItemDisabled(item)"
@click="setQuantityMode(item, '收费')"
>
收费
</button>
</div>
</div>
</div>
<div class="form-section">
<h2 class="section-title">皮肤与交接</h2>
<div class="skin-groups">
<div v-for="group in skinGroups" :key="group.key" class="skin-group">
<div class="skin-group-title">
<span>{{ group.title }}</span>
<small>
{{ group.options.filter(skin => selectedSkins.includes(skin)).length }}
</small>
</div>
<div class="multi-chip-group">
<button
v-for="skin in group.options"
:key="skin"
type="button"
class="radio-btn"
:class="{ active: selectedSkins.includes(skin) }"
@click="toggleSkin(skin)"
>
{{ skin }}
</button>
</div>
</div>
</div>
<van-field label="上号方式" class="publish-field">
<template #input>
<div class="radio-group">
<button
v-for="opt in effectiveLoginMethodOptions"
:key="opt"
type="button"
class="radio-btn"
:class="{ active: form.login_method === opt }"
@click="selectRadio(opt, value => (form.login_method = value))"
>
{{ opt }}
</button>
</div>
</template>
</van-field>
<p class="field-hint">
{{
isScanOnlyServer
? '微信账号仅支持扫码登录,上号方式固定为扫码。'
: '优先推荐【账密登录】,出售速度约为扫码的数倍,两种方式安全性一致,账密登录更便捷,请自行衡量'
}}
</p>
<van-field label="解锁赛伊德" required class="publish-field">
<template #input>
<div class="radio-group">
<button
v-for="opt in unlockSaidOptions"
:key="opt"
type="button"
class="radio-btn"
:class="{ active: form.unlock_said === opt }"
@click="selectRadio(opt, value => (form.unlock_said = value))"
>
{{ opt }}
</button>
</div>
</template>
</van-field>
<div class="online-time-block">
<div class="online-time-head">
<div class="online-time-title">
<span class="req">*</span>
<span>在线时间</span>
</div>
<button
type="button"
class="radio-btn online-time-allday"
:class="{ active: isAllDayOnline() }"
@click="selectAllDayOnline"
>
全天
</button>
</div>
<div class="online-time-grid">
<button type="button" class="online-time-pick" @click="openOnlineTimePicker('start')">
<span class="online-time-label">开始</span>
<strong :class="{ placeholder: !form.online_start }">
{{ form.online_start || '请选择' }}
</strong>
</button>
<button type="button" class="online-time-pick" @click="openOnlineTimePicker('end')">
<span class="online-time-label">
结束
<em v-if="onlineTimeRangeHint()">次日</em>
</span>
<strong :class="{ placeholder: !form.online_end }">
{{ form.online_end || '请选择' }}
</strong>
</button>
</div>
<p v-if="onlineTimeRangeHint()" class="online-time-summary">
{{ onlineTimeRangeHint() }}
</p>
</div>
<p class="field-hint">
请填写能百分百联系上您的时间该时段联系不上可能影响上架或扣款请预留扫码与冻结人脸时间
</p>
<van-popup v-model:show="onlineTimePickerVisible" position="bottom" round teleport="body">
<van-time-picker
v-model="onlineTimePickerValue"
:title="onlineTimePickerTitle"
:columns-type="['hour', 'minute']"
@confirm="onOnlineTimePickerConfirm"
@cancel="onlineTimePickerVisible = false"
/>
</van-popup>
<van-field label="封禁记录" required class="publish-field">
<template #input>
<div class="radio-group">
<button
v-for="opt in banRecordOptions"
:key="opt"
type="button"
class="radio-btn"
:class="{ active: form.ban_record === opt }"
@click="selectRadio(opt, value => (form.ban_record = value))"
>
{{ opt }}
</button>
</div>
</template>
</van-field>
<p v-if="showBanRecordRiskHint && banRecordOptions.length" class="field-hint">
刚出租结算完的账号需冷号七天才可再次出租IP频繁变动会导致封号
</p>
<div class="region-panel">
<div class="region-title">
<span>常用登录地区</span>
<small>已选 {{ form.common_regions.length }}</small>
</div>
<div class="region-grid">
<button
v-for="region in regionOptions"
:key="region"
type="button"
class="region-btn"
:class="{ active: form.common_regions.includes(region) }"
@click="toggleRegion(region)"
>
{{ region }}
</button>
</div>
</div>
<p class="field-hint">
推荐勾选账号常用登录地便于同地区玩家租用可大幅减少账号触发异地登录保护
</p>
</div>
<div class="form-section">
<h2 class="section-title">截图材料</h2>
<div class="upload-list">
<div v-for="slot in screenshotSlots" :key="slot.key" class="upload-line">
<div class="upload-meta">
<strong
class="upload-title"
:class="{ 'has-hint': Boolean(slot.hint) }"
:data-hint="slot.hint"
:tabindex="slot.hint ? 0 : -1"
>
{{ slot.label }}<span v-if="isScreenshotRequired(slot)">*</span>
</strong>
<small>{{ slot.hint }}</small>
<small v-if="getScreenshotLimitHint(slot.key)">{{
getScreenshotLimitHint(slot.key)
}}</small>
<GuideImage
v-if="screenshotGuides[slot.key]"
:src="screenshotGuides[slot.key] || ''"
label="点击查看处罚截图教程"
/>
</div>
<div v-if="getScreenshotCount(slot.key)" class="upload-preview-list">
<div
v-for="(url, index) in getScreenshotURLs(slot.key)"
:key="`${slot.key}-${url}`"
class="upload-preview"
>
<img
:src="getScreenshotPreviewURL(slot.key, index)"
:alt="`${slot.label}${index + 1}`"
/>
<button type="button" @click="removeScreenshot(slot.key, index)">移除</button>
</div>
<button
v-if="canAddScreenshot(slot.key)"
type="button"
class="upload-add"
:disabled="uploading"
@click="triggerUpload(slot.key)"
>
<van-icon name="photograph" :size="22" color="#999" />
<span>{{ uploading && activeUploadKey === slot.key ? '上传中' : '继续上传' }}</span>
</button>
</div>
<button
v-else
type="button"
class="upload-add"
:disabled="uploading"
@click="triggerUpload(slot.key)"
>
<van-icon name="photograph" :size="22" color="#999" />
<span>{{ uploading && activeUploadKey === slot.key ? '上传中' : '上传' }}</span>
</button>
</div>
</div>
<input
ref="fileInput"
type="file"
accept="image/jpeg,image/png,image/webp"
:multiple="isMultiScreenshotSlot(activeUploadKey)"
style="display: none"
@change="handleScreenshotUpload"
/>
</div>
<div class="form-section">
<h2 class="section-title">押金与价格</h2>
<div class="deposit-card">
<label class="deposit-heading">
<span>押金<b>*</b></span>
<em v-if="priceConfig.deposit_hint">{{ priceConfig.deposit_hint }}</em>
</label>
<div class="deposit-input-wrap">
<input
v-model="form.depositAmountYuan"
type="number"
inputmode="decimal"
:placeholder="priceConfig.deposit_placeholder"
@blur="handleDepositBlur"
/>
<span></span>
</div>
<p v-if="priceConfig.deposit_placeholder" class="deposit-help">
{{ priceConfig.deposit_placeholder }}
</p>
<button class="deposit-recommend-btn" type="button" @click="useRecommendedDeposit">
推荐押金 ¥{{ formatMoney(recommendedDepositAmount) }}
</button>
</div>
<van-field label="每日损耗" required class="publish-field">
<template #input>
<div class="radio-group">
<button
v-for="loss in dailyLossOptions"
:key="loss"
type="button"
class="radio-btn"
:class="{ active: dailyLossMAmount === loss }"
@click="form.daily_loss_m = loss"
>
{{ loss }}M/
</button>
</div>
</template>
</van-field>
<p class="field-hint">
比例调整在默认10M/每增加10M出租比例相应+1请根据实际需求合理设置感谢您的配合
</p>
<div class="ratio-panel">
<div class="ratio-reference">
<span>参考比例</span>
<strong>{{ calculatedDefaultSaleRatioText }}</strong>
</div>
<p class="ratio-range">{{ saleRatioRangeText }}比例越高出租速度越快自行选择</p>
<div class="ratio-mode-grid">
<button
type="button"
class="ratio-mode-btn"
:class="{ active: !hasAcceleratedSaleRatioInput() }"
:disabled="calculatedDefaultSaleRatio <= 0"
@click="useReferenceSaleRatio"
>
<strong>参考比例</strong>
<span>
{{
calculatedDefaultSaleRatio > 0
? `1元=${formatNumber(calculatedDefaultSaleRatio)}万`
: '自动计算'
}}
</span>
</button>
<button
type="button"
class="ratio-mode-btn"
:class="{ active: hasAcceleratedSaleRatioInput() }"
:disabled="maxAcceleratedSaleRatio <= 0"
@click="useMaxAcceleratedSaleRatio"
>
<strong>加速比例</strong>
<span>
{{
maxAcceleratedSaleRatio > 0
? `1元=${formatNumber(calculatedDefaultSaleRatio)}~${formatNumber(maxAcceleratedSaleRatio)}万`
: '自动计算'
}}
</span>
</button>
</div>
<van-field
:model-value="form.accelerated_sale_ratio"
label="自定比例"
type="number"
:placeholder="acceleratedSaleRatioPlaceholder"
class="publish-field ratio-input-field"
@update:model-value="handleAcceleratedSaleRatioInput"
@blur="clampAcceleratedSaleRatioInput"
/>
<p class="field-hint ratio-input-hint">
留空使用参考比例填写后范围为参考比例到参考比例+10请根据实际需求合理设置
</p>
</div>
<div class="result-grid">
<van-field
:model-value="calculatedRatioText"
label="卖家比例"
readonly
:placeholder="priceConfig.ratio_description"
class="publish-field result-field"
>
<template #label>
<span class="hint-label" :data-hint="priceConfig.ratio_description" tabindex="0"
>卖家比例</span
>
</template>
</van-field>
<van-field
:model-value="calculatedCoinBasePrice ? `¥${formatMoney(calculatedCoinBasePrice)}` : ''"
label="纯币基础价"
readonly
required
:placeholder="priceConfig.price_placeholder"
class="publish-field result-field"
>
<template #label>
<span class="hint-label" :data-hint="priceConfig.price_placeholder" tabindex="0"
>纯币基础价</span
>
</template>
</van-field>
<van-field
:model-value="`¥${formatMoney(calculatedConsumablePrice)}`"
label="额外消耗品"
readonly
class="publish-field result-field"
/>
<van-field
:model-value="calculatedSellerPrice ? `¥${formatMoney(calculatedSellerPrice)}` : ''"
label="卖家价格"
readonly
required
:placeholder="priceConfig.price_placeholder"
class="publish-field result-field total-price-field"
/>
</div>
<p class="price-breakdown-hint">卖家价格 = 纯币基础价 + 额外消耗品</p>
<van-field
v-model="form.remark"
label="备注"
type="textarea"
rows="3"
autosize
placeholder="如有一些不可使用的物资,请在此备注,并且在买家下单后,主动在群聊处,再次提醒买家"
class="publish-field"
/>
</div>
<div class="publish-agreement-card">
<van-checkbox v-model="virtualAssetSaleAgreementChecked" icon-size="18px">
<span class="agreement-check-label">
我已阅读并同意
<button
type="button"
class="agreement-link"
@click.stop.prevent="openPublishAgreement(publishAgreements.virtual_asset_sale)"
>
{{ publishAgreements.virtual_asset_sale.title }}
</button>
</span>
</van-checkbox>
<van-checkbox v-model="sellerAgreementChecked" icon-size="18px">
<span class="agreement-check-label">
我已阅读并同意
<button
type="button"
class="agreement-link"
@click.stop.prevent="openPublishAgreement(publishAgreements.seller_agreement)"
>
{{ publishAgreements.seller_agreement.title }}
</button>
</span>
</van-checkbox>
<van-checkbox v-model="passwordAndDeviceConfirmed" icon-size="18px">
<span class="agreement-check-label"
>我已知晓:完成后须改密并清除登录设备若遇封号须解封后查号方可结算未经查号即结算者后续账号问题商行概不负责</span
>
</van-checkbox>
</div>
<div class="publish-actions">
<van-button round class="reset-btn" :disabled="loading" @click="handleResetDraft">
{{ isEditMode ? '恢复' : '重置' }}
</van-button>
<van-button
type="primary"
round
class="submit-btn"
:loading="loading"
:disabled="!canPublishAfterAgreements"
:loading-text="submitLoadingText"
@click="handleSubmit"
>
{{ submitButtonText }}
</van-button>
</div>
</section>
<van-popup
v-model:show="agreementPopupVisible"
round
closeable
position="bottom"
lock-scroll
class="publish-agreement-popup"
>
<div class="agreement-popup-body">
<h3>{{ activeAgreementTitle }}</h3>
<div class="agreement-popup-content">{{ activeAgreementContent }}</div>
</div>
</van-popup>
<MobileBottomNav />
</main>
</template>
<style scoped src="./MobileSellerListingCreateView.css"></style>