优化发布截图上传与押金提示
This commit is contained in:
@@ -97,6 +97,7 @@ type PublishScreenshotSlot struct {
|
|||||||
|
|
||||||
type PublishPriceConfig struct {
|
type PublishPriceConfig struct {
|
||||||
DepositPlaceholder string `json:"deposit_placeholder"`
|
DepositPlaceholder string `json:"deposit_placeholder"`
|
||||||
|
DepositHint string `json:"deposit_hint"`
|
||||||
PricePlaceholder string `json:"price_placeholder"`
|
PricePlaceholder string `json:"price_placeholder"`
|
||||||
RatioDescription string `json:"ratio_description"`
|
RatioDescription string `json:"ratio_description"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ func DefaultPublishOptions() PublishOptionsDTO {
|
|||||||
FireLevelMin: 38,
|
FireLevelMin: 38,
|
||||||
PriceConfig: PublishPriceConfig{
|
PriceConfig: PublishPriceConfig{
|
||||||
DepositPlaceholder: "温馨提示:本押金用于保障账号安全。若买家在租用期间,使用非纯币类道具/资源(如消耗品、材料等)或导致账号被封禁,相关损失将直接从押金中扣除进行赔付。押金过高会延长出租等待时间(请自行衡量)感谢理解。",
|
DepositPlaceholder: "温馨提示:本押金用于保障账号安全。若买家在租用期间,使用非纯币类道具/资源(如消耗品、材料等)或导致账号被封禁,相关损失将直接从押金中扣除进行赔付。押金过高会延长出租等待时间(请自行衡量)感谢理解。",
|
||||||
|
DepositHint: "租客缴纳,封号必赔付",
|
||||||
PricePlaceholder: "填写币数、保险、体力和负重后自动计算;发布总价=纯币基础价+额外消耗品",
|
PricePlaceholder: "填写币数、保险、体力和负重后自动计算;发布总价=纯币基础价+额外消耗品",
|
||||||
RatioDescription: "1:xx 表示 1 元人民币(RMB)等价兑换 xx 万哈夫币",
|
RatioDescription: "1:xx 表示 1 元人民币(RMB)等价兑换 xx 万哈夫币",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -347,6 +347,9 @@ func normalizePublishOptions(options *PublishOptionsDTO) {
|
|||||||
if options.PriceConfig.DepositPlaceholder == "" {
|
if options.PriceConfig.DepositPlaceholder == "" {
|
||||||
options.PriceConfig.DepositPlaceholder = defaults.PriceConfig.DepositPlaceholder
|
options.PriceConfig.DepositPlaceholder = defaults.PriceConfig.DepositPlaceholder
|
||||||
}
|
}
|
||||||
|
if options.PriceConfig.DepositHint == "" {
|
||||||
|
options.PriceConfig.DepositHint = defaults.PriceConfig.DepositHint
|
||||||
|
}
|
||||||
if options.PriceConfig.PricePlaceholder == "" {
|
if options.PriceConfig.PricePlaceholder == "" {
|
||||||
options.PriceConfig.PricePlaceholder = defaults.PriceConfig.PricePlaceholder
|
options.PriceConfig.PricePlaceholder = defaults.PriceConfig.PricePlaceholder
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -311,6 +311,13 @@ function readError(error: unknown, fallback: string) {
|
|||||||
<el-form-item label="押金提示">
|
<el-form-item label="押金提示">
|
||||||
<el-input v-model="publishOptionsDraft.price_config.deposit_placeholder" type="textarea" :rows="3" />
|
<el-input v-model="publishOptionsDraft.price_config.deposit_placeholder" type="textarea" :rows="3" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="押金后缀">
|
||||||
|
<el-input
|
||||||
|
v-model="publishOptionsDraft.price_config.deposit_hint"
|
||||||
|
class="full-control"
|
||||||
|
placeholder="例如:租客缴纳,封号必赔付"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="价格提示">
|
<el-form-item label="价格提示">
|
||||||
<el-input v-model="publishOptionsDraft.price_config.price_placeholder" class="full-control" />
|
<el-input v-model="publishOptionsDraft.price_config.price_placeholder" class="full-control" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface PublishScreenshotSlot {
|
|||||||
|
|
||||||
export interface PublishPriceConfig {
|
export interface PublishPriceConfig {
|
||||||
deposit_placeholder: string
|
deposit_placeholder: string
|
||||||
|
deposit_hint: string
|
||||||
price_placeholder: string
|
price_placeholder: string
|
||||||
ratio_description: string
|
ratio_description: string
|
||||||
}
|
}
|
||||||
@@ -132,6 +133,7 @@ export const emptyListingPublishOptions: ListingPublishOptions = {
|
|||||||
fire_level_min: 38,
|
fire_level_min: 38,
|
||||||
price_config: {
|
price_config: {
|
||||||
deposit_placeholder: '',
|
deposit_placeholder: '',
|
||||||
|
deposit_hint: '租客缴纳,封号必赔付',
|
||||||
price_placeholder: '',
|
price_placeholder: '',
|
||||||
ratio_description: '',
|
ratio_description: '',
|
||||||
},
|
},
|
||||||
@@ -287,6 +289,10 @@ function normalizePriceConfig(value?: unknown): PublishPriceConfig {
|
|||||||
const row = isRecord(value) ? value : {}
|
const row = isRecord(value) ? value : {}
|
||||||
return {
|
return {
|
||||||
deposit_placeholder: typeof row.deposit_placeholder === 'string' ? row.deposit_placeholder.trim() : '',
|
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() : '',
|
price_placeholder: typeof row.price_placeholder === 'string' ? row.price_placeholder.trim() : '',
|
||||||
ratio_description: typeof row.ratio_description === 'string' ? row.ratio_description.trim() : '',
|
ratio_description: typeof row.ratio_description === 'string' ? row.ratio_description.trim() : '',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ const detailMetrics = computed(() => {
|
|||||||
|
|
||||||
const detailScreenshots = computed(() => {
|
const detailScreenshots = computed(() => {
|
||||||
if (!listing.value) return [];
|
if (!listing.value) return [];
|
||||||
|
const groupedScreenshots = readGroupedScreenshots(listing.value);
|
||||||
|
if (groupedScreenshots.length) return groupedScreenshots;
|
||||||
const labels = ["纯币截图", "游戏ID截图", "总资产截图", "腾讯安全中心截图", "皮肤截图"];
|
const labels = ["纯币截图", "游戏ID截图", "总资产截图", "腾讯安全中心截图", "皮肤截图"];
|
||||||
return (listing.value.screenshot_urls || []).map((url, index) => ({
|
return (listing.value.screenshot_urls || []).map((url, index) => ({
|
||||||
label: labels[index] || `账号截图${index + 1}`,
|
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<string, unknown>)[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(() => {
|
const detailSkinGroups = computed(() => {
|
||||||
if (!listing.value) return [];
|
if (!listing.value) return [];
|
||||||
const groups = listing.value.asset_summary?.skin_groups;
|
const groups = listing.value.asset_summary?.skin_groups;
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ const detailMetrics = computed(() => {
|
|||||||
|
|
||||||
const detailScreenshots = computed(() => {
|
const detailScreenshots = computed(() => {
|
||||||
if (!listing.value) return [];
|
if (!listing.value) return [];
|
||||||
|
const groupedScreenshots = readGroupedScreenshots(listing.value);
|
||||||
|
if (groupedScreenshots.length) return groupedScreenshots;
|
||||||
const labels = ["纯币截图", "游戏ID截图", "总资产截图", "腾讯安全中心截图", "皮肤截图"];
|
const labels = ["纯币截图", "游戏ID截图", "总资产截图", "腾讯安全中心截图", "皮肤截图"];
|
||||||
return (listing.value.screenshot_urls || []).map((url, index) => ({
|
return (listing.value.screenshot_urls || []).map((url, index) => ({
|
||||||
label: labels[index] || `账号截图${index + 1}`,
|
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<string, unknown>)[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(() => {
|
const detailSkinGroups = computed(() => {
|
||||||
if (!listing.value) return [];
|
if (!listing.value) return [];
|
||||||
const groups = listing.value.asset_summary?.skin_groups;
|
const groups = listing.value.asset_summary?.skin_groups;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function buildPublishDraft(options: {
|
|||||||
form: PublishForm
|
form: PublishForm
|
||||||
quantityValues: Record<QuantityKey, number>
|
quantityValues: Record<QuantityKey, number>
|
||||||
quantityModes: Record<QuantityKey, ChargeMode>
|
quantityModes: Record<QuantityKey, ChargeMode>
|
||||||
screenshotFiles: Record<ScreenshotKey, string>
|
screenshotFiles: Record<ScreenshotKey, string[]>
|
||||||
selectedSkins: string[]
|
selectedSkins: string[]
|
||||||
}): PublishDraft {
|
}): PublishDraft {
|
||||||
return {
|
return {
|
||||||
@@ -38,7 +38,7 @@ export function buildPublishDraft(options: {
|
|||||||
},
|
},
|
||||||
quantityValues: { ...options.quantityValues },
|
quantityValues: { ...options.quantityValues },
|
||||||
quantityModes: { ...options.quantityModes },
|
quantityModes: { ...options.quantityModes },
|
||||||
screenshotFiles: { ...options.screenshotFiles },
|
screenshotFiles: cloneScreenshotFiles(options.screenshotFiles),
|
||||||
selectedSkins: [...options.selectedSkins],
|
selectedSkins: [...options.selectedSkins],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@ export function readPublishDraft(draftKey: string) {
|
|||||||
form: normalizeDraftForm(draft.form),
|
form: normalizeDraftForm(draft.form),
|
||||||
quantityValues: normalizeNumberRecord(draft.quantityValues),
|
quantityValues: normalizeNumberRecord(draft.quantityValues),
|
||||||
quantityModes: normalizeQuantityModes(draft.quantityModes),
|
quantityModes: normalizeQuantityModes(draft.quantityModes),
|
||||||
screenshotFiles: normalizeStringRecord(draft.screenshotFiles),
|
screenshotFiles: normalizeScreenshotFiles(draft.screenshotFiles),
|
||||||
selectedSkins: Array.isArray(draft.selectedSkins)
|
selectedSkins: Array.isArray(draft.selectedSkins)
|
||||||
? draft.selectedSkins.filter((skin): skin is string => typeof skin === 'string')
|
? draft.selectedSkins.filter((skin): skin is string => typeof skin === 'string')
|
||||||
: [],
|
: [],
|
||||||
@@ -105,15 +105,25 @@ function normalizeNumberRecord(value: unknown) {
|
|||||||
return record
|
return record
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStringRecord(value: unknown) {
|
function normalizeScreenshotFiles(value: unknown) {
|
||||||
const record: Record<string, string> = {}
|
const record: Record<string, string[]> = {}
|
||||||
if (!isRecord(value)) return record
|
if (!isRecord(value)) return record
|
||||||
for (const [key, item] of Object.entries(value)) {
|
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
|
return record
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cloneScreenshotFiles(value: Record<string, string[]>) {
|
||||||
|
return Object.fromEntries(Object.entries(value).map(([key, urls]) => [key, [...urls]]))
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeQuantityModes(value: unknown) {
|
function normalizeQuantityModes(value: unknown) {
|
||||||
const record: Record<string, ChargeMode> = {}
|
const record: Record<string, ChargeMode> = {}
|
||||||
if (!isRecord(value)) return record
|
if (!isRecord(value)) return record
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import type { PublishForm } from '@/types/publish'
|
|||||||
import { commonOnlineTimes, dailyLossOptions, formatNumber, roundRatio } from '@/utils/pricing'
|
import { commonOnlineTimes, dailyLossOptions, formatNumber, roundRatio } from '@/utils/pricing'
|
||||||
|
|
||||||
const draftSaveDelay = 400
|
const draftSaveDelay = 400
|
||||||
|
const multiScreenshotLimit = 3
|
||||||
|
const multiScreenshotKeys = new Set(['tencentSecurity', 'skin'])
|
||||||
|
|
||||||
interface UsePublishFormOptions {
|
interface UsePublishFormOptions {
|
||||||
draftKey: string
|
draftKey: string
|
||||||
@@ -56,8 +58,8 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
|||||||
const form = reactive<PublishForm>(defaultPublishForm())
|
const form = reactive<PublishForm>(defaultPublishForm())
|
||||||
const quantityValues = reactive<Record<string, number>>({})
|
const quantityValues = reactive<Record<string, number>>({})
|
||||||
const quantityModes = reactive<Record<string, ChargeMode>>({})
|
const quantityModes = reactive<Record<string, ChargeMode>>({})
|
||||||
const screenshotFiles = reactive<Record<string, string>>({})
|
const screenshotFiles = reactive<Record<string, string[]>>({})
|
||||||
const screenshotPreviews = reactive<Record<string, string>>({})
|
const screenshotPreviews = reactive<Record<string, string[]>>({})
|
||||||
const selectedSkins = ref<string[]>([])
|
const selectedSkins = ref<string[]>([])
|
||||||
let draftSaveTimer: number | undefined
|
let draftSaveTimer: number | undefined
|
||||||
|
|
||||||
@@ -70,7 +72,9 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
|||||||
screenshotFiles,
|
screenshotFiles,
|
||||||
selectedSkins,
|
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 requiredScreenshotCount = ref(0)
|
||||||
const canPublishAfterAgreements = computed(
|
const canPublishAfterAgreements = computed(
|
||||||
() => virtualAssetSaleAgreementChecked.value && sellerAgreementChecked.value,
|
() => virtualAssetSaleAgreementChecked.value && sellerAgreementChecked.value,
|
||||||
@@ -263,24 +267,43 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function triggerUpload(key: ScreenshotKey) {
|
function triggerUpload(key: ScreenshotKey) {
|
||||||
|
if (!canAddScreenshot(key)) {
|
||||||
|
options.notifyWarning(`${getScreenshotLabel(key)}最多上传${getScreenshotLimit(key)}张`)
|
||||||
|
return
|
||||||
|
}
|
||||||
activeUploadKey.value = key
|
activeUploadKey.value = key
|
||||||
fileInput.value?.click()
|
fileInput.value?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleScreenshotUpload(event: Event) {
|
async function handleScreenshotUpload(event: Event) {
|
||||||
const input = event.target as HTMLInputElement
|
const input = event.target as HTMLInputElement
|
||||||
const file = input.files?.[0]
|
const files = Array.from(input.files || [])
|
||||||
if (!file) return
|
if (!files.length) return
|
||||||
const key = activeUploadKey.value
|
const key = activeUploadKey.value
|
||||||
const previewURL = URL.createObjectURL(file)
|
const availableCount = getScreenshotLimit(key) - getScreenshotCount(key)
|
||||||
setScreenshotPreview(key, previewURL)
|
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
|
uploading.value = true
|
||||||
try {
|
try {
|
||||||
const uploaded = await uploadFile(file, 'listing')
|
for (const file of uploadFiles) {
|
||||||
screenshotFiles[key] = uploaded.url
|
const previewURL = URL.createObjectURL(file)
|
||||||
options.notifySuccess('截图已上传')
|
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) {
|
} catch (error) {
|
||||||
revokeScreenshotPreview(key)
|
|
||||||
options.notifyError(readError(error, '截图上传失败'))
|
options.notifyError(readError(error, '截图上传失败'))
|
||||||
} finally {
|
} finally {
|
||||||
uploading.value = false
|
uploading.value = false
|
||||||
@@ -288,38 +311,55 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeScreenshot(key: ScreenshotKey) {
|
function removeScreenshot(key: ScreenshotKey, index = 0) {
|
||||||
screenshotFiles[key] = ''
|
revokeScreenshotPreview(key, index)
|
||||||
revokeScreenshotPreview(key)
|
screenshotFiles[key]?.splice(index, 1)
|
||||||
|
if (!screenshotFiles[key]?.length) delete screenshotFiles[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
function getScreenshotPreviewURL(key: ScreenshotKey) {
|
function getScreenshotPreviewURL(key: ScreenshotKey, index = 0) {
|
||||||
return screenshotPreviews[key] || screenshotFiles[key] || ''
|
return screenshotPreviews[key]?.[index] || screenshotFiles[key]?.[index] || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function setScreenshotPreview(key: ScreenshotKey, previewURL: string) {
|
function appendScreenshot(key: ScreenshotKey, fileURL: string, previewURL: string) {
|
||||||
revokeScreenshotPreview(key)
|
if (!screenshotFiles[key]) screenshotFiles[key] = []
|
||||||
screenshotPreviews[key] = previewURL
|
if (!screenshotPreviews[key]) screenshotPreviews[key] = []
|
||||||
|
screenshotFiles[key].push(fileURL)
|
||||||
|
screenshotPreviews[key].push(previewURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
function revokeScreenshotPreview(key: ScreenshotKey) {
|
function setScreenshotPreview(key: ScreenshotKey, index: number, previewURL: string) {
|
||||||
const previewURL = screenshotPreviews[key]
|
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)
|
if (previewURL?.startsWith('blob:')) URL.revokeObjectURL(previewURL)
|
||||||
delete screenshotPreviews[key]
|
screenshotPreviews[key]?.splice(index, 1)
|
||||||
|
if (!screenshotPreviews[key]?.length) delete screenshotPreviews[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
function revokeAllScreenshotPreviews() {
|
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() {
|
async function hydrateScreenshotPreviews() {
|
||||||
for (const [key, fileURL] of Object.entries(screenshotFiles)) {
|
for (const [key, fileURLs] of Object.entries(screenshotFiles)) {
|
||||||
if (!fileURL || screenshotPreviews[key] || !fileURL.startsWith('/api/files/object')) continue
|
for (const [index, fileURL] of fileURLs.entries()) {
|
||||||
try {
|
if (!fileURL || screenshotPreviews[key]?.[index] || !fileURL.startsWith('/api/files/object')) continue
|
||||||
const blob = await fetchFileBlobByURL(fileURL)
|
try {
|
||||||
setScreenshotPreview(key, URL.createObjectURL(blob))
|
const blob = await fetchFileBlobByURL(fileURL)
|
||||||
} catch {
|
setScreenshotPreview(key, index, URL.createObjectURL(blob))
|
||||||
// 草稿预览失败不影响已上传文件地址,提交时仍会带上原 URL。
|
} catch {
|
||||||
|
// 草稿预览失败不影响已上传文件地址,提交时仍会带上原 URL。
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -463,7 +503,7 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
|||||||
if (!Number.isFinite(pricing.calculatedFinalPrice.value)) return '发布价格计算异常,请检查填写内容'
|
if (!Number.isFinite(pricing.calculatedFinalPrice.value)) return '发布价格计算异常,请检查填写内容'
|
||||||
if (!canPublishAfterAgreements.value) return '请先阅读并勾选两份发布协议'
|
if (!canPublishAfterAgreements.value) return '请先阅读并勾选两份发布协议'
|
||||||
for (const item of pricing.screenshotSlots.value) {
|
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) {
|
for (const item of pricing.quantityItems.value) {
|
||||||
if (!pricing.isQuantityItemDisabled(item)) {
|
if (!pricing.isQuantityItemDisabled(item)) {
|
||||||
@@ -529,6 +569,7 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
|||||||
groups[group.key] = group.options.filter((skin) => selectedSkins.value.includes(skin))
|
groups[group.key] = group.options.filter((skin) => selectedSkins.value.includes(skin))
|
||||||
return groups
|
return groups
|
||||||
}, {}),
|
}, {}),
|
||||||
|
screenshot_groups: buildScreenshotGroups(),
|
||||||
online_time: {
|
online_time: {
|
||||||
start: form.online_start,
|
start: form.online_start,
|
||||||
end: form.online_end,
|
end: form.online_end,
|
||||||
@@ -539,6 +580,43 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildScreenshotGroups() {
|
||||||
|
return pricing.screenshotSlots.value.reduce<Record<string, string[]>>((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 {
|
return {
|
||||||
...pricing,
|
...pricing,
|
||||||
dailyLossOptions,
|
dailyLossOptions,
|
||||||
@@ -571,7 +649,13 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
|||||||
triggerUpload,
|
triggerUpload,
|
||||||
handleScreenshotUpload,
|
handleScreenshotUpload,
|
||||||
removeScreenshot,
|
removeScreenshot,
|
||||||
|
getScreenshotURLs,
|
||||||
|
getScreenshotCount,
|
||||||
getScreenshotPreviewURL,
|
getScreenshotPreviewURL,
|
||||||
|
getScreenshotLimit,
|
||||||
|
isMultiScreenshotSlot,
|
||||||
|
canAddScreenshot,
|
||||||
|
getScreenshotLimitHint,
|
||||||
handleFireLevelInput,
|
handleFireLevelInput,
|
||||||
handleAcceleratedSaleRatioInput,
|
handleAcceleratedSaleRatioInput,
|
||||||
syncRecommendedDeposit,
|
syncRecommendedDeposit,
|
||||||
|
|||||||
@@ -99,6 +99,24 @@
|
|||||||
outline: none;
|
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,
|
.hint-label::before,
|
||||||
.backend-hint-target.has-hint::before,
|
.backend-hint-target.has-hint::before,
|
||||||
.upload-title.has-hint::before,
|
.upload-title.has-hint::before,
|
||||||
@@ -473,6 +491,13 @@
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-preview-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.upload-add,
|
.upload-add,
|
||||||
.upload-preview {
|
.upload-preview {
|
||||||
width: 76px;
|
width: 76px;
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ const {
|
|||||||
form,
|
form,
|
||||||
quantityValues,
|
quantityValues,
|
||||||
quantityModes,
|
quantityModes,
|
||||||
screenshotFiles,
|
|
||||||
selectedSkins,
|
selectedSkins,
|
||||||
serverOptions,
|
serverOptions,
|
||||||
faceOptions,
|
faceOptions,
|
||||||
@@ -57,7 +56,12 @@ const {
|
|||||||
triggerUpload,
|
triggerUpload,
|
||||||
handleScreenshotUpload,
|
handleScreenshotUpload,
|
||||||
removeScreenshot,
|
removeScreenshot,
|
||||||
|
getScreenshotURLs,
|
||||||
|
getScreenshotCount,
|
||||||
getScreenshotPreviewURL,
|
getScreenshotPreviewURL,
|
||||||
|
isMultiScreenshotSlot,
|
||||||
|
canAddScreenshot,
|
||||||
|
getScreenshotLimitHint,
|
||||||
handleFireLevelInput,
|
handleFireLevelInput,
|
||||||
handleAcceleratedSaleRatioInput,
|
handleAcceleratedSaleRatioInput,
|
||||||
useRecommendedDeposit,
|
useRecommendedDeposit,
|
||||||
@@ -480,10 +484,23 @@ function selectOnlineEnd(value: string) {
|
|||||||
{{ slot.label }}<span v-if="isScreenshotRequired(slot)">*</span>
|
{{ slot.label }}<span v-if="isScreenshotRequired(slot)">*</span>
|
||||||
</strong>
|
</strong>
|
||||||
<small>{{ slot.hint }}</small>
|
<small>{{ slot.hint }}</small>
|
||||||
|
<small v-if="getScreenshotLimitHint(slot.key)">{{ getScreenshotLimitHint(slot.key) }}</small>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="screenshotFiles[slot.key]" class="upload-preview">
|
<div v-if="getScreenshotCount(slot.key)" class="upload-preview-list">
|
||||||
<img :src="getScreenshotPreviewURL(slot.key)" :alt="slot.label" />
|
<div v-for="(url, index) in getScreenshotURLs(slot.key)" :key="`${slot.key}-${url}`" class="upload-preview">
|
||||||
<button type="button" @click="removeScreenshot(slot.key)">移除</button>
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
@@ -502,6 +519,7 @@ function selectOnlineEnd(value: string) {
|
|||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png,image/webp"
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
:multiple="isMultiScreenshotSlot(activeUploadKey)"
|
||||||
style="display: none"
|
style="display: none"
|
||||||
@change="handleScreenshotUpload"
|
@change="handleScreenshotUpload"
|
||||||
/>
|
/>
|
||||||
@@ -519,7 +537,9 @@ function selectOnlineEnd(value: string) {
|
|||||||
class="publish-field"
|
class="publish-field"
|
||||||
>
|
>
|
||||||
<template #label>
|
<template #label>
|
||||||
<span class="hint-label" :data-hint="priceConfig.deposit_placeholder" tabindex="0">押金</span>
|
<span class="hint-label deposit-label" :data-hint="priceConfig.deposit_placeholder" tabindex="0">
|
||||||
|
押金<em v-if="priceConfig.deposit_hint">{{ priceConfig.deposit_hint }}</em>
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</van-field>
|
</van-field>
|
||||||
<button class="deposit-recommend-btn" type="button" @click="useRecommendedDeposit">
|
<button class="deposit-recommend-btn" type="button" @click="useRecommendedDeposit">
|
||||||
|
|||||||
@@ -150,6 +150,28 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.deposit-label {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deposit-label em {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 20px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border: 1px solid #ffd6dc;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff7f8;
|
||||||
|
color: #f04452;
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
.level-panel,
|
.level-panel,
|
||||||
.time-preset-panel,
|
.time-preset-panel,
|
||||||
.skin-groups {
|
.skin-groups {
|
||||||
@@ -434,6 +456,20 @@
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-copy em {
|
||||||
|
color: #ff6a00;
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-preview-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.upload-add,
|
.upload-add,
|
||||||
.upload-preview {
|
.upload-preview {
|
||||||
width: 96px;
|
width: 96px;
|
||||||
@@ -909,6 +945,10 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-preview-list {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
.deposit-control {
|
.deposit-control {
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ const {
|
|||||||
form,
|
form,
|
||||||
quantityValues,
|
quantityValues,
|
||||||
quantityModes,
|
quantityModes,
|
||||||
screenshotFiles,
|
|
||||||
selectedSkins,
|
selectedSkins,
|
||||||
uploadedScreenshotCount,
|
uploadedScreenshotCount,
|
||||||
requiredScreenshotCount,
|
requiredScreenshotCount,
|
||||||
@@ -63,7 +62,12 @@ const {
|
|||||||
triggerUpload,
|
triggerUpload,
|
||||||
handleScreenshotUpload,
|
handleScreenshotUpload,
|
||||||
removeScreenshot,
|
removeScreenshot,
|
||||||
|
getScreenshotURLs,
|
||||||
|
getScreenshotCount,
|
||||||
getScreenshotPreviewURL,
|
getScreenshotPreviewURL,
|
||||||
|
isMultiScreenshotSlot,
|
||||||
|
canAddScreenshot,
|
||||||
|
getScreenshotLimitHint,
|
||||||
handleFireLevelInput,
|
handleFireLevelInput,
|
||||||
handleAcceleratedSaleRatioInput,
|
handleAcceleratedSaleRatioInput,
|
||||||
useRecommendedDeposit,
|
useRecommendedDeposit,
|
||||||
@@ -359,17 +363,24 @@ function selectOnlineEnd(value: string | number) {
|
|||||||
</div>
|
</div>
|
||||||
</PublishSection>
|
</PublishSection>
|
||||||
|
|
||||||
<PublishSection title="截图材料" :description="`${uploadedScreenshotCount}/${screenshotSlots.length} 已上传`">
|
<PublishSection title="截图材料" :description="`${uploadedScreenshotCount}/${screenshotSlots.length} 项已上传`">
|
||||||
<div class="upload-grid">
|
<div class="upload-grid">
|
||||||
<div v-for="slot in screenshotSlots" :key="slot.key" class="upload-item">
|
<div v-for="slot in screenshotSlots" :key="slot.key" class="upload-item">
|
||||||
<div class="upload-copy">
|
<div class="upload-copy">
|
||||||
<strong>{{ slot.label }}<span v-if="isScreenshotRequired(slot)">*</span></strong>
|
<strong>{{ slot.label }}<span v-if="isScreenshotRequired(slot)">*</span></strong>
|
||||||
<small>{{ slot.hint }}</small>
|
<small>{{ slot.hint }}</small>
|
||||||
|
<em v-if="getScreenshotLimitHint(slot.key)">{{ getScreenshotLimitHint(slot.key) }}</em>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="screenshotFiles[slot.key]" class="upload-preview">
|
<div v-if="getScreenshotCount(slot.key)" class="upload-preview-list">
|
||||||
<img :src="getScreenshotPreviewURL(slot.key)" :alt="slot.label" />
|
<div v-for="(url, index) in getScreenshotURLs(slot.key)" :key="`${slot.key}-${url}`" class="upload-preview">
|
||||||
<button type="button" @click="removeScreenshot(slot.key)">
|
<img :src="getScreenshotPreviewURL(slot.key, index)" :alt="`${slot.label}${index + 1}`" />
|
||||||
<el-icon><Delete /></el-icon>
|
<button type="button" @click="removeScreenshot(slot.key, index)">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button v-if="canAddScreenshot(slot.key)" type="button" class="upload-add" :disabled="uploading" @click="triggerUpload(slot.key)">
|
||||||
|
<el-icon><Picture /></el-icon>
|
||||||
|
<span>{{ uploading && activeUploadKey === slot.key ? '上传中' : '继续上传' }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button v-else type="button" class="upload-add" :disabled="uploading" @click="triggerUpload(slot.key)">
|
<button v-else type="button" class="upload-add" :disabled="uploading" @click="triggerUpload(slot.key)">
|
||||||
@@ -382,6 +393,7 @@ function selectOnlineEnd(value: string | number) {
|
|||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png,image/webp"
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
:multiple="isMultiScreenshotSlot(activeUploadKey)"
|
||||||
class="hidden-file"
|
class="hidden-file"
|
||||||
@change="handleScreenshotUpload"
|
@change="handleScreenshotUpload"
|
||||||
/>
|
/>
|
||||||
@@ -390,7 +402,7 @@ function selectOnlineEnd(value: string | number) {
|
|||||||
<PublishSection title="押金与价格" description="系统按资料自动计算售价">
|
<PublishSection title="押金与价格" description="系统按资料自动计算售价">
|
||||||
<div class="compact-grid two">
|
<div class="compact-grid two">
|
||||||
<label class="input-block">
|
<label class="input-block">
|
||||||
<span>押金<b>*</b></span>
|
<span class="deposit-label">押金<b>*</b><em v-if="priceConfig.deposit_hint">{{ priceConfig.deposit_hint }}</em></span>
|
||||||
<div class="deposit-control">
|
<div class="deposit-control">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="form.deposit_amount"
|
v-model="form.deposit_amount"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function usePricingCalculator(options: {
|
|||||||
form: PublishForm
|
form: PublishForm
|
||||||
quantityValues: Record<QuantityKey, number>
|
quantityValues: Record<QuantityKey, number>
|
||||||
quantityModes: Record<QuantityKey, ChargeMode>
|
quantityModes: Record<QuantityKey, ChargeMode>
|
||||||
screenshotFiles: Record<ScreenshotKey, string>
|
screenshotFiles: Record<ScreenshotKey, string[]>
|
||||||
selectedSkins: Ref<string[]>
|
selectedSkins: Ref<string[]>
|
||||||
}) {
|
}) {
|
||||||
const serverOptions = computed(() => options.publishOptions.value.server_options)
|
const serverOptions = computed(() => options.publishOptions.value.server_options)
|
||||||
@@ -47,7 +47,7 @@ export function usePricingCalculator(options: {
|
|||||||
const dailyLossMAmount = computed(() => Number(options.form.daily_loss_m || 10))
|
const dailyLossMAmount = computed(() => Number(options.form.daily_loss_m || 10))
|
||||||
const dailyLossRatioAdjustment = computed(() => calculateDailyLossRatioAdjustment(dailyLossMAmount.value))
|
const dailyLossRatioAdjustment = computed(() => calculateDailyLossRatioAdjustment(dailyLossMAmount.value))
|
||||||
const screenshotUrls = computed(() =>
|
const screenshotUrls = computed(() =>
|
||||||
screenshotSlots.value.map((item) => options.screenshotFiles?.[item.key]).filter((url): url is string => Boolean(url)),
|
screenshotSlots.value.flatMap((item) => options.screenshotFiles?.[item.key] || []).filter((url) => Boolean(url)),
|
||||||
)
|
)
|
||||||
|
|
||||||
function hasAcceleratedSaleRatioInput() {
|
function hasAcceleratedSaleRatioInput() {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export interface PublishDraft {
|
|||||||
form: PublishForm
|
form: PublishForm
|
||||||
quantityValues: Record<QuantityKey, number>
|
quantityValues: Record<QuantityKey, number>
|
||||||
quantityModes: Record<QuantityKey, ChargeMode>
|
quantityModes: Record<QuantityKey, ChargeMode>
|
||||||
screenshotFiles: Record<ScreenshotKey, string>
|
screenshotFiles: Record<ScreenshotKey, string[]>
|
||||||
selectedSkins: string[]
|
selectedSkins: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export interface PublishDraft {
|
|||||||
form: PublishForm
|
form: PublishForm
|
||||||
quantityValues: Record<QuantityKey, number>
|
quantityValues: Record<QuantityKey, number>
|
||||||
quantityModes: Record<QuantityKey, ChargeMode>
|
quantityModes: Record<QuantityKey, ChargeMode>
|
||||||
screenshotFiles: Record<ScreenshotKey, string>
|
screenshotFiles: Record<ScreenshotKey, string[]>
|
||||||
selectedSkins: string[]
|
selectedSkins: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user