feat: add announcement feature with admin management and home page display
Build and Push Docker Image / build-and-push (push) Successful in 1m3s
Build and Push Docker Image / deploy (push) Successful in 7s

This commit is contained in:
nuyue
2026-07-16 15:28:37 +08:00
parent c94641cda0
commit 43009ec3b9
9 changed files with 426 additions and 5 deletions
@@ -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})
}
+11
View File
@@ -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)
}
}
}
}
+22
View File
@@ -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"
}
+1
View File
@@ -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)
+11
View File
@@ -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`),
}
+5 -1
View File
@@ -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'
+1
View File
@@ -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') },
+184
View File
@@ -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>
+67 -3
View File
@@ -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%;