Files
cuimengxue/frontend/src/views/admin/AdminProducts.vue
wangziqi 1df27d3a23 fix: 修复图片上传回显、登录认证和API路径问题
- 修复上传图片响应解析,正确处理 Arco Upload 返回的 response 对象
- 修复后端 AuthInterceptor 路径匹配,正确放行 /api/auth/login 等公开接口
- 统一前端 API 路径配置,移除重复 /api 前缀
- 添加 /uploads 静态资源代理配置
- 修复图片 URL 生成,添加 origin 前缀确保回显正常
2026-02-11 09:10:29 +08:00

301 lines
9.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<a-card :bordered="false" class="content-card">
<template #title>
<a-space>
<span>商品管理</span>
<a-tag color="green"> {{ products.length }} 件商品</a-tag>
</a-space>
</template>
<template #extra>
<a-space>
<a-button type="primary" @click="openProductModal()">
<template #icon><icon-plus /></template>
新增商品
</a-button>
<a-button @click="loadProducts">
<template #icon><icon-refresh /></template>
刷新
</a-button>
</a-space>
</template>
<a-table :columns="productColumns" :data="products" :pagination="{ pageSize: 8 }" :bordered="false">
<template #approved="{ record }">
<a-switch :model-value="record.approved" @change="() => toggleApprove(record)" />
</template>
<template #actions="{ record }">
<a-space>
<a-button size="mini" type="primary" shape="round" @click="openProductModal(record)">编辑</a-button>
<a-popconfirm content="确定删除此商品吗?" @ok="deleteProduct(record.id)">
<a-button size="mini" status="danger" shape="round">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</a-table>
</a-card>
<a-modal v-model:visible="productModalVisible" :title="productForm.id ? '编辑商品' : '新增商品'" :ok-text="productForm.id ? '保存' : '添加'" cancel-text="取消" @ok="submitProductModal" width="600">
<a-form :model="productForm" layout="vertical">
<a-row :gutter="16">
<a-col :span="16">
<a-form-item label="商品名称" required>
<a-input v-model="productForm.name" placeholder="请输入商品名称" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="分类">
<a-select v-model="productForm.category" placeholder="选择分类">
<a-option value="奶粉">奶粉</a-option>
<a-option value="尿裤">尿裤</a-option>
<a-option value="辅食">辅食</a-option>
<a-option value="玩具">玩具</a-option>
<a-option value="服饰">服饰</a-option>
<a-option value="用品">用品</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="商品描述">
<a-textarea v-model="productForm.description" placeholder="请输入商品描述" :rows="3" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="价格" required>
<a-input-number v-model="productForm.price" :min="0" :precision="2" :step="0.01" style="width: 100%" prefix="¥" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="库存" required>
<a-input-number v-model="productForm.stock" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="商品图片">
<a-upload
:action="uploadAction"
:show-file-list="false"
:headers="uploadHeaders"
accept="image/*"
@success="onUploadSuccess"
@error="onUploadError"
@before-upload="beforeUpload"
>
<template #upload-button>
<div class="upload-trigger" :class="{ 'has-image': productForm.imageUrl }">
<img v-if="productForm.imageUrl" :src="productForm.imageUrl" class="preview-image" />
<div v-else class="upload-placeholder">
<icon-plus :size="24" />
<span>点击上传图片</span>
</div>
</div>
</template>
</a-upload>
<div v-if="productForm.imageUrl" class="upload-actions">
<a-button type="text" size="small" @click="clearImage">
<template #icon><icon-delete /></template>
清除图片
</a-button>
</div>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="所属商家">
<a-select v-model="productForm.merchantId" placeholder="选择商家" allow-search>
<a-option v-for="m in merchantOptions" :key="m.value" :value="m.value">{{ m.label }}</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="审核状态">
<a-switch v-model="productForm.approved" />
<span style="margin-left: 8px">{{ productForm.approved ? '已上架' : '待审核' }}</span>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-modal>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import { api } from '../../api'
import { IconPlus, IconRefresh, IconDelete } from '@arco-design/web-vue/es/icon'
const products = ref([])
const merchantOptions = ref([])
const productModalVisible = ref(false)
const productForm = reactive({ id: null, name: '', category: '', description: '', price: 0, stock: 0, imageUrl: '', merchantId: null, approved: false })
const uploadAction = '/api/upload/image'
const uploadHeaders = computed(() => ({
'X-Token': localStorage.getItem('token') || ''
}))
const beforeUpload = (file) => {
const isImage = file.type.startsWith('image/')
if (!isImage) {
Message.error('只能上传图片文件!')
return false
}
const isLt5M = file.size / 1024 / 1024 < 5
if (!isLt5M) {
Message.error('图片大小不能超过 5MB')
return false
}
return true
}
const onUploadSuccess = (uploadInfo) => {
const response = uploadInfo.response
console.log('Upload server response:', response)
if (response && response.code === 0 && response.data) {
const imageUrl = response.data
console.log('Image URL from response:', imageUrl)
if (imageUrl.startsWith('/')) {
productForm.imageUrl = window.location.origin + imageUrl
} else {
productForm.imageUrl = imageUrl
}
console.log('Final imageUrl:', productForm.imageUrl)
Message.success('图片上传成功')
} else {
console.warn('Unexpected response format:', response)
Message.warning('上传成功,但无法解析图片地址')
}
}
const onUploadError = (error) => {
Message.error('图片上传失败:' + (error.message || '未知错误'))
}
const clearImage = () => {
productForm.imageUrl = ''
}
const productColumns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '商品名', dataIndex: 'name' },
{ title: '分类', dataIndex: 'category', width: 80 },
{ title: '价格', dataIndex: 'price', render: ({ record }) => `¥${record.price}`, width: 80 },
{ title: '库存', dataIndex: 'stock', width: 70 },
{ title: '商家', dataIndex: 'merchantUsername', width: 100 },
{ title: '审核', slotName: 'approved', width: 80 },
{ title: '操作', slotName: 'actions', width: 140 }
]
const loadProducts = async () => {
products.value = await api.adminProductViews()
}
const loadMerchantOptions = async () => {
const list = await api.adminUsers()
merchantOptions.value = list.filter((u) => u.role === 'MERCHANT').map((u) => ({ label: `${u.nickname || u.username} (${u.username})`, value: u.id }))
}
const openProductModal = (row = null) => {
if (row) {
productForm.id = row.id
productForm.name = row.name || ''
productForm.category = row.category || ''
productForm.description = row.description || ''
productForm.price = row.price || 0
productForm.stock = row.stock || 0
productForm.imageUrl = row.imageUrl || ''
productForm.merchantId = row.merchantId || null
productForm.approved = !!row.approved
} else {
productForm.id = null
productForm.name = ''
productForm.category = ''
productForm.description = ''
productForm.price = 0
productForm.stock = 0
productForm.imageUrl = ''
productForm.merchantId = null
productForm.approved = false
}
if (merchantOptions.value.length === 0) loadMerchantOptions()
productModalVisible.value = true
}
const submitProductModal = async () => {
if (!productForm.merchantId) return Message.warning('请选择所属商家')
await api.adminSaveProduct({ ...productForm })
Message.success(productForm.id ? '商品已更新' : '商品已新增')
productModalVisible.value = false
await loadProducts()
}
const toggleApprove = async (row) => {
await api.adminApproveProduct(row.id, { approved: !row.approved })
Message.success(!row.approved ? '已上架' : '已下架')
await loadProducts()
}
const deleteProduct = async (id) => {
await api.adminDeleteProduct(id)
Message.success('商品已删除')
await loadProducts()
}
onMounted(() => {
loadProducts()
loadMerchantOptions()
})
</script>
<style scoped>
.content-card {
border-radius: 12px;
}
:deep(.arco-btn-primary) {
background: linear-gradient(135deg, #722ed1 0%, #9254de 100%);
border-color: transparent;
}
:deep(.arco-card-header) {
border-bottom: 1px solid #f0f0f0;
}
.upload-trigger {
width: 100%;
height: 150px;
border: 2px dashed #d9d9d9;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
overflow: hidden;
}
.upload-trigger:hover {
border-color: #722ed1;
}
.upload-trigger.has-image {
border: none;
}
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
color: #999;
gap: 8px;
}
.upload-actions {
margin-top: 8px;
text-align: center;
}
</style>