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
-2
View File
@@ -445,8 +445,6 @@ onMounted(() => {
<style scoped lang="scss">
.cart-page {
max-width: 1200px;
margin: 0 auto;
padding-bottom: 40px;
}
+737 -165
View File
@@ -1,218 +1,790 @@
<template>
<div class="home-page">
<section class="hero">
<div class="hero-content">
<h1>Discover Premium Products</h1>
<p>Find the best products from trusted suppliers worldwide</p>
<el-button type="primary" size="large" @click="$router.push('/products')">
{{ $t('common.products') }}
</el-button>
</div>
</section>
<section class="section">
<div class="section-header">
<h2 class="section-title">{{ $t('product.category') }}</h2>
</div>
<div class="category-grid">
<div v-for="cat in categories" :key="cat.id" class="category-card" @click="goCategory(cat.id)">
<div class="category-icon">
<el-icon size="32"><Folder /></el-icon>
</div>
<span class="category-name">{{ cat.name }}</span>
</div>
</div>
</section>
<section class="section">
<div class="section-header">
<h2 class="section-title">{{ $t('common.products') }}</h2>
<el-button link @click="$router.push('/products')">{{ $t('common.search') }} </el-button>
</div>
<div class="product-grid">
<div v-for="p in products" :key="p.id" class="product-card" @click="$router.push(`/products/${p.id}`)">
<div class="product-image">
<el-image v-if="getFirstImage(p.images)" :src="getFirstImage(p.images)" fit="cover" lazy>
<template #error>
<div class="image-placeholder">
<el-icon size="48"><Goods /></el-icon>
</div>
</template>
</el-image>
<div v-else class="image-placeholder">
<el-icon size="48"><Goods /></el-icon>
<div class="banner-section">
<el-carousel height="280px" :interval="5000" arrow="hover" trigger="click">
<el-carousel-item v-for="(banner, index) in banners" :key="index">
<div class="banner-slide" :style="{ background: banner.bg_color || banner.bg }">
<img v-if="banner.image" :src="banner.image" class="banner-bg-image" />
<div class="banner-content">
<h2>{{ banner.title }}</h2>
<p>{{ banner.desc }}</p>
<button class="banner-btn" @click="$router.push(banner.link)">{{ banner.button || banner.btn }}</button>
</div>
<div class="banner-icon" v-if="banner.icon">
<el-icon :size="80"><component :is="banner.icon" /></el-icon>
</div>
</div>
<div class="product-info">
<h3 class="product-name">{{ p.name }}</h3>
<p class="product-price">¥{{ p.price }}</p>
<div class="product-tags">
<el-tag v-if="p.require_credit" size="small" type="warning">{{ $t('product.requireCredit') }}</el-tag>
<el-tag v-if="p.credit_reward > 0" size="small" type="success">+{{ p.credit_reward }} {{ $t('product.creditReward') }}</el-tag>
</div>
</div>
</div>
</div>
</section>
</el-carousel-item>
</el-carousel>
</div>
<section class="section" v-if="lotteries.length">
<div class="section-header">
<h2 class="section-title">{{ $t('lottery.title') }}</h2>
<el-button link @click="$router.push('/lotteries')">View All </el-button>
<div class="quick-actions">
<div class="action-item" @click="$router.push('/products')">
<div class="action-icon" style="background: rgba(78, 110, 242, 0.15); color: #4e6ef2;">
<el-icon size="22"><Goods /></el-icon>
</div>
<span>商品</span>
</div>
<div class="lottery-grid">
<div class="action-item" @click="$router.push('/lotteries')">
<div class="action-icon" style="background: rgba(245, 158, 11, 0.15); color: #f59e0b;">
<el-icon size="22"><Trophy /></el-icon>
</div>
<span>抽奖</span>
</div>
<div class="action-item" @click="$router.push('/articles')">
<div class="action-icon" style="background: rgba(16, 185, 129, 0.15); color: #10b981;">
<el-icon size="22"><Document /></el-icon>
</div>
<span>资讯</span>
</div>
<div class="action-item" @click="$router.push('/cart')">
<div class="action-icon" style="background: rgba(124, 92, 252, 0.15); color: #7c5cfc;">
<el-icon size="22"><ShoppingCart /></el-icon>
</div>
<span>购物车</span>
</div>
</div>
<div class="section" v-if="lotteries.length">
<div class="section-head">
<h2>热门活动</h2>
</div>
<div class="lottery-cards">
<div v-for="l in lotteries" :key="l.id" class="lottery-card" @click="$router.push(`/lotteries/${l.id}`)">
<h3>{{ l.name }}</h3>
<p>{{ l.description }}</p>
<el-button type="primary" size="small">{{ $t('lottery.register') }}</el-button>
<div class="lottery-icon">
<el-icon size="20"><Trophy /></el-icon>
</div>
<div class="lottery-info">
<h3>{{ l.name }}</h3>
<p>{{ l.description }}</p>
</div>
<div class="lottery-right">
<span class="lottery-status" :class="l.status === 'active' ? 'active' : ''">
{{ l.status === 'active' ? '进行中' : '已结束' }}
</span>
<button class="lottery-btn">参与</button>
</div>
</div>
</div>
</section>
</div>
<div class="section" v-if="articles.length">
<div class="section-head">
<h2>最新资讯</h2>
<span class="section-more" @click="$router.push('/articles')">查看全部 </span>
</div>
<div class="article-list">
<div v-for="a in articles" :key="a.id" class="article-item" @click="$router.push(`/articles/${a.id}`)">
<div class="article-icon">
<el-icon size="18"><Document /></el-icon>
</div>
<div class="article-info">
<h3>{{ a.title }}</h3>
<p>{{ a.summary || a.content?.slice(0, 60) }}...</p>
</div>
<span class="article-date">{{ formatDate(a.created_at) }}</span>
</div>
</div>
</div>
<div class="section" v-if="categories.length">
<div class="section-head">
<h2>热门分类</h2>
<span class="section-more" @click="$router.push('/products')">查看全部 </span>
</div>
<div class="category-list">
<div v-for="cat in categories" :key="cat.id" class="category-item" @click="goCategory(cat.id)">
<div class="category-icon">
<el-icon size="18"><Folder /></el-icon>
</div>
<div class="category-info">
<h3>{{ cat.name }}</h3>
<p>{{ cat.product_count || 0 }} 件商品</p>
</div>
<el-icon class="category-arrow"><ArrowRight /></el-icon>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, shallowRef } from 'vue'
import { useRouter } from 'vue-router'
import { Folder, Goods } from '@element-plus/icons-vue'
import { categoryApi, productApi, lotteryApi } from '../../api'
import { getFirstImage } from '../../utils/image'
import { Goods, Trophy, Document, ShoppingCart, Folder, Present, Star, Timer, ArrowRight } from '@element-plus/icons-vue'
import { categoryApi, lotteryApi, articleApi, bannerApi } from '../../api'
const router = useRouter()
const categories = ref<any[]>([])
const products = ref<any[]>([])
const lotteries = ref<any[]>([])
const articles = ref<any[]>([])
const banners = ref<any[]>([])
const defaultBanners = [
{ title: '新品上市', desc: '精选优质商品,限时特惠', btn: '立即选购', link: '/products', icon: shallowRef(Present), bg: 'linear-gradient(135deg, #4e6ef2 0%, #7c5cfc 100%)' },
{ title: '幸运抽奖', desc: '参与抽奖赢取好礼', btn: '参与活动', link: '/lotteries', icon: shallowRef(Star), bg: 'linear-gradient(135deg, #f59e0b 0%, #f97316 100%)' },
{ title: '限时秒杀', desc: '每日精选,超值优惠', btn: '查看详情', link: '/products', icon: shallowRef(Timer), bg: 'linear-gradient(135deg, #10b981 0%, #059669 100%)' },
]
function goCategory(id: number) {
router.push({ path: '/products', query: { category_id: String(id) } })
}
function formatDate(date: string) {
if (!date) return '-'
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
onMounted(async () => {
try {
const [catRes, prodRes, lotRes]: any[] = await Promise.all([
const [catRes, lotRes, artRes, banRes]: any[] = await Promise.all([
categoryApi.list(),
productApi.list({ page: 1, page_size: 8 }),
lotteryApi.list(),
articleApi.list({ page: 1, page_size: 5 }),
bannerApi.list(),
])
categories.value = catRes.data || []
products.value = prodRes.data || []
categories.value = (catRes.data || []).slice(0, 6)
lotteries.value = (lotRes.data || []).slice(0, 3)
} catch {}
articles.value = (artRes.data || []).slice(0, 5)
if (banRes.data && banRes.data.length > 0) {
banners.value = banRes.data
} else {
banners.value = defaultBanners as any
}
} catch (e) {
console.log('加载失败:', e)
banners.value = defaultBanners as any
}
})
</script>
<style scoped lang="scss">
.hero {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
color: #fff;
padding: 80px 24px;
text-align: center;
h1 { font-size: 42px; font-weight: 700; margin-bottom: 16px; }
p { font-size: 18px; opacity: 0.8; margin-bottom: 32px; }
.home-page {
padding: 0;
width: 100%;
max-width: 100vw;
overflow-x: hidden;
box-sizing: border-box;
}
.section {
max-width: 1200px;
margin: 0 auto;
padding: 40px 24px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
.banner-section {
margin-bottom: 24px;
}
.section-title {
color: #fff;
font-size: 24px;
font-weight: 600;
}
.category-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px;
}
.category-card {
background: #2d2d44;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
padding: 24px 16px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
&:hover { transform: translateY(-4px); border-color: rgba(78, 110, 242, 0.3); }
}
.category-icon { margin-bottom: 8px; color: #4e6ef2; }
.category-name { font-size: 14px; font-weight: 500; color: rgba(255, 255, 255, 0.85); }
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 20px;
}
.product-card {
background: #2d2d44;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
width: 100%;
max-width: 100%;
overflow: hidden;
cursor: pointer;
transition: all 0.3s;
&:hover { transform: translateY(-4px); border-color: rgba(78, 110, 242, 0.3); }
position: relative;
}
.product-image {
height: 200px;
background: rgba(255, 255, 255, 0.04);
.banner-slide {
height: 280px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 60px;
border-radius: 12px;
position: relative;
overflow: hidden;
width: 100%;
box-sizing: border-box;
}
.banner-bg-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 0;
}
.banner-content {
z-index: 1;
position: relative;
}
.banner-content h2 {
font-size: 32px;
font-weight: 700;
color: #fff;
margin-bottom: 12px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.banner-content p {
font-size: 16px;
color: rgba(255, 255, 255, 0.95);
margin-bottom: 24px;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
.banner-btn {
padding: 12px 32px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
color: #fff;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(8px);
}
.banner-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
.banner-icon {
color: rgba(255, 255, 255, 0.2);
}
.quick-actions {
display: flex;
gap: 12px;
margin-bottom: 28px;
width: 100%;
box-sizing: border-box;
}
.action-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 16px;
background: #2d2d44;
border-radius: 12px;
cursor: pointer;
transition: all 0.15s;
border: 1px solid rgba(255, 255, 255, 0.04);
}
.action-item:hover {
background: #35355a;
transform: translateY(-2px);
}
.action-icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
.el-image {
width: 100%;
height: 100%;
}
.image-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.15);
}
}
.product-info { padding: 16px; }
.product-name { font-size: 16px; font-weight: 500; margin-bottom: 8px; color: rgba(255, 255, 255, 0.9); }
.product-price { font-size: 20px; font-weight: 700; color: #4e6ef2; margin-bottom: 8px; }
.product-tags { display: flex; gap: 8px; }
.action-item span {
font-size: 13px;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
}
.lottery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
.section {
margin-bottom: 28px;
width: 100%;
box-sizing: border-box;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.section-head h2 {
font-size: 17px;
font-weight: 600;
color: #fff;
}
.section-more {
font-size: 13px;
color: rgba(255, 255, 255, 0.35);
cursor: pointer;
transition: color 0.15s;
}
.section-more:hover {
color: #4e6ef2;
}
.category-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.category-item {
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
background: #2d2d44;
border-radius: 12px;
cursor: pointer;
transition: all 0.15s;
border: 1px solid rgba(255, 255, 255, 0.04);
}
.category-item:hover {
background: #35355a;
}
.category-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: linear-gradient(135deg, #f59e0b, #f97316);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
}
.category-info {
flex: 1;
min-width: 0;
}
.category-info h3 {
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 4px;
}
.category-info p {
font-size: 12px;
color: rgba(255, 255, 255, 0.35);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-arrow {
color: rgba(255, 255, 255, 0.25);
flex-shrink: 0;
}
.lottery-cards {
display: flex;
flex-direction: column;
gap: 10px;
}
.lottery-card {
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
color: #fff;
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
background: #2d2d44;
border-radius: 12px;
padding: 24px;
cursor: pointer;
transition: all 0.3s;
transition: all 0.15s;
border: 1px solid rgba(255, 255, 255, 0.04);
}
h3 { margin-bottom: 8px; }
p { opacity: 0.8; margin-bottom: 16px; font-size: 14px; }
.lottery-card:hover {
background: #35355a;
}
.lottery-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
}
.lottery-info {
flex: 1;
min-width: 0;
}
.lottery-info h3 {
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 4px;
}
.lottery-info p {
font-size: 12px;
color: rgba(255, 255, 255, 0.35);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lottery-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
flex-shrink: 0;
}
.lottery-status {
font-size: 12px;
padding: 3px 10px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.4);
}
.lottery-status.active {
background: rgba(16, 185, 129, 0.12);
color: #10b981;
}
.lottery-btn {
padding: 6px 16px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
color: #fff;
background: #4e6ef2;
border: none;
cursor: pointer;
transition: background 0.15s;
}
.lottery-btn:hover {
background: #5a7af5;
}
.article-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.article-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: #2d2d44;
border-radius: 10px;
cursor: pointer;
transition: background 0.15s;
border: 1px solid rgba(255, 255, 255, 0.04);
}
.article-item:hover {
background: #35355a;
}
.article-icon {
width: 32px;
height: 32px;
border-radius: 6px;
background: rgba(16, 185, 129, 0.12);
display: flex;
align-items: center;
justify-content: center;
color: #10b981;
flex-shrink: 0;
}
.article-info {
flex: 1;
min-width: 0;
}
.article-info h3 {
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.article-info p {
font-size: 12px;
color: rgba(255, 255, 255, 0.35);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.article-date {
font-size: 12px;
color: rgba(255, 255, 255, 0.25);
flex-shrink: 0;
}
:deep(.el-carousel) {
position: relative !important;
}
:deep(.el-carousel__container) {
position: relative !important;
height: 280px;
}
:deep(.el-carousel__arrow) {
top: 50% !important;
transform: translateY(-50%) !important;
background: rgba(0, 0, 0, 0.3) !important;
color: #fff !important;
border: none !important;
width: 36px !important;
height: 36px !important;
font-size: 16px !important;
margin-top: 0 !important;
}
:deep(.el-carousel__arrow:hover) {
background: rgba(0, 0, 0, 0.5) !important;
}
:deep(.el-carousel__arrow--left) {
left: 16px !important;
}
:deep(.el-carousel__arrow--right) {
right: 16px !important;
}
:deep(.el-carousel__indicators) {
position: absolute !important;
bottom: 16px !important;
left: 50% !important;
transform: translateX(-50%) !important;
z-index: 10 !important;
margin: 0 !important;
padding: 0 !important;
}
:deep(.el-carousel__indicator--horizontal .el-carousel__button) {
background: rgba(255, 255, 255, 0.3);
width: 24px;
height: 4px;
border-radius: 2px;
}
:deep(.el-carousel__indicator--horizontal.is-active .el-carousel__button) {
background: #fff;
}
@media (max-width: 768px) {
.home-page {
padding: 0;
width: 100%;
max-width: 100vw;
overflow-x: hidden;
}
.banner-section {
margin-bottom: 16px;
width: 100%;
max-width: 100vw;
position: relative;
}
.banner-slide {
height: 200px;
padding: 0 20px;
border-radius: 0;
width: 100%;
max-width: 100vw;
box-sizing: border-box;
}
.banner-content h2 {
font-size: 20px;
margin-bottom: 8px;
}
.banner-content p {
font-size: 14px;
margin-bottom: 16px;
}
.banner-btn {
padding: 8px 20px;
font-size: 13px;
}
.banner-icon {
display: none;
}
.quick-actions {
flex-wrap: wrap;
gap: 8px;
margin-bottom: 20px;
padding: 0 12px;
}
.action-item {
flex: 1 1 45%;
padding: 12px;
}
.action-icon {
width: 36px;
height: 36px;
}
.action-item span {
font-size: 12px;
}
.section {
margin-bottom: 20px;
padding: 0 12px;
width: 100% !important;
max-width: 100vw !important;
box-sizing: border-box !important;
overflow-x: hidden !important;
}
.section-head {
margin-bottom: 12px;
}
.section-head h2 {
font-size: 16px;
}
.section-more {
font-size: 12px;
}
.category-list {
gap: 8px;
}
.category-item {
padding: 12px;
gap: 10px;
}
.category-icon {
width: 36px;
height: 36px;
}
.category-info h3 {
font-size: 13px;
}
.category-info p {
font-size: 11px;
}
.lottery-cards {
gap: 8px;
}
.lottery-card {
padding: 12px;
gap: 10px;
}
.lottery-icon {
width: 36px;
height: 36px;
}
.lottery-info h3 {
font-size: 13px;
}
.lottery-info p {
font-size: 11px;
}
.lottery-status {
font-size: 11px;
padding: 2px 8px;
}
.lottery-btn {
padding: 5px 12px;
font-size: 11px;
}
.article-list {
gap: 6px;
}
.article-item {
padding: 10px;
gap: 10px;
}
.article-icon {
width: 28px;
height: 28px;
}
.article-info h3 {
font-size: 13px;
}
.article-info p {
font-size: 11px;
}
.article-date {
font-size: 11px;
}
:deep(.el-carousel) {
position: relative !important;
}
:deep(.el-carousel__container) {
height: 200px !important;
}
:deep(.el-carousel__arrow) {
width: 28px !important;
height: 28px !important;
font-size: 14px !important;
top: 50% !important;
transform: translateY(-50%) !important;
margin-top: 0 !important;
}
:deep(.el-carousel__arrow--left) {
left: 10px !important;
}
:deep(.el-carousel__arrow--right) {
right: 10px !important;
}
:deep(.el-carousel__indicators) {
bottom: 12px !important;
position: absolute !important;
left: 50% !important;
transform: translateX(-50%) !important;
z-index: 10 !important;
}
:deep(.el-carousel__indicator--horizontal .el-carousel__button) {
width: 20px;
height: 3px;
}
}
</style>
+32 -5
View File
@@ -15,9 +15,11 @@
<el-table-column label="创建时间" width="180">
<template #default="{ row }">{{ formatDate(row.created_at) }}</template>
</el-table-column>
<el-table-column :label="$t('common.edit')" width="120">
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="$router.push(`/orders/${row.id}`)">查看</el-button>
<el-button v-if="row.status === 'pending_payment' || row.status === 'pending_confirm'" link type="warning" size="small" @click="cancelOrder(row.id)">取消</el-button>
<el-button v-if="row.status === 'shipped'" link type="success" size="small" @click="confirmReceipt(row.id)">确认收货</el-button>
</template>
</el-table-column>
</el-table>
@@ -27,6 +29,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { orderApi } from '../../api'
import BackNav from '../../components/BackNav.vue'
@@ -54,10 +57,34 @@ function formatDate(date: string) {
return `${year}-${month}-${day} ${hours}:${minutes}`
}
onMounted(async () => {
const res: any = await orderApi.list()
orders.value = res.data || []
})
async function fetchOrders() {
try {
const res: any = await orderApi.list()
orders.value = res.data || []
} catch {
ElMessage.error('获取订单失败')
}
}
async function cancelOrder(id: number) {
try {
await ElMessageBox.confirm('确定要取消该订单吗?', '提示', { type: 'warning' })
await orderApi.cancel(id)
ElMessage.success('订单已取消')
fetchOrders()
} catch {}
}
async function confirmReceipt(id: number) {
try {
await ElMessageBox.confirm('确认已收到商品?', '提示', { type: 'info' })
await orderApi.confirmReceipt(id)
ElMessage.success('已确认收货')
fetchOrders()
} catch {}
}
onMounted(fetchOrders)
</script>
<style scoped lang="scss">
+1 -2
View File
@@ -94,8 +94,7 @@ onMounted(async () => {
<style scoped lang="scss">
.products-page {
max-width: 1200px;
margin: 0 auto;
padding: 0;
}
.page-header {
+166
View File
@@ -0,0 +1,166 @@
<template>
<div class="ticket-detail-page">
<BackNav />
<div v-if="ticket" class="ticket-card">
<div class="ticket-header">
<h2>{{ ticket.subject }}</h2>
<el-tag :type="statusType(ticket.status)">{{ statusText(ticket.status) }}</el-tag>
</div>
<div class="ticket-meta">
<span>分类{{ ticket.category }}</span>
<span>创建时间{{ formatDate(ticket.created_at) }}</span>
</div>
<div class="ticket-content">
<p>{{ ticket.content }}</p>
</div>
<div v-if="ticket.reply" class="reply-section">
<h3>回复</h3>
<div class="reply-content" v-html="formatReply(ticket.reply)"></div>
</div>
<div v-if="ticket.status !== 'resolved' && ticket.status !== 'closed'" class="reply-form">
<h3>追加回复</h3>
<el-input v-model="replyContent" type="textarea" :rows="4" placeholder="请输入回复内容" />
<el-button type="primary" @click="submitReply" :loading="submitting" style="margin-top: 12px">提交回复</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ticketApi } from '../../api'
import BackNav from '../../components/BackNav.vue'
const route = useRoute()
const ticket = ref<any>(null)
const replyContent = ref('')
const submitting = ref(false)
const statusMap: Record<string, string> = {
pending: '待处理', processing: '处理中', resolved: '已解决', closed: '已关闭',
}
const statusTypeMap: Record<string, string> = {
pending: 'warning', processing: 'primary', resolved: 'success', closed: 'info',
}
function statusText(s: string) { return statusMap[s] || s }
function statusType(s: string) { return statusTypeMap[s] || 'info' }
function formatDate(date: string) {
if (!date) return '-'
const d = new Date(date)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function formatReply(reply: string) {
return reply.replace(/\n/g, '<br>').replace(/---/g, '<hr style="border-color: rgba(255,255,255,0.1); margin: 12px 0;">')
}
async function fetchTicket() {
try {
const id = Number(route.params.id)
const res: any = await ticketApi.getById(id)
ticket.value = res.data
} catch {
ElMessage.error('获取工单失败')
}
}
async function submitReply() {
if (!replyContent.value.trim()) {
ElMessage.warning('请输入回复内容')
return
}
submitting.value = true
try {
await ticketApi.reply(Number(route.params.id), { content: replyContent.value })
ElMessage.success('回复成功')
replyContent.value = ''
fetchTicket()
} catch {
ElMessage.error('回复失败')
} finally {
submitting.value = false
}
}
onMounted(fetchTicket)
</script>
<style scoped lang="scss">
.ticket-detail-page { padding: 0; }
.ticket-card {
background: #2d2d44;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
padding: 24px;
}
.ticket-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
h2 {
font-size: 18px;
font-weight: 600;
color: #fff;
margin: 0;
}
}
.ticket-meta {
display: flex;
gap: 16px;
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 16px;
}
.ticket-content {
padding: 16px;
background: rgba(255, 255, 255, 0.04);
border-radius: 8px;
color: rgba(255, 255, 255, 0.8);
line-height: 1.6;
margin-bottom: 20px;
}
.reply-section {
margin-bottom: 20px;
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
h3 {
font-size: 15px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 12px;
}
}
.reply-content {
padding: 16px;
background: rgba(78, 110, 242, 0.08);
border-radius: 8px;
color: rgba(255, 255, 255, 0.8);
line-height: 1.6;
}
.reply-form {
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
h3 {
font-size: 15px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 12px;
}
}
</style>
+6 -1
View File
@@ -7,7 +7,7 @@
<div class="table-card">
<el-table :data="tickets">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" :label="$t('ticket.subject')" />
<el-table-column prop="subject" :label="$t('ticket.subject')" />
<el-table-column prop="status" :label="$t('ticket.status')" width="120">
<template #default="{ row }">
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
@@ -16,6 +16,11 @@
<el-table-column label="创建时间" width="180">
<template #default="{ row }">{{ formatDate(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="$router.push(`/tickets/${row.id}`)">查看</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>