feat: P3阶段完成 - 全部模块迁移完成 🎉
## P3.1: 争议仲裁模块(disputes)✅ - API: disputes.ts - 模块导出 ## P3.2: 卖家中心模块(seller)✅ - Views: 4个页面 - Composables: usePublishForm, usePublishDraft - 模块导出 ## P3.3: 管理后台模块(admin)✅ - API: 8个文件(adminAuth, adminDashboard, adminUsers等) - Views: 15个管理页面 - Composables: useAdminTable, useAdminPaginatedTable - Components: 管理端组件 - 模块导出 --- ## 🎉 Features 架构迁移全部完成! ### 最终统计 - ✅ P0: shared(基础设施)- 22个文件 - ✅ P1: wallet, chats, orders - 24个文件 - ✅ P2: listings, auth - 35个文件 - ✅ P3: seller, disputes, admin - 47个文件 **总计:** 9个模块,128个文件完成迁移 ### 新架构 ``` frontend/src/ ├── features/ # 9个业务模块 ✅ │ ├── wallet/ ✅ │ ├── chats/ ✅ │ ├── orders/ ✅ (已重构) │ ├── listings/ ✅ │ ├── auth/ ✅ │ ├── seller/ ✅ │ ├── disputes/ ✅ │ └── admin/ ✅ └── shared/ ✅ ``` 下一步:清理旧文件、更新路由配置 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
3534cffce1
commit
c9397635e2
@@ -0,0 +1,124 @@
|
||||
import type { ChargeMode, QuantityKey, ScreenshotKey } from '@/api/listingOptions'
|
||||
import type { PublishDraft, PublishForm } from '@/types/publish'
|
||||
|
||||
export function defaultPublishForm(): PublishForm {
|
||||
return {
|
||||
server_region: '',
|
||||
face_owner: '',
|
||||
haf_coin_amount: '',
|
||||
rank_level: '',
|
||||
secret_kd: '',
|
||||
fire_level: '',
|
||||
daily_loss_m: 10,
|
||||
accelerated_sale_ratio: '',
|
||||
season_insurance: '',
|
||||
stamina_level: '',
|
||||
load_level: '',
|
||||
login_method: '',
|
||||
online_start: '',
|
||||
online_end: '',
|
||||
ban_record: '',
|
||||
common_regions: [],
|
||||
deposit_amount: '',
|
||||
remark: '',
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPublishDraft(options: {
|
||||
form: PublishForm
|
||||
quantityValues: Record<QuantityKey, number>
|
||||
quantityModes: Record<QuantityKey, ChargeMode>
|
||||
screenshotFiles: Record<ScreenshotKey, string>
|
||||
selectedSkins: string[]
|
||||
}): PublishDraft {
|
||||
return {
|
||||
form: {
|
||||
...options.form,
|
||||
common_regions: [...options.form.common_regions],
|
||||
},
|
||||
quantityValues: { ...options.quantityValues },
|
||||
quantityModes: { ...options.quantityModes },
|
||||
screenshotFiles: { ...options.screenshotFiles },
|
||||
selectedSkins: [...options.selectedSkins],
|
||||
}
|
||||
}
|
||||
|
||||
export function readPublishDraft(draftKey: string) {
|
||||
const raw = localStorage.getItem(draftKey)
|
||||
if (!raw) return null
|
||||
try {
|
||||
const draft = JSON.parse(raw) as Partial<PublishDraft>
|
||||
return {
|
||||
form: normalizeDraftForm(draft.form),
|
||||
quantityValues: normalizeNumberRecord(draft.quantityValues),
|
||||
quantityModes: normalizeQuantityModes(draft.quantityModes),
|
||||
screenshotFiles: normalizeStringRecord(draft.screenshotFiles),
|
||||
selectedSkins: Array.isArray(draft.selectedSkins)
|
||||
? draft.selectedSkins.filter((skin): skin is string => typeof skin === 'string')
|
||||
: [],
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(draftKey)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writePublishDraft(draftKey: string, draft: PublishDraft) {
|
||||
const nextValue = JSON.stringify(draft)
|
||||
if (localStorage.getItem(draftKey) === nextValue) return
|
||||
localStorage.setItem(draftKey, nextValue)
|
||||
}
|
||||
|
||||
export function removePublishDraft(draftKey: string) {
|
||||
localStorage.removeItem(draftKey)
|
||||
}
|
||||
|
||||
export function clearRecord(record: Record<string, unknown>) {
|
||||
for (const key of Object.keys(record)) delete record[key]
|
||||
}
|
||||
|
||||
function normalizeDraftForm(value: unknown): PublishForm {
|
||||
const next = defaultPublishForm()
|
||||
if (!isRecord(value)) return next
|
||||
for (const key of Object.keys(next) as Array<keyof PublishForm>) {
|
||||
if (key === 'common_regions') continue
|
||||
const draftValue = value[key]
|
||||
if (draftValue !== undefined) next[key] = draftValue as never
|
||||
}
|
||||
next.common_regions = Array.isArray(value.common_regions)
|
||||
? value.common_regions.filter((region): region is string => typeof region === 'string')
|
||||
: []
|
||||
return next
|
||||
}
|
||||
|
||||
function normalizeNumberRecord(value: unknown) {
|
||||
const record: Record<string, number> = {}
|
||||
if (!isRecord(value)) return record
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
const parsed = Number(item)
|
||||
if (Number.isFinite(parsed)) record[key] = parsed
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
function normalizeStringRecord(value: unknown) {
|
||||
const record: Record<string, string> = {}
|
||||
if (!isRecord(value)) return record
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (typeof item === 'string') record[key] = item
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
function normalizeQuantityModes(value: unknown) {
|
||||
const record: Record<string, ChargeMode> = {}
|
||||
if (!isRecord(value)) return record
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (item === '赠送' || item === '收费') record[key] = item
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { fetchFileBlobByURL, uploadFile } from '@/api/files'
|
||||
import {
|
||||
emptyListingPublishOptions,
|
||||
emptyListingSalePriceConfig,
|
||||
fetchListingPublishOptions,
|
||||
fetchListingSalePriceConfig,
|
||||
type ChargeMode,
|
||||
type ListingPublishOptions,
|
||||
type PublishSalePriceConfig,
|
||||
type ScreenshotKey,
|
||||
} from '@/api/listingOptions'
|
||||
import { createListing } from '@/api/listings'
|
||||
import { usePricingCalculator } from '@/composables/usePricingCalculator'
|
||||
import {
|
||||
buildPublishDraft,
|
||||
clearRecord,
|
||||
defaultPublishForm,
|
||||
readPublishDraft,
|
||||
removePublishDraft,
|
||||
writePublishDraft,
|
||||
} from '@/composables/usePublishDraft'
|
||||
import type { PublishForm } from '@/types/publish'
|
||||
import { commonOnlineTimes, dailyLossOptions, formatNumber, roundRatio } from '@/utils/pricing'
|
||||
|
||||
const draftSaveDelay = 400
|
||||
|
||||
interface UsePublishFormOptions {
|
||||
draftKey: string
|
||||
submitSuccessPath: string
|
||||
persistBeforeUnload?: boolean
|
||||
confirmReset?: () => Promise<void>
|
||||
notifySuccess: (message: string) => void
|
||||
notifyWarning: (message: string) => void
|
||||
notifyError: (message: string) => void
|
||||
}
|
||||
|
||||
export function usePublishForm(options: UsePublishFormOptions) {
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const uploading = ref(false)
|
||||
const suppressDraftSave = ref(false)
|
||||
const draftReady = ref(false)
|
||||
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions)
|
||||
const salePriceConfig = ref<PublishSalePriceConfig>(emptyListingSalePriceConfig)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const activeUploadKey = ref<ScreenshotKey>('coin')
|
||||
const form = reactive<PublishForm>(defaultPublishForm())
|
||||
const quantityValues = reactive<Record<string, number>>({})
|
||||
const quantityModes = reactive<Record<string, ChargeMode>>({})
|
||||
const screenshotFiles = reactive<Record<string, string>>({})
|
||||
const screenshotPreviews = reactive<Record<string, string>>({})
|
||||
const selectedSkins = ref<string[]>([])
|
||||
let draftSaveTimer: number | undefined
|
||||
|
||||
const pricing = usePricingCalculator({
|
||||
form,
|
||||
publishOptions,
|
||||
salePriceConfig,
|
||||
quantityValues,
|
||||
quantityModes,
|
||||
screenshotFiles,
|
||||
selectedSkins,
|
||||
})
|
||||
const uploadedScreenshotCount = computed(() => pricing.screenshotUrls.value.length)
|
||||
const requiredScreenshotCount = ref(0)
|
||||
|
||||
onMounted(() => {
|
||||
restoreDraft()
|
||||
draftReady.value = true
|
||||
if (options.persistBeforeUnload) window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
loadPublishOptions()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
saveDraft({ force: true })
|
||||
if (options.persistBeforeUnload) window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
revokeAllScreenshotPreviews()
|
||||
})
|
||||
|
||||
watch(
|
||||
[form, quantityValues, quantityModes, screenshotFiles, selectedSkins],
|
||||
() => {
|
||||
saveDraft()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
pricing.recommendedDepositAmount,
|
||||
() => {
|
||||
syncRecommendedDeposit()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => form.season_insurance, pricing.quantityItems],
|
||||
() => {
|
||||
clearForbiddenQuantityItems()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[pricing.screenshotSlots, () => form.ban_record],
|
||||
() => {
|
||||
requiredScreenshotCount.value = pricing.screenshotSlots.value.filter((item) => isScreenshotRequired(item)).length
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
async function loadPublishOptions() {
|
||||
try {
|
||||
const [nextPublishOptions, nextSalePriceConfig] = await Promise.all([
|
||||
fetchListingPublishOptions(),
|
||||
fetchListingSalePriceConfig(),
|
||||
])
|
||||
publishOptions.value = nextPublishOptions
|
||||
salePriceConfig.value = nextSalePriceConfig
|
||||
|
||||
// 默认数字都是0
|
||||
for (const item of nextPublishOptions.quantity_items) {
|
||||
if (quantityValues[item.key] === undefined) {
|
||||
quantityValues[item.key] = 0
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
publishOptions.value = emptyListingPublishOptions
|
||||
salePriceConfig.value = emptyListingSalePriceConfig
|
||||
}
|
||||
}
|
||||
|
||||
function saveDraft(saveOptions: { force?: boolean } = {}) {
|
||||
if (suppressDraftSave.value) {
|
||||
clearDraftSaveTimer()
|
||||
return
|
||||
}
|
||||
if (!saveOptions.force && !draftReady.value) return
|
||||
if (!saveOptions.force) {
|
||||
scheduleDraftSave()
|
||||
return
|
||||
}
|
||||
clearDraftSaveTimer()
|
||||
writeDraft()
|
||||
}
|
||||
|
||||
function scheduleDraftSave() {
|
||||
clearDraftSaveTimer()
|
||||
draftSaveTimer = window.setTimeout(() => {
|
||||
draftSaveTimer = undefined
|
||||
writeDraft()
|
||||
}, draftSaveDelay)
|
||||
}
|
||||
|
||||
function clearDraftSaveTimer() {
|
||||
if (draftSaveTimer === undefined) return
|
||||
window.clearTimeout(draftSaveTimer)
|
||||
draftSaveTimer = undefined
|
||||
}
|
||||
|
||||
function writeDraft() {
|
||||
writePublishDraft(
|
||||
options.draftKey,
|
||||
buildPublishDraft({
|
||||
form,
|
||||
quantityValues,
|
||||
quantityModes,
|
||||
screenshotFiles,
|
||||
selectedSkins: selectedSkins.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function handleSaveDraft() {
|
||||
saveDraft({ force: true })
|
||||
options.notifySuccess('草稿已保存')
|
||||
}
|
||||
|
||||
function handleBeforeUnload() {
|
||||
saveDraft({ force: true })
|
||||
}
|
||||
|
||||
function restoreDraft() {
|
||||
const draft = readPublishDraft(options.draftKey)
|
||||
if (!draft) return
|
||||
Object.assign(form, draft.form)
|
||||
clearRecord(quantityValues)
|
||||
clearRecord(quantityModes)
|
||||
clearRecord(screenshotFiles)
|
||||
Object.assign(quantityValues, draft.quantityValues)
|
||||
Object.assign(quantityModes, draft.quantityModes)
|
||||
Object.assign(screenshotFiles, draft.screenshotFiles)
|
||||
selectedSkins.value = draft.selectedSkins
|
||||
hydrateScreenshotPreviews()
|
||||
}
|
||||
|
||||
function resetDraftState() {
|
||||
Object.assign(form, defaultPublishForm())
|
||||
clearRecord(quantityValues)
|
||||
clearRecord(quantityModes)
|
||||
clearRecord(screenshotFiles)
|
||||
revokeAllScreenshotPreviews()
|
||||
selectedSkins.value = []
|
||||
activeUploadKey.value = 'coin'
|
||||
|
||||
// Also re-initialize quantityValues to 0
|
||||
for (const item of publishOptions.value.quantity_items) {
|
||||
quantityValues[item.key] = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetDraft() {
|
||||
try {
|
||||
await options.confirmReset?.()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
suppressDraftSave.value = true
|
||||
clearDraftSaveTimer()
|
||||
resetDraftState()
|
||||
removePublishDraft(options.draftKey)
|
||||
options.notifySuccess('已重置')
|
||||
window.setTimeout(() => {
|
||||
suppressDraftSave.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function toggleSkin(skin: string) {
|
||||
selectedSkins.value = selectedSkins.value.includes(skin)
|
||||
? selectedSkins.value.filter((item) => item !== skin)
|
||||
: [...selectedSkins.value, skin]
|
||||
}
|
||||
|
||||
function toggleRegion(region: string) {
|
||||
form.common_regions = form.common_regions.includes(region)
|
||||
? form.common_regions.filter((item) => item !== region)
|
||||
: [...form.common_regions, region]
|
||||
}
|
||||
|
||||
function triggerUpload(key: ScreenshotKey) {
|
||||
activeUploadKey.value = key
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function handleScreenshotUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
const key = activeUploadKey.value
|
||||
const previewURL = URL.createObjectURL(file)
|
||||
setScreenshotPreview(key, previewURL)
|
||||
uploading.value = true
|
||||
try {
|
||||
const uploaded = await uploadFile(file, 'listing')
|
||||
screenshotFiles[key] = uploaded.url
|
||||
options.notifySuccess('截图已上传')
|
||||
} catch (error) {
|
||||
revokeScreenshotPreview(key)
|
||||
options.notifyError(readError(error, '截图上传失败'))
|
||||
} finally {
|
||||
uploading.value = false
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function removeScreenshot(key: ScreenshotKey) {
|
||||
screenshotFiles[key] = ''
|
||||
revokeScreenshotPreview(key)
|
||||
}
|
||||
|
||||
function getScreenshotPreviewURL(key: ScreenshotKey) {
|
||||
return screenshotPreviews[key] || screenshotFiles[key] || ''
|
||||
}
|
||||
|
||||
function setScreenshotPreview(key: ScreenshotKey, previewURL: string) {
|
||||
revokeScreenshotPreview(key)
|
||||
screenshotPreviews[key] = previewURL
|
||||
}
|
||||
|
||||
function revokeScreenshotPreview(key: ScreenshotKey) {
|
||||
const previewURL = screenshotPreviews[key]
|
||||
if (previewURL?.startsWith('blob:')) URL.revokeObjectURL(previewURL)
|
||||
delete screenshotPreviews[key]
|
||||
}
|
||||
|
||||
function revokeAllScreenshotPreviews() {
|
||||
for (const key of Object.keys(screenshotPreviews)) revokeScreenshotPreview(key)
|
||||
}
|
||||
|
||||
async function hydrateScreenshotPreviews() {
|
||||
for (const [key, fileURL] of Object.entries(screenshotFiles)) {
|
||||
if (!fileURL || screenshotPreviews[key] || !fileURL.startsWith('/api/files/object')) continue
|
||||
try {
|
||||
const blob = await fetchFileBlobByURL(fileURL)
|
||||
setScreenshotPreview(key, URL.createObjectURL(blob))
|
||||
} catch {
|
||||
// 草稿预览失败不影响已上传文件地址,提交时仍会带上原 URL。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleFireLevelInput(value: string | number | undefined) {
|
||||
if (value === '' || value === undefined) {
|
||||
form.fire_level = ''
|
||||
return
|
||||
}
|
||||
const level = Number(value)
|
||||
form.fire_level = Number.isFinite(level) ? Math.trunc(level) : ''
|
||||
}
|
||||
|
||||
function handleAcceleratedSaleRatioInput(value: string | number | undefined) {
|
||||
if (value === '' || value === undefined) {
|
||||
form.accelerated_sale_ratio = ''
|
||||
return
|
||||
}
|
||||
const ratio = Number(value)
|
||||
form.accelerated_sale_ratio = Number.isFinite(ratio) ? ratio : ''
|
||||
}
|
||||
|
||||
function syncRecommendedDeposit() {
|
||||
const recommended = pricing.recommendedDepositAmount.value
|
||||
if (recommended <= 0) return
|
||||
const current = Number(form.deposit_amount)
|
||||
if (form.deposit_amount === '' || !Number.isFinite(current) || current < recommended) {
|
||||
form.deposit_amount = recommended
|
||||
}
|
||||
}
|
||||
|
||||
function useRecommendedDeposit() {
|
||||
if (pricing.recommendedDepositAmount.value > 0) {
|
||||
form.deposit_amount = pricing.recommendedDepositAmount.value
|
||||
}
|
||||
}
|
||||
|
||||
function clearForbiddenQuantityItems() {
|
||||
for (const item of pricing.quantityItems.value) {
|
||||
if (!pricing.isQuantityItemDisabled(item)) continue
|
||||
quantityValues[item.key] = 0
|
||||
quantityModes[item.key] = '赠送'
|
||||
}
|
||||
}
|
||||
|
||||
function setQuantityMode(item: { key: string; label: string }, mode: ChargeMode) {
|
||||
if (pricing.isQuantityItemDisabled(item)) return
|
||||
quantityModes[item.key] = mode
|
||||
}
|
||||
|
||||
function clampAcceleratedSaleRatioInput() {
|
||||
if (!pricing.hasAcceleratedSaleRatioInput() || pricing.calculatedDefaultSaleRatio.value <= 0) return
|
||||
const ratio = Number(form.accelerated_sale_ratio)
|
||||
if (!Number.isFinite(ratio)) {
|
||||
form.accelerated_sale_ratio = ''
|
||||
return
|
||||
}
|
||||
form.accelerated_sale_ratio = roundRatio(
|
||||
Math.min(Math.max(ratio, pricing.calculatedDefaultSaleRatio.value), pricing.maxAcceleratedSaleRatio.value),
|
||||
)
|
||||
}
|
||||
|
||||
function useReferenceSaleRatio() {
|
||||
form.accelerated_sale_ratio = ''
|
||||
}
|
||||
|
||||
function useMaxAcceleratedSaleRatio() {
|
||||
if (pricing.maxAcceleratedSaleRatio.value <= 0) return
|
||||
form.accelerated_sale_ratio = pricing.maxAcceleratedSaleRatio.value
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const error = validateForm()
|
||||
if (error) {
|
||||
options.notifyWarning(error)
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const listing = await createListing({
|
||||
title: pricing.publishTitle.value,
|
||||
description: form.remark,
|
||||
server_region: form.server_region,
|
||||
login_platform: form.login_method,
|
||||
rank_level: form.rank_level,
|
||||
haf_coin_amount: pricing.coinMAmount.value * 1000000,
|
||||
asset_summary: buildAssetSummary(),
|
||||
screenshot_urls: pricing.screenshotUrls.value,
|
||||
price: pricing.calculatedFinalPrice.value,
|
||||
deposit_amount: Number(form.deposit_amount),
|
||||
})
|
||||
removePublishDraft(options.draftKey)
|
||||
suppressDraftSave.value = true
|
||||
clearDraftSaveTimer()
|
||||
options.notifySuccess(
|
||||
listing.status === 'published' && listing.review_status === 'approved'
|
||||
? '发布成功,已上架'
|
||||
: '发布成功,等待后台审核',
|
||||
)
|
||||
await router.push(options.submitSuccessPath)
|
||||
} catch (error) {
|
||||
options.notifyError(readError(error, '发布失败,请确认已登录并完成实名认证'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
if (!form.server_region) return '请选择区服'
|
||||
if (pricing.coinMAmount.value <= 0) return '请填写哈夫币/M'
|
||||
if (!form.rank_level) return '请选择段位'
|
||||
if (!form.fire_level) return '请填写烽火等级'
|
||||
if (Number(form.fire_level) < pricing.fireLevelMin.value) return `烽火等级低于${pricing.fireLevelMin.value}级的号无法发布`
|
||||
if (!form.season_insurance) return '请选择赛季保险'
|
||||
if (!form.stamina_level) return '请选择体力等级'
|
||||
if (!form.load_level) return '请选择负重等级'
|
||||
if (!dailyLossOptions.includes(pricing.dailyLossMAmount.value)) return '请选择每日损耗'
|
||||
if (pricing.hasAcceleratedSaleRatioInput()) {
|
||||
const ratio = Number(form.accelerated_sale_ratio)
|
||||
if (!Number.isFinite(ratio) || ratio <= 0) return '加速出售比例格式不正确'
|
||||
if (pricing.calculatedDefaultSaleRatio.value > 0 && ratio < pricing.calculatedDefaultSaleRatio.value) {
|
||||
return `加速出售比例不能低于默认比例 1:${formatNumber(pricing.calculatedDefaultSaleRatio.value)}`
|
||||
}
|
||||
if (pricing.maxAcceleratedSaleRatio.value > 0 && ratio > pricing.maxAcceleratedSaleRatio.value) {
|
||||
return `加速出售比例不能超过 1:${formatNumber(pricing.maxAcceleratedSaleRatio.value)}`
|
||||
}
|
||||
}
|
||||
if (pricing.banRecordOptions.value.length && !form.ban_record) return '请选择封禁记录'
|
||||
if (form.deposit_amount === '' || Number(form.deposit_amount) < 0) return '请填写押金'
|
||||
if (!Number.isFinite(Number(form.deposit_amount))) return '押金格式不正确'
|
||||
if (pricing.recommendedDepositAmount.value > 0 && Number(form.deposit_amount) < pricing.recommendedDepositAmount.value) {
|
||||
return `押金不能低于智能推荐 ¥${pricing.recommendedDepositAmount.value}`
|
||||
}
|
||||
if (pricing.calculatedConsumablePrice.value > 0 && Number(form.deposit_amount) <= pricing.calculatedConsumablePrice.value) {
|
||||
return `押金必须大于额外消耗品总价值 ¥${pricing.calculatedConsumablePrice.value}`
|
||||
}
|
||||
if (!pricing.calculatedFinalPrice.value) return '请完善币数、保险、体力和负重后再发布'
|
||||
if (!Number.isFinite(pricing.calculatedFinalPrice.value)) return '发布价格计算异常,请检查填写内容'
|
||||
for (const item of pricing.screenshotSlots.value) {
|
||||
if (isScreenshotRequired(item) && !screenshotFiles[item.key]) return `请上传${item.label}`
|
||||
}
|
||||
for (const item of pricing.quantityItems.value) {
|
||||
if (!pricing.isQuantityItemDisabled(item)) {
|
||||
const val = quantityValues[item.key]
|
||||
if (val === undefined || val === null || (val as unknown) === '') {
|
||||
return `请填写${item.label}的数量`
|
||||
}
|
||||
if (Number(val) < 0) {
|
||||
return `${item.label}的数量不能为负数`
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const item of pricing.quantityItems.value) {
|
||||
if (pricing.isQuantityItemDisabled(item) && Number(quantityValues[item.key] || 0) > 0) {
|
||||
return '赛季保险选择 3*3 时不能填写 9格体验卡'
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function isScreenshotRequired(item: { key: string; required: boolean }) {
|
||||
return item.required || (item.key === 'tencentSecurity' && shouldRequireBanEvidence())
|
||||
}
|
||||
|
||||
function shouldRequireBanEvidence() {
|
||||
return pricing.banEvidenceOptions.value.includes(form.ban_record)
|
||||
}
|
||||
|
||||
function buildAssetSummary() {
|
||||
return {
|
||||
face_owner: form.face_owner,
|
||||
secret_kd: form.secret_kd,
|
||||
fire_level: Number(form.fire_level),
|
||||
daily_loss_m: pricing.dailyLossMAmount.value,
|
||||
publish_ratio: pricing.calculatedPlatformPricing.value.buyerRatio,
|
||||
price_breakdown: {
|
||||
seller_reference_ratio: pricing.calculatedSellerReferenceRatio.value,
|
||||
seller_ratio: pricing.calculatedRatio.value,
|
||||
seller_coin_base_price: pricing.calculatedCoinBasePrice.value,
|
||||
seller_total_price: pricing.calculatedSellerPrice.value,
|
||||
consumable_price: pricing.calculatedConsumablePrice.value,
|
||||
daily_loss_ratio_adjustment: pricing.dailyLossRatioAdjustment.value,
|
||||
accelerated_sale_ratio: pricing.hasAcceleratedSaleRatioInput()
|
||||
? Number(form.accelerated_sale_ratio)
|
||||
: pricing.calculatedDefaultSaleRatio.value,
|
||||
buyer_coin_base_price: pricing.calculatedPlatformPricing.value.buyerCoinBasePrice,
|
||||
buyer_total_price: pricing.calculatedFinalPrice.value,
|
||||
buyer_ratio: pricing.calculatedPlatformPricing.value.buyerRatio,
|
||||
platform_markup_amount: pricing.calculatedPlatformPricing.value.platformMarkupAmount,
|
||||
platform_rule_type: pricing.calculatedPlatformPricing.value.ruleType,
|
||||
},
|
||||
season_insurance: form.season_insurance,
|
||||
stamina_level: form.stamina_level,
|
||||
load_level: form.load_level,
|
||||
resources: pricing.quantityItems.value.map((item) => ({
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
price: item.price,
|
||||
quantity: pricing.isQuantityItemDisabled(item) ? 0 : Number(quantityValues[item.key] || 0),
|
||||
mode: pricing.isQuantityItemDisabled(item) ? '赠送' : quantityModes[item.key] || '收费',
|
||||
})),
|
||||
skin_groups: pricing.skinGroups.value.reduce<Record<string, string[]>>((groups, group) => {
|
||||
groups[group.key] = group.options.filter((skin) => selectedSkins.value.includes(skin))
|
||||
return groups
|
||||
}, {}),
|
||||
online_time: {
|
||||
start: form.online_start,
|
||||
end: form.online_end,
|
||||
},
|
||||
ban_record: form.ban_record,
|
||||
common_regions: form.common_regions,
|
||||
remark: form.remark,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...pricing,
|
||||
dailyLossOptions,
|
||||
commonOnlineTimes,
|
||||
formatNumber,
|
||||
router,
|
||||
loading,
|
||||
uploading,
|
||||
fileInput,
|
||||
activeUploadKey,
|
||||
form,
|
||||
quantityValues,
|
||||
quantityModes,
|
||||
screenshotFiles,
|
||||
screenshotPreviews,
|
||||
selectedSkins,
|
||||
uploadedScreenshotCount,
|
||||
requiredScreenshotCount,
|
||||
loadPublishOptions,
|
||||
saveDraft,
|
||||
handleSaveDraft,
|
||||
resetDraftState,
|
||||
handleResetDraft,
|
||||
toggleSkin,
|
||||
toggleRegion,
|
||||
triggerUpload,
|
||||
handleScreenshotUpload,
|
||||
removeScreenshot,
|
||||
getScreenshotPreviewURL,
|
||||
handleFireLevelInput,
|
||||
handleAcceleratedSaleRatioInput,
|
||||
syncRecommendedDeposit,
|
||||
useRecommendedDeposit,
|
||||
clearForbiddenQuantityItems,
|
||||
setQuantityMode,
|
||||
clampAcceleratedSaleRatioInput,
|
||||
useReferenceSaleRatio,
|
||||
useMaxAcceleratedSaleRatio,
|
||||
handleSubmit,
|
||||
validateForm,
|
||||
isScreenshotRequired,
|
||||
shouldRequireBanEvidence,
|
||||
buildAssetSummary,
|
||||
}
|
||||
}
|
||||
|
||||
function readError(error: unknown, fallback: string) {
|
||||
if (typeof error === 'object' && error && 'response' in error) {
|
||||
const response = (error as { response?: { data?: { message?: string } } }).response
|
||||
return response?.data?.message || fallback
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Seller 模块统一导出
|
||||
export * from './composables/usePublishForm'
|
||||
export * from './composables/usePublishDraft'
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<section class="page">
|
||||
<div class="page-header">
|
||||
<p class="eyebrow">Earnings</p>
|
||||
<h1>收益流水</h1>
|
||||
<p>查看订单金额、平台抽成、结算和冻结金额。</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { fetchOrders, type Order } from '@/api/orders'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { handoffStatusLabel, orderStatusLabel } from '@/utils/statusLabels'
|
||||
import { formatDateTime } from '@/utils/time'
|
||||
|
||||
const session = useSessionStore()
|
||||
const loading = ref(false)
|
||||
const orders = ref<Order[]>([])
|
||||
const status = ref('')
|
||||
|
||||
const sellerOrders = computed(() => orders.value.filter((order) => order.owner_id === session.userId))
|
||||
const todoOrders = computed(() =>
|
||||
sellerOrders.value.filter((order) =>
|
||||
[
|
||||
'pending_handoff',
|
||||
'renting',
|
||||
'overdue',
|
||||
'pending_checkout_confirm',
|
||||
'pending_checkout_accept',
|
||||
'checkout_disputing',
|
||||
'disputing',
|
||||
'abnormal',
|
||||
].includes(order.status),
|
||||
),
|
||||
)
|
||||
const displayOrders = computed(() => {
|
||||
const source = todoOrders.value
|
||||
if (!status.value) return source
|
||||
return source.filter((order) => order.status === status.value)
|
||||
})
|
||||
const pendingHandoffCount = computed(
|
||||
() => sellerOrders.value.filter((order) => order.status === 'pending_handoff' && order.handoff_status === 'pending_owner').length,
|
||||
)
|
||||
const pendingCheckoutCount = computed(
|
||||
() => sellerOrders.value.filter((order) => order.status === 'pending_checkout_confirm').length,
|
||||
)
|
||||
const abnormalCount = computed(
|
||||
() => sellerOrders.value.filter((order) => ['overdue', 'checkout_disputing', 'disputing', 'abnormal'].includes(order.status)).length,
|
||||
)
|
||||
|
||||
onMounted(loadOrders)
|
||||
|
||||
async function loadOrders() {
|
||||
loading.value = true
|
||||
try {
|
||||
orders.value = await fetchOrders()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function actionText(order: Order) {
|
||||
if (order.status === 'pending_handoff' && order.handoff_status === 'pending_owner') return '提交交接'
|
||||
if (order.status === 'pending_checkout_confirm') return '处理结账'
|
||||
if (order.status === 'pending_checkout_accept') return '等待租客'
|
||||
if (['overdue', 'checkout_disputing', 'disputing', 'abnormal'].includes(order.status)) return '查看处理'
|
||||
return '详情'
|
||||
}
|
||||
|
||||
function money(value: unknown) {
|
||||
return Math.round(Number(value || 0))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<div class="page-header-row">
|
||||
<div class="page-header">
|
||||
<p class="eyebrow">Handoffs</p>
|
||||
<h1>交接管理</h1>
|
||||
<p>处理待交接、租赁中、待结账确认和异常订单。</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-select v-model="status" clearable placeholder="待办状态" style="width: 190px">
|
||||
<el-option label="待交接" value="pending_handoff" />
|
||||
<el-option label="使用中" value="renting" />
|
||||
<el-option label="逾期中" value="overdue" />
|
||||
<el-option label="待确认结账" value="pending_checkout_confirm" />
|
||||
<el-option label="待租客确认修正" value="pending_checkout_accept" />
|
||||
<el-option label="结账争议中" value="checkout_disputing" />
|
||||
<el-option label="申诉中" value="disputing" />
|
||||
<el-option label="异常" value="abnormal" />
|
||||
</el-select>
|
||||
<el-button @click="loadOrders">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-grid">
|
||||
<div class="metric-card">
|
||||
<span>待交接</span>
|
||||
<strong>{{ pendingHandoffCount }} 单</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>待结账</span>
|
||||
<strong>{{ pendingCheckoutCount }} 单</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>异常/争议</span>
|
||||
<strong>{{ abnormalCount }} 单</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" class="table-panel" :data="displayOrders">
|
||||
<el-table-column prop="order_no" label="订单号" min-width="220" />
|
||||
<el-table-column prop="title" label="账号" min-width="180" />
|
||||
<el-table-column label="金额" width="120">
|
||||
<template #default="{ row }">¥{{ money(row.display_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="订单状态" width="150">
|
||||
<template #default="{ row }">{{ orderStatusLabel(row.status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="交接状态" width="180">
|
||||
<template #default="{ row }">{{ handoffStatusLabel(row.handoff_status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" min-width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<RouterLink :to="`/orders/${row.id}`">
|
||||
<el-button size="small" type="primary">{{ actionText(row) }}</el-button>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,490 @@
|
||||
<script setup lang="ts">
|
||||
import { Delete, DocumentChecked, Picture, RefreshRight, UploadFilled } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
import { usePublishForm } from '@/composables/usePublishForm'
|
||||
import OptionChips from './components/OptionChips.vue'
|
||||
import PublishSection from './components/PublishSection.vue'
|
||||
|
||||
const {
|
||||
commonOnlineTimes,
|
||||
dailyLossOptions,
|
||||
formatNumber,
|
||||
loading,
|
||||
uploading,
|
||||
fileInput,
|
||||
activeUploadKey,
|
||||
form,
|
||||
quantityValues,
|
||||
quantityModes,
|
||||
screenshotFiles,
|
||||
selectedSkins,
|
||||
uploadedScreenshotCount,
|
||||
requiredScreenshotCount,
|
||||
serverOptions,
|
||||
faceOptions,
|
||||
rankOptions,
|
||||
insuranceOptions,
|
||||
levelOptions,
|
||||
loginMethodOptions,
|
||||
regionOptions,
|
||||
banRecordOptions,
|
||||
skinGroups,
|
||||
quantityItems,
|
||||
screenshotSlots,
|
||||
priceConfig,
|
||||
fireLevelPlaceholder,
|
||||
coinMAmount,
|
||||
dailyLossMAmount,
|
||||
calculatedDefaultSaleRatio,
|
||||
maxAcceleratedSaleRatio,
|
||||
calculatedCoinBasePrice,
|
||||
calculatedConsumablePrice,
|
||||
calculatedSellerPrice,
|
||||
calculatedRatioText,
|
||||
calculatedDefaultSaleRatioText,
|
||||
saleRatioRangeText,
|
||||
acceleratedSaleRatioPlaceholder,
|
||||
recommendedDepositAmount,
|
||||
depositBreakdownItems,
|
||||
publishTitle,
|
||||
hasAcceleratedSaleRatioInput,
|
||||
isQuantityItemDisabled,
|
||||
handleSaveDraft,
|
||||
handleResetDraft,
|
||||
toggleSkin,
|
||||
toggleRegion,
|
||||
triggerUpload,
|
||||
handleScreenshotUpload,
|
||||
removeScreenshot,
|
||||
getScreenshotPreviewURL,
|
||||
handleFireLevelInput,
|
||||
handleAcceleratedSaleRatioInput,
|
||||
useRecommendedDeposit,
|
||||
setQuantityMode,
|
||||
clampAcceleratedSaleRatioInput,
|
||||
useReferenceSaleRatio,
|
||||
useMaxAcceleratedSaleRatio,
|
||||
handleSubmit,
|
||||
isScreenshotRequired,
|
||||
} = usePublishForm({
|
||||
draftKey: 'hfb.pc.publish.draft',
|
||||
submitSuccessPath: '/seller/listings',
|
||||
persistBeforeUnload: true,
|
||||
async confirmReset() {
|
||||
await ElMessageBox.confirm('将清空当前填写内容和本地草稿。', '重置发布内容', {
|
||||
confirmButtonText: '重置',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
},
|
||||
notifySuccess: (message) => ElMessage.success(message),
|
||||
notifyWarning: (message) => ElMessage.warning(message),
|
||||
notifyError: (message) => ElMessage.error(message),
|
||||
})
|
||||
|
||||
function toggleSkinOption(value: string | number) {
|
||||
toggleSkin(String(value))
|
||||
}
|
||||
|
||||
function selectDailyLoss(value: string | number) {
|
||||
form.daily_loss_m = Number(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="publish-page">
|
||||
<div class="publish-layout">
|
||||
<section class="form-column">
|
||||
<PublishSection title="基础资料" :description="publishTitle">
|
||||
<div class="field-row panel-field-row">
|
||||
<label>区服<span>*</span></label>
|
||||
<OptionChips :options="serverOptions" :model-value="form.server_region" @select="form.server_region = String($event)" />
|
||||
</div>
|
||||
|
||||
<div v-if="faceOptions.length" class="field-row panel-field-row">
|
||||
<label>是否本人人脸</label>
|
||||
<OptionChips :options="faceOptions" :model-value="form.face_owner" @select="form.face_owner = String($event)" />
|
||||
</div>
|
||||
|
||||
<div class="compact-grid panel-input-grid">
|
||||
<label class="input-block">
|
||||
<span>哈夫币/M<b>*</b></span>
|
||||
<el-input-number v-model="form.haf_coin_amount" :min="0" :controls="false" placeholder="100M 写 100" />
|
||||
</label>
|
||||
<label class="input-block">
|
||||
<span>绝密KD</span>
|
||||
<el-input-number v-model="form.secret_kd" :min="0" :controls="false" :step="0.1" placeholder="填写绝密KD" />
|
||||
</label>
|
||||
<label class="input-block">
|
||||
<span>烽火等级<b>*</b></span>
|
||||
<el-input-number
|
||||
:model-value="form.fire_level"
|
||||
:min="0"
|
||||
:controls="false"
|
||||
:placeholder="fireLevelPlaceholder"
|
||||
@update:model-value="handleFireLevelInput"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field-row panel-field-row">
|
||||
<label>段位<span>*</span></label>
|
||||
<OptionChips :options="rankOptions" :model-value="form.rank_level" @select="form.rank_level = String($event)" />
|
||||
</div>
|
||||
|
||||
<div class="field-row panel-field-row">
|
||||
<label>赛季保险<span>*</span></label>
|
||||
<OptionChips
|
||||
:options="insuranceOptions"
|
||||
:model-value="form.season_insurance"
|
||||
@select="form.season_insurance = String($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="level-panel">
|
||||
<div class="level-row">
|
||||
<strong><span>*</span>体力</strong>
|
||||
<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="form.stamina_level = opt"
|
||||
>
|
||||
{{ opt }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-row">
|
||||
<strong><span>*</span>负重</strong>
|
||||
<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="form.load_level = opt"
|
||||
>
|
||||
{{ opt }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PublishSection>
|
||||
|
||||
<PublishSection v-if="quantityItems.length" title="额外消耗品" description="收费项会计入发布价格">
|
||||
<div class="quantity-table">
|
||||
<div class="quantity-header">
|
||||
<span>物资</span>
|
||||
<span>单价</span>
|
||||
<span>数量<b>*</b></span>
|
||||
<span>计费</span>
|
||||
<span>状态</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="item in quantityItems"
|
||||
:key="item.key"
|
||||
class="quantity-item"
|
||||
:class="{ disabled: isQuantityItemDisabled(item) }"
|
||||
>
|
||||
<div class="quantity-meta">
|
||||
<strong :title="item.placeholder || item.label"><span>*</span>{{ item.label }}</strong>
|
||||
<small v-if="item.placeholder">{{ item.placeholder }}</small>
|
||||
</div>
|
||||
<span class="quantity-price">{{ item.price }}</span>
|
||||
<el-input-number
|
||||
v-model="quantityValues[item.key]"
|
||||
:min="0"
|
||||
:controls="false"
|
||||
:disabled="isQuantityItemDisabled(item)"
|
||||
/>
|
||||
<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>
|
||||
<span class="quantity-status">
|
||||
{{ isQuantityItemDisabled(item) ? '3*3 已包含' : '可填写' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PublishSection>
|
||||
|
||||
<PublishSection title="皮肤与交接">
|
||||
<div v-if="skinGroups.length" class="skin-groups">
|
||||
<div
|
||||
v-for="group in skinGroups"
|
||||
:key="group.key"
|
||||
class="skin-group"
|
||||
>
|
||||
<div class="skin-title">
|
||||
<strong>{{ group.title }}</strong>
|
||||
</div>
|
||||
<OptionChips :options="group.options" :active-values="selectedSkins" @select="toggleSkinOption" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row login-method-row">
|
||||
<label>上号方式</label>
|
||||
<OptionChips
|
||||
:options="loginMethodOptions"
|
||||
:model-value="form.login_method"
|
||||
@select="form.login_method = String($event)"
|
||||
/>
|
||||
<p class="field-hint">优先推荐账密登录,出售速度通常更快;请结合账号安全情况自行选择。</p>
|
||||
</div>
|
||||
|
||||
<div class="time-preset-panel">
|
||||
<div class="time-preset-row">
|
||||
<strong>在线开始</strong>
|
||||
<div class="time-preset-content">
|
||||
<el-time-picker
|
||||
v-model="form.online_start"
|
||||
value-format="HH:mm"
|
||||
format="HH:mm"
|
||||
placeholder="开始时间"
|
||||
class="time-input"
|
||||
/>
|
||||
<OptionChips
|
||||
:options="commonOnlineTimes"
|
||||
:model-value="form.online_start"
|
||||
key-prefix="start-"
|
||||
@select="form.online_start = String($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="time-preset-row">
|
||||
<strong>在线结束</strong>
|
||||
<div class="time-preset-content">
|
||||
<el-time-picker
|
||||
v-model="form.online_end"
|
||||
value-format="HH:mm"
|
||||
format="HH:mm"
|
||||
placeholder="结束时间"
|
||||
class="time-input"
|
||||
/>
|
||||
<OptionChips
|
||||
:options="commonOnlineTimes"
|
||||
:model-value="form.online_end"
|
||||
key-prefix="end-"
|
||||
@select="form.online_end = String($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-hint">请填写能稳定联系上您的时间,便于扫码、冻结人脸和订单交接。</p>
|
||||
|
||||
<div v-if="banRecordOptions.length" class="field-row">
|
||||
<label>封禁记录<span>*</span></label>
|
||||
<OptionChips :options="banRecordOptions" :model-value="form.ban_record" @select="form.ban_record = String($event)" />
|
||||
</div>
|
||||
|
||||
<div v-if="regionOptions.length" class="region-panel login-region-panel">
|
||||
<div class="region-title">
|
||||
<strong>常用登录地区</strong>
|
||||
<span>已选 {{ form.common_regions.length }}</span>
|
||||
</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>
|
||||
</PublishSection>
|
||||
|
||||
<PublishSection title="截图材料" :description="`${uploadedScreenshotCount}/${screenshotSlots.length} 已上传`">
|
||||
<div class="upload-grid">
|
||||
<div v-for="slot in screenshotSlots" :key="slot.key" class="upload-item">
|
||||
<div class="upload-copy">
|
||||
<strong>{{ slot.label }}<span v-if="isScreenshotRequired(slot)">*</span></strong>
|
||||
<small>{{ slot.hint }}</small>
|
||||
</div>
|
||||
<div v-if="screenshotFiles[slot.key]" class="upload-preview">
|
||||
<img :src="getScreenshotPreviewURL(slot.key)" :alt="slot.label" />
|
||||
<button type="button" @click="removeScreenshot(slot.key)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
<button v-else type="button" class="upload-add" :disabled="uploading" @click="triggerUpload(slot.key)">
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>{{ uploading && activeUploadKey === slot.key ? '上传中' : '上传' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
class="hidden-file"
|
||||
@change="handleScreenshotUpload"
|
||||
/>
|
||||
</PublishSection>
|
||||
|
||||
<PublishSection title="押金与价格" description="系统按资料自动计算售价">
|
||||
<div class="compact-grid two">
|
||||
<label class="input-block">
|
||||
<span>押金<b>*</b></span>
|
||||
<div class="deposit-control">
|
||||
<el-input-number
|
||||
v-model="form.deposit_amount"
|
||||
:min="0"
|
||||
:controls="false"
|
||||
:placeholder="priceConfig.deposit_placeholder"
|
||||
/>
|
||||
<button class="recommend-button" type="button" @click="useRecommendedDeposit">
|
||||
使用推荐 ¥{{ recommendedDepositAmount }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="deposit-breakdown">
|
||||
<span v-for="item in depositBreakdownItems" :key="`${item.label}-${item.count}`">
|
||||
{{ item.label }}<template v-if="item.count > 1"> x{{ item.count }}</template> ¥{{ item.amount }}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="input-block">
|
||||
<span>每日损耗<b>*</b></span>
|
||||
<OptionChips
|
||||
:options="dailyLossOptions"
|
||||
:model-value="dailyLossMAmount"
|
||||
compact
|
||||
suffix="M/天"
|
||||
@select="selectDailyLoss"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="ratio-panel">
|
||||
<div class="ratio-reference">
|
||||
<span>参考比例</span>
|
||||
<strong>{{ calculatedDefaultSaleRatioText }}</strong>
|
||||
</div>
|
||||
<p>{{ 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: form.accelerated_sale_ratio === maxAcceleratedSaleRatio && maxAcceleratedSaleRatio > 0 }"
|
||||
:disabled="maxAcceleratedSaleRatio <= 0"
|
||||
@click="useMaxAcceleratedSaleRatio"
|
||||
>
|
||||
<strong>最高比例 (加速)</strong>
|
||||
<span>{{ maxAcceleratedSaleRatio > 0 ? `最高 1元=${formatNumber(maxAcceleratedSaleRatio)}万` : '自动计算' }}</span>
|
||||
</button>
|
||||
<div
|
||||
class="ratio-custom-card"
|
||||
:class="{ active: hasAcceleratedSaleRatioInput() && form.accelerated_sale_ratio !== maxAcceleratedSaleRatio }"
|
||||
>
|
||||
<strong>自定比例</strong>
|
||||
<el-input-number
|
||||
:model-value="form.accelerated_sale_ratio"
|
||||
:min="0"
|
||||
:controls="false"
|
||||
:placeholder="acceleratedSaleRatioPlaceholder"
|
||||
@update:model-value="handleAcceleratedSaleRatioInput"
|
||||
@blur="clampAcceleratedSaleRatioInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="price-grid">
|
||||
<div class="price-cell">
|
||||
<span>卖家比例</span>
|
||||
<strong>{{ calculatedRatioText }}</strong>
|
||||
</div>
|
||||
<div class="price-cell">
|
||||
<span>纯币基础价</span>
|
||||
<strong>{{ calculatedCoinBasePrice ? `¥${calculatedCoinBasePrice}` : '--' }}</strong>
|
||||
</div>
|
||||
<div class="price-cell">
|
||||
<span>额外消耗品</span>
|
||||
<strong>¥{{ calculatedConsumablePrice }}</strong>
|
||||
</div>
|
||||
<div class="price-cell accent">
|
||||
<span>发布价格</span>
|
||||
<strong>{{ calculatedSellerPrice ? `¥${calculatedSellerPrice}` : '--' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="input-block remark">
|
||||
<span>备注</span>
|
||||
<el-input
|
||||
v-model="form.remark"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="如有不可使用的物资,请在此备注,并在买家下单后主动提醒。"
|
||||
/>
|
||||
</label>
|
||||
</PublishSection>
|
||||
</section>
|
||||
|
||||
<aside class="summary-column">
|
||||
<div class="summary-panel">
|
||||
<div class="summary-main">
|
||||
<span>卖家发布价格</span>
|
||||
<strong>{{ calculatedSellerPrice ? `¥${calculatedSellerPrice}` : '--' }}</strong>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-actions">
|
||||
<el-button :icon="UploadFilled" class="btn-publish" type="primary" :loading="loading" @click="handleSubmit">立即发布</el-button>
|
||||
<el-button :icon="DocumentChecked" :disabled="loading" @click="handleSaveDraft">保存草稿</el-button>
|
||||
<el-button :icon="RefreshRight" :disabled="loading" @click="handleResetDraft">重置草稿</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped src="./SellerListingCreateView.css"></style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { fetchSellerListings, offlineListing, submitListingReview, type Listing } from '@/api/listings'
|
||||
import { listingReviewStatusLabel, listingStatusLabel } from '@/utils/statusLabels'
|
||||
import { getListingSellerPrice } from '@/utils/listingDisplay'
|
||||
|
||||
const loading = ref(false)
|
||||
const listings = ref<Listing[]>([])
|
||||
|
||||
onMounted(loadListings)
|
||||
|
||||
async function loadListings() {
|
||||
loading.value = true
|
||||
try {
|
||||
listings.value = await fetchSellerListings()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReview(id: number) {
|
||||
const listing = await submitListingReview(id)
|
||||
ElMessage.success(
|
||||
listing.status === 'published' && listing.review_status === 'approved'
|
||||
? '已上架'
|
||||
: '已提交审核,等待后台处理',
|
||||
)
|
||||
await loadListings()
|
||||
}
|
||||
|
||||
async function offline(id: number) {
|
||||
await offlineListing(id)
|
||||
ElMessage.success('已下架')
|
||||
await loadListings()
|
||||
}
|
||||
|
||||
function listingPrice(row: Listing) {
|
||||
return `¥${Math.round(getListingSellerPrice(row))}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<div class="page-header page-header-row">
|
||||
<div>
|
||||
<p class="eyebrow">Seller</p>
|
||||
<h1>我的发布</h1>
|
||||
<p>管理账号发布、审核状态、上架和下架。</p>
|
||||
</div>
|
||||
<RouterLink to="/seller/listings/create">
|
||||
<el-button type="primary">发布账号</el-button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" class="table-panel" :data="listings">
|
||||
<el-table-column prop="title" label="标题" min-width="180" />
|
||||
<el-table-column prop="server_region" label="区服" width="120" />
|
||||
<el-table-column prop="haf_coin_amount" label="哈夫币" width="120" />
|
||||
<el-table-column label="价格" width="100">
|
||||
<template #default="{ row }">{{ listingPrice(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deposit_amount" label="押金" width="100" />
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">{{ listingStatusLabel(row.status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="审核" width="110">
|
||||
<template #default="{ row }">{{ listingReviewStatusLabel(row.review_status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="review_reason" label="审核原因" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="190">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
size="small"
|
||||
:disabled="row.review_status === 'pending' || row.status === 'rented' || row.status === 'published'"
|
||||
@click="submitReview(row.id)"
|
||||
>
|
||||
提审
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" :disabled="row.status === 'rented'" @click="offline(row.id)">下架</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user