优化发布图片问题

This commit is contained in:
yml2213
2026-05-23 12:19:24 +08:00
parent 367f26ffd1
commit d90f31360f
6 changed files with 122 additions and 25 deletions
+4 -3
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"mime/multipart" "mime/multipart"
"net/url"
"strings" "strings"
) )
@@ -48,13 +49,13 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
url := "/api/files/object?key=" + key fileURL := "/api/files/object?key=" + url.QueryEscape(key)
if scene == "home-banner" { if scene == "home-banner" {
url = "/api/public/files/object?key=" + key fileURL = "/api/public/files/object?key=" + url.QueryEscape(key)
} }
return &UploadDTO{ return &UploadDTO{
ObjectKey: key, ObjectKey: key,
URL: url, URL: fileURL,
Filename: req.Header.Filename, Filename: req.Header.Filename,
ContentType: contentType, ContentType: contentType,
Size: req.Header.Size, Size: req.Header.Size,
@@ -36,15 +36,19 @@ func DefaultPublishOptions() PublishOptionsDTO {
}, },
}, },
{ {
Key: "operator", Key: "operatorGold",
Title: "干员皮", Title: "干员皮",
Options: []string{ Options: []string{
"威龙-凌霄戍卫", "红狼-蚀金玫瑰", "露娜-黑天际线", "蛊-能天使午夜邮差", "露娜-古墓丽影联动", "蛊-不羁人生", "红狼-电锯惊魂", "露娜-金牌射手",
"骇爪-维什戴尔", "骇爪-水墨云图", "威龙-吴彦祖", "威龙-铁面判官", "牧羊人-街头之星", "蜂医-危险物质", "蜂医-送葬人", "无名-夜鹰",
"威龙-壮志凌云", "威龙-蛟龙特战队", "蜂医-送葬人", "蜂医-危险物质", "威龙-壮志凌云", "威龙-蛟龙特战队", "威龙-铁面判官", "威龙-吴彦祖",
"蜂医-黑鹰坠落", "牧羊人-街头之星", "牧羊人-黑鹰坠落", "乌鲁鲁-黑鹰坠落", },
"红狼-黑鹰坠落", "无名-夜鹰", "深蓝-不破誓约", "红狼-电锯惊魂", },
"露娜-古墓丽影劳拉", "赛伊德", {
Key: "operatorRed",
Title: "干员红皮",
Options: []string{
"凌霄成卫", "蚀金玫瑰", "水墨云图", "午夜邮差", "天际线", "维什戴尔",
}, },
}, },
{ {
+16 -8
View File
@@ -84,7 +84,8 @@ VALUES
), ),
'skin_groups', JSON_OBJECT( 'skin_groups', JSON_OBJECT(
'melee', JSON_ARRAY('龙牙', '信条'), 'melee', JSON_ARRAY('龙牙', '信条'),
'operator', JSON_ARRAY('威龙-凌霄戍卫', '红狼-蚀金玫瑰'), 'operatorGold', JSON_ARRAY(),
'operatorRed', JSON_ARRAY('凌霄成卫', '蚀金玫瑰'),
'weapon', JSON_ARRAY('M4A1棱镜攻势', 'K416命运') 'weapon', JSON_ARRAY('M4A1棱镜攻势', 'K416命运')
), ),
'remark', '部分仓库材料请勿使用,下单后会再次提醒。' 'remark', '部分仓库材料请勿使用,下单后会再次提醒。'
@@ -120,7 +121,8 @@ VALUES
), ),
'skin_groups', JSON_OBJECT( 'skin_groups', JSON_OBJECT(
'melee', JSON_ARRAY('怜悯'), 'melee', JSON_ARRAY('怜悯'),
'operator', JSON_ARRAY('露娜-黑天际线'), 'operatorGold', JSON_ARRAY(),
'operatorRed', JSON_ARRAY('天际线'),
'weapon', JSON_ARRAY('SCAR-H电玩高手') 'weapon', JSON_ARRAY('SCAR-H电玩高手')
), ),
'remark', '' 'remark', ''
@@ -156,7 +158,8 @@ VALUES
), ),
'skin_groups', JSON_OBJECT( 'skin_groups', JSON_OBJECT(
'melee', JSON_ARRAY('暗星'), 'melee', JSON_ARRAY('暗星'),
'operator', JSON_ARRAY('赛伊德'), 'operatorGold', JSON_ARRAY(),
'operatorRed', JSON_ARRAY(),
'weapon', JSON_ARRAY('Vector美杜莎') 'weapon', JSON_ARRAY('Vector美杜莎')
), ),
'remark', '' 'remark', ''
@@ -193,7 +196,8 @@ VALUES
), ),
'skin_groups', JSON_OBJECT( 'skin_groups', JSON_OBJECT(
'melee', JSON_ARRAY('坠星者', '处刑者'), 'melee', JSON_ARRAY('坠星者', '处刑者'),
'operator', JSON_ARRAY('威龙-铁面判官', '红狼-蚀金玫瑰'), 'operatorGold', JSON_ARRAY('威龙-铁面判官'),
'operatorRed', JSON_ARRAY('蚀金玫瑰'),
'weapon', JSON_ARRAY('M7棱镜攻势S2', 'KC17-造物纪元') 'weapon', JSON_ARRAY('M7棱镜攻势S2', 'KC17-造物纪元')
), ),
'remark', '高价值账号,需按平台流程交接。' 'remark', '高价值账号,需按平台流程交接。'
@@ -229,7 +233,8 @@ VALUES
), ),
'skin_groups', JSON_OBJECT( 'skin_groups', JSON_OBJECT(
'melee', JSON_ARRAY('影锋'), 'melee', JSON_ARRAY('影锋'),
'operator', JSON_ARRAY('骇爪-水墨云图'), 'operatorGold', JSON_ARRAY(),
'operatorRed', JSON_ARRAY('水墨云图'),
'weapon', JSON_ARRAY('QBZ95王牌之剑', 'AUG气象感应') 'weapon', JSON_ARRAY('QBZ95王牌之剑', 'AUG气象感应')
), ),
'remark', 'AWM 子弹赠送,其他消耗品按规则扣费。' 'remark', 'AWM 子弹赠送,其他消耗品按规则扣费。'
@@ -264,7 +269,8 @@ VALUES
), ),
'skin_groups', JSON_OBJECT( 'skin_groups', JSON_OBJECT(
'melee', JSON_ARRAY('北极星'), 'melee', JSON_ARRAY('北极星'),
'operator', JSON_ARRAY('蜂医-送葬人'), 'operatorGold', JSON_ARRAY('蜂医-送葬人'),
'operatorRed', JSON_ARRAY(),
'weapon', JSON_ARRAY('MP7电玩高手S2') 'weapon', JSON_ARRAY('MP7电玩高手S2')
), ),
'remark', '' 'remark', ''
@@ -300,7 +306,8 @@ VALUES
), ),
'skin_groups', JSON_OBJECT( 'skin_groups', JSON_OBJECT(
'melee', JSON_ARRAY('赤枭', '黑海'), 'melee', JSON_ARRAY('赤枭', '黑海'),
'operator', JSON_ARRAY('威龙-壮志凌云', '蜂医-危险物质'), 'operatorGold', JSON_ARRAY('威龙-壮志凌云', '蜂医-危险物质'),
'operatorRed', JSON_ARRAY(),
'weapon', JSON_ARRAY('AS Val悬赏令', 'K416命运', 'AUG气象感应') 'weapon', JSON_ARRAY('AS Val悬赏令', 'K416命运', 'AUG气象感应')
), ),
'remark', '' 'remark', ''
@@ -337,7 +344,8 @@ VALUES
), ),
'skin_groups', JSON_OBJECT( 'skin_groups', JSON_OBJECT(
'melee', JSON_ARRAY('电锯惊魂', '处刑者'), 'melee', JSON_ARRAY('电锯惊魂', '处刑者'),
'operator', JSON_ARRAY('露娜-古墓丽影劳拉', '红狼-电锯惊魂'), 'operatorGold', JSON_ARRAY('露娜-古墓丽影联动', '红狼-电锯惊魂'),
'operatorRed', JSON_ARRAY(),
'weapon', JSON_ARRAY('M250电玩高手S2', '腾龙气象感应') 'weapon', JSON_ARRAY('M250电玩高手S2', '腾龙气象感应')
), ),
'remark', '六级弹赠送数量以下单截图为准。' 'remark', '六级弹赠送数量以下单截图为准。'
+8
View File
@@ -41,3 +41,11 @@ export async function fetchAdminFileBlob(key: string) {
}) })
return data return data
} }
export async function fetchFileBlobByURL(fileURL: string) {
const apiPath = fileURL.startsWith('/api/') ? fileURL.slice(4) : fileURL
const { data } = await apiClient.get<Blob>(apiPath, {
responseType: 'blob',
})
return data
}
@@ -81,6 +81,8 @@ const detailSkinGroups = computed(() => {
const titles: Record<string, string> = { const titles: Record<string, string> = {
melee: "近战皮肤", melee: "近战皮肤",
operator: "干员皮肤", operator: "干员皮肤",
operatorGold: "干员金皮",
operatorRed: "干员红皮",
weapon: "武器皮肤", weapon: "武器皮肤",
}; };
return Object.entries(groups as Record<string, unknown>) return Object.entries(groups as Record<string, unknown>)
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue"; import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRouter, useRoute } from "vue-router"; import { useRouter, useRoute } from "vue-router";
import { showDialog, showToast } from "vant"; import { showDialog, showToast } from "vant";
import { uploadFile } from "@/api/files"; import { fetchFileBlobByURL, uploadFile } from "@/api/files";
import { import {
emptyListingPublishOptions, emptyListingPublishOptions,
fetchListingPublishOptions, fetchListingPublishOptions,
@@ -94,6 +94,7 @@ const quantityValues = reactive<Record<QuantityKey, number>>(defaultQuantityValu
const quantityModes = reactive<Record<QuantityKey, ChargeMode>>(defaultQuantityModes()); const quantityModes = reactive<Record<QuantityKey, ChargeMode>>(defaultQuantityModes());
const screenshotFiles = reactive<Record<ScreenshotKey, string>>(defaultScreenshotFiles()); const screenshotFiles = reactive<Record<ScreenshotKey, string>>(defaultScreenshotFiles());
const screenshotPreviews = reactive<Record<ScreenshotKey, string>>({});
const selectedSkins = ref<string[]>([]); const selectedSkins = ref<string[]>([]);
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
@@ -118,8 +119,11 @@ const screenshotUrls = computed(() =>
); );
const coinWanAmount = computed(() => Number(form.haf_coin_amount || 0)); const coinWanAmount = computed(() => Number(form.haf_coin_amount || 0));
const calculatedRatio = computed(() => calculatePublishRatio()); const calculatedRatio = computed(() => calculatePublishRatio());
const calculatedConsumablePrice = computed(() => calculateConsumablePrice());
const calculatedFinalPrice = computed(() => const calculatedFinalPrice = computed(() =>
calculatedRatio.value > 0 ? roundMoney(coinWanAmount.value / calculatedRatio.value) : 0 calculatedRatio.value > 0
? roundMoney(coinWanAmount.value / calculatedRatio.value + calculatedConsumablePrice.value)
: 0
); );
const calculatedRentDays = computed(() => const calculatedRentDays = computed(() =>
coinWanAmount.value > 0 ? calculateRentDays(coinWanAmount.value) : 0 coinWanAmount.value > 0 ? calculateRentDays(coinWanAmount.value) : 0
@@ -130,6 +134,10 @@ onMounted(() => {
loadPublishOptions(); loadPublishOptions();
}); });
onBeforeUnmount(() => {
revokeAllScreenshotPreviews();
});
watch( watch(
[form, quantityValues, quantityModes, screenshotFiles, selectedSkins], [form, quantityValues, quantityModes, screenshotFiles, selectedSkins],
() => { () => {
@@ -176,6 +184,7 @@ function restoreDraft() {
selectedSkins.value = Array.isArray(draft.selectedSkins) selectedSkins.value = Array.isArray(draft.selectedSkins)
? draft.selectedSkins.filter((skin): skin is string => typeof skin === "string") ? draft.selectedSkins.filter((skin): skin is string => typeof skin === "string")
: []; : [];
hydrateScreenshotPreviews();
} catch { } catch {
localStorage.removeItem(draftKey); localStorage.removeItem(draftKey);
} }
@@ -207,6 +216,7 @@ function resetDraftState() {
Object.assign(quantityValues, defaultQuantityValues()); Object.assign(quantityValues, defaultQuantityValues());
Object.assign(quantityModes, defaultQuantityModes()); Object.assign(quantityModes, defaultQuantityModes());
Object.assign(screenshotFiles, defaultScreenshotFiles()); Object.assign(screenshotFiles, defaultScreenshotFiles());
revokeAllScreenshotPreviews();
selectedSkins.value = []; selectedSkins.value = [];
activeUploadKey.value = "coin"; activeUploadKey.value = "coin";
} }
@@ -257,12 +267,16 @@ async function handleScreenshotUpload(event: Event) {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const file = input.files?.[0]; const file = input.files?.[0];
if (!file) return; if (!file) return;
const key = activeUploadKey.value;
const previewURL = URL.createObjectURL(file);
setScreenshotPreview(key, previewURL);
uploading.value = true; uploading.value = true;
try { try {
const uploaded = await uploadFile(file, "listing"); const uploaded = await uploadFile(file, "listing");
screenshotFiles[activeUploadKey.value] = uploaded.url; screenshotFiles[key] = uploaded.url;
showToast({ message: "截图已上传", icon: "passed" }); showToast({ message: "截图已上传", icon: "passed" });
} catch (error) { } catch (error) {
revokeScreenshotPreview(key);
showToast({ message: readError(error, "截图上传失败"), icon: "cross" }); showToast({ message: readError(error, "截图上传失败"), icon: "cross" });
} finally { } finally {
uploading.value = false; uploading.value = false;
@@ -272,6 +286,44 @@ async function handleScreenshotUpload(event: Event) {
function removeScreenshot(key: ScreenshotKey) { function removeScreenshot(key: ScreenshotKey) {
screenshotFiles[key] = ""; 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) { function handleFireLevelInput(value: string | number) {
@@ -386,6 +438,28 @@ function roundMoney(value: number) {
return Math.round(value * 100) / 100; return Math.round(value * 100) / 100;
} }
function calculateConsumablePrice() {
const total = quantityItems.value.reduce((sum, item) => {
const quantity = Number(quantityValues[item.key] || 0);
const mode = quantityModes[item.key] || "收费";
if (quantity <= 0 || mode !== "收费") return sum;
return sum + quantity * readUnitPrice(item.price);
}, 0);
return roundMoney(total);
}
function readUnitPrice(priceText: string) {
const normalized = priceText.replace(//g, ",").trim();
const fractionMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元\s*\/\s*(\d+(?:\.\d+)?)/);
if (fractionMatch) {
const amount = Number(fractionMatch[1]);
const count = Number(fractionMatch[2]);
return count > 0 ? amount / count : 0;
}
const singleMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元/);
return singleMatch ? Number(singleMatch[1]) : 0;
}
function calculatePublishRatio() { function calculatePublishRatio() {
if ( if (
coinWanAmount.value <= 0 || coinWanAmount.value <= 0 ||
@@ -420,7 +494,7 @@ function getInsuranceSlots(insurance: string) {
function getConfigHitCount() { function getConfigHitCount() {
const checks = [ const checks = [
hasSelectedSkinGroup("operator"), hasSelectedSkinGroup("operatorRed"),
hasSelectedSkinGroup("melee"), hasSelectedSkinGroup("melee"),
isMaxLevel(form.stamina_level), isMaxLevel(form.stamina_level),
isMaxLevel(form.load_level), isMaxLevel(form.load_level),
@@ -756,7 +830,7 @@ function readError(error: unknown, fallback: string) {
<small>{{ slot.hint }}</small> <small>{{ slot.hint }}</small>
</div> </div>
<div v-if="screenshotFiles[slot.key]" class="upload-preview"> <div v-if="screenshotFiles[slot.key]" class="upload-preview">
<img :src="screenshotFiles[slot.key]" :alt="slot.label" /> <img :src="getScreenshotPreviewURL(slot.key)" :alt="slot.label" />
<button type="button" @click="removeScreenshot(slot.key)">移除</button> <button type="button" @click="removeScreenshot(slot.key)">移除</button>
</div> </div>
<button <button