From a8b3d3813b369af983f8766aa1652276252e3a11 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 7 Jun 2026 21:40:36 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=8F=91=E5=B8=83=E6=88=AA?= =?UTF-8?q?=E5=9B=BE=E4=B8=8A=E4=BC=A0=E4=B8=8E=E6=8A=BC=E9=87=91=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/modules/systemconfig/dto.go | 1 + .../modules/systemconfig/publish_options.go | 1 + .../internal/modules/systemconfig/service.go | 3 + .../admin/components/PublishOptionsDialog.vue | 7 + .../features/listings/api/listingOptions.ts | 6 + .../listings/views/ListingDetailView.vue | 23 +++ .../views/MobileListingDetailView.vue | 23 +++ .../seller/composables/usePublishDraft.ts | 22 ++- .../seller/composables/usePublishForm.ts | 146 ++++++++++++++---- .../views/MobileSellerListingCreateView.css | 25 +++ .../views/MobileSellerListingCreateView.vue | 30 +++- .../seller/views/SellerListingCreateView.css | 40 +++++ .../seller/views/SellerListingCreateView.vue | 26 +++- .../composables/usePricingCalculator.ts | 4 +- frontend/src/shared/types/publish.ts | 2 +- frontend/src/types/publish.ts | 2 +- 16 files changed, 308 insertions(+), 53 deletions(-) diff --git a/backend/internal/modules/systemconfig/dto.go b/backend/internal/modules/systemconfig/dto.go index 442216c..567c9ab 100644 --- a/backend/internal/modules/systemconfig/dto.go +++ b/backend/internal/modules/systemconfig/dto.go @@ -97,6 +97,7 @@ type PublishScreenshotSlot struct { type PublishPriceConfig struct { DepositPlaceholder string `json:"deposit_placeholder"` + DepositHint string `json:"deposit_hint"` PricePlaceholder string `json:"price_placeholder"` RatioDescription string `json:"ratio_description"` } diff --git a/backend/internal/modules/systemconfig/publish_options.go b/backend/internal/modules/systemconfig/publish_options.go index d3d1d6d..fcc862d 100644 --- a/backend/internal/modules/systemconfig/publish_options.go +++ b/backend/internal/modules/systemconfig/publish_options.go @@ -84,6 +84,7 @@ func DefaultPublishOptions() PublishOptionsDTO { FireLevelMin: 38, PriceConfig: PublishPriceConfig{ DepositPlaceholder: "温馨提示:本押金用于保障账号安全。若买家在租用期间,使用非纯币类道具/资源(如消耗品、材料等)或导致账号被封禁,相关损失将直接从押金中扣除进行赔付。押金过高会延长出租等待时间(请自行衡量)感谢理解。", + DepositHint: "租客缴纳,封号必赔付", PricePlaceholder: "填写币数、保险、体力和负重后自动计算;发布总价=纯币基础价+额外消耗品", RatioDescription: "1:xx 表示 1 元人民币(RMB)等价兑换 xx 万哈夫币", }, diff --git a/backend/internal/modules/systemconfig/service.go b/backend/internal/modules/systemconfig/service.go index c05fa40..6f138f6 100644 --- a/backend/internal/modules/systemconfig/service.go +++ b/backend/internal/modules/systemconfig/service.go @@ -347,6 +347,9 @@ func normalizePublishOptions(options *PublishOptionsDTO) { if options.PriceConfig.DepositPlaceholder == "" { options.PriceConfig.DepositPlaceholder = defaults.PriceConfig.DepositPlaceholder } + if options.PriceConfig.DepositHint == "" { + options.PriceConfig.DepositHint = defaults.PriceConfig.DepositHint + } if options.PriceConfig.PricePlaceholder == "" { options.PriceConfig.PricePlaceholder = defaults.PriceConfig.PricePlaceholder } diff --git a/frontend/src/features/admin/components/PublishOptionsDialog.vue b/frontend/src/features/admin/components/PublishOptionsDialog.vue index 822aac9..84d1167 100644 --- a/frontend/src/features/admin/components/PublishOptionsDialog.vue +++ b/frontend/src/features/admin/components/PublishOptionsDialog.vue @@ -311,6 +311,13 @@ function readError(error: unknown, fallback: string) { + + + diff --git a/frontend/src/features/listings/api/listingOptions.ts b/frontend/src/features/listings/api/listingOptions.ts index 81214a4..00e980d 100644 --- a/frontend/src/features/listings/api/listingOptions.ts +++ b/frontend/src/features/listings/api/listingOptions.ts @@ -31,6 +31,7 @@ export interface PublishScreenshotSlot { export interface PublishPriceConfig { deposit_placeholder: string + deposit_hint: string price_placeholder: string ratio_description: string } @@ -132,6 +133,7 @@ export const emptyListingPublishOptions: ListingPublishOptions = { fire_level_min: 38, price_config: { deposit_placeholder: '', + deposit_hint: '租客缴纳,封号必赔付', price_placeholder: '', ratio_description: '', }, @@ -287,6 +289,10 @@ function normalizePriceConfig(value?: unknown): PublishPriceConfig { const row = isRecord(value) ? value : {} return { deposit_placeholder: typeof row.deposit_placeholder === 'string' ? row.deposit_placeholder.trim() : '', + deposit_hint: + typeof row.deposit_hint === 'string' && row.deposit_hint.trim() + ? row.deposit_hint.trim() + : emptyListingPublishOptions.price_config.deposit_hint, price_placeholder: typeof row.price_placeholder === 'string' ? row.price_placeholder.trim() : '', ratio_description: typeof row.ratio_description === 'string' ? row.ratio_description.trim() : '', } diff --git a/frontend/src/features/listings/views/ListingDetailView.vue b/frontend/src/features/listings/views/ListingDetailView.vue index 4bf3ea6..b1b2b8d 100644 --- a/frontend/src/features/listings/views/ListingDetailView.vue +++ b/frontend/src/features/listings/views/ListingDetailView.vue @@ -103,6 +103,8 @@ const detailMetrics = computed(() => { const detailScreenshots = computed(() => { if (!listing.value) return []; + const groupedScreenshots = readGroupedScreenshots(listing.value); + if (groupedScreenshots.length) return groupedScreenshots; const labels = ["纯币截图", "游戏ID截图", "总资产截图", "腾讯安全中心截图", "皮肤截图"]; return (listing.value.screenshot_urls || []).map((url, index) => ({ label: labels[index] || `账号截图${index + 1}`, @@ -110,6 +112,27 @@ const detailScreenshots = computed(() => { })); }); +function readGroupedScreenshots(item: Listing) { + const groups = item.asset_summary?.screenshot_groups; + if (typeof groups !== "object" || groups === null) return []; + const slots = [ + { key: "coin", label: "纯币截图" }, + { key: "gameId", label: "游戏ID截图" }, + { key: "totalAsset", label: "总资产截图" }, + { key: "tencentSecurity", label: "腾讯安全中心截图" }, + { key: "skin", label: "皮肤截图" }, + ]; + return slots.flatMap((slot) => { + const urls = (groups as Record)[slot.key]; + if (!Array.isArray(urls)) return []; + const validUrls = urls.filter((url): url is string => typeof url === "string" && Boolean(url)); + return validUrls.map((url, index) => ({ + label: validUrls.length > 1 ? `${slot.label}${index + 1}` : slot.label, + url, + })); + }); +} + const detailSkinGroups = computed(() => { if (!listing.value) return []; const groups = listing.value.asset_summary?.skin_groups; diff --git a/frontend/src/features/listings/views/MobileListingDetailView.vue b/frontend/src/features/listings/views/MobileListingDetailView.vue index 958d18d..5199943 100644 --- a/frontend/src/features/listings/views/MobileListingDetailView.vue +++ b/frontend/src/features/listings/views/MobileListingDetailView.vue @@ -95,6 +95,8 @@ const detailMetrics = computed(() => { const detailScreenshots = computed(() => { if (!listing.value) return []; + const groupedScreenshots = readGroupedScreenshots(listing.value); + if (groupedScreenshots.length) return groupedScreenshots; const labels = ["纯币截图", "游戏ID截图", "总资产截图", "腾讯安全中心截图", "皮肤截图"]; return (listing.value.screenshot_urls || []).map((url, index) => ({ label: labels[index] || `账号截图${index + 1}`, @@ -102,6 +104,27 @@ const detailScreenshots = computed(() => { })); }); +function readGroupedScreenshots(item: Listing) { + const groups = item.asset_summary?.screenshot_groups; + if (typeof groups !== "object" || groups === null) return []; + const slots = [ + { key: "coin", label: "纯币截图" }, + { key: "gameId", label: "游戏ID截图" }, + { key: "totalAsset", label: "总资产截图" }, + { key: "tencentSecurity", label: "腾讯安全中心截图" }, + { key: "skin", label: "皮肤截图" }, + ]; + return slots.flatMap((slot) => { + const urls = (groups as Record)[slot.key]; + if (!Array.isArray(urls)) return []; + const validUrls = urls.filter((url): url is string => typeof url === "string" && Boolean(url)); + return validUrls.map((url, index) => ({ + label: validUrls.length > 1 ? `${slot.label}${index + 1}` : slot.label, + url, + })); + }); +} + const detailSkinGroups = computed(() => { if (!listing.value) return []; const groups = listing.value.asset_summary?.skin_groups; diff --git a/frontend/src/features/seller/composables/usePublishDraft.ts b/frontend/src/features/seller/composables/usePublishDraft.ts index a851d32..f6063a6 100644 --- a/frontend/src/features/seller/composables/usePublishDraft.ts +++ b/frontend/src/features/seller/composables/usePublishDraft.ts @@ -28,7 +28,7 @@ export function buildPublishDraft(options: { form: PublishForm quantityValues: Record quantityModes: Record - screenshotFiles: Record + screenshotFiles: Record selectedSkins: string[] }): PublishDraft { return { @@ -38,7 +38,7 @@ export function buildPublishDraft(options: { }, quantityValues: { ...options.quantityValues }, quantityModes: { ...options.quantityModes }, - screenshotFiles: { ...options.screenshotFiles }, + screenshotFiles: cloneScreenshotFiles(options.screenshotFiles), selectedSkins: [...options.selectedSkins], } } @@ -52,7 +52,7 @@ export function readPublishDraft(draftKey: string) { form: normalizeDraftForm(draft.form), quantityValues: normalizeNumberRecord(draft.quantityValues), quantityModes: normalizeQuantityModes(draft.quantityModes), - screenshotFiles: normalizeStringRecord(draft.screenshotFiles), + screenshotFiles: normalizeScreenshotFiles(draft.screenshotFiles), selectedSkins: Array.isArray(draft.selectedSkins) ? draft.selectedSkins.filter((skin): skin is string => typeof skin === 'string') : [], @@ -105,15 +105,25 @@ function normalizeNumberRecord(value: unknown) { return record } -function normalizeStringRecord(value: unknown) { - const record: Record = {} +function normalizeScreenshotFiles(value: unknown) { + const record: Record = {} if (!isRecord(value)) return record for (const [key, item] of Object.entries(value)) { - if (typeof item === 'string') record[key] = item + if (typeof item === 'string') { + record[key] = item ? [item] : [] + continue + } + if (Array.isArray(item)) { + record[key] = item.filter((url): url is string => typeof url === 'string' && Boolean(url)) + } } return record } +function cloneScreenshotFiles(value: Record) { + return Object.fromEntries(Object.entries(value).map(([key, urls]) => [key, [...urls]])) +} + function normalizeQuantityModes(value: unknown) { const record: Record = {} if (!isRecord(value)) return record diff --git a/frontend/src/features/seller/composables/usePublishForm.ts b/frontend/src/features/seller/composables/usePublishForm.ts index 6630926..b94f024 100644 --- a/frontend/src/features/seller/composables/usePublishForm.ts +++ b/frontend/src/features/seller/composables/usePublishForm.ts @@ -29,6 +29,8 @@ import type { PublishForm } from '@/types/publish' import { commonOnlineTimes, dailyLossOptions, formatNumber, roundRatio } from '@/utils/pricing' const draftSaveDelay = 400 +const multiScreenshotLimit = 3 +const multiScreenshotKeys = new Set(['tencentSecurity', 'skin']) interface UsePublishFormOptions { draftKey: string @@ -56,8 +58,8 @@ export function usePublishForm(options: UsePublishFormOptions) { const form = reactive(defaultPublishForm()) const quantityValues = reactive>({}) const quantityModes = reactive>({}) - const screenshotFiles = reactive>({}) - const screenshotPreviews = reactive>({}) + const screenshotFiles = reactive>({}) + const screenshotPreviews = reactive>({}) const selectedSkins = ref([]) let draftSaveTimer: number | undefined @@ -70,7 +72,9 @@ export function usePublishForm(options: UsePublishFormOptions) { screenshotFiles, selectedSkins, }) - const uploadedScreenshotCount = computed(() => pricing.screenshotUrls.value.length) + const uploadedScreenshotCount = computed( + () => pricing.screenshotSlots.value.filter((item) => getScreenshotCount(item.key) > 0).length, + ) const requiredScreenshotCount = ref(0) const canPublishAfterAgreements = computed( () => virtualAssetSaleAgreementChecked.value && sellerAgreementChecked.value, @@ -263,24 +267,43 @@ export function usePublishForm(options: UsePublishFormOptions) { } function triggerUpload(key: ScreenshotKey) { + if (!canAddScreenshot(key)) { + options.notifyWarning(`${getScreenshotLabel(key)}最多上传${getScreenshotLimit(key)}张`) + return + } 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 files = Array.from(input.files || []) + if (!files.length) return const key = activeUploadKey.value - const previewURL = URL.createObjectURL(file) - setScreenshotPreview(key, previewURL) + const availableCount = getScreenshotLimit(key) - getScreenshotCount(key) + if (availableCount <= 0) { + options.notifyWarning(`${getScreenshotLabel(key)}最多上传${getScreenshotLimit(key)}张`) + input.value = '' + return + } + const uploadFiles = files.slice(0, availableCount) + if (files.length > availableCount) { + options.notifyWarning(`${getScreenshotLabel(key)}最多上传${getScreenshotLimit(key)}张,本次仅上传${availableCount}张`) + } uploading.value = true try { - const uploaded = await uploadFile(file, 'listing') - screenshotFiles[key] = uploaded.url - options.notifySuccess('截图已上传') + for (const file of uploadFiles) { + const previewURL = URL.createObjectURL(file) + try { + const uploaded = await uploadFile(file, 'listing') + appendScreenshot(key, uploaded.url, previewURL) + } catch (error) { + URL.revokeObjectURL(previewURL) + throw error + } + } + options.notifySuccess(uploadFiles.length > 1 ? `已上传${uploadFiles.length}张截图` : '截图已上传') } catch (error) { - revokeScreenshotPreview(key) options.notifyError(readError(error, '截图上传失败')) } finally { uploading.value = false @@ -288,38 +311,55 @@ export function usePublishForm(options: UsePublishFormOptions) { } } - function removeScreenshot(key: ScreenshotKey) { - screenshotFiles[key] = '' - revokeScreenshotPreview(key) + function removeScreenshot(key: ScreenshotKey, index = 0) { + revokeScreenshotPreview(key, index) + screenshotFiles[key]?.splice(index, 1) + if (!screenshotFiles[key]?.length) delete screenshotFiles[key] } - function getScreenshotPreviewURL(key: ScreenshotKey) { - return screenshotPreviews[key] || screenshotFiles[key] || '' + function getScreenshotPreviewURL(key: ScreenshotKey, index = 0) { + return screenshotPreviews[key]?.[index] || screenshotFiles[key]?.[index] || '' } - function setScreenshotPreview(key: ScreenshotKey, previewURL: string) { - revokeScreenshotPreview(key) - screenshotPreviews[key] = previewURL + function appendScreenshot(key: ScreenshotKey, fileURL: string, previewURL: string) { + if (!screenshotFiles[key]) screenshotFiles[key] = [] + if (!screenshotPreviews[key]) screenshotPreviews[key] = [] + screenshotFiles[key].push(fileURL) + screenshotPreviews[key].push(previewURL) } - function revokeScreenshotPreview(key: ScreenshotKey) { - const previewURL = screenshotPreviews[key] + function setScreenshotPreview(key: ScreenshotKey, index: number, previewURL: string) { + revokeScreenshotPreview(key, index) + if (!screenshotPreviews[key]) screenshotPreviews[key] = [] + screenshotPreviews[key][index] = previewURL + } + + function revokeScreenshotPreview(key: ScreenshotKey, index: number) { + const previewURL = screenshotPreviews[key]?.[index] if (previewURL?.startsWith('blob:')) URL.revokeObjectURL(previewURL) - delete screenshotPreviews[key] + screenshotPreviews[key]?.splice(index, 1) + if (!screenshotPreviews[key]?.length) delete screenshotPreviews[key] } function revokeAllScreenshotPreviews() { - for (const key of Object.keys(screenshotPreviews)) revokeScreenshotPreview(key) + for (const [key, previews] of Object.entries(screenshotPreviews)) { + for (const previewURL of previews) { + if (previewURL?.startsWith('blob:')) URL.revokeObjectURL(previewURL) + } + delete screenshotPreviews[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。 + for (const [key, fileURLs] of Object.entries(screenshotFiles)) { + for (const [index, fileURL] of fileURLs.entries()) { + if (!fileURL || screenshotPreviews[key]?.[index] || !fileURL.startsWith('/api/files/object')) continue + try { + const blob = await fetchFileBlobByURL(fileURL) + setScreenshotPreview(key, index, URL.createObjectURL(blob)) + } catch { + // 草稿预览失败不影响已上传文件地址,提交时仍会带上原 URL。 + } } } } @@ -463,7 +503,7 @@ export function usePublishForm(options: UsePublishFormOptions) { if (!Number.isFinite(pricing.calculatedFinalPrice.value)) return '发布价格计算异常,请检查填写内容' if (!canPublishAfterAgreements.value) return '请先阅读并勾选两份发布协议' for (const item of pricing.screenshotSlots.value) { - if (isScreenshotRequired(item) && !screenshotFiles[item.key]) return `请上传${item.label}` + if (isScreenshotRequired(item) && getScreenshotCount(item.key) === 0) return `请上传${item.label}` } for (const item of pricing.quantityItems.value) { if (!pricing.isQuantityItemDisabled(item)) { @@ -529,6 +569,7 @@ export function usePublishForm(options: UsePublishFormOptions) { groups[group.key] = group.options.filter((skin) => selectedSkins.value.includes(skin)) return groups }, {}), + screenshot_groups: buildScreenshotGroups(), online_time: { start: form.online_start, end: form.online_end, @@ -539,6 +580,43 @@ export function usePublishForm(options: UsePublishFormOptions) { } } + function buildScreenshotGroups() { + return pricing.screenshotSlots.value.reduce>((groups, slot) => { + groups[slot.key] = getScreenshotURLs(slot.key) + return groups + }, {}) + } + + function getScreenshotURLs(key: ScreenshotKey) { + return (screenshotFiles[key] || []).filter(Boolean) + } + + function getScreenshotCount(key: ScreenshotKey) { + return getScreenshotURLs(key).length + } + + function getScreenshotLimit(key: ScreenshotKey) { + return multiScreenshotKeys.has(key) ? multiScreenshotLimit : 1 + } + + function isMultiScreenshotSlot(key: ScreenshotKey) { + return getScreenshotLimit(key) > 1 + } + + function canAddScreenshot(key: ScreenshotKey) { + return !uploading.value && getScreenshotCount(key) < getScreenshotLimit(key) + } + + function getScreenshotLimitHint(key: ScreenshotKey) { + const limit = getScreenshotLimit(key) + if (limit <= 1) return '' + return `最多${limit}张,已上传${getScreenshotCount(key)}张` + } + + function getScreenshotLabel(key: ScreenshotKey) { + return pricing.screenshotSlots.value.find((item) => item.key === key)?.label || '该截图' + } + return { ...pricing, dailyLossOptions, @@ -571,7 +649,13 @@ export function usePublishForm(options: UsePublishFormOptions) { triggerUpload, handleScreenshotUpload, removeScreenshot, + getScreenshotURLs, + getScreenshotCount, getScreenshotPreviewURL, + getScreenshotLimit, + isMultiScreenshotSlot, + canAddScreenshot, + getScreenshotLimitHint, handleFireLevelInput, handleAcceleratedSaleRatioInput, syncRecommendedDeposit, diff --git a/frontend/src/features/seller/views/MobileSellerListingCreateView.css b/frontend/src/features/seller/views/MobileSellerListingCreateView.css index 4d8fc82..e5b8cbd 100644 --- a/frontend/src/features/seller/views/MobileSellerListingCreateView.css +++ b/frontend/src/features/seller/views/MobileSellerListingCreateView.css @@ -99,6 +99,24 @@ outline: none; } +.deposit-label { + flex-wrap: wrap; + gap: 4px; +} + +.deposit-label em { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: 999px; + background: #fff3f4; + color: #f04452; + font-size: 10px; + font-style: normal; + font-weight: 700; + line-height: 1.25; +} + .hint-label::before, .backend-hint-target.has-hint::before, .upload-title.has-hint::before, @@ -473,6 +491,13 @@ line-height: 1.4; } +.upload-preview-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + .upload-add, .upload-preview { width: 76px; diff --git a/frontend/src/features/seller/views/MobileSellerListingCreateView.vue b/frontend/src/features/seller/views/MobileSellerListingCreateView.vue index 40a1c2a..70a13a8 100644 --- a/frontend/src/features/seller/views/MobileSellerListingCreateView.vue +++ b/frontend/src/features/seller/views/MobileSellerListingCreateView.vue @@ -22,7 +22,6 @@ const { form, quantityValues, quantityModes, - screenshotFiles, selectedSkins, serverOptions, faceOptions, @@ -57,7 +56,12 @@ const { triggerUpload, handleScreenshotUpload, removeScreenshot, + getScreenshotURLs, + getScreenshotCount, getScreenshotPreviewURL, + isMultiScreenshotSlot, + canAddScreenshot, + getScreenshotLimitHint, handleFireLevelInput, handleAcceleratedSaleRatioInput, useRecommendedDeposit, @@ -480,10 +484,23 @@ function selectOnlineEnd(value: string) { {{ slot.label }}* {{ slot.hint }} + {{ getScreenshotLimitHint(slot.key) }} -
- - +
+
+ + +
+
- +
{{ slot.label }}* {{ slot.hint }} + {{ getScreenshotLimitHint(slot.key) }}
-
- - +
+