From 1a4471e34d2754d0046b24b40c7dfdb55b0fb056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=AB=E5=9C=B0=E5=83=A7?= <357099073@qq.com> Date: Thu, 17 Sep 2026 23:42:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0website?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/controllers/index_website.go | 99 +++- go/controllers/platform_website_common.go | 11 +- go/controllers/platform_website_notice.go | 426 ++++++++++++++++ .../sql/create_platform_website_notice.sql | 88 ++++ go/models/init.go | 2 + go/models/platform_website.go | 71 +++ go/routers/index/index.go | 4 + go/routers/platform/platform.go | 13 + platform/src/api/notice.js | 105 ++++ .../content/notice/components/edit.vue | 291 +++++++++++ .../views/website/content/notice/index.vue | 471 ++++++++++++++++++ website/src/api/content.ts | 109 +++- website/src/components/FilterBar.vue | 87 +++- website/src/components/SiteFooter.vue | 107 +--- website/src/types/content.ts | 6 +- website/src/utils/category.ts | 34 ++ .../views/home/components/NoticeSection.vue | 180 ++++--- website/src/views/list.vue | 5 +- 18 files changed, 1922 insertions(+), 187 deletions(-) create mode 100644 go/controllers/platform_website_notice.go create mode 100644 go/docs/sql/create_platform_website_notice.sql create mode 100644 platform/src/api/notice.js create mode 100644 platform/src/views/website/content/notice/components/edit.vue create mode 100644 platform/src/views/website/content/notice/index.vue create mode 100644 website/src/utils/category.ts diff --git a/go/controllers/index_website.go b/go/controllers/index_website.go index e2cf742..b6df555 100644 --- a/go/controllers/index_website.go +++ b/go/controllers/index_website.go @@ -155,7 +155,9 @@ func (c *IndexWebsiteController) News() { cond := publishedArticleCond() if cateStr := strings.TrimSpace(c.GetString("category")); cateStr != "" { 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 != "" { @@ -221,20 +223,25 @@ func (c *IndexWebsiteController) NewsCategories() { if _, err := models.Orm.QueryTable(new(models.PlatformWebsiteCategory)). Filter("delete_time__isnull", true). 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()) return } + // 父分类的数量包含其所有后代,与列表页按父分类筛选的口径保持一致 + descMap := models.PlatformWebsiteCategoryDescendantMap() + list := make([]map[string]interface{}, 0, len(cates)) for _, item := range cates { + ids := append([]uint64{item.ID}, descMap[item.ID]...) cnt, _ := models.Orm.QueryTable(new(models.PlatformWebsiteArticle)). Filter("delete_time__isnull", true). Filter("status", articleStatusPublished). - Filter("cate_id", item.ID). + Filter("cate_id__in", ids). Count() list = append(list, map[string]interface{}{ "id": item.ID, + "cid": item.Cid, "name": item.Name, "desc": item.Desc, "image": item.Image, @@ -390,3 +397,89 @@ func (c *IndexWebsiteController) NewsView() { } 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) +} diff --git a/go/controllers/platform_website_common.go b/go/controllers/platform_website_common.go index 5d9dd95..c6931a1 100644 --- a/go/controllers/platform_website_common.go +++ b/go/controllers/platform_website_common.go @@ -62,9 +62,9 @@ func websiteID(c *beego.Controller) (uint64, bool) { return id, true } -// websiteStatusParam 解析 status 查询参数,未传时返回 -1(表示不过滤) -func websiteStatusParam(c *beego.Controller) int { - raw := strings.TrimSpace(c.GetString("status")) +// websiteIntParam 解析整型查询参数,未传或非法时返回 -1(表示不过滤) +func websiteIntParam(c *beego.Controller, key string) int { + raw := strings.TrimSpace(c.GetString(key)) if raw == "" { return -1 } @@ -75,6 +75,11 @@ func websiteStatusParam(c *beego.Controller) int { return v } +// websiteStatusParam 解析 status 查询参数,未传时返回 -1(表示不过滤) +func websiteStatusParam(c *beego.Controller) int { + return websiteIntParam(c, "status") +} + // websiteBind 解析 JSON 请求体到 target,失败时已写出 400 func websiteBind(c *beego.Controller, target interface{}) bool { body, err := io.ReadAll(c.Ctx.Request.Body) diff --git a/go/controllers/platform_website_notice.go b/go/controllers/platform_website_notice.go new file mode 100644 index 0000000..0db293b --- /dev/null +++ b/go/controllers/platform_website_notice.go @@ -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) +} diff --git a/go/docs/sql/create_platform_website_notice.sql b/go/docs/sql/create_platform_website_notice.sql new file mode 100644 index 0000000..b6c4f6d --- /dev/null +++ b/go/docs/sql/create_platform_website_notice.sql @@ -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 + ('云泽科技官网全新改版上线', '本次改版聚焦信息架构与访问体验,欢迎反馈建议。', + '

云泽科技官网全新改版上线,本次改版聚焦信息架构与访问体验。

', 1, 1, 1, 0, 1, NOW()); diff --git a/go/models/init.go b/go/models/init.go index 73c93c0..f873cf9 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -129,6 +129,8 @@ func Init(_ string) { new(PlatformWebsiteNav), new(PlatformWebsiteLink), new(PlatformWebsitePage), + // 站点公告(docs/sql/create_platform_website_notice.sql) + new(PlatformWebsiteNotice), new(SystemReminderList), diff --git a/go/models/platform_website.go b/go/models/platform_website.go index 2f338f7..30bb902 100644 --- a/go/models/platform_website.go +++ b/go/models/platform_website.go @@ -160,6 +160,33 @@ func (m *PlatformWebsitePage) TableName() string { 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 ===================== // PlatformWebsiteCategoryNameMap 分类ID -> 分类名称 @@ -199,6 +226,50 @@ func PlatformWebsiteCategoryMap(ids []uint64) map[uint64]PlatformWebsiteCategory 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 -> 标题(评论列表展示所属文章用) func PlatformWebsiteArticleTitleMap(ids []uint64) map[uint64]string { out := make(map[uint64]string) diff --git a/go/routers/index/index.go b/go/routers/index/index.go index 109bc9d..c62f8d6 100644 --- a/go/routers/index/index.go +++ b/go/routers/index/index.go @@ -28,6 +28,10 @@ func Register() { beego.Router("/site/news/:id/view", &controllers.IndexWebsiteController{}, "post:NewsView") 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/footerdata", &controllers.IndexPortalController{}, "get:GetFooterData") beego.Router("/index/newscentertop4", &controllers.IndexPortalController{}, "get:GetNewsCenterTop4") diff --git a/go/routers/platform/platform.go b/go/routers/platform/platform.go index d91a087..53431a4 100644 --- a/go/routers/platform/platform.go +++ b/go/routers/platform/platform.go @@ -402,4 +402,17 @@ func Register() { beego.Router("/platform/website/page/all", &controllers.PlatformWebsitePageController{}, "get:All") beego.Router("/platform/website/page", &controllers.PlatformWebsitePageController{}, "post:Create") 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") } diff --git a/platform/src/api/notice.js b/platform/src/api/notice.js new file mode 100644 index 0000000..fc918a9 --- /dev/null +++ b/platform/src/api/notice.js @@ -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", + }); +} diff --git a/platform/src/views/website/content/notice/components/edit.vue b/platform/src/views/website/content/notice/components/edit.vue new file mode 100644 index 0000000..7aac7e6 --- /dev/null +++ b/platform/src/views/website/content/notice/components/edit.vue @@ -0,0 +1,291 @@ + + + + + diff --git a/platform/src/views/website/content/notice/index.vue b/platform/src/views/website/content/notice/index.vue new file mode 100644 index 0000000..c58304b --- /dev/null +++ b/platform/src/views/website/content/notice/index.vue @@ -0,0 +1,471 @@ + + + + + diff --git a/website/src/api/content.ts b/website/src/api/content.ts index 111bd03..4e66f3c 100644 --- a/website/src/api/content.ts +++ b/website/src/api/content.ts @@ -76,6 +76,8 @@ interface RemoteListData { interface RemoteCategoryItem { id: number; + /** 父级分类ID,0 为顶级 */ + cid?: number; name: string; desc?: 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 统一用字符串,便于路由参数传递。 */ export async function fetchCategories(section: SectionKey): Promise { @@ -270,11 +302,7 @@ export async function fetchCategories(section: SectionKey): Promise const data = await getJSON<{ list: RemoteCategoryItem[] }>( "/site/news/categories" ); - return (data?.list ?? []).map((item) => ({ - slug: String(item.id), - name: item.name, - desc: item.desc ?? "", - })); + return buildCategoryTree(data?.list ?? []); } return sections[section].categories; } @@ -286,3 +314,72 @@ export async function fetchSinglePage( await tick(); 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 = { + 1: "通知公告", + 2: "活动公告", + 3: "系统维护", +}; + +/** + * 站点公告列表(首页 banner 下方的「弹窗公告」区) + * + * 后端已按「已发布 + 处于有效期内」过滤,已过期、未发布、已下线的公告 + * 不会返回 —— 因此调用方只需判断返回是否为空,为空即隐藏公告区。 + * 接口异常时同样返回空数组,由页面降级隐藏,不影响新闻中心展示。 + */ +export async function fetchNotices( + options: { popup?: boolean; limit?: number } = {} +): Promise { + 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), + }; + }); +} diff --git a/website/src/components/FilterBar.vue b/website/src/components/FilterBar.vue index 28d7bc2..692de0c 100644 --- a/website/src/components/FilterBar.vue +++ b/website/src/components/FilterBar.vue @@ -1,6 +1,7 @@ @@ -93,6 +146,13 @@ const activeDesc = computed(() => { flex-wrap: wrap; } + /* 二级分类行:缩进 + 左侧引导线,体现层级 */ + &__row--sub { + margin-left: 6px; + padding-left: 14px; + border-left: 2px solid @line; + } + &__desc { font-size: 13px; line-height: 1.8; @@ -132,6 +192,23 @@ const activeDesc = computed(() => { 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 { diff --git a/website/src/components/SiteFooter.vue b/website/src/components/SiteFooter.vue index c744f95..dfab81a 100644 --- a/website/src/components/SiteFooter.vue +++ b/website/src/components/SiteFooter.vue @@ -44,7 +44,7 @@ import { footerColumns, isExternalLink, policyLinks, siteInfo } from "@/config/s - diff --git a/website/src/types/content.ts b/website/src/types/content.ts index 9630999..553a92d 100644 --- a/website/src/types/content.ts +++ b/website/src/types/content.ts @@ -7,11 +7,15 @@ /** 栏目标识 */ export type SectionKey = "news" | "products" | "solutions" | "cases" | "events"; -/** 分类 */ +/** 分类(支持父子树:顶级分类的 parent 为 "" 或 "0") */ export interface Category { slug: string; name: string; desc?: string; + /** 父级分类 slug,顶级为空串 */ + parent?: string; + /** 子分类,叶子节点为空数组 */ + children?: Category[]; } /** 封面配色(暂用渐变替代实拍图) */ diff --git a/website/src/utils/category.ts b/website/src/utils/category.ts new file mode 100644 index 0000000..559d838 --- /dev/null +++ b/website/src/utils/category.ts @@ -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; +} diff --git a/website/src/views/home/components/NoticeSection.vue b/website/src/views/home/components/NoticeSection.vue index 904cf04..603f89d 100644 --- a/website/src/views/home/components/NoticeSection.vue +++ b/website/src/views/home/components/NoticeSection.vue @@ -2,66 +2,42 @@ import { computed, onBeforeUnmount, onMounted, ref } from "vue"; import type { ContentItem } from "@/types/content"; import { categoryName } from "@/config/sections"; -import { fetchList } from "@/api/content"; - -interface Popup { - tags: string[]; - title: string; - rows: [string, string][]; -} +import { fetchList, fetchNotices, type NoticeItem } from "@/api/content"; /** - * 首页取新闻中心最新 4 条, + * 首页取新闻中心最新若干条, * 卡片文案与详情页共用同一数据源,点击标题/卡片可直接进入详情。 */ const notices = ref([]); -const NOTICE_LIMIT = 4; +/** 新闻卡片池:无弹窗公告时新闻中心整行显示,每页多展示一张 */ +const NOTICE_POOL = 6; -const popups: Popup[] = [ - { - tags: ["市场活动", "线上直播"], - title: "数据中台实战公开课", - rows: [ - ["主办单位", "云泽科技"], - ["主 讲 人", "产品总监 · 李明"], - ["直播时间", "9 月 25 日 20:00"], - ["参与方式", "扫码预约直播"], - ], - }, - { - tags: ["产品发布", "新版本"], - title: "云泽数据中台 V3.0 发布", - rows: [ - ["发布时间", "9 月 10 日"], - ["版本亮点", "实时计算 · 指标管理"], - ["试用方式", "官网申请免费试用"], - ["咨询电话", "400-000-0000"], - ], - }, - { - tags: ["展会日程", "线下活动"], - title: "2026 云泽数智大会", - rows: [ - ["举办城市", "上海"], - ["举办时间", "10 月 18 日 09:30"], - ["举办地点", "国家会展中心 3 号馆"], - ["报名方式", "官网在线报名"], - ], - }, -]; +/** + * 站点公告(弹窗公告区)。 + * 后端只回「已发布 + 处于有效期内」的弹窗公告,列表为空即代表无公告或 + * 公告已过期,页面据此隐藏该区域、让新闻中心整行显示。 + */ +const popups = ref([]); +const POPUP_LIMIT = 3; /* ---------------- 公告轮播 ---------------- */ -const pageSize = 2; +/** 是否存在可展示的弹窗公告,决定本区块的整体布局 */ +const hasPopup = computed(() => popups.value.length > 0); +/** 每页卡片数:整行显示时放宽到 3 张,避免两张被拉得过宽 */ +const pageSize = computed(() => (hasPopup.value ? 2 : 3)); const page = ref(0); const progress = ref(0); const playing = ref(true); 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(() => - 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; @@ -101,18 +77,27 @@ const togglePlay = () => { /* ---------------- 弹窗公告轮播 ---------------- */ const popupIndex = ref(0); -const currentPopup = computed(() => popups[popupIndex.value]); +const currentPopup = computed( + () => popups.value[popupIndex.value] ?? popups.value[0] ?? null +); 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 () => { - const result = await fetchList({ - section: "news", - page: 1, - pageSize: NOTICE_LIMIT, - }); + // 新闻与公告并行拉取;公告为空时只隐藏弹窗区,不影响新闻中心 + const [result, noticeList] = await Promise.all([ + fetchList({ + section: "news", + page: 1, + pageSize: NOTICE_POOL, + }), + fetchNotices({ popup: true, limit: POPUP_LIMIT }), + ]); notices.value = result.list; + popups.value = noticeList; startTimer(); }); @@ -120,8 +105,9 @@ onBeforeUnmount(stopTimer);