feat(points): 支持后台上传积分商品图片

This commit is contained in:
yml2213
2026-11-04 19:28:40 +08:00
parent 27c66548a2
commit 46c682cb78
3 changed files with 112 additions and 4 deletions
+4 -2
View File
@@ -109,7 +109,7 @@ func normalizeContentType(contentType string, data []byte) string {
func fileURLForScene(scene string, key string) string {
fileURL := "/api/files/object?key=" + url.QueryEscape(key)
if scene == "home-banner" || scene == "avatar" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" || scene == "cooperation-feedback" || scene == "support-qrcode" {
if scene == "home-banner" || scene == "avatar" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" || scene == "cooperation-feedback" || scene == "support-qrcode" || scene == "points-mall" {
fileURL = "/api/public/files/object?key=" + url.QueryEscape(key)
}
return fileURL
@@ -118,12 +118,14 @@ func fileURLForScene(scene string, key string) string {
func normalizeScene(scene string) string {
scene = strings.TrimSpace(strings.ToLower(scene))
switch scene {
case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode", "support-qrcode", "mohong", "crash", "aw-recycle", "cooperation-feedback", "manual-disbursement", "operating-expense":
case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode", "support-qrcode", "mohong", "crash", "aw-recycle", "cooperation-feedback", "manual-disbursement", "operating-expense", "points-mall":
return scene
case "listing_screenshot", "listing-screenshot":
return "listing"
case "operating_expense":
return "operating-expense"
case "points_mall":
return "points-mall"
default:
return "misc"
}
@@ -87,6 +87,16 @@ func TestSupportQrcodeSceneUsesPublicFileURL(t *testing.T) {
}
}
func TestPointsMallSceneUsesPublicFileURL(t *testing.T) {
if got := normalizeScene("points_mall"); got != "points-mall" {
t.Fatalf("normalize points_mall scene = %q, want points-mall", got)
}
got := fileURLForScene("points-mall", "points-mall/2026/11/04/example.webp")
if !strings.HasPrefix(got, "/api/public/files/object?") {
t.Fatalf("points-mall file url = %q, want public file endpoint", got)
}
}
func TestNormalizeSceneListingScreenshotAndOperatingExpense(t *testing.T) {
if got := normalizeScene("listing_screenshot"); got != "listing" {
t.Fatalf("normalize listing_screenshot scene = %q, want listing", got)
@@ -13,6 +13,7 @@ import {
type PointsMallRedemption,
} from '@/features/points/api/pointsMall'
import { useAdminSessionStore } from '@/stores/adminSession'
import { uploadAdminFile } from '@/shared/api/files'
import { readError } from '@/shared/utils/error'
const adminSession = useAdminSessionStore()
@@ -23,6 +24,8 @@ const products = ref<PointsMallProduct[]>([])
const redemptions = ref<PointsMallRedemption[]>([])
const editorVisible = ref(false)
const editingID = ref(0)
const coverUploading = ref(false)
const coverFileInput = ref<HTMLInputElement | null>(null)
const canManage = computed(() => adminSession.hasPermission('points_mall:manage'))
const form = reactive<PointsMallProductPayload>(defaultProduct())
@@ -102,6 +105,27 @@ async function completeRedemption(item: PointsMallRedemption) {
}
}
function triggerCoverUpload() {
coverFileInput.value?.click()
}
async function handleCoverFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
coverUploading.value = true
try {
const uploaded = await uploadAdminFile(file, 'points-mall')
form.cover_url = uploaded.url
ElMessage.success('商品图片上传成功')
} catch (error) {
ElMessage.error(readError(error, '商品图片上传失败'))
} finally {
coverUploading.value = false
}
}
function defaultProduct(): PointsMallProductPayload {
return {
title: '',
@@ -203,8 +227,30 @@ function defaultProduct(): PointsMallProductPayload {
<el-form-item label="商品说明">
<el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="封面图片 URL">
<el-input v-model="form.cover_url" />
<el-form-item label="商品封面">
<div class="cover-editor">
<div class="cover-preview">
<img v-if="form.cover_url" :src="form.cover_url" alt="商品封面预览" />
<span v-else>暂无图片</span>
</div>
<div class="cover-actions">
<el-button :loading="coverUploading" @click="triggerCoverUpload">
{{ coverUploading ? '上传中' : '上传本地图片' }}
</el-button>
<el-button v-if="form.cover_url" plain @click="form.cover_url = ''"
>移除图片</el-button
>
<el-input v-model="form.cover_url" placeholder="也可以直接填写图片 URL" />
<small>支持 JPG、PNG、WebP,上传后自动保存到商品资料。</small>
</div>
</div>
<input
ref="coverFileInput"
class="hidden-cover-input"
type="file"
accept="image/jpeg,image/png,image/webp"
@change="handleCoverFileChange"
/>
</el-form-item>
<el-form-item label="兑换后发放说明">
<el-input v-model="form.redemption_instructions" type="textarea" :rows="3" />
@@ -269,9 +315,59 @@ function defaultProduct(): PointsMallProductPayload {
gap: 12px;
}
.cover-editor {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 14px;
width: 100%;
}
.cover-preview {
display: grid;
width: 180px;
height: 120px;
place-items: center;
overflow: hidden;
border: 1px dashed #cbd5e1;
border-radius: 10px;
background: #f8fafc;
color: #94a3b8;
font-size: 12px;
}
.cover-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-actions {
display: grid;
align-content: start;
gap: 9px;
}
.cover-actions .el-button {
justify-self: start;
margin-left: 0;
}
.cover-actions small {
color: #94a3b8;
font-size: 11px;
}
.hidden-cover-input {
display: none;
}
@media (max-width: 760px) {
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cover-editor {
grid-template-columns: 1fr;
}
}
</style>