feat: 重构后台路由并使用嵌套路由;添加图片上传功能

- 拆分 AdminView 为多个子页面组件,使用嵌套路由
- 拆分 MerchantView 为多个子页面组件,使用嵌套路由
- 创建 AdminLayout 和 MerchantLayout 布局组件
- 修复编译错误:IconSend 重复导入、IconDatabase 不存在
- 修复 HomeView 缺失 onMounted 导入
- 添加文件上传后端接口和静态资源映射
- 为商品和轮播图添加图片上传功能(支持预览、清除)
This commit is contained in:
wangziqi
2026-02-10 15:14:23 +08:00
parent a7ce0a089e
commit d6451cf188
29 changed files with 4948 additions and 479 deletions

View File

@@ -0,0 +1,291 @@
<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 = 'http://localhost:8080/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>