更新website
This commit is contained in:
@@ -155,7 +155,9 @@ func (c *IndexWebsiteController) News() {
|
|||||||
cond := publishedArticleCond()
|
cond := publishedArticleCond()
|
||||||
if cateStr := strings.TrimSpace(c.GetString("category")); cateStr != "" {
|
if cateStr := strings.TrimSpace(c.GetString("category")); cateStr != "" {
|
||||||
if cid, err := strconv.ParseUint(cateStr, 10, 64); err == nil && cid > 0 {
|
if cid, err := strconv.ParseUint(cateStr, 10, 64); err == nil && cid > 0 {
|
||||||
cond = cond.And("cate_id", cid)
|
// 选中父分类时把它整棵子树的文章一起返回,否则挂在子分类上的内容会查不到
|
||||||
|
ids := append([]uint64{cid}, models.PlatformWebsiteCategoryDescendantMap()[cid]...)
|
||||||
|
cond = cond.And("cate_id__in", ids)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if kw := strings.TrimSpace(c.GetString("keyword")); kw != "" {
|
if kw := strings.TrimSpace(c.GetString("keyword")); kw != "" {
|
||||||
@@ -221,20 +223,25 @@ func (c *IndexWebsiteController) NewsCategories() {
|
|||||||
if _, err := models.Orm.QueryTable(new(models.PlatformWebsiteCategory)).
|
if _, err := models.Orm.QueryTable(new(models.PlatformWebsiteCategory)).
|
||||||
Filter("delete_time__isnull", true).
|
Filter("delete_time__isnull", true).
|
||||||
OrderBy("sort", "id").
|
OrderBy("sort", "id").
|
||||||
All(&cates, "ID", "Name", "Desc", "Image", "Sort"); err != nil {
|
All(&cates, "ID", "Cid", "Name", "Desc", "Image", "Sort"); err != nil {
|
||||||
c.fail(500, 500, "获取分类失败: "+err.Error())
|
c.fail(500, 500, "获取分类失败: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 父分类的数量包含其所有后代,与列表页按父分类筛选的口径保持一致
|
||||||
|
descMap := models.PlatformWebsiteCategoryDescendantMap()
|
||||||
|
|
||||||
list := make([]map[string]interface{}, 0, len(cates))
|
list := make([]map[string]interface{}, 0, len(cates))
|
||||||
for _, item := range cates {
|
for _, item := range cates {
|
||||||
|
ids := append([]uint64{item.ID}, descMap[item.ID]...)
|
||||||
cnt, _ := models.Orm.QueryTable(new(models.PlatformWebsiteArticle)).
|
cnt, _ := models.Orm.QueryTable(new(models.PlatformWebsiteArticle)).
|
||||||
Filter("delete_time__isnull", true).
|
Filter("delete_time__isnull", true).
|
||||||
Filter("status", articleStatusPublished).
|
Filter("status", articleStatusPublished).
|
||||||
Filter("cate_id", item.ID).
|
Filter("cate_id__in", ids).
|
||||||
Count()
|
Count()
|
||||||
list = append(list, map[string]interface{}{
|
list = append(list, map[string]interface{}{
|
||||||
"id": item.ID,
|
"id": item.ID,
|
||||||
|
"cid": item.Cid,
|
||||||
"name": item.Name,
|
"name": item.Name,
|
||||||
"desc": item.Desc,
|
"desc": item.Desc,
|
||||||
"image": item.Image,
|
"image": item.Image,
|
||||||
@@ -390,3 +397,89 @@ func (c *IndexWebsiteController) NewsView() {
|
|||||||
}
|
}
|
||||||
c.ok(nil)
|
c.ok(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 站点公告 yz_platform_website_notice
|
||||||
|
//
|
||||||
|
// GET /site/notices 公告列表(?limit=6&popup=1 只取弹窗公告)
|
||||||
|
// GET /site/notices/:id 公告详情(正文为 HTML,浏览量 +1)
|
||||||
|
//
|
||||||
|
// 仅返回「已发布」且当前时间处于有效期内的公告,条件见 noticeVisibleCond。
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
// noticeListItem 公告列表项(不含正文,减小响应体)
|
||||||
|
func noticeListItem(r models.PlatformWebsiteNotice) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"id": r.ID,
|
||||||
|
"title": r.Title,
|
||||||
|
"summary": r.Summary,
|
||||||
|
"type": r.Type,
|
||||||
|
"link_url": r.LinkURL,
|
||||||
|
"is_popup": r.IsPopup,
|
||||||
|
"top": r.Top,
|
||||||
|
"date": r.PublishTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notices GET /site/notices?limit=&popup=
|
||||||
|
func (c *IndexWebsiteController) Notices() {
|
||||||
|
limit, _ := c.GetInt("limit", 6)
|
||||||
|
if limit < 1 {
|
||||||
|
limit = 6
|
||||||
|
}
|
||||||
|
if limit > 50 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
cond := noticeVisibleCond()
|
||||||
|
// popup 传 1 / true 时只取弹窗公告(首页弹窗、侧栏公告用)
|
||||||
|
if pop := strings.TrimSpace(c.GetString("popup")); pop != "" && pop != "0" {
|
||||||
|
cond = cond.And("is_popup", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []models.PlatformWebsiteNotice
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.PlatformWebsiteNotice)).
|
||||||
|
SetCond(cond).
|
||||||
|
OrderBy("-top", "sort", "-publish_time", "-id").
|
||||||
|
Limit(limit).
|
||||||
|
All(&rows, "ID", "Title", "Summary", "Type", "LinkURL", "IsPopup", "Top",
|
||||||
|
"Sort", "PublishTime", "CreateTime"); err != nil {
|
||||||
|
c.fail(500, 500, "获取公告失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]map[string]interface{}, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
list = append(list, noticeListItem(r))
|
||||||
|
}
|
||||||
|
c.ok(map[string]interface{}{"list": list, "total": len(list)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoticeDetail GET /site/notices/:id
|
||||||
|
func (c *IndexWebsiteController) NoticeDetail() {
|
||||||
|
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.fail(400, 400, "无效的公告ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var row models.PlatformWebsiteNotice
|
||||||
|
if err = models.Orm.QueryTable(new(models.PlatformWebsiteNotice)).
|
||||||
|
SetCond(noticeVisibleCond()).
|
||||||
|
Filter("id", id).
|
||||||
|
One(&row); err != nil {
|
||||||
|
c.fail(404, 404, "公告不存在或已下线")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 浏览量 +1,失败不影响正文展示
|
||||||
|
_, _ = models.Orm.Raw(
|
||||||
|
"UPDATE yz_platform_website_notice SET views = views + 1 WHERE id = ?",
|
||||||
|
id,
|
||||||
|
).Exec()
|
||||||
|
|
||||||
|
item := noticeListItem(row)
|
||||||
|
item["content"] = row.Content
|
||||||
|
item["views"] = row.Views + 1
|
||||||
|
c.ok(item)
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,9 +62,9 @@ func websiteID(c *beego.Controller) (uint64, bool) {
|
|||||||
return id, true
|
return id, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// websiteStatusParam 解析 status 查询参数,未传时返回 -1(表示不过滤)
|
// websiteIntParam 解析整型查询参数,未传或非法时返回 -1(表示不过滤)
|
||||||
func websiteStatusParam(c *beego.Controller) int {
|
func websiteIntParam(c *beego.Controller, key string) int {
|
||||||
raw := strings.TrimSpace(c.GetString("status"))
|
raw := strings.TrimSpace(c.GetString(key))
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
@@ -75,6 +75,11 @@ func websiteStatusParam(c *beego.Controller) int {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// websiteStatusParam 解析 status 查询参数,未传时返回 -1(表示不过滤)
|
||||||
|
func websiteStatusParam(c *beego.Controller) int {
|
||||||
|
return websiteIntParam(c, "status")
|
||||||
|
}
|
||||||
|
|
||||||
// websiteBind 解析 JSON 请求体到 target,失败时已写出 400
|
// websiteBind 解析 JSON 请求体到 target,失败时已写出 400
|
||||||
func websiteBind(c *beego.Controller, target interface{}) bool {
|
func websiteBind(c *beego.Controller, target interface{}) bool {
|
||||||
body, err := io.ReadAll(c.Ctx.Request.Body)
|
body, err := io.ReadAll(c.Ctx.Request.Body)
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
"server/pkg/jwtutil"
|
||||||
|
|
||||||
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
|
"github.com/beego/beego/v2/client/orm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 平台官网 - 站点公告 /platform/website/notice/*
|
||||||
|
// 表:yz_platform_website_notice
|
||||||
|
// 建表 SQL:docs/sql/create_platform_website_notice.sql
|
||||||
|
//
|
||||||
|
// 公告状态:0 草稿 / 1 已发布 / 2 已下线。
|
||||||
|
// 前台 GET /site/notices 只返回「已发布」且当前时间处于有效期内的公告,
|
||||||
|
// 见 index_website.go 的 noticeVisibleCond。
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
const (
|
||||||
|
noticeStatusDraft int8 = 0
|
||||||
|
noticeStatusPublished int8 = 1
|
||||||
|
noticeStatusOffline int8 = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
type PlatformWebsiteNoticeController struct {
|
||||||
|
beego.Controller
|
||||||
|
}
|
||||||
|
|
||||||
|
type platformWebsiteNoticePayload struct {
|
||||||
|
Title *string `json:"title"`
|
||||||
|
Summary *string `json:"summary"`
|
||||||
|
Content *string `json:"content"`
|
||||||
|
Type *int8 `json:"type"`
|
||||||
|
LinkURL *string `json:"link_url"`
|
||||||
|
IsPopup *int8 `json:"is_popup"`
|
||||||
|
Top *int8 `json:"top"`
|
||||||
|
Sort *int `json:"sort"`
|
||||||
|
Status *int8 `json:"status"`
|
||||||
|
StartTime *string `json:"start_time"`
|
||||||
|
EndTime *string `json:"end_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseNoticeTime 解析前端时间字符串,支持 "2006-01-02 15:04:05"、RFC3339、"2006-01-02";
|
||||||
|
// 空串或无法识别时返回 nil(对应数据库 NULL,表示不限制时间)。
|
||||||
|
func parseNoticeTime(raw *string) *time.Time {
|
||||||
|
if raw == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := strings.TrimSpace(*raw)
|
||||||
|
if v == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339, "2006-01-02"} {
|
||||||
|
if t, err := time.ParseInLocation(layout, v, time.Local); err == nil {
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// noticeVisibleCond 前台可见条件:未删除 + 已发布 + 当前时间落在 [start_time, end_time] 内
|
||||||
|
// start_time / end_time 为 NULL 表示该侧不限制。
|
||||||
|
func noticeVisibleCond() *orm.Condition {
|
||||||
|
now := time.Now()
|
||||||
|
return orm.NewCondition().
|
||||||
|
And("delete_time__isnull", true).
|
||||||
|
And("status", noticeStatusPublished).
|
||||||
|
AndCond(orm.NewCondition().Or("start_time__isnull", true).Or("start_time__lte", now)).
|
||||||
|
AndCond(orm.NewCondition().Or("end_time__isnull", true).Or("end_time__gte", now))
|
||||||
|
}
|
||||||
|
|
||||||
|
// noticeFromPayload 依据请求体构造新公告,缺省:类型=通知公告、状态=草稿
|
||||||
|
func noticeFromPayload(p platformWebsiteNoticePayload) models.PlatformWebsiteNotice {
|
||||||
|
row := models.PlatformWebsiteNotice{
|
||||||
|
Type: 1,
|
||||||
|
Status: noticeStatusDraft,
|
||||||
|
}
|
||||||
|
if p.Title != nil {
|
||||||
|
row.Title = strings.TrimSpace(*p.Title)
|
||||||
|
}
|
||||||
|
if p.Summary != nil {
|
||||||
|
row.Summary = strings.TrimSpace(*p.Summary)
|
||||||
|
}
|
||||||
|
if p.Content != nil {
|
||||||
|
row.Content = *p.Content
|
||||||
|
}
|
||||||
|
if p.Type != nil {
|
||||||
|
row.Type = *p.Type
|
||||||
|
}
|
||||||
|
if p.LinkURL != nil {
|
||||||
|
row.LinkURL = strings.TrimSpace(*p.LinkURL)
|
||||||
|
}
|
||||||
|
if p.IsPopup != nil {
|
||||||
|
row.IsPopup = *p.IsPopup
|
||||||
|
}
|
||||||
|
if p.Top != nil {
|
||||||
|
row.Top = *p.Top
|
||||||
|
}
|
||||||
|
if p.Sort != nil {
|
||||||
|
row.Sort = *p.Sort
|
||||||
|
}
|
||||||
|
if p.Status != nil {
|
||||||
|
row.Status = *p.Status
|
||||||
|
}
|
||||||
|
row.StartTime = parseNoticeTime(p.StartTime)
|
||||||
|
row.EndTime = parseNoticeTime(p.EndTime)
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
// noticeUpdateMap 把请求体转成待更新字段,只包含显式传入的键(未传的字段保持原值)
|
||||||
|
func noticeUpdateMap(p platformWebsiteNoticePayload) map[string]interface{} {
|
||||||
|
up := map[string]interface{}{}
|
||||||
|
if p.Title != nil {
|
||||||
|
up["title"] = strings.TrimSpace(*p.Title)
|
||||||
|
}
|
||||||
|
if p.Summary != nil {
|
||||||
|
up["summary"] = strings.TrimSpace(*p.Summary)
|
||||||
|
}
|
||||||
|
if p.Content != nil {
|
||||||
|
up["content"] = *p.Content
|
||||||
|
}
|
||||||
|
if p.Type != nil {
|
||||||
|
up["type"] = *p.Type
|
||||||
|
}
|
||||||
|
if p.LinkURL != nil {
|
||||||
|
up["link_url"] = strings.TrimSpace(*p.LinkURL)
|
||||||
|
}
|
||||||
|
if p.IsPopup != nil {
|
||||||
|
up["is_popup"] = *p.IsPopup
|
||||||
|
}
|
||||||
|
if p.Top != nil {
|
||||||
|
up["top"] = *p.Top
|
||||||
|
}
|
||||||
|
if p.Sort != nil {
|
||||||
|
up["sort"] = *p.Sort
|
||||||
|
}
|
||||||
|
if p.Status != nil {
|
||||||
|
up["status"] = *p.Status
|
||||||
|
}
|
||||||
|
if p.StartTime != nil {
|
||||||
|
up["start_time"] = parseNoticeTime(p.StartTime)
|
||||||
|
}
|
||||||
|
if p.EndTime != nil {
|
||||||
|
up["end_time"] = parseNoticeTime(p.EndTime)
|
||||||
|
}
|
||||||
|
return up
|
||||||
|
}
|
||||||
|
|
||||||
|
// List GET /platform/website/notice/list
|
||||||
|
// query: keyword(标题/摘要)、type、status、is_popup、page、pageSize
|
||||||
|
func (c *PlatformWebsiteNoticeController) List() {
|
||||||
|
if _, ok := websiteAuth(&c.Controller); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page, pageSize := websitePaging(&c.Controller)
|
||||||
|
|
||||||
|
cond := orm.NewCondition().And("delete_time__isnull", true)
|
||||||
|
if kw := strings.TrimSpace(c.GetString("keyword")); kw != "" {
|
||||||
|
cond = cond.AndCond(orm.NewCondition().
|
||||||
|
Or("title__icontains", kw).
|
||||||
|
Or("summary__icontains", kw))
|
||||||
|
}
|
||||||
|
if tp := websiteIntParam(&c.Controller, "type"); tp >= 0 {
|
||||||
|
cond = cond.And("type", tp)
|
||||||
|
}
|
||||||
|
if st := websiteStatusParam(&c.Controller); st >= 0 {
|
||||||
|
cond = cond.And("status", st)
|
||||||
|
}
|
||||||
|
if pop := websiteIntParam(&c.Controller, "is_popup"); pop >= 0 {
|
||||||
|
cond = cond.And("is_popup", pop)
|
||||||
|
}
|
||||||
|
|
||||||
|
qs := models.Orm.QueryTable(new(models.PlatformWebsiteNotice)).SetCond(cond)
|
||||||
|
total, err := qs.Count()
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 列表不返回富文本正文,减小响应体
|
||||||
|
var rows []models.PlatformWebsiteNotice
|
||||||
|
if _, err = qs.OrderBy("-top", "sort", "-id").
|
||||||
|
Limit(pageSize, (page-1)*pageSize).
|
||||||
|
All(&rows, "ID", "Title", "Summary", "Type", "LinkURL", "IsPopup", "Top",
|
||||||
|
"Sort", "Status", "StartTime", "EndTime", "Views",
|
||||||
|
"PublishTime", "CreateTime", "UpdateTime"); err != nil {
|
||||||
|
jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
websiteOK(&c.Controller, map[string]interface{}{"list": rows, "total": total})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get GET /platform/website/notice/:id — 含正文,供编辑回显
|
||||||
|
func (c *PlatformWebsiteNoticeController) Get() {
|
||||||
|
if _, ok := websiteAuth(&c.Controller); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := websiteID(&c.Controller)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var row models.PlatformWebsiteNotice
|
||||||
|
if err := models.Orm.QueryTable(new(models.PlatformWebsiteNotice)).
|
||||||
|
Filter("id", id).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
One(&row); err != nil {
|
||||||
|
jsonErr(&c.Controller, 404, 404, "公告不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
websiteOK(&c.Controller, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create POST /platform/website/notice
|
||||||
|
func (c *PlatformWebsiteNoticeController) Create() {
|
||||||
|
claims, ok := websiteAuth(&c.Controller)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p platformWebsiteNoticePayload
|
||||||
|
if !websiteBind(&c.Controller, &p) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.Title == nil || strings.TrimSpace(*p.Title) == "" {
|
||||||
|
jsonErr(&c.Controller, 400, 400, "公告标题不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
row := noticeFromPayload(p)
|
||||||
|
// 直接提交为已发布时同步补发布时间与发布人
|
||||||
|
if row.Status == noticeStatusPublished {
|
||||||
|
now := time.Now()
|
||||||
|
uid := uint64(claims.UserID)
|
||||||
|
row.PublishTime = &now
|
||||||
|
row.PublisherID = &uid
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := models.Orm.Insert(&row)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
websiteOK(&c.Controller, map[string]interface{}{"id": id})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update POST /platform/website/notice/:id
|
||||||
|
func (c *PlatformWebsiteNoticeController) Update() {
|
||||||
|
claims, ok := websiteAuth(&c.Controller)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := websiteID(&c.Controller)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p platformWebsiteNoticePayload
|
||||||
|
if !websiteBind(&c.Controller, &p) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.Title != nil && strings.TrimSpace(*p.Title) == "" {
|
||||||
|
jsonErr(&c.Controller, 400, 400, "公告标题不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先取原记录:既用于 404 判断,也用于判断是否「首次转为已发布」
|
||||||
|
var row models.PlatformWebsiteNotice
|
||||||
|
if err := models.Orm.QueryTable(new(models.PlatformWebsiteNotice)).
|
||||||
|
Filter("id", id).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
One(&row, "ID", "Status"); err != nil {
|
||||||
|
jsonErr(&c.Controller, 404, 404, "公告不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
up := noticeUpdateMap(p)
|
||||||
|
if len(up) == 0 {
|
||||||
|
jsonErr(&c.Controller, 400, 400, "无更新字段")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.Status != nil && *p.Status == noticeStatusPublished && row.Status != noticeStatusPublished {
|
||||||
|
// 首次发布才写发布时间,避免每次保存覆盖发布时间
|
||||||
|
now := time.Now()
|
||||||
|
uid := uint64(claims.UserID)
|
||||||
|
up["publish_time"] = now
|
||||||
|
up["publisher_id"] = uid
|
||||||
|
}
|
||||||
|
up["update_time"] = time.Now()
|
||||||
|
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.PlatformWebsiteNotice)).
|
||||||
|
Filter("id", id).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Update(up); err != nil {
|
||||||
|
jsonErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
websiteOK(&c.Controller, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// noticeToggle 更新单个开关字段(status / top / is_popup)
|
||||||
|
func (c *PlatformWebsiteNoticeController) noticeToggle(claims *jwtutil.Claims, field string, value int8) {
|
||||||
|
id, ok := websiteID(&c.Controller)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
up := map[string]interface{}{field: value, "update_time": time.Now()}
|
||||||
|
// 发布时补发布时间与发布人(其余开关无需登录身份,claims 可能为 nil)
|
||||||
|
if field == "status" && value == noticeStatusPublished {
|
||||||
|
up["publish_time"] = time.Now()
|
||||||
|
if claims != nil {
|
||||||
|
up["publisher_id"] = uint64(claims.UserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n, err := models.Orm.QueryTable(new(models.PlatformWebsiteNotice)).
|
||||||
|
Filter("id", id).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Update(up)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(&c.Controller, 500, 500, "操作失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
jsonErr(&c.Controller, 404, 404, "公告不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
websiteOK(&c.Controller, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish POST /platform/website/notice/:id/publish — 状态置为已发布
|
||||||
|
func (c *PlatformWebsiteNoticeController) Publish() {
|
||||||
|
claims, ok := websiteAuth(&c.Controller)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.noticeToggle(claims, "status", noticeStatusPublished)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnPublish POST /platform/website/notice/:id/unpublish — 状态置为已下线
|
||||||
|
func (c *PlatformWebsiteNoticeController) UnPublish() {
|
||||||
|
if _, ok := websiteAuth(&c.Controller); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.noticeToggle(nil, "status", noticeStatusOffline)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top POST /platform/website/notice/:id/top
|
||||||
|
func (c *PlatformWebsiteNoticeController) Top() {
|
||||||
|
if _, ok := websiteAuth(&c.Controller); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.noticeToggle(nil, "top", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnTop POST /platform/website/notice/:id/untop
|
||||||
|
func (c *PlatformWebsiteNoticeController) UnTop() {
|
||||||
|
if _, ok := websiteAuth(&c.Controller); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.noticeToggle(nil, "top", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Popup POST /platform/website/notice/:id/popup — 标记为弹窗公告
|
||||||
|
func (c *PlatformWebsiteNoticeController) Popup() {
|
||||||
|
if _, ok := websiteAuth(&c.Controller); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.noticeToggle(nil, "is_popup", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnPopup POST /platform/website/notice/:id/unpopup
|
||||||
|
func (c *PlatformWebsiteNoticeController) UnPopup() {
|
||||||
|
if _, ok := websiteAuth(&c.Controller); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.noticeToggle(nil, "is_popup", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete DELETE /platform/website/notice/:id — 软删除
|
||||||
|
func (c *PlatformWebsiteNoticeController) Delete() {
|
||||||
|
if _, ok := websiteAuth(&c.Controller); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := websiteID(&c.Controller)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
n, err := models.Orm.QueryTable(new(models.PlatformWebsiteNotice)).
|
||||||
|
Filter("id", id).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Update(map[string]interface{}{"delete_time": now, "update_time": now})
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
jsonErr(&c.Controller, 404, 404, "公告不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
websiteOK(&c.Controller, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchDelete POST /platform/website/notice/batchdelete — body { ids: [] }
|
||||||
|
func (c *PlatformWebsiteNoticeController) BatchDelete() {
|
||||||
|
if _, ok := websiteAuth(&c.Controller); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p struct {
|
||||||
|
IDs []uint64 `json:"ids"`
|
||||||
|
}
|
||||||
|
if !websiteBind(&c.Controller, &p) || len(p.IDs) == 0 {
|
||||||
|
jsonErr(&c.Controller, 400, 400, "请选择要删除的公告")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.PlatformWebsiteNotice)).
|
||||||
|
Filter("id__in", p.IDs).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Update(map[string]interface{}{"delete_time": now, "update_time": now}); err != nil {
|
||||||
|
jsonErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
websiteOK(&c.Controller, nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- 平台官网 - 站点公告
|
||||||
|
-- 表:yz_platform_website_notice
|
||||||
|
--
|
||||||
|
-- 用途:平台端「官网管理 → 内容管理 → 站点公告」维护官网的通知 / 活动 /
|
||||||
|
-- 系统维护类公告;前台经 GET /site/notices 读取(仅返回「已发布」
|
||||||
|
-- 且处于有效期内的公告)。
|
||||||
|
--
|
||||||
|
-- 执行方式:人工在数据库执行(代码中不做任何自动建表 / 补列)。
|
||||||
|
-- 说明:下方 DDL 幂等,可重复执行。
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yz_platform_website_notice` (
|
||||||
|
`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
|
`title` varchar(255) NOT NULL DEFAULT '' COMMENT '公告标题',
|
||||||
|
`summary` varchar(500) NOT NULL DEFAULT '' COMMENT '公告摘要(前台卡片展示,留空则前台截取正文)',
|
||||||
|
`content` mediumtext COMMENT '公告正文(富文本 HTML)',
|
||||||
|
`type` tinyint NOT NULL DEFAULT 1 COMMENT '公告类型:1 通知公告 2 活动公告 3 系统维护',
|
||||||
|
`link_url` varchar(500) NOT NULL DEFAULT '' COMMENT '点击跳转地址,站内相对路径或完整 URL;留空表示不可点击',
|
||||||
|
`is_popup` tinyint NOT NULL DEFAULT 0 COMMENT '是否作为弹窗/侧栏公告:0 否 1 是',
|
||||||
|
`top` tinyint NOT NULL DEFAULT 0 COMMENT '是否置顶:0 否 1 是',
|
||||||
|
`sort` int NOT NULL DEFAULT 0 COMMENT '排序,数字越小越靠前',
|
||||||
|
`status` tinyint NOT NULL DEFAULT 0 COMMENT '状态:0 草稿 1 已发布 2 已下线',
|
||||||
|
`start_time` datetime DEFAULT NULL COMMENT '生效开始时间,NULL 表示立即生效',
|
||||||
|
`end_time` datetime DEFAULT NULL COMMENT '生效结束时间,NULL 表示永久有效',
|
||||||
|
`views` int NOT NULL DEFAULT 0 COMMENT '浏览量',
|
||||||
|
`publisher_id` bigint UNSIGNED DEFAULT NULL COMMENT '发布人(平台用户ID)',
|
||||||
|
`publish_time` datetime DEFAULT NULL COMMENT '发布时间',
|
||||||
|
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||||
|
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_status_sort` (`status`, `sort`, `id`),
|
||||||
|
KEY `idx_popup` (`is_popup`, `status`),
|
||||||
|
KEY `idx_publish_time` (`publish_time`)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '平台官网-站点公告';
|
||||||
|
|
||||||
|
|
||||||
|
-- =============================================================
|
||||||
|
-- 平台菜单:把「站点公告」挂到「官网管理 → 内容管理」下
|
||||||
|
--
|
||||||
|
-- 菜单数据统一维护在 yz_system_menu(平台端 cid=1 的角色可见)。
|
||||||
|
-- 父级不用写死自增ID,按现有「内容管理」的子菜单反查,可在任意环境重复执行。
|
||||||
|
-- 若本次不需要自动挂菜单,可只执行上面的建表语句,手动在
|
||||||
|
-- 「系统管理 → 菜单管理」里添加:路径 /website/content/notice,
|
||||||
|
-- 组件 /website/content/notice/index.vue
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- 父级定位一:取现有「内容管理」子菜单(文章管理)的 pid
|
||||||
|
SET @notice_pid := (
|
||||||
|
SELECT `pid` FROM `yz_system_menu`
|
||||||
|
WHERE `delete_time` IS NULL AND `component_path` LIKE '/website/content/%'
|
||||||
|
ORDER BY `id` ASC LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 父级定位二:兜底按「内容管理」的路径或名称查
|
||||||
|
SET @notice_pid := IFNULL(@notice_pid, (
|
||||||
|
SELECT `id` FROM `yz_system_menu`
|
||||||
|
WHERE `delete_time` IS NULL
|
||||||
|
AND (`path` = '/website/content' OR `title` = '内容管理')
|
||||||
|
ORDER BY `id` ASC LIMIT 1
|
||||||
|
));
|
||||||
|
|
||||||
|
-- 已存在同路径菜单时跳过,避免重复插入
|
||||||
|
INSERT INTO `yz_system_menu`
|
||||||
|
(`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `creater`, `remark`, `create_time`)
|
||||||
|
SELECT
|
||||||
|
@notice_pid, '站点公告', '/website/content/notice', '/website/content/notice/index.vue',
|
||||||
|
'Bell', 50, 1, 1, '[1]', 2, 'sql', '官网管理 - 站点公告', NOW()
|
||||||
|
FROM (SELECT 1) AS `t`
|
||||||
|
WHERE @notice_pid IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM (
|
||||||
|
SELECT `id` FROM `yz_system_menu`
|
||||||
|
WHERE `delete_time` IS NULL AND `path` = '/website/content/notice'
|
||||||
|
LIMIT 1
|
||||||
|
) AS `exist`
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- =============================================================
|
||||||
|
-- 可选:一条示例公告,便于前台联调(不需要可整段跳过)
|
||||||
|
-- =============================================================
|
||||||
|
INSERT INTO `yz_platform_website_notice`
|
||||||
|
(`title`, `summary`, `content`, `type`, `is_popup`, `top`, `sort`, `status`, `publish_time`)
|
||||||
|
VALUES
|
||||||
|
('云泽科技官网全新改版上线', '本次改版聚焦信息架构与访问体验,欢迎反馈建议。',
|
||||||
|
'<p>云泽科技官网全新改版上线,本次改版聚焦信息架构与访问体验。</p>', 1, 1, 1, 0, 1, NOW());
|
||||||
@@ -129,6 +129,8 @@ func Init(_ string) {
|
|||||||
new(PlatformWebsiteNav),
|
new(PlatformWebsiteNav),
|
||||||
new(PlatformWebsiteLink),
|
new(PlatformWebsiteLink),
|
||||||
new(PlatformWebsitePage),
|
new(PlatformWebsitePage),
|
||||||
|
// 站点公告(docs/sql/create_platform_website_notice.sql)
|
||||||
|
new(PlatformWebsiteNotice),
|
||||||
|
|
||||||
new(SystemReminderList),
|
new(SystemReminderList),
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,33 @@ func (m *PlatformWebsitePage) TableName() string {
|
|||||||
return "yz_platform_website_page"
|
return "yz_platform_website_page"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PlatformWebsiteNotice 平台官网-站点公告 yz_platform_website_notice
|
||||||
|
// 建表 SQL:docs/sql/create_platform_website_notice.sql
|
||||||
|
type PlatformWebsiteNotice struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
|
Title string `orm:"column(title);size(255);default('')" json:"title"`
|
||||||
|
Summary string `orm:"column(summary);size(500);default('')" json:"summary"`
|
||||||
|
Content string `orm:"column(content);type(mediumtext);null" json:"content"`
|
||||||
|
Type int8 `orm:"column(type);default(1)" json:"type"` // 1 通知公告 2 活动公告 3 系统维护
|
||||||
|
LinkURL string `orm:"column(link_url);size(500);default('')" json:"link_url"`
|
||||||
|
IsPopup int8 `orm:"column(is_popup);default(0)" json:"is_popup"` // 是否作为弹窗公告
|
||||||
|
Top int8 `orm:"column(top);default(0)" json:"top"` // 是否置顶
|
||||||
|
Sort int `orm:"column(sort);default(0)" json:"sort"`
|
||||||
|
Status int8 `orm:"column(status);default(0)" json:"status"` // 0 草稿 1 已发布 2 已下线
|
||||||
|
StartTime *time.Time `orm:"column(start_time);type(datetime);null" json:"start_time"`
|
||||||
|
EndTime *time.Time `orm:"column(end_time);type(datetime);null" json:"end_time"`
|
||||||
|
Views int `orm:"column(views);default(0)" json:"views"`
|
||||||
|
PublisherID *uint64 `orm:"column(publisher_id);null" json:"publisher_id"`
|
||||||
|
PublishTime *time.Time `orm:"column(publish_time);type(datetime);null" json:"publish_time"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
|
||||||
|
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
|
||||||
|
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *PlatformWebsiteNotice) TableName() string {
|
||||||
|
return "yz_platform_website_notice"
|
||||||
|
}
|
||||||
|
|
||||||
// ===================== 公共查询helper =====================
|
// ===================== 公共查询helper =====================
|
||||||
|
|
||||||
// PlatformWebsiteCategoryNameMap 分类ID -> 分类名称
|
// PlatformWebsiteCategoryNameMap 分类ID -> 分类名称
|
||||||
@@ -199,6 +226,50 @@ func PlatformWebsiteCategoryMap(ids []uint64) map[uint64]PlatformWebsiteCategory
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PlatformWebsiteCategoryDescendantMap 分类ID -> 全部后代分类ID(不含自身)
|
||||||
|
//
|
||||||
|
// 官网前台按父分类筛选 / 统计数量时,需要把整棵子树都算进来:
|
||||||
|
// 文章挂在子分类上时,点父分类也应该能看到。
|
||||||
|
// 分类数据量很小,每次整体构建一次即可;存在环时靠 seen 截断。
|
||||||
|
func PlatformWebsiteCategoryDescendantMap() map[uint64][]uint64 {
|
||||||
|
out := make(map[uint64][]uint64)
|
||||||
|
|
||||||
|
var rows []PlatformWebsiteCategory
|
||||||
|
if _, err := Orm.QueryTable(new(PlatformWebsiteCategory)).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
All(&rows, "ID", "Cid"); err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
children := make(map[uint64][]uint64, len(rows))
|
||||||
|
ids := make([]uint64, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
ids = append(ids, r.ID)
|
||||||
|
if r.Cid > 0 && r.Cid != r.ID {
|
||||||
|
children[r.Cid] = append(children[r.Cid], r.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var walk func(id uint64, seen map[uint64]bool) []uint64
|
||||||
|
walk = func(id uint64, seen map[uint64]bool) []uint64 {
|
||||||
|
res := make([]uint64, 0, len(children[id]))
|
||||||
|
for _, child := range children[id] {
|
||||||
|
if seen[child] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[child] = true
|
||||||
|
res = append(res, child)
|
||||||
|
res = append(res, walk(child, seen)...)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range ids {
|
||||||
|
out[id] = walk(id, map[uint64]bool{id: true})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// PlatformWebsiteArticleTitleMap 文章ID -> 标题(评论列表展示所属文章用)
|
// PlatformWebsiteArticleTitleMap 文章ID -> 标题(评论列表展示所属文章用)
|
||||||
func PlatformWebsiteArticleTitleMap(ids []uint64) map[uint64]string {
|
func PlatformWebsiteArticleTitleMap(ids []uint64) map[uint64]string {
|
||||||
out := make(map[uint64]string)
|
out := make(map[uint64]string)
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ func Register() {
|
|||||||
beego.Router("/site/news/:id/view", &controllers.IndexWebsiteController{}, "post:NewsView")
|
beego.Router("/site/news/:id/view", &controllers.IndexWebsiteController{}, "post:NewsView")
|
||||||
beego.Router("/site/news/:id", &controllers.IndexWebsiteController{}, "get:NewsDetail")
|
beego.Router("/site/news/:id", &controllers.IndexWebsiteController{}, "get:NewsDetail")
|
||||||
|
|
||||||
|
// 官网前台站点公告(平台「官网管理 - 站点公告」配置,仅返回已发布且在有效期内的)
|
||||||
|
beego.Router("/site/notices", &controllers.IndexWebsiteController{}, "get:Notices")
|
||||||
|
beego.Router("/site/notices/:id", &controllers.IndexWebsiteController{}, "get:NoticeDetail")
|
||||||
|
|
||||||
beego.Router("/index/headmenu", &controllers.IndexPortalController{}, "get:GetHeadMenu")
|
beego.Router("/index/headmenu", &controllers.IndexPortalController{}, "get:GetHeadMenu")
|
||||||
beego.Router("/index/footerdata", &controllers.IndexPortalController{}, "get:GetFooterData")
|
beego.Router("/index/footerdata", &controllers.IndexPortalController{}, "get:GetFooterData")
|
||||||
beego.Router("/index/newscentertop4", &controllers.IndexPortalController{}, "get:GetNewsCenterTop4")
|
beego.Router("/index/newscentertop4", &controllers.IndexPortalController{}, "get:GetNewsCenterTop4")
|
||||||
|
|||||||
@@ -402,4 +402,17 @@ func Register() {
|
|||||||
beego.Router("/platform/website/page/all", &controllers.PlatformWebsitePageController{}, "get:All")
|
beego.Router("/platform/website/page/all", &controllers.PlatformWebsitePageController{}, "get:All")
|
||||||
beego.Router("/platform/website/page", &controllers.PlatformWebsitePageController{}, "post:Create")
|
beego.Router("/platform/website/page", &controllers.PlatformWebsitePageController{}, "post:Create")
|
||||||
beego.Router("/platform/website/page/:id", &controllers.PlatformWebsitePageController{}, "get:Get;post:Update;delete:Delete")
|
beego.Router("/platform/website/page/:id", &controllers.PlatformWebsitePageController{}, "get:Get;post:Update;delete:Delete")
|
||||||
|
|
||||||
|
// 站点公告 yz_platform_website_notice
|
||||||
|
// 注意:静态段(list / batchdelete)必须在 :id 之前注册
|
||||||
|
beego.Router("/platform/website/notice/list", &controllers.PlatformWebsiteNoticeController{}, "get:List")
|
||||||
|
beego.Router("/platform/website/notice/batchdelete", &controllers.PlatformWebsiteNoticeController{}, "post:BatchDelete")
|
||||||
|
beego.Router("/platform/website/notice", &controllers.PlatformWebsiteNoticeController{}, "post:Create")
|
||||||
|
beego.Router("/platform/website/notice/:id/publish", &controllers.PlatformWebsiteNoticeController{}, "post:Publish")
|
||||||
|
beego.Router("/platform/website/notice/:id/unpublish", &controllers.PlatformWebsiteNoticeController{}, "post:UnPublish")
|
||||||
|
beego.Router("/platform/website/notice/:id/top", &controllers.PlatformWebsiteNoticeController{}, "post:Top")
|
||||||
|
beego.Router("/platform/website/notice/:id/untop", &controllers.PlatformWebsiteNoticeController{}, "post:UnTop")
|
||||||
|
beego.Router("/platform/website/notice/:id/popup", &controllers.PlatformWebsiteNoticeController{}, "post:Popup")
|
||||||
|
beego.Router("/platform/website/notice/:id/unpopup", &controllers.PlatformWebsiteNoticeController{}, "post:UnPopup")
|
||||||
|
beego.Router("/platform/website/notice/:id", &controllers.PlatformWebsiteNoticeController{}, "get:Get;post:Update;delete:Delete")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
// ==================== 官网管理 - 站点公告(平台端) ====================
|
||||||
|
// 数据表:yz_platform_website_notice
|
||||||
|
// 建表 SQL:go/docs/sql/create_platform_website_notice.sql
|
||||||
|
//
|
||||||
|
// 状态约定:0 草稿 / 1 已发布 / 2 已下线
|
||||||
|
// 类型约定:1 通知公告 / 2 活动公告 / 3 系统维护
|
||||||
|
|
||||||
|
// 公告列表(分页):keyword / type / status / is_popup / page / pageSize
|
||||||
|
export function listNotices(params) {
|
||||||
|
return request({
|
||||||
|
url: "/platform/website/notice/list",
|
||||||
|
method: "get",
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公告详情(含富文本正文,供编辑回显)
|
||||||
|
export function getNotice(id) {
|
||||||
|
return request({
|
||||||
|
url: `/platform/website/notice/${id}`,
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增公告
|
||||||
|
export function createNotice(data) {
|
||||||
|
return request({
|
||||||
|
url: "/platform/website/notice",
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑公告
|
||||||
|
export function editNotice(id, data) {
|
||||||
|
return request({
|
||||||
|
url: `/platform/website/notice/${id}`,
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除公告(软删除)
|
||||||
|
export function deleteNotice(id) {
|
||||||
|
return request({
|
||||||
|
url: `/platform/website/notice/${id}`,
|
||||||
|
method: "delete",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量删除公告
|
||||||
|
export function batchDeleteNotices(ids) {
|
||||||
|
return request({
|
||||||
|
url: "/platform/website/notice/batchdelete",
|
||||||
|
method: "post",
|
||||||
|
data: { ids },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发布 / 下线(status 1 / 2)
|
||||||
|
export function publishNotice(id) {
|
||||||
|
return request({
|
||||||
|
url: `/platform/website/notice/${id}/publish`,
|
||||||
|
method: "post",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unpublishNotice(id) {
|
||||||
|
return request({
|
||||||
|
url: `/platform/website/notice/${id}/unpublish`,
|
||||||
|
method: "post",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 置顶 / 取消置顶
|
||||||
|
export function topNotice(id) {
|
||||||
|
return request({
|
||||||
|
url: `/platform/website/notice/${id}/top`,
|
||||||
|
method: "post",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function untopNotice(id) {
|
||||||
|
return request({
|
||||||
|
url: `/platform/website/notice/${id}/untop`,
|
||||||
|
method: "post",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记为弹窗公告 / 取消弹窗
|
||||||
|
export function popupNotice(id) {
|
||||||
|
return request({
|
||||||
|
url: `/platform/website/notice/${id}/popup`,
|
||||||
|
method: "post",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unpopupNotice(id) {
|
||||||
|
return request({
|
||||||
|
url: `/platform/website/notice/${id}/unpopup`,
|
||||||
|
method: "post",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
<template>
|
||||||
|
<el-drawer
|
||||||
|
v-model="visible"
|
||||||
|
:title="isEdit ? '编辑公告' : '新增公告'"
|
||||||
|
size="780px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
destroy-on-close
|
||||||
|
@closed="handleClosed"
|
||||||
|
>
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||||
|
<el-form-item label="公告标题" prop="title">
|
||||||
|
<el-input
|
||||||
|
v-model="form.title"
|
||||||
|
placeholder="请输入公告标题"
|
||||||
|
maxlength="255"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="公告类型" prop="type">
|
||||||
|
<el-select v-model="form.type" style="width: 200px">
|
||||||
|
<el-option
|
||||||
|
v-for="item in TYPE_OPTIONS"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="公告摘要" prop="summary">
|
||||||
|
<el-input
|
||||||
|
v-model="form.summary"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="列表卡片展示的摘要,留空则前台展示正文开头"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="公告正文">
|
||||||
|
<div class="editor-container">
|
||||||
|
<UmoEditor v-model="form.content" :cate="materialCateId" />
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="跳转地址" prop="link_url">
|
||||||
|
<el-input
|
||||||
|
v-model="form.link_url"
|
||||||
|
placeholder="点击公告后跳转的地址,站内填 /news,站外填完整 URL;留空表示不可点击"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="生效时间" prop="start_time">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.start_time"
|
||||||
|
type="datetime"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
placeholder="留空表示立即生效"
|
||||||
|
clearable
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="失效时间" prop="end_time">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.end_time"
|
||||||
|
type="datetime"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
placeholder="留空表示长期有效"
|
||||||
|
clearable
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="首页弹窗">
|
||||||
|
<el-switch v-model="form.is_popup" :active-value="1" :inactive-value="0" />
|
||||||
|
<span class="field-tip">开启后该公告进入首页弹窗 / 侧栏公告区</span>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="置顶">
|
||||||
|
<el-switch v-model="form.top" :active-value="1" :inactive-value="0" />
|
||||||
|
<span class="field-tip">置顶公告在列表中优先展示</span>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="排序" prop="sort">
|
||||||
|
<el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" />
|
||||||
|
<span class="field-tip">数字越小越靠前</span>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="状态" prop="status">
|
||||||
|
<el-radio-group v-model="form.status">
|
||||||
|
<el-radio :value="0">草稿</el-radio>
|
||||||
|
<el-radio :value="1">已发布</el-radio>
|
||||||
|
<el-radio :value="2">已下线</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
|
||||||
|
{{ isEdit ? '保存' : '创建' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { createNotice, editNotice, getNotice } from '@/api/notice'
|
||||||
|
import { ensureWebsiteMaterialCate } from '@/utils/websiteUpload'
|
||||||
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
isEdit: { type: Boolean, default: false },
|
||||||
|
noticeId: { type: Number, default: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'success'])
|
||||||
|
|
||||||
|
// 公告类型:与 go/controllers/platform_website_notice.go 保持一致
|
||||||
|
const TYPE_OPTIONS = [
|
||||||
|
{ value: 1, label: '通知公告' },
|
||||||
|
{ value: 2, label: '活动公告' },
|
||||||
|
{ value: 3, label: '系统维护' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const visible = ref(props.modelValue)
|
||||||
|
const submitLoading = ref(false)
|
||||||
|
const formRef = ref(null)
|
||||||
|
// 「官网素材」文件分组ID:正文内插入的图片归入该分组
|
||||||
|
const materialCateId = ref(0)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
title: '',
|
||||||
|
summary: '',
|
||||||
|
content: '',
|
||||||
|
type: 1,
|
||||||
|
link_url: '',
|
||||||
|
is_popup: 0,
|
||||||
|
top: 0,
|
||||||
|
sort: 0,
|
||||||
|
status: 0,
|
||||||
|
start_time: '',
|
||||||
|
end_time: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
title: [{ required: true, message: '请输入公告标题', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEdit = computed(() => props.isEdit)
|
||||||
|
|
||||||
|
/** 后端返回 RFC3339,转成 el-date-picker 需要的 YYYY-MM-DD HH:mm:ss;无值返回空串 */
|
||||||
|
const toPickerValue = (value) => {
|
||||||
|
if (!value) return ''
|
||||||
|
const text = formatDateTime(value)
|
||||||
|
return text === '-' ? '' : text
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
formRef.value?.clearValidate()
|
||||||
|
Object.assign(form, {
|
||||||
|
title: '',
|
||||||
|
summary: '',
|
||||||
|
content: '',
|
||||||
|
type: 1,
|
||||||
|
link_url: '',
|
||||||
|
is_popup: 0,
|
||||||
|
top: 0,
|
||||||
|
sort: 0,
|
||||||
|
status: 0,
|
||||||
|
start_time: '',
|
||||||
|
end_time: ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
async (val) => {
|
||||||
|
if (!val) {
|
||||||
|
visible.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resetForm()
|
||||||
|
if (props.isEdit && props.noticeId) {
|
||||||
|
try {
|
||||||
|
const res = await getNotice(props.noticeId)
|
||||||
|
if (res.code === 200 && res.data) {
|
||||||
|
const d = res.data
|
||||||
|
Object.assign(form, {
|
||||||
|
title: d.title || '',
|
||||||
|
summary: d.summary || '',
|
||||||
|
content: d.content || '',
|
||||||
|
type: d.type ?? 1,
|
||||||
|
link_url: d.link_url || '',
|
||||||
|
is_popup: d.is_popup ?? 0,
|
||||||
|
top: d.top ?? 0,
|
||||||
|
sort: d.sort ?? 0,
|
||||||
|
status: d.status ?? 0,
|
||||||
|
start_time: toPickerValue(d.start_time),
|
||||||
|
end_time: toPickerValue(d.end_time)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || '获取公告详情失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('获取公告详情失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 数据就绪后再打开抽屉,保证富文本编辑器挂载时即可拿到回显内容
|
||||||
|
visible.value = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(visible, (val) => {
|
||||||
|
emit('update:modelValue', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleClosed = () => {
|
||||||
|
resetForm()
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!formRef.value) return
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
submitLoading.value = true
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
title: form.title,
|
||||||
|
summary: form.summary,
|
||||||
|
content: form.content,
|
||||||
|
type: form.type,
|
||||||
|
link_url: form.link_url,
|
||||||
|
is_popup: form.is_popup,
|
||||||
|
top: form.top,
|
||||||
|
sort: form.sort,
|
||||||
|
status: form.status,
|
||||||
|
// 空串表示不限制,后端按 NULL 处理
|
||||||
|
start_time: form.start_time || '',
|
||||||
|
end_time: form.end_time || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = isEdit.value
|
||||||
|
? await editNotice(props.noticeId, payload)
|
||||||
|
: await createNotice(payload)
|
||||||
|
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success(isEdit.value ? '保存成功' : '创建成功')
|
||||||
|
visible.value = false
|
||||||
|
emit('success')
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || '操作失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
} finally {
|
||||||
|
submitLoading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 提前解析「官网素材」分组,供正文内插入图片时使用
|
||||||
|
ensureWebsiteMaterialCate().then((id) => {
|
||||||
|
materialCateId.value = id
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.editor-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 420px;
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-tip {
|
||||||
|
margin-left: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container-box notice-manage">
|
||||||
|
<div class="header-bar">
|
||||||
|
<h2>站点公告</h2>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
:disabled="selectedIds.length === 0"
|
||||||
|
@click="handleBatchDelete"
|
||||||
|
>
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
批量删除
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" @click="handleAdd">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
新增公告
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="fetchList" :loading="loading">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-alert type="info" :closable="false" class="tips">
|
||||||
|
<template #title>
|
||||||
|
<div>
|
||||||
|
官网通知 / 活动 / 系统维护类公告。仅 <b>已发布</b> 且当前时间处于
|
||||||
|
<b>有效期内</b> 的公告会在官网展示;标记为「弹窗公告」的条目供首页弹窗区读取。
|
||||||
|
前台接口:<code>GET /site/notices</code>(<code>?popup=1</code> 只取弹窗公告)。
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
|
<div class="search-bar">
|
||||||
|
<el-input
|
||||||
|
v-model="searchForm.keyword"
|
||||||
|
placeholder="搜索公告标题 / 摘要"
|
||||||
|
clearable
|
||||||
|
style="width: 220px"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
@clear="handleSearch"
|
||||||
|
/>
|
||||||
|
<el-select
|
||||||
|
v-model="searchForm.type"
|
||||||
|
placeholder="全部类型"
|
||||||
|
clearable
|
||||||
|
style="width: 140px"
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in TYPE_OPTIONS"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-model="searchForm.status"
|
||||||
|
placeholder="全部状态"
|
||||||
|
clearable
|
||||||
|
style="width: 140px"
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in STATUS_OPTIONS"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-model="searchForm.isPopup"
|
||||||
|
placeholder="是否弹窗"
|
||||||
|
clearable
|
||||||
|
style="width: 140px"
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<el-option label="弹窗公告" :value="1" />
|
||||||
|
<el-option label="普通公告" :value="0" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" @click="handleSearch">
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
搜索
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="resetSearch">重置</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
:data="noticeList"
|
||||||
|
v-loading="loading"
|
||||||
|
border
|
||||||
|
stripe
|
||||||
|
style="width: 100%"
|
||||||
|
@selection-change="handleSelectionChange"
|
||||||
|
>
|
||||||
|
<el-table-column type="selection" width="50" align="center" />
|
||||||
|
<el-table-column prop="id" label="ID" width="70" align="center" />
|
||||||
|
|
||||||
|
<el-table-column label="公告标题" min-width="240" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.top === 1" size="small" type="danger" effect="plain">置顶</el-tag>
|
||||||
|
<span class="title-text">{{ row.title }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="类型" width="110" align="center">
|
||||||
|
<template #default="{ row }">{{ typeLabel(row.type) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="弹窗" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-switch
|
||||||
|
:model-value="row.is_popup === 1"
|
||||||
|
@change="(val) => handlePopupChange(row, val)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="置顶" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-switch
|
||||||
|
:model-value="row.top === 1"
|
||||||
|
@change="(val) => handleTopChange(row, val)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="sort" label="排序" width="80" align="center" />
|
||||||
|
|
||||||
|
<el-table-column label="状态" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusTagType(row.status)" size="small">
|
||||||
|
{{ statusLabel(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="有效期" width="180" align="center">
|
||||||
|
<template #default="{ row }">{{ validRange(row) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="发布时间" width="170" align="center">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.publish_time) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="操作" width="200" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" text type="primary" @click="handleEdit(row)">
|
||||||
|
<el-icon><Edit /></el-icon>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
:type="row.status === 1 ? 'warning' : 'success'"
|
||||||
|
@click="handleTogglePublish(row)"
|
||||||
|
>
|
||||||
|
<el-icon><Upload /></el-icon>
|
||||||
|
{{ row.status === 1 ? '下线' : '发布' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" text type="danger" @click="handleDelete(row)">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="暂无公告" :image-size="80" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination-container">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="pagination.page"
|
||||||
|
v-model:page-size="pagination.pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="pagination.total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NoticeEdit
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:is-edit="isEdit"
|
||||||
|
:notice-id="editId"
|
||||||
|
@success="fetchList"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus, Refresh, Search, Edit, Delete, Upload } from '@element-plus/icons-vue'
|
||||||
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
|
import {
|
||||||
|
listNotices,
|
||||||
|
deleteNotice,
|
||||||
|
batchDeleteNotices,
|
||||||
|
publishNotice,
|
||||||
|
unpublishNotice,
|
||||||
|
topNotice,
|
||||||
|
untopNotice,
|
||||||
|
popupNotice,
|
||||||
|
unpopupNotice
|
||||||
|
} from '@/api/notice'
|
||||||
|
import NoticeEdit from './components/edit.vue'
|
||||||
|
|
||||||
|
// 公告类型 / 状态:与 go/controllers/platform_website_notice.go 保持一致
|
||||||
|
const TYPE_OPTIONS = [
|
||||||
|
{ value: 1, label: '通知公告' },
|
||||||
|
{ value: 2, label: '活动公告' },
|
||||||
|
{ value: 3, label: '系统维护' }
|
||||||
|
]
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: 0, label: '草稿' },
|
||||||
|
{ value: 1, label: '已发布' },
|
||||||
|
{ value: 2, label: '已下线' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const typeLabel = (type) =>
|
||||||
|
TYPE_OPTIONS.find((item) => item.value === type)?.label || '通知公告'
|
||||||
|
const statusLabel = (status) =>
|
||||||
|
STATUS_OPTIONS.find((item) => item.value === status)?.label || '草稿'
|
||||||
|
const statusTagType = (status) => {
|
||||||
|
if (status === 1) return 'success'
|
||||||
|
if (status === 2) return 'warning'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const noticeList = ref([])
|
||||||
|
const selectedIds = ref([])
|
||||||
|
const searchForm = reactive({ keyword: '', type: '', status: '', isPopup: '' })
|
||||||
|
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const isEdit = ref(false)
|
||||||
|
const editId = ref(0)
|
||||||
|
|
||||||
|
/** 有效期展示:两端都为空表示不限制 */
|
||||||
|
const validRange = (row) => {
|
||||||
|
const start = row.start_time ? formatDateTime(row.start_time) : ''
|
||||||
|
const end = row.end_time ? formatDateTime(row.end_time) : ''
|
||||||
|
if (!start && !end) return '长期有效'
|
||||||
|
return `${start || '立即'} ~ ${end || '不限'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await listNotices({
|
||||||
|
keyword: searchForm.keyword,
|
||||||
|
type: searchForm.type === '' ? '' : searchForm.type,
|
||||||
|
status: searchForm.status === '' ? '' : searchForm.status,
|
||||||
|
is_popup: searchForm.isPopup === '' ? '' : searchForm.isPopup,
|
||||||
|
page: pagination.page,
|
||||||
|
pageSize: pagination.pageSize
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
const data = res.data
|
||||||
|
noticeList.value = Array.isArray(data) ? data : data?.list || []
|
||||||
|
pagination.total = Array.isArray(data) ? data.length : data?.total || 0
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || '获取公告列表失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('获取公告列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.page = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetSearch = () => {
|
||||||
|
Object.assign(searchForm, { keyword: '', type: '', status: '', isPopup: '' })
|
||||||
|
pagination.page = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSizeChange = (val) => {
|
||||||
|
pagination.pageSize = val
|
||||||
|
pagination.page = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (val) => {
|
||||||
|
pagination.page = val
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectionChange = (selection) => {
|
||||||
|
selectedIds.value = selection.map((item) => item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
isEdit.value = false
|
||||||
|
editId.value = 0
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (row) => {
|
||||||
|
isEdit.value = true
|
||||||
|
editId.value = row.id
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTopChange = async (row, val) => {
|
||||||
|
const next = val ? 1 : 0
|
||||||
|
try {
|
||||||
|
const res = next === 1 ? await topNotice(row.id) : await untopNotice(row.id)
|
||||||
|
if (res.code === 200) {
|
||||||
|
row.top = next
|
||||||
|
ElMessage.success(next === 1 ? '已置顶' : '已取消置顶')
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || '操作失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePopupChange = async (row, val) => {
|
||||||
|
const next = val ? 1 : 0
|
||||||
|
try {
|
||||||
|
const res = next === 1 ? await popupNotice(row.id) : await unpopupNotice(row.id)
|
||||||
|
if (res.code === 200) {
|
||||||
|
row.is_popup = next
|
||||||
|
ElMessage.success(next === 1 ? '已设为弹窗公告' : '已取消弹窗公告')
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || '操作失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTogglePublish = async (row) => {
|
||||||
|
const offline = row.status === 1
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
offline
|
||||||
|
? `确定要下线公告「${row.title}」吗?下线后官网将不再展示。`
|
||||||
|
: `确定要发布公告「${row.title}」吗?发布后官网立即可见(受有效期限制)。`,
|
||||||
|
offline ? '下线确认' : '发布确认',
|
||||||
|
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = offline ? await unpublishNotice(row.id) : await publishNotice(row.id)
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success(offline ? '已下线' : '已发布')
|
||||||
|
fetchList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || '操作失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要删除公告「${row.title}」吗?`, '删除确认', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await deleteNotice(row.id)
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
fetchList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBatchDelete = async () => {
|
||||||
|
if (selectedIds.value.length === 0) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要删除选中的 ${selectedIds.value.length} 条公告吗?`,
|
||||||
|
'批量删除确认',
|
||||||
|
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await batchDeleteNotices(selectedIds.value)
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
selectedIds.value = []
|
||||||
|
fetchList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchList()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.container-box {
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-text {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+103
-6
@@ -76,6 +76,8 @@ interface RemoteListData {
|
|||||||
|
|
||||||
interface RemoteCategoryItem {
|
interface RemoteCategoryItem {
|
||||||
id: number;
|
id: number;
|
||||||
|
/** 父级分类ID,0 为顶级 */
|
||||||
|
cid?: number;
|
||||||
name: string;
|
name: string;
|
||||||
desc?: string;
|
desc?: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
@@ -262,7 +264,37 @@ export async function fetchCategoryCounts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分类列表:已接入后端的栏目取后端分类,其余取 config/sections 的静态配置。
|
* 后端分类是扁平列表(带 cid),这里在前端按 cid 组树。
|
||||||
|
* 顶级分类作为筛选条第一行,子分类作为第二行。
|
||||||
|
* 父级不存在(数据异常)时视为顶级,避免分类丢失。
|
||||||
|
*/
|
||||||
|
function buildCategoryTree(raw: RemoteCategoryItem[]): Category[] {
|
||||||
|
const nodes: Category[] = raw.map((item) => ({
|
||||||
|
slug: String(item.id),
|
||||||
|
name: item.name,
|
||||||
|
desc: item.desc ?? "",
|
||||||
|
parent: item.cid ? String(item.cid) : "",
|
||||||
|
children: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const bySlug = new Map(nodes.map((node) => [node.slug, node]));
|
||||||
|
const roots: Category[] = [];
|
||||||
|
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
const parent = node.parent ? bySlug.get(node.parent) : undefined;
|
||||||
|
if (parent) {
|
||||||
|
parent.children?.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分类列表:已接入后端的栏目取后端分类(返回父子树),
|
||||||
|
* 其余取 config/sections 的静态配置。
|
||||||
* 分类 slug 统一用字符串,便于路由参数传递。
|
* 分类 slug 统一用字符串,便于路由参数传递。
|
||||||
*/
|
*/
|
||||||
export async function fetchCategories(section: SectionKey): Promise<Category[]> {
|
export async function fetchCategories(section: SectionKey): Promise<Category[]> {
|
||||||
@@ -270,11 +302,7 @@ export async function fetchCategories(section: SectionKey): Promise<Category[]>
|
|||||||
const data = await getJSON<{ list: RemoteCategoryItem[] }>(
|
const data = await getJSON<{ list: RemoteCategoryItem[] }>(
|
||||||
"/site/news/categories"
|
"/site/news/categories"
|
||||||
);
|
);
|
||||||
return (data?.list ?? []).map((item) => ({
|
return buildCategoryTree(data?.list ?? []);
|
||||||
slug: String(item.id),
|
|
||||||
name: item.name,
|
|
||||||
desc: item.desc ?? "",
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
return sections[section].categories;
|
return sections[section].categories;
|
||||||
}
|
}
|
||||||
@@ -286,3 +314,72 @@ export async function fetchSinglePage(
|
|||||||
await tick();
|
await tick();
|
||||||
return singlePages[key];
|
return singlePages[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 站点公告(yz_platform_website_notice)
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/** /site/notices 返回的公告结构 */
|
||||||
|
interface RemoteNotice {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
summary?: string;
|
||||||
|
type?: number;
|
||||||
|
link_url?: string;
|
||||||
|
is_popup?: number;
|
||||||
|
top?: number;
|
||||||
|
date?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoticeItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
type: number;
|
||||||
|
typeLabel: string;
|
||||||
|
/** 跳转地址:站内相对路径或完整 URL,为空表示不可点击 */
|
||||||
|
linkUrl: string;
|
||||||
|
/** linkUrl 是否站外地址 */
|
||||||
|
external: boolean;
|
||||||
|
top: boolean;
|
||||||
|
/** YYYY-MM-DD,无发布时间时为空串 */
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 公告类型:与平台端「官网管理 - 站点公告」保持一致 */
|
||||||
|
const NOTICE_TYPE_LABELS: Record<number, string> = {
|
||||||
|
1: "通知公告",
|
||||||
|
2: "活动公告",
|
||||||
|
3: "系统维护",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 站点公告列表(首页 banner 下方的「弹窗公告」区)
|
||||||
|
*
|
||||||
|
* 后端已按「已发布 + 处于有效期内」过滤,已过期、未发布、已下线的公告
|
||||||
|
* 不会返回 —— 因此调用方只需判断返回是否为空,为空即隐藏公告区。
|
||||||
|
* 接口异常时同样返回空数组,由页面降级隐藏,不影响新闻中心展示。
|
||||||
|
*/
|
||||||
|
export async function fetchNotices(
|
||||||
|
options: { popup?: boolean; limit?: number } = {}
|
||||||
|
): Promise<NoticeItem[]> {
|
||||||
|
const data = await getJSON<{ list: RemoteNotice[] }>("/site/notices", {
|
||||||
|
popup: options.popup ? 1 : undefined,
|
||||||
|
limit: options.limit ?? 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (data?.list ?? []).map((raw) => {
|
||||||
|
const linkUrl = raw.link_url || "";
|
||||||
|
return {
|
||||||
|
id: String(raw.id),
|
||||||
|
title: raw.title || "",
|
||||||
|
summary: raw.summary || "",
|
||||||
|
type: raw.type ?? 1,
|
||||||
|
typeLabel: NOTICE_TYPE_LABELS[raw.type ?? 1] ?? "通知公告",
|
||||||
|
linkUrl,
|
||||||
|
external: /^https?:\/\//i.test(linkUrl),
|
||||||
|
top: raw.top === 1,
|
||||||
|
date: formatDate(raw.date),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import type { Category } from "@/types/content";
|
import type { Category } from "@/types/content";
|
||||||
|
import { findCategory, findTopCategory } from "@/utils/category";
|
||||||
import AppIcon from "./AppIcon.vue";
|
import AppIcon from "./AppIcon.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -18,11 +19,46 @@ const emit = defineEmits<{
|
|||||||
(e: "update:keyword", value: string): void;
|
(e: "update:keyword", value: string): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
/** 当前选中分类的描述 */
|
/** 当前选中分类(可能是二级分类) */
|
||||||
const activeDesc = computed(() => {
|
const activeItem = computed(() =>
|
||||||
if (!props.active) return "";
|
props.active ? findCategory(props.categories, props.active) : undefined
|
||||||
return props.categories.find((item) => item.slug === props.active)?.desc ?? "";
|
);
|
||||||
|
|
||||||
|
/** 当前所属的一级分类 */
|
||||||
|
const activeTop = computed(() => {
|
||||||
|
const item = activeItem.value;
|
||||||
|
if (!item) return undefined;
|
||||||
|
return props.categories.some((top) => top.slug === item.slug)
|
||||||
|
? item
|
||||||
|
: findTopCategory(props.categories, item.slug);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 二级分类行:首位是「全部」(选中它等于选中整个一级分类),其后是各子分类。
|
||||||
|
* 返回空数组表示当前一级分类没有子分类,此时整行隐藏。
|
||||||
|
*/
|
||||||
|
const subTabs = computed(() => {
|
||||||
|
const top = activeTop.value;
|
||||||
|
const children = top?.children ?? [];
|
||||||
|
if (!top || children.length === 0) return [];
|
||||||
|
|
||||||
|
const toTab = (slug: string, name: string) => ({
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
count: props.counts[slug] ?? 0,
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
toTab(top.slug, "全部"),
|
||||||
|
...children.map((child) => toTab(child.slug, child.name)),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 一级分类高亮:自身被选中,或它的某个子分类被选中 */
|
||||||
|
const isTopActive = (item: Category) =>
|
||||||
|
props.active === item.slug || activeTop.value?.slug === item.slug;
|
||||||
|
|
||||||
|
/** 当前选中分类的描述 */
|
||||||
|
const activeDesc = computed(() => activeItem.value?.desc ?? "");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -44,7 +80,7 @@ const activeDesc = computed(() => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="filter-tab"
|
class="filter-tab"
|
||||||
:class="{ 'is-active': active === item.slug }"
|
:class="{ 'is-active': isTopActive(item) }"
|
||||||
@click="emit('update:active', item.slug)"
|
@click="emit('update:active', item.slug)"
|
||||||
>
|
>
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
@@ -66,6 +102,23 @@ const activeDesc = computed(() => {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 二级分类:仅当当前一级分类存在子分类时出现 -->
|
||||||
|
<div v-if="subTabs.length" class="filter-bar__row filter-bar__row--sub">
|
||||||
|
<ul class="filter-bar__tabs">
|
||||||
|
<li v-for="item in subTabs" :key="item.slug">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="filter-tab filter-tab--sub"
|
||||||
|
:class="{ 'is-active': active === item.slug }"
|
||||||
|
@click="emit('update:active', item.slug)"
|
||||||
|
>
|
||||||
|
{{ item.name }}
|
||||||
|
<em>{{ item.count }}</em>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p v-if="activeDesc" class="filter-bar__desc">{{ activeDesc }}</p>
|
<p v-if="activeDesc" class="filter-bar__desc">{{ activeDesc }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -93,6 +146,13 @@ const activeDesc = computed(() => {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 二级分类行:缩进 + 左侧引导线,体现层级 */
|
||||||
|
&__row--sub {
|
||||||
|
margin-left: 6px;
|
||||||
|
padding-left: 14px;
|
||||||
|
border-left: 2px solid @line;
|
||||||
|
}
|
||||||
|
|
||||||
&__desc {
|
&__desc {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
@@ -132,6 +192,23 @@ const activeDesc = computed(() => {
|
|||||||
color: rgba(255, 255, 255, 0.6);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 二级分类:更小的胶囊 + 虚线边框,与一级分类区分层级 */
|
||||||
|
&--sub {
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-style: dashed;
|
||||||
|
|
||||||
|
em {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background: @blue-600;
|
||||||
|
border-color: @blue-600;
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-search {
|
.filter-search {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import { footerColumns, isExternalLink, policyLinks, siteInfo } from "@/config/s
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 链接分组 -->
|
<!-- 链接分组 -->
|
||||||
<div class="footer-links">
|
<!-- <div class="footer-links">
|
||||||
<div
|
<div
|
||||||
v-for="column in footerColumns"
|
v-for="column in footerColumns"
|
||||||
:key="column.title"
|
:key="column.title"
|
||||||
@@ -57,7 +57,7 @@ import { footerColumns, isExternalLink, policyLinks, siteInfo } from "@/config/s
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="site-footer__bottom">
|
<div class="site-footer__bottom">
|
||||||
@@ -80,109 +80,6 @@ import { footerColumns, isExternalLink, policyLinks, siteInfo } from "@/config/s
|
|||||||
<RouterLink v-else :to="link.path">{{ link.label }}</RouterLink>
|
<RouterLink v-else :to="link.path">{{ link.label }}</RouterLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<ul class="social-links">
|
|
||||||
<li>
|
|
||||||
<a href="#" aria-label="微博" title="微博">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
d="M10.5 20c-4 0-7.5-2-7.5-4.6 0-1.6 1-3.4 2.7-5C7.6 9 9.7 8 11.4 8c.8 0 1.4.2 1.7.6.4.5.2 1.2-.1 1.9 0 0 .9-.4 1.5-.4 1 0 1.4.6 1.4 1.4 0 .5-.2 1-.6 1.5 1.3.4 2.2 1.3 2.2 2.4C17.5 17.6 14.6 20 10.5 20Z"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.6"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="#" aria-label="微信" title="微信">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
d="M9 4.5c-3.6 0-6.5 2.4-6.5 5.4 0 1.7.9 3.2 2.4 4.2l-.6 2 2.3-1.2c.7.2 1.5.3 2.4.3h.5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.6"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M21.5 15.2c0-2.6-2.5-4.7-5.6-4.7s-5.6 2.1-5.6 4.7 2.5 4.7 5.6 4.7c.7 0 1.4-.1 2-.3l2 1-.5-1.7c1.3-.9 2.1-2.2 2.1-3.7Z"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.6"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="#" aria-label="哔哩哔哩" title="哔哩哔哩">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<rect
|
|
||||||
x="3"
|
|
||||||
y="7"
|
|
||||||
width="18"
|
|
||||||
height="13"
|
|
||||||
rx="3.5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.6"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M7.5 4.5 9.5 7M16.5 4.5 14.5 7M8.5 12v3M15.5 12v3"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.6"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="#" aria-label="抖音" title="抖音">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
d="M14 3.5v10.2a3.6 3.6 0 1 1-3.6-3.6c.3 0 .6 0 .9.1"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.6"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M14 3.5c.5 2.3 2.1 3.8 4.4 4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.6"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="#" aria-label="邮箱" title="邮箱">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<rect
|
|
||||||
x="3"
|
|
||||||
y="5.5"
|
|
||||||
width="18"
|
|
||||||
height="13"
|
|
||||||
rx="2.5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.6"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="m4.5 8 7.5 5 7.5-5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.6"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,11 +7,15 @@
|
|||||||
/** 栏目标识 */
|
/** 栏目标识 */
|
||||||
export type SectionKey = "news" | "products" | "solutions" | "cases" | "events";
|
export type SectionKey = "news" | "products" | "solutions" | "cases" | "events";
|
||||||
|
|
||||||
/** 分类 */
|
/** 分类(支持父子树:顶级分类的 parent 为 "" 或 "0") */
|
||||||
export interface Category {
|
export interface Category {
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
desc?: string;
|
desc?: string;
|
||||||
|
/** 父级分类 slug,顶级为空串 */
|
||||||
|
parent?: string;
|
||||||
|
/** 子分类,叶子节点为空数组 */
|
||||||
|
children?: Category[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 封面配色(暂用渐变替代实拍图) */
|
/** 封面配色(暂用渐变替代实拍图) */
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* 分类树查找工具
|
||||||
|
*
|
||||||
|
* 后台「官网管理 - 分类管理」的分组支持父子两级(cid 指向父级),
|
||||||
|
* 前台筛选条按「一级 / 二级」两行渲染,列表页还要根据路由里的分类 slug
|
||||||
|
* 反查分类名与描述,因此把递归查找统一收在这里,避免各处重复实现。
|
||||||
|
*/
|
||||||
|
import type { Category } from "@/types/content";
|
||||||
|
|
||||||
|
/** 递归查找分类(子分类也能命中),未找到返回 undefined */
|
||||||
|
export function findCategory(
|
||||||
|
list: Category[],
|
||||||
|
slug: string
|
||||||
|
): Category | undefined {
|
||||||
|
for (const item of list) {
|
||||||
|
if (item.slug === slug) return item;
|
||||||
|
const hit = findCategory(item.children ?? [], slug);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 递归查找某分类所属的一级分类,找不到返回 undefined */
|
||||||
|
export function findTopCategory(
|
||||||
|
list: Category[],
|
||||||
|
slug: string
|
||||||
|
): Category | undefined {
|
||||||
|
for (const item of list) {
|
||||||
|
if ((item.children ?? []).some((child) => child.slug === slug)) return item;
|
||||||
|
const hit = findTopCategory(item.children ?? [], slug);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -2,66 +2,42 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
import type { ContentItem } from "@/types/content";
|
import type { ContentItem } from "@/types/content";
|
||||||
import { categoryName } from "@/config/sections";
|
import { categoryName } from "@/config/sections";
|
||||||
import { fetchList } from "@/api/content";
|
import { fetchList, fetchNotices, type NoticeItem } from "@/api/content";
|
||||||
|
|
||||||
interface Popup {
|
|
||||||
tags: string[];
|
|
||||||
title: string;
|
|
||||||
rows: [string, string][];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 首页取新闻中心最新 4 条,
|
* 首页取新闻中心最新若干条,
|
||||||
* 卡片文案与详情页共用同一数据源,点击标题/卡片可直接进入详情。
|
* 卡片文案与详情页共用同一数据源,点击标题/卡片可直接进入详情。
|
||||||
*/
|
*/
|
||||||
const notices = ref<ContentItem[]>([]);
|
const notices = ref<ContentItem[]>([]);
|
||||||
const NOTICE_LIMIT = 4;
|
/** 新闻卡片池:无弹窗公告时新闻中心整行显示,每页多展示一张 */
|
||||||
|
const NOTICE_POOL = 6;
|
||||||
|
|
||||||
const popups: Popup[] = [
|
/**
|
||||||
{
|
* 站点公告(弹窗公告区)。
|
||||||
tags: ["市场活动", "线上直播"],
|
* 后端只回「已发布 + 处于有效期内」的弹窗公告,列表为空即代表无公告或
|
||||||
title: "数据中台实战公开课",
|
* 公告已过期,页面据此隐藏该区域、让新闻中心整行显示。
|
||||||
rows: [
|
*/
|
||||||
["主办单位", "云泽科技"],
|
const popups = ref<NoticeItem[]>([]);
|
||||||
["主 讲 人", "产品总监 · 李明"],
|
const POPUP_LIMIT = 3;
|
||||||
["直播时间", "9 月 25 日 20:00"],
|
|
||||||
["参与方式", "扫码预约直播"],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tags: ["产品发布", "新版本"],
|
|
||||||
title: "云泽数据中台 V3.0 发布",
|
|
||||||
rows: [
|
|
||||||
["发布时间", "9 月 10 日"],
|
|
||||||
["版本亮点", "实时计算 · 指标管理"],
|
|
||||||
["试用方式", "官网申请免费试用"],
|
|
||||||
["咨询电话", "400-000-0000"],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tags: ["展会日程", "线下活动"],
|
|
||||||
title: "2026 云泽数智大会",
|
|
||||||
rows: [
|
|
||||||
["举办城市", "上海"],
|
|
||||||
["举办时间", "10 月 18 日 09:30"],
|
|
||||||
["举办地点", "国家会展中心 3 号馆"],
|
|
||||||
["报名方式", "官网在线报名"],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/* ---------------- 公告轮播 ---------------- */
|
/* ---------------- 公告轮播 ---------------- */
|
||||||
const pageSize = 2;
|
/** 是否存在可展示的弹窗公告,决定本区块的整体布局 */
|
||||||
|
const hasPopup = computed(() => popups.value.length > 0);
|
||||||
|
/** 每页卡片数:整行显示时放宽到 3 张,避免两张被拉得过宽 */
|
||||||
|
const pageSize = computed(() => (hasPopup.value ? 2 : 3));
|
||||||
const page = ref(0);
|
const page = ref(0);
|
||||||
const progress = ref(0);
|
const progress = ref(0);
|
||||||
const playing = ref(true);
|
const playing = ref(true);
|
||||||
|
|
||||||
const pageCount = computed(() =>
|
const pageCount = computed(() =>
|
||||||
Math.max(1, Math.ceil(notices.value.length / pageSize))
|
Math.max(1, Math.ceil(notices.value.length / pageSize.value))
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentNotices = computed(() =>
|
const currentNotices = computed(() =>
|
||||||
notices.value.slice(page.value * pageSize, page.value * pageSize + pageSize)
|
notices.value.slice(
|
||||||
|
page.value * pageSize.value,
|
||||||
|
page.value * pageSize.value + pageSize.value
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
let timer: number | undefined;
|
let timer: number | undefined;
|
||||||
@@ -101,18 +77,27 @@ const togglePlay = () => {
|
|||||||
|
|
||||||
/* ---------------- 弹窗公告轮播 ---------------- */
|
/* ---------------- 弹窗公告轮播 ---------------- */
|
||||||
const popupIndex = ref(0);
|
const popupIndex = ref(0);
|
||||||
const currentPopup = computed(() => popups[popupIndex.value]);
|
const currentPopup = computed<NoticeItem | null>(
|
||||||
|
() => popups.value[popupIndex.value] ?? popups.value[0] ?? null
|
||||||
|
);
|
||||||
const stepPopup = (delta: number) => {
|
const stepPopup = (delta: number) => {
|
||||||
popupIndex.value = (popupIndex.value + delta + popups.length) % popups.length;
|
if (popups.value.length === 0) return;
|
||||||
|
popupIndex.value =
|
||||||
|
(popupIndex.value + delta + popups.value.length) % popups.value.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const result = await fetchList({
|
// 新闻与公告并行拉取;公告为空时只隐藏弹窗区,不影响新闻中心
|
||||||
section: "news",
|
const [result, noticeList] = await Promise.all([
|
||||||
page: 1,
|
fetchList({
|
||||||
pageSize: NOTICE_LIMIT,
|
section: "news",
|
||||||
});
|
page: 1,
|
||||||
|
pageSize: NOTICE_POOL,
|
||||||
|
}),
|
||||||
|
fetchNotices({ popup: true, limit: POPUP_LIMIT }),
|
||||||
|
]);
|
||||||
notices.value = result.list;
|
notices.value = result.list;
|
||||||
|
popups.value = noticeList;
|
||||||
startTimer();
|
startTimer();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,8 +105,9 @@ onBeforeUnmount(stopTimer);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="notice">
|
<section class="notice" :class="{ 'notice--nopopup': !hasPopup }">
|
||||||
<div class="container notice__grid">
|
<!-- 无弹窗公告(未配置 / 已过期 / 已下线)时整行显示新闻中心 -->
|
||||||
|
<div class="container notice__grid" :class="{ 'is-single': !hasPopup }">
|
||||||
<!-- 通知公告 -->
|
<!-- 通知公告 -->
|
||||||
<div class="notice__main">
|
<div class="notice__main">
|
||||||
<header class="notice__head">
|
<header class="notice__head">
|
||||||
@@ -141,7 +127,7 @@ onBeforeUnmount(stopTimer);
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="notice__cards">
|
<div class="notice__cards" :class="{ 'is-wide': !hasPopup }">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="item in currentNotices"
|
v-for="item in currentNotices"
|
||||||
:key="item.slug"
|
:key="item.slug"
|
||||||
@@ -220,10 +206,10 @@ onBeforeUnmount(stopTimer);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 弹窗公告 -->
|
<!-- 弹窗公告 -->
|
||||||
<aside class="popup">
|
<aside v-if="currentPopup" class="popup">
|
||||||
<header class="popup__head">
|
<header class="popup__head">
|
||||||
<h2>弹窗公告</h2>
|
<h2>弹窗公告</h2>
|
||||||
<div class="popup__tools">
|
<div v-if="popups.length > 1" class="popup__tools">
|
||||||
<button type="button" aria-label="上一条弹窗" @click="stepPopup(-1)">
|
<button type="button" aria-label="上一条弹窗" @click="stepPopup(-1)">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
@@ -253,17 +239,33 @@ onBeforeUnmount(stopTimer);
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<transition name="popup-fade" mode="out-in">
|
<transition name="popup-fade" mode="out-in">
|
||||||
<div :key="popupIndex" class="popup__card">
|
<div :key="currentPopup.id" class="popup__card">
|
||||||
<ul class="popup__tags">
|
<ul class="popup__tags">
|
||||||
<li v-for="tag in currentPopup.tags" :key="tag">{{ tag }}</li>
|
<li>{{ currentPopup.typeLabel }}</li>
|
||||||
|
<li v-if="currentPopup.top">置顶</li>
|
||||||
</ul>
|
</ul>
|
||||||
<h3>{{ currentPopup.title }}</h3>
|
<h3>{{ currentPopup.title }}</h3>
|
||||||
|
<p v-if="currentPopup.summary" class="popup__summary">
|
||||||
|
{{ currentPopup.summary }}
|
||||||
|
</p>
|
||||||
<ul class="popup__rows">
|
<ul class="popup__rows">
|
||||||
<li v-for="[label, value] in currentPopup.rows" :key="label">
|
<li>
|
||||||
<span>{{ label }}</span>
|
<span>发布时间</span>
|
||||||
<em>{{ value }}</em>
|
<em>{{ currentPopup.date || "-" }}</em>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<!-- 公告配置了跳转地址才给出入口,避免指向不存在的页面 -->
|
||||||
|
<div v-if="currentPopup.linkUrl" class="popup__action">
|
||||||
|
<a
|
||||||
|
v-if="currentPopup.external"
|
||||||
|
:href="currentPopup.linkUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
查看详情
|
||||||
|
</a>
|
||||||
|
<RouterLink v-else :to="currentPopup.linkUrl">查看详情</RouterLink>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -279,10 +281,20 @@ onBeforeUnmount(stopTimer);
|
|||||||
padding: 74px 0 96px;
|
padding: 74px 0 96px;
|
||||||
height: 650px;
|
height: 650px;
|
||||||
|
|
||||||
|
/* 无弹窗公告时按内容自适应高度(固定 650px 是为右侧弹窗卡片预留的) */
|
||||||
|
&--nopopup {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
&__grid {
|
&__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 470px;
|
grid-template-columns: minmax(0, 1fr) 470px;
|
||||||
gap: 70px;
|
gap: 70px;
|
||||||
|
|
||||||
|
/* 无弹窗公告:新闻中心整行显示 */
|
||||||
|
&.is-single {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__head {
|
&__head {
|
||||||
@@ -333,6 +345,11 @@ onBeforeUnmount(stopTimer);
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
|
|
||||||
|
/* 整行显示时放宽到三列,避免两张卡被拉得过宽 */
|
||||||
|
&.is-wide {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__controls {
|
&__controls {
|
||||||
@@ -537,6 +554,39 @@ onBeforeUnmount(stopTimer);
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 公告摘要:超过三行截断,避免卡片高度失控 */
|
||||||
|
&__summary {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.85;
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 仅在公告配置了跳转地址时出现 */
|
||||||
|
&__action {
|
||||||
|
margin-top: 22px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 9px 22px;
|
||||||
|
border-radius: 100px;
|
||||||
|
background: @white;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: @navy-800;
|
||||||
|
transition: opacity 0.22s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.86;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__tags {
|
&__tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -601,6 +651,11 @@ onBeforeUnmount(stopTimer);
|
|||||||
grid-template-columns: minmax(0, 1fr) 380px;
|
grid-template-columns: minmax(0, 1fr) 380px;
|
||||||
gap: 44px;
|
gap: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* is-single 特异性更高,需在此断点同样覆盖,否则中屏会退回两列 */
|
||||||
|
&__grid.is-single {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,7 +676,8 @@ onBeforeUnmount(stopTimer);
|
|||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__cards {
|
&__cards,
|
||||||
|
&__cards.is-wide {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from "vue-router";
|
|||||||
import type { Category, ContentItem } from "@/types/content";
|
import type { Category, ContentItem } from "@/types/content";
|
||||||
import { sections } from "@/config/sections";
|
import { sections } from "@/config/sections";
|
||||||
import { fetchCategories, fetchCategoryCounts, fetchList } from "@/api/content";
|
import { fetchCategories, fetchCategoryCounts, fetchList } from "@/api/content";
|
||||||
|
import { findCategory } from "@/utils/category";
|
||||||
import PageHero from "@/components/PageHero.vue";
|
import PageHero from "@/components/PageHero.vue";
|
||||||
import FilterBar from "@/components/FilterBar.vue";
|
import FilterBar from "@/components/FilterBar.vue";
|
||||||
import ContentCard from "@/components/ContentCard.vue";
|
import ContentCard from "@/components/ContentCard.vue";
|
||||||
@@ -44,10 +45,10 @@ const categories = ref<Category[]>([]);
|
|||||||
/** 瀑布式列表每页 6 条,卡片网格每页 9 条 */
|
/** 瀑布式列表每页 6 条,卡片网格每页 9 条 */
|
||||||
const pageSize = computed(() => (section.value.layout === "grid" ? 9 : 6));
|
const pageSize = computed(() => (section.value.layout === "grid" ? 9 : 6));
|
||||||
|
|
||||||
/** 当前选中的分类(来自接口或静态配置) */
|
/** 当前选中的分类(可能是二级分类,需递归查找) */
|
||||||
const activeCategoryItem = computed(() =>
|
const activeCategoryItem = computed(() =>
|
||||||
activeCategory.value
|
activeCategory.value
|
||||||
? categories.value.find((item) => item.slug === activeCategory.value)
|
? findCategory(categories.value, activeCategory.value)
|
||||||
: undefined
|
: undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user