fix: 全面修复项目问题

后端修复:
- 验证码存储与校验机制
- 订单创建事务+库存扣减
- GetStats字段名错误
- VerifyEmail改为POST
- 供应商更新字段白名单
- 文件删除安全检查
- 抽奖安全随机数
- 用户管理CRUD
- 订单取消/确认收货
- 工单回复
- Toggle返回新数据
- 移除死代码

前端修复:
- 404兜底路由
- 401软跳转
- API层统一
- 面包屑补充banners
- 国际化完善
- 购物车并行删除
- 退出清理购物车
- 供应商Dashboard数据
- 工单详情页
- 订单取消/确认收货
This commit is contained in:
2026-05-06 09:16:34 +08:00
parent 739481b59f
commit 3a898e8aa0
52 changed files with 2807 additions and 920 deletions
+331
View File
@@ -0,0 +1,331 @@
<template>
<div class="banners-page">
<div class="page-header">
<el-button type="primary" @click="openAdd">添加轮播图</el-button>
</div>
<div class="table-card">
<el-table :data="banners">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="图片" width="150">
<template #default="{ row }">
<img :src="getImageUrl(row.image)" style="width: 120px; height: 60px; object-fit: cover; border-radius: 4px;" />
</template>
</el-table-column>
<el-table-column prop="title" label="标题" />
<el-table-column prop="desc" label="描述" />
<el-table-column prop="button" label="按钮文字" width="120" />
<el-table-column prop="sort_order" label="排序" width="80" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'">
{{ row.is_active ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="editBanner(row)">编辑</el-button>
<el-button link :type="row.is_active ? 'warning' : 'success'" size="small" @click="toggleStatus(row)">
{{ row.is_active ? '禁用' : '启用' }}
</el-button>
<el-button link type="danger" size="small" @click="deleteBanner(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-dialog v-model="showAdd" :title="editing ? '编辑轮播图' : '添加轮播图'" width="600px">
<el-form :model="form" label-width="100px">
<el-form-item label="标题" required>
<el-input v-model="form.title" placeholder="请输入标题" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.desc" type="textarea" :rows="2" placeholder="请输入描述" />
</el-form-item>
<el-form-item label="图片" required>
<el-upload
class="banner-uploader"
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleImageSuccess"
:before-upload="beforeImageUpload"
accept="image/*"
>
<img v-if="form.image" :src="form.image" class="banner-image" />
<el-icon v-else class="banner-uploader-icon"><Plus /></el-icon>
</el-upload>
<div class="image-tip">建议尺寸: 1920x400px支持 jpgpngwebp 格式</div>
</el-form-item>
<el-form-item label="链接类型">
<el-radio-group v-model="linkType" @change="handleLinkTypeChange">
<el-radio-button value="custom">自定义</el-radio-button>
<el-radio-button value="product">商品</el-radio-button>
<el-radio-button value="article">资讯</el-radio-button>
<el-radio-button value="lottery">活动</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="链接" v-if="linkType === 'custom'">
<el-input v-model="form.link" placeholder="点击跳转链接,如: /products" />
</el-form-item>
<el-form-item label="选择商品" v-if="linkType === 'product'">
<el-select v-model="selectedProduct" placeholder="请选择商品" filterable @change="handleProductSelect" style="width: 100%">
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="选择资讯" v-if="linkType === 'article'">
<el-select v-model="selectedArticle" placeholder="请选择资讯" filterable @change="handleArticleSelect" style="width: 100%">
<el-option v-for="a in articles" :key="a.id" :label="a.title" :value="a.id" />
</el-select>
</el-form-item>
<el-form-item label="选择活动" v-if="linkType === 'lottery'">
<el-select v-model="selectedLottery" placeholder="请选择活动" filterable @change="handleLotterySelect" style="width: 100%">
<el-option v-for="l in lotteries" :key="l.id" :label="l.name" :value="l.id" />
</el-select>
</el-form-item>
<el-form-item label="按钮文字">
<el-input v-model="form.button" placeholder="如: 立即选购" />
</el-form-item>
<el-form-item label="背景颜色">
<el-input v-model="form.bg_color" placeholder="如: linear-gradient(135deg, #4e6ef2 0%, #7c5cfc 100%)" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort_order" :min="0" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.is_active" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAdd = false">取消</el-button>
<el-button type="primary" @click="saveBanner">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { adminApi, productApi, articleApi, lotteryApi } from '../../api'
import { getImageUrl } from '../../utils/image'
const uploadUrl = import.meta.env.VITE_API_URL + '/upload'
const uploadHeaders = { Authorization: `Bearer ${localStorage.getItem('token')}` }
const banners = ref<any[]>([])
const products = ref<any[]>([])
const articles = ref<any[]>([])
const lotteries = ref<any[]>([])
const showAdd = ref(false)
const editing = ref<any>(null)
const linkType = ref('custom')
const selectedProduct = ref<number | null>(null)
const selectedArticle = ref<number | null>(null)
const selectedLottery = ref<number | null>(null)
const form = reactive({
title: '',
desc: '',
image: '',
link: '',
button: '',
bg_color: '',
sort_order: 0,
is_active: true
})
async function fetchBanners() {
const res: any = await adminApi.getBanners()
banners.value = res.data || []
}
async function fetchProducts() {
const res: any = await productApi.list({ page: 1, page_size: 100 })
products.value = res.data || []
}
async function fetchArticles() {
const res: any = await articleApi.list({ page: 1, page_size: 100 })
articles.value = res.data || []
}
async function fetchLotteries() {
const res: any = await lotteryApi.list()
lotteries.value = res.data || []
}
function handleLinkTypeChange() {
form.link = ''
selectedProduct.value = null
selectedArticle.value = null
selectedLottery.value = null
}
function handleProductSelect(productId: number) {
form.link = `/products/${productId}`
}
function handleArticleSelect(articleId: number) {
form.link = `/articles/${articleId}`
}
function handleLotterySelect(lotteryId: number) {
form.link = `/lotteries/${lotteryId}`
}
function openAdd() {
editing.value = null
linkType.value = 'custom'
selectedProduct.value = null
selectedArticle.value = null
selectedLottery.value = null
Object.assign(form, { title: '', desc: '', image: '', link: '', button: '', bg_color: '', sort_order: 0, is_active: true })
showAdd.value = true
}
function editBanner(row: any) {
editing.value = row
Object.assign(form, {
title: row.title,
desc: row.desc || '',
image: row.image || '',
link: row.link || '',
button: row.button || '',
bg_color: row.bg_color || '',
sort_order: row.sort_order,
is_active: row.is_active
})
// 根据链接判断类型
if (row.link) {
if (row.link.startsWith('/products/')) {
linkType.value = 'product'
const id = parseInt(row.link.split('/')[2])
selectedProduct.value = isNaN(id) ? null : id
} else if (row.link.startsWith('/articles/')) {
linkType.value = 'article'
const id = parseInt(row.link.split('/')[2])
selectedArticle.value = isNaN(id) ? null : id
} else if (row.link.startsWith('/lotteries/')) {
linkType.value = 'lottery'
const id = parseInt(row.link.split('/')[2])
selectedLottery.value = isNaN(id) ? null : id
} else {
linkType.value = 'custom'
}
} else {
linkType.value = 'custom'
}
showAdd.value = true
}
function beforeImageUpload(file: File) {
const isImage = file.type.startsWith('image/')
const isLt5M = file.size / 1024 / 1024 < 5
if (!isImage) {
ElMessage.error('只能上传图片文件!')
return false
}
if (!isLt5M) {
ElMessage.error('图片大小不能超过 5MB!')
return false
}
return true
}
function handleImageSuccess(response: any) {
if (response.url) {
form.image = response.url
ElMessage.success('图片上传成功')
} else {
ElMessage.error('上传失败')
}
}
async function saveBanner() {
if (!form.title || !form.image) {
ElMessage.warning('请填写必填项')
return
}
if (editing.value) {
await adminApi.updateBanner(editing.value.id, form)
} else {
await adminApi.createBanner(form)
}
ElMessage.success('保存成功')
showAdd.value = false
editing.value = null
await fetchBanners()
}
async function toggleStatus(row: any) {
await adminApi.toggleBanner(row.id)
ElMessage.success('操作成功')
await fetchBanners()
}
async function deleteBanner(id: number) {
await ElMessageBox.confirm('确定删除此轮播图?', '确认删除')
await adminApi.deleteBanner(id)
ElMessage.success('删除成功')
await fetchBanners()
}
onMounted(async () => {
await Promise.all([
fetchBanners(),
fetchProducts(),
fetchArticles(),
fetchLotteries()
])
})
</script>
<style scoped lang="scss">
.banners-page { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
.banner-uploader {
:deep(.el-upload) {
border: 1px dashed rgba(255, 255, 255, 0.2);
border-radius: 8px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: all 0.3s;
width: 300px;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
border-color: #4e6ef2;
}
}
}
.banner-image {
width: 300px;
height: 120px;
object-fit: cover;
border-radius: 8px;
}
.banner-uploader-icon {
font-size: 28px;
color: rgba(255, 255, 255, 0.4);
}
.image-tip {
margin-top: 8px;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
}
</style>