feat: add announcement feature with admin management and home page display
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"sale/internal/models"
|
||||
"sale/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AnnouncementHandler struct{}
|
||||
|
||||
func NewAnnouncementHandler() *AnnouncementHandler {
|
||||
return &AnnouncementHandler{}
|
||||
}
|
||||
|
||||
// List 用户端接口 - 只返回激活的公告
|
||||
func (h *AnnouncementHandler) List(c *gin.Context) {
|
||||
var announcements []models.Announcement
|
||||
utils.DB.Where("is_active = ?", true).
|
||||
Order("sort_order ASC, created_at DESC").
|
||||
Find(&announcements)
|
||||
c.JSON(http.StatusOK, gin.H{"data": announcements})
|
||||
}
|
||||
|
||||
// AdminList 管理端接口 - 返回所有公告
|
||||
func (h *AnnouncementHandler) AdminList(c *gin.Context) {
|
||||
var announcements []models.Announcement
|
||||
utils.DB.Order("sort_order ASC, created_at DESC").Find(&announcements)
|
||||
c.JSON(http.StatusOK, gin.H{"data": announcements})
|
||||
}
|
||||
|
||||
// Create 创建公告
|
||||
func (h *AnnouncementHandler) Create(c *gin.Context) {
|
||||
var req struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Content string `json:"content"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "标题不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
announcement := models.Announcement{
|
||||
Title: req.Title,
|
||||
Content: req.Content,
|
||||
SortOrder: req.SortOrder,
|
||||
IsActive: req.IsActive,
|
||||
}
|
||||
utils.DB.Create(&announcement)
|
||||
c.JSON(http.StatusCreated, gin.H{"data": announcement})
|
||||
}
|
||||
|
||||
// Update 更新公告
|
||||
func (h *AnnouncementHandler) Update(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var announcement models.Announcement
|
||||
if err := utils.DB.First(&announcement, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "公告不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
announcement.Title = req.Title
|
||||
announcement.Content = req.Content
|
||||
announcement.SortOrder = req.SortOrder
|
||||
announcement.IsActive = req.IsActive
|
||||
utils.DB.Save(&announcement)
|
||||
c.JSON(http.StatusOK, gin.H{"data": announcement})
|
||||
}
|
||||
|
||||
// Delete 删除公告
|
||||
func (h *AnnouncementHandler) Delete(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := utils.DB.Delete(&models.Announcement{}, id).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||
}
|
||||
|
||||
// ToggleActive 切换激活状态
|
||||
func (h *AnnouncementHandler) ToggleActive(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var announcement models.Announcement
|
||||
if err := utils.DB.First(&announcement, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "公告不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
announcement.IsActive = !announcement.IsActive
|
||||
utils.DB.Save(&announcement)
|
||||
c.JSON(http.StatusOK, gin.H{"data": announcement})
|
||||
}
|
||||
@@ -28,6 +28,7 @@ func SetupRoutes(r *gin.Engine) {
|
||||
logHandler := handlers.NewLogHandler()
|
||||
purchaseGroupHandler := handlers.NewPurchaseGroupHandler()
|
||||
shippingTemplateHandler := handlers.NewShippingTemplateHandler()
|
||||
announcementHandler := handlers.NewAnnouncementHandler()
|
||||
|
||||
r.Use(middlewares.CORSMiddleware())
|
||||
|
||||
@@ -62,6 +63,7 @@ func SetupRoutes(r *gin.Engine) {
|
||||
}
|
||||
|
||||
api.GET("/banners", bannerHandler.List)
|
||||
api.GET("/announcements", announcementHandler.List)
|
||||
|
||||
api.GET("/categories", categoryHandler.List)
|
||||
api.GET("/brands", brandHandler.List)
|
||||
@@ -262,6 +264,15 @@ func SetupRoutes(r *gin.Engine) {
|
||||
adminShippingTemplates.PUT("/:id", shippingTemplateHandler.Update)
|
||||
adminShippingTemplates.DELETE("/:id", shippingTemplateHandler.Delete)
|
||||
}
|
||||
|
||||
adminAnnouncements := admin.Group("/announcements")
|
||||
{
|
||||
adminAnnouncements.GET("", announcementHandler.AdminList)
|
||||
adminAnnouncements.POST("", announcementHandler.Create)
|
||||
adminAnnouncements.PUT("/:id", announcementHandler.Update)
|
||||
adminAnnouncements.DELETE("/:id", announcementHandler.Delete)
|
||||
adminAnnouncements.PUT("/:id/toggle", announcementHandler.ToggleActive)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Announcement struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Title string `gorm:"size:255;not null" json:"title"`
|
||||
Content string `gorm:"type:text" json:"content"`
|
||||
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
func (Announcement) TableName() string {
|
||||
return "announcements"
|
||||
}
|
||||
@@ -82,6 +82,7 @@ func AutoMigrate() {
|
||||
&models.Article{},
|
||||
&models.Banner{},
|
||||
&models.PaymentChannel{},
|
||||
&models.Announcement{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to migrate database: %v", err)
|
||||
|
||||
@@ -91,6 +91,10 @@ export const bannerApi = {
|
||||
list: () => api.get('/banners'),
|
||||
}
|
||||
|
||||
export const announcementApi = {
|
||||
list: () => api.get('/announcements'),
|
||||
}
|
||||
|
||||
export const lotteryApi = {
|
||||
list: () => api.get('/lotteries'),
|
||||
getById: (id: number) => api.get(`/lotteries/${id}`),
|
||||
@@ -201,4 +205,11 @@ export const adminApi = {
|
||||
createShippingTemplate: (data: any) => api.post('/admin/shipping-templates', data),
|
||||
updateShippingTemplate: (id: number, data: any) => api.put(`/admin/shipping-templates/${id}`, data),
|
||||
deleteShippingTemplate: (id: number) => api.delete(`/admin/shipping-templates/${id}`),
|
||||
|
||||
// 公告
|
||||
getAnnouncements: () => api.get('/admin/announcements'),
|
||||
createAnnouncement: (data: any) => api.post('/admin/announcements', data),
|
||||
updateAnnouncement: (id: number, data: any) => api.put(`/admin/announcements/${id}`, data),
|
||||
deleteAnnouncement: (id: number) => api.delete(`/admin/announcements/${id}`),
|
||||
toggleAnnouncement: (id: number) => api.put(`/admin/announcements/${id}/toggle`),
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span class="nav-text">{{ $t('admin.bannerManagement') }}</span>
|
||||
</router-link>
|
||||
<router-link to="/admin/announcements" class="nav-link">
|
||||
<el-icon><Bell /></el-icon>
|
||||
<span class="nav-text">公告管理</span>
|
||||
</router-link>
|
||||
<router-link to="/admin/payment" class="nav-link">
|
||||
<el-icon><Wallet /></el-icon>
|
||||
<span class="nav-text">{{ $t('admin.paymentManagement') }}</span>
|
||||
@@ -169,7 +173,7 @@ import { ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import logoImg from '../assets/logo.png'
|
||||
import { DataAnalysis, User, Folder, Grid, Stamp, Goods, List, Van, Trophy, ChatDotSquare, Setting, Document, Picture, Compass, Fold, Expand, HomeFilled, FullScreen, ArrowDown, SwitchButton, ArrowRight, Wallet, Notebook, Box } from '@element-plus/icons-vue'
|
||||
import { DataAnalysis, User, Folder, Grid, Stamp, Goods, List, Van, Trophy, ChatDotSquare, Setting, Document, Picture, Compass, Fold, Expand, HomeFilled, FullScreen, ArrowDown, SwitchButton, ArrowRight, Wallet, Notebook, Box, Bell } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '../store/user'
|
||||
import { useCartStore } from '../store/cart'
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ const routes = [
|
||||
{ path: 'settings', name: 'AdminSettings', component: () => import('../views/admin/Settings.vue') },
|
||||
{ path: 'articles', name: 'AdminArticles', component: () => import('../views/admin/Articles.vue') },
|
||||
{ path: 'banners', name: 'AdminBanners', component: () => import('../views/admin/Banners.vue') },
|
||||
{ path: 'announcements', name: 'AdminAnnouncements', component: () => import('../views/admin/Announcements.vue') },
|
||||
{ path: 'payment', name: 'AdminPayment', component: () => import('../views/admin/PaymentChannels.vue') },
|
||||
{ path: 'logs', name: 'AdminLogs', component: () => import('../views/admin/Logs.vue') },
|
||||
{ path: 'shipping-templates', name: 'AdminShippingTemplates', component: () => import('../views/admin/ShippingTemplates.vue') },
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div class="announcements-page">
|
||||
<div class="page-header">
|
||||
<h2>公告管理</h2>
|
||||
<el-button type="primary" @click="showAdd = true">添加公告</el-button>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<el-table :data="announcements" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="title" label="标题" />
|
||||
<el-table-column prop="content" label="内容" show-overflow-tooltip />
|
||||
<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 prop="created_at" label="创建时间" width="180">
|
||||
<template #default="{ row }">{{ formatDate(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="editAnnouncement(row)">编辑</el-button>
|
||||
<el-button link :type="row.is_active ? 'warning' : 'success'" @click="toggleActive(row)">
|
||||
{{ row.is_active ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button link type="danger" @click="deleteAnnouncement(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="showAdd" :title="editing ? '编辑公告' : '添加公告'" width="500px">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="标题" required>
|
||||
<el-input v-model="form.title" placeholder="请输入公告标题" />
|
||||
</el-form-item>
|
||||
<el-form-item label="内容">
|
||||
<el-input v-model="form.content" type="textarea" :rows="3" placeholder="请输入公告内容" />
|
||||
</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" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAdd = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveAnnouncement" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { adminApi } from '../../api'
|
||||
|
||||
const announcements = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const showAdd = ref(false)
|
||||
const editing = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
|
||||
const form = ref({
|
||||
title: '',
|
||||
content: '',
|
||||
sort_order: 0,
|
||||
is_active: true,
|
||||
})
|
||||
|
||||
function formatDate(date: string) {
|
||||
if (!date) return '-'
|
||||
const d = new Date(date)
|
||||
return d.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
async function fetchAnnouncements() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await adminApi.getAnnouncements()
|
||||
announcements.value = res.data || []
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function editAnnouncement(row: any) {
|
||||
editing.value = true
|
||||
editingId.value = row.id
|
||||
form.value = {
|
||||
title: row.title,
|
||||
content: row.content || '',
|
||||
sort_order: row.sort_order || 0,
|
||||
is_active: row.is_active,
|
||||
}
|
||||
showAdd.value = true
|
||||
}
|
||||
|
||||
async function saveAnnouncement() {
|
||||
if (!form.value.title.trim()) {
|
||||
ElMessage.warning('请输入公告标题')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value && editingId.value) {
|
||||
await adminApi.updateAnnouncement(editingId.value, form.value)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await adminApi.createAnnouncement(form.value)
|
||||
ElMessage.success('添加成功')
|
||||
}
|
||||
showAdd.value = false
|
||||
fetchAnnouncements()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.response?.data?.error || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(row: any) {
|
||||
try {
|
||||
await adminApi.toggleAnnouncement(row.id)
|
||||
ElMessage.success('状态已更新')
|
||||
fetchAnnouncements()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.response?.data?.error || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAnnouncement(id: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该公告吗?', '提示', { type: 'warning' })
|
||||
await adminApi.deleteAnnouncement(id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchAnnouncements()
|
||||
} catch (err: any) {
|
||||
if (err !== 'cancel') {
|
||||
ElMessage.error(err.response?.data?.error || '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAnnouncements()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.announcements-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,19 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<!-- 公告区域 -->
|
||||
<div class="announcement-bar" v-if="announcements.length">
|
||||
<div class="announcement-icon">
|
||||
<el-icon><Bell /></el-icon>
|
||||
</div>
|
||||
<div class="announcement-content">
|
||||
<el-carousel height="32px" direction="vertical" :autoplay="true" :interval="4000" indicator-position="none">
|
||||
<el-carousel-item v-for="(item, index) in announcements" :key="index">
|
||||
<span class="announcement-text">{{ item.title }}</span>
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
@@ -111,8 +125,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, shallowRef } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Goods, Trophy, Document, ShoppingCart, Folder, Present, Star, Timer, ArrowRight } from '@element-plus/icons-vue'
|
||||
import { categoryApi, lotteryApi, articleApi, bannerApi } from '../../api'
|
||||
import { Goods, Trophy, Document, ShoppingCart, Folder, Present, Star, Timer, ArrowRight, Bell } from '@element-plus/icons-vue'
|
||||
import { categoryApi, lotteryApi, articleApi, bannerApi, announcementApi } from '../../api'
|
||||
import { getImageUrl } from '../../utils/image'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -120,6 +134,7 @@ const categories = ref<any[]>([])
|
||||
const lotteries = ref<any[]>([])
|
||||
const articles = ref<any[]>([])
|
||||
const banners = ref<any[]>([])
|
||||
const announcements = ref<any[]>([])
|
||||
|
||||
const defaultBanners = [
|
||||
{ title: '新品上市', desc: '精选优质商品,限时特惠', btn: '立即选购', link: '/products', icon: shallowRef(Present), bg: 'linear-gradient(135deg, #4e6ef2 0%, #7c5cfc 100%)' },
|
||||
@@ -142,15 +157,17 @@ function formatDate(date: string) {
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [catRes, lotRes, artRes, banRes]: any[] = await Promise.all([
|
||||
const [catRes, lotRes, artRes, banRes, annRes]: any[] = await Promise.all([
|
||||
categoryApi.list(),
|
||||
lotteryApi.list(),
|
||||
articleApi.list({ page: 1, page_size: 5 }),
|
||||
bannerApi.list(),
|
||||
announcementApi.list(),
|
||||
])
|
||||
categories.value = (catRes.data || []).slice(0, 6)
|
||||
lotteries.value = (lotRes.data || []).slice(0, 3)
|
||||
articles.value = (artRes.data || []).slice(0, 5)
|
||||
announcements.value = annRes.data || []
|
||||
|
||||
if (banRes.data && banRes.data.length > 0) {
|
||||
banners.value = banRes.data
|
||||
@@ -173,6 +190,53 @@ onMounted(async () => {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.announcement-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background: linear-gradient(135deg, rgba(78, 110, 242, 0.15), rgba(124, 92, 252, 0.15));
|
||||
border: 1px solid rgba(78, 110, 242, 0.2);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.announcement-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
animation: bell-ring 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes bell-ring {
|
||||
0%, 100% { transform: rotate(0); }
|
||||
10%, 30% { transform: rotate(10deg); }
|
||||
20%, 40% { transform: rotate(-10deg); }
|
||||
50% { transform: rotate(0); }
|
||||
}
|
||||
|
||||
.announcement-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.announcement-text {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.banner-section {
|
||||
margin-bottom: 24px;
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user