feat(points): 支持后台上传积分商品图片
This commit is contained in:
@@ -109,7 +109,7 @@ func normalizeContentType(contentType string, data []byte) string {
|
|||||||
|
|
||||||
func fileURLForScene(scene string, key string) string {
|
func fileURLForScene(scene string, key string) string {
|
||||||
fileURL := "/api/files/object?key=" + url.QueryEscape(key)
|
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)
|
fileURL = "/api/public/files/object?key=" + url.QueryEscape(key)
|
||||||
}
|
}
|
||||||
return fileURL
|
return fileURL
|
||||||
@@ -118,12 +118,14 @@ func fileURLForScene(scene string, key string) string {
|
|||||||
func normalizeScene(scene string) string {
|
func normalizeScene(scene string) string {
|
||||||
scene = strings.TrimSpace(strings.ToLower(scene))
|
scene = strings.TrimSpace(strings.ToLower(scene))
|
||||||
switch 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
|
return scene
|
||||||
case "listing_screenshot", "listing-screenshot":
|
case "listing_screenshot", "listing-screenshot":
|
||||||
return "listing"
|
return "listing"
|
||||||
case "operating_expense":
|
case "operating_expense":
|
||||||
return "operating-expense"
|
return "operating-expense"
|
||||||
|
case "points_mall":
|
||||||
|
return "points-mall"
|
||||||
default:
|
default:
|
||||||
return "misc"
|
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) {
|
func TestNormalizeSceneListingScreenshotAndOperatingExpense(t *testing.T) {
|
||||||
if got := normalizeScene("listing_screenshot"); got != "listing" {
|
if got := normalizeScene("listing_screenshot"); got != "listing" {
|
||||||
t.Fatalf("normalize listing_screenshot scene = %q, want listing", got)
|
t.Fatalf("normalize listing_screenshot scene = %q, want listing", got)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type PointsMallRedemption,
|
type PointsMallRedemption,
|
||||||
} from '@/features/points/api/pointsMall'
|
} from '@/features/points/api/pointsMall'
|
||||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||||
|
import { uploadAdminFile } from '@/shared/api/files'
|
||||||
import { readError } from '@/shared/utils/error'
|
import { readError } from '@/shared/utils/error'
|
||||||
|
|
||||||
const adminSession = useAdminSessionStore()
|
const adminSession = useAdminSessionStore()
|
||||||
@@ -23,6 +24,8 @@ const products = ref<PointsMallProduct[]>([])
|
|||||||
const redemptions = ref<PointsMallRedemption[]>([])
|
const redemptions = ref<PointsMallRedemption[]>([])
|
||||||
const editorVisible = ref(false)
|
const editorVisible = ref(false)
|
||||||
const editingID = ref(0)
|
const editingID = ref(0)
|
||||||
|
const coverUploading = ref(false)
|
||||||
|
const coverFileInput = ref<HTMLInputElement | null>(null)
|
||||||
const canManage = computed(() => adminSession.hasPermission('points_mall:manage'))
|
const canManage = computed(() => adminSession.hasPermission('points_mall:manage'))
|
||||||
const form = reactive<PointsMallProductPayload>(defaultProduct())
|
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 {
|
function defaultProduct(): PointsMallProductPayload {
|
||||||
return {
|
return {
|
||||||
title: '',
|
title: '',
|
||||||
@@ -203,8 +227,30 @@ function defaultProduct(): PointsMallProductPayload {
|
|||||||
<el-form-item label="商品说明">
|
<el-form-item label="商品说明">
|
||||||
<el-input v-model="form.description" type="textarea" :rows="3" />
|
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="封面图片 URL">
|
<el-form-item label="商品封面">
|
||||||
<el-input v-model="form.cover_url" />
|
<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>
|
||||||
<el-form-item label="兑换后发放说明">
|
<el-form-item label="兑换后发放说明">
|
||||||
<el-input v-model="form.redemption_instructions" type="textarea" :rows="3" />
|
<el-input v-model="form.redemption_instructions" type="textarea" :rows="3" />
|
||||||
@@ -269,9 +315,59 @@ function defaultProduct(): PointsMallProductPayload {
|
|||||||
gap: 12px;
|
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) {
|
@media (max-width: 760px) {
|
||||||
.form-grid {
|
.form-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cover-editor {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user