Files
cuimengxue/frontend/src/views/admin/AdminProducts.vue
wangziqi b1a153298f feat: 前端配置代理解决上传跨域问题
- 配置 Vite 开发服务器代理 /api 和 /uploads 到后端
- API baseURL 改为相对路径 '/api'
- 修改上传 action 为相对路径 '/api/upload/image'
- 移除后端 FileUploadController 的 @CrossOrigin 注解
- 恢复 WebMvcConfig 的拦截器配置
2026-02-10 16:06:53 +08:00

292 lines
9.1 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 = (response) => {
if (response && response.data) {
productForm.imageUrl = response.data
Message.success('图片上传成功')
} else if (typeof response === 'string') {
productForm.imageUrl = response
Message.success('图片上传成功')
}
}
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>