feat: 用户管理显示资格和消费金额,订单管理添加详情功能

- 用户管理: 显示资格数和消费金额,支持编辑资格
- 订单管理: 添加订单详情查看功能
- 后端: AdminGetUsers返回消费金额, AdminGetByID获取订单详情

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 18:36:19 +08:00
parent ec0a30c43d
commit ded24ddccc
6 changed files with 303 additions and 10 deletions
+44 -5
View File
@@ -376,7 +376,42 @@ func (h *AuthHandler) Install(c *gin.Context) {
func (h *AuthHandler) AdminGetUsers(c *gin.Context) {
var users []models.User
utils.DB.Select("id, username, email, role, purchase_credits, is_active, email_verified, created_at").Find(&users)
c.JSON(http.StatusOK, gin.H{"data": users})
// 计算每个用户的消费金额
type UserWithSpent struct {
ID uint `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
PurchaseCredits int `json:"purchase_credits"`
IsActive bool `json:"is_active"`
EmailVerified bool `json:"email_verified"`
CreatedAt time.Time `json:"created_at"`
TotalSpent float64 `json:"total_spent"`
}
result := make([]UserWithSpent, len(users))
for i, user := range users {
var totalSpent float64
utils.DB.Model(&models.Order{}).
Where("user_id = ? AND status IN ?", user.ID, []string{"completed", "shipped"}).
Select("COALESCE(SUM(total_amount), 0)").
Scan(&totalSpent)
result[i] = UserWithSpent{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Role: user.Role,
PurchaseCredits: user.PurchaseCredits,
IsActive: user.IsActive,
EmailVerified: user.EmailVerified,
CreatedAt: user.CreatedAt,
TotalSpent: totalSpent,
}
}
c.JSON(http.StatusOK, gin.H{"data": result})
}
func (h *AuthHandler) AdminUpdateUser(c *gin.Context) {
@@ -388,10 +423,11 @@ func (h *AuthHandler) AdminUpdateUser(c *gin.Context) {
}
var req struct {
Username *string `json:"username"`
Email *string `json:"email"`
Role *string `json:"role"`
IsActive *bool `json:"is_active"`
Username *string `json:"username"`
Email *string `json:"email"`
Role *string `json:"role"`
IsActive *bool `json:"is_active"`
PurchaseCredits *int `json:"purchase_credits"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -411,6 +447,9 @@ func (h *AuthHandler) AdminUpdateUser(c *gin.Context) {
if req.IsActive != nil {
updates["is_active"] = *req.IsActive
}
if req.PurchaseCredits != nil {
updates["purchase_credits"] = *req.PurchaseCredits
}
utils.DB.Model(&user).Updates(updates)
utils.DB.First(&user, id)
+14
View File
@@ -503,6 +503,20 @@ func (h *OrderHandler) AdminList(c *gin.Context) {
})
}
func (h *OrderHandler) AdminGetByID(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var order models.Order
if err := utils.DB.Preload("OrderItems.Product").Preload("ShippingAddress").Preload("User").First(&order, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Order not found"})
return
}
checkOrderExpired(&order)
c.JSON(http.StatusOK, gin.H{"data": order})
}
func (h *OrderHandler) ProcessRefund(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
+1
View File
@@ -174,6 +174,7 @@ func SetupRoutes(r *gin.Engine) {
adminOrders := admin.Group("/orders")
{
adminOrders.GET("", orderHandler.AdminList)
adminOrders.GET("/:id", orderHandler.AdminGetByID)
adminOrders.GET("/export", orderHandler.Export)
adminOrders.PUT("/:id/refund", orderHandler.ProcessRefund)
}
+1
View File
@@ -136,6 +136,7 @@ export const adminApi = {
addCustomField: (id: number, data: any) => api.post(`/admin/products/${id}/custom-fields`, data),
getOrders: (params?: any) => api.get('/admin/orders', { params }),
getOrderById: (id: number) => api.get(`/admin/orders/${id}`),
exportOrders: () => api.get('/admin/orders/export'),
processRefund: (id: number, data: any) => api.put(`/admin/orders/${id}/refund`, data),
+216 -1
View File
@@ -13,8 +13,9 @@
<el-table-column label="创建时间" min-width="160">
<template #default="{ row }">{{ formatDate(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="viewDetail(row)">详情</el-button>
<el-button v-if="row.refund_status === 'pending'" link type="primary" size="small" @click="processRefund(row.id, 'approved')">批准</el-button>
<el-button v-if="row.refund_status === 'pending'" link type="danger" size="small" @click="processRefund(row.id, 'rejected')">拒绝</el-button>
</template>
@@ -30,6 +31,118 @@
/>
</div>
</div>
<!-- 订单详情弹窗 -->
<el-dialog v-model="detailVisible" title="订单详情" width="700px" top="5vh">
<div v-if="currentOrder" class="order-detail">
<div class="detail-section">
<h4>基本信息</h4>
<div class="detail-grid">
<div class="detail-item">
<span class="label">订单号</span>
<span class="value">#{{ currentOrder.id }}</span>
</div>
<div class="detail-item">
<span class="label">用户</span>
<span class="value">{{ currentOrder.user?.username || '-' }}</span>
</div>
<div class="detail-item">
<span class="label">状态</span>
<span class="value"><el-tag>{{ statusText(currentOrder.status) }}</el-tag></span>
</div>
<div class="detail-item">
<span class="label">创建时间</span>
<span class="value">{{ formatDate(currentOrder.created_at) }}</span>
</div>
</div>
</div>
<div class="detail-section" v-if="currentOrder.shipping_address">
<h4>收货地址</h4>
<div class="address-info">
<p><strong>{{ currentOrder.shipping_address.name }}</strong> {{ currentOrder.shipping_address.phone }}</p>
<p>{{ currentOrder.shipping_address.province }}{{ currentOrder.shipping_address.city }}{{ currentOrder.shipping_address.district }}{{ currentOrder.shipping_address.address }}</p>
</div>
</div>
<div class="detail-section">
<h4>商品清单</h4>
<el-table :data="currentOrder.order_items" size="small">
<el-table-column label="商品" min-width="200">
<template #default="{ row }">
<div class="product-info">
<span class="product-name">{{ row.product?.name || '-' }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="quantity" label="数量" width="80" align="center" />
<el-table-column label="单价" width="100" align="right">
<template #default="{ row }">¥{{ row.price }}</template>
</el-table-column>
<el-table-column label="小计" width="100" align="right">
<template #default="{ row }">¥{{ (row.price * row.quantity).toFixed(2) }}</template>
</el-table-column>
</el-table>
</div>
<div class="detail-section">
<h4>费用明细</h4>
<div class="fee-list">
<div class="fee-item">
<span>商品金额</span>
<span>¥{{ (currentOrder.subtotal || 0).toFixed(2) }}</span>
</div>
<div class="fee-item">
<span>运费</span>
<span>¥{{ (currentOrder.shipping_fee || 0).toFixed(2) }}</span>
</div>
<div class="fee-item" v-if="currentOrder.service_fee > 0">
<span>服务费</span>
<span>¥{{ (currentOrder.service_fee || 0).toFixed(2) }}</span>
</div>
<div class="fee-item" v-if="currentOrder.tax > 0">
<span>税费</span>
<span>¥{{ (currentOrder.tax || 0).toFixed(2) }}</span>
</div>
<div class="fee-item total">
<span>合计</span>
<span class="total-amount">¥{{ (currentOrder.total_amount || 0).toFixed(2) }}</span>
</div>
</div>
</div>
<div class="detail-section" v-if="currentOrder.tracking_number">
<h4>物流信息</h4>
<div class="detail-grid">
<div class="detail-item">
<span class="label">快递单号</span>
<span class="value">{{ currentOrder.tracking_number }}</span>
</div>
</div>
</div>
<div class="detail-section" v-if="currentOrder.refund_status">
<h4>退款信息</h4>
<div class="detail-grid">
<div class="detail-item">
<span class="label">退款状态</span>
<span class="value"><el-tag type="warning">{{ currentOrder.refund_status }}</el-tag></span>
</div>
<div class="detail-item" v-if="currentOrder.refund_reason">
<span class="label">退款原因</span>
<span class="value">{{ currentOrder.refund_reason }}</span>
</div>
<div class="detail-item" v-if="currentOrder.refund_amount">
<span class="label">退款金额</span>
<span class="value">¥{{ currentOrder.refund_amount.toFixed(2) }}</span>
</div>
</div>
</div>
</div>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -47,6 +160,9 @@ const paginatedOrders = computed(() => {
return orders.value.slice(start, start + pageSize)
})
const detailVisible = ref(false)
const currentOrder = ref<any>(null)
const statusMap: Record<string, string> = {
pending_payment: '待支付', pending_confirm: '待确认', pending_ship: '待发货',
shipped: '已发货', completed: '已完成', refunding: '退款中', refunded: '已退款', cancelled: '已取消',
@@ -73,6 +189,16 @@ async function fetchOrders() {
orders.value = res.data || []
}
async function viewDetail(row: any) {
try {
const res: any = await adminApi.getOrderById(row.id)
currentOrder.value = res.data
detailVisible.value = true
} catch {
ElMessage.error('获取订单详情失败')
}
}
async function processRefund(id: number, status: string) {
await adminApi.processRefund(id, { status })
ElMessage.success('已处理')
@@ -164,4 +290,93 @@ onMounted(fetchOrders)
.pagination-wrap :deep(.el-pagination__total) {
color: rgba(255, 255, 255, 0.5);
}
// 订单详情样式
.order-detail {
.detail-section {
margin-bottom: 24px;
h4 {
color: #fff;
font-size: 15px;
font-weight: 600;
margin: 0 0 12px 0;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.detail-item {
.label {
color: rgba(255, 255, 255, 0.5);
font-size: 13px;
}
.value {
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
}
}
.address-info {
background: rgba(255, 255, 255, 0.03);
padding: 12px 16px;
border-radius: 8px;
p {
color: rgba(255, 255, 255, 0.8);
margin: 0;
line-height: 1.6;
&:first-child {
margin-bottom: 4px;
}
strong {
color: #fff;
}
}
}
.product-info {
.product-name {
color: rgba(255, 255, 255, 0.9);
}
}
.fee-list {
.fee-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
&.total {
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin-top: 8px;
padding-top: 12px;
color: #fff;
font-weight: 500;
.total-amount {
color: #f56c6c;
font-size: 18px;
font-weight: 700;
}
}
}
}
}
@media (max-width: 768px) {
.detail-grid {
grid-template-columns: 1fr !important;
}
}
</style>
+27 -4
View File
@@ -3,8 +3,8 @@
<div class="table-card">
<el-table :data="paginatedUsers">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="username" label="用户名" min-width="100" />
<el-table-column prop="email" label="邮箱" min-width="150" />
<el-table-column prop="role" label="角色" width="100">
<template #default="{ row }">
<el-tag :type="row.role === 'admin' ? 'danger' : row.role === 'supplier' ? 'warning' : 'info'">
@@ -12,7 +12,16 @@
</el-tag>
</template>
</el-table-column>
<el-table-column prop="purchase_credits" label="积分" width="100" />
<el-table-column prop="purchase_credits" label="资格" width="80">
<template #default="{ row }">
<span class="credit-value">{{ row.purchase_credits }}</span>
</template>
</el-table-column>
<el-table-column prop="total_spent" label="消费金额" width="100">
<template #default="{ row }">
<span class="spent-value">¥{{ (row.total_spent || 0).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column prop="is_active" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'danger'" size="small">
@@ -20,7 +29,7 @@
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="注册时间" width="180">
<el-table-column prop="created_at" label="注册时间" width="160">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
@@ -58,6 +67,9 @@
<el-option label="管理员" value="admin" />
</el-select>
</el-form-item>
<el-form-item label="资格">
<el-input-number v-model="editForm.purchase_credits" :min="0" style="width: 100%" />
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="editForm.is_active" active-text="启用" inactive-text="禁用" />
</el-form-item>
@@ -126,6 +138,7 @@ async function handleEdit() {
username: editForm.value.username,
email: editForm.value.email,
role: editForm.value.role,
purchase_credits: editForm.value.purchase_credits,
is_active: editForm.value.is_active,
})
ElMessage.success('更新成功')
@@ -152,6 +165,16 @@ async function handleDelete(row: any) {
.users-page { padding: 0; }
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
.credit-value {
color: #f59e0b;
font-weight: 600;
}
.spent-value {
color: #10b981;
font-weight: 600;
}
.pagination-wrap {
margin-top: 20px;
display: flex;