From 67e819c6299c81278eb6fcf0be88562d50b2c1b0 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:00:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=BD=91=E7=AB=99=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/controllers/index_website.go | 392 +++++++++ go/controllers/platform_file.go | 59 ++ go/controllers/platform_website_article.go | 832 ++++++++++++++++++ go/controllers/platform_website_common.go | 99 +++ go/controllers/platform_website_content.go | 461 ++++++++++ go/controllers/platform_website_site.go | 707 +++++++++++++++ go/docs/sql/alter_backend_crm_columns.sql | 144 --- go/docs/sql/alter_backend_crm_overdue.sql | 68 -- .../alter_backend_crm_payback_plan_date.sql | 42 - .../sql/alter_backend_crm_payback_receipt.sql | 21 - .../alter_backend_crm_product_dev_mode.sql | 20 - go/docs/sql/alter_contact_mobiles.sql | 55 -- .../alter_crm_product_category_default.sql | 24 - go/docs/sql/alter_crm_unit_default.sql | 22 - .../sql/alter_oa_schedule_carry_columns.sql | 48 - go/docs/sql/alter_payback_record_item.sql | 23 - .../alter_platform_payment_order_promoter.sql | 30 - go/docs/sql/alter_project_contacts.sql | 28 - .../alter_site_reminderlist_target_ids.sql | 21 - .../sql/alter_system_admin_role_columns.sql | 20 - .../alter_system_files_storage_columns.sql | 51 -- .../sql/alter_system_tenant_user_columns.sql | 20 - go/docs/sql/alter_tenant_package_billing.sql | 14 - go/docs/sql/create_backend_crm_contract.sql | 47 - go/docs/sql/create_backend_crm_payback.sql | 62 -- go/docs/sql/create_backend_crm_unit.sql | 28 - go/docs/sql/create_cms_article.sql | 49 -- go/docs/sql/create_cms_product.sql | 39 - go/docs/sql/create_cms_solution.sql | 46 - go/docs/sql/create_oa_document.sql | 133 --- go/docs/sql/create_oa_notice.sql | 31 - go/docs/sql/create_platform_commission.sql | 73 -- go/docs/sql/create_platform_payment.sql | 223 ----- .../sql/create_platform_product_pricing.sql | 98 --- go/docs/sql/create_platform_site_setting.sql | 29 - go/docs/sql/create_platform_wechat_mp.sql | 101 --- go/docs/sql/create_tenant_package.sql | 228 ----- go/docs/sql/create_tenant_setting_items.sql | 20 - go/docs/sql/init_front_menu_defaults.sql | 29 - go/docs/sql/init_platform_payment_channel.sql | 39 - go/docs/sql/init_platform_site_setting.sql | 67 -- .../sql/init_platform_website_file_cate.sql | 22 + go/docs/sql/update_role_system.sql | 67 -- go/models/init.go | 10 + go/models/platform_website.go | 307 +++++++ go/routers/index/index.go | 11 + go/routers/platform/platform.go | 54 ++ platform/src/api/article.js | 126 +-- platform/src/api/comment.js | 57 ++ platform/src/api/friendlink.js | 68 +- platform/src/api/frontMenu.js | 40 +- platform/src/api/onepage.js | 41 +- platform/src/api/tag.js | 66 ++ platform/src/router/index.js | 2 +- platform/src/utils/websiteUpload.js | 61 ++ platform/src/views/components/UmoEditor.vue | 8 +- .../src/views/system/fileManager/index.vue | 41 +- .../article/components/ArticleForm.vue | 589 +++++++++++++ .../article/components/ArticleList.vue | 621 +++++++++++++ .../content/article/components/preview.vue | 140 +++ .../views/website/content/article/create.vue | 58 ++ .../views/website/content/article/draft.vue | 16 + .../views/website/content/article/edit.vue | 62 ++ .../views/website/content/article/index.vue | 16 + .../views/website/content/article/list.vue | 16 + .../views/website/content/article/recycle.vue | 16 + .../content/category/components/edit.vue | 284 ++++++ .../views/website/content/category/index.vue | 286 ++++++ .../views/website/content/comment/index.vue | 417 +++++++++ .../src/views/website/content/tag/index.vue | 329 +++++++ .../website/site/link/components/edit.vue | 266 ++++++ .../src/views/website/site/link/index.vue | 359 ++++++++ .../website/site/nav/components/edit.vue | 232 +++++ platform/src/views/website/site/nav/index.vue | 340 +++++++ .../website/site/page/components/edit.vue | 160 ++++ .../src/views/website/site/page/index.vue | 265 ++++++ .../{ => site}/setting/components/TagTip.vue | 0 .../setting/components/tab_basic.vue | 10 +- .../setting/components/tab_company.vue | 0 .../setting/components/tab_policy.vue | 0 .../setting/components/tab_record.vue | 0 .../website/{ => site}/setting/index.vue | 0 website/src/api/content.ts | 182 +++- website/src/api/request.ts | 69 ++ website/src/components/BackTop.vue | 116 +++ website/src/components/BlockRenderer.vue | 173 +++- website/src/components/ContentCard.vue | 33 +- .../src/{layout => components}/SiteFooter.vue | 0 .../src/{layout => components}/SiteHeader.vue | 166 +++- website/src/config/nav.ts | 77 ++ website/src/layout/DefaultLayout.vue | 10 +- website/src/main.ts | 12 + website/src/router/index.ts | 9 + website/src/types/content.ts | 11 +- website/src/types/global-components.d.ts | 16 + website/src/views/detail.vue | 11 +- .../src/views/home/components/HeroBanner.vue | 36 +- website/src/views/list.vue | 38 +- 98 files changed, 8711 insertions(+), 2254 deletions(-) create mode 100644 go/controllers/index_website.go create mode 100644 go/controllers/platform_website_article.go create mode 100644 go/controllers/platform_website_common.go create mode 100644 go/controllers/platform_website_content.go create mode 100644 go/controllers/platform_website_site.go delete mode 100644 go/docs/sql/alter_backend_crm_columns.sql delete mode 100644 go/docs/sql/alter_backend_crm_overdue.sql delete mode 100644 go/docs/sql/alter_backend_crm_payback_plan_date.sql delete mode 100644 go/docs/sql/alter_backend_crm_payback_receipt.sql delete mode 100644 go/docs/sql/alter_backend_crm_product_dev_mode.sql delete mode 100644 go/docs/sql/alter_contact_mobiles.sql delete mode 100644 go/docs/sql/alter_crm_product_category_default.sql delete mode 100644 go/docs/sql/alter_crm_unit_default.sql delete mode 100644 go/docs/sql/alter_oa_schedule_carry_columns.sql delete mode 100644 go/docs/sql/alter_payback_record_item.sql delete mode 100644 go/docs/sql/alter_platform_payment_order_promoter.sql delete mode 100644 go/docs/sql/alter_project_contacts.sql delete mode 100644 go/docs/sql/alter_site_reminderlist_target_ids.sql delete mode 100644 go/docs/sql/alter_system_admin_role_columns.sql delete mode 100644 go/docs/sql/alter_system_files_storage_columns.sql delete mode 100644 go/docs/sql/alter_system_tenant_user_columns.sql delete mode 100644 go/docs/sql/alter_tenant_package_billing.sql delete mode 100644 go/docs/sql/create_backend_crm_contract.sql delete mode 100644 go/docs/sql/create_backend_crm_payback.sql delete mode 100644 go/docs/sql/create_backend_crm_unit.sql delete mode 100644 go/docs/sql/create_cms_article.sql delete mode 100644 go/docs/sql/create_cms_product.sql delete mode 100644 go/docs/sql/create_cms_solution.sql delete mode 100644 go/docs/sql/create_oa_document.sql delete mode 100644 go/docs/sql/create_oa_notice.sql delete mode 100644 go/docs/sql/create_platform_commission.sql delete mode 100644 go/docs/sql/create_platform_payment.sql delete mode 100644 go/docs/sql/create_platform_product_pricing.sql delete mode 100644 go/docs/sql/create_platform_site_setting.sql delete mode 100644 go/docs/sql/create_platform_wechat_mp.sql delete mode 100644 go/docs/sql/create_tenant_package.sql delete mode 100644 go/docs/sql/create_tenant_setting_items.sql delete mode 100644 go/docs/sql/init_front_menu_defaults.sql delete mode 100644 go/docs/sql/init_platform_payment_channel.sql delete mode 100644 go/docs/sql/init_platform_site_setting.sql create mode 100644 go/docs/sql/init_platform_website_file_cate.sql delete mode 100644 go/docs/sql/update_role_system.sql create mode 100644 go/models/platform_website.go create mode 100644 platform/src/api/comment.js create mode 100644 platform/src/api/tag.js create mode 100644 platform/src/utils/websiteUpload.js create mode 100644 platform/src/views/website/content/article/components/ArticleForm.vue create mode 100644 platform/src/views/website/content/article/components/ArticleList.vue create mode 100644 platform/src/views/website/content/article/components/preview.vue create mode 100644 platform/src/views/website/content/article/create.vue create mode 100644 platform/src/views/website/content/article/draft.vue create mode 100644 platform/src/views/website/content/article/edit.vue create mode 100644 platform/src/views/website/content/article/index.vue create mode 100644 platform/src/views/website/content/article/list.vue create mode 100644 platform/src/views/website/content/article/recycle.vue create mode 100644 platform/src/views/website/content/category/components/edit.vue create mode 100644 platform/src/views/website/content/category/index.vue create mode 100644 platform/src/views/website/content/comment/index.vue create mode 100644 platform/src/views/website/content/tag/index.vue create mode 100644 platform/src/views/website/site/link/components/edit.vue create mode 100644 platform/src/views/website/site/link/index.vue create mode 100644 platform/src/views/website/site/nav/components/edit.vue create mode 100644 platform/src/views/website/site/nav/index.vue create mode 100644 platform/src/views/website/site/page/components/edit.vue create mode 100644 platform/src/views/website/site/page/index.vue rename platform/src/views/website/{ => site}/setting/components/TagTip.vue (100%) rename platform/src/views/website/{ => site}/setting/components/tab_basic.vue (94%) rename platform/src/views/website/{ => site}/setting/components/tab_company.vue (100%) rename platform/src/views/website/{ => site}/setting/components/tab_policy.vue (100%) rename platform/src/views/website/{ => site}/setting/components/tab_record.vue (100%) rename platform/src/views/website/{ => site}/setting/index.vue (100%) create mode 100644 website/src/api/request.ts create mode 100644 website/src/components/BackTop.vue rename website/src/{layout => components}/SiteFooter.vue (100%) rename website/src/{layout => components}/SiteHeader.vue (60%) create mode 100644 website/src/config/nav.ts create mode 100644 website/src/types/global-components.d.ts diff --git a/go/controllers/index_website.go b/go/controllers/index_website.go new file mode 100644 index 0000000..e2cf742 --- /dev/null +++ b/go/controllers/index_website.go @@ -0,0 +1,392 @@ +package controllers + +import ( + "strconv" + "strings" + + "server/models" + + beego "github.com/beego/beego/v2/server/web" + "github.com/beego/beego/v2/client/orm" +) + +// ============================================================= +// 官网前台只读接口:平台官网内容(yz_platform_website_*) +// +// 供 website SPA 调用,无需登录(与 /site/setting 同级)。 +// 数据由 platform 端「官网管理」模块维护;仅返回「已发布」的内容。 +// +// GET /site/news 新闻列表(分页 / 分类 / 关键词) +// GET /site/news/categories 新闻分类 + 各分类已发布数量 +// GET /site/news/:id 新闻详情(正文为 HTML) +// GET /site/news/:id/related 相关推荐 +// POST /site/news/:id/view 浏览量 +1 +// +// 说明:列表与详情中返回的 image 形如 /uploads/xxx,由前端拼接接口域名。 +// ============================================================= + +// 文章状态:与 platform 端「文章管理」保持一致 +const ( + articleStatusPublished int8 = 2 +) + +// 导航菜单类型:与 platform 端「导航菜单」保持一致 +const ( + navTypeExternal int8 = 3 +) + +type IndexWebsiteController struct { + beego.Controller +} + +func (c *IndexWebsiteController) ok(data interface{}) { + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} + _ = c.ServeJSON() +} + +func (c *IndexWebsiteController) fail(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +// publishedArticleQuery 已发布文章的基础查询条件 +func publishedArticleCond() *orm.Condition { + return orm.NewCondition(). + And("delete_time__isnull", true). + And("status", articleStatusPublished) +} + +// articleListItem 列表项(不含正文,减小响应体) +// image 与 category_image 分开返回,由前端决定兜底:文章没有封面时用所属分类的图片。 +func articleListItem(row models.PlatformWebsiteArticle, cate models.PlatformWebsiteCategory, tags []string) map[string]interface{} { + return map[string]interface{}{ + "id": row.ID, + "title": row.Title, + "summary": row.Desc, + "image": row.Image, + "author": row.Author, + "views": row.Views, + "top": row.Top, + "recommend": row.Recommend, + "date": row.PublishTime, + "category_id": row.CateID, + "category_name": cate.Name, + "category_desc": cate.Desc, + "category_image": cate.Image, + "tags": tags, + } +} + +// navNode 导航菜单节点(递归结构,供页头渲染) +type navNode struct { + ID uint64 `json:"id"` + Pid uint64 `json:"pid"` + Title string `json:"title"` + Type int8 `json:"type"` // 1 目录 / 2 页面 / 3 外链 / 4 单页 + Path string `json:"path"` + Icon string `json:"icon"` + External bool `json:"external"` + Children []*navNode `json:"children"` +} + +// Nav GET /site/nav +// 官网页头导航:仅返回启用且显示中的菜单项,按 pid 组树。 +// 父级被隐藏/禁用时其子项一并隐藏,避免出现无归属的顶层菜单。 +func (c *IndexWebsiteController) Nav() { + var rows []models.PlatformWebsiteNav + if _, err := models.Orm.QueryTable(new(models.PlatformWebsiteNav)). + Filter("delete_time__isnull", true). + Filter("status", 1). + Filter("is_visible", 1). + OrderBy("sort", "id"). + All(&rows, "ID", "Pid", "Title", "Type", "Path", "Icon"); err != nil { + c.fail(500, 500, "获取导航菜单失败: "+err.Error()) + return + } + + nodes := make(map[uint64]*navNode, len(rows)) + order := make([]uint64, 0, len(rows)) + for _, r := range rows { + nodes[r.ID] = &navNode{ + ID: r.ID, + Pid: r.Pid, + Title: r.Title, + Type: r.Type, + Path: r.Path, + Icon: r.Icon, + External: r.Type == navTypeExternal || strings.HasPrefix(r.Path, "http://") || strings.HasPrefix(r.Path, "https://"), + Children: []*navNode{}, + } + order = append(order, r.ID) + } + + roots := make([]*navNode, 0, len(rows)) + for _, id := range order { + node := nodes[id] + if node.Pid == 0 { + roots = append(roots, node) + continue + } + parent, ok := nodes[node.Pid] + if !ok { + continue + } + parent.Children = append(parent.Children, node) + } + + c.ok(map[string]interface{}{"list": roots}) +} + +// News GET /site/news?page=&pageSize=&category=&keyword= +func (c *IndexWebsiteController) News() { + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 6) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 6 + } + if pageSize > 50 { + pageSize = 50 + } + + 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) + } + } + if kw := strings.TrimSpace(c.GetString("keyword")); kw != "" { + cond = cond.AndCond(orm.NewCondition(). + Or("title__icontains", kw). + Or("desc__icontains", kw)) + } + + qs := models.Orm.QueryTable(new(models.PlatformWebsiteArticle)).SetCond(cond) + total, err := qs.Count() + if err != nil { + c.fail(500, 500, "获取新闻列表失败: "+err.Error()) + return + } + + var rows []models.PlatformWebsiteArticle + if _, err = qs.OrderBy("-top", "-recommend", "-publish_time", "-id"). + Limit(pageSize, (page-1)*pageSize). + All(&rows, "ID", "CateID", "Title", "Author", "Desc", "Image", + "Status", "Top", "Recommend", "Views", "PublishTime"); err != nil { + c.fail(500, 500, "获取新闻列表失败: "+err.Error()) + return + } + + articleIDs := make([]uint64, 0, len(rows)) + cateIDs := make([]uint64, 0, len(rows)) + for _, r := range rows { + articleIDs = append(articleIDs, r.ID) + if r.CateID > 0 { + cateIDs = append(cateIDs, r.CateID) + } + } + cateMap := models.PlatformWebsiteCategoryMap(cateIDs) + tagMap := models.PlatformWebsiteArticleTagNameMap(articleIDs) + + list := make([]map[string]interface{}, 0, len(rows)) + for _, r := range rows { + tags := tagMap[r.ID] + if tags == nil { + tags = []string{} + } + list = append(list, articleListItem(r, cateMap[r.CateID], tags)) + } + + totalPages := int((total + int64(pageSize) - 1) / int64(pageSize)) + if totalPages < 1 { + totalPages = 1 + } + + c.ok(map[string]interface{}{ + "list": list, + "total": total, + "page": page, + "pageSize": pageSize, + "totalPages": totalPages, + }) +} + +// NewsCategories GET /site/news/categories +// 返回启用中的分类及各自已发布文章数量,供列表页筛选条展示。 +func (c *IndexWebsiteController) NewsCategories() { + var cates []models.PlatformWebsiteCategory + 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 { + c.fail(500, 500, "获取分类失败: "+err.Error()) + return + } + + list := make([]map[string]interface{}, 0, len(cates)) + for _, item := range cates { + cnt, _ := models.Orm.QueryTable(new(models.PlatformWebsiteArticle)). + Filter("delete_time__isnull", true). + Filter("status", articleStatusPublished). + Filter("cate_id", item.ID). + Count() + list = append(list, map[string]interface{}{ + "id": item.ID, + "name": item.Name, + "desc": item.Desc, + "image": item.Image, + "count": cnt, + }) + } + + total, _ := models.Orm.QueryTable(new(models.PlatformWebsiteArticle)). + SetCond(publishedArticleCond()). + Count() + + c.ok(map[string]interface{}{"list": list, "total": total}) +} + +// NewsDetail GET /site/news/:id +func (c *IndexWebsiteController) NewsDetail() { + 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.PlatformWebsiteArticle + if err = models.Orm.QueryTable(new(models.PlatformWebsiteArticle)). + SetCond(publishedArticleCond()). + Filter("id", id). + One(&row); err != nil { + c.fail(404, 404, "内容不存在或已下线") + return + } + + cate := models.PlatformWebsiteCategoryMap([]uint64{row.CateID})[row.CateID] + tags := models.PlatformWebsiteArticleTagNameMap([]uint64{row.ID})[row.ID] + if tags == nil { + tags = []string{} + } + + c.ok(map[string]interface{}{ + "id": row.ID, + "title": row.Title, + "summary": row.Desc, + "image": row.Image, + "content": row.Content, + "author": row.Author, + "views": row.Views, + "date": row.PublishTime, + "category_id": row.CateID, + "category_name": cate.Name, + "category_desc": cate.Desc, + "category_image": cate.Image, + "tags": tags, + }) +} + +// NewsRelated GET /site/news/:id/related?limit=3 +// 同分类优先,不足时用其它分类补齐。 +func (c *IndexWebsiteController) NewsRelated() { + id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + if err != nil || id == 0 { + c.fail(400, 400, "无效的内容ID") + return + } + limit, _ := c.GetInt("limit", 3) + if limit < 1 || limit > 12 { + limit = 3 + } + + var current models.PlatformWebsiteArticle + if err = models.Orm.QueryTable(new(models.PlatformWebsiteArticle)). + SetCond(publishedArticleCond()). + Filter("id", id). + One(¤t); err != nil { + // 当前内容不存在时返回空列表,由前端展示"暂无推荐" + c.ok(map[string]interface{}{"list": []interface{}{}}) + return + } + + pick := make([]models.PlatformWebsiteArticle, 0, limit) + seen := map[uint64]bool{id: true} + + // 1. 同分类 + if current.CateID > 0 { + var same []models.PlatformWebsiteArticle + _, _ = models.Orm.QueryTable(new(models.PlatformWebsiteArticle)). + SetCond(publishedArticleCond()). + Filter("cate_id", current.CateID). + Exclude("id", id). + OrderBy("-publish_time", "-id"). + Limit(limit). + All(&same, "ID", "CateID", "Title", "Author", "Desc", "Image", + "Top", "Recommend", "Views", "PublishTime") + for _, r := range same { + if len(pick) >= limit { + break + } + pick = append(pick, r) + seen[r.ID] = true + } + } + + // 2. 不足时用最新其它文章补齐 + if len(pick) < limit { + ids := make([]uint64, 0, len(seen)) + for k := range seen { + ids = append(ids, k) + } + var others []models.PlatformWebsiteArticle + _, _ = models.Orm.QueryTable(new(models.PlatformWebsiteArticle)). + SetCond(publishedArticleCond()). + Exclude("id__in", ids). + OrderBy("-publish_time", "-id"). + Limit(limit - len(pick)). + All(&others, "ID", "CateID", "Title", "Author", "Desc", "Image", + "Top", "Recommend", "Views", "PublishTime") + pick = append(pick, others...) + } + + articleIDs := make([]uint64, 0, len(pick)) + cateIDs := make([]uint64, 0, len(pick)) + for _, r := range pick { + articleIDs = append(articleIDs, r.ID) + if r.CateID > 0 { + cateIDs = append(cateIDs, r.CateID) + } + } + cateMap := models.PlatformWebsiteCategoryMap(cateIDs) + tagMap := models.PlatformWebsiteArticleTagNameMap(articleIDs) + + list := make([]map[string]interface{}, 0, len(pick)) + for _, r := range pick { + tags := tagMap[r.ID] + if tags == nil { + tags = []string{} + } + list = append(list, articleListItem(r, cateMap[r.CateID], tags)) + } + c.ok(map[string]interface{}{"list": list}) +} + +// NewsView POST /site/news/:id/view — 浏览量 +1(详情页打开时调用) +func (c *IndexWebsiteController) NewsView() { + id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + if err != nil || id == 0 { + c.fail(400, 400, "无效的内容ID") + return + } + if _, err = models.Orm.Raw( + "UPDATE yz_platform_website_article SET views = views + 1 WHERE id = ? AND status = ? AND delete_time IS NULL", + id, articleStatusPublished, + ).Exec(); err != nil { + c.fail(500, 500, "更新浏览量失败") + return + } + c.ok(nil) +} diff --git a/go/controllers/platform_file.go b/go/controllers/platform_file.go index 03696da..2c71cc5 100644 --- a/go/controllers/platform_file.go +++ b/go/controllers/platform_file.go @@ -287,6 +287,34 @@ type platformRenameCateBody struct { Name string `json:"name"` } +// protectedFileCateNames 受保护的文件分组名称(平台端)。 +// +// 这些分组由功能模块按「名称」定位,删除或改名都会导致模块找不到分组: +// - 删除:素材被分散到未分类,且模块会重新补建一个空分组; +// - 改名:模块按名称找不到旧分组,会新建一个同名分组,历史文件被遗留在旧分组里。 +// 因此统一禁止删除/重命名,需要调整时走 SQL 人工处理。 +// +// 官网素材 官网管理(platform/src/views/website)所有上传的归属分组 +// (见 platform/src/utils/websiteUpload.js) +// appsupgrade 软件升级安装包分组(见 platform/src/views/platform/softwareupgrade) +// 图片 平台预留的图片分组,作为官网/后台配图的固定归档位 +var protectedFileCateNames = []string{ + "官网素材", + "appsupgrade", + "图片", +} + +// isProtectedFileCate 判断分组是否为受保护的系统默认分组 +func isProtectedFileCate(name string) bool { + name = strings.TrimSpace(name) + for _, n := range protectedFileCateNames { + if n == name { + return true + } + } + return false +} + // RenameFileCate POST /platform/renamefilecate/:id func (c *PlatformFileController) RenameFileCate() { claims, err := c.platformClaims() @@ -316,6 +344,22 @@ func (c *PlatformFileController) RenameFileCate() { c.jsonErr(400, 400, "分组名称不能为空") return } + + // 受保护分组禁止改名:功能模块按名称定位该分组 + var existCate models.SystemFilesCategory + if err = models.Orm.QueryTable(new(models.SystemFilesCategory)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + One(&existCate); err != nil { + c.jsonErr(404, 404, "分组不存在") + return + } + if isProtectedFileCate(existCate.Name) { + c.jsonErr(400, 400, fmt.Sprintf("「%s」为系统默认分组,不可重命名", existCate.Name)) + return + } + n, err := models.Orm.QueryTable(new(models.SystemFilesCategory)). Filter("id", id). Filter("tid", tid). @@ -347,6 +391,21 @@ func (c *PlatformFileController) DeleteFileCate() { c.jsonErr(400, 400, "无效的分组ID") return } + // 受保护分组禁止删除(先取出分组,便于给出明确提示) + var existCate models.SystemFilesCategory + if err = models.Orm.QueryTable(new(models.SystemFilesCategory)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + One(&existCate); err != nil { + c.jsonErr(404, 404, "分组不存在") + return + } + if isProtectedFileCate(existCate.Name) { + c.jsonErr(400, 400, fmt.Sprintf("「%s」为系统默认分组,不可删除", existCate.Name)) + return + } + cnt, err := models.Orm.QueryTable(new(models.SystemFile)). Filter("cate", id). Filter("tid", tid). diff --git a/go/controllers/platform_website_article.go b/go/controllers/platform_website_article.go new file mode 100644 index 0000000..5eac4ea --- /dev/null +++ b/go/controllers/platform_website_article.go @@ -0,0 +1,832 @@ +package controllers + +import ( + "strings" + "time" + + "server/models" + + beego "github.com/beego/beego/v2/server/web" +) + +// ============================================================= +// 平台官网 - 文章管理 /platform/website/article/* +// - 分类管理 /platform/website/category/* +// 表:yz_platform_website_article / yz_platform_website_category +// ============================================================= + +type PlatformWebsiteArticleController struct { + beego.Controller +} + +type platformWebsiteArticlePayload struct { + Title *string `json:"title"` + Author *string `json:"author"` + Cate *uint64 `json:"cate"` + CateID *uint64 `json:"cate_id"` + Content *string `json:"content"` + Desc *string `json:"desc"` + Image *string `json:"image"` + IsTrans *int8 `json:"is_trans"` + TransURL *string `json:"transurl"` + Status *int8 `json:"status"` + Tags []uint64 `json:"tags"` +} + +// articleCateID 兼容前端 cate / cate_id 两种传参 +func (p *platformWebsiteArticlePayload) articleCateID() (uint64, bool) { + if p.Cate != nil { + return *p.Cate, true + } + if p.CateID != nil { + return *p.CateID, true + } + return 0, false +} + +// List GET /platform/website/article/list +// query: keyword、cate、status、startDate、endDate、page、pageSize +func (c *PlatformWebsiteArticleController) List() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + page, pageSize := websitePaging(&c.Controller) + + qs := models.Orm.QueryTable(new(models.PlatformWebsiteArticle)). + Filter("delete_time__isnull", true) + if kw := strings.TrimSpace(c.GetString("keyword")); kw != "" { + qs = qs.Filter("title__icontains", kw) + } + if cate, err := c.GetInt64("cate"); err == nil && cate > 0 { + qs = qs.Filter("cate_id", cate) + } + if st := websiteStatusParam(&c.Controller); st >= 0 { + qs = qs.Filter("status", st) + } + if start := strings.TrimSpace(c.GetString("startDate")); start != "" { + qs = qs.Filter("publish_time__gte", start+" 00:00:00") + } + if end := strings.TrimSpace(c.GetString("endDate")); end != "" { + qs = qs.Filter("publish_time__lte", end+" 23:59:59") + } + + total, err := qs.Count() + if err != nil { + jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error()) + return + } + + var rows []models.PlatformWebsiteArticle + if _, err = qs.OrderBy("-top", "-recommend", "-id"). + Limit(pageSize, (page-1)*pageSize). + All(&rows, + "ID", "CateID", "Title", "Author", "Desc", "Image", + "IsTrans", "Status", "Top", "Recommend", "Views", "Likes", + "PublishTime", "CreateTime", "UpdateTime"); err != nil { + jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error()) + return + } + + cateIDs := make([]uint64, 0, len(rows)) + for _, r := range rows { + if r.CateID > 0 { + cateIDs = append(cateIDs, r.CateID) + } + } + cateMap := models.PlatformWebsiteCategoryNameMap(cateIDs) + + list := make([]map[string]interface{}, 0, len(rows)) + for _, r := range rows { + list = append(list, map[string]interface{}{ + "id": r.ID, + "cate_id": r.CateID, + "cate": cateMap[r.CateID], + "title": r.Title, + "author": r.Author, + "desc": r.Desc, + "image": r.Image, + "is_trans": r.IsTrans, + "status": r.Status, + "top": r.Top, + "recommend": r.Recommend, + "views": r.Views, + "likes": r.Likes, + "publish_time": r.PublishTime, + "create_time": r.CreateTime, + "update_time": r.UpdateTime, + }) + } + + websiteOK(&c.Controller, map[string]interface{}{"list": list, "total": total}) +} + +// ListAll GET /platform/website/article/all — 不分页,供选择/关联使用 +func (c *PlatformWebsiteArticleController) ListAll() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + qs := models.Orm.QueryTable(new(models.PlatformWebsiteArticle)). + Filter("delete_time__isnull", true) + if kw := strings.TrimSpace(c.GetString("keyword")); kw != "" { + qs = qs.Filter("title__icontains", kw) + } + if st := websiteStatusParam(&c.Controller); st >= 0 { + qs = qs.Filter("status", st) + } + var rows []models.PlatformWebsiteArticle + if _, err := qs.OrderBy("-id").All(&rows, "ID", "Title", "Status", "CateID", "PublishTime"); err != nil { + jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error()) + return + } + websiteOK(&c.Controller, rows) +} + +// Recycle GET /platform/website/article/recycle — 回收站(已软删除的文章) +func (c *PlatformWebsiteArticleController) Recycle() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + page, pageSize := websitePaging(&c.Controller) + + qs := models.Orm.QueryTable(new(models.PlatformWebsiteArticle)). + Filter("delete_time__isnull", false) + if kw := strings.TrimSpace(c.GetString("keyword")); kw != "" { + qs = qs.Filter("title__icontains", kw) + } + total, err := qs.Count() + if err != nil { + jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error()) + return + } + + var rows []models.PlatformWebsiteArticle + if _, err = qs.OrderBy("-delete_time"). + Limit(pageSize, (page-1)*pageSize). + All(&rows, "ID", "CateID", "Title", "Author", "Desc", "Image", + "Status", "Top", "Recommend", "Views", "Likes", + "PublishTime", "CreateTime", "UpdateTime", "DeleteTime"); err != nil { + jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error()) + return + } + + cateIDs := make([]uint64, 0, len(rows)) + for _, r := range rows { + if r.CateID > 0 { + cateIDs = append(cateIDs, r.CateID) + } + } + cateMap := models.PlatformWebsiteCategoryNameMap(cateIDs) + + list := make([]map[string]interface{}, 0, len(rows)) + for _, r := range rows { + list = append(list, map[string]interface{}{ + "id": r.ID, + "cate_id": r.CateID, + "cate": cateMap[r.CateID], + "title": r.Title, + "author": r.Author, + "desc": r.Desc, + "image": r.Image, + "status": r.Status, + "top": r.Top, + "recommend": r.Recommend, + "views": r.Views, + "likes": r.Likes, + "publish_time": r.PublishTime, + "create_time": r.CreateTime, + "update_time": r.UpdateTime, + "delete_time": r.DeleteTime, + }) + } + + websiteOK(&c.Controller, map[string]interface{}{"list": list, "total": total}) +} + +// Restore POST /platform/website/article/:id/restore — 从回收站恢复 +func (c *PlatformWebsiteArticleController) Restore() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + id, ok := websiteID(&c.Controller) + if !ok { + return + } + // 清空 delete_time 属于「反软删除」,beego 的 Update(map) 不便表达,直接走 SQL + res, err := models.Orm.Raw( + "UPDATE yz_platform_website_article SET delete_time = NULL, update_time = ? WHERE id = ? AND delete_time IS NOT NULL", + time.Now(), id, + ).Exec() + if err != nil { + jsonErr(&c.Controller, 500, 500, "恢复失败: "+err.Error()) + return + } + if affected, _ := res.RowsAffected(); affected == 0 { + jsonErr(&c.Controller, 404, 404, "文章不存在或未被删除") + return + } + websiteOK(&c.Controller, nil) +} + +// Purge DELETE /platform/website/article/:id/purge — 彻底删除(仅限回收站中的记录) +func (c *PlatformWebsiteArticleController) Purge() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + id, ok := websiteID(&c.Controller) + if !ok { + return + } + n, err := models.Orm.QueryTable(new(models.PlatformWebsiteArticle)). + Filter("id", id). + Filter("delete_time__isnull", false). + Delete() + if err != nil { + jsonErr(&c.Controller, 500, 500, "彻底删除失败: "+err.Error()) + return + } + if n == 0 { + jsonErr(&c.Controller, 404, 404, "仅可彻底删除回收站中的文章") + return + } + _ = models.ClearPlatformWebsiteArticleTags(id) + websiteOK(&c.Controller, nil) +} + +// Get GET /platform/website/article/:id — 详情(含标签ID列表) +func (c *PlatformWebsiteArticleController) Get() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + id, ok := websiteID(&c.Controller) + if !ok { + return + } + var row models.PlatformWebsiteArticle + if err := models.Orm.QueryTable(new(models.PlatformWebsiteArticle)). + Filter("id", id). + Filter("delete_time__isnull", true). + One(&row); err != nil { + jsonErr(&c.Controller, 404, 404, "文章不存在") + return + } + + cateName := "" + if row.CateID > 0 { + cateName = models.PlatformWebsiteCategoryNameMap([]uint64{row.CateID})[row.CateID] + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{ + "id": row.ID, + "cate_id": row.CateID, + "cate": cateName, + "title": row.Title, + "author": row.Author, + "desc": row.Desc, + "image": row.Image, + "content": row.Content, + "is_trans": row.IsTrans, + "transurl": row.TransURL, + "status": row.Status, + "top": row.Top, + "recommend": row.Recommend, + "views": row.Views, + "likes": row.Likes, + "publisher_id": row.PublisherID, + "publish_time": row.PublishTime, + "create_time": row.CreateTime, + "update_time": row.UpdateTime, + "tags": models.PlatformWebsiteArticleTagIDs(row.ID), + }, + } + _ = c.ServeJSON() +} + +// Create POST /platform/website/article +func (c *PlatformWebsiteArticleController) Create() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + var p platformWebsiteArticlePayload + if !websiteBind(&c.Controller, &p) { + return + } + title := "" + if p.Title != nil { + title = strings.TrimSpace(*p.Title) + } + if title == "" { + jsonErr(&c.Controller, 400, 400, "文章标题不能为空") + return + } + + row := models.PlatformWebsiteArticle{Title: title} + if p.Author != nil { + row.Author = strings.TrimSpace(*p.Author) + } + if cate, has := p.articleCateID(); has { + row.CateID = cate + } + if p.Content != nil { + row.Content = *p.Content + } + if p.Desc != nil { + row.Desc = *p.Desc + } + if p.Image != nil { + row.Image = strings.ReplaceAll(*p.Image, "\\", "/") + } + if p.IsTrans != nil { + row.IsTrans = *p.IsTrans + } + if row.IsTrans == 1 && p.TransURL != nil { + url := strings.TrimSpace(*p.TransURL) + row.TransURL = &url + } + if p.Status != nil { + row.Status = *p.Status + } + // 直接提交待审核时同步记录发布时间 + if row.Status == 2 { + now := time.Now() + row.PublishTime = &now + } + + id, err := models.Orm.Insert(&row) + if err != nil { + jsonErr(&c.Controller, 500, 500, "创建失败: "+err.Error()) + return + } + if err = models.SetPlatformWebsiteArticleTags(uint64(id), p.Tags); err != nil { + jsonErr(&c.Controller, 500, 500, "标签保存失败: "+err.Error()) + return + } + websiteOK(&c.Controller, map[string]interface{}{"id": id}) +} + +// Update POST /platform/website/article/:id +func (c *PlatformWebsiteArticleController) Update() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + id, ok := websiteID(&c.Controller) + if !ok { + return + } + var p platformWebsiteArticlePayload + if !websiteBind(&c.Controller, &p) { + return + } + + up := map[string]interface{}{} + if p.Title != nil { + title := strings.TrimSpace(*p.Title) + if title == "" { + jsonErr(&c.Controller, 400, 400, "文章标题不能为空") + return + } + up["title"] = title + } + if p.Author != nil { + up["author"] = strings.TrimSpace(*p.Author) + } + if cate, has := p.articleCateID(); has { + up["cate_id"] = cate + } + if p.Content != nil { + up["content"] = *p.Content + } + if p.Desc != nil { + up["desc"] = *p.Desc + } + if p.Image != nil { + up["image"] = strings.ReplaceAll(*p.Image, "\\", "/") + } + if p.IsTrans != nil { + up["is_trans"] = *p.IsTrans + } + if p.TransURL != nil { + url := strings.TrimSpace(*p.TransURL) + if url == "" { + up["transurl"] = nil + } else { + up["transurl"] = url + } + } + if p.Status != nil { + up["status"] = *p.Status + if *p.Status == 2 { + up["publish_time"] = time.Now() + } + } + up["update_time"] = time.Now() + + n, err := models.Orm.QueryTable(new(models.PlatformWebsiteArticle)). + 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 + } + // tags 传了才覆盖,未传则保持原有关联 + if p.Tags != nil { + if err = models.SetPlatformWebsiteArticleTags(id, p.Tags); err != nil { + jsonErr(&c.Controller, 500, 500, "标签保存失败: "+err.Error()) + return + } + } + websiteOK(&c.Controller, nil) +} + +// Delete DELETE /platform/website/article/:id +func (c *PlatformWebsiteArticleController) 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.PlatformWebsiteArticle)). + 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 + } + _ = models.ClearPlatformWebsiteArticleTags(id) + websiteOK(&c.Controller, nil) +} + +// Publish POST /platform/website/article/:id/publish +// body: { uid } 选填,用于记录发布人(平台管理员ID) +func (c *PlatformWebsiteArticleController) Publish() { + claims, ok := websiteAuth(&c.Controller) + if !ok { + return + } + id, ok := websiteID(&c.Controller) + if !ok { + return + } + var p struct { + UID *uint64 `json:"uid"` + } + // 发布人参数可选,缺失时回退为当前登录管理员 + websiteBindOptional(&c.Controller, &p) + + publisher := uint64(claims.UserID) + if p.UID != nil && *p.UID > 0 { + publisher = *p.UID + } + + now := time.Now() + n, err := models.Orm.QueryTable(new(models.PlatformWebsiteArticle)). + Filter("id", id). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{ + "status": 2, + "publish_time": now, + "publisher_id": publisher, + "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) +} + +// UnPublish POST /platform/website/article/:id/unpublish — 下架后状态置为「已隐藏」 +func (c *PlatformWebsiteArticleController) UnPublish() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + id, ok := websiteID(&c.Controller) + if !ok { + return + } + n, err := models.Orm.QueryTable(new(models.PlatformWebsiteArticle)). + Filter("id", id). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"status": 3, "update_time": 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) +} + +// updateArticleFlag 统一处理推荐/置顶标记 +func (c *PlatformWebsiteArticleController) updateArticleFlag(field string, value int8, failMsg string) { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + id, ok := websiteID(&c.Controller) + if !ok { + return + } + n, err := models.Orm.QueryTable(new(models.PlatformWebsiteArticle)). + Filter("id", id). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{field: value, "update_time": time.Now()}) + if err != nil { + jsonErr(&c.Controller, 500, 500, failMsg+": "+err.Error()) + return + } + if n == 0 { + jsonErr(&c.Controller, 404, 404, "文章不存在") + return + } + websiteOK(&c.Controller, nil) +} + +// Recommend POST /platform/website/article/:id/recommend +func (c *PlatformWebsiteArticleController) Recommend() { + c.updateArticleFlag("recommend", 1, "推荐失败") +} + +// UnRecommend POST /platform/website/article/:id/unrecommend +func (c *PlatformWebsiteArticleController) UnRecommend() { + c.updateArticleFlag("recommend", 0, "取消推荐失败") +} + +// Top POST /platform/website/article/:id/top +func (c *PlatformWebsiteArticleController) Top() { + c.updateArticleFlag("top", 1, "置顶失败") +} + +// UnTop POST /platform/website/article/:id/untop +func (c *PlatformWebsiteArticleController) UnTop() { + c.updateArticleFlag("top", 0, "取消置顶失败") +} + +// ============================================================= +// 分类管理 +// ============================================================= + +type PlatformWebsiteCategoryController struct { + beego.Controller +} + +type platformWebsiteCategoryPayload struct { + Name *string `json:"name"` + CID *uint64 `json:"cid"` + Image *string `json:"image"` + Desc *string `json:"desc"` + Sort *int `json:"sort"` + Status *int8 `json:"status"` +} + +// All GET /platform/website/category/all — 全量返回(前端自行组树),支持 keyword +func (c *PlatformWebsiteCategoryController) All() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + qs := models.Orm.QueryTable(new(models.PlatformWebsiteCategory)). + Filter("delete_time__isnull", true) + if kw := strings.TrimSpace(c.GetString("keyword")); kw != "" { + qs = qs.Filter("name__icontains", kw) + } + var rows []models.PlatformWebsiteCategory + if _, err := qs.OrderBy("sort", "id").All(&rows); err != nil { + jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error()) + return + } + websiteOK(&c.Controller, rows) +} + +// List GET /platform/website/category/list — 分页 +func (c *PlatformWebsiteCategoryController) List() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + page, pageSize := websitePaging(&c.Controller) + qs := models.Orm.QueryTable(new(models.PlatformWebsiteCategory)). + Filter("delete_time__isnull", true) + if kw := strings.TrimSpace(c.GetString("keyword")); kw != "" { + qs = qs.Filter("name__icontains", kw) + } + total, err := qs.Count() + if err != nil { + jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error()) + return + } + var rows []models.PlatformWebsiteCategory + if _, err = qs.OrderBy("sort", "id").Limit(pageSize, (page-1)*pageSize).All(&rows); err != nil { + jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error()) + return + } + websiteOK(&c.Controller, map[string]interface{}{"list": rows, "total": total}) +} + +// Get GET /platform/website/category/:id +func (c *PlatformWebsiteCategoryController) Get() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + id, ok := websiteID(&c.Controller) + if !ok { + return + } + var row models.PlatformWebsiteCategory + if err := models.Orm.QueryTable(new(models.PlatformWebsiteCategory)). + 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/category +func (c *PlatformWebsiteCategoryController) Create() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + var p platformWebsiteCategoryPayload + if !websiteBind(&c.Controller, &p) { + return + } + if p.Name == nil || strings.TrimSpace(*p.Name) == "" { + jsonErr(&c.Controller, 400, 400, "分类名称不能为空") + return + } + row := models.PlatformWebsiteCategory{ + Name: strings.TrimSpace(*p.Name), + Status: 1, + } + if p.CID != nil { + row.Cid = *p.CID + } + if p.Image != nil { + row.Image = strings.ReplaceAll(*p.Image, "\\", "/") + } + if p.Desc != nil { + row.Desc = *p.Desc + } + if p.Sort != nil { + row.Sort = *p.Sort + } + if p.Status != nil { + row.Status = *p.Status + } + 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/category/:id +func (c *PlatformWebsiteCategoryController) Update() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + id, ok := websiteID(&c.Controller) + if !ok { + return + } + var p platformWebsiteCategoryPayload + if !websiteBind(&c.Controller, &p) { + return + } + up := map[string]interface{}{} + if p.Name != nil { + name := strings.TrimSpace(*p.Name) + if name == "" { + jsonErr(&c.Controller, 400, 400, "分类名称不能为空") + return + } + up["name"] = name + } + if p.CID != nil { + if *p.CID == id { + jsonErr(&c.Controller, 400, 400, "父级分类不能是自己") + return + } + up["cid"] = *p.CID + } + if p.Image != nil { + up["image"] = strings.ReplaceAll(*p.Image, "\\", "/") + } + if p.Desc != nil { + up["desc"] = *p.Desc + } + if p.Sort != nil { + up["sort"] = *p.Sort + } + if p.Status != nil { + up["status"] = *p.Status + } + if len(up) == 0 { + jsonErr(&c.Controller, 400, 400, "无更新字段") + return + } + up["update_time"] = time.Now() + + n, err := models.Orm.QueryTable(new(models.PlatformWebsiteCategory)). + 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) +} + +// UpdateStatus PATCH /platform/website/category/:id/status — body { status } +func (c *PlatformWebsiteCategoryController) UpdateStatus() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + id, ok := websiteID(&c.Controller) + if !ok { + return + } + var p struct { + Status *int8 `json:"status"` + } + if !websiteBind(&c.Controller, &p) || p.Status == nil { + jsonErr(&c.Controller, 400, 400, "状态参数错误") + return + } + n, err := models.Orm.QueryTable(new(models.PlatformWebsiteCategory)). + Filter("id", id). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"status": *p.Status, "update_time": 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) +} + +// Delete DELETE /platform/website/category/:id — 存在子分类时禁止删除 +func (c *PlatformWebsiteCategoryController) Delete() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + id, ok := websiteID(&c.Controller) + if !ok { + return + } + child, err := models.Orm.QueryTable(new(models.PlatformWebsiteCategory)). + Filter("cid", id). + Filter("delete_time__isnull", true). + Count() + if err != nil { + jsonErr(&c.Controller, 500, 500, "删除失败: "+err.Error()) + return + } + if child > 0 { + jsonErr(&c.Controller, 400, 400, "该分类下存在子分类,请先删除子分类") + return + } + now := time.Now() + n, err := models.Orm.QueryTable(new(models.PlatformWebsiteCategory)). + 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) +} diff --git a/go/controllers/platform_website_common.go b/go/controllers/platform_website_common.go new file mode 100644 index 0000000..5d9dd95 --- /dev/null +++ b/go/controllers/platform_website_common.go @@ -0,0 +1,99 @@ +package controllers + +import ( + "bytes" + "encoding/json" + "io" + "strconv" + "strings" + + "server/pkg/jwtutil" + + beego "github.com/beego/beego/v2/server/web" +) + +// ============================================================= +// 平台官网管理(/platform/website/*)公共工具 +// 数据表前缀 yz_platform_website_,建表见 docs/sql/create_platform_website.sql +// ============================================================= + +// websiteAuth 平台管理员鉴权,失败时已写出错误响应,返回 ok=false +func websiteAuth(c *beego.Controller) (*jwtutil.Claims, bool) { + claims, err := requirePlatform(c) + if err != nil { + jsonErr(c, 401, 401, err.Error()) + return nil, false + } + return claims, true +} + +// websiteOK 统一成功响应:{ code:200, msg:"success", data:... } +func websiteOK(c *beego.Controller, data interface{}) { + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} + _ = c.ServeJSON() +} + +// websitePaging 解析分页参数:page 默认 1,pageSize 默认 10(兼容 limit),上限 200 +func websitePaging(c *beego.Controller) (int, int) { + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 0) + if pageSize <= 0 { + pageSize, _ = c.GetInt("limit", 0) + } + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 10 + } + if pageSize > 200 { + pageSize = 200 + } + return page, pageSize +} + +// websiteID 解析路径参数 :id +func websiteID(c *beego.Controller) (uint64, bool) { + id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + if err != nil || id == 0 { + jsonErr(c, 400, 400, "无效ID") + return 0, false + } + return id, true +} + +// websiteStatusParam 解析 status 查询参数,未传时返回 -1(表示不过滤) +func websiteStatusParam(c *beego.Controller) int { + raw := strings.TrimSpace(c.GetString("status")) + if raw == "" { + return -1 + } + v, err := strconv.Atoi(raw) + if err != nil { + return -1 + } + return v +} + +// websiteBind 解析 JSON 请求体到 target,失败时已写出 400 +func websiteBind(c *beego.Controller, target interface{}) bool { + body, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + jsonErr(c, 400, 400, "参数错误") + return false + } + if err = json.Unmarshal(body, target); err != nil { + jsonErr(c, 400, 400, "参数错误") + return false + } + return true +} + +// websiteBindOptional 宽松解析请求体:body 为空或非法时不作响应,调用方按零值处理 +func websiteBindOptional(c *beego.Controller, target interface{}) { + body, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil || len(bytes.TrimSpace(body)) == 0 { + return + } + _ = json.Unmarshal(body, target) +} diff --git a/go/controllers/platform_website_content.go b/go/controllers/platform_website_content.go new file mode 100644 index 0000000..11c383d --- /dev/null +++ b/go/controllers/platform_website_content.go @@ -0,0 +1,461 @@ +package controllers + +import ( + "strings" + "time" + + "server/models" + + beego "github.com/beego/beego/v2/server/web" + "github.com/beego/beego/v2/client/orm" +) + +// ============================================================= +// 平台官网 - 标签管理 /platform/website/tag/* +// - 评论管理 /platform/website/comment/* +// 表:yz_platform_website_tag / yz_platform_website_comment +// ============================================================= + +type PlatformWebsiteTagController struct { + beego.Controller +} + +type platformWebsiteTagPayload struct { + Name *string `json:"name"` + Sort *int `json:"sort"` + Status *int8 `json:"status"` +} + +// List GET /platform/website/tag/list — 分页 +func (c *PlatformWebsiteTagController) List() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + page, pageSize := websitePaging(&c.Controller) + qs := models.Orm.QueryTable(new(models.PlatformWebsiteTag)). + Filter("delete_time__isnull", true) + if kw := strings.TrimSpace(c.GetString("keyword")); kw != "" { + qs = qs.Filter("name__icontains", kw) + } + if st := websiteStatusParam(&c.Controller); st >= 0 { + qs = qs.Filter("status", st) + } + total, err := qs.Count() + if err != nil { + jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error()) + return + } + var rows []models.PlatformWebsiteTag + if _, err = qs.OrderBy("sort", "id").Limit(pageSize, (page-1)*pageSize).All(&rows); err != nil { + jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error()) + return + } + websiteOK(&c.Controller, map[string]interface{}{"list": rows, "total": total}) +} + +// All GET /platform/website/tag/all — 全量返回,供文章编辑选择标签 +func (c *PlatformWebsiteTagController) All() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + var rows []models.PlatformWebsiteTag + if _, err := models.Orm.QueryTable(new(models.PlatformWebsiteTag)). + Filter("delete_time__isnull", true). + OrderBy("sort", "id"). + All(&rows); err != nil { + jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error()) + return + } + websiteOK(&c.Controller, rows) +} + +// Get GET /platform/website/tag/:id +func (c *PlatformWebsiteTagController) Get() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + id, ok := websiteID(&c.Controller) + if !ok { + return + } + var row models.PlatformWebsiteTag + if err := models.Orm.QueryTable(new(models.PlatformWebsiteTag)). + 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/tag +func (c *PlatformWebsiteTagController) Create() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + var p platformWebsiteTagPayload + if !websiteBind(&c.Controller, &p) { + return + } + if p.Name == nil || strings.TrimSpace(*p.Name) == "" { + jsonErr(&c.Controller, 400, 400, "标签名称不能为空") + return + } + name := strings.TrimSpace(*p.Name) + // 同名标签去重,避免前台聚合出现重复项 + exist, err := models.Orm.QueryTable(new(models.PlatformWebsiteTag)). + Filter("name", name). + Filter("delete_time__isnull", true). + Count() + if err != nil { + jsonErr(&c.Controller, 500, 500, "创建失败: "+err.Error()) + return + } + if exist > 0 { + jsonErr(&c.Controller, 400, 400, "标签名称已存在") + return + } + + row := models.PlatformWebsiteTag{Name: name, Status: 1} + if p.Sort != nil { + row.Sort = *p.Sort + } + if p.Status != nil { + row.Status = *p.Status + } + 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/tag/:id +func (c *PlatformWebsiteTagController) Update() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + id, ok := websiteID(&c.Controller) + if !ok { + return + } + var p platformWebsiteTagPayload + if !websiteBind(&c.Controller, &p) { + return + } + up := map[string]interface{}{} + if p.Name != nil { + name := strings.TrimSpace(*p.Name) + if name == "" { + jsonErr(&c.Controller, 400, 400, "标签名称不能为空") + return + } + dup, err := models.Orm.QueryTable(new(models.PlatformWebsiteTag)). + Filter("name", name). + Filter("delete_time__isnull", true). + Exclude("id", id). + Count() + if err != nil { + jsonErr(&c.Controller, 500, 500, "更新失败: "+err.Error()) + return + } + if dup > 0 { + jsonErr(&c.Controller, 400, 400, "标签名称已存在") + return + } + up["name"] = name + } + if p.Sort != nil { + up["sort"] = *p.Sort + } + if p.Status != nil { + up["status"] = *p.Status + } + if len(up) == 0 { + jsonErr(&c.Controller, 400, 400, "无更新字段") + return + } + up["update_time"] = time.Now() + + n, err := models.Orm.QueryTable(new(models.PlatformWebsiteTag)). + 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) +} + +// UpdateStatus PATCH /platform/website/tag/:id/status — body { status } +func (c *PlatformWebsiteTagController) UpdateStatus() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + id, ok := websiteID(&c.Controller) + if !ok { + return + } + var p struct { + Status *int8 `json:"status"` + } + if !websiteBind(&c.Controller, &p) || p.Status == nil { + jsonErr(&c.Controller, 400, 400, "状态参数错误") + return + } + n, err := models.Orm.QueryTable(new(models.PlatformWebsiteTag)). + Filter("id", id). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"status": *p.Status, "update_time": 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) +} + +// Delete DELETE /platform/website/tag/:id +func (c *PlatformWebsiteTagController) 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.PlatformWebsiteTag)). + 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 + } + // 同步清理文章关联,避免前台聚合到已删除标签 + _, _ = models.Orm.QueryTable(new(models.PlatformWebsiteArticleTag)). + Filter("tag_id", id). + Delete() + websiteOK(&c.Controller, nil) +} + +// ============================================================= +// 评论管理 +// ============================================================= + +type PlatformWebsiteCommentController struct { + beego.Controller +} + +// List GET /platform/website/comment/list +// query: keyword(内容/昵称)、status、article_id、page、pageSize +func (c *PlatformWebsiteCommentController) 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("content__icontains", kw). + Or("nickname__icontains", kw)) + } + if st := websiteStatusParam(&c.Controller); st >= 0 { + cond = cond.And("status", st) + } + if articleID, err := c.GetInt64("article_id"); err == nil && articleID > 0 { + cond = cond.And("article_id", articleID) + } + + qs := models.Orm.QueryTable(new(models.PlatformWebsiteComment)).SetCond(cond) + total, err := qs.Count() + if err != nil { + jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error()) + return + } + var rows []models.PlatformWebsiteComment + if _, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows); err != nil { + jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error()) + return + } + + articleIDs := make([]uint64, 0, len(rows)) + for _, r := range rows { + if r.ArticleID > 0 { + articleIDs = append(articleIDs, r.ArticleID) + } + } + titleMap := models.PlatformWebsiteArticleTitleMap(articleIDs) + + list := make([]map[string]interface{}, 0, len(rows)) + for _, r := range rows { + list = append(list, map[string]interface{}{ + "id": r.ID, + "article_id": r.ArticleID, + "article_title": titleMap[r.ArticleID], + "nickname": r.Nickname, + "email": r.Email, + "content": r.Content, + "reply": r.Reply, + "status": r.Status, + "ip": r.IP, + "create_time": r.CreateTime, + "update_time": r.UpdateTime, + }) + } + + websiteOK(&c.Controller, map[string]interface{}{"list": list, "total": total}) +} + +// Get GET /platform/website/comment/:id +func (c *PlatformWebsiteCommentController) Get() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + id, ok := websiteID(&c.Controller) + if !ok { + return + } + var row models.PlatformWebsiteComment + if err := models.Orm.QueryTable(new(models.PlatformWebsiteComment)). + Filter("id", id). + Filter("delete_time__isnull", true). + One(&row); err != nil { + jsonErr(&c.Controller, 404, 404, "评论不存在") + return + } + websiteOK(&c.Controller, row) +} + +// Audit POST /platform/website/comment/:id/audit — body { status: 1 通过 / 2 拒绝 } +func (c *PlatformWebsiteCommentController) Audit() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + id, ok := websiteID(&c.Controller) + if !ok { + return + } + var p struct { + Status *int8 `json:"status"` + } + if !websiteBind(&c.Controller, &p) || p.Status == nil { + jsonErr(&c.Controller, 400, 400, "状态参数错误") + return + } + if *p.Status != 1 && *p.Status != 2 { + jsonErr(&c.Controller, 400, 400, "仅支持通过(1)或拒绝(2)") + return + } + n, err := models.Orm.QueryTable(new(models.PlatformWebsiteComment)). + Filter("id", id). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"status": *p.Status, "update_time": 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) +} + +// Reply POST /platform/website/comment/:id/reply — body { reply } +func (c *PlatformWebsiteCommentController) Reply() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + id, ok := websiteID(&c.Controller) + if !ok { + return + } + var p struct { + Reply *string `json:"reply"` + } + if !websiteBind(&c.Controller, &p) || p.Reply == nil || strings.TrimSpace(*p.Reply) == "" { + jsonErr(&c.Controller, 400, 400, "回复内容不能为空") + return + } + n, err := models.Orm.QueryTable(new(models.PlatformWebsiteComment)). + Filter("id", id). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{ + "reply": strings.TrimSpace(*p.Reply), + "update_time": 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) +} + +// Delete DELETE /platform/website/comment/:id +func (c *PlatformWebsiteCommentController) 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.PlatformWebsiteComment)). + 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/comment/batchdelete — body { ids: [] } +func (c *PlatformWebsiteCommentController) BatchDelete() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + var raw struct { + IDs []uint64 `json:"ids"` + } + if !websiteBind(&c.Controller, &raw) || len(raw.IDs) == 0 { + jsonErr(&c.Controller, 400, 400, "请选择要删除的评论") + return + } + now := time.Now() + if _, err := models.Orm.QueryTable(new(models.PlatformWebsiteComment)). + Filter("id__in", raw.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/controllers/platform_website_site.go b/go/controllers/platform_website_site.go new file mode 100644 index 0000000..1337b06 --- /dev/null +++ b/go/controllers/platform_website_site.go @@ -0,0 +1,707 @@ +package controllers + +import ( + "strings" + "time" + + "server/models" + + beego "github.com/beego/beego/v2/server/web" +) + +// ============================================================= +// 平台官网 - 导航菜单 /platform/website/nav/* +// - 友情链接 /platform/website/link/* +// - 单页管理 /platform/website/page/* +// 表:yz_platform_website_nav / yz_platform_website_link / yz_platform_website_page +// ============================================================= + +type PlatformWebsiteNavController struct { + beego.Controller +} + +type platformWebsiteNavPayload struct { + Pid *uint64 `json:"pid"` + Title *string `json:"title"` + Type *int8 `json:"type"` + Path *string `json:"path"` + ComponentPath *string `json:"component_path"` + Icon *string `json:"icon"` + Sort *int `json:"sort"` + Status *int8 `json:"status"` + IsVisible *int8 `json:"is_visible"` + Permission *string `json:"permission"` +} + +// normalize 按菜单类型规范化字段,并返回校验错误信息 +func (p *platformWebsiteNavPayload) normalize() (path, componentPath string, errMsg string) { + navType := int8(1) + if p.Type != nil { + navType = *p.Type + } + if navType < 1 || navType > 4 { + return "", "", "菜单类型不正确" + } + if p.Path != nil { + path = strings.TrimSpace(*p.Path) + } + if p.ComponentPath != nil { + componentPath = strings.TrimSpace(*p.ComponentPath) + } + switch navType { + case 3: // 外链 + if path == "" { + return "", "", "外链地址不能为空" + } + if !strings.HasPrefix(path, "http://") && !strings.HasPrefix(path, "https://") { + return "", "", "外链地址需以 http:// 或 https:// 开头" + } + componentPath = "" + case 2: // 页面 + if path == "" { + return "", "", "路由地址不能为空" + } + if componentPath == "" { + return "", "", "页面类型需填写组件路径" + } + default: // 目录 / 单页 + if path == "" { + return "", "", "路由地址不能为空" + } + componentPath = "" + } + return path, componentPath, "" +} + +// List GET /platform/website/nav/list — 全量平铺返回,前端自行组树 +func (c *PlatformWebsiteNavController) List() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + var rows []models.PlatformWebsiteNav + if _, err := models.Orm.QueryTable(new(models.PlatformWebsiteNav)). + Filter("delete_time__isnull", true). + OrderBy("sort", "id"). + All(&rows); err != nil { + jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error()) + return + } + websiteOK(&c.Controller, rows) +} + +// Create POST /platform/website/nav +func (c *PlatformWebsiteNavController) Create() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + var p platformWebsiteNavPayload + if !websiteBind(&c.Controller, &p) { + return + } + if p.Title == nil || strings.TrimSpace(*p.Title) == "" { + jsonErr(&c.Controller, 400, 400, "菜单名称不能为空") + return + } + path, componentPath, errMsg := p.normalize() + if errMsg != "" { + jsonErr(&c.Controller, 400, 400, errMsg) + return + } + + row := models.PlatformWebsiteNav{ + Title: strings.TrimSpace(*p.Title), + Path: path, + ComponentPath: componentPath, + Status: 1, + IsVisible: 1, + Type: 1, + } + if p.Pid != nil { + row.Pid = *p.Pid + } + if p.Type != nil { + row.Type = *p.Type + } + if p.Icon != nil { + row.Icon = strings.TrimSpace(*p.Icon) + } + if p.Sort != nil { + row.Sort = *p.Sort + } + if p.Status != nil { + row.Status = *p.Status + } + if p.IsVisible != nil { + row.IsVisible = *p.IsVisible + } + if p.Permission != nil { + row.Permission = strings.TrimSpace(*p.Permission) + } + + 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/nav/:id +func (c *PlatformWebsiteNavController) Update() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + id, ok := websiteID(&c.Controller) + if !ok { + return + } + var p platformWebsiteNavPayload + if !websiteBind(&c.Controller, &p) { + return + } + + // 类型/路由/组件路径需要联合校验,先取当前记录作为基线 + var current models.PlatformWebsiteNav + if err := models.Orm.QueryTable(new(models.PlatformWebsiteNav)). + Filter("id", id). + Filter("delete_time__isnull", true). + One(¤t); err != nil { + jsonErr(&c.Controller, 404, 404, "菜单不存在") + return + } + merged := platformWebsiteNavPayload{ + Type: p.Type, + Path: p.Path, + ComponentPath: p.ComponentPath, + } + if merged.Type == nil { + t := current.Type + merged.Type = &t + } + if merged.Path == nil { + v := current.Path + merged.Path = &v + } + if merged.ComponentPath == nil { + v := current.ComponentPath + merged.ComponentPath = &v + } + path, componentPath, errMsg := merged.normalize() + if errMsg != "" { + jsonErr(&c.Controller, 400, 400, errMsg) + return + } + + up := map[string]interface{}{ + "path": path, + "component_path": componentPath, + "type": *merged.Type, + } + if p.Pid != nil { + if *p.Pid == id { + jsonErr(&c.Controller, 400, 400, "父级菜单不能是自己") + return + } + up["pid"] = *p.Pid + } + if p.Title != nil { + title := strings.TrimSpace(*p.Title) + if title == "" { + jsonErr(&c.Controller, 400, 400, "菜单名称不能为空") + return + } + up["title"] = title + } + if p.Icon != nil { + up["icon"] = strings.TrimSpace(*p.Icon) + } + if p.Sort != nil { + up["sort"] = *p.Sort + } + if p.Status != nil { + up["status"] = *p.Status + } + if p.IsVisible != nil { + up["is_visible"] = *p.IsVisible + } + if p.Permission != nil { + up["permission"] = strings.TrimSpace(*p.Permission) + } + up["update_time"] = time.Now() + + n, err := models.Orm.QueryTable(new(models.PlatformWebsiteNav)). + 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) +} + +// Delete DELETE /platform/website/nav/:id — 有子菜单时禁止删除 +func (c *PlatformWebsiteNavController) Delete() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + id, ok := websiteID(&c.Controller) + if !ok { + return + } + child, err := models.Orm.QueryTable(new(models.PlatformWebsiteNav)). + Filter("pid", id). + Filter("delete_time__isnull", true). + Count() + if err != nil { + jsonErr(&c.Controller, 500, 500, "删除失败: "+err.Error()) + return + } + if child > 0 { + jsonErr(&c.Controller, 400, 400, "该菜单下存在子菜单,请先删除子菜单") + return + } + now := time.Now() + n, err := models.Orm.QueryTable(new(models.PlatformWebsiteNav)). + 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) +} + +// ============================================================= +// 友情链接 +// ============================================================= + +type PlatformWebsiteLinkController struct { + beego.Controller +} + +type platformWebsiteLinkPayload struct { + LinkName *string `json:"link_name"` + LinkURL *string `json:"link_url"` + LinkLogo *string `json:"link_logo"` + Description *string `json:"description"` + Sort *int `json:"sort"` + Status *int8 `json:"status"` +} + +// List GET /platform/website/link/list?page&pageSize(limit)&keyword&status +func (c *PlatformWebsiteLinkController) List() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + page, pageSize := websitePaging(&c.Controller) + qs := models.Orm.QueryTable(new(models.PlatformWebsiteLink)). + Filter("delete_time__isnull", true) + if kw := strings.TrimSpace(c.GetString("keyword")); kw != "" { + qs = qs.Filter("link_name__icontains", kw) + } + if st := websiteStatusParam(&c.Controller); st >= 0 { + qs = qs.Filter("status", st) + } + total, err := qs.Count() + if err != nil { + jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error()) + return + } + var rows []models.PlatformWebsiteLink + if _, err = qs.OrderBy("sort", "-id").Limit(pageSize, (page-1)*pageSize).All(&rows); err != nil { + jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error()) + return + } + websiteOK(&c.Controller, map[string]interface{}{"list": rows, "total": total}) +} + +// All GET /platform/website/link/all — 全量(仅启用),供官网/下拉使用 +func (c *PlatformWebsiteLinkController) All() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + var rows []models.PlatformWebsiteLink + if _, err := models.Orm.QueryTable(new(models.PlatformWebsiteLink)). + Filter("delete_time__isnull", true). + Filter("status", 1). + OrderBy("sort", "-id"). + All(&rows); err != nil { + jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error()) + return + } + websiteOK(&c.Controller, rows) +} + +// Create POST /platform/website/link +func (c *PlatformWebsiteLinkController) Create() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + var p platformWebsiteLinkPayload + if !websiteBind(&c.Controller, &p) { + return + } + if p.LinkName == nil || strings.TrimSpace(*p.LinkName) == "" { + jsonErr(&c.Controller, 400, 400, "链接名称不能为空") + return + } + if p.LinkURL == nil || strings.TrimSpace(*p.LinkURL) == "" { + jsonErr(&c.Controller, 400, 400, "链接地址不能为空") + return + } + row := models.PlatformWebsiteLink{ + LinkName: strings.TrimSpace(*p.LinkName), + LinkURL: strings.TrimSpace(*p.LinkURL), + Status: 1, + } + if p.LinkLogo != nil { + row.LinkLogo = strings.ReplaceAll(*p.LinkLogo, "\\", "/") + } + if p.Description != nil { + row.Description = *p.Description + } + if p.Sort != nil { + row.Sort = *p.Sort + } + if p.Status != nil { + row.Status = *p.Status + } + 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 PUT /platform/website/link/:id — 也支持只传部分字段(如仅 status) +func (c *PlatformWebsiteLinkController) Update() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + id, ok := websiteID(&c.Controller) + if !ok { + return + } + var p platformWebsiteLinkPayload + if !websiteBind(&c.Controller, &p) { + return + } + up := map[string]interface{}{} + if p.LinkName != nil { + name := strings.TrimSpace(*p.LinkName) + if name == "" { + jsonErr(&c.Controller, 400, 400, "链接名称不能为空") + return + } + up["link_name"] = name + } + if p.LinkURL != nil { + url := strings.TrimSpace(*p.LinkURL) + if url == "" { + jsonErr(&c.Controller, 400, 400, "链接地址不能为空") + return + } + up["link_url"] = url + } + if p.LinkLogo != nil { + up["link_logo"] = strings.ReplaceAll(*p.LinkLogo, "\\", "/") + } + if p.Description != nil { + up["description"] = *p.Description + } + if p.Sort != nil { + up["sort"] = *p.Sort + } + if p.Status != nil { + up["status"] = *p.Status + } + if len(up) == 0 { + jsonErr(&c.Controller, 400, 400, "无更新字段") + return + } + up["update_time"] = time.Now() + + n, err := models.Orm.QueryTable(new(models.PlatformWebsiteLink)). + 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) +} + +// Delete DELETE /platform/website/link/:id +func (c *PlatformWebsiteLinkController) 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.PlatformWebsiteLink)). + 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/link/batchdelete — body { ids: [] } +func (c *PlatformWebsiteLinkController) BatchDelete() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + var raw struct { + IDs []uint64 `json:"ids"` + } + if !websiteBind(&c.Controller, &raw) || len(raw.IDs) == 0 { + jsonErr(&c.Controller, 400, 400, "请选择要删除的链接") + return + } + now := time.Now() + if _, err := models.Orm.QueryTable(new(models.PlatformWebsiteLink)). + Filter("id__in", raw.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) +} + +// ============================================================= +// 单页管理 +// ============================================================= + +type PlatformWebsitePageController struct { + beego.Controller +} + +type platformWebsitePagePayload struct { + Title *string `json:"title"` + Path *string `json:"path"` + Content *string `json:"content"` + Sort *int `json:"sort"` + Status *int8 `json:"status"` +} + +// normalizePath 统一去掉前导斜杠,与前端提交约定保持一致 +func normalizePath(raw string) string { + return strings.Trim(strings.TrimSpace(raw), "/") +} + +// All GET /platform/website/page/all — 全量返回 +func (c *PlatformWebsitePageController) All() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + var rows []models.PlatformWebsitePage + if _, err := models.Orm.QueryTable(new(models.PlatformWebsitePage)). + Filter("delete_time__isnull", true). + OrderBy("sort", "id"). + All(&rows); err != nil { + jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error()) + return + } + websiteOK(&c.Controller, rows) +} + +// Get GET /platform/website/page/:id +func (c *PlatformWebsitePageController) Get() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + id, ok := websiteID(&c.Controller) + if !ok { + return + } + var row models.PlatformWebsitePage + if err := models.Orm.QueryTable(new(models.PlatformWebsitePage)). + 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/page +func (c *PlatformWebsitePageController) Create() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + var p platformWebsitePagePayload + if !websiteBind(&c.Controller, &p) { + return + } + if p.Title == nil || strings.TrimSpace(*p.Title) == "" { + jsonErr(&c.Controller, 400, 400, "单页标题不能为空") + return + } + path := "" + if p.Path != nil { + path = normalizePath(*p.Path) + } + if path == "" { + jsonErr(&c.Controller, 400, 400, "路由不能为空") + return + } + exist, err := models.Orm.QueryTable(new(models.PlatformWebsitePage)). + Filter("path", path). + Filter("delete_time__isnull", true). + Count() + if err != nil { + jsonErr(&c.Controller, 500, 500, "创建失败: "+err.Error()) + return + } + if exist > 0 { + jsonErr(&c.Controller, 400, 400, "该路由已存在") + return + } + + row := models.PlatformWebsitePage{ + Title: strings.TrimSpace(*p.Title), + Path: path, + Status: 1, + } + if p.Content != nil { + row.Content = *p.Content + } + if p.Sort != nil { + row.Sort = *p.Sort + } + if p.Status != nil { + row.Status = *p.Status + } + 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/page/:id +func (c *PlatformWebsitePageController) Update() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + id, ok := websiteID(&c.Controller) + if !ok { + return + } + var p platformWebsitePagePayload + if !websiteBind(&c.Controller, &p) { + return + } + up := map[string]interface{}{} + if p.Title != nil { + title := strings.TrimSpace(*p.Title) + if title == "" { + jsonErr(&c.Controller, 400, 400, "单页标题不能为空") + return + } + up["title"] = title + } + if p.Path != nil { + path := normalizePath(*p.Path) + if path == "" { + jsonErr(&c.Controller, 400, 400, "路由不能为空") + return + } + dup, err := models.Orm.QueryTable(new(models.PlatformWebsitePage)). + Filter("path", path). + Filter("delete_time__isnull", true). + Exclude("id", id). + Count() + if err != nil { + jsonErr(&c.Controller, 500, 500, "更新失败: "+err.Error()) + return + } + if dup > 0 { + jsonErr(&c.Controller, 400, 400, "该路由已存在") + return + } + up["path"] = path + } + if p.Content != nil { + up["content"] = *p.Content + } + if p.Sort != nil { + up["sort"] = *p.Sort + } + if p.Status != nil { + up["status"] = *p.Status + } + if len(up) == 0 { + jsonErr(&c.Controller, 400, 400, "无更新字段") + return + } + up["update_time"] = time.Now() + + n, err := models.Orm.QueryTable(new(models.PlatformWebsitePage)). + 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) +} + +// Delete DELETE /platform/website/page/:id +func (c *PlatformWebsitePageController) 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.PlatformWebsitePage)). + 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) +} diff --git a/go/docs/sql/alter_backend_crm_columns.sql b/go/docs/sql/alter_backend_crm_columns.sql deleted file mode 100644 index 40dfb06..0000000 --- a/go/docs/sql/alter_backend_crm_columns.sql +++ /dev/null @@ -1,144 +0,0 @@ --- ============================================================= --- CRM 各表现有表补齐列(历史库升级用) --- 覆盖: --- 1) yz_backend_crm_contract 我方角色 our_role --- 2) yz_backend_crm_clue 预计金额 amount --- 3) yz_backend_crm_project 产品清单 products / 文档库分类 doc_category_id --- 4) 客户/供应商 create_user_id(历史表名差异:yz_backend_erp_customer 与 yz_tenant_crm_customer, --- 模型 TableName 为前者,初始化 SQL 中为后者,两个候选表名都执行) --- 5) 客户公海字段(in_pool / owner_user_id / owner_user_name / --- last_owner_name / pool_in_time / pool_reason) --- 6) yz_backend_crm_product 软件开发字段(line_type / dev_unit_price / --- dev_mode / dev_tree;dev_mode 另有单独文件 alter_backend_crm_product_dev_mode.sql) --- 本脚本可重复执行:表或列不存在时才执行 ALTER,已存在自动跳过(不再报 1060/1146)。 --- 运行:mysql -h -P -u -p < alter_backend_crm_columns.sql --- ============================================================= - --- 1) 合同表:我方角色 -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_contract'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_contract' AND COLUMN_NAME = 'our_role'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_crm_contract` ADD COLUMN `our_role` tinyint NOT NULL DEFAULT 2 COMMENT ''我方角色:1甲方/2乙方/3丙方/4丁方''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- 2) 线索表:预计金额 -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_clue'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_clue' AND COLUMN_NAME = 'amount'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_crm_clue` ADD COLUMN `amount` decimal(14,2) NOT NULL DEFAULT ''0.00'' COMMENT ''预计金额''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- 3) 项目表:产品清单 -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_project'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_project' AND COLUMN_NAME = 'products'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_crm_project` ADD COLUMN `products` text COMMENT ''项目产品清单JSON(写入产品管理时使用)''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- 3) 项目表:文档库文件夹分类 -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_project'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_project' AND COLUMN_NAME = 'doc_category_id'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_crm_project` ADD COLUMN `doc_category_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT ''关联OA文档库项目文件夹分类ID''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- 4) 客户:创建人(删除权限校验) -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_customer'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_customer' AND COLUMN_NAME = 'create_user_id'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_erp_customer` ADD COLUMN `create_user_id` varchar(64) NOT NULL DEFAULT '''' COMMENT ''创建人用户ID,用于删除权限校验''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_customer'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_customer' AND COLUMN_NAME = 'create_user_id'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_tenant_crm_customer` ADD COLUMN `create_user_id` varchar(64) NOT NULL DEFAULT '''' COMMENT ''创建人用户ID,用于删除权限校验''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- 4) 供应商:创建人(删除权限校验) -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_supplier'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_supplier' AND COLUMN_NAME = 'create_user_id'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_erp_supplier` ADD COLUMN `create_user_id` varchar(64) NOT NULL DEFAULT '''' COMMENT ''创建人用户ID,用于删除权限校验''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_supplier'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_supplier' AND COLUMN_NAME = 'create_user_id'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_tenant_crm_supplier` ADD COLUMN `create_user_id` varchar(64) NOT NULL DEFAULT '''' COMMENT ''创建人用户ID,用于删除权限校验''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- 5) 客户公海字段:yz_backend_erp_customer -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_customer'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_customer' AND COLUMN_NAME = 'in_pool'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_erp_customer` ADD COLUMN `in_pool` tinyint NOT NULL DEFAULT 0 COMMENT ''是否在公海:0-否 1-是''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_customer'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_customer' AND COLUMN_NAME = 'owner_user_id'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_erp_customer` ADD COLUMN `owner_user_id` varchar(64) NOT NULL DEFAULT '''' COMMENT ''负责人ID,公海中为空''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_customer'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_customer' AND COLUMN_NAME = 'owner_user_name'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_erp_customer` ADD COLUMN `owner_user_name` varchar(128) NOT NULL DEFAULT '''' COMMENT ''负责人姓名''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_customer'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_customer' AND COLUMN_NAME = 'last_owner_name'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_erp_customer` ADD COLUMN `last_owner_name` varchar(128) NOT NULL DEFAULT '''' COMMENT ''移入公海前的原负责人''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_customer'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_customer' AND COLUMN_NAME = 'pool_in_time'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_erp_customer` ADD COLUMN `pool_in_time` datetime DEFAULT NULL COMMENT ''进入公海时间''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_customer'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_customer' AND COLUMN_NAME = 'pool_reason'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_erp_customer` ADD COLUMN `pool_reason` varchar(200) NOT NULL DEFAULT '''' COMMENT ''移入公海原因''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- 5) 客户公海字段:yz_tenant_crm_customer(历史表名) -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_customer'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_customer' AND COLUMN_NAME = 'in_pool'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_tenant_crm_customer` ADD COLUMN `in_pool` tinyint NOT NULL DEFAULT 0 COMMENT ''是否在公海:0-否 1-是''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_customer'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_customer' AND COLUMN_NAME = 'owner_user_id'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_tenant_crm_customer` ADD COLUMN `owner_user_id` varchar(64) NOT NULL DEFAULT '''' COMMENT ''负责人ID,公海中为空''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_customer'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_customer' AND COLUMN_NAME = 'owner_user_name'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_tenant_crm_customer` ADD COLUMN `owner_user_name` varchar(128) NOT NULL DEFAULT '''' COMMENT ''负责人姓名''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_customer'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_customer' AND COLUMN_NAME = 'last_owner_name'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_tenant_crm_customer` ADD COLUMN `last_owner_name` varchar(128) NOT NULL DEFAULT '''' COMMENT ''移入公海前的原负责人''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_customer'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_customer' AND COLUMN_NAME = 'pool_in_time'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_tenant_crm_customer` ADD COLUMN `pool_in_time` datetime DEFAULT NULL COMMENT ''进入公海时间''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_customer'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_customer' AND COLUMN_NAME = 'pool_reason'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_tenant_crm_customer` ADD COLUMN `pool_reason` varchar(200) NOT NULL DEFAULT '''' COMMENT ''移入公海原因''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- 6) 产品表:软件开发字段 -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_product'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_product' AND COLUMN_NAME = 'line_type'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_crm_product` ADD COLUMN `line_type` varchar(20) NOT NULL DEFAULT ''product'' COMMENT ''行类型:product成品/dev软件开发''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_product'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_product' AND COLUMN_NAME = 'dev_unit_price'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_crm_product` ADD COLUMN `dev_unit_price` decimal(14,2) NOT NULL DEFAULT ''0.00'' COMMENT ''软件开发统一人天单价''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_product'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_product' AND COLUMN_NAME = 'dev_mode'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_crm_product` ADD COLUMN `dev_mode` varchar(20) NOT NULL DEFAULT ''self'' COMMENT ''软件开发研发方式:self自行研发/outsource外协研发''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_product'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_product' AND COLUMN_NAME = 'dev_tree'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_crm_product` ADD COLUMN `dev_tree` text COMMENT ''软件开发模块树JSON(叶子人天×统一人天单价)''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/go/docs/sql/alter_backend_crm_overdue.sql b/go/docs/sql/alter_backend_crm_overdue.sql deleted file mode 100644 index eab60f5..0000000 --- a/go/docs/sql/alter_backend_crm_overdue.sql +++ /dev/null @@ -1,68 +0,0 @@ --- ============================================================= --- CRM 逾期(overdue)判定与索引 --- 说明: --- 逾期以「当前日期」实时判定,无需新增存储字段。 --- 前端在列表/详情中展示「逾期 N 天」标签,规则如下: --- - 项目 : 结束日期(end_date) < 今天 且 状态 != 已完成(3) --- - 合同 : 到期日期(expire_date) < 今天 且 状态 != 已完成(2) / 已作废(3) --- - 回款明细: 计划回款日期(plan_date) < 今天 且 明细状态 != 已回款(3) --- 本脚本仅补充判定所需的日期索引,并给出等价 SQL 供统计/定时任务使用。 --- ============================================================= - --- 1) 项目:结束日期索引(逾期判定 + 按结束日期排序) --- 可重复执行:索引不存在时才执行 ALTER,已存在自动跳过(不再报 1061 Duplicate key)。 -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_project'); -SET @idx := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_project' AND INDEX_NAME = 'idx_end_date'); -SET @ddl := IF(@tbl > 0 AND @idx = 0, 'ALTER TABLE `yz_backend_crm_project` ADD INDEX `idx_end_date` (`end_date`)', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- 2) 合同:到期日期索引(逾期判定 + 按到期日期排序) -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_contract'); -SET @idx := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_contract' AND INDEX_NAME = 'idx_expire_date'); -SET @ddl := IF(@tbl > 0 AND @idx = 0, 'ALTER TABLE `yz_backend_crm_contract` ADD INDEX `idx_expire_date` (`expire_date`)', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- 3) 回款明细逾期:plan_date 内嵌于 items(JSON),无法建单列索引。 --- 如需按「逾期回款明细」做服务端统计,建议将回款明细拆为独立子表 --- (如 yz_backend_crm_payback_item,含 plan_date DATE 列),再建索引。 --- 当前版本逾期在应用层(前端/接口)实时计算,items 已含 plan_date。 - --- ============================================================= --- 等价查询(统计「逾期」记录,供运营/定时任务参考) --- 注意:DATE(end_date) < CURDATE() 表示「结束日期在今天之前」。 --- ============================================================= - --- 逾期项目(结束日期已过且未完成) -SELECT id, project_name, end_date, status -FROM `yz_backend_crm_project` -WHERE `delete_time` IS NULL - AND `end_date` IS NOT NULL - AND DATE(`end_date`) < CURDATE() - AND `status` <> 3; -- 3=已完成 - --- 逾期合同(到期日期已过且非已完成/已作废) -SELECT id, contract_name, expire_date, status -FROM `yz_backend_crm_contract` -WHERE `delete_time` IS NULL - AND `expire_date` IS NOT NULL - AND DATE(`expire_date`) < CURDATE() - AND `status` NOT IN (2, 3); -- 2=已完成, 3=已作废 - --- 逾期回款明细(MySQL 5.7+ 可用 JSON 函数;低版本建议在应用层计算) --- 列出「存在任一逾期明细」的回款计划 -SELECT p.id, p.contract_name, - j.seq, j.name, j.plan_date, j.status -FROM `yz_backend_crm_payback` p, - JSON_TABLE( - p.items, - '$[*]' COLUMNS ( - seq INT PATH '$.seq', - name VARCHAR(100) PATH '$.name', - plan_date DATE PATH '$.plan_date', - status INT PATH '$.status' - ) - ) j -WHERE p.`delete_time` IS NULL - AND j.plan_date IS NOT NULL - AND DATE(j.plan_date) < CURDATE() - AND j.status <> 3; -- 3=已回款 diff --git a/go/docs/sql/alter_backend_crm_payback_plan_date.sql b/go/docs/sql/alter_backend_crm_payback_plan_date.sql deleted file mode 100644 index a231c86..0000000 --- a/go/docs/sql/alter_backend_crm_payback_plan_date.sql +++ /dev/null @@ -1,42 +0,0 @@ --- ============================================================= --- 回款计划表(yz_backend_crm_payback)建表 / 核对脚本 --- ============================================================= --- 说明: --- 1. 计划明细 items 以 JSON 文本存储,plan_date(计划回款日期)已内置于 --- 各类 plan_type 的明细结构中,无需新增独立列,故无 ALTER TABLE 语句。 --- 2. 各 plan_type 的 items 明细结构: --- 月度/季度/年度:{"seq":int,"name":"第1期","amount":0,"plan_date":"2026-10-01"} --- 进度: {"seq":int,"name":"预付款","percent":30,"amount":0,"plan_date":"2026-10-01"} --- 自定义: {"seq":int,"name":"首付款","plan_date":"2026-10-01","amount":0} --- 每行另含 received_amount / received_date / status(回款进度登记用)。 --- 3. 执行下方建表脚本可创建或核对表结构(幂等,已存在则跳过)。 --- ============================================================= - -CREATE TABLE IF NOT EXISTS `yz_backend_crm_payback` ( - `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - `tenant_id` VARCHAR(64) NOT NULL DEFAULT '', - `contract_id` BIGINT UNSIGNED NULL, - `contract_no` VARCHAR(50) NOT NULL DEFAULT '', - `contract_name` VARCHAR(100) NOT NULL DEFAULT '', - `customer_id` BIGINT UNSIGNED NULL, - `customer_name` VARCHAR(128) NOT NULL DEFAULT '', - `plan_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1月度/2季度/3年度/4进度/5自定义', - `pay_method` TINYINT NOT NULL DEFAULT 1 COMMENT '1对公/2网银/3现金/4支票/5支付宝/6微信/7其他', - `remind_days` INT NOT NULL DEFAULT 0 COMMENT '提前几天提醒', - `owner_user_id` VARCHAR(64) NOT NULL DEFAULT '', - `owner_user_name` VARCHAR(128) NOT NULL DEFAULT '', - `contract_amount` DECIMAL(14,2) NOT NULL DEFAULT 0 COMMENT '合同总金额', - `total_amount` DECIMAL(14,2) NOT NULL DEFAULT 0 COMMENT '计划回款总额', - `received_amount` DECIMAL(14,2) NOT NULL DEFAULT 0 COMMENT '已回款金额', - `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1进行中/2已完成', - `items` TEXT NULL COMMENT '计划明细 JSON 数组(含 plan_date 计划回款日期)', - `remark` TEXT NULL, - `create_user_id` VARCHAR(64) NOT NULL DEFAULT '', - `create_time` DATETIME NOT NULL, - `update_time` DATETIME NOT NULL, - `delete_time` DATETIME NULL, - PRIMARY KEY (`id`), - KEY `idx_yz_backend_crm_payback_tenant` (`tenant_id`), - KEY `idx_yz_backend_crm_payback_contract` (`contract_id`), - KEY `idx_yz_backend_crm_payback_delete` (`delete_time`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='回款计划表'; diff --git a/go/docs/sql/alter_backend_crm_payback_receipt.sql b/go/docs/sql/alter_backend_crm_payback_receipt.sql deleted file mode 100644 index a4edee0..0000000 --- a/go/docs/sql/alter_backend_crm_payback_receipt.sql +++ /dev/null @@ -1,21 +0,0 @@ --- ============================================================= --- CRM 回款计划:流水回执(receipt)字段 --- 说明: --- 回款登记支持上传「流水回执」(银行流水 / 转账凭证等),非必填。 --- 新增两列用于保存回执文件地址与文件名,随回款计划主表一并存取。 --- 文件本身经 /backend/uploadfile 上传到文件存储,此处仅存其可访问地址。 --- ============================================================= - --- 回款计划表:流水回执地址 + 文件名(均允许为空) --- 可重复执行:列不存在时才执行 ALTER,已存在自动跳过(不再报 1060 Duplicate column)。 - --- receipt_url -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_payback'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_payback' AND COLUMN_NAME = 'receipt_url'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_crm_payback` ADD COLUMN `receipt_url` VARCHAR(512) NULL COMMENT ''流水回执文件地址(选填)'' AFTER `remark`', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- receipt_name -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_payback' AND COLUMN_NAME = 'receipt_name'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_crm_payback` ADD COLUMN `receipt_name` VARCHAR(255) NULL COMMENT ''流水回执文件名(选填)'' AFTER `receipt_url`', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/go/docs/sql/alter_backend_crm_product_dev_mode.sql b/go/docs/sql/alter_backend_crm_product_dev_mode.sql deleted file mode 100644 index 7634810..0000000 --- a/go/docs/sql/alter_backend_crm_product_dev_mode.sql +++ /dev/null @@ -1,20 +0,0 @@ --- ============================================================= --- Product dev_mode: distinguish self-developed vs outsourced --- (software development has two kinds: developed by ourselves, --- or developed by others for us) --- Table: yz_backend_crm_product --- dev_mode varchar(20) NOT NULL DEFAULT 'self' --- 'self' -> developed in-house (default) --- 'outsource' -> outsourced / developed by others for us --- Note: server-side auto-migration has been removed; run this file --- manually. Re-running reports --- "Duplicate column name", which is safe. --- Run: mysql -h -P -u -p < alter_backend_crm_product_dev_mode.sql --- NOTE: keep this file ASCII-only; piping through some shells mangles non-ASCII. --- ============================================================= - --- idempotent: ALTER only runs when the column is missing (no 1060 on re-run) -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_product'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_product' AND COLUMN_NAME = 'dev_mode'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_crm_product` ADD COLUMN `dev_mode` VARCHAR(20) NOT NULL DEFAULT ''self'' COMMENT ''software dev mode: self in-house / outsource developed by others'' AFTER `dev_unit_price`', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/go/docs/sql/alter_contact_mobiles.sql b/go/docs/sql/alter_contact_mobiles.sql deleted file mode 100644 index 6870fe0..0000000 --- a/go/docs/sql/alter_contact_mobiles.sql +++ /dev/null @@ -1,55 +0,0 @@ --- ============================================================= --- Contacts: support multiple mobile numbers (a person may have several) --- Tables: --- 1) yz_backend_crm_entity_contact : contacts of clue / business / project --- 2) yz_backend_erp_contact : global address book contacts --- 3) yz_backend_contact_company : company contacts (customers/suppliers), --- shared by project contact picker / address book / customer-supplier --- conversion. This one was missed before: without the column any SELECT --- fails and contact lists show as empty. Run this script manually --- (server-side auto-migration has been removed). --- All tables get a new column `mobiles` (TEXT, JSON array, max 5). --- The old `mobile` / `phone` column keeps the FIRST number for backward --- compatibility (list display, phone-based dedupe on sync), and existing --- single numbers are backfilled into `mobiles`. --- Run: mysql -h -P -u -p < alter_contact_mobiles.sql --- (idempotent: existing columns are skipped, safe to re-run) --- NOTE: keep this file ASCII-only; piping through some shells mangles non-ASCII. --- ============================================================= - --- 1) clue / business / project contacts --- idempotent: ALTER only runs when the column is missing (no 1060 on re-run) -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_entity_contact'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_entity_contact' AND COLUMN_NAME = 'mobiles'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_crm_entity_contact` ADD COLUMN `mobiles` TEXT NULL COMMENT ''phones in JSON array, max 5'' AFTER `mobile`', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- backfill existing single number as a one-element JSON array -UPDATE `yz_backend_crm_entity_contact` - SET `mobiles` = JSON_ARRAY(`mobile`) - WHERE (`mobiles` IS NULL OR `mobiles` = '') - AND `mobile` IS NOT NULL AND `mobile` <> ''; - --- 2) global address book contacts -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_contact'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_contact' AND COLUMN_NAME = 'mobiles'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_erp_contact` ADD COLUMN `mobiles` TEXT NULL COMMENT ''phones in JSON array, max 5'' AFTER `phone`', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -UPDATE `yz_backend_erp_contact` - SET `mobiles` = JSON_ARRAY(`phone`) - WHERE (`mobiles` IS NULL OR `mobiles` = '') - AND `phone` IS NOT NULL AND `phone` <> ''; - --- 3) company contacts (customers / suppliers) - shared by project contact --- picker / address book / customer-supplier conversion. --- NOTE: run this script manually (server-side auto-migration has been removed). -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_contact_company'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_contact_company' AND COLUMN_NAME = 'mobiles'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_contact_company` ADD COLUMN `mobiles` TEXT NULL COMMENT ''phones in JSON array, max 5'' AFTER `phone`', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -UPDATE `yz_backend_contact_company` - SET `mobiles` = JSON_ARRAY(`phone`) - WHERE (`mobiles` IS NULL OR `mobiles` = '') - AND `phone` IS NOT NULL AND `phone` <> ''; diff --git a/go/docs/sql/alter_crm_product_category_default.sql b/go/docs/sql/alter_crm_product_category_default.sql deleted file mode 100644 index 7f6894e..0000000 --- a/go/docs/sql/alter_crm_product_category_default.sql +++ /dev/null @@ -1,24 +0,0 @@ --- ============================================================= --- CRM product categories: system default category "未分类" --- Table: yz_backend_crm_product_category --- is_default = 1 -> system default category ("未分类"): kept by --- the system, cannot be edited or deleted, --- sort pinned to the top (-1) --- is_default = 0 -> tenant custom category (full CRUD) --- Existing "未分类" rows (if any) are marked as default below; --- the backend also self-heals the flag and creates the record --- on first use after upgrade. --- Run: mysql --default-character-set=utf8mb4 -h -P -u -p < alter_crm_product_category_default.sql --- (re-running only reports "Duplicate column name", which is safe) --- ============================================================= - --- 可重复执行:列不存在时才执行 ALTER,已存在自动跳过(不再报 1060 Duplicate column)。 -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_product_category'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_product_category' AND COLUMN_NAME = 'is_default'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_crm_product_category` ADD COLUMN `is_default` TINYINT NOT NULL DEFAULT 0 COMMENT ''1=system default category (read-only), 0=tenant custom'' AFTER `status`', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- Mark existing default category (same name as backend default) and pin it to the top -UPDATE `yz_backend_crm_product_category` - SET `is_default` = 1, `sort` = -1 - WHERE `name` = '未分类'; diff --git a/go/docs/sql/alter_crm_unit_default.sql b/go/docs/sql/alter_crm_unit_default.sql deleted file mode 100644 index c3e4e85..0000000 --- a/go/docs/sql/alter_crm_unit_default.sql +++ /dev/null @@ -1,22 +0,0 @@ --- ============================================================= --- CRM units: mark system default units as not editable/deletable --- Table: yz_backend_crm_unit --- is_default = 1 -> system default unit (seeded by the system, --- cannot be edited or deleted by tenants) --- is_default = 0 -> tenant custom unit (full CRUD) --- Existing seeded default units (matched by name) are marked below; --- the backend also self-heals the flag on first use after upgrade. --- Run: mysql --default-character-set=utf8mb4 -h -P -u -p < alter_crm_unit_default.sql --- (re-running only reports "Duplicate column name", which is safe) --- ============================================================= - --- 可重复执行:列不存在时才执行 ALTER,已存在自动跳过(不再报 1060 Duplicate column)。 -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_unit'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_unit' AND COLUMN_NAME = 'is_default'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_crm_unit` ADD COLUMN `is_default` TINYINT NOT NULL DEFAULT 0 COMMENT ''1=system default unit (read-only), 0=tenant custom'' AFTER `status`', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- Mark seeded default units (same list as backend crmDefaultUnits) -UPDATE `yz_backend_crm_unit` - SET `is_default` = 1 - WHERE `name` IN ('套', '台', '个', '件', '批', '次', '项', '人月', '人天', '年', '月', '米', '平方米', '吨', '千克', '箱'); diff --git a/go/docs/sql/alter_oa_schedule_carry_columns.sql b/go/docs/sql/alter_oa_schedule_carry_columns.sql deleted file mode 100644 index 461e709..0000000 --- a/go/docs/sql/alter_oa_schedule_carry_columns.sql +++ /dev/null @@ -1,48 +0,0 @@ --- ============================================================= --- OA日程表补齐「顺延 / 相关图片 / 提醒绑定」字段:yz_backend_oa_schedule --- 变更: --- source_id bigint unsigned NOT NULL DEFAULT 0 顺延来源日程ID 0-原始日程 --- carry_over tinyint NOT NULL DEFAULT 0 是否已顺延到下一天 0-否 1-是 --- carry_count int NOT NULL DEFAULT 0 累计顺延次数 --- carry_to_date varchar(10) NOT NULL DEFAULT '' 顺延目标日期(YYYY-MM-DD) --- images text NULL 相关图片JSON数组 [{"id":1,"url":"","name":""}] --- reminder_id bigint unsigned NOT NULL DEFAULT 0 绑定的提醒任务ID(对应 yz_backend_schedule.id) 0-无提醒 --- 本脚本可重复执行:列不存在时才执行 ALTER,已存在自动跳过(不再报 1060 Duplicate column)。 --- 运行:mysql -h -P -u -p < alter_oa_schedule_carry_columns.sql --- ============================================================= - --- source_id:顺延来源日程ID -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule' AND COLUMN_NAME = 'source_id'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_oa_schedule` ADD COLUMN `source_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT ''顺延来源日程ID 0-原始日程''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- carry_over:是否已顺延到下一天 -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule' AND COLUMN_NAME = 'carry_over'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_oa_schedule` ADD COLUMN `carry_over` tinyint NOT NULL DEFAULT 0 COMMENT ''是否已顺延到下一天 0-否 1-是''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- carry_count:累计顺延次数 -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule' AND COLUMN_NAME = 'carry_count'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_oa_schedule` ADD COLUMN `carry_count` int NOT NULL DEFAULT 0 COMMENT ''累计顺延次数''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- carry_to_date:顺延目标日期 -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule' AND COLUMN_NAME = 'carry_to_date'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_oa_schedule` ADD COLUMN `carry_to_date` varchar(10) NOT NULL DEFAULT '''' COMMENT ''顺延目标日期(YYYY-MM-DD)''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- images:相关图片 -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule' AND COLUMN_NAME = 'images'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_oa_schedule` ADD COLUMN `images` text NULL COMMENT ''相关图片JSON数组 [{id,url,name}]''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- reminder_id:绑定的提醒任务ID -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule' AND COLUMN_NAME = 'reminder_id'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_oa_schedule` ADD COLUMN `reminder_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT ''绑定的提醒任务ID(对应yz_backend_schedule.id) 0-无提醒''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/go/docs/sql/alter_payback_record_item.sql b/go/docs/sql/alter_payback_record_item.sql deleted file mode 100644 index 808772b..0000000 --- a/go/docs/sql/alter_payback_record_item.sql +++ /dev/null @@ -1,23 +0,0 @@ --- ============================================================= --- Payback records: mark which plan item (progress node / period) --- a received payment belongs to, so payments can be registered --- per progress node and shown as tree children under the plan. --- Table: yz_backend_crm_payback_record --- item_seq : seq of the plan items entry (1-based), 0 = not specified --- item_name : node name snapshot (e.g. "预付款") --- Run: mysql -h -P -u -p < alter_payback_record_item.sql --- (re-running only reports "Duplicate column name", which is safe) --- ============================================================= - --- 可重复执行:列不存在时才执行 ALTER,已存在自动跳过(不再报 1060 Duplicate column)。 - --- item_seq -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_payback_record'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_payback_record' AND COLUMN_NAME = 'item_seq'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_crm_payback_record` ADD COLUMN `item_seq` INT NOT NULL DEFAULT 0 COMMENT ''plan item seq (1-based), 0 = not specified'' AFTER `payback_id`', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- item_name -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_payback_record' AND COLUMN_NAME = 'item_name'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_crm_payback_record` ADD COLUMN `item_name` VARCHAR(50) NULL COMMENT ''plan item name snapshot'' AFTER `item_seq`', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/go/docs/sql/alter_platform_payment_order_promoter.sql b/go/docs/sql/alter_platform_payment_order_promoter.sql deleted file mode 100644 index 914f6cd..0000000 --- a/go/docs/sql/alter_platform_payment_order_promoter.sql +++ /dev/null @@ -1,30 +0,0 @@ --- ============================================================= --- 支付单表补列:yz_platform_payment_order 增加推广方快照字段 --- 变更原因:佣金在「支付成功」事件上生成,需要知道该笔收款归属的推广方; --- 推广方在下单时确定,随支付单落快照(后续改归属不影响历史佣金)。 --- 新增列: --- promoter_id varchar(64) NOT NULL DEFAULT '' 推广方ID --- promoter_name varchar(128) NOT NULL DEFAULT '' 推广方名称 --- promoter_type varchar(32) NOT NULL DEFAULT '' 推广方类型 channel/service/staff --- 本脚本可重复执行:列不存在时才执行 ALTER,已存在自动跳过(不报 1060 Duplicate column)。 --- 运行:mysql -h -P -u -p < alter_platform_payment_order_promoter.sql --- ============================================================= - --- promoter_id -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_payment_order'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_payment_order' AND COLUMN_NAME = 'promoter_id'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_platform_payment_order` ADD COLUMN `promoter_id` varchar(64) NOT NULL DEFAULT '''' COMMENT ''推广方ID(下单时快照,支付成功后据此生成佣金)'' AFTER `order_synced`', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- promoter_name -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_payment_order' AND COLUMN_NAME = 'promoter_name'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_platform_payment_order` ADD COLUMN `promoter_name` varchar(128) NOT NULL DEFAULT '''' COMMENT ''推广方名称快照'' AFTER `promoter_id`', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- promoter_type -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_payment_order' AND COLUMN_NAME = 'promoter_type'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_platform_payment_order` ADD COLUMN `promoter_type` varchar(32) NOT NULL DEFAULT '''' COMMENT ''推广方类型快照:channel渠道伙伴/service服务商/staff内部员工'' AFTER `promoter_name`', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- 校验: -SHOW COLUMNS FROM yz_platform_payment_order LIKE 'promoter%'; diff --git a/go/docs/sql/alter_project_contacts.sql b/go/docs/sql/alter_project_contacts.sql deleted file mode 100644 index 5650dc2..0000000 --- a/go/docs/sql/alter_project_contacts.sql +++ /dev/null @@ -1,28 +0,0 @@ --- ============================================================= --- Project contacts: pick contacts from the linked companies --- (a project no longer shows ALL contacts of the customer) --- Table: yz_backend_crm_project --- 1) upstream_contact_ids : JSON array of contact IDs picked from --- the customer's address book (upstream). --- 2) downstream_contact_ids : JSON array of contact IDs picked from --- the counterpart's address book --- (downstream for type=2 / midstream for type=3). --- Contact details always come from yz_backend_erp_contact in real time; --- only the picked ID references are stored here. Empty array -> ''. --- Run: mysql -h -P -u -p < alter_project_contacts.sql --- (re-running only reports "Duplicate column name", which is safe) --- NOTE: keep this file ASCII-only; piping through some shells mangles non-ASCII. --- ============================================================= - --- 可重复执行:列不存在时才执行 ALTER,已存在自动跳过(不再报 1060 Duplicate column)。 - --- upstream_contact_ids -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_project'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_project' AND COLUMN_NAME = 'upstream_contact_ids'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_crm_project` ADD COLUMN `upstream_contact_ids` TEXT NULL COMMENT ''picked contact IDs (JSON array) from customer address book'' AFTER `downstream_phone`', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- downstream_contact_ids -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_project' AND COLUMN_NAME = 'downstream_contact_ids'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_crm_project` ADD COLUMN `downstream_contact_ids` TEXT NULL COMMENT ''picked contact IDs (JSON array) from counterpart address book (downstream/midstream)'' AFTER `upstream_contact_ids`', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/go/docs/sql/alter_site_reminderlist_target_ids.sql b/go/docs/sql/alter_site_reminderlist_target_ids.sql deleted file mode 100644 index 3cf274c..0000000 --- a/go/docs/sql/alter_site_reminderlist_target_ids.sql +++ /dev/null @@ -1,21 +0,0 @@ --- ============================================================= --- 站内信消息表补齐「多选接收目标」字段:yz_system_reminderlist --- 背景:接收目标改为 6 种,其中 3 种支持多选,多选ID统一存 target_ids: --- target_type 取值: --- all 全平台(平台端 + 租户端所有启用用户) --- platform 平台端所有启用用户 --- tenant_all 租户端所有租户的启用用户 --- tenant_role 指定租户角色(多选)下的启用用户 -> target_ids = 角色ID列表 --- tenant 指定租户(多选)下的启用用户 -> target_ids = 租户ID列表 --- tenant_user 指定租户用户(多选) -> target_ids = 用户ID列表 --- target_ids text NULL JSON 数组字符串,如 [1,2,3];全体范围(all/platform/tenant_all)为 NULL --- 兼容说明: --- 旧字段 target_role_id(平台角色)/ target_tenant_id(特定租户)保留,旧数据不受影响。 --- 本脚本可重复执行:列不存在时才执行 ALTER,已存在自动跳过(不再报 1060 Duplicate column)。 --- 运行:mysql -h -P -u -p < alter_site_reminderlist_target_ids.sql --- ============================================================= - -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_reminderlist'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_reminderlist' AND COLUMN_NAME = 'target_ids'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_system_reminderlist` ADD COLUMN `target_ids` text NULL COMMENT ''接收目标ID列表JSON数组(租户角色/租户/租户用户多选)''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/go/docs/sql/alter_system_admin_role_columns.sql b/go/docs/sql/alter_system_admin_role_columns.sql deleted file mode 100644 index 20fb1b1..0000000 --- a/go/docs/sql/alter_system_admin_role_columns.sql +++ /dev/null @@ -1,20 +0,0 @@ --- ============================================================= --- 角色表补齐列:yz_system_admin_role --- 变更(历史库早于「租户隔离 / 自定义角色」特性建表时需要): --- tenant_id bigint unsigned NOT NULL DEFAULT 0 租户ID 0-平台/全局 >0-具体租户 --- is_custom tinyint unsigned NOT NULL DEFAULT 0 自定义角色 0-系统/平台预置 1-租户自建 --- 本脚本可重复执行:列不存在时才执行 ALTER,已存在自动跳过(不再报 1060 Duplicate column)。 --- 运行:mysql -h -P -u -p < alter_system_admin_role_columns.sql --- ============================================================= - --- tenant_id:租户ID -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_admin_role'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_admin_role' AND COLUMN_NAME = 'tenant_id'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_system_admin_role` ADD COLUMN `tenant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT ''租户ID 0-平台/全局 >0-具体租户''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- is_custom:是否租户自建角色 -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_admin_role'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_admin_role' AND COLUMN_NAME = 'is_custom'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_system_admin_role` ADD COLUMN `is_custom` tinyint unsigned NOT NULL DEFAULT 0 COMMENT ''自定义角色 0-系统/平台预置 1-租户自建''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/go/docs/sql/alter_system_files_storage_columns.sql b/go/docs/sql/alter_system_files_storage_columns.sql deleted file mode 100644 index 666bff9..0000000 --- a/go/docs/sql/alter_system_files_storage_columns.sql +++ /dev/null @@ -1,51 +0,0 @@ --- ============================================================= --- 附件表补齐「文件存储分层」改造字段与索引:yz_system_files --- 变更: --- source varchar(16) NOT NULL DEFAULT 'backend' 来源端 backend-租户后台 platform-平台端 --- scope varchar(16) NOT NULL DEFAULT 'tenant' 归属 tenant-租户共享 user-用户个人 --- storage varchar(16) NOT NULL DEFAULT '' 存储类型 local/qiniu --- object_key varchar(512) NOT NULL DEFAULT '' 存储相对路径(不含域名) --- idx_file_dedup (source, scope, tid, tuid, md5(32)) MD5 精确去重 --- idx_file_owner (source, tid, scope, tuid, delete_time) 归属列表过滤 --- 注意: --- 1. md5 在部分历史库中是 TEXT 类型,MySQL 不允许对 TEXT/BLOB 建整列索引, --- 故索引使用前缀长度 md5(32)(MD5 十六进制串固定 32 字符)。 --- 2. 本脚本可重复执行:列/索引不存在时才执行 ALTER,已存在自动跳过(不再报 1060/1061)。 --- 运行:mysql -h -P -u -p < alter_system_files_storage_columns.sql --- ============================================================= - --- source:来源端 -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_files'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'source'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_system_files` ADD COLUMN `source` varchar(16) NOT NULL DEFAULT ''backend'' COMMENT ''来源端: backend-租户后台 platform-平台端''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- scope:归属 -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_files'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'scope'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_system_files` ADD COLUMN `scope` varchar(16) NOT NULL DEFAULT ''tenant'' COMMENT ''归属: tenant-租户共享 user-用户个人''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- storage:存储类型 -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_files'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'storage'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_system_files` ADD COLUMN `storage` varchar(16) NOT NULL DEFAULT '''' COMMENT ''存储类型: local/qiniu''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- object_key:存储相对路径 -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_files'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_files' AND COLUMN_NAME = 'object_key'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_system_files` ADD COLUMN `object_key` varchar(512) NOT NULL DEFAULT '''' COMMENT ''存储相对路径(不含域名)''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- idx_file_dedup:MD5 精确去重索引 -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_files'); -SET @idx := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_files' AND INDEX_NAME = 'idx_file_dedup'); -SET @ddl := IF(@tbl > 0 AND @idx = 0, 'ALTER TABLE `yz_system_files` ADD KEY `idx_file_dedup` (`source`, `scope`, `tid`, `tuid`, `md5`(32))', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- idx_file_owner:归属列表过滤索引 -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_files'); -SET @idx := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_files' AND INDEX_NAME = 'idx_file_owner'); -SET @ddl := IF(@tbl > 0 AND @idx = 0, 'ALTER TABLE `yz_system_files` ADD KEY `idx_file_owner` (`source`, `tid`, `scope`, `tuid`, `delete_time`)', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/go/docs/sql/alter_system_tenant_user_columns.sql b/go/docs/sql/alter_system_tenant_user_columns.sql deleted file mode 100644 index 1b304d4..0000000 --- a/go/docs/sql/alter_system_tenant_user_columns.sql +++ /dev/null @@ -1,20 +0,0 @@ --- ============================================================= --- 租户用户表补齐列:yz_system_tenant_user --- 变更: --- group_id bigint unsigned NOT NULL DEFAULT 0 角色ID 关联 yz_system_admin_role(id, cid=2),0-未分配(视为全权限) --- org_id bigint unsigned NOT NULL DEFAULT 0 所属组织(部门)ID 关联 yz_backend_organization(id),0-未分配 --- 本脚本可重复执行:列不存在时才执行 ALTER,已存在自动跳过(不再报 1060 Duplicate column)。 --- 运行:mysql -h -P -u -p < alter_system_tenant_user_columns.sql --- ============================================================= - --- group_id:角色ID -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant_user'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant_user' AND COLUMN_NAME = 'group_id'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_system_tenant_user` ADD COLUMN `group_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT ''角色ID 关联 yz_system_admin_role(cid=2)''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- org_id:所属组织(部门)ID -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant_user'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant_user' AND COLUMN_NAME = 'org_id'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_system_tenant_user` ADD COLUMN `org_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT ''所属组织ID 关联 yz_backend_organization(id),0-未分配''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/go/docs/sql/alter_tenant_package_billing.sql b/go/docs/sql/alter_tenant_package_billing.sql deleted file mode 100644 index 125130e..0000000 --- a/go/docs/sql/alter_tenant_package_billing.sql +++ /dev/null @@ -1,14 +0,0 @@ --- 租户套餐按年计费(元/年)+ 到期自动切换基础套餐 --- 1) 套餐表增加有效时长(天),365 = 按年计费;2) 租户表增加套餐到期时间 - --- yz_system_tenant_package 增加 duration_days -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant_package'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant_package' AND COLUMN_NAME = 'duration_days'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_system_tenant_package` ADD COLUMN `duration_days` int(11) NOT NULL DEFAULT 365 COMMENT ''套餐有效时长(天),365=按年计费;到期后租户自动切换基础套餐''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- yz_system_tenant 增加 package_expire_time -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant' AND COLUMN_NAME = 'package_expire_time'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_system_tenant` ADD COLUMN `package_expire_time` datetime NULL COMMENT ''套餐到期时间(NULL-永久有效);超过时效自动切换基础套餐''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/go/docs/sql/create_backend_crm_contract.sql b/go/docs/sql/create_backend_crm_contract.sql deleted file mode 100644 index 9fd447b..0000000 --- a/go/docs/sql/create_backend_crm_contract.sql +++ /dev/null @@ -1,47 +0,0 @@ --- ============================================================= --- CRM合同表:yz_backend_crm_contract --- 说明: --- 含「我方角色 our_role」「合同形式 party_count」等字段; --- 产品清单 / 各方签约主体以 JSON 文本存储。 --- 可重复执行(CREATE TABLE IF NOT EXISTS)。 --- 运行:mysql -h -P -u -p < create_backend_crm_contract.sql --- ============================================================= - -CREATE TABLE IF NOT EXISTS `yz_backend_crm_contract` ( - `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', - `tenant_id` varchar(64) NOT NULL COMMENT '租户ID', - `contract_no` varchar(50) NOT NULL DEFAULT '' COMMENT '合同编号', - `contract_name` varchar(100) NOT NULL COMMENT '合同名称', - `contract_category` varchar(20) NOT NULL DEFAULT '' COMMENT '合同分类:1开发/2服务/3销售/4租赁/5采购/6运维/7咨询/8其他', - `our_role` tinyint(4) NOT NULL DEFAULT '2' COMMENT '我方角色:1甲方/2乙方/3丙方/4丁方', - `party_count` tinyint(4) NOT NULL DEFAULT '2' COMMENT '合同形式:2/3/4方', - `project_id` bigint(20) DEFAULT NULL COMMENT '关联项目ID,空=无头合同', - `project_name` varchar(100) NOT NULL DEFAULT '' COMMENT '项目名称', - `owner_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '项目负责人用户ID', - `owner_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '项目负责人姓名', - `sign_date` date DEFAULT NULL COMMENT '签订日期', - `effective_date` date DEFAULT NULL COMMENT '生效日期', - `expire_date` date DEFAULT NULL COMMENT '结束日期', - `parties` text COMMENT '各方签约主体JSON', - `products` text COMMENT '产品清单JSON', - `hardware_amount` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '硬件部分金额', - `software_amount` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '软件部分金额', - `other_amount` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '其他部分金额', - `total_amount` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '合同总金额', - `total_cost` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '产品总成本', - `total_profit` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '合同总利润', - `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1草稿/2已完成/3已作废/4履约中/5异常', - `step` tinyint(4) NOT NULL DEFAULT '1' COMMENT '进度步骤:1合同信息/2产品清单', - `remark` text COMMENT '备注', - `create_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人用户ID', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', - PRIMARY KEY (`id`), - KEY `idx_tenant_id` (`tenant_id`), - KEY `idx_contract_name` (`tenant_id`,`contract_name`), - KEY `idx_contract_no` (`tenant_id`,`contract_no`), - KEY `idx_project` (`tenant_id`,`project_id`), - KEY `idx_status` (`tenant_id`,`status`), - KEY `idx_delete_time` (`delete_time`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM合同表'; diff --git a/go/docs/sql/create_backend_crm_payback.sql b/go/docs/sql/create_backend_crm_payback.sql deleted file mode 100644 index e3bc322..0000000 --- a/go/docs/sql/create_backend_crm_payback.sql +++ /dev/null @@ -1,62 +0,0 @@ --- ============================================================= --- CRM回款相关表:回款计划 / 回款记录(回款进度流水) --- yz_backend_crm_payback 回款计划 --- yz_backend_crm_payback_record 回款记录(每次「新建回款」追加一条, --- 以回款计划 payback_id 为先导条件) --- 可重复执行(CREATE TABLE IF NOT EXISTS)。 --- 运行:mysql -h -P -u -p < create_backend_crm_payback.sql --- ============================================================= - -CREATE TABLE IF NOT EXISTS `yz_backend_crm_payback` ( - `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', - `tenant_id` varchar(64) NOT NULL COMMENT '租户ID', - `contract_id` bigint(20) DEFAULT NULL COMMENT '关联合同ID(yz_backend_crm_contract.id)', - `contract_no` varchar(50) NOT NULL DEFAULT '' COMMENT '合同编号', - `contract_name` varchar(100) NOT NULL DEFAULT '' COMMENT '合同名称', - `customer_id` bigint(20) DEFAULT NULL COMMENT '客户ID', - `customer_name` varchar(128) NOT NULL DEFAULT '' COMMENT '客户名称', - `plan_type` tinyint(4) NOT NULL DEFAULT '1' COMMENT '回款周期:1月度/2季度/3年度/4进度/5自定义', - `pay_method` tinyint(4) NOT NULL DEFAULT '1' COMMENT '回款方式:1对公转账/2网银转账/3现金/4支票/5支付宝/6微信/7其他', - `remind_days` int(11) NOT NULL DEFAULT '0' COMMENT '提前几天提醒', - `owner_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '负责人用户ID', - `owner_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '负责人姓名', - `contract_amount` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '合同总金额', - `total_amount` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '计划回款总额', - `received_amount` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '已回款金额', - `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1进行中/2已完成', - `items` text COMMENT '计划明细JSON数组', - `remark` text COMMENT '备注', - `create_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人用户ID', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', - PRIMARY KEY (`id`), - KEY `idx_tenant_id` (`tenant_id`), - KEY `idx_contract` (`tenant_id`,`contract_id`), - KEY `idx_customer` (`tenant_id`,`customer_id`), - KEY `idx_status` (`tenant_id`,`status`), - KEY `idx_delete_time` (`delete_time`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM回款计划表'; - -CREATE TABLE IF NOT EXISTS `yz_backend_crm_payback_record` ( - `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', - `tenant_id` varchar(64) NOT NULL COMMENT '租户ID', - `contract_id` bigint(20) DEFAULT NULL COMMENT '关联合同ID(yz_backend_crm_contract.id)', - `payback_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '关联回款计划ID(yz_backend_crm_payback.id)', - `amount` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '本次回款金额', - `received_date` varchar(20) NOT NULL DEFAULT '' COMMENT '回款日期 YYYY-MM-DD', - `pay_method` tinyint(4) NOT NULL DEFAULT '1' COMMENT '回款方式:1对公/2网银/3现金/4支票/5支付宝/6微信/7其他', - `receipt_url` varchar(512) DEFAULT NULL COMMENT '流水回执文件地址', - `receipt_name` varchar(255) DEFAULT NULL COMMENT '流水回执文件名', - `remark` text COMMENT '备注', - `owner_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '负责人用户ID', - `owner_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '负责人姓名', - `create_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人用户ID', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', - PRIMARY KEY (`id`), - KEY `idx_tenant_contract` (`tenant_id`,`contract_id`), - KEY `idx_payback` (`tenant_id`,`payback_id`), - KEY `idx_delete_time` (`delete_time`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM回款记录表(回款进度流水)'; diff --git a/go/docs/sql/create_backend_crm_unit.sql b/go/docs/sql/create_backend_crm_unit.sql deleted file mode 100644 index cf5fc46..0000000 --- a/go/docs/sql/create_backend_crm_unit.sql +++ /dev/null @@ -1,28 +0,0 @@ --- ============================================================= --- CRM 基本设置:计量单位表 yz_backend_crm_unit --- 说明: --- 单位用于产品管理(产品「单位」字段)、合同/报价产品清单等业务; --- 在「CRM -> 基本设置 -> 单位设置」中维护,产品表单的单位下拉取自本表。 --- tenant_id + name 唯一(同租户下单位名称不可重复)。 --- 默认单位(套/台/个/件/批/次/项/人月/人天/年/月/米/平方米/吨/千克/箱) --- 由服务端在租户首次访问单位列表时自动写入,无需手工初始化。 --- ============================================================= - -CREATE TABLE IF NOT EXISTS `yz_backend_crm_unit` ( - `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', - `tenant_id` varchar(64) NOT NULL COMMENT '租户ID', - `name` varchar(20) NOT NULL COMMENT '单位名称,如:套 / 台 / 个', - `code` varchar(50) NOT NULL DEFAULT '' COMMENT '单位编码(选填),如:SET / PCS', - `sort` int(11) NOT NULL DEFAULT '0' COMMENT '排序(升序,小的在前)', - `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1启用 / 0停用', - `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', - `create_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人用户ID', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_tenant_name` (`tenant_id`,`name`), - KEY `idx_tenant_id` (`tenant_id`), - KEY `idx_status` (`tenant_id`,`status`), - KEY `idx_delete_time` (`delete_time`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM计量单位表'; diff --git a/go/docs/sql/create_cms_article.sql b/go/docs/sql/create_cms_article.sql deleted file mode 100644 index de52750..0000000 --- a/go/docs/sql/create_cms_article.sql +++ /dev/null @@ -1,49 +0,0 @@ --- ============================================================= --- CMS 文章相关表:文章分类 / 文章 --- yz_cms_article_category 文章分类 --- yz_cms_article 文章 --- 可重复执行(CREATE TABLE IF NOT EXISTS)。 --- 运行:mysql -h -P -u -p < create_cms_article.sql --- ============================================================= - -CREATE TABLE IF NOT EXISTS `yz_cms_article_category` ( - `id` bigint unsigned NOT NULL AUTO_INCREMENT, - `tid` bigint unsigned NOT NULL DEFAULT 0, - `cid` bigint unsigned NOT NULL DEFAULT 0, - `name` varchar(100) NOT NULL DEFAULT '', - `image` varchar(500) NOT NULL DEFAULT '', - `desc` varchar(500) NOT NULL DEFAULT '', - `sort` int NOT NULL DEFAULT 0, - `status` tinyint NOT NULL DEFAULT 1, - `create_time` datetime NOT NULL, - `update_time` datetime DEFAULT NULL, - `delete_time` datetime DEFAULT NULL, - PRIMARY KEY (`id`), - KEY `idx_tid_cid` (`tid`,`cid`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS `yz_cms_article` ( - `id` bigint unsigned NOT NULL AUTO_INCREMENT, - `tid` bigint unsigned NOT NULL DEFAULT 0, - `title` varchar(255) NOT NULL DEFAULT '', - `author` varchar(100) NOT NULL DEFAULT '', - `cate_id` bigint unsigned NOT NULL DEFAULT 0, - `content` mediumtext, - `desc` varchar(500) NOT NULL DEFAULT '', - `image` varchar(500) NOT NULL DEFAULT '', - `is_trans` tinyint NOT NULL DEFAULT 0, - `transurl` varchar(500) DEFAULT NULL, - `status` tinyint NOT NULL DEFAULT 0, - `top` tinyint NOT NULL DEFAULT 0, - `recommend` tinyint NOT NULL DEFAULT 0, - `views` int NOT NULL DEFAULT 0, - `likes` int NOT NULL DEFAULT 0, - `publisher_id` bigint unsigned DEFAULT NULL, - `publish_time` datetime DEFAULT NULL, - `create_time` datetime NOT NULL, - `update_time` datetime DEFAULT NULL, - `delete_time` datetime DEFAULT NULL, - PRIMARY KEY (`id`), - KEY `idx_tid_status` (`tid`,`status`), - KEY `idx_cate_id` (`cate_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/go/docs/sql/create_cms_product.sql b/go/docs/sql/create_cms_product.sql deleted file mode 100644 index c532f94..0000000 --- a/go/docs/sql/create_cms_product.sql +++ /dev/null @@ -1,39 +0,0 @@ --- ============================================================= --- CMS 产品相关表:产品 / 产品分类 --- yz_cms_product 产品 --- yz_cms_product_category 产品分类 --- 可重复执行(CREATE TABLE IF NOT EXISTS)。 --- 运行:mysql -h -P -u -p < create_cms_product.sql --- ============================================================= - -CREATE TABLE IF NOT EXISTS `yz_cms_product` ( - `id` bigint unsigned NOT NULL AUTO_INCREMENT, - `tid` bigint unsigned NOT NULL DEFAULT 0, - `title` varchar(100) NOT NULL DEFAULT '', - `thumb` varchar(500) NOT NULL DEFAULT '', - `desc` varchar(500) NOT NULL DEFAULT '', - `content` mediumtext, - `url` varchar(500) NOT NULL DEFAULT '', - `sort` int NOT NULL DEFAULT 0, - `status` tinyint NOT NULL DEFAULT 1, - `create_time` datetime NOT NULL, - `update_time` datetime DEFAULT NULL, - `delete_time` datetime DEFAULT NULL, - PRIMARY KEY (`id`), - KEY `idx_tid_status` (`tid`,`status`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS `yz_cms_product_category` ( - `id` bigint unsigned NOT NULL AUTO_INCREMENT, - `tid` bigint unsigned NOT NULL DEFAULT 0, - `title` varchar(100) NOT NULL DEFAULT '', - `pid` bigint unsigned NOT NULL DEFAULT 0, - `desc` varchar(500) NOT NULL DEFAULT '', - `sort` int NOT NULL DEFAULT 0, - `status` tinyint NOT NULL DEFAULT 1, - `create_time` datetime NOT NULL, - `update_time` datetime DEFAULT NULL, - `delete_time` datetime DEFAULT NULL, - PRIMARY KEY (`id`), - KEY `idx_tid_pid` (`tid`,`pid`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/go/docs/sql/create_cms_solution.sql b/go/docs/sql/create_cms_solution.sql deleted file mode 100644 index e1c58a3..0000000 --- a/go/docs/sql/create_cms_solution.sql +++ /dev/null @@ -1,46 +0,0 @@ --- ============================================================= --- CMS 解决方案(特色服务)相关表:解决方案 / 解决方案分类 --- yz_cms_solution 解决方案 --- yz_cms_solution_category 解决方案分类 --- 说明:建表可重复执行;末尾的历史库补列重复执行报 --- "Duplicate column name" 属正常,跳过即可。 --- 运行:mysql -h -P -u -p < create_cms_solution.sql --- ============================================================= - -CREATE TABLE IF NOT EXISTS `yz_cms_solution` ( - `id` bigint unsigned NOT NULL AUTO_INCREMENT, - `tid` bigint unsigned NOT NULL DEFAULT 0, - `title` varchar(100) NOT NULL DEFAULT '', - `thumb` varchar(500) NOT NULL DEFAULT '', - `desc` varchar(500) NOT NULL DEFAULT '', - `content` mediumtext, - `url` varchar(500) NOT NULL DEFAULT '', - `sort` int NOT NULL DEFAULT 0, - `status` tinyint NOT NULL DEFAULT 1, - `create_time` datetime NOT NULL, - `update_time` datetime DEFAULT NULL, - `delete_time` datetime DEFAULT NULL, - PRIMARY KEY (`id`), - KEY `idx_tid_status` (`tid`,`status`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE IF NOT EXISTS `yz_cms_solution_category` ( - `id` bigint unsigned NOT NULL AUTO_INCREMENT, - `tid` bigint unsigned NOT NULL DEFAULT 0, - `title` varchar(100) NOT NULL DEFAULT '', - `pid` bigint unsigned NOT NULL DEFAULT 0, - `desc` varchar(500) NOT NULL DEFAULT '', - `sort` int NOT NULL DEFAULT 0, - `status` tinyint NOT NULL DEFAULT 1, - `create_time` datetime NOT NULL, - `update_time` datetime DEFAULT NULL, - `delete_time` datetime DEFAULT NULL, - PRIMARY KEY (`id`), - KEY `idx_tid_pid` (`tid`,`pid`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- 历史库补齐在线内容字段(表已存在且缺列时才执行 ALTER,可重复执行不报错) -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_cms_solution'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_cms_solution' AND COLUMN_NAME = 'content'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_cms_solution` ADD COLUMN `content` mediumtext', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/go/docs/sql/create_oa_document.sql b/go/docs/sql/create_oa_document.sql deleted file mode 100644 index 57988ad..0000000 --- a/go/docs/sql/create_oa_document.sql +++ /dev/null @@ -1,133 +0,0 @@ --- ============================================================= --- OA文档库相关表:分类表 / 文档表 / 关联表 / 共享表 --- yz_backend_oa_doc_category 文档分类(含共享/私密双空间) --- yz_backend_oa_doc 文档 --- yz_backend_oa_doc_link 文档关联(图谱) --- yz_backend_oa_doc_share 文档共享(用户/部门) --- 说明:建表可重复执行;末尾的历史库补列与数据修正在重复执行时 --- 报 "Duplicate column name" 属正常,跳过即可。 --- 运行:mysql -h -P -u -p < create_oa_document.sql --- ============================================================= - -CREATE TABLE IF NOT EXISTS `yz_backend_oa_doc_category` ( - `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `tid` int NOT NULL DEFAULT 0 COMMENT '租户ID', - `user_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '归属用户ID 0-租户级(共享分类) >0-该用户的个人分类', - `parent_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '父级ID,0为一级分类', - `name` varchar(128) NOT NULL DEFAULT '' COMMENT '分类名称', - `sort` int NOT NULL DEFAULT 0 COMMENT '排序,越小越靠前', - `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', - `is_system` tinyint NOT NULL DEFAULT 0 COMMENT '是否系统内置分类 0-否(可删除) 1-是(不可删除)', - `scope` varchar(16) NOT NULL DEFAULT 'shared' COMMENT '所属范围 personal-个人文档 shared-共享文档', - `is_deleted` tinyint NOT NULL DEFAULT 0 COMMENT '是否删除 0-否 1-是', - `create_time` datetime DEFAULT NULL COMMENT '创建时间', - `update_time` datetime DEFAULT NULL COMMENT '更新时间', - `delete_time` datetime DEFAULT NULL COMMENT '删除时间', - PRIMARY KEY (`id`), - KEY `idx_tid_parent` (`tid`,`parent_id`), - KEY `idx_tid_scope_user` (`tid`,`scope`,`user_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA文档分类表'; - -CREATE TABLE IF NOT EXISTS `yz_backend_oa_doc` ( - `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `tid` int NOT NULL DEFAULT 0 COMMENT '租户ID', - `category_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '所属分类ID,0为未分类', - `title` varchar(255) NOT NULL DEFAULT '' COMMENT '文档标题', - `file_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '关联附件ID(yz_system_files)', - `file_url` varchar(512) NOT NULL DEFAULT '' COMMENT '附件访问地址', - `file_name` varchar(255) NOT NULL DEFAULT '' COMMENT '原始文件名', - `ext` varchar(16) NOT NULL DEFAULT '' COMMENT '扩展名', - `size` bigint unsigned NOT NULL DEFAULT 0 COMMENT '文件大小(字节)', - `doc_type` tinyint NOT NULL DEFAULT 0 COMMENT '类型 0-其他 1-文档 2-表格 3-演示 4-PDF 5-图片 6-压缩包 7-视频 8-音频', - `tags` varchar(255) NOT NULL DEFAULT '' COMMENT '标签,逗号分隔', - `summary` varchar(1000) NOT NULL DEFAULT '' COMMENT '摘要/描述', - `content` longtext NULL COMMENT '在线制作文档的编辑源(富文本HTML),仅用于再次编辑', - `status` tinyint NOT NULL DEFAULT 0 COMMENT '状态 0-草稿 1-已发布 2-已归档', - `version` int NOT NULL DEFAULT 1 COMMENT '版本号', - `is_star` tinyint NOT NULL DEFAULT 0 COMMENT '是否收藏 0-否 1-是', - `owner_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '负责人ID', - `owner_name` varchar(100) NOT NULL DEFAULT '' COMMENT '负责人姓名', - `view_count` int NOT NULL DEFAULT 0 COMMENT '查看次数', - `download_count` int NOT NULL DEFAULT 0 COMMENT '下载次数', - `creator_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '创建人ID', - `creator_name` varchar(100) NOT NULL DEFAULT '' COMMENT '创建人姓名', - `visibility` tinyint NOT NULL DEFAULT 0 COMMENT '可见性 0-租户公开 1-私密(仅创建者与被共享者)', - `is_deleted` tinyint NOT NULL DEFAULT 0 COMMENT '是否删除 0-否 1-是', - `create_time` datetime DEFAULT NULL COMMENT '创建时间', - `update_time` datetime DEFAULT NULL COMMENT '更新时间', - `delete_time` datetime DEFAULT NULL COMMENT '删除时间', - PRIMARY KEY (`id`), - KEY `idx_tid_cate` (`tid`,`category_id`,`is_deleted`), - KEY `idx_tid_status` (`tid`,`status`,`is_deleted`), - KEY `idx_tid_title` (`tid`,`title`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA文档表'; - -CREATE TABLE IF NOT EXISTS `yz_backend_oa_doc_link` ( - `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `tid` int NOT NULL DEFAULT 0 COMMENT '租户ID', - `source_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '源文档ID', - `target_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '目标文档ID', - `relation` varchar(32) NOT NULL DEFAULT 'related' COMMENT '关系 reference-引用 related-相关 version-版本 belong-从属', - `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '关系说明', - `creator_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '创建人ID', - `create_time` datetime DEFAULT NULL COMMENT '创建时间', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_doc_pair` (`tid`,`source_id`,`target_id`,`relation`), - KEY `idx_tid_source` (`tid`,`source_id`), - KEY `idx_tid_target` (`tid`,`target_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA文档关联表'; - -CREATE TABLE IF NOT EXISTS `yz_backend_oa_doc_share` ( - `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `tid` int NOT NULL DEFAULT 0 COMMENT '租户ID', - `doc_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '文档ID', - `share_type` tinyint NOT NULL DEFAULT 0 COMMENT '共享类型 0-用户(target_id=uid) 1-部门(target_id=org_id,含子部门)', - `target_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '共享目标ID', - `creator_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '授权人ID', - `create_time` datetime DEFAULT NULL COMMENT '创建时间', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_doc_target` (`tid`,`doc_id`,`share_type`,`target_id`), - KEY `idx_tid_doc` (`tid`,`doc_id`), - KEY `idx_target` (`tid`,`share_type`,`target_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA文档共享表'; - --- ------------------------------------------------------------- --- 历史库补齐列(表已存在且缺列时才执行 ALTER,可重复执行不报错) --- ------------------------------------------------------------- - --- yz_backend_oa_doc.visibility -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_doc'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_doc' AND COLUMN_NAME = 'visibility'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_oa_doc` ADD COLUMN `visibility` tinyint NOT NULL DEFAULT 0 COMMENT ''可见性 0-租户公开 1-私密(仅创建者与被共享者)''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- yz_backend_oa_doc.content -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_doc'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_doc' AND COLUMN_NAME = 'content'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_oa_doc` ADD COLUMN `content` longtext NULL COMMENT ''在线制作文档的编辑源(富文本HTML),仅用于再次编辑''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- yz_backend_oa_doc_category.is_system -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_doc_category'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_doc_category' AND COLUMN_NAME = 'is_system'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_oa_doc_category` ADD COLUMN `is_system` tinyint NOT NULL DEFAULT 0 COMMENT ''是否系统内置分类 0-否(可删除) 1-是(不可删除)''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- yz_backend_oa_doc_category.scope -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_doc_category'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_doc_category' AND COLUMN_NAME = 'scope'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_oa_doc_category` ADD COLUMN `scope` varchar(16) NOT NULL DEFAULT ''shared'' COMMENT ''所属范围 personal-个人文档 shared-共享文档''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- yz_backend_oa_doc_category.user_id -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_doc_category'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_doc_category' AND COLUMN_NAME = 'user_id'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_backend_oa_doc_category` ADD COLUMN `user_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT ''归属用户ID 0-租户级(共享分类) >0-该用户的个人分类''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- 双空间模型:兼容存量数据,将不在 (shared, personal) 范围内的分类统一归为共享空间 --- (user_id 置 0、scope 置 shared),避免历史脏数据导致分类不可见。 --- 注意:不再把 personal 并入 shared,私密空间需保留各自的 user_id 隔离。 -UPDATE `yz_backend_oa_doc_category` -SET `user_id` = 0, `scope` = 'shared' -WHERE `scope` NOT IN ('shared', 'personal') AND `is_deleted` = 0; diff --git a/go/docs/sql/create_oa_notice.sql b/go/docs/sql/create_oa_notice.sql deleted file mode 100644 index c10c829..0000000 --- a/go/docs/sql/create_oa_notice.sql +++ /dev/null @@ -1,31 +0,0 @@ --- ============================================================= --- OA通知公告表:yz_backend_oa_notice --- 说明: --- 按租户 tid 隔离:租户内发布,租户内所有后台用户可见。 --- status: 0-草稿 1-已发布 2-已下架;notice_type: 0-通知 1-公告 2-活动; --- level: 0-普通 1-重要 2-紧急;is_top: 0-否 1-是(置顶优先展示)。 --- 可重复执行(CREATE TABLE IF NOT EXISTS)。 --- 运行:mysql -h -P -u -p < create_oa_notice.sql --- ============================================================= - -CREATE TABLE IF NOT EXISTS `yz_backend_oa_notice` ( - `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `tid` int NOT NULL DEFAULT 0 COMMENT '租户ID', - `title` varchar(255) NOT NULL DEFAULT '' COMMENT '公告标题', - `content` text COMMENT '公告内容', - `notice_type` tinyint NOT NULL DEFAULT 0 COMMENT '类型 0-通知 1-公告 2-活动', - `level` tinyint NOT NULL DEFAULT 0 COMMENT '紧急程度 0-普通 1-重要 2-紧急', - `status` tinyint NOT NULL DEFAULT 0 COMMENT '状态 0-草稿 1-已发布 2-已下架', - `is_top` tinyint NOT NULL DEFAULT 0 COMMENT '是否置顶 0-否 1-是', - `publish_time` datetime DEFAULT NULL COMMENT '发布时间', - `publisher_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '发布人ID', - `publisher_name` varchar(100) NOT NULL DEFAULT '' COMMENT '发布人姓名', - `read_count` int NOT NULL DEFAULT 0 COMMENT '阅读次数', - `is_deleted` tinyint NOT NULL DEFAULT 0 COMMENT '是否删除 0-否 1-是', - `create_time` datetime DEFAULT NULL COMMENT '创建时间', - `update_time` datetime DEFAULT NULL COMMENT '更新时间', - `delete_time` datetime DEFAULT NULL COMMENT '删除时间', - PRIMARY KEY (`id`), - KEY `idx_tid_status` (`tid`,`status`), - KEY `idx_tid_top` (`tid`,`is_top`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA通知公告表'; diff --git a/go/docs/sql/create_platform_commission.sql b/go/docs/sql/create_platform_commission.sql deleted file mode 100644 index b240e72..0000000 --- a/go/docs/sql/create_platform_commission.sql +++ /dev/null @@ -1,73 +0,0 @@ --- ============================================================= --- 平台端推广佣金相关表 --- yz_platform_commission_rule 佣金规则(按比例 / 固定金额 / 阶梯) --- yz_platform_commission_order 佣金单(台账,含发放登记) --- --- 业务约定: --- 1. 佣金由平台自有资金支出,与租户收款资金流解耦,不参与分账; --- 2. 佣金单在「业务订单支付成功」事件上按启用中的规则生成,状态先为 payable(应付); --- 3. 一笔支付单 + 同一推广方只应生成一张佣金单(uk_pay_promoter), --- 但 MySQL 唯一索引对 NULL 不去重(delete_time),业务层仍需按 pay_no 查重后再插入; --- 4. 金额一律以「分」为单位的 bigint 存储; --- 5. 规则修改只影响之后生成的佣金单,历史佣金单保留规则快照字段(rule_name/calc_type)。 --- --- 可重复执行(CREATE TABLE IF NOT EXISTS)。 --- 运行:mysql --default-character-set=utf8mb4 -h -P -u -p < create_platform_commission.sql --- ============================================================= - --- ------------------------------------------------------------- --- 1. 佣金规则表 --- ------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS `yz_platform_commission_rule` ( - `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', - `rule_name` varchar(128) NOT NULL DEFAULT '' COMMENT '规则名称,如「渠道推广-平台使用费 5%」', - `calc_type` varchar(20) NOT NULL DEFAULT 'percent' COMMENT '计算方式:percent按比例/fixed固定金额/tiered阶梯', - `value` bigint(20) NOT NULL DEFAULT '0' COMMENT '规则值:percent=百分比×100(5%存500,支持2.5%存250);fixed=固定金额(分);tiered=0且明细见 tier_json', - `tier_json` text COMMENT '阶梯配置JSON:[{"min":0,"max":1000000,"rate":300}];金额单位为分,max 为 null 表示不限,rate 同为百分比×100', - `order_type` varchar(32) NOT NULL DEFAULT 'platform_usage' COMMENT '适用订单类型:all全部/platform_usage平台使用费/module_shop模块购买/service_fee服务费', - `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1启用/0停用(停用后新佣金单不再匹配该规则,历史佣金单不受影响)', - `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', - `create_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人ID', - `create_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '创建人姓名', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', - PRIMARY KEY (`id`), - KEY `idx_status` (`status`,`order_type`), - KEY `idx_delete_time` (`delete_time`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台推广佣金规则表'; - --- ------------------------------------------------------------- --- 2. 佣金单表(台账) --- ------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS `yz_platform_commission_order` ( - `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', - `commission_no` varchar(40) NOT NULL DEFAULT '' COMMENT '佣金单号,形如 CM202609150001', - `pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '关联支付单号(yz_platform_payment_order.pay_no)', - `out_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '关联业务订单号', - `rule_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '命中的佣金规则ID', - `rule_name` varchar(128) NOT NULL DEFAULT '' COMMENT '命中规则名称快照', - `calc_type` varchar(20) NOT NULL DEFAULT '' COMMENT '计算方式快照:percent/fixed/tiered', - `promoter_id` varchar(64) NOT NULL DEFAULT '' COMMENT '推广方ID(渠道伙伴 / 服务商 / 内部员工)', - `promoter_name` varchar(128) NOT NULL DEFAULT '' COMMENT '推广方名称', - `promoter_type` varchar(32) NOT NULL DEFAULT '' COMMENT '推广方类型:channel渠道伙伴/service服务商/staff内部员工', - `base_amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '计佣基数(分)= 业务订单实付金额', - `amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '佣金金额(分)', - `status` varchar(20) NOT NULL DEFAULT 'payable' COMMENT '佣金状态:payable应付/settled已付/tax_processing税务处理中/canceled已作废', - `pay_method` varchar(20) NOT NULL DEFAULT '' COMMENT '发放方式:bank银行转账/balance余额抵扣/manual人工发放/offset下期抵扣', - `pay_voucher` varchar(255) NOT NULL DEFAULT '' COMMENT '发放凭证 / 银行流水号', - `paid_at` datetime DEFAULT NULL COMMENT '发放时间', - `paid_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '登记发放人ID', - `paid_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '登记发放人姓名', - `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_commission_no` (`commission_no`,`delete_time`), - UNIQUE KEY `uk_pay_promoter` (`pay_no`,`promoter_id`,`delete_time`), - KEY `idx_out_trade_no` (`out_trade_no`), - KEY `idx_status` (`status`,`create_time`), - KEY `idx_promoter` (`promoter_id`,`create_time`), - KEY `idx_delete_time` (`delete_time`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台推广佣金单表(台账)'; diff --git a/go/docs/sql/create_platform_payment.sql b/go/docs/sql/create_platform_payment.sql deleted file mode 100644 index 72661ee..0000000 --- a/go/docs/sql/create_platform_payment.sql +++ /dev/null @@ -1,223 +0,0 @@ --- ============================================================= --- 平台端支付模块相关表(平台向租户收取平台使用费) --- yz_platform_payment_channel 渠道配置(一渠道一行,敏感参数加密后存 JSON) --- yz_platform_payment_order 支付单 --- yz_platform_payment_order_log 支付单状态流转流水 --- yz_platform_payment_callback_log 渠道回调日志(幂等去重 + 问题排查) --- yz_platform_payment_channel_bill 渠道账单明细(对账源数据) --- yz_platform_payment_reconcile_diff 对账差异 --- yz_platform_payment_refund 手动原路退回记录(兜底能力) --- --- 设计约定(与业务约定一致): --- 1. 资金流向:租户付款直接进平台对公账户,不涉及分账;仅保留后台「手动原路退回」兜底; --- 2. 金额一律用「分」为单位的 bigint 存储,前端展示时 /100 转元; --- 3. 状态类字段用 varchar 存英文枚举,前端 StatusTag 直接映射,避免数字魔法值; --- 4. 渠道密钥 / 私钥 / ClientSecret 不入库明文,只存 config_json(写入前由后端加密); --- 证书本体存文件(object_key),库内只留元信息(cert_json); --- 5. 表结构与项目其他表一致:软删除 delete_time、create_time/update_time 自动维护; --- 6. 唯一键带上 delete_time 是本项目约定(软删除后可重新插入同键数据); --- 注意 MySQL 唯一索引对 NULL 不去重,因此「支付幂等/回调去重」仍必须在业务层 --- 用「按 pay_no 查重 + 条件更新(WHERE status <> 'paid')」兜底,不能只依赖唯一键。 --- 7. 业务订单字段(业务订单状态、订单摘要等)由业务模块持有,本模块只冗余订单号与金额, --- 详情接口聚合返回,不在此重复落库。 --- --- 可重复执行(CREATE TABLE IF NOT EXISTS)。 --- 运行:mysql --default-character-set=utf8mb4 -h -P -u -p < create_platform_payment.sql --- ============================================================= - --- ------------------------------------------------------------- --- 1. 支付渠道配置表 --- ------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS `yz_platform_payment_channel` ( - `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', - `channel` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道标识:wechat/alipay/unionpay/cloudpay/paypal', - `name` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道名称(展示用)', - `merchant_no` varchar(128) NOT NULL DEFAULT '' COMMENT '商户号(展示用,未配置时前端显示未配置)', - `config_json` text COMMENT '渠道参数JSON(含密钥/私钥/Secret,写入前加密;接口回显敏感字段时只回掩码)', - `cert_json` text COMMENT '证书元信息JSON:文件名、object_key、序列号、到期日(不含证书私钥内容)', - `callback_url` varchar(255) NOT NULL DEFAULT '' COMMENT '回调地址(只读):/api/payment/callback/{channel}', - `enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用:1启用/0停用(参数不完整时前端展示为未配置)', - `extra_json` text COMMENT '渠道扩展开关JSON:云闪付 show_logo/logo_positions、PayPal env 等', - `last_test_time` datetime DEFAULT NULL COMMENT '最近一次「测试连接」时间', - `last_test_result` varchar(255) NOT NULL DEFAULT '' COMMENT '最近一次测试连接结果说明', - `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_channel` (`channel`,`delete_time`), - KEY `idx_enabled` (`enabled`), - KEY `idx_delete_time` (`delete_time`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付渠道配置表'; - --- ------------------------------------------------------------- --- 2. 支付单表 --- ------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS `yz_platform_payment_order` ( - `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', - `pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '支付单号(支付模块生成,形如 P202609150009)', - `out_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '业务订单号(业务模块生成;同一订单可有多笔支付单)', - `order_type` varchar(32) NOT NULL DEFAULT 'platform_usage' COMMENT '订单类型:platform_usage平台使用费/module_shop模块购买/service_fee服务费', - `subject` varchar(255) NOT NULL DEFAULT '' COMMENT '订单标题(渠道侧展示文案)', - `tenant_id` varchar(64) NOT NULL DEFAULT '' COMMENT '租户ID', - `tenant_name` varchar(128) NOT NULL DEFAULT '' COMMENT '租户名称(冗余,便于列表展示)', - `amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '实付金额(分)', - `order_amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '业务订单金额(分)', - `currency` varchar(8) NOT NULL DEFAULT 'CNY' COMMENT '币种', - `channel` varchar(32) NOT NULL DEFAULT '' COMMENT '支付渠道标识', - `merchant_no` varchar(128) NOT NULL DEFAULT '' COMMENT '下单时商户号快照', - `channel_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道流水号', - `status` varchar(20) NOT NULL DEFAULT 'created' COMMENT '状态:created/pending/paying/paid/failed/closed/refunding/refunded', - `refund_amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '已退回金额(分),仅兜底手动退回时累加', - `client_ip` varchar(64) NOT NULL DEFAULT '' COMMENT '下单客户端IP', - `return_url` varchar(255) NOT NULL DEFAULT '' COMMENT '支付完成同步跳回地址(由下单参数带入)', - `notify_url` varchar(255) NOT NULL DEFAULT '' COMMENT '渠道回调地址快照', - `expire_at` datetime DEFAULT NULL COMMENT '支付超时时间(过期由补偿任务置为 closed)', - `paid_at` datetime DEFAULT NULL COMMENT '支付完成时间', - `closed_at` datetime DEFAULT NULL COMMENT '关闭时间', - `notify_at` datetime DEFAULT NULL COMMENT '最近一次成功回调时间', - `last_query_at` datetime DEFAULT NULL COMMENT '最近一次主动查询渠道状态时间', - `order_synced` tinyint(1) NOT NULL DEFAULT '0' COMMENT '业务订单状态是否已同步:1已同步/0待同步(补偿任务扫描用)', - `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_pay_no` (`pay_no`,`delete_time`), - KEY `idx_out_trade_no` (`out_trade_no`), - KEY `idx_tenant` (`tenant_id`,`create_time`), - KEY `idx_status` (`status`,`create_time`), - KEY `idx_channel_trade` (`channel`,`channel_trade_no`), - KEY `idx_order_synced` (`order_synced`,`status`), - KEY `idx_delete_time` (`delete_time`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付单表'; - --- ------------------------------------------------------------- --- 3. 支付单状态流转流水(详情页时间轴数据源) --- ------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS `yz_platform_payment_order_log` ( - `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', - `pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '支付单号(yz_platform_payment_order.pay_no)', - `from_status` varchar(20) NOT NULL DEFAULT '' COMMENT '变更前状态(创建时为空)', - `to_status` varchar(20) NOT NULL DEFAULT '' COMMENT '变更后状态', - `operator` varchar(64) NOT NULL DEFAULT '' COMMENT '操作来源:租户端下单页/渠道回调/平台管理员/定时补偿', - `remark` varchar(500) NOT NULL DEFAULT '' COMMENT '变更说明(如:支付成功,已同步业务订单为已支付)', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', - PRIMARY KEY (`id`), - KEY `idx_pay_no` (`pay_no`,`create_time`), - KEY `idx_delete_time` (`delete_time`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付单状态流转流水表'; - --- ------------------------------------------------------------- --- 4. 渠道回调日志表(幂等去重与排查) --- ------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS `yz_platform_payment_callback_log` ( - `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', - `channel` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道标识', - `event_type` varchar(64) NOT NULL DEFAULT '' COMMENT '事件类型:TRANSACTION.SUCCESS/trade.success/PAYMENT.CAPTURE.COMPLETED 等', - `event_id` varchar(128) NOT NULL DEFAULT '' COMMENT '渠道事件ID(微信 event id / 支付宝 notify_id / PayPal event id)', - `pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '关联支付单号', - `out_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '业务订单号', - `channel_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道流水号', - `trade_state` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道侧交易状态原文', - `amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '渠道通知金额(分)', - `verify_result` tinyint(1) NOT NULL DEFAULT '0' COMMENT '验签结果:1通过/0失败', - `is_duplicate` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否重复通知:1是(幂等命中,直接返回渠道要求的成功响应)/0否', - `handle_result` tinyint(1) NOT NULL DEFAULT '0' COMMENT '处理结果:1成功/0失败', - `handle_msg` varchar(255) NOT NULL DEFAULT '' COMMENT '处理说明或错误信息', - `raw_body` mediumtext COMMENT '渠道原始报文(脱敏后保留,详情页「渠道回调原文」数据源)', - `client_ip` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道来源IP', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_channel_event` (`channel`,`channel_trade_no`,`event_type`,`delete_time`), - KEY `idx_pay_no` (`pay_no`), - KEY `idx_event_id` (`event_id`), - KEY `idx_create_time` (`create_time`), - KEY `idx_delete_time` (`delete_time`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付渠道回调日志表(幂等去重)'; - --- ------------------------------------------------------------- --- 5. 渠道账单明细表(对账源数据) --- ------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS `yz_platform_payment_channel_bill` ( - `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', - `channel` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道标识', - `bill_date` date NOT NULL COMMENT '账单日期(渠道账单自然日)', - `channel_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道流水号', - `out_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道侧商户订单号(对应业务订单号)', - `amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '渠道账单金额(分)', - `fee` bigint(20) NOT NULL DEFAULT '0' COMMENT '渠道手续费(分)', - `trade_state` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道侧交易状态', - `trade_time` datetime DEFAULT NULL COMMENT '渠道交易时间', - `is_matched` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已与本地支付单匹配:1是/0否', - `matched_pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '匹配到的本地支付单号', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_channel_trade` (`channel`,`channel_trade_no`,`delete_time`), - KEY `idx_bill_date` (`channel`,`bill_date`,`is_matched`), - KEY `idx_out_trade_no` (`out_trade_no`,`bill_date`), - KEY `idx_delete_time` (`delete_time`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付渠道账单明细表(对账源数据)'; - --- ------------------------------------------------------------- --- 6. 对账差异表 --- ------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS `yz_platform_payment_reconcile_diff` ( - `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', - `channel` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道标识', - `bill_date` date NOT NULL COMMENT '对账日期(同渠道账单日)', - `batch_no` varchar(40) NOT NULL DEFAULT '' COMMENT '对账批次号(同渠道同日一次对账一个批次,便于追溯与重跑)', - `local_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '本地支付单号(长款时为空串,不写 NULL 以便参与唯一键)', - `channel_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道流水号(短款时为空串)', - `local_amount` bigint(20) DEFAULT NULL COMMENT '本地金额(分),无本地记录时为 NULL', - `channel_amount` bigint(20) DEFAULT NULL COMMENT '渠道金额(分),无渠道记录时为 NULL', - `diff_amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '差额(分)= 渠道金额 - 本地金额,负数表示短款', - `diff_type` varchar(20) NOT NULL DEFAULT '' COMMENT '差异类型:long长款(渠道有本地无)/short短款(本地有渠道无)/status_mismatch状态不一致', - `handle_status` varchar(20) NOT NULL DEFAULT 'unhandled' COMMENT '处理状态:unhandled待处理/handling处理中/handled已处理/ignored已忽略', - `handle_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '处理人ID', - `handle_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '处理人姓名', - `handle_time` datetime DEFAULT NULL COMMENT '处理时间', - `remark` varchar(500) NOT NULL DEFAULT '' COMMENT '处理说明(人工填写,便于日后追溯)', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_diff` (`channel`,`bill_date`,`diff_type`,`channel_trade_no`,`local_trade_no`,`delete_time`), - KEY `idx_channel_date` (`channel`,`bill_date`), - KEY `idx_handle` (`handle_status`,`bill_date`), - KEY `idx_batch_no` (`batch_no`), - KEY `idx_delete_time` (`delete_time`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付对账差异表'; - --- ------------------------------------------------------------- --- 7. 手动原路退回记录表(兜底能力,业务上不提供退款入口) --- ------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS `yz_platform_payment_refund` ( - `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', - `refund_no` varchar(40) NOT NULL DEFAULT '' COMMENT '退回单号(支付模块生成)', - `pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '关联支付单号', - `out_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '关联业务订单号', - `channel` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道标识', - `channel_refund_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道退回流水号', - `amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '退回金额(分)', - `reason` varchar(500) NOT NULL DEFAULT '' COMMENT '退回原因(必填,人工填写,用于审计)', - `status` varchar(20) NOT NULL DEFAULT 'processing' COMMENT '退回状态:processing处理中/success成功/failed失败', - `fail_reason` varchar(500) NOT NULL DEFAULT '' COMMENT '失败原因', - `operator_id` varchar(64) NOT NULL DEFAULT '' COMMENT '操作人ID(平台管理员)', - `operator_name` varchar(128) NOT NULL DEFAULT '' COMMENT '操作人姓名', - `finished_at` datetime DEFAULT NULL COMMENT '退回完成时间', - `remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_refund_no` (`refund_no`,`delete_time`), - KEY `idx_pay_no` (`pay_no`), - KEY `idx_status` (`status`,`create_time`), - KEY `idx_delete_time` (`delete_time`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付手动原路退回记录表(兜底能力)'; diff --git a/go/docs/sql/create_platform_product_pricing.sql b/go/docs/sql/create_platform_product_pricing.sql deleted file mode 100644 index 13d8b09..0000000 --- a/go/docs/sql/create_platform_product_pricing.sql +++ /dev/null @@ -1,98 +0,0 @@ --- ============================================================= --- 产品定价(platform「基础设置 → 产品定价」) --- --- 业务说明: --- 1. 产品(yz_platform_product):平台对外售卖的功能产品,如「进销存 ERP」「CRM 客户管理」。 --- 仅维护:产品名称、产品编码、价格、关联功能(module_code)、上下架状态。 --- 2. 功能节点(yz_platform_product_feature):产品的功能树,支持父子层级(pid)。 --- 购买页按树形展示产品包含的功能清单。 --- 3. 购买订单(yz_platform_product_order):租户下单记录。 --- 在线支付走 /backend/payment/create;未配置支付渠道时可先下单,由平台在订单里手动开通。 --- --- 本脚本可重复执行:建表用 IF NOT EXISTS,补列做存在性判断,预置数据做存在性判断。 --- 运行:mysql --default-character-set=utf8mb4 -h -P -u -p < create_platform_product_pricing.sql --- ============================================================= - --- 1) 产品表 -CREATE TABLE IF NOT EXISTS `yz_platform_product` ( - `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', - `code` varchar(64) NOT NULL DEFAULT '' COMMENT '产品编码(唯一,建议与功能模块 code 一致,如 erp/crm)', - `name` varchar(128) NOT NULL DEFAULT '' COMMENT '产品名称', - `price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '产品价格(元)', - `module_code` varchar(64) NOT NULL DEFAULT '' COMMENT '关联功能模块编码 yz_system_modules(code)', - `sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前', - `status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '状态:1上架 0下架', - `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, - `update_time` datetime DEFAULT NULL, - `delete_time` datetime DEFAULT NULL, - PRIMARY KEY (`id`), - KEY `idx_code` (`code`, `delete_time`), - KEY `idx_delete_time` (`delete_time`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品定价-产品表'; - --- 2) 产品功能节点表(树形,pid=0 为顶级) -CREATE TABLE IF NOT EXISTS `yz_platform_product_feature` ( - `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', - `product_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '产品ID 关联 yz_platform_product(id)', - `pid` bigint(20) NOT NULL DEFAULT 0 COMMENT '上级节点ID,0=顶级节点', - `title` varchar(128) NOT NULL DEFAULT '' COMMENT '功能节点标题', - `description` varchar(500) NOT NULL DEFAULT '' COMMENT '功能节点说明', - `sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), - KEY `idx_product_id` (`product_id`), - KEY `idx_pid` (`pid`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品定价-产品功能节点表(树形)'; - --- 3) 产品购买订单表 -CREATE TABLE IF NOT EXISTS `yz_platform_product_order` ( - `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', - `order_no` varchar(40) NOT NULL DEFAULT '' COMMENT '业务订单号', - `tid` bigint(20) NOT NULL DEFAULT 0 COMMENT '租户ID', - `tenant_name` varchar(128) NOT NULL DEFAULT '' COMMENT '租户名称(快照)', - `product_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '产品ID', - `product_code` varchar(64) NOT NULL DEFAULT '' COMMENT '产品编码(快照)', - `product_name` varchar(128) NOT NULL DEFAULT '' COMMENT '产品名称(快照)', - `amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '订单金额(元)', - `package_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '购买后绑定的套餐ID(快照)', - `pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '支付单号 yz_platform_payment_order(pay_no)', - `channel` varchar(32) NOT NULL DEFAULT '' COMMENT '支付渠道:wechat/alipay/... 空=线下', - `status` varchar(20) NOT NULL DEFAULT 'pending' COMMENT 'pending待支付 paid已支付 activated已开通 closed已关闭', - `activate_time` datetime DEFAULT NULL COMMENT '开通时间', - `expire_time` datetime DEFAULT NULL COMMENT '有效期截止时间(单品功能默认开通后 1 年,到期权益自动失效,需续费)', - `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, - `update_time` datetime DEFAULT NULL, - `delete_time` datetime DEFAULT NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `uk_order_no` (`order_no`), - KEY `idx_tid` (`tid`), - KEY `idx_status` (`status`), - KEY `idx_delete_time` (`delete_time`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品定价-购买订单表'; - --- 4) 兼容旧结构:历史库已建过产品表时补齐/清理字段 -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_product'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_product' AND COLUMN_NAME = 'price'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_platform_product` ADD COLUMN `price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT ''产品价格(元)'' AFTER `name`', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_product_feature'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_product_feature' AND COLUMN_NAME = 'pid'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_platform_product_feature` ADD COLUMN `pid` bigint(20) NOT NULL DEFAULT 0 COMMENT ''上级节点ID,0=顶级节点'' AFTER `product_id`', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- 购买订单有效期字段(历史库补齐;单品功能默认开通后 1 年有效) -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_product_order'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_product_order' AND COLUMN_NAME = 'expire_time'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_platform_product_order` ADD COLUMN `expire_time` datetime DEFAULT NULL COMMENT ''有效期截止时间(单品功能默认开通后 1 年,到期权益自动失效,需续费)'' AFTER `activate_time`', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- 5) 平台端菜单:基础设置 → 产品定价(仅平台端可见) --- pid=17 为「基础设置」菜单;若环境中 17 不是基础设置,请在平台端「菜单管理」里手工挂载本页面: --- 组件路径 /basicSettings/pricing/index.vue,路径 /basicSettings/pricing -INSERT INTO `yz_system_menu` (`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `permission`, `creater`, `remark`, `create_time`, `update_time`) -SELECT 17, '产品定价', '/basicSettings/pricing', '/basicSettings/pricing/index.vue', 'Goods', 5, 1, 1, '[1]', 2, '', 'system', '产品定价:产品与功能节点树', NOW(), NOW() - WHERE NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_menu` WHERE `path` = '/basicSettings/pricing' AND `delete_time` IS NULL) t) - AND EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_menu` WHERE `id` = 17 AND `delete_time` IS NULL) t2); diff --git a/go/docs/sql/create_platform_site_setting.sql b/go/docs/sql/create_platform_site_setting.sql deleted file mode 100644 index 002e649..0000000 --- a/go/docs/sql/create_platform_site_setting.sql +++ /dev/null @@ -1,29 +0,0 @@ --- 平台官网站点设置表 yz_platform_site_setting(通用键值结构) --- 一行一个设置项:title 名称 / value 值 / tag 标签名 / remark 说明。 --- 模板里用 {yz:tag} 输出对应 value;新增设置项只需插一行,无需加字段。 --- 表结构需手动执行本文件(服务端已取消自动建表); --- 建表后,已登记的默认设置项会由服务端启动时自动补齐(纯数据初始化)。 --- 已登记的默认设置项: --- sitename 站点名称 --- subtitle 副标题 --- logo 深色 Logo(浅色背景) --- logow 浅色 Logo(深色背景) --- icp ICP 备案号 --- policeicp 公安备案号 --- copyright 版权信息 --- privacy 隐私政策链接 --- terms 使用条款链接 - -CREATE TABLE IF NOT EXISTS `yz_platform_site_setting` ( - `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', - `title` varchar(128) NOT NULL DEFAULT '' COMMENT '设置项名称', - `value` text COMMENT '设置项的值', - `tag` varchar(64) NOT NULL DEFAULT '' COMMENT '标签名,模板中写作 {yz:tag}', - `remark` varchar(255) NOT NULL DEFAULT '' 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`), - UNIQUE KEY `uk_tag` (`tag`, `delete_time`), - KEY `idx_delete_time` (`delete_time`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台官网站点设置表'; diff --git a/go/docs/sql/create_platform_wechat_mp.sql b/go/docs/sql/create_platform_wechat_mp.sql deleted file mode 100644 index 0ea3f0c..0000000 --- a/go/docs/sql/create_platform_wechat_mp.sql +++ /dev/null @@ -1,101 +0,0 @@ --- ============================================================= --- 微信公众号(服务号)绑定与推送 --- --- 业务说明: --- 1. 服务号配置(yz_platform_wechat_mp_config):AppID / AppSecret / Token / EncodingAESKey, --- 其中 AppSecret 与 EncodingAESKey 以 AES-256-GCM 加密后存储 --- (密钥取 app.conf 的 wechat_mp_secret_key,代码内部 SHA-256 派生)。 --- 2. 关注用户(yz_platform_wechat_mp_follower):用户关注/取关事件沉淀,openid 维度唯一; --- 通过「扫码关注 → 公众号回复验证码 → 平台输入验证码」流程绑定到 --- 平台用户(platform_user)或租户用户(tenant_user),认证后可定向推送。 --- 3. 验证码(yz_platform_wechat_mp_verify_code):一次绑定会话一条,scene 绑定二维码场景值, --- 默认 5 分钟有效;状态:0待扫码 1已扫码待核销 2已核销 3已过期。 --- --- 未认证服务号限制:仅支持服务器配置、关注/扫码事件、被动回复、带参数二维码; --- 模板消息/订阅通知/客服消息需认证后用。 --- --- 本脚本可重复执行:建表用 IF NOT EXISTS,旧表名(yz_wechat_*)做存在性改名兼容。 --- 运行:mysql --default-character-set=utf8mb4 -h -P -u -p < create_platform_wechat_mp.sql --- ============================================================= - --- 0) 兼容早期表名(yz_wechat_mp_config / yz_wechat_follower / yz_wechat_verify_code): --- 旧表存在且新表不存在时直接改名,保留已配置数据。 -SET @old := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_wechat_mp_config'); -SET @new := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_wechat_mp_config'); -SET @ddl := IF(@old > 0 AND @new = 0, 'RENAME TABLE `yz_wechat_mp_config` TO `yz_platform_wechat_mp_config`', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @old := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_wechat_follower'); -SET @new := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_wechat_mp_follower'); -SET @ddl := IF(@old > 0 AND @new = 0, 'RENAME TABLE `yz_wechat_follower` TO `yz_platform_wechat_mp_follower`', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @old := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_wechat_verify_code'); -SET @new := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_wechat_mp_verify_code'); -SET @ddl := IF(@old > 0 AND @new = 0, 'RENAME TABLE `yz_wechat_verify_code` TO `yz_platform_wechat_mp_verify_code`', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- 1) 服务号配置(单行) -CREATE TABLE IF NOT EXISTS `yz_platform_wechat_mp_config` ( - `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', - `app_id` varchar(64) NOT NULL DEFAULT '' COMMENT '公众号 AppID', - `app_secret` varchar(512) NOT NULL DEFAULT '' COMMENT 'AppSecret(AES 加密存储)', - `token` varchar(128) NOT NULL DEFAULT '' COMMENT '服务器配置 Token(自定义,用于签名校验)', - `aes_key` varchar(512) NOT NULL DEFAULT '' COMMENT 'EncodingAESKey(43位,AES 加密存储)', - `encrypt_mode` varchar(16) NOT NULL DEFAULT 'plain' COMMENT '消息加解密方式:plain明文 compatible兼容 safe安全', - `template_id` varchar(128) NOT NULL DEFAULT '' COMMENT '模板消息ID(认证后在公众号后台申请后填入)', - `verified` tinyint(4) NOT NULL DEFAULT 0 COMMENT '公众号是否已认证:1是 0否(认证后才可主动推送)', - `enabled` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否启用:1启用 0停用', - `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` datetime DEFAULT NULL COMMENT '更新时间', - PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信公众号(服务号)配置表'; - --- 2) 关注用户(粉丝) -CREATE TABLE IF NOT EXISTS `yz_platform_wechat_mp_follower` ( - `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', - `openid` varchar(64) NOT NULL DEFAULT '' COMMENT '微信 openid(公众号维度用户标识)', - `unionid` varchar(64) NOT NULL DEFAULT '' COMMENT '微信 unionid(开放平台绑定后才有)', - `nickname` varchar(128) NOT NULL DEFAULT '' COMMENT '昵称(未认证公众号可能拉取不到,留空展示 openid 缩写)', - `avatar` varchar(512) NOT NULL DEFAULT '' COMMENT '头像地址', - `sex` tinyint(4) NOT NULL DEFAULT 0 COMMENT '性别:0未知 1男 2女', - `city` varchar(64) NOT NULL DEFAULT '' COMMENT '城市', - `province` varchar(64) NOT NULL DEFAULT '' COMMENT '省份', - `country` varchar(64) NOT NULL DEFAULT '' COMMENT '国家', - `subscribe` tinyint(4) NOT NULL DEFAULT 1 COMMENT '关注状态:1已关注 0已取关', - `subscribe_time` datetime DEFAULT NULL COMMENT '关注时间', - `unsubscribe_time` datetime DEFAULT NULL COMMENT '取关时间', - `bind_type` varchar(32) NOT NULL DEFAULT '' COMMENT '绑定对象:platform_user平台用户 tenant_user租户用户 空=未绑定', - `bind_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '绑定对象ID(平台用户ID / 租户用户ID)', - `bind_tid` bigint(20) NOT NULL DEFAULT 0 COMMENT '绑定租户ID(租户用户时记录)', - `bind_time` datetime DEFAULT NULL COMMENT '绑定时间', - `remark` varchar(255) NOT NULL DEFAULT '' 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`), - UNIQUE KEY `uk_openid` (`openid`), - KEY `idx_bind` (`bind_type`,`bind_id`), - KEY `idx_subscribe` (`subscribe`), - KEY `idx_delete_time` (`delete_time`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信公众号关注用户(粉丝)表'; - --- 3) 关注验证码(绑定会话) -CREATE TABLE IF NOT EXISTS `yz_platform_wechat_mp_verify_code` ( - `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', - `scene` varchar(64) NOT NULL DEFAULT '' COMMENT '二维码场景值(随机串,唯一)', - `code` varchar(8) NOT NULL DEFAULT '' COMMENT '验证码(6位数字,公众号被动回复给用户)', - `openid` varchar(64) NOT NULL DEFAULT '' COMMENT '扫码用户 openid(扫码后写入)', - `status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '状态:0待扫码 1已扫码待核销 2已核销 3已过期', - `bind_type` varchar(32) NOT NULL DEFAULT '' COMMENT '发起绑定方:platform_user / tenant_user', - `bind_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '发起绑定方ID', - `bind_tid` bigint(20) NOT NULL DEFAULT 0 COMMENT '发起绑定租户ID', - `expire_at` datetime DEFAULT NULL COMMENT '有效期截止(默认5分钟)', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` datetime DEFAULT NULL COMMENT '更新时间', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_scene` (`scene`), - KEY `idx_openid` (`openid`), - KEY `idx_create_time` (`create_time`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信公众号关注取码/绑定验证码表'; diff --git a/go/docs/sql/create_tenant_package.sql b/go/docs/sql/create_tenant_package.sql deleted file mode 100644 index 4428f5d..0000000 --- a/go/docs/sql/create_tenant_package.sql +++ /dev/null @@ -1,228 +0,0 @@ --- ============================================================= --- 租户套餐 + 用户数配额(平台端「租户套餐」功能) --- --- 业务说明: --- 1. 套餐(yz_system_tenant_package)决定租户能开通哪些功能模块 --- (模块取自 yz_system_modules,如 erp / oa / crm / cms), --- 租户绑定套餐后,租户端菜单按套餐包含的模块过滤(未包含的整块菜单不展示)。 --- 2. 所有租户都有用户数上限(yz_system_tenant.user_quota,默认 20), --- 租户用户数达到上限后不允许再新增账号,需要先增购用户数: --- · 单个增购:按套餐的 extra_user_price(如 200 元/人) --- · 套餐增购:按 yz_system_tenant_user_quota_package 配置的规格(如 5人/500元) --- 每次增购写入 yz_system_tenant_quota_order 记录,并累加到租户的 user_quota。 --- --- 本脚本可重复执行:建表用 IF NOT EXISTS,补列/预置数据均做存在性判断。 --- 运行:mysql --default-character-set=utf8mb4 -h -P -u -p < create_tenant_package.sql --- ============================================================= - --- 1) 租户套餐表 -CREATE TABLE IF NOT EXISTS `yz_system_tenant_package` ( - `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', - `name` varchar(64) NOT NULL DEFAULT '' COMMENT '套餐名称,如:ERP套餐', - `code` varchar(64) NOT NULL DEFAULT '' COMMENT '套餐编码(唯一,程序识别用)', - `description` varchar(500) NOT NULL DEFAULT '' COMMENT '套餐说明', - `price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '套餐售价(元)', - `user_quota` int(11) NOT NULL DEFAULT 20 COMMENT '套餐包含的用户数(新租户绑定套餐后初始化的用户数上限)', - `extra_user_price` decimal(10,2) NOT NULL DEFAULT 200.00 COMMENT '单个用户增购价格(元/人)', - `is_default` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否默认套餐:1是 0否(新建租户未指定套餐时使用)', - `allow_repurchase` tinyint(4) NOT NULL DEFAULT 1 COMMENT '是否允许重复购买:1允许(默认) 0仅可购买一次(体验套餐)', - `sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前', - `status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '状态:1启用 0禁用', - `remark` varchar(255) NOT NULL DEFAULT '' 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_code` (`code`, `delete_time`), - KEY `idx_delete_time` (`delete_time`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户套餐表(功能模块 + 用户数配额)'; - --- 2) 套餐-功能模块关联表(一个套餐勾选多个模块,模块取自 yz_system_modules.code) -CREATE TABLE IF NOT EXISTS `yz_system_tenant_package_module` ( - `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', - `package_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '套餐ID 关联 yz_system_tenant_package(id)', - `module_code` varchar(64) NOT NULL DEFAULT '' COMMENT '功能模块编码 关联 yz_system_modules(code),如 erp/oa/crm/cms', - `module_name` varchar(64) NOT NULL DEFAULT '' COMMENT '功能模块名称(冗余,便于展示)', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - PRIMARY KEY (`id`), - KEY `idx_package_id` (`package_id`), - KEY `idx_module_code` (`module_code`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户套餐-功能模块关联表'; - --- 3) 用户数加购套餐表(平台可维护的「人/元」规格,如 5人 500元) -CREATE TABLE IF NOT EXISTS `yz_system_tenant_user_quota_package` ( - `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', - `name` varchar(64) NOT NULL DEFAULT '' COMMENT '规格名称,如:5人套餐', - `user_count` int(11) NOT NULL DEFAULT 0 COMMENT '包含用户数', - `price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '售价(元)', - `sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前', - `status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '状态:1启用 0禁用', - `remark` varchar(255) NOT NULL DEFAULT '' 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_delete_time` (`delete_time`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户用户数加购套餐表'; - --- 4) 租户用户数增购记录表 -CREATE TABLE IF NOT EXISTS `yz_system_tenant_quota_order` ( - `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', - `tid` bigint(20) NOT NULL DEFAULT 0 COMMENT '租户ID 关联 yz_system_tenant(id)', - `type` tinyint(4) NOT NULL DEFAULT 1 COMMENT '增购方式:1单个增购 2套餐增购', - `quota_package_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '加购套餐ID(type=2 时有效)', - `package_name` varchar(64) NOT NULL DEFAULT '' COMMENT '加购规格名称(冗余)', - `user_count` int(11) NOT NULL DEFAULT 0 COMMENT '本次增购用户数', - `unit_price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '单价(元/人,type=1 时使用)', - `amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '本次金额(元)', - `quota_before` int(11) NOT NULL DEFAULT 0 COMMENT '增购前用户数上限', - `quota_after` int(11) NOT NULL DEFAULT 0 COMMENT '增购后用户数上限', - `operator` varchar(64) NOT NULL DEFAULT '' COMMENT '操作人(平台账号)', - `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', - `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - PRIMARY KEY (`id`), - KEY `idx_tid` (`tid`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户用户数增购记录表'; - --- 4.5) 历史库补列:套餐是否允许重复购买(0=仅可购买一次,适用于体验套餐) -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant_package'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant_package' AND COLUMN_NAME = 'allow_repurchase'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_system_tenant_package` ADD COLUMN `allow_repurchase` tinyint(4) NOT NULL DEFAULT 1 COMMENT ''是否允许重复购买:1允许(默认) 0仅可购买一次(体验套餐)'' AFTER `is_default`', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- 体验套餐(不允许重复购买)不涉及用户数增购:清理历史增购价 -UPDATE `yz_system_tenant_package` SET `extra_user_price` = 0 WHERE `allow_repurchase` = 0; - --- 5) 租户表补列:套餐ID + 用户数上限 -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant' AND COLUMN_NAME = 'package_id'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_system_tenant` ADD COLUMN `package_id` bigint(20) NOT NULL DEFAULT 0 COMMENT ''套餐ID 关联 yz_system_tenant_package(id),0-未绑定''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant'); -SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant' AND COLUMN_NAME = 'user_quota'); -SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_system_tenant` ADD COLUMN `user_quota` int(11) NOT NULL DEFAULT 20 COMMENT ''用户数上限(默认20,增购后累加)''', 'DO 0'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- 存量租户兜底:用户数上限为 0 的按默认 20 初始化 -UPDATE `yz_system_tenant` SET `user_quota` = 20 WHERE `user_quota` <= 0; - --- 存量租户兜底:用户数上限小于已开用户数时,按已开用户数抬高(避免历史租户立刻被锁死) -UPDATE `yz_system_tenant` t - SET t.`user_quota` = ( - SELECT cnt FROM (SELECT COUNT(*) AS cnt FROM `yz_system_tenant_user` u WHERE u.`tid` = t.`id` AND u.`delete_time` IS NULL) c - ) - WHERE t.`user_quota` < ( - SELECT cnt FROM (SELECT COUNT(*) AS cnt FROM `yz_system_tenant_user` u WHERE u.`tid` = t.`id` AND u.`delete_time` IS NULL) c2 - ); - --- 6) 预置套餐(幂等:按 code 判断,不存在才插入) -INSERT INTO `yz_system_tenant_package` (`name`, `code`, `description`, `price`, `user_quota`, `extra_user_price`, `is_default`, `sort`, `status`, `remark`, `create_time`, `update_time`) -SELECT '基础版', 'basic', '基础功能:系统管理、基础设置、便捷工具', 0.00, 20, 200.00, 1, 1, 1, '默认套餐,新建租户自动绑定', NOW(), NOW() - WHERE NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `code` = 'basic' AND `delete_time` IS NULL) t); - -INSERT INTO `yz_system_tenant_package` (`name`, `code`, `description`, `price`, `user_quota`, `extra_user_price`, `is_default`, `sort`, `status`, `remark`, `create_time`, `update_time`) -SELECT 'ERP套餐', 'erp', '基础功能 + 进销存(ERP)', 0.00, 20, 200.00, 0, 2, 1, '', NOW(), NOW() - WHERE NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `code` = 'erp' AND `delete_time` IS NULL) t); - -INSERT INTO `yz_system_tenant_package` (`name`, `code`, `description`, `price`, `user_quota`, `extra_user_price`, `is_default`, `sort`, `status`, `remark`, `create_time`, `update_time`) -SELECT 'CRM套餐', 'crm', '基础功能 + 客户关系管理(CRM)', 0.00, 20, 200.00, 0, 3, 1, '', NOW(), NOW() - WHERE NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `code` = 'crm' AND `delete_time` IS NULL) t); - -INSERT INTO `yz_system_tenant_package` (`name`, `code`, `description`, `price`, `user_quota`, `extra_user_price`, `is_default`, `sort`, `status`, `remark`, `create_time`, `update_time`) -SELECT '企业全功能版', 'all', '基础功能 + 进销存 + OA + CRM + 网站管理', 0.00, 20, 200.00, 0, 9, 1, '', NOW(), NOW() - WHERE NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `code` = 'all' AND `delete_time` IS NULL) t); - --- 7) 预置套餐功能模块(按套餐 code + 模块 code 幂等插入) --- 注意:MySQL 的派生表不能引用外层字段,因此这里把「已存在的关联」整表materialize 成 ex, --- 再在 WHERE 中与外层套餐/模块做关联,既避免了「不能 SELECT 目标表」,也保证可重复执行。 --- 基础模块:基础设置(basicSettings)、系统管理(systemManager)、便捷工具(bianjiegongju) -INSERT INTO `yz_system_tenant_package_module` (`package_id`, `module_code`, `module_name`, `create_time`) -SELECT p.`id`, m.`code`, m.`name`, NOW() - FROM `yz_system_tenant_package` p - JOIN `yz_system_modules` m ON m.`delete_time` IS NULL AND m.`code` IN ('basicSettings', 'systemManager', 'bianjiegongju') - WHERE p.`code` IN ('basic', 'erp', 'crm', 'all') AND p.`delete_time` IS NULL - AND NOT EXISTS ( - SELECT 1 FROM (SELECT `package_id`, `module_code` FROM `yz_system_tenant_package_module`) ex - WHERE ex.`package_id` = p.`id` AND ex.`module_code` = m.`code` - ); - --- ERP套餐:附加 erp 模块 -INSERT INTO `yz_system_tenant_package_module` (`package_id`, `module_code`, `module_name`, `create_time`) -SELECT p.`id`, m.`code`, m.`name`, NOW() - FROM `yz_system_tenant_package` p - JOIN `yz_system_modules` m ON m.`delete_time` IS NULL AND m.`code` = 'erp' - WHERE p.`code` = 'erp' AND p.`delete_time` IS NULL - AND NOT EXISTS ( - SELECT 1 FROM (SELECT `package_id`, `module_code` FROM `yz_system_tenant_package_module`) ex - WHERE ex.`package_id` = p.`id` AND ex.`module_code` = m.`code` - ); - --- CRM套餐:附加 crm 模块 -INSERT INTO `yz_system_tenant_package_module` (`package_id`, `module_code`, `module_name`, `create_time`) -SELECT p.`id`, m.`code`, m.`name`, NOW() - FROM `yz_system_tenant_package` p - JOIN `yz_system_modules` m ON m.`delete_time` IS NULL AND m.`code` = 'crm' - WHERE p.`code` = 'crm' AND p.`delete_time` IS NULL - AND NOT EXISTS ( - SELECT 1 FROM (SELECT `package_id`, `module_code` FROM `yz_system_tenant_package_module`) ex - WHERE ex.`package_id` = p.`id` AND ex.`module_code` = m.`code` - ); - --- 企业全功能版:附加 erp / oa / crm / cms -INSERT INTO `yz_system_tenant_package_module` (`package_id`, `module_code`, `module_name`, `create_time`) -SELECT p.`id`, m.`code`, m.`name`, NOW() - FROM `yz_system_tenant_package` p - JOIN `yz_system_modules` m ON m.`delete_time` IS NULL AND m.`code` IN ('erp', 'oa', 'crm', 'cms') - WHERE p.`code` = 'all' AND p.`delete_time` IS NULL - AND NOT EXISTS ( - SELECT 1 FROM (SELECT `package_id`, `module_code` FROM `yz_system_tenant_package_module`) ex - WHERE ex.`package_id` = p.`id` AND ex.`module_code` = m.`code` - ); - --- 8) 预置用户数加购套餐(5人 500元;可按需在平台端维护更多规格) -INSERT INTO `yz_system_tenant_user_quota_package` (`name`, `user_count`, `price`, `sort`, `status`, `remark`, `create_time`, `update_time`) -SELECT '5人套餐', 5, 500.00, 1, 1, '一次性增购 5 个用户数', NOW(), NOW() - WHERE NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_tenant_user_quota_package` WHERE `name` = '5人套餐' AND `delete_time` IS NULL) t); - -INSERT INTO `yz_system_tenant_user_quota_package` (`name`, `user_count`, `price`, `sort`, `status`, `remark`, `create_time`, `update_time`) -SELECT '10人套餐', 10, 900.00, 2, 1, '一次性增购 10 个用户数', NOW(), NOW() - WHERE NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_tenant_user_quota_package` WHERE `name` = '10人套餐' AND `delete_time` IS NULL) t); - --- 9) 存量租户兜底:未绑定套餐的存量租户绑定「企业全功能版」, --- 避免上线后老租户因套餐未配置而看不到原有功能菜单; --- 之后可在「租户管理 → 编辑 / 套餐与用户数」中按实际购买情况改为 ERP/CRM 等套餐。 --- (若没有 all 套餐,则退回绑定默认套餐) -UPDATE `yz_system_tenant` t - SET t.`package_id` = (SELECT p.`id` FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `code` = 'all' AND `delete_time` IS NULL ORDER BY `sort`, `id` LIMIT 1) p) - WHERE (t.`package_id` IS NULL OR t.`package_id` = 0) - AND EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `code` = 'all' AND `delete_time` IS NULL) p2); - -UPDATE `yz_system_tenant` t - SET t.`package_id` = (SELECT p.`id` FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `is_default` = 1 AND `delete_time` IS NULL ORDER BY `sort`, `id` LIMIT 1) p) - WHERE (t.`package_id` IS NULL OR t.`package_id` = 0) - AND EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `is_default` = 1 AND `delete_time` IS NULL) p2); - --- 10) 平台端菜单:基础设置 → 租户套餐(仅平台端可见) --- pid=17 为「基础设置」菜单;若环境中 17 不是基础设置,请在平台端「菜单管理」里手工挂载本页面: --- 组件路径 /basicSettings/tenantpackage/index.vue,路径 /basicSettings/tenantpackage -INSERT INTO `yz_system_menu` (`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `permission`, `creater`, `remark`, `create_time`, `update_time`) -SELECT 17, '租户套餐', '/basicSettings/tenantpackage', '/basicSettings/tenantpackage/index.vue', 'Box', 4, 1, 1, '[1]', 2, '', 'system', '租户功能套餐与用户数加购规格', NOW(), NOW() - WHERE NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_menu` WHERE `path` = '/basicSettings/tenantpackage' AND `delete_time` IS NULL) t) - AND EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_menu` WHERE `id` = 17 AND `delete_time` IS NULL) t2); - --- 11) 【按需执行】存量租户套餐调整 --- 本脚本第 9 步只处理「package_id 为空/0」的租户;若存量租户已被绑定为「基础版」, --- 上线菜单按套餐过滤后,这些租户将看不到 进销存/OA/CRM/网站管理 等菜单。 --- 如需要让老租户保留原有全部功能,按下面的语句把它们改为「企业全功能版」 --- (或改成各自实际购买的套餐;也可直接在平台端「租户管理 → 编辑」里选择套餐)。 --- --- UPDATE `yz_system_tenant` --- SET `package_id` = (SELECT p.`id` FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `code` = 'all' AND `delete_time` IS NULL ORDER BY `sort`, `id` LIMIT 1) p) --- WHERE `id` IN (1, 2); -- ← 改成你的租户ID,或去掉 WHERE 批量处理 - --- 校验:套餐与模块 --- SELECT p.id, p.name, p.code, p.user_quota, p.extra_user_price, GROUP_CONCAT(pm.module_code) AS modules --- FROM yz_system_tenant_package p LEFT JOIN yz_system_tenant_package_module pm ON pm.package_id = p.id --- WHERE p.delete_time IS NULL GROUP BY p.id; --- SELECT id, tenant_name, package_id, user_quota FROM yz_system_tenant; diff --git a/go/docs/sql/create_tenant_setting_items.sql b/go/docs/sql/create_tenant_setting_items.sql deleted file mode 100644 index 4079285..0000000 --- a/go/docs/sql/create_tenant_setting_items.sql +++ /dev/null @@ -1,20 +0,0 @@ --- ============================================================= --- 租户站点扩展设置表:yz_system_tenant_setting_items --- 说明:租户后台「站点设置」的扩展键值项(tid + setting_key 唯一)。 --- 可重复执行(CREATE TABLE IF NOT EXISTS)。 --- 运行:mysql -h -P -u -p < create_tenant_setting_items.sql --- ============================================================= - -CREATE TABLE IF NOT EXISTS `yz_system_tenant_setting_items` ( - `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - `tid` BIGINT UNSIGNED NOT NULL DEFAULT 0, - `setting_key` VARCHAR(64) NOT NULL DEFAULT '', - `setting_value` LONGTEXT NULL, - `create_time` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, - `update_time` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - `delete_time` DATETIME NULL DEFAULT NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `uk_tid_key` (`tid`,`setting_key`), - KEY `idx_tid` (`tid`), - KEY `idx_delete_time` (`delete_time`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户站点扩展设置'; diff --git a/go/docs/sql/init_front_menu_defaults.sql b/go/docs/sql/init_front_menu_defaults.sql deleted file mode 100644 index 6938e9d..0000000 --- a/go/docs/sql/init_front_menu_defaults.sql +++ /dev/null @@ -1,29 +0,0 @@ --- ============================================================= --- 官网前端导航:全局默认菜单(tenant_id=0,全租户共享、不可删改) --- 表:yz_backend_menu_front --- 说明: --- 默认 5 个一级导航:新闻中心 / 产品展示 / 解决方案 / 关于我们 / 联系我们。 --- 以 tenant_id=0 存放,租户端接口带 tenant_id 过滤,天然无法触碰这些行。 --- 路径对应官网前台路由:新闻中心 /news,其余为单页 /page/:path --- (单页内容在 backend 端「单页管理」中按 path 维护)。 --- 本脚本幂等:同 tenant_id=0 下存在同名(含已软删)记录则跳过,可重复执行。 --- 如需恢复被误删的默认菜单,手动将其 delete_time 置 NULL 即可。 --- 运行:mysql --default-character-set=utf8mb4 -h -P -u -p < init_front_menu_defaults.sql --- ============================================================= - -INSERT INTO `yz_backend_menu_front` - (`tenant_id`, `pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `type`, `permission`, `create_time`, `update_time`) -SELECT v.`tenant_id`, v.`pid`, v.`title`, v.`path`, '', '', v.`sort`, 1, 1, v.`type`, '', NOW(), NOW() -FROM ( - SELECT 0 AS `tenant_id`, 0 AS `pid`, '新闻中心' AS `title`, '/news' AS `path`, 1 AS `sort`, 2 AS `type` - UNION ALL SELECT 0, 0, '产品展示', '/page/products', 2, 4 - UNION ALL SELECT 0, 0, '解决方案', '/page/solutions', 3, 4 - UNION ALL SELECT 0, 0, '关于我们', '/page/about', 4, 4 - UNION ALL SELECT 0, 0, '联系我们', '/page/contact', 5, 4 -) v -WHERE NOT EXISTS ( - SELECT 1 FROM `yz_backend_menu_front` m - WHERE m.`tenant_id` = 0 AND m.`title` = v.`title` -); - --- 校验:SELECT id, title, path, sort, type, delete_time FROM yz_backend_menu_front WHERE tenant_id = 0 ORDER BY sort; diff --git a/go/docs/sql/init_platform_payment_channel.sql b/go/docs/sql/init_platform_payment_channel.sql deleted file mode 100644 index 4fe1882..0000000 --- a/go/docs/sql/init_platform_payment_channel.sql +++ /dev/null @@ -1,39 +0,0 @@ --- ============================================================= --- 平台支付渠道:初始化五个渠道配置行 --- 表:yz_platform_payment_channel --- 说明: --- 五个渠道(微信支付 / 支付宝 / 银联 / 云闪付 / PayPal)与前端 --- platform/src/views/system/payment/channels/ 一一对应。 --- 这里只插入「渠道骨架」:名称、回调地址、默认开关,商户号与密钥留空, --- 由平台管理员在渠道配置页填写(敏感参数写入 config_json 前由后端加密)。 --- 初始 enabled 一律为 0(停用),配置完成并通过「测试连接」后再由页面启用, --- 前端会按「enabled=1 → 已启用;enabled=0 且无参数 → 未配置;enabled=0 且有参数 → 已停用」展示状态。 --- 云闪付复用银联商户参数,仅存收银台标识开关(extra_json.show_logo / logo_positions); --- PayPal 默认沙箱环境(extra_json.env=sandbox),当前业务不跨境,保持停用。 --- 本脚本幂等:已存在同名渠道(含已软删)则跳过,可重复执行。 --- 依赖:先执行 create_platform_payment.sql 建表。 --- 运行:mysql --default-character-set=utf8mb4 -h -P -u -p < init_platform_payment_channel.sql --- ============================================================= - -INSERT INTO `yz_platform_payment_channel` - (`channel`, `name`, `merchant_no`, `config_json`, `cert_json`, `callback_url`, `enabled`, `extra_json`, `remark`, `create_time`, `update_time`) -SELECT v.`channel`, v.`name`, '', NULL, NULL, - CONCAT('/api/payment/callback/', v.`channel`), 0, v.`extra_json`, v.`remark`, NOW(), NOW() -FROM ( - SELECT 'wechat' AS `channel`, '微信支付' AS `name`, '' AS `extra_json`, - '主渠道:JSAPI / Native 扫码收款,需商户号、APPID、APIv3 密钥与商户证书' AS `remark` - UNION ALL SELECT 'alipay', '支付宝', '', - '电脑网站支付 / 手机网站支付,RSA2 签名,异步通知无需登记' - UNION ALL SELECT 'unionpay', '银联', '', - '全渠道收单,商户号与证书由收单机构下发,回调地址需向收单机构报备' - UNION ALL SELECT 'cloudpay', '云闪付', '{"show_logo":1,"logo_positions":["pc","h5"]}', - '复用银联商户参数,仅控制收银台是否展示云闪付标识' - UNION ALL SELECT 'paypal', 'PayPal', '{"env":"sandbox"}', - '跨境预留渠道,当前业务不跨境,默认停用' -) v -WHERE NOT EXISTS ( - SELECT 1 FROM `yz_platform_payment_channel` c WHERE c.`channel` = v.`channel` -); - --- 校验:SELECT id, channel, name, merchant_no, enabled, callback_url, delete_time --- FROM yz_platform_payment_channel ORDER BY id; diff --git a/go/docs/sql/init_platform_site_setting.sql b/go/docs/sql/init_platform_site_setting.sql deleted file mode 100644 index 1a083bb..0000000 --- a/go/docs/sql/init_platform_site_setting.sql +++ /dev/null @@ -1,67 +0,0 @@ --- 平台官网站点设置 - 初始化/调整数据(yz_platform_site_setting,通用键值结构) --- --- 前提:表已按 create_platform_site_setting.sql 建成通用结构(title/value/tag/remark)。 --- --- remark 可选标记(用 | 分隔,未识别片段作为说明文字展示): --- group:分组名 决定 platform 端界面落在哪个 tab(缺省「基本设置」) --- type:控件类型 text(默认) / textarea / image(图片上传) --- --- 当前分组:基本设置 / 公司信息 / 备案信息 / 政策条款 --- value 为建议初始值,请按实际情况修改(备案号请替换为真实备案号)。 --- --- ============================================================ --- PART 1:全新环境(表中无数据)—— 直接执行 --- ============================================================ -INSERT INTO `yz_platform_site_setting` - (`title`, `value`, `tag`, `remark`, `create_time`, `update_time`) -VALUES - -- 基本设置 - ('站点名称', '云泽科技', 'sitename', '常用于 和页头 | group:基本设置', NOW(), NOW()), - ('副标题', 'YUNZER TECH', 'subtitle', '站点名称下方的补充说明,常用于页头/首页 | group:基本设置', NOW(), NOW()), - ('Logo(深色)', '', 'logo', '深色 Logo,用于浅色背景(如页头) | group:基本设置 | type:image', NOW(), NOW()), - ('Logo(浅色)', '', 'logow', '浅色 Logo,用于深色背景(如页脚) | group:基本设置 | type:image', NOW(), NOW()), - -- 公司信息 - ('公司名称', '云泽科技有限公司', 'companyname', '公司全称,常用于页脚与「关于我们」 | group:公司信息', NOW(), NOW()), - ('联系电话', '400-000-0000', 'phone', '服务热线,常用于页头与页脚 | group:公司信息', NOW(), NOW()), - ('电子邮箱', 'contact@yunzer.com', 'email', '商务合作邮箱,可配合 mailto: 使用 | group:公司信息', NOW(), NOW()), - ('公司地址', '北京市海淀区中关村软件园 25 号楼', 'address', '公司所在地址,展示于页脚/联系页面 | group:公司信息', NOW(), NOW()), - ('工作时间', '工作日 9:00 - 18:00', 'worktime', '如:工作日 9:00 - 18:00 | group:公司信息', NOW(), NOW()), - -- 备案信息 - ('ICP 备案号', '京ICP备00000000号-1', 'icp', '请替换为真实备案号,常用于页脚 | group:备案信息', NOW(), NOW()), - ('公安备案', '京公网安备11010800000000号', 'policeicp', '请替换为真实公安备案号,常用于页脚 | group:备案信息', NOW(), NOW()), - ('版权信息', 'COPYRIGHT (C) 2026 YUNZER TECHNOLOGY CO., LTD. ALL RIGHTS RESERVED.', 'copyright', '常用于页脚 | group:备案信息', NOW(), NOW()), - -- 政策条款 - ('隐私政策', '/policy/privacy', 'privacy', '隐私政策页面链接 | group:政策条款', NOW(), NOW()), - ('使用条款', '/policy/terms', 'terms', '使用条款页面链接 | group:政策条款', NOW(), NOW()); - --- ============================================================ --- PART 2:已有数据(表里有旧的 9 项)—— 只做增量调整,可重复执行 --- ============================================================ - --- 2.1 隐私政策 / 使用条款 归入「政策条款」tab -UPDATE `yz_platform_site_setting` - SET `remark` = '隐私政策页面链接 | group:政策条款', `update_time` = NOW() - WHERE `tag` = 'privacy' AND `delete_time` IS NULL; - -UPDATE `yz_platform_site_setting` - SET `remark` = '使用条款页面链接 | group:政策条款', `update_time` = NOW() - WHERE `tag` = 'terms' AND `delete_time` IS NULL; - --- 2.2 新增「公司信息」分组的 5 个设置项(已存在同 tag 的记录不会重复插入) -INSERT INTO `yz_platform_site_setting` - (`title`, `value`, `tag`, `remark`, `create_time`, `update_time`) -SELECT v.`title`, v.`value`, v.`tag`, v.`remark`, NOW(), NOW() -FROM ( - SELECT '公司名称' AS `title`, '云泽科技有限公司' AS `value`, 'companyname' AS `tag`, - '公司全称,常用于页脚与「关于我们」 | group:公司信息' AS `remark` - UNION ALL SELECT '联系电话', '400-000-0000', 'phone', '服务热线,常用于页头与页脚 | group:公司信息' - UNION ALL SELECT '电子邮箱', 'contact@yunzer.com', 'email', '商务合作邮箱,可配合 mailto: 使用 | group:公司信息' - UNION ALL SELECT '公司地址', '北京市海淀区中关村软件园 25 号楼', 'address', '公司所在地址,展示于页脚/联系页面 | group:公司信息' - UNION ALL SELECT '工作时间', '工作日 9:00 - 18:00', 'worktime', '如:工作日 9:00 - 18:00 | group:公司信息' -) v -WHERE NOT EXISTS ( - SELECT 1 FROM `yz_platform_site_setting` s - WHERE s.`tag` = v.`tag` AND s.`delete_time` IS NULL -); - --- 校验:SELECT tag, title, value, remark FROM yz_platform_site_setting WHERE delete_time IS NULL ORDER BY id; diff --git a/go/docs/sql/init_platform_website_file_cate.sql b/go/docs/sql/init_platform_website_file_cate.sql new file mode 100644 index 0000000..91e41fd --- /dev/null +++ b/go/docs/sql/init_platform_website_file_cate.sql @@ -0,0 +1,22 @@ +-- ============================================================= +-- 平台官网管理专用上传分组:官网素材 +-- +-- 官网管理(platform/src/views/website)下所有图片/文件上传统一归入该分组, +-- 便于在「系统管理 → 文件管理」(/system/fileManager) 中集中查找与复用。 +-- +-- 归属:tid = 0(平台端),uid = 1(平台管理员),与既有 appsupgrade、图片 分组一致。 +-- 幂等:已存在同名未删除分组时不会重复插入。 +-- 运行:mysql -h<host> -P<port> -u<user> -p <db> < init_platform_website_file_cate.sql +-- +-- 备注:前端 utils/websiteUpload.js 也会在上传时按名称兜底查找/补建, +-- 因此即使本文件未执行,首次上传后该分组同样会出现。 +-- ============================================================= + +INSERT INTO `yz_system_files_category` (`tid`, `uid`, `name`, `create_time`) +SELECT * FROM ( + SELECT 0 AS tid, 1 AS uid, '官网素材' AS name, NOW() AS create_time +) AS seed +WHERE NOT EXISTS ( + SELECT 1 FROM `yz_system_files_category` + WHERE `tid` = 0 AND `name` = '官网素材' AND `delete_time` IS NULL +); diff --git a/go/docs/sql/update_role_system.sql b/go/docs/sql/update_role_system.sql deleted file mode 100644 index db71210..0000000 --- a/go/docs/sql/update_role_system.sql +++ /dev/null @@ -1,67 +0,0 @@ --- ============================================================= --- 角色体系整理:命名规范 + 全局角色统一 --- 表:yz_system_admin_role --- --- 整理后的角色模型: --- 平台端(cid=1, tenant_id=0): --- 总管理员 平台端最高权限(rights 为空 = 全权限),管理所有租户 --- 平台管理员 平台端运营人员(由第 4 段创建) --- 租户端(cid=2, tenant_id=0,全局共用,所有租户都能看到/使用): --- 租户管理员 租户内的管理员(rights 为空 = 全权限) --- 租户用户 租户内的普通用户(原「普通租户」改名,避免被误解为"租户实体") --- 租户自建角色(cid=2, tenant_id=租户ID, is_custom=1):租户端自行创建 --- --- 说明:本脚本幂等(按「id/旧名」匹配,重复执行不会重复改、不会误改)。 --- 运行:mysql --default-character-set=utf8mb4 -h<host> -P<port> -u<user> -p <db> < update_role_system.sql --- ============================================================= - --- 1) 平台端最高角色:管理员 → 总管理员;并修正 cid / tenant_id / is_custom -UPDATE `yz_system_admin_role` - SET `name` = '总管理员', `cid` = 1, `tenant_id` = 0, `is_custom` = 0, `update_time` = NOW() - WHERE `id` = 1 AND `name` = '管理员'; - --- 2) 「租户管理员」由租户专属改为全局共用(tenant_id=0): --- 迁移后所有租户共用同一条记录,由平台端统一维护; --- 若全局已存在同名角色则不重复迁移(避免出现两条全局「租户管理员」)。 -UPDATE `yz_system_admin_role` - SET `tenant_id` = 0, `is_custom` = 0, `update_time` = NOW() - WHERE `name` = '租户管理员' AND `tenant_id` > 0 - AND NOT EXISTS ( - SELECT 1 FROM ( - SELECT `id` FROM `yz_system_admin_role` - WHERE `name` = '租户管理员' AND `tenant_id` = 0 - ) t - ); - --- 3) 「租户用户」(租户内的普通用户):原「普通租户」更名为「租户用户」 --- 3.1 改名(若全局已存在「租户用户」则不重复改名) -UPDATE `yz_system_admin_role` - SET `name` = '租户用户', `update_time` = NOW() - WHERE `name` = '普通租户' AND `cid` = 2 - AND NOT EXISTS ( - SELECT 1 FROM ( - SELECT `id` FROM `yz_system_admin_role` - WHERE `name` = '租户用户' AND `cid` = 2 AND `tenant_id` = 0 - ) t - ); - --- 3.2 确保「租户用户」为全局角色(幂等保险,已是全局时不生效) -UPDATE `yz_system_admin_role` - SET `cid` = 2, `tenant_id` = 0, `is_custom` = 0, `update_time` = NOW() - WHERE `name` = '租户用户' AND `cid` = 2 - AND (`tenant_id` <> 0 OR `is_custom` <> 0); - --- 4) 平台端第二级角色「平台管理员」(幂等:已存在则跳过;默认未分配用户,按需在平台端用户管理中绑定) -INSERT INTO `yz_system_admin_role` (`tenant_id`, `cid`, `is_custom`, `name`, `status`, `create_time`, `update_time`) -SELECT 0, 1, 0, '平台管理员', 1, NOW(), NOW() - WHERE NOT EXISTS ( - SELECT 1 FROM (SELECT `id` FROM `yz_system_admin_role` WHERE `cid` = 1 AND `tenant_id` = 0 AND `name` = '平台管理员') t - ); - --- 5) 清理租户旧的预置角色「部门负责人」「员工」(仅删租户级预置角色,无用户绑定则删除无影响) --- 本环境已执行过;其它环境如仍在业务中使用请勿执行,租户需要时可在租户端自建自定义角色。 -DELETE FROM `yz_system_admin_role` - WHERE `name` IN ('部门负责人', '员工') AND `tenant_id` > 0 AND `is_custom` = 0; - --- 校验:整理后的角色清单 -SELECT id, cid, tenant_id, is_custom, name, status FROM yz_system_admin_role ORDER BY cid, tenant_id, id; diff --git a/go/models/init.go b/go/models/init.go index 6dc29c8..73c93c0 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -120,6 +120,16 @@ func Init(_ string) { new(CmsFrontendOnepage), new(BackendMenuFront), + // 平台官网管理(docs/sql/create_platform_website.sql) + new(PlatformWebsiteCategory), + new(PlatformWebsiteArticle), + new(PlatformWebsiteTag), + new(PlatformWebsiteArticleTag), + new(PlatformWebsiteComment), + new(PlatformWebsiteNav), + new(PlatformWebsiteLink), + new(PlatformWebsitePage), + new(SystemReminderList), new(SystemNormalSetting), diff --git a/go/models/platform_website.go b/go/models/platform_website.go new file mode 100644 index 0000000..2f338f7 --- /dev/null +++ b/go/models/platform_website.go @@ -0,0 +1,307 @@ +package models + +import ( + "time" +) + +// ============================================================= +// 平台官网管理模型(表前缀 yz_platform_website_) +// +// 对应 platform 端「官网管理」模块,仅服务平台主站官网, +// 与租户站点内容表(yz_cms_*)互相独立。 +// 建表 SQL:docs/sql/create_platform_website.sql +// ============================================================= + +// PlatformWebsiteCategory 平台官网-文章分类 yz_platform_website_category +type PlatformWebsiteCategory struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Cid uint64 `orm:"column(cid);default(0)" json:"cid"` // 父级分类ID,0 为顶级 + Name string `orm:"column(name);size(100);default('')" json:"name"` + Image string `orm:"column(image);size(500);default('')" json:"image"` + Desc string `orm:"column(desc);size(500);default('')" json:"desc"` + Sort int `orm:"column(sort);default(0)" json:"sort"` + Status int8 `orm:"column(status);default(1)" json:"status"` + 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 *PlatformWebsiteCategory) TableName() string { + return "yz_platform_website_category" +} + +// PlatformWebsiteArticle 平台官网-文章 yz_platform_website_article +type PlatformWebsiteArticle struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + CateID uint64 `orm:"column(cate_id);default(0)" json:"cate_id"` + Title string `orm:"column(title);size(255);default('')" json:"title"` + Author string `orm:"column(author);size(100);default('')" json:"author"` + Desc string `orm:"column(desc);size(500);default('')" json:"desc"` + Image string `orm:"column(image);size(500);default('')" json:"image"` + Content string `orm:"column(content);type(mediumtext);null" json:"content"` + IsTrans int8 `orm:"column(is_trans);default(0)" json:"is_trans"` + TransURL *string `orm:"column(transurl);size(500);null" json:"transurl"` + Status int8 `orm:"column(status);default(0)" json:"status"` // 0 草稿 1 待审核 2 已发布 3 已隐藏 + Top int8 `orm:"column(top);default(0)" json:"top"` + Recommend int8 `orm:"column(recommend);default(0)" json:"recommend"` + Views int `orm:"column(views);default(0)" json:"views"` + Likes int `orm:"column(likes);default(0)" json:"likes"` + 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 *PlatformWebsiteArticle) TableName() string { + return "yz_platform_website_article" +} + +// PlatformWebsiteTag 平台官网-标签 yz_platform_website_tag +type PlatformWebsiteTag struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Name string `orm:"column(name);size(50);default('')" json:"name"` + Sort int `orm:"column(sort);default(0)" json:"sort"` + Status int8 `orm:"column(status);default(1)" json:"status"` + 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 *PlatformWebsiteTag) TableName() string { + return "yz_platform_website_tag" +} + +// PlatformWebsiteArticleTag 平台官网-文章标签关联 yz_platform_website_article_tag +type PlatformWebsiteArticleTag struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + ArticleID uint64 `orm:"column(article_id);default(0)" json:"article_id"` + TagID uint64 `orm:"column(tag_id);default(0)" json:"tag_id"` + CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"` +} + +func (m *PlatformWebsiteArticleTag) TableName() string { + return "yz_platform_website_article_tag" +} + +// PlatformWebsiteComment 平台官网-评论 yz_platform_website_comment +type PlatformWebsiteComment struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + ArticleID uint64 `orm:"column(article_id);default(0)" json:"article_id"` + Nickname string `orm:"column(nickname);size(100);default('')" json:"nickname"` + Email string `orm:"column(email);size(150);default('')" json:"email"` + Content string `orm:"column(content);type(text);null" json:"content"` + Reply string `orm:"column(reply);type(text);null" json:"reply"` + Status int8 `orm:"column(status);default(0)" json:"status"` // 0 待审核 1 已通过 2 已拒绝 + IP string `orm:"column(ip);size(64);default('')" json:"ip"` + 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 *PlatformWebsiteComment) TableName() string { + return "yz_platform_website_comment" +} + +// PlatformWebsiteNav 平台官网-导航菜单 yz_platform_website_nav +type PlatformWebsiteNav struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Pid uint64 `orm:"column(pid);default(0)" json:"pid"` + Title string `orm:"column(title);size(100);default('')" json:"title"` + Type int8 `orm:"column(type);default(1)" json:"type"` // 1 目录 2 页面 3 外链 4 单页 + Path string `orm:"column(path);size(255);default('')" json:"path"` + ComponentPath string `orm:"column(component_path);size(255);default('')" json:"component_path"` + Icon string `orm:"column(icon);size(100);default('')" json:"icon"` + Sort int `orm:"column(sort);default(0)" json:"sort"` + Status int8 `orm:"column(status);default(1)" json:"status"` + IsVisible int8 `orm:"column(is_visible);default(1)" json:"is_visible"` + Permission string `orm:"column(permission);size(100);default('')" json:"permission"` + 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 *PlatformWebsiteNav) TableName() string { + return "yz_platform_website_nav" +} + +// PlatformWebsiteLink 平台官网-友情链接 yz_platform_website_link +type PlatformWebsiteLink struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + LinkName string `orm:"column(link_name);size(100);default('')" json:"link_name"` + LinkURL string `orm:"column(link_url);size(500);default('')" json:"link_url"` + LinkLogo string `orm:"column(link_logo);size(500);default('')" json:"link_logo"` + Description string `orm:"column(description);size(255);default('')" json:"description"` + Sort int `orm:"column(sort);default(0)" json:"sort"` + Status int8 `orm:"column(status);default(1)" json:"status"` + 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 *PlatformWebsiteLink) TableName() string { + return "yz_platform_website_link" +} + +// PlatformWebsitePage 平台官网-单页 yz_platform_website_page +type PlatformWebsitePage struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Title string `orm:"column(title);size(255);default('')" json:"title"` + Path string `orm:"column(path);size(200);default('')" json:"path"` + Content string `orm:"column(content);type(longtext);null" json:"content"` + Sort int `orm:"column(sort);default(0)" json:"sort"` + Status int8 `orm:"column(status);default(1)" json:"status"` + 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 *PlatformWebsitePage) TableName() string { + return "yz_platform_website_page" +} + +// ===================== 公共查询helper ===================== + +// PlatformWebsiteCategoryNameMap 分类ID -> 分类名称 +func PlatformWebsiteCategoryNameMap(ids []uint64) map[uint64]string { + out := make(map[uint64]string) + if len(ids) == 0 { + return out + } + var rows []PlatformWebsiteCategory + if _, err := Orm.QueryTable(new(PlatformWebsiteCategory)). + Filter("id__in", ids). + All(&rows, "ID", "Name"); err != nil { + return out + } + for _, r := range rows { + out[r.ID] = r.Name + } + return out +} + +// PlatformWebsiteCategoryMap 分类ID -> 分类记录 +// 官网前台用:展示分类名与描述,并在文章没有封面图时用分类图片兜底。 +func PlatformWebsiteCategoryMap(ids []uint64) map[uint64]PlatformWebsiteCategory { + out := make(map[uint64]PlatformWebsiteCategory) + if len(ids) == 0 { + return out + } + var rows []PlatformWebsiteCategory + if _, err := Orm.QueryTable(new(PlatformWebsiteCategory)). + Filter("id__in", ids). + All(&rows, "ID", "Name", "Desc", "Image"); err != nil { + return out + } + for _, r := range rows { + out[r.ID] = r + } + return out +} + +// PlatformWebsiteArticleTitleMap 文章ID -> 标题(评论列表展示所属文章用) +func PlatformWebsiteArticleTitleMap(ids []uint64) map[uint64]string { + out := make(map[uint64]string) + if len(ids) == 0 { + return out + } + var rows []PlatformWebsiteArticle + if _, err := Orm.QueryTable(new(PlatformWebsiteArticle)). + Filter("id__in", ids). + All(&rows, "ID", "Title"); err != nil { + return out + } + for _, r := range rows { + out[r.ID] = r.Title + } + return out +} + +// PlatformWebsiteArticleTagNameMap 文章ID -> 标签名称列表(官网前台展示用) +func PlatformWebsiteArticleTagNameMap(articleIDs []uint64) map[uint64][]string { + out := make(map[uint64][]string) + if len(articleIDs) == 0 { + return out + } + var links []PlatformWebsiteArticleTag + if _, err := Orm.QueryTable(new(PlatformWebsiteArticleTag)). + Filter("article_id__in", articleIDs). + All(&links, "ID", "ArticleID", "TagID"); err != nil { + return out + } + if len(links) == 0 { + return out + } + + tagIDs := make([]uint64, 0, len(links)) + for _, l := range links { + tagIDs = append(tagIDs, l.TagID) + } + var tags []PlatformWebsiteTag + if _, err := Orm.QueryTable(new(PlatformWebsiteTag)). + Filter("id__in", tagIDs). + Filter("delete_time__isnull", true). + All(&tags, "ID", "Name"); err != nil { + return out + } + nameMap := make(map[uint64]string, len(tags)) + for _, t := range tags { + nameMap[t.ID] = t.Name + } + + for _, l := range links { + if name, ok := nameMap[l.TagID]; ok && name != "" { + out[l.ArticleID] = append(out[l.ArticleID], name) + } + } + return out +} + +// PlatformWebsiteArticleTagIDs 取某文章绑定的标签ID列表 +func PlatformWebsiteArticleTagIDs(articleID uint64) []uint64 { + out := make([]uint64, 0) + if articleID == 0 { + return out + } + var rows []PlatformWebsiteArticleTag + if _, err := Orm.QueryTable(new(PlatformWebsiteArticleTag)). + Filter("article_id", articleID). + All(&rows, "ID", "TagID"); err != nil { + return out + } + for _, r := range rows { + out = append(out, r.TagID) + } + return out +} + +// SetPlatformWebsiteArticleTags 覆盖式写入文章标签(先清空再插入) +func SetPlatformWebsiteArticleTags(articleID uint64, tagIDs []uint64) error { + if articleID == 0 { + return nil + } + if _, err := Orm.QueryTable(new(PlatformWebsiteArticleTag)). + Filter("article_id", articleID). + Delete(); err != nil { + return err + } + seen := make(map[uint64]bool, len(tagIDs)) + for _, tid := range tagIDs { + if tid == 0 || seen[tid] { + continue + } + seen[tid] = true + if _, err := Orm.Insert(&PlatformWebsiteArticleTag{ArticleID: articleID, TagID: tid}); err != nil { + return err + } + } + return nil +} + +// ClearPlatformWebsiteArticleTags 删除文章时清理标签关联 +func ClearPlatformWebsiteArticleTags(articleID uint64) error { + _, err := Orm.QueryTable(new(PlatformWebsiteArticleTag)). + Filter("article_id", articleID). + Delete() + return err +} diff --git a/go/routers/index/index.go b/go/routers/index/index.go index a69518e..109bc9d 100644 --- a/go/routers/index/index.go +++ b/go/routers/index/index.go @@ -17,6 +17,17 @@ func Register() { // 官网前台站点设置(公开接口,供 SPA 官网读取,字段与模板标签 {yz:xxx} 同源) beego.Router("/site/setting", &controllers.TenantSiteController{}, "get:SiteSetting") + // 官网页头导航菜单(平台「官网管理 - 导航菜单」配置,仅返回启用且显示的项) + beego.Router("/site/nav", &controllers.IndexWebsiteController{}, "get:Nav") + + // SPA 官网内容接口(读取平台官网管理 yz_platform_website_*,仅返回已发布内容) + // 注意:静态段必须在 :id 之前注册,保证 /site/news/categories 不被当作详情 + beego.Router("/site/news", &controllers.IndexWebsiteController{}, "get:News") + beego.Router("/site/news/categories", &controllers.IndexWebsiteController{}, "get:NewsCategories") + beego.Router("/site/news/:id/related", &controllers.IndexWebsiteController{}, "get:NewsRelated") + beego.Router("/site/news/:id/view", &controllers.IndexWebsiteController{}, "post:NewsView") + beego.Router("/site/news/:id", &controllers.IndexWebsiteController{}, "get:NewsDetail") + 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 2705120..d91a087 100644 --- a/go/routers/platform/platform.go +++ b/go/routers/platform/platform.go @@ -348,4 +348,58 @@ func Register() { // 平台官网站点设置(yz_platform_site_setting) beego.Router("/platform/website/setting/info", &controllers.PlatformWebsiteSettingController{}, "get:Info") beego.Router("/platform/website/setting/save", &controllers.PlatformWebsiteSettingController{}, "post:Save") + + // ===== 平台官网管理(docs/sql/create_platform_website.sql)===== + // 文章管理 yz_platform_website_article + beego.Router("/platform/website/article/list", &controllers.PlatformWebsiteArticleController{}, "get:List") + beego.Router("/platform/website/article/all", &controllers.PlatformWebsiteArticleController{}, "get:ListAll") + beego.Router("/platform/website/article/recycle", &controllers.PlatformWebsiteArticleController{}, "get:Recycle") + beego.Router("/platform/website/article", &controllers.PlatformWebsiteArticleController{}, "post:Create") + beego.Router("/platform/website/article/:id/restore", &controllers.PlatformWebsiteArticleController{}, "post:Restore") + beego.Router("/platform/website/article/:id/purge", &controllers.PlatformWebsiteArticleController{}, "delete:Purge") + beego.Router("/platform/website/article/:id/publish", &controllers.PlatformWebsiteArticleController{}, "post:Publish") + beego.Router("/platform/website/article/:id/unpublish", &controllers.PlatformWebsiteArticleController{}, "post:UnPublish") + beego.Router("/platform/website/article/:id/recommend", &controllers.PlatformWebsiteArticleController{}, "post:Recommend") + beego.Router("/platform/website/article/:id/unrecommend", &controllers.PlatformWebsiteArticleController{}, "post:UnRecommend") + beego.Router("/platform/website/article/:id/top", &controllers.PlatformWebsiteArticleController{}, "post:Top") + beego.Router("/platform/website/article/:id/untop", &controllers.PlatformWebsiteArticleController{}, "post:UnTop") + beego.Router("/platform/website/article/:id", &controllers.PlatformWebsiteArticleController{}, "get:Get;post:Update;delete:Delete") + + // 分类管理 yz_platform_website_category + beego.Router("/platform/website/category/all", &controllers.PlatformWebsiteCategoryController{}, "get:All") + beego.Router("/platform/website/category/list", &controllers.PlatformWebsiteCategoryController{}, "get:List") + beego.Router("/platform/website/category", &controllers.PlatformWebsiteCategoryController{}, "post:Create") + beego.Router("/platform/website/category/:id/status", &controllers.PlatformWebsiteCategoryController{}, "patch:UpdateStatus") + beego.Router("/platform/website/category/:id", &controllers.PlatformWebsiteCategoryController{}, "get:Get;post:Update;delete:Delete") + + // 标签管理 yz_platform_website_tag + beego.Router("/platform/website/tag/all", &controllers.PlatformWebsiteTagController{}, "get:All") + beego.Router("/platform/website/tag/list", &controllers.PlatformWebsiteTagController{}, "get:List") + beego.Router("/platform/website/tag", &controllers.PlatformWebsiteTagController{}, "post:Create") + beego.Router("/platform/website/tag/:id/status", &controllers.PlatformWebsiteTagController{}, "patch:UpdateStatus") + beego.Router("/platform/website/tag/:id", &controllers.PlatformWebsiteTagController{}, "get:Get;post:Update;delete:Delete") + + // 评论管理 yz_platform_website_comment + beego.Router("/platform/website/comment/list", &controllers.PlatformWebsiteCommentController{}, "get:List") + beego.Router("/platform/website/comment/batchdelete", &controllers.PlatformWebsiteCommentController{}, "post:BatchDelete") + beego.Router("/platform/website/comment/:id/audit", &controllers.PlatformWebsiteCommentController{}, "post:Audit") + beego.Router("/platform/website/comment/:id/reply", &controllers.PlatformWebsiteCommentController{}, "post:Reply") + beego.Router("/platform/website/comment/:id", &controllers.PlatformWebsiteCommentController{}, "get:Get;delete:Delete") + + // 导航菜单 yz_platform_website_nav + beego.Router("/platform/website/nav/list", &controllers.PlatformWebsiteNavController{}, "get:List") + beego.Router("/platform/website/nav", &controllers.PlatformWebsiteNavController{}, "post:Create") + beego.Router("/platform/website/nav/:id", &controllers.PlatformWebsiteNavController{}, "post:Update;delete:Delete") + + // 友情链接 yz_platform_website_link + beego.Router("/platform/website/link/list", &controllers.PlatformWebsiteLinkController{}, "get:List") + beego.Router("/platform/website/link/all", &controllers.PlatformWebsiteLinkController{}, "get:All") + beego.Router("/platform/website/link/batchdelete", &controllers.PlatformWebsiteLinkController{}, "post:BatchDelete") + beego.Router("/platform/website/link", &controllers.PlatformWebsiteLinkController{}, "post:Create") + beego.Router("/platform/website/link/:id", &controllers.PlatformWebsiteLinkController{}, "put:Update;delete:Delete") + + // 单页管理 yz_platform_website_page + 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") } diff --git a/platform/src/api/article.js b/platform/src/api/article.js index c5f69b6..92291be 100644 --- a/platform/src/api/article.js +++ b/platform/src/api/article.js @@ -1,37 +1,42 @@ -// 文章管理相关API import request from "@/utils/request"; -// 获取文章列表 +// ==================== 官网管理 - 文章 / 分类(平台端) ==================== +// 数据表:yz_platform_website_article / yz_platform_website_category +// 建表 SQL:go/docs/sql/create_platform_website.sql + +// ---------- 文章 ---------- + +// 文章列表(分页):keyword / cate / status / startDate / endDate / page / pageSize export function listArticles(params) { return request({ - url: `/platform/articlesList`, + url: "/platform/website/article/list", method: "get", params, }); } -// 获取文章所有文章 +// 全部文章(不分页,供选择/关联使用) export function listAllArticles(params) { return request({ - url: `/platform/allarticles`, + url: "/platform/website/article/all", method: "get", params, }); } -// 获取文章详情 +// 文章详情(含标签ID列表 tags) export function getArticle(id) { return request({ - url: `/platform/articles/${id}`, + url: `/platform/website/article/${id}`, method: "get", }); } -// 创建文章 +// 新增文章 export function createArticle(data) { return request({ - url: '/platform/createarticle', - method: 'post', + url: "/platform/website/article", + method: "post", data, }); } @@ -39,8 +44,8 @@ export function createArticle(data) { // 编辑文章 export function editArticle(id, data) { return request({ - url: `/platform/editarticle/${id}`, - method: 'post', + url: `/platform/website/article/${id}`, + method: "post", data, }); } @@ -48,105 +53,124 @@ export function editArticle(id, data) { // 删除文章 export function deleteArticle(id) { return request({ - url: `/platform/deletearticle/${id}`, + url: `/platform/website/article/${id}`, method: "delete", }); } -// 发布文章 -export function publishArticle(id,uid) { +// 回收站列表(已软删除的文章):keyword / page / pageSize +export function recycleArticles(params) { return request({ - url: `/platform/publisharticle/${id}`, - method: 'post', - data: { - uid - } + url: "/platform/website/article/recycle", + method: "get", + params, }); } -// 下架文章 +// 从回收站恢复文章 +export function restoreArticle(id) { + return request({ + url: `/platform/website/article/${id}/restore`, + method: "post", + }); +} + +// 彻底删除文章(仅限回收站中的记录,不可恢复) +export function purgeArticle(id) { + return request({ + url: `/platform/website/article/${id}/purge`, + method: "delete", + }); +} + +// 发布文章(uid 选填,用于记录发布人) +export function publishArticle(id, uid) { + return request({ + url: `/platform/website/article/${id}/publish`, + method: "post", + data: { uid }, + }); +} + +// 下架文章(状态置为「已隐藏」) export function unPublishArticle(id) { return request({ - url: `/platform/unPublisharticle/${id}`, - method: 'post' + url: `/platform/website/article/${id}/unpublish`, + method: "post", }); } -// 文章推荐 +// 文章推荐 / 取消推荐 export function articleRecommend(id) { return request({ - url: `/platform/articleRecommend/${id}`, - method: 'post' + url: `/platform/website/article/${id}/recommend`, + method: "post", }); } -// 取消文章推荐 export function unArticleRecommend(id) { return request({ - url: `/platform/unArticleRecommend/${id}`, - method: 'post' + url: `/platform/website/article/${id}/unrecommend`, + method: "post", }); } -// 文章置顶 +// 文章置顶 / 取消置顶 export function articleTop(id) { return request({ - url: `/platform/articleTop/${id}`, - method: 'post' + url: `/platform/website/article/${id}/top`, + method: "post", }); } -// 取消文章置顶 export function unArticleTop(id) { return request({ - url: `/platform/unArticleTop/${id}`, - method: 'post' + url: `/platform/website/article/${id}/untop`, + method: "post", }); } +// ---------- 分类 ---------- - -////////////////////////////分类相关//////////////////////////// - -// 获取所有分类列表 +// 全量分类(前端自行组树,支持 keyword) export function allCategories(params) { return request({ - url: `/platform/allcategories`, + url: "/platform/website/category/all", method: "get", params, }); } -// 获取分类列表 +// 分类分页列表 export function listCategories(params) { return request({ - url: `/platform/categories`, + url: "/platform/website/category/list", method: "get", params, }); } -// 获取分类详情 +// 分类详情 export function getCategory(id) { return request({ - url: `/platform/categories/${id}`, + url: `/platform/website/category/${id}`, method: "get", }); } -// 创建分类 +// 新增分类 export function createCategory(data) { return request({ - url: `/platform/createCategory`, + url: "/platform/website/category", method: "post", data, }); } -// 更新分类 +// 编辑分类 export function editCategory(id, data) { return request({ - url: `/platform/editCategory/${id}`, + url: `/platform/website/category/${id}`, method: "post", data, }); @@ -155,7 +179,7 @@ export function editCategory(id, data) { // 删除分类 export function deleteCategory(id) { return request({ - url: `/platform/categories/${id}`, + url: `/platform/website/category/${id}`, method: "delete", }); } @@ -163,8 +187,8 @@ export function deleteCategory(id) { // 更新分类状态 export function updateCategoryStatus(id, status) { return request({ - url: `/platform/categories/${id}/status`, + url: `/platform/website/category/${id}/status`, method: "patch", data: { status }, }); -} \ No newline at end of file +} diff --git a/platform/src/api/comment.js b/platform/src/api/comment.js new file mode 100644 index 0000000..d34f3f4 --- /dev/null +++ b/platform/src/api/comment.js @@ -0,0 +1,57 @@ +import request from "@/utils/request"; + +// ==================== 官网管理 - 评论管理(平台端) ==================== +// 数据表:yz_platform_website_comment +// status:0 待审核,1 已通过,2 已拒绝 + +// 评论列表(分页):keyword / status / article_id / page / pageSize +export function listComments(params) { + return request({ + url: "/platform/website/comment/list", + method: "get", + params, + }); +} + +// 评论详情 +export function getComment(id) { + return request({ + url: `/platform/website/comment/${id}`, + method: "get", + }); +} + +// 审核评论(status:1 通过 / 2 拒绝) +export function auditComment(id, status) { + return request({ + url: `/platform/website/comment/${id}/audit`, + method: "post", + data: { status }, + }); +} + +// 回复评论 +export function replyComment(id, reply) { + return request({ + url: `/platform/website/comment/${id}/reply`, + method: "post", + data: { reply }, + }); +} + +// 删除评论 +export function deleteComment(id) { + return request({ + url: `/platform/website/comment/${id}`, + method: "delete", + }); +} + +// 批量删除评论 +export function batchDeleteComments(ids) { + return request({ + url: "/platform/website/comment/batchdelete", + method: "post", + data: { ids }, + }); +} diff --git a/platform/src/api/friendlink.js b/platform/src/api/friendlink.js index 653be4b..14a2084 100644 --- a/platform/src/api/friendlink.js +++ b/platform/src/api/friendlink.js @@ -1,77 +1,81 @@ -import request from '@/utils/request' +import request from "@/utils/request"; + +// ==================== 官网管理 - 友情链接(平台端) ==================== +// 数据表:yz_platform_website_link +// 建表 SQL:go/docs/sql/create_platform_website.sql /** - * 获取友情链接列表 - * @param {Object} params - 查询参数 + * 获取友情链接列表(分页) + * @param {Object} params page / pageSize / keyword / status * @returns {Promise} */ export function getFriendlinkList(params) { return request({ - url: '/platform/friendlinks', - method: 'get', - params - }) + url: "/platform/website/link/list", + method: "get", + params, + }); } /** - * 获取所有友情链接(下拉选择用) + * 获取所有启用中的友情链接 * @returns {Promise} */ export function getAllFriendlinks() { return request({ - url: '/platform/friendlinks/all', - method: 'get' - }) + url: "/platform/website/link/all", + method: "get", + }); } /** * 添加友情链接 - * @param {Object} data - 链接数据 + * @param {Object} data 链接数据 * @returns {Promise} */ export function addFriendlink(data) { return request({ - url: '/platform/friendlinks', - method: 'post', - data - }) + url: "/platform/website/link", + method: "post", + data, + }); } /** - * 更新友情链接 - * @param {number} id - 链接ID - * @param {Object} data - 链接数据 + * 更新友情链接(支持只传部分字段,如 {status:1}) + * @param {number} id 链接ID + * @param {Object} data 链接数据 * @returns {Promise} */ export function updateFriendlink(id, data) { return request({ - url: `/platform/friendlinks/${id}`, - method: 'put', - data - }) + url: `/platform/website/link/${id}`, + method: "put", + data, + }); } /** * 删除友情链接 - * @param {number} id - 链接ID + * @param {number} id 链接ID * @returns {Promise} */ export function deleteFriendlink(id) { return request({ - url: `/platform/friendlinks/${id}`, - method: 'delete' - }) + url: `/platform/website/link/${id}`, + method: "delete", + }); } /** * 批量删除友情链接 - * @param {Array} ids - 链接ID数组 + * @param {Array} ids 链接ID数组 * @returns {Promise} */ export function batchDeleteFriendlinks(ids) { return request({ - url: '/platform/friendlinks/batchdelete', - method: 'post', - data: { ids } - }) + url: "/platform/website/link/batchdelete", + method: "post", + data: { ids }, + }); } diff --git a/platform/src/api/frontMenu.js b/platform/src/api/frontMenu.js index 753988f..46969ce 100644 --- a/platform/src/api/frontMenu.js +++ b/platform/src/api/frontMenu.js @@ -1,55 +1,55 @@ import request from "@/utils/request"; +// ==================== 官网管理 - 导航菜单(平台端) ==================== +// 数据表:yz_platform_website_nav +// 建表 SQL:go/docs/sql/create_platform_website.sql + /** - * 获取所有前端导航 + * 获取全部导航菜单(平铺返回,前端按 pid 组树) * @returns {Promise} */ export function getFrontMenus() { return request({ - url: "/platform/frontmenus", + url: "/platform/website/nav/list", method: "get", }); } /** - * 创建前端导航 - * @param {Object} frontMenuData 前端导航数据 + * 创建导航菜单 + * @param {Object} data 菜单数据 * @returns {Promise} */ -export function createFrontMenu(formData, options = {}) { +export function createFrontMenu(data) { return request({ - url: "/platform/createfrontmenu", + url: "/platform/website/nav", method: "post", - data: formData, - headers: { - "Content-Type": "multipart/form-data" - } + data, }); } /** - * 编辑前端导航 - * @param {number|string} id 前端导航ID - * @param {Object} frontMenuData 更新的数据 + * 编辑导航菜单 + * @param {number|string} id 菜单ID + * @param {Object} data 更新的数据 * @returns {Promise} */ -export function editFrontMenu(id, frontMenuData) { +export function editFrontMenu(id, data) { return request({ - url: `/platform/editfrontmenu/${id}`, + url: `/platform/website/nav/${id}`, method: "post", - data: frontMenuData, + data, }); } /** - * 删除前端导航 - * @param {number|string} id 前端导航ID + * 删除导航菜单 + * @param {number|string} id 菜单ID * @returns {Promise} */ export function deleteFrontMenu(id) { return request({ - url: `/platform/deletefrontmenu/${id}`, + url: `/platform/website/nav/${id}`, method: "delete", }); } - diff --git a/platform/src/api/onepage.js b/platform/src/api/onepage.js index 4a1b0c1..a53afe8 100644 --- a/platform/src/api/onepage.js +++ b/platform/src/api/onepage.js @@ -1,40 +1,56 @@ import request from "@/utils/request"; +// ==================== 官网管理 - 单页管理(平台端) ==================== +// 数据表:yz_platform_website_page +// 建表 SQL:go/docs/sql/create_platform_website.sql + /** - * 获取所有单页 + * 获取全部单页 * @returns {Promise} */ export function getOnePages() { return request({ - url: "/platform/allonepages", + url: "/platform/website/page/all", + method: "get", + }); +} + +/** + * 单页详情 + * @param {number|string} id 单页ID + * @returns {Promise} + */ +export function getOnePage(id) { + return request({ + url: `/platform/website/page/${id}`, method: "get", }); } /** * 创建单页 - * @param {Object} onePageData 单页数据 + * @param {Object} data 单页数据 * @returns {Promise} */ -export function createOnePage(formData) { +export function createOnePage(data) { return request({ - url: "/platform/createonepage", + url: "/platform/website/page", method: "post", - data: formData, + data, }); } /** * 编辑单页 * @param {number|string} id 单页ID - * @param {Object} onePageData 更新的数据 + * @param {Object} data 更新的数据 * @returns {Promise} */ -export function editOnePage(id, onePageData) { +export function editOnePage(id, data) { return request({ - url: `/platform/editonepage/${id}`, + url: `/platform/website/page/${id}`, method: "post", - data: onePageData, + data, }); } @@ -45,13 +61,13 @@ export function editOnePage(id, onePageData) { */ export function deleteOnePage(id) { return request({ - url: `/platform/deleteonepage/${id}`, + url: `/platform/website/page/${id}`, method: "delete", }); } /** - * 根据路径获取单页(前端使用) + * 租户官网前台:根据路径获取单页(公开接口,非平台管理) * @param {string} path 路由路径 * @returns {Promise} */ @@ -61,4 +77,3 @@ export function getOnePageByPath(path) { method: "get", }); } - diff --git a/platform/src/api/tag.js b/platform/src/api/tag.js new file mode 100644 index 0000000..c69df7d --- /dev/null +++ b/platform/src/api/tag.js @@ -0,0 +1,66 @@ +import request from "@/utils/request"; + +// ==================== 官网管理 - 标签管理(平台端) ==================== +// 数据表:yz_platform_website_tag(+ 关联表 yz_platform_website_article_tag) +// 建表 SQL:go/docs/sql/create_platform_website.sql + +// 标签列表(分页):keyword / status / page / pageSize +export function listTags(params) { + return request({ + url: "/platform/website/tag/list", + method: "get", + params, + }); +} + +// 全部标签(不分页,供文章编辑选择) +export function allTags(params) { + return request({ + url: "/platform/website/tag/all", + method: "get", + params, + }); +} + +// 标签详情 +export function getTag(id) { + return request({ + url: `/platform/website/tag/${id}`, + method: "get", + }); +} + +// 新增标签 +export function createTag(data) { + return request({ + url: "/platform/website/tag", + method: "post", + data, + }); +} + +// 编辑标签 +export function editTag(id, data) { + return request({ + url: `/platform/website/tag/${id}`, + method: "post", + data, + }); +} + +// 删除标签 +export function deleteTag(id) { + return request({ + url: `/platform/website/tag/${id}`, + method: "delete", + }); +} + +// 更新标签状态(1 启用 / 0 禁用) +export function updateTagStatus(id, status) { + return request({ + url: `/platform/website/tag/${id}/status`, + method: "patch", + data: { status }, + }); +} diff --git a/platform/src/router/index.js b/platform/src/router/index.js index f3e4933..f29643f 100644 --- a/platform/src/router/index.js +++ b/platform/src/router/index.js @@ -30,7 +30,7 @@ const staticMainChildren = [ { path: "/website/setting", name: "PlatformWebsiteSetting", - component: () => import("@/views/website/setting/index.vue"), + component: () => import("@/views/website/site/setting/index.vue"), meta: { requiresAuth: true, title: "站点设置" } }, { diff --git a/platform/src/utils/websiteUpload.js b/platform/src/utils/websiteUpload.js new file mode 100644 index 0000000..a16504f --- /dev/null +++ b/platform/src/utils/websiteUpload.js @@ -0,0 +1,61 @@ +/** + * 官网管理上传辅助(platform/src/views/website 专用) + * + * 官网管理各页面(文章封面 / 分类图片 / Logo / 友情链接 Logo / 富文本正文图片) + * 上传的文件统一归入「文件管理」的「官网素材」分组,便于在 + * /system/fileManager 中集中查找与复用。 + * + * 分组按名称查找,不存在时补建 —— 与 platform/softwareupgrade 的 + * appsupgrade 分组使用同一套做法;该默认分组同时由 + * go/docs/sql/init_platform_website_file_cate.sql 预置。 + */ +import { getUserCate, createFileCate, uploadFile } from "@/api/file"; + +export const WEBSITE_MATERIAL_CATE_NAME = "官网素材"; + +// 分组ID缓存(同一会话内只解析一次,并发调用复用同一个 Promise) +let cateIdPromise = null; + +/** + * 取「官网素材」分组ID。 + * 查询/创建任一环节失败时返回 0(未分类),不阻断上传本身。 + * @returns {Promise<number>} + */ +export function ensureWebsiteMaterialCate() { + if (!cateIdPromise) { + cateIdPromise = (async () => { + try { + const res = await getUserCate(); + if (res?.code === 200 && Array.isArray(res.data)) { + const found = res.data.find( + (item) => String(item.name || "").trim() === WEBSITE_MATERIAL_CATE_NAME + ); + if (found?.id) { + return Number(found.id); + } + } + + const created = await createFileCate({ name: WEBSITE_MATERIAL_CATE_NAME }); + if (created?.code === 200 && created.data?.id != null) { + return Number(created.data.id); + } + } catch (e) { + console.error("解析「官网素材」文件分组失败:", e); + } + return 0; + })(); + } + return cateIdPromise; +} + +/** + * 上传文件到「官网素材」分组 + * @param {File} file 待上传文件 + * @returns {Promise} 与 uploadFile 返回值一致(code 200 新上传 / 201 已存在) + */ +export async function uploadToWebsiteMaterial(file) { + const cate = await ensureWebsiteMaterialCate(); + const formData = new FormData(); + formData.append("file", file); + return uploadFile(formData, { cate }); +} diff --git a/platform/src/views/components/UmoEditor.vue b/platform/src/views/components/UmoEditor.vue index 530470e..4ac850b 100644 --- a/platform/src/views/components/UmoEditor.vue +++ b/platform/src/views/components/UmoEditor.vue @@ -18,6 +18,12 @@ const props = defineProps({ type: String, default: '', }, + // 上传文件归属的文件管理分组ID(0 表示未分类)。 + // 官网文章编辑传入「官网素材」分组ID,正文内插入的图片即归入该分组。 + cate: { + type: [Number, String], + default: 0, + }, }) const emit = defineEmits(['update:modelValue']) @@ -35,7 +41,7 @@ const options = { const onFileUpload = async (file) => { const formData = new FormData() formData.append('file', file) - const res = await uploadFile(formData) + const res = await uploadFile(formData, { cate: props.cate }) return { id: res.data.id, url: res.data.url, diff --git a/platform/src/views/system/fileManager/index.vue b/platform/src/views/system/fileManager/index.vue index 6feb592..d5a7ffa 100644 --- a/platform/src/views/system/fileManager/index.vue +++ b/platform/src/views/system/fileManager/index.vue @@ -50,10 +50,19 @@ </div> <div class="group-actions" v-if="group.id !== 0"> <div class="action-buttons" @click.stop> - <el-button type="primary" link size="small" :icon="Edit" @click="handleRenameCategory(group)" - title="重命名" /> - <el-button type="danger" link size="small" :icon="Delete" @click="deleteFileCateData(group.id)" - title="删除" /> + <!-- 系统默认分组(如「官网素材」)由功能模块按名称定位,禁止改名/删除 --> + <template v-if="isProtectedCategory(group)"> + <el-button type="primary" link size="small" :icon="Lock" disabled + title="系统默认分组,不可重命名" /> + <el-button type="danger" link size="small" :icon="Lock" disabled + title="系统默认分组,不可删除" /> + </template> + <template v-else> + <el-button type="primary" link size="small" :icon="Edit" @click="handleRenameCategory(group)" + title="重命名" /> + <el-button type="danger" link size="small" :icon="Delete" @click="deleteFileCateData(group.id)" + title="删除" /> + </template> </div> </div> </div> @@ -302,6 +311,7 @@ import { ZoomOut, RefreshLeft, FullScreen, + Lock, } from "@element-plus/icons-vue"; import type { FormInstance, FormRules } from "element-plus"; import { @@ -426,6 +436,21 @@ const uncategorizedGroup = ref<Group>({ description: "未分类的文件", }); +// 系统默认分组:由功能模块按「名称」定位(如官网管理统一上传到「官网素材」、 +// 软件升级安装包归入 appsupgrade),改名或删除都会让模块找不到分组,故禁止操作。 +// 需与后端 controllers/platform_file.go 的 protectedFileCateNames 保持一致。 +const PROTECTED_CATEGORY_NAMES = ["官网素材", "appsupgrade", "图片"]; + +/** 是否为受保护的系统默认分组 */ +const isProtectedCategory = (group: any) => + PROTECTED_CATEGORY_NAMES.includes(String(group?.name || "").trim()); + +/** 按分组ID判断是否受保护(删除入口的兜底校验) */ +const isProtectedCategoryId = (id: number) => { + const target = groups.value.find((item) => item.id === id); + return !!target && isProtectedCategory(target); +}; + // 过滤后的分组列表(未分类组置顶) const filteredGroups = computed(() => { let result = [uncategorizedGroup.value, ...groups.value]; @@ -484,6 +509,10 @@ const handleRenameCategory = (group: any) => { ElMessage.error("分组信息不完整"); return; } + if (isProtectedCategory(group)) { + ElMessage.warning("系统默认分组不可重命名"); + return; + } currentRenameCategoryId.value = group.id; currentRenameCategoryName.value = group.name || ""; showRenameCategoryDialog.value = true; @@ -519,6 +548,10 @@ const handleRenameCategoryClose = () => { //删除文件分组 const deleteFileCateData = async (id: number) => { + if (isProtectedCategoryId(id)) { + ElMessage.warning("系统默认分组不可删除"); + return; + } ElMessageBox.confirm("确定删除该文件分组吗?", "提示", { confirmButtonText: "确定", cancelButtonText: "取消", diff --git a/platform/src/views/website/content/article/components/ArticleForm.vue b/platform/src/views/website/content/article/components/ArticleForm.vue new file mode 100644 index 0000000..869543b --- /dev/null +++ b/platform/src/views/website/content/article/components/ArticleForm.vue @@ -0,0 +1,589 @@ +<template> + <div class="article-form" v-loading="loading"> + <el-form + ref="formRef" + :model="form" + :rules="rules" + label-position="top" + class="article-form__form" + > + <div class="form-layout"> + <!-- ==================== 左侧:基础信息 ==================== --> + <div class="form-side"> + <el-form-item label="标题" prop="title"> + <el-input + v-model="form.title" + placeholder="请输入文章标题" + maxlength="200" + show-word-limit + /> + </el-form-item> + + <el-form-item label="所属分类" prop="cate"> + <div class="cate-panel"> + <div class="cate-panel__head"> + <el-input + v-model="cateFilter" + size="small" + placeholder="筛选分类" + clearable + :prefix-icon="Search" + /> + </div> + + <div class="cate-panel__tree"> + <el-tree + ref="cateTreeRef" + :data="categoryTree" + :props="{ label: 'name', children: 'children' }" + node-key="id" + highlight-current + :expand-on-click-node="false" + default-expand-all + :filter-node-method="filterCateNode" + empty-text="暂无分类,请先到「分类管理」创建" + @node-click="handleCateSelect" + /> + </div> + + <div class="cate-panel__foot"> + <span class="cate-panel__current"> + 已选:{{ selectedCateName || '未选择' }} + </span> + <el-button + v-if="form.cate" + link + type="primary" + size="small" + @click="handleCateClear" + > + 清除 + </el-button> + </div> + </div> + </el-form-item> + + <el-row :gutter="12"> + <el-col :span="14"> + <el-form-item label="作者" prop="author"> + <el-input v-model="form.author" placeholder="请输入作者" /> + </el-form-item> + </el-col> + <el-col :span="10"> + <el-form-item label="是否转载" prop="is_trans"> + <el-radio-group v-model="form.is_trans"> + <el-radio-button :value="0">否</el-radio-button> + <el-radio-button :value="1">是</el-radio-button> + </el-radio-group> + </el-form-item> + </el-col> + </el-row> + + <el-form-item v-if="form.is_trans === 1" label="原文地址" prop="transurl"> + <el-input v-model="form.transurl" placeholder="请输入转载原文地址" /> + </el-form-item> + + <el-form-item label="标签" prop="tags"> + <el-select + v-model="form.tags" + multiple + filterable + clearable + collapse-tags + collapse-tags-tooltip + placeholder="选择标签(可多选)" + style="width: 100%" + > + <el-option + v-for="item in tagOptions" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + + <el-form-item v-if="isEdit" 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> + <div class="field-tip">选择「已发布」后会在官网前台展示</div> + </el-form-item> + + <el-form-item label="简介" prop="desc"> + <el-input + v-model="form.desc" + type="textarea" + :rows="3" + maxlength="200" + show-word-limit + placeholder="用于列表页摘要展示" + /> + </el-form-item> + + <el-form-item label="封面图" prop="image"> + <el-upload + class="cover-uploader" + :show-file-list="false" + :auto-upload="false" + accept="image/*" + :on-change="handleCoverChange" + > + <img v-if="form.image" :src="previewUrl(form.image)" class="cover-image" /> + <div v-else class="cover-placeholder"> + <el-icon><Plus /></el-icon> + <span>上传封面</span> + </div> + </el-upload> + <div class="field-tip">建议尺寸 750 × 420,支持 jpg / png,单张不超过 10MB</div> + </el-form-item> + </div> + + <!-- ==================== 右侧:正文编辑 ==================== --> + <div class="form-main"> + <el-form-item label="内容" prop="content" class="form-main__item"> + <div class="editor-container"> + <UmoEditor v-model="form.content" :cate="materialCateId" /> + </div> + </el-form-item> + </div> + </div> + </el-form> + + <div class="form-footer"> + <el-button @click="emit('cancel')">取消</el-button> + <el-button v-if="!isEdit" :loading="submitLoading" @click="handleSubmit(0)"> + 存为草稿 + </el-button> + <el-button + type="primary" + :loading="submitLoading" + @click="handleSubmit(isEdit ? form.status : 1)" + > + {{ isEdit ? '保存' : '提交' }} + </el-button> + </div> + </div> +</template> + +<script setup> +import { ref, reactive, computed, watch, nextTick, onMounted } from 'vue' +import { ElMessage } from 'element-plus' +import { Plus, Search } from '@element-plus/icons-vue' +import { createArticle, editArticle, getArticle, allCategories } from '@/api/article' +import { allTags } from '@/api/tag' +import { uploadToWebsiteMaterial, ensureWebsiteMaterialCate } from '@/utils/websiteUpload' + +const props = defineProps({ + isEdit: { type: Boolean, default: false }, + articleId: { type: Number, default: 0 } +}) + +const emit = defineEmits(['saved', 'cancel']) + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '' + +const loading = ref(false) +const submitLoading = ref(false) +const formRef = ref(null) +const coverFile = ref(null) +const categoryOptions = ref([]) +const tagOptions = ref([]) +// 「官网素材」文件分组ID:富文本正文内插入的图片也归入该分组 +const materialCateId = ref(0) + +// 分类树相关 +const cateTreeRef = ref(null) +const cateFilter = ref('') +const selectedCateName = ref('') + +const form = reactive({ + title: '', + author: '', + cate: '', + tags: [], + content: '', + desc: '', + image: '', + is_trans: 0, + transurl: null, + status: 0 +}) + +const rules = { + title: [{ required: true, message: '请输入文章标题', trigger: 'blur' }], + content: [{ required: true, message: '请输入文章内容', trigger: 'blur' }] +} + +const previewUrl = (url) => { + if (!url) return '' + if (/^https?:\/\//i.test(url)) return url + return API_BASE_URL + url +} + +// 平铺分类 -> 树(兼容 cid 父子字段) +const categoryTree = computed(() => { + const nodes = categoryOptions.value.map((item) => ({ ...item, children: [] })) + const map = new Map(nodes.map((n) => [n.id, n])) + const roots = [] + nodes.forEach((node) => { + const parent = map.get(node.cid) + if (parent && parent.id !== node.id) { + parent.children.push(node) + } else { + roots.push(node) + } + }) + // 无子节点时移除空 children,避免出现空展开箭头 + const prune = (arr) => + arr.forEach((n) => { + if (n.children.length === 0) { + delete n.children + } else { + prune(n.children) + } + }) + prune(roots) + return roots +}) + +watch(cateFilter, (val) => { + cateTreeRef.value?.filter(val) +}) + +const filterCateNode = (value, data) => { + if (!value) return true + return String(data.name || '') + .toLowerCase() + .includes(String(value).toLowerCase()) +} + +const handleCateSelect = (data) => { + form.cate = data.id + selectedCateName.value = data.name + formRef.value?.clearValidate('cate') +} + +const handleCateClear = () => { + form.cate = '' + selectedCateName.value = '' + cateTreeRef.value?.setCurrentKey(null) +} + +// 分类数据/文章详情到达后,把树上的选中态同步过来 +const syncCateSelection = async () => { + await nextTick() + if (form.cate) { + const matched = categoryOptions.value.find((c) => c.id === form.cate) + selectedCateName.value = matched?.name || '' + cateTreeRef.value?.setCurrentKey(form.cate) + } else { + selectedCateName.value = '' + cateTreeRef.value?.setCurrentKey(null) + } +} + +// 编辑模式:拉取详情回显 +const loadDetail = async (id) => { + if (!id) { + ElMessage.error('缺少文章ID,无法编辑') + return + } + loading.value = true + try { + const res = await getArticle(id) + if (res.code === 200 && res.data) { + const m = res.data + Object.assign(form, { + title: m.title || '', + author: m.author || '', + cate: m.cate_id ?? m.cate ?? '', + tags: Array.isArray(m.tags) ? [...m.tags] : [], + content: m.content || '', + desc: m.desc || '', + image: m.image || '', + is_trans: m.is_trans || 0, + transurl: m.transurl || null, + status: m.status ?? 0 + }) + await syncCateSelection() + } else { + ElMessage.error(res.msg || '获取文章详情失败') + } + } catch (e) { + ElMessage.error('获取文章详情失败') + } finally { + loading.value = false + } +} + +const fetchOptions = async () => { + try { + const res = await allCategories() + if (res.code === 200) { + const data = res.data + categoryOptions.value = Array.isArray(data) ? data : data?.list || [] + await syncCateSelection() + } + } catch (e) { + // 分类加载失败不阻断表单 + } + try { + const res = await allTags() + if (res.code === 200) { + const list = Array.isArray(res.data) ? res.data : res.data?.list || [] + tagOptions.value = list.filter((item) => item.status === 1) + } + } catch (e) { + // 标签加载失败不阻断表单 + } +} + +const handleCoverChange = (file) => { + const raw = file.raw + if (!raw) return + if (!raw.type.startsWith('image/')) { + ElMessage.error('封面仅支持图片格式') + return + } + if (raw.size / 1024 / 1024 >= 10) { + ElMessage.error('封面图片不能超过 10MB') + return + } + coverFile.value = raw + form.image = URL.createObjectURL(raw) +} + +const handleSubmit = async (status) => { + if (!formRef.value) return + await formRef.value.validate(async (valid) => { + if (!valid) return + submitLoading.value = true + try { + let imageUrl = form.image + // 本地预览地址(blob)说明用户新选了文件,先上传 + if (coverFile.value) { + const uploadRes = await uploadToWebsiteMaterial(coverFile.value) + if ((uploadRes.code === 200 || uploadRes.code === 201) && uploadRes.data?.url) { + imageUrl = uploadRes.data.url.replace(/\\/g, '/') + } else { + ElMessage.error(uploadRes.msg || '封面上传失败') + return + } + } + + const payload = { + title: form.title, + author: form.author, + cate: form.cate, + tags: form.tags, + content: form.content, + desc: form.desc, + image: imageUrl, + is_trans: form.is_trans, + transurl: form.is_trans === 1 ? form.transurl : null, + status + } + + const res = props.isEdit + ? await editArticle(props.articleId, payload) + : await createArticle(payload) + + if (res.code === 200) { + ElMessage.success( + !props.isEdit && status === 0 ? '已存为草稿' : props.isEdit ? '保存成功' : '发布成功' + ) + emit('saved') + } else { + ElMessage.error(res.msg || '保存失败') + } + } catch (e) { + ElMessage.error('保存失败') + } finally { + submitLoading.value = false + } + }) +} + +watch( + () => props.articleId, + (id) => { + if (props.isEdit) loadDetail(id) + }, + { immediate: true } +) + +onMounted(() => { + fetchOptions() + // 提前解析「官网素材」分组,供正文内插入图片时使用 + ensureWebsiteMaterialCate().then((id) => { + materialCateId.value = id + }) +}) +</script> + +<style lang="less" scoped> +.article-form { + &__form { + max-width: 1600px; + } +} + +/* ---------- 左右两栏 ---------- */ +.form-layout { + display: flex; + align-items: flex-start; + gap: 20px; +} + +.form-side { + flex: 0 0 400px; + min-width: 0; +} + +.form-main { + flex: 1; + min-width: 0; + + &__item { + margin-bottom: 0; + } +} + +/* ---------- 分类树 ---------- */ +.cate-panel { + width: 100%; + border: 1px solid var(--el-border-color); + border-radius: 4px; + overflow: hidden; + + &__head { + padding: 6px; + border-bottom: 1px solid var(--el-border-color-lighter); + background: var(--el-fill-color-light); + } + + &__tree { + max-height: 260px; + overflow: auto; + padding: 4px 0; + + :deep(.el-tree) { + background: transparent; + } + + :deep(.el-tree-node__content) { + height: 32px; + } + } + + &__foot { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + border-top: 1px solid var(--el-border-color-lighter); + background: var(--el-fill-color-light); + } + + &__current { + font-size: 12.5px; + color: var(--el-text-color-secondary); + } +} + +/* ---------- 正文编辑器 ---------- */ +.editor-container { + width: 100%; + height: calc(100vh - 300px); + min-height: 480px; + border: 1px solid var(--el-border-color); + border-radius: 4px; + overflow: hidden; +} + +/* ---------- 封面 ---------- */ +.cover-uploader { + :deep(.el-upload) { + border: 1px dashed var(--el-border-color); + border-radius: 6px; + cursor: pointer; + overflow: hidden; + transition: border-color 0.3s; + + &:hover { + border-color: var(--el-color-primary); + } + } +} + +.cover-image { + width: 180px; + height: 105px; + display: block; + object-fit: cover; +} + +.cover-placeholder { + width: 180px; + height: 105px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + color: var(--el-text-color-secondary); + font-size: 12px; + + .el-icon { + font-size: 22px; + } +} + +.field-tip { + margin-top: 6px; + font-size: 12px; + line-height: 1.6; + color: var(--el-text-color-secondary); +} + +/* ---------- 底部操作栏:吸附在内容区底部 ---------- + 滚动容器是 Main.vue 的 .right-main(el-main,overflow-y: auto), + 用 sticky 而不是 fixed:侧边栏折叠/窗口缩放时无需重算左右偏移。 */ +.form-footer { + position: sticky; + bottom: 0; + z-index: 2000; + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 12px; + padding: 12px 16px; + background: var(--el-bg-color); + border: 1px solid var(--el-border-color-lighter); + border-radius: 6px; + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.08); +} + +/* ---------- 窄屏改为上下排列 ---------- */ +@media (max-width: 1200px) { + .form-layout { + flex-direction: column; + } + + .form-side { + flex: none; + width: 100%; + } + + .form-main { + width: 100%; + } + + .editor-container { + height: 560px; + } +} +</style> diff --git a/platform/src/views/website/content/article/components/ArticleList.vue b/platform/src/views/website/content/article/components/ArticleList.vue new file mode 100644 index 0000000..08f81f3 --- /dev/null +++ b/platform/src/views/website/content/article/components/ArticleList.vue @@ -0,0 +1,621 @@ +<template> + <div class="article-list"> + <div class="header-bar"> + <h2>{{ pageTitle }}</h2> + <div class="header-actions"> + <el-button v-if="mode !== 'recycle'" type="primary" @click="goCreate"> + <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>{{ pageTips }}</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-if="mode === 'all'" + v-model="searchForm.cate" + placeholder="全部分类" + clearable + style="width: 160px" + @change="handleSearch" + > + <el-option + v-for="item in categoryOptions" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + + <el-select + v-if="mode === 'all'" + v-model="searchForm.status" + placeholder="全部状态" + clearable + style="width: 140px" + @change="handleSearch" + > + <el-option + v-for="(item, value) in statusMap" + :key="value" + :label="item.label" + :value="Number(value)" + /> + </el-select> + + <el-date-picker + v-if="mode !== 'recycle'" + v-model="searchForm.dateRange" + type="daterange" + value-format="YYYY-MM-DD" + range-separator="至" + start-placeholder="开始日期" + end-placeholder="结束日期" + style="width: 260px" + @change="handleSearch" + /> + + <el-button type="primary" @click="handleSearch"> + <el-icon><Search /></el-icon> + 搜索 + </el-button> + <el-button @click="resetSearch">重置</el-button> + </div> + + <!-- 列表 --> + <el-table :data="list" v-loading="loading" border stripe style="width: 100%"> + <el-table-column prop="title" label="标题" min-width="300" show-overflow-tooltip> + <template #default="{ row }"> + <div class="title-cell"> + <el-tag v-if="row.top === 1" type="danger" size="small" effect="dark">置顶</el-tag> + <el-tag v-if="row.recommend === 1" type="warning" size="small" effect="dark">推荐</el-tag> + <el-link + v-if="mode !== 'recycle'" + type="primary" + :underline="false" + @click="handleView(row)" + > + {{ row.title }} + </el-link> + <span v-else>{{ row.title }}</span> + </div> + </template> + </el-table-column> + + <el-table-column prop="cate" label="分类" width="130" show-overflow-tooltip /> + <el-table-column prop="author" label="作者" width="110" show-overflow-tooltip /> + + <el-table-column v-if="mode !== 'recycle'" label="状态" width="100" align="center"> + <template #default="{ row }"> + <el-tag :type="statusMap[row.status]?.type || 'info'" size="small"> + {{ statusMap[row.status]?.label || '未知' }} + </el-tag> + </template> + </el-table-column> + + <template v-if="mode === 'all'"> + <el-table-column prop="views" label="浏览" width="80" align="center" /> + <el-table-column prop="likes" label="点赞" width="80" align="center" /> + </template> + + <el-table-column + v-if="mode !== 'recycle'" + label="发布时间" + width="170" + align="center" + > + <template #default="{ row }"> + {{ formatDateTime(row.publish_time || row.create_time) }} + </template> + </el-table-column> + + <el-table-column + :label="mode === 'recycle' ? '删除时间' : '更新时间'" + width="170" + align="center" + > + <template #default="{ row }"> + {{ formatDateTime(mode === 'recycle' ? row.delete_time : row.update_time) }} + </template> + </el-table-column> + + <el-table-column label="操作" :width="actionWidth" fixed="right" align="center"> + <template #default="{ row }"> + <!-- 回收站:恢复 / 彻底删除 --> + <template v-if="mode === 'recycle'"> + <el-button size="small" text type="primary" @click="handleRestore(row)"> + <el-icon><RefreshLeft /></el-icon> + 恢复 + </el-button> + <el-button size="small" text type="danger" @click="handlePurge(row)"> + <el-icon><Delete /></el-icon> + 彻底删除 + </el-button> + </template> + + <!-- 正常列表 / 草稿箱 --> + <template v-else> + <el-button + v-if="mode === 'all' && row.status !== 2" + size="small" + text + type="success" + @click="handlePublish(row)" + > + 发布 + </el-button> + + <el-button size="small" text type="primary" @click="handleEdit(row)"> + 编辑 + </el-button> + + <template v-if="mode === 'all'"> + <el-button size="small" text type="warning" @click="toggleRecommend(row)"> + {{ row.recommend === 1 ? '取消推荐' : '推荐' }} + </el-button> + + <el-button size="small" text type="warning" @click="toggleTop(row)"> + {{ row.top === 1 ? '取消置顶' : '置顶' }} + </el-button> + + <el-button + v-if="row.status === 2" + size="small" + text + type="info" + @click="handleUnPublish(row)" + > + 下架 + </el-button> + </template> + + <el-button + v-if="mode === 'draft'" + size="small" + text + type="success" + @click="handlePublish(row)" + > + 发布 + </el-button> + + <el-button size="small" text type="danger" @click="handleDelete(row)"> + 删除 + </el-button> + </template> + </template> + </el-table-column> + + <template #empty> + <el-empty :description="emptyText" :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> + + <ArticlePreview v-model="previewVisible" :model="currentRow" /> + </div> +</template> + +<script setup> +import { ref, reactive, computed, onMounted } from 'vue' +import { useRouter } from 'vue-router' +import { ElMessage, ElMessageBox } from 'element-plus' +import { Plus, Refresh, RefreshLeft, Search, Delete } from '@element-plus/icons-vue' +import { formatDateTime } from '@/utils/datetime' +import { useAuthStore } from '@/stores/auth' +import { + listArticles, + recycleArticles, + getArticle, + deleteArticle, + restoreArticle, + purgeArticle, + publishArticle, + unPublishArticle, + articleRecommend, + articleTop, + unArticleRecommend, + unArticleTop, + allCategories +} from '@/api/article' +import ArticlePreview from './preview.vue' + +// mode:all 文章列表 / draft 草稿箱 / recycle 回收站 +const props = defineProps({ + mode: { + type: String, + default: 'all', + validator: (v) => ['all', 'draft', 'recycle'].includes(v) + } +}) + +// 状态字典:0 草稿、1 待审核、2 已发布、3 已隐藏 +const statusMap = { + 0: { label: '草稿', type: 'info' }, + 1: { label: '待审核', type: 'warning' }, + 2: { label: '已发布', type: 'success' }, + 3: { label: '已隐藏', type: 'danger' } +} + +const router = useRouter() +const authStore = useAuthStore() + +const pageTitle = computed(() => { + if (props.mode === 'draft') return '草稿箱' + if (props.mode === 'recycle') return '回收站' + return '文章列表' +}) + +const pageTips = computed(() => { + if (props.mode === 'draft') { + return '草稿状态的文章不会在官网前台展示,完善内容后点击「发布」即可上线。' + } + if (props.mode === 'recycle') { + return '被删除的文章会保留在此,「恢复」后回到文章列表;「彻底删除」不可恢复,请谨慎操作。' + } + return '官网文章内容在此统一维护。文章状态:草稿 → 待审核 → 已发布,仅已发布 的文章会在企业官网前台展示。' +}) + +const emptyText = computed(() => { + if (props.mode === 'draft') return '暂无草稿' + if (props.mode === 'recycle') return '回收站是空的' + return '暂无文章' +}) + +const actionWidth = computed(() => (props.mode === 'all' ? 300 : props.mode === 'draft' ? 200 : 200)) + +const loading = ref(false) +const list = ref([]) +const categoryOptions = ref([]) + +const searchForm = reactive({ + keyword: '', + cate: '', + status: '', + dateRange: null +}) + +const pagination = reactive({ + page: 1, + pageSize: 10, + total: 0 +}) + +const previewVisible = ref(false) +const currentRow = ref(null) + +const fetchList = async () => { + loading.value = true + try { + if (props.mode === 'recycle') { + const res = await recycleArticles({ + keyword: searchForm.keyword, + page: pagination.page, + pageSize: pagination.pageSize + }) + if (res.code === 200) { + list.value = res.data?.list || [] + pagination.total = res.data?.total || 0 + } else { + ElMessage.error(res.msg || '获取回收站失败') + } + return + } + + const res = await listArticles({ + keyword: searchForm.keyword, + cate: searchForm.cate, + // 草稿箱固定只看草稿 + status: props.mode === 'draft' ? 0 : searchForm.status === '' ? '' : searchForm.status, + startDate: searchForm.dateRange?.[0] || '', + endDate: searchForm.dateRange?.[1] || '', + page: pagination.page, + pageSize: pagination.pageSize + }) + if (res.code === 200) { + list.value = res.data?.list || [] + pagination.total = res.data?.total || 0 + } else { + ElMessage.error(res.msg || '获取文章列表失败') + } + } catch (e) { + ElMessage.error('获取列表失败') + } finally { + loading.value = false + } +} + +const fetchCategories = async () => { + try { + const res = await allCategories() + if (res.code === 200) { + const data = res.data + categoryOptions.value = Array.isArray(data) ? data : data?.list || [] + } + } catch (e) { + // 分类获取失败不影响列表展示 + } +} + +const handleSearch = () => { + pagination.page = 1 + fetchList() +} + +const resetSearch = () => { + searchForm.keyword = '' + searchForm.cate = '' + searchForm.status = '' + searchForm.dateRange = null + pagination.page = 1 + fetchList() +} + +const handleSizeChange = (val) => { + pagination.pageSize = val + pagination.page = 1 + fetchList() +} + +const handlePageChange = (val) => { + pagination.page = val + fetchList() +} + +const goCreate = () => { + router.push('/website/content/article/create') +} + +const handleEdit = (row) => { + router.push({ path: '/website/content/article/edit', query: { id: row.id } }) +} + +const handleView = async (row) => { + currentRow.value = { ...row } + previewVisible.value = true + try { + const res = await getArticle(row.id) + if (res.code === 200 && res.data) { + currentRow.value = { ...row, ...res.data, _raw: res.data } + } + } catch (e) { + // 详情失败时用列表行数据兜底 + } +} + +const handleDelete = async (row) => { + try { + await ElMessageBox.confirm( + `确定要删除文章「${row.title}」吗?删除后可在回收站找回。`, + '删除确认', + { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' } + ) + } catch (e) { + return + } + try { + const res = await deleteArticle(row.id) + if (res.code === 200) { + ElMessage.success('已移入回收站') + fetchList() + } else { + ElMessage.error(res.msg || '删除失败') + } + } catch (e) { + ElMessage.error('删除失败') + } +} + +const handleRestore = async (row) => { + try { + const res = await restoreArticle(row.id) + if (res.code === 200) { + ElMessage.success('恢复成功') + fetchList() + } else { + ElMessage.error(res.msg || '恢复失败') + } + } catch (e) { + ElMessage.error('恢复失败') + } +} + +const handlePurge = async (row) => { + try { + await ElMessageBox.confirm( + `确定要彻底删除「${row.title}」吗?该操作不可恢复。`, + '彻底删除确认', + { confirmButtonText: '彻底删除', cancelButtonText: '取消', type: 'error' } + ) + } catch (e) { + return + } + try { + const res = await purgeArticle(row.id) + if (res.code === 200) { + ElMessage.success('已彻底删除') + fetchList() + } else { + ElMessage.error(res.msg || '删除失败') + } + } catch (e) { + ElMessage.error('删除失败') + } +} + +const handlePublish = async (row) => { + try { + await ElMessageBox.confirm('确认发布该文章吗?发布后将在官网前台展示。', '确认发布', { + confirmButtonText: '确认发布', + cancelButtonText: '取消', + type: 'warning' + }) + } catch (e) { + return + } + try { + const res = await publishArticle(row.id, authStore.user?.id || undefined) + if (res.code === 200) { + ElMessage.success('发布成功') + fetchList() + } else { + ElMessage.error(res.msg || '发布失败') + } + } catch (e) { + ElMessage.error('发布失败') + } +} + +const handleUnPublish = async (row) => { + try { + await ElMessageBox.confirm('确认下架该文章吗?下架后官网前台将不再展示。', '确认下架', { + confirmButtonText: '确认', + cancelButtonText: '取消', + type: 'warning' + }) + } catch (e) { + return + } + try { + const res = await unPublishArticle(row.id) + if (res.code === 200) { + ElMessage.success('下架成功') + fetchList() + } else { + ElMessage.error(res.msg || '下架失败') + } + } catch (e) { + ElMessage.error('下架失败') + } +} + +const toggleRecommend = async (row) => { + const next = row.recommend === 1 ? 0 : 1 + try { + await ElMessageBox.confirm( + next === 1 ? '确认推荐该文章吗?' : '确认取消推荐该文章吗?', + '提示', + { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' } + ) + } catch (e) { + return + } + try { + const res = next === 1 ? await articleRecommend(row.id) : await unArticleRecommend(row.id) + if (res.code === 200) { + ElMessage.success(next === 1 ? '推荐成功' : '已取消推荐') + fetchList() + } else { + ElMessage.error(res.msg || '操作失败') + } + } catch (e) { + ElMessage.error('操作失败') + } +} + +const toggleTop = async (row) => { + const next = row.top === 1 ? 0 : 1 + try { + await ElMessageBox.confirm( + next === 1 ? '确认置顶该文章吗?' : '确认取消置顶该文章吗?', + '提示', + { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' } + ) + } catch (e) { + return + } + try { + const res = next === 1 ? await articleTop(row.id) : await unArticleTop(row.id) + if (res.code === 200) { + ElMessage.success(next === 1 ? '置顶成功' : '已取消置顶') + fetchList() + } else { + ElMessage.error(res.msg || '操作失败') + } + } catch (e) { + ElMessage.error('操作失败') + } +} + +onMounted(() => { + fetchList() + if (props.mode === 'all') { + fetchCategories() + } +}) +</script> + +<style lang="less" scoped> +.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-cell { + display: flex; + align-items: center; + gap: 6px; +} + +.pagination-container { + display: flex; + justify-content: flex-end; + margin-top: 16px; +} +</style> diff --git a/platform/src/views/website/content/article/components/preview.vue b/platform/src/views/website/content/article/components/preview.vue new file mode 100644 index 0000000..00d4775 --- /dev/null +++ b/platform/src/views/website/content/article/components/preview.vue @@ -0,0 +1,140 @@ +<template> + <el-drawer v-model="visible" title="文章预览" size="60%"> + <div class="preview" v-loading="!model"> + <h1 class="preview__title">{{ model?.title || '-' }}</h1> + + <div class="preview__meta"> + <span v-if="model?.cate">分类:{{ model.cate }}</span> + <span v-if="model?.author">作者:{{ model.author }}</span> + <span>发布时间:{{ formatDateTime(model?.publish_time || model?.publish_date) }}</span> + <span v-if="model?.views">浏览:{{ model.views }}</span> + </div> + + <el-divider /> + + <p v-if="model?.desc" class="preview__desc">{{ model.desc }}</p> + + <img v-if="model?.image" :src="previewUrl(model.image)" class="preview__cover" alt="封面" /> + + <div v-if="model?.is_trans === 1 && model?.transurl" class="preview__trans"> + 本文为转载内容,原文地址: + <el-link type="primary" :href="model.transurl" target="_blank">{{ model.transurl }}</el-link> + </div> + + <div class="preview__content" v-html="contentHtml"></div> + </div> + </el-drawer> +</template> + +<script setup> +import { ref, computed, watch } from 'vue' +import { formatDateTime } from '@/utils/datetime' + +const props = defineProps({ + modelValue: { type: Boolean, default: false }, + model: { type: Object, default: null } +}) + +const emit = defineEmits(['update:modelValue']) + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '' +const visible = ref(props.modelValue) + +// 无正文时给出占位提示,避免抽屉内容区空白 +const contentHtml = computed( + () => props.model?.content || '<p style="color:#999">暂无正文</p>' +) + +watch( + () => props.modelValue, + (val) => { + visible.value = val + } +) + +watch(visible, (val) => { + emit('update:modelValue', val) +}) + +const previewUrl = (url) => { + if (!url) return '' + if (/^https?:\/\//i.test(url)) return url + return API_BASE_URL + url +} +</script> + +<style lang="less" scoped> +.preview { + padding: 0 8px 40px; + + &__title { + margin: 0 0 12px; + font-size: 24px; + font-weight: 600; + line-height: 1.4; + } + + &__meta { + display: flex; + flex-wrap: wrap; + gap: 16px; + font-size: 13px; + color: var(--el-text-color-secondary); + } + + &__desc { + margin: 0 0 16px; + padding: 12px 14px; + background: var(--el-fill-color-light); + border-radius: 6px; + font-size: 13px; + line-height: 1.8; + color: var(--el-text-color-regular); + } + + &__cover { + width: 100%; + max-height: 260px; + object-fit: cover; + border-radius: 6px; + margin-bottom: 16px; + } + + &__trans { + margin-bottom: 16px; + font-size: 13px; + color: var(--el-text-color-secondary); + word-break: break-all; + } + + &__content { + font-size: 15px; + line-height: 1.9; + color: var(--el-text-color-primary); + word-break: break-word; + + :deep(img) { + max-width: 100%; + border-radius: 4px; + } + + :deep(pre) { + padding: 12px 16px; + background: var(--el-fill-color-light); + border-radius: 4px; + overflow-x: auto; + } + + :deep(table) { + width: 100%; + border-collapse: collapse; + + th, + td { + border: 1px solid var(--el-border-color); + padding: 8px 12px; + } + } + } +} +</style> diff --git a/platform/src/views/website/content/article/create.vue b/platform/src/views/website/content/article/create.vue new file mode 100644 index 0000000..a237744 --- /dev/null +++ b/platform/src/views/website/content/article/create.vue @@ -0,0 +1,58 @@ +<template> + <div class="container-box"> + <div class="header-bar"> + <h2>发布文章</h2> + <el-button @click="goList"> + <el-icon><Back /></el-icon> + 返回文章列表 + </el-button> + </div> +<!-- + <el-alert type="info" :closable="false" class="tips"> + <template #title> + <div> + 点击「提交」后文章进入<b>待审核</b>状态;点击「存为草稿」保留为草稿,可稍后在「草稿箱」继续编辑。 + 文章需在文章列表点击「发布」后才会在官网前台展示。 + </div> + </template> + </el-alert> --> + + <ArticleForm :is-edit="false" @saved="goList" @cancel="goList" /> + </div> +</template> + +<script setup> +import { useRouter } from 'vue-router' +import { Back } from '@element-plus/icons-vue' +import ArticleForm from './components/ArticleForm.vue' + +const router = useRouter() + +const goList = () => { + router.push('/website/content/article/list') +} +</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; + } +} + +.tips { + margin-bottom: 16px; +} +</style> diff --git a/platform/src/views/website/content/article/draft.vue b/platform/src/views/website/content/article/draft.vue new file mode 100644 index 0000000..c90d8b2 --- /dev/null +++ b/platform/src/views/website/content/article/draft.vue @@ -0,0 +1,16 @@ +<template> + <div class="container-box"> + <ArticleList mode="draft" /> + </div> +</template> + +<script setup> +import ArticleList from './components/ArticleList.vue' +</script> + +<style lang="less" scoped> +.container-box { + padding: 16px; + background: var(--el-bg-color); +} +</style> diff --git a/platform/src/views/website/content/article/edit.vue b/platform/src/views/website/content/article/edit.vue new file mode 100644 index 0000000..08fcf06 --- /dev/null +++ b/platform/src/views/website/content/article/edit.vue @@ -0,0 +1,62 @@ +<template> + <div class="container-box"> + <div class="header-bar"> + <h2>编辑文章</h2> + <el-button @click="goList"> + <el-icon><Back /></el-icon> + 返回文章列表 + </el-button> + </div> + + <el-empty + v-if="!articleId" + description="缺少文章ID,请从「文章列表」或「草稿箱」点击「编辑」进入" + > + <el-button type="primary" @click="goList">返回文章列表</el-button> + </el-empty> + + <ArticleForm + v-else + :is-edit="true" + :article-id="articleId" + @saved="goList" + @cancel="goList" + /> + </div> +</template> + +<script setup> +import { computed } from 'vue' +import { useRoute, useRouter } from 'vue-router' +import { Back } from '@element-plus/icons-vue' +import ArticleForm from './components/ArticleForm.vue' + +const route = useRoute() +const router = useRouter() + +const articleId = computed(() => Number(route.query.id) || 0) + +const goList = () => { + router.push('/website/content/article/list') +} +</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: 16px; + + h2 { + margin: 0; + font-size: 18px; + font-weight: 500; + } +} +</style> diff --git a/platform/src/views/website/content/article/index.vue b/platform/src/views/website/content/article/index.vue new file mode 100644 index 0000000..914e0ff --- /dev/null +++ b/platform/src/views/website/content/article/index.vue @@ -0,0 +1,16 @@ +<template> + <div class="container-box"> + <ArticleList mode="all" /> + </div> +</template> + +<script setup> +import ArticleList from './components/ArticleList.vue' +</script> + +<style lang="less" scoped> +.container-box { + padding: 16px; + background: var(--el-bg-color); +} +</style> diff --git a/platform/src/views/website/content/article/list.vue b/platform/src/views/website/content/article/list.vue new file mode 100644 index 0000000..914e0ff --- /dev/null +++ b/platform/src/views/website/content/article/list.vue @@ -0,0 +1,16 @@ +<template> + <div class="container-box"> + <ArticleList mode="all" /> + </div> +</template> + +<script setup> +import ArticleList from './components/ArticleList.vue' +</script> + +<style lang="less" scoped> +.container-box { + padding: 16px; + background: var(--el-bg-color); +} +</style> diff --git a/platform/src/views/website/content/article/recycle.vue b/platform/src/views/website/content/article/recycle.vue new file mode 100644 index 0000000..bf0d8a1 --- /dev/null +++ b/platform/src/views/website/content/article/recycle.vue @@ -0,0 +1,16 @@ +<template> + <div class="container-box"> + <ArticleList mode="recycle" /> + </div> +</template> + +<script setup> +import ArticleList from './components/ArticleList.vue' +</script> + +<style lang="less" scoped> +.container-box { + padding: 16px; + background: var(--el-bg-color); +} +</style> diff --git a/platform/src/views/website/content/category/components/edit.vue b/platform/src/views/website/content/category/components/edit.vue new file mode 100644 index 0000000..6cfbb8f --- /dev/null +++ b/platform/src/views/website/content/category/components/edit.vue @@ -0,0 +1,284 @@ +<template> + <el-dialog + v-model="visible" + :title="isEdit ? '编辑分类' : '新增分类'" + width="620px" + :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="name"> + <el-input v-model="form.name" placeholder="请输入分类名称" maxlength="50" show-word-limit /> + </el-form-item> + + <el-row :gutter="20"> + <el-col :span="14"> + <el-form-item label="父级分类" prop="cid"> + <el-tree-select + v-model="form.cid" + :data="treeData" + :props="{ label: 'name', value: 'id', children: 'children' }" + placeholder="不选则为顶级分类" + clearable + check-strictly + :render-after-expand="false" + style="width: 100%" + /> + </el-form-item> + </el-col> + <el-col :span="10"> + <el-form-item label="排序" prop="sort"> + <el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" style="width: 100%" /> + </el-form-item> + </el-col> + </el-row> + + <el-form-item label="分类图片" prop="image"> + <el-upload + class="image-uploader" + :show-file-list="false" + :auto-upload="false" + accept="image/*" + :on-change="handleImageChange" + > + <img v-if="form.image" :src="previewUrl(form.image)" class="image-preview" /> + <div v-else class="upload-placeholder"> + <el-icon><Plus /></el-icon> + <span>上传图片</span> + </div> + </el-upload> + <div class="upload-tip">建议尺寸 250 × 140,支持 jpg / png,单张不超过 10MB</div> + </el-form-item> + + <el-form-item label="分类描述" prop="desc"> + <el-input + v-model="form.desc" + type="textarea" + :rows="3" + maxlength="200" + show-word-limit + placeholder="请输入分类描述" + /> + </el-form-item> + + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="form.status"> + <el-radio :value="1">启用</el-radio> + <el-radio :value="0">禁用</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-dialog> +</template> + +<script setup> +import { ref, reactive, computed, watch, nextTick } from 'vue' +import { ElMessage } from 'element-plus' +import { Plus } from '@element-plus/icons-vue' +import { createCategory, editCategory } from '@/api/article' +import { uploadToWebsiteMaterial } from '@/utils/websiteUpload' + +const props = defineProps({ + modelValue: { type: Boolean, default: false }, + category: { type: Object, default: null }, + treeData: { type: Array, default: () => [] } +}) + +const emit = defineEmits(['update:modelValue', 'saved']) + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '' +const visible = ref(props.modelValue) +const submitLoading = ref(false) +const formRef = ref(null) +const imageFile = ref(null) + +const form = reactive({ + id: null, + name: '', + cid: 0, + image: '', + desc: '', + sort: 0, + status: 1 +}) + +const rules = { + name: [{ required: true, message: '请输入分类名称', trigger: 'blur' }] +} + +const isEdit = computed(() => !!form.id) + +const previewUrl = (url) => { + if (!url) return '' + if (/^https?:\/\//i.test(url)) return url + return API_BASE_URL + url +} + +watch( + () => props.modelValue, + (val) => { + visible.value = val + if (!val) return + nextTick(() => { + imageFile.value = null + formRef.value?.clearValidate() + const c = props.category + if (c && c.id) { + Object.assign(form, { + id: c.id, + name: c.name || '', + cid: c.cid ?? 0, + image: c.image || '', + desc: c.desc || '', + sort: c.sort ?? 0, + status: c.status ?? 1 + }) + } else if (c && c.cid) { + // 新增子分类:仅带上父级,其余重置 + Object.assign(form, { + id: null, + name: '', + cid: c.cid, + image: '', + desc: '', + sort: 0, + status: 1 + }) + } else { + handleClosed() + } + }) + } +) + +watch(visible, (val) => { + emit('update:modelValue', val) +}) + +const handleImageChange = (file) => { + const raw = file.raw + if (!raw) return + if (!raw.type.startsWith('image/')) { + ElMessage.error('仅支持图片格式') + return + } + if (raw.size / 1024 / 1024 >= 10) { + ElMessage.error('图片不能超过 10MB') + return + } + imageFile.value = raw + form.image = URL.createObjectURL(raw) +} + +const handleClosed = () => { + imageFile.value = null + formRef.value?.clearValidate() + Object.assign(form, { + id: null, + name: '', + cid: 0, + image: '', + desc: '', + sort: 0, + status: 1 + }) +} + +const handleSubmit = async () => { + if (!formRef.value) return + await formRef.value.validate(async (valid) => { + if (!valid) return + submitLoading.value = true + try { + let imageUrl = form.image + if (imageFile.value) { + const uploadRes = await uploadToWebsiteMaterial(imageFile.value) + if ((uploadRes.code === 200 || uploadRes.code === 201) && uploadRes.data?.url) { + imageUrl = uploadRes.data.url.replace(/\\/g, '/') + } else { + ElMessage.error(uploadRes.msg || '图片上传失败') + return + } + } + + const payload = { + name: form.name, + image: imageUrl, + desc: form.desc, + sort: form.sort, + status: form.status, + cid: form.cid || 0 + } + + const res = isEdit.value + ? await editCategory(form.id, payload) + : await createCategory(payload) + + if (res.code === 200) { + ElMessage.success(isEdit.value ? '保存成功' : '创建成功') + visible.value = false + emit('saved') + } else { + ElMessage.error(res.msg || '操作失败') + } + } catch (e) { + ElMessage.error('操作失败') + } finally { + submitLoading.value = false + } + }) +} +</script> + +<style lang="less" scoped> +.image-uploader { + :deep(.el-upload) { + border: 1px dashed var(--el-border-color); + border-radius: 6px; + cursor: pointer; + overflow: hidden; + transition: border-color 0.3s; + + &:hover { + border-color: var(--el-color-primary); + } + } +} + +.image-preview { + width: 140px; + height: 90px; + display: block; + object-fit: cover; +} + +.upload-placeholder { + width: 140px; + height: 90px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + color: var(--el-text-color-secondary); + font-size: 12px; + + .el-icon { + font-size: 22px; + } +} + +.upload-tip { + margin-top: 6px; + font-size: 12px; + color: var(--el-text-color-secondary); +} +</style> diff --git a/platform/src/views/website/content/category/index.vue b/platform/src/views/website/content/category/index.vue new file mode 100644 index 0000000..abe77e6 --- /dev/null +++ b/platform/src/views/website/content/category/index.vue @@ -0,0 +1,286 @@ +<template> + <div class="container-box category-manage"> + <div class="header-bar"> + <h2>分类管理</h2> + <div class="header-actions"> + <el-button @click="toggleExpandAll"> + <el-icon><Sort /></el-icon> + {{ expandAll ? '全部折叠' : '全部展开' }} + </el-button> + <el-button type="primary" @click="handleAdd"> + <el-icon><Plus /></el-icon> + 新增分类 + </el-button> + <el-button @click="fetchCategories" :loading="loading"> + <el-icon><Refresh /></el-icon> + 刷新 + </el-button> + </div> + </div> + + <el-alert type="info" :closable="false" class="tips"> + <template #title> + <div> + 分类用于组织官网文章(对应模板标签 <code>{yz:category}</code> 等)。 + 支持多级父子结构,分类图片用于列表页/栏目页展示。 + </div> + </template> + </el-alert> + + <div class="search-bar"> + <el-input + v-model="searchForm.keyword" + placeholder="搜索分类名称" + clearable + style="width: 220px" + @keyup.enter="fetchCategories" + @clear="fetchCategories" + /> + <el-button type="primary" @click="fetchCategories"> + <el-icon><Search /></el-icon> + 搜索 + </el-button> + <el-button @click="resetSearch">重置</el-button> + </div> + + <el-table + v-if="tableVisible" + :data="categoryTree" + v-loading="loading" + row-key="id" + border + default-expand-all + :tree-props="{ children: 'children' }" + style="width: 100%" + > + <el-table-column prop="name" label="分类名称" min-width="220" show-overflow-tooltip /> + <el-table-column label="分类图片" width="110" align="center"> + <template #default="{ row }"> + <el-image + v-if="row.image" + :src="previewUrl(row.image)" + :preview-src-list="[previewUrl(row.image)]" + preview-teleported + fit="cover" + style="width: 60px; height: 40px; border-radius: 4px" + /> + <span v-else class="muted">-</span> + </template> + </el-table-column> + <el-table-column prop="desc" label="描述" min-width="180" show-overflow-tooltip> + <template #default="{ row }"> + <span>{{ row.desc || '-' }}</span> + </template> + </el-table-column> + <el-table-column prop="sort" label="排序" width="90" align="center" /> + <el-table-column label="状态" width="100" align="center"> + <template #default="{ row }"> + <el-tag :type="row.status === 1 ? 'success' : 'info'" size="small"> + {{ row.status === 1 ? '启用' : '禁用' }} + </el-tag> + </template> + </el-table-column> + <el-table-column label="操作" width="240" fixed="right" align="center"> + <template #default="{ row }"> + <el-button size="small" text @click="handleAddChild(row)"> + <el-icon><CirclePlus /></el-icon> + 子分类 + </el-button> + <el-button size="small" text type="primary" @click="handleEdit(row)"> + <el-icon><Edit /></el-icon> + 编辑 + </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> + + <CategoryEdit + v-model="dialogVisible" + :category="currentCategory" + :tree-data="treeOptions" + @saved="fetchCategories" + /> + </div> +</template> + +<script setup> +import { ref, reactive, computed, onMounted, nextTick } from 'vue' +import { ElMessage, ElMessageBox } from 'element-plus' +import { Plus, Refresh, Search, Edit, Delete, CirclePlus, Sort } from '@element-plus/icons-vue' +import { allCategories, deleteCategory } from '@/api/article' +import CategoryEdit from './components/edit.vue' + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '' + +const loading = ref(false) +const categories = ref([]) +const searchForm = reactive({ keyword: '' }) +const dialogVisible = ref(false) +const currentCategory = ref(null) +const expandAll = ref(true) +// 通过 v-if 重建表格以应用「全部展开/折叠」 +const tableVisible = ref(true) + +const previewUrl = (url) => { + if (!url) return '' + if (/^https?:\/\//i.test(url)) return url + return API_BASE_URL + url +} + +// 平铺列表 -> 树(兼容 cid 父子字段) +const buildTree = (list) => { + const nodes = list.map((item) => ({ + ...item, + children: [] + })) + const map = new Map(nodes.map((n) => [n.id, n])) + const roots = [] + nodes.forEach((node) => { + const parent = map.get(node.cid) + if (parent && parent.id !== node.id) { + parent.children.push(node) + } else { + roots.push(node) + } + }) + // 无子节点时移除空 children,避免树表出现空展开箭头 + const prune = (arr) => + arr.forEach((n) => { + if (n.children.length === 0) { + delete n.children + } else { + prune(n.children) + } + }) + prune(roots) + return roots +} + +const categoryTree = computed(() => buildTree(categories.value)) +const treeOptions = computed(() => [ + { id: 0, name: '顶级分类', children: categoryTree.value } +]) + +const fetchCategories = async () => { + loading.value = true + try { + const res = await allCategories({ keyword: searchForm.keyword }) + if (res.code === 200) { + categories.value = Array.isArray(res.data) ? res.data : res.data?.list || [] + } else { + ElMessage.error(res.msg || '获取分类失败') + } + } catch (e) { + ElMessage.error('获取分类失败') + } finally { + loading.value = false + } +} + +const resetSearch = () => { + searchForm.keyword = '' + fetchCategories() +} + +const toggleExpandAll = async () => { + expandAll.value = !expandAll.value + // 重建表格:default-expand-all 是初始化属性,需要重挂载才会生效 + tableVisible.value = false + await nextTick() + tableVisible.value = true +} + +const handleAdd = () => { + currentCategory.value = null + dialogVisible.value = true +} + +const handleAddChild = (row) => { + currentCategory.value = { cid: row.id } + dialogVisible.value = true +} + +const handleEdit = (row) => { + currentCategory.value = { ...row } + dialogVisible.value = true +} + +const handleDelete = async (row) => { + if (row.children && row.children.length > 0) { + ElMessage.warning('该分类下存在子分类,请先删除子分类') + return + } + try { + await ElMessageBox.confirm(`确定要删除分类「${row.name}」吗?`, '删除确认', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning' + }) + } catch (e) { + return + } + try { + const res = await deleteCategory(row.id) + if (res.code === 200) { + ElMessage.success('删除成功') + fetchCategories() + } else { + ElMessage.error(res.msg || '删除失败') + } + } catch (e) { + ElMessage.error('删除失败') + } +} + +onMounted(() => { + fetchCategories() +}) +</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; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.muted { + color: var(--el-text-color-placeholder); +} +</style> diff --git a/platform/src/views/website/content/comment/index.vue b/platform/src/views/website/content/comment/index.vue new file mode 100644 index 0000000..d87ae51 --- /dev/null +++ b/platform/src/views/website/content/comment/index.vue @@ -0,0 +1,417 @@ +<template> + <div class="container-box comment-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 @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> 评论不会在官网展示,通过后才会显示。 + </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.status" + placeholder="全部状态" + clearable + style="width: 140px" + @change="handleSearch" + > + <el-option + v-for="(item, value) in statusMap" + :key="value" + :label="item.label" + :value="Number(value)" + /> + </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="commentList" + 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="80" align="center" /> + + <el-table-column label="评论内容" min-width="300"> + <template #default="{ row }"> + <div class="comment-content">{{ row.content }}</div> + <div v-if="row.reply" class="comment-reply"> + <b>管理员回复:</b>{{ row.reply }} + </div> + </template> + </el-table-column> + + <el-table-column label="所属文章" min-width="180" show-overflow-tooltip> + <template #default="{ row }">{{ row.article_title || `#${row.article_id || '-'}` }}</template> + </el-table-column> + + <el-table-column label="评论人" width="150"> + <template #default="{ row }"> + <div>{{ row.nickname || '匿名' }}</div> + <div class="muted">{{ row.email || '-' }}</div> + </template> + </el-table-column> + + <el-table-column label="状态" width="100" align="center"> + <template #default="{ row }"> + <el-tag :type="statusMap[row.status]?.type || 'info'" size="small"> + {{ statusMap[row.status]?.label || '未知' }} + </el-tag> + </template> + </el-table-column> + + <el-table-column label="评论时间" width="180" align="center"> + <template #default="{ row }">{{ formatDateTime(row.create_time) }}</template> + </el-table-column> + + <el-table-column label="操作" width="230" fixed="right" align="center"> + <template #default="{ row }"> + <el-button + v-if="row.status !== 1" + size="small" + text + type="success" + @click="handleAudit(row, 1)" + > + 通过 + </el-button> + <el-button + v-if="row.status !== 2" + size="small" + text + type="warning" + @click="handleAudit(row, 2)" + > + 拒绝 + </el-button> + <el-button size="small" text type="primary" @click="handleReply(row)"> + 回复 + </el-button> + <el-button size="small" text type="danger" @click="handleDelete(row)"> + 删除 + </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> + + <!-- 回复评论 --> + <el-dialog v-model="replyVisible" title="回复评论" width="520px" destroy-on-close> + <div class="reply-quote"> + <b>{{ replyTarget?.nickname || '匿名' }}:</b>{{ replyTarget?.content }} + </div> + <el-input + v-model="replyContent" + type="textarea" + :rows="4" + maxlength="500" + show-word-limit + placeholder="请输入回复内容" + /> + <template #footer> + <el-button @click="replyVisible = false">取消</el-button> + <el-button type="primary" :loading="replyLoading" @click="submitReply">确定</el-button> + </template> + </el-dialog> + </div> +</template> + +<script setup> +import { ref, reactive, onMounted } from 'vue' +import { ElMessage, ElMessageBox } from 'element-plus' +import { Refresh, Search, Delete } from '@element-plus/icons-vue' +import { formatDateTime } from '@/utils/datetime' +import { + listComments, + auditComment, + replyComment, + deleteComment, + batchDeleteComments +} from '@/api/comment' + +// 0 待审核、1 已通过、2 已拒绝 +const statusMap = { + 0: { label: '待审核', type: 'warning' }, + 1: { label: '已通过', type: 'success' }, + 2: { label: '已拒绝', type: 'danger' } +} + +const loading = ref(false) +const commentList = ref([]) +const selectedIds = ref([]) +const searchForm = reactive({ keyword: '', status: '' }) +const pagination = reactive({ page: 1, pageSize: 10, total: 0 }) + +const replyVisible = ref(false) +const replyLoading = ref(false) +const replyContent = ref('') +const replyTarget = ref(null) + +const fetchList = async () => { + loading.value = true + try { + const res = await listComments({ + keyword: searchForm.keyword, + status: searchForm.status === '' ? '' : searchForm.status, + page: pagination.page, + pageSize: pagination.pageSize + }) + if (res.code === 200) { + const data = res.data + commentList.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 = () => { + searchForm.keyword = '' + searchForm.status = '' + 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 handleAudit = async (row, status) => { + try { + const res = await auditComment(row.id, status) + if (res.code === 200) { + ElMessage.success(status === 1 ? '已通过' : '已拒绝') + fetchList() + } else { + ElMessage.error(res.msg || '操作失败') + } + } catch (e) { + ElMessage.error('操作失败') + } +} + +const handleReply = (row) => { + replyTarget.value = row + replyContent.value = row.reply || '' + replyVisible.value = true +} + +const submitReply = async () => { + if (!replyContent.value.trim()) { + ElMessage.warning('请输入回复内容') + return + } + replyLoading.value = true + try { + const res = await replyComment(replyTarget.value.id, replyContent.value.trim()) + if (res.code === 200) { + ElMessage.success('回复成功') + replyVisible.value = false + fetchList() + } else { + ElMessage.error(res.msg || '回复失败') + } + } catch (e) { + ElMessage.error('回复失败') + } finally { + replyLoading.value = false + } +} + +const handleDelete = async (row) => { + try { + await ElMessageBox.confirm('确定要删除该评论吗?', '删除确认', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning' + }) + } catch (e) { + return + } + try { + const res = await deleteComment(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 batchDeleteComments(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; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.pagination-container { + display: flex; + justify-content: flex-end; + margin-top: 16px; +} + +.comment-content { + line-height: 1.7; + word-break: break-word; +} + +.comment-reply { + margin-top: 6px; + padding: 6px 10px; + background: var(--el-fill-color-light); + border-radius: 4px; + font-size: 12.5px; + color: var(--el-text-color-regular); + word-break: break-word; +} + +.reply-quote { + margin-bottom: 12px; + padding: 10px 12px; + background: var(--el-fill-color-light); + border-radius: 4px; + font-size: 13px; + line-height: 1.7; + color: var(--el-text-color-regular); + word-break: break-word; +} + +.muted { + font-size: 12px; + color: var(--el-text-color-secondary); +} +</style> diff --git a/platform/src/views/website/content/tag/index.vue b/platform/src/views/website/content/tag/index.vue new file mode 100644 index 0000000..e7d6201 --- /dev/null +++ b/platform/src/views/website/content/tag/index.vue @@ -0,0 +1,329 @@ +<template> + <div class="container-box tag-manage"> + <div class="header-bar"> + <h2>标签管理</h2> + <div class="header-actions"> + <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> + 标签用于给官网内容打标并按主题聚合,前台可通过模板标签 + <code>{yz:taglist}</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.status" + 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="tagList" v-loading="loading" border stripe style="width: 100%"> + <el-table-column prop="id" label="ID" width="80" align="center" /> + <el-table-column prop="name" label="标签名称" min-width="200" show-overflow-tooltip /> + <el-table-column prop="sort" label="排序" width="90" align="center" /> + <el-table-column label="状态" width="110" align="center"> + <template #default="{ row }"> + <el-switch + :model-value="row.status === 1" + @change="(val) => handleStatusChange(row, val)" + /> + </template> + </el-table-column> + <el-table-column label="创建时间" width="180" align="center"> + <template #default="{ row }">{{ formatDateTime(row.create_time) }}</template> + </el-table-column> + <el-table-column label="操作" width="160" 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="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> + + <el-dialog + v-model="dialogVisible" + :title="isEdit ? '编辑标签' : '新增标签'" + width="480px" + :close-on-click-modal="false" + destroy-on-close + @closed="resetForm" + > + <el-form ref="formRef" :model="form" :rules="rules" label-width="90px"> + <el-form-item label="标签名称" prop="name"> + <el-input v-model="form.name" placeholder="请输入标签名称" maxlength="50" show-word-limit /> + </el-form-item> + <el-form-item label="排序" prop="sort"> + <el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="form.status"> + <el-radio :value="1">启用</el-radio> + <el-radio :value="0">禁用</el-radio> + </el-radio-group> + </el-form-item> + </el-form> + + <template #footer> + <el-button @click="dialogVisible = false">取消</el-button> + <el-button type="primary" :loading="submitLoading" @click="handleSubmit"> + {{ isEdit ? '保存' : '创建' }} + </el-button> + </template> + </el-dialog> + </div> +</template> + +<script setup> +import { ref, reactive, onMounted } from 'vue' +import { ElMessage, ElMessageBox } from 'element-plus' +import { Plus, Refresh, Search, Edit, Delete } from '@element-plus/icons-vue' +import { formatDateTime } from '@/utils/datetime' +import { listTags, createTag, editTag, deleteTag, updateTagStatus } from '@/api/tag' + +const loading = ref(false) +const tagList = ref([]) +const searchForm = reactive({ keyword: '', status: '' }) +const pagination = reactive({ page: 1, pageSize: 10, total: 0 }) + +const dialogVisible = ref(false) +const submitLoading = ref(false) +const isEdit = ref(false) +const editId = ref(0) +const formRef = ref(null) + +const form = reactive({ name: '', sort: 0, status: 1 }) +const rules = { + name: [{ required: true, message: '请输入标签名称', trigger: 'blur' }] +} + +const fetchList = async () => { + loading.value = true + try { + const res = await listTags({ + keyword: searchForm.keyword, + status: searchForm.status === '' ? '' : searchForm.status, + page: pagination.page, + pageSize: pagination.pageSize + }) + if (res.code === 200) { + const data = res.data + tagList.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 = () => { + searchForm.keyword = '' + searchForm.status = '' + pagination.page = 1 + fetchList() +} + +const handleSizeChange = (val) => { + pagination.pageSize = val + pagination.page = 1 + fetchList() +} + +const handlePageChange = (val) => { + pagination.page = val + fetchList() +} + +const resetForm = () => { + formRef.value?.clearValidate() + isEdit.value = false + editId.value = 0 + Object.assign(form, { name: '', sort: 0, status: 1 }) +} + +const handleAdd = () => { + resetForm() + dialogVisible.value = true +} + +const handleEdit = (row) => { + isEdit.value = true + editId.value = row.id + Object.assign(form, { + name: row.name || '', + sort: row.sort ?? 0, + status: row.status ?? 1 + }) + dialogVisible.value = true +} + +const handleSubmit = async () => { + if (!formRef.value) return + await formRef.value.validate(async (valid) => { + if (!valid) return + submitLoading.value = true + try { + const res = isEdit.value + ? await editTag(editId.value, { ...form }) + : await createTag({ ...form }) + if (res.code === 200) { + ElMessage.success(isEdit.value ? '保存成功' : '创建成功') + dialogVisible.value = false + fetchList() + } else { + ElMessage.error(res.msg || '操作失败') + } + } catch (e) { + ElMessage.error('操作失败') + } finally { + submitLoading.value = false + } + }) +} + +const handleStatusChange = async (row, val) => { + const next = val ? 1 : 0 + try { + const res = await updateTagStatus(row.id, next) + if (res.code === 200) { + row.status = next + ElMessage.success(next === 1 ? '已启用' : '已禁用') + } else { + ElMessage.error(res.msg || '状态更新失败') + } + } catch (e) { + ElMessage.error('状态更新失败') + } +} + +const handleDelete = async (row) => { + try { + await ElMessageBox.confirm(`确定要删除标签「${row.name}」吗?`, '删除确认', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning' + }) + } catch (e) { + return + } + try { + const res = await deleteTag(row.id) + if (res.code === 200) { + ElMessage.success('删除成功') + 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; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.pagination-container { + display: flex; + justify-content: flex-end; + margin-top: 16px; +} +</style> diff --git a/platform/src/views/website/site/link/components/edit.vue b/platform/src/views/website/site/link/components/edit.vue new file mode 100644 index 0000000..76cd22e --- /dev/null +++ b/platform/src/views/website/site/link/components/edit.vue @@ -0,0 +1,266 @@ +<template> + <el-dialog + :model-value="visible" + :title="title" + width="560px" + :close-on-click-modal="false" + destroy-on-close + @update:model-value="handleVisibleChange" + > + <el-form ref="formRef" :model="form" :rules="rules" label-width="100px"> + <el-form-item label="链接名称" prop="link_name"> + <el-input v-model="form.link_name" placeholder="请输入链接名称" maxlength="100" /> + </el-form-item> + + <el-form-item label="链接地址" prop="link_url"> + <el-input v-model="form.link_url" placeholder="例如:https://www.example.com" /> + </el-form-item> + + <el-form-item label="Logo" prop="link_logo"> + <el-upload + class="logo-uploader" + :show-file-list="false" + :auto-upload="false" + accept="image/*" + :on-change="handleLogoChange" + > + <img v-if="form.link_logo" :src="previewUrl(form.link_logo)" class="logo-preview" /> + <div v-else class="upload-placeholder"> + <el-icon><Plus /></el-icon> + <span>上传 Logo</span> + </div> + </el-upload> + <div class="field-tip">建议尺寸 200 × 200,支持 jpg / png,单张不超过 2MB</div> + </el-form-item> + + <el-form-item label="描述" prop="description"> + <el-input + v-model="form.description" + type="textarea" + :rows="3" + maxlength="200" + show-word-limit + placeholder="请输入描述" + /> + </el-form-item> + + <el-form-item label="排序" prop="sort"> + <el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" /> + </el-form-item> + + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="form.status"> + <el-radio :value="1">启用</el-radio> + <el-radio :value="0">禁用</el-radio> + </el-radio-group> + </el-form-item> + </el-form> + + <template #footer> + <el-button @click="handleCancel">取消</el-button> + <el-button type="primary" :loading="submitLoading" @click="handleSubmit"> + {{ isEdit ? '保存' : '添加' }} + </el-button> + </template> + </el-dialog> +</template> + +<script setup> +import { ref, reactive, computed, watch } from 'vue' +import { ElMessage } from 'element-plus' +import { Plus } from '@element-plus/icons-vue' +import { addFriendlink, updateFriendlink } from '@/api/friendlink' +import { uploadToWebsiteMaterial } from '@/utils/websiteUpload' + +const props = defineProps({ + modelValue: { type: Boolean, default: false }, + title: { type: String, default: '添加友情链接' }, + isEdit: { type: Boolean, default: false }, + rowData: { type: Object, default: () => ({}) } +}) + +const emit = defineEmits(['update:modelValue', 'success']) + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '' +const visible = ref(props.modelValue) +const submitLoading = ref(false) +const formRef = ref(null) +const logoFile = ref(null) + +const form = reactive({ + link_name: '', + link_url: '', + link_logo: '', + description: '', + sort: 0, + status: 1 +}) + +const rules = { + link_name: [{ required: true, message: '请输入链接名称', trigger: 'blur' }], + link_url: [ + { required: true, message: '请输入链接地址', trigger: 'blur' }, + { type: 'url', message: '请输入正确的 URL 地址', trigger: 'blur' } + ] +} + +const isEdit = computed(() => props.isEdit) + +const previewUrl = (url) => { + if (!url) return '' + if (/^https?:\/\//i.test(url)) return url + return API_BASE_URL + url +} + +watch( + () => props.modelValue, + (val) => { + visible.value = val + if (!val) return + formRef.value?.clearValidate() + logoFile.value = null + if (props.isEdit && props.rowData) { + Object.assign(form, { + link_name: props.rowData.link_name || '', + link_url: props.rowData.link_url || '', + link_logo: props.rowData.link_logo || '', + description: props.rowData.description || '', + sort: props.rowData.sort ?? 0, + status: props.rowData.status ?? 1 + }) + } else { + resetForm() + } + } +) + +watch(visible, (val) => { + emit('update:modelValue', val) +}) + +const resetForm = () => { + formRef.value?.clearValidate() + logoFile.value = null + Object.assign(form, { + link_name: '', + link_url: '', + link_logo: '', + description: '', + sort: 0, + status: 1 + }) +} + +const handleLogoChange = (file) => { + const raw = file.raw + if (!raw) return + if (!raw.type.startsWith('image/')) { + ElMessage.error('仅支持图片格式') + return + } + if (raw.size / 1024 / 1024 >= 2) { + ElMessage.error('Logo 不能超过 2MB') + return + } + logoFile.value = raw + form.link_logo = URL.createObjectURL(raw) +} + +const handleVisibleChange = (val) => { + if (!val) emit('update:modelValue', false) +} + +const handleCancel = () => { + emit('update:modelValue', false) +} + +const handleSubmit = async () => { + if (!formRef.value) return + await formRef.value.validate(async (valid) => { + if (!valid) return + submitLoading.value = true + try { + let logoUrl = form.link_logo + if (logoFile.value) { + const uploadRes = await uploadToWebsiteMaterial(logoFile.value) + if ((uploadRes.code === 200 || uploadRes.code === 201) && uploadRes.data?.url) { + logoUrl = uploadRes.data.url.replace(/\\/g, '/') + } else { + ElMessage.error(uploadRes.msg || 'Logo 上传失败') + return + } + } + + const payload = { + link_name: form.link_name, + link_url: form.link_url, + link_logo: logoUrl, + description: form.description, + sort: form.sort, + status: form.status + } + + const res = isEdit.value + ? await updateFriendlink(props.rowData.id, payload) + : await addFriendlink(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 + } + }) +} +</script> + +<style lang="less" scoped> +.logo-uploader { + :deep(.el-upload) { + border: 1px dashed var(--el-border-color); + border-radius: 6px; + cursor: pointer; + overflow: hidden; + transition: border-color 0.3s; + + &:hover { + border-color: var(--el-color-primary); + } + } +} + +.logo-preview { + width: 120px; + height: 120px; + display: block; + object-fit: contain; +} + +.upload-placeholder { + width: 120px; + height: 120px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + color: var(--el-text-color-secondary); + font-size: 12px; + + .el-icon { + font-size: 22px; + } +} + +.field-tip { + margin-top: 6px; + font-size: 12px; + color: var(--el-text-color-secondary); +} +</style> diff --git a/platform/src/views/website/site/link/index.vue b/platform/src/views/website/site/link/index.vue new file mode 100644 index 0000000..189f492 --- /dev/null +++ b/platform/src/views/website/site/link/index.vue @@ -0,0 +1,359 @@ +<template> + <div class="container-box link-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> + 官网页脚 / 友情链接区的合作站点,对应模板标签 <code>{yz:friendlink}</code>。 + 仅 <b>启用</b> 状态的链接会在官网展示。 + </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.status" + 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="linkList" + v-loading="loading" + border + stripe + style="width: 100%" + @selection-change="handleSelectionChange" + > + <el-table-column type="selection" width="50" align="center" /> + <el-table-column prop="sort" label="排序" width="80" align="center" /> + + <el-table-column prop="link_name" label="链接名称" min-width="150" show-overflow-tooltip /> + + <el-table-column label="链接地址" min-width="220" show-overflow-tooltip> + <template #default="{ row }"> + <el-link :href="row.link_url" target="_blank" type="primary">{{ row.link_url }}</el-link> + </template> + </el-table-column> + + <el-table-column label="Logo" width="100" align="center"> + <template #default="{ row }"> + <el-image + v-if="row.link_logo" + :src="previewUrl(row.link_logo)" + :preview-src-list="[previewUrl(row.link_logo)]" + preview-teleported + fit="contain" + style="width: 60px; height: 40px" + /> + <span v-else class="muted">-</span> + </template> + </el-table-column> + + <el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip> + <template #default="{ row }">{{ row.description || '-' }}</template> + </el-table-column> + + <el-table-column label="状态" width="100" align="center"> + <template #default="{ row }"> + <el-switch + :model-value="row.status === 1" + @change="(val) => handleStatusChange(row, val)" + /> + </template> + </el-table-column> + + <el-table-column label="创建时间" width="180" align="center"> + <template #default="{ row }">{{ formatDateTime(row.create_time) }}</template> + </el-table-column> + + <el-table-column label="操作" width="160" 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="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.limit" + :page-sizes="[10, 20, 50, 100]" + :total="pagination.total" + layout="total, sizes, prev, pager, next, jumper" + @size-change="handleSizeChange" + @current-change="handlePageChange" + /> + </div> + + <LinkEdit + v-model="dialogVisible" + :title="dialogTitle" + :is-edit="isEdit" + :row-data="currentRow" + @success="fetchList" + /> + </div> +</template> + +<script setup> +import { ref, reactive, onMounted } from 'vue' +import { ElMessage, ElMessageBox } from 'element-plus' +import { Plus, Refresh, Search, Edit, Delete } from '@element-plus/icons-vue' +import { formatDateTime } from '@/utils/datetime' +import { + getFriendlinkList, + updateFriendlink, + deleteFriendlink, + batchDeleteFriendlinks +} from '@/api/friendlink' +import LinkEdit from './components/edit.vue' + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '' + +const loading = ref(false) +const linkList = ref([]) +const selectedIds = ref([]) +const searchForm = reactive({ keyword: '', status: '' }) +const pagination = reactive({ page: 1, limit: 10, total: 0 }) + +const dialogVisible = ref(false) +const dialogTitle = ref('添加友情链接') +const isEdit = ref(false) +const currentRow = ref({}) + +const previewUrl = (url) => { + if (!url) return '' + if (/^https?:\/\//i.test(url)) return url + return API_BASE_URL + url +} + +const fetchList = async () => { + loading.value = true + try { + const res = await getFriendlinkList({ + page: pagination.page, + limit: pagination.limit, + keyword: searchForm.keyword, + status: searchForm.status === '' ? '' : searchForm.status + }) + if (res.code === 200) { + const data = res.data + linkList.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 = () => { + searchForm.keyword = '' + searchForm.status = '' + pagination.page = 1 + fetchList() +} + +const handleSizeChange = (val) => { + pagination.limit = 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 + dialogTitle.value = '添加友情链接' + currentRow.value = {} + dialogVisible.value = true +} + +const handleEdit = (row) => { + isEdit.value = true + dialogTitle.value = '编辑友情链接' + currentRow.value = { ...row } + dialogVisible.value = true +} + +const handleStatusChange = async (row, val) => { + const next = val ? 1 : 0 + try { + const res = await updateFriendlink(row.id, { status: next }) + if (res.code === 200) { + row.status = next + ElMessage.success(next === 1 ? '已启用' : '已禁用') + } else { + ElMessage.error(res.msg || '状态更新失败') + } + } catch (e) { + ElMessage.error('状态更新失败') + } +} + +const handleDelete = async (row) => { + try { + await ElMessageBox.confirm(`确定要删除友情链接「${row.link_name}」吗?`, '删除确认', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning' + }) + } catch (e) { + return + } + try { + const res = await deleteFriendlink(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 batchDeleteFriendlinks(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; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.pagination-container { + display: flex; + justify-content: flex-end; + margin-top: 16px; +} + +.muted { + color: var(--el-text-color-placeholder); +} +</style> diff --git a/platform/src/views/website/site/nav/components/edit.vue b/platform/src/views/website/site/nav/components/edit.vue new file mode 100644 index 0000000..367af97 --- /dev/null +++ b/platform/src/views/website/site/nav/components/edit.vue @@ -0,0 +1,232 @@ +<template> + <el-dialog + :model-value="visible" + :title="dialogTitle" + width="560px" + :close-on-click-modal="false" + destroy-on-close + @update:model-value="handleVisibleChange" + > + <el-form ref="formRef" :model="form" :rules="rules" label-width="100px"> + <el-form-item label="父级菜单" prop="pid"> + <el-tree-select + v-model="form.pid" + :data="parentMenuOptions" + :props="{ value: 'id', label: 'title', children: 'children' }" + placeholder="不选则为顶级菜单" + clearable + check-strictly + :render-after-expand="false" + style="width: 100%" + /> + </el-form-item> + + <el-form-item label="菜单名称" prop="title"> + <el-input v-model="form.title" placeholder="请输入菜单名称" maxlength="50" /> + </el-form-item> + + <el-form-item label="菜单类型" prop="type"> + <el-radio-group v-model="form.type"> + <el-radio-button :value="1">目录</el-radio-button> + <el-radio-button :value="2">页面</el-radio-button> + <el-radio-button :value="3">外链</el-radio-button> + <el-radio-button :value="4">单页</el-radio-button> + </el-radio-group> + <div class="field-tip"> + <div>目录:只做分组,本身不跳转</div> + <div>页面:需填写路由地址 + 组件路径</div> + <div>外链:填写完整外部地址(https://...)</div> + <div>单页:路由地址需与「单页管理」中的路由一致</div> + </div> + </el-form-item> + + <el-form-item v-if="form.type !== 3" label="路由地址" prop="path"> + <el-input v-model="form.path" placeholder="例如:products / /products" /> + </el-form-item> + + <el-form-item v-if="form.type === 2" label="组件路径" prop="component_path"> + <el-input v-model="form.component_path" placeholder="例如:/views/list.vue" /> + </el-form-item> + + <el-form-item v-if="form.type === 3" label="外链地址" prop="path"> + <el-input v-model="form.path" placeholder="例如:https://www.example.com" /> + </el-form-item> + + <el-form-item label="图标" prop="icon"> + <el-input v-model="form.icon" placeholder="图标类名,例如 fa-solid fa-house" /> + </el-form-item> + + <el-row :gutter="20"> + <el-col :span="12"> + <el-form-item label="排序" prop="sort"> + <el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" style="width: 100%" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="form.status"> + <el-radio :value="1">启用</el-radio> + <el-radio :value="0">禁用</el-radio> + </el-radio-group> + </el-form-item> + </el-col> + </el-row> + + <el-form-item label="是否显示" prop="is_visible"> + <el-switch v-model="form.is_visible" :active-value="1" :inactive-value="0" /> + <span class="field-tip inline">关闭后该菜单不在官网前台显示</span> + </el-form-item> + + <el-form-item label="权限标识" prop="permission"> + <el-input v-model="form.permission" placeholder="选填,例如:website:nav:list" /> + </el-form-item> + </el-form> + + <template #footer> + <el-button @click="handleCancel">取消</el-button> + <el-button type="primary" @click="handleSave">确定</el-button> + </template> + </el-dialog> +</template> + +<script setup> +import { ref, reactive, computed, watch } from 'vue' +import { ElMessage } from 'element-plus' + +const props = defineProps({ + visible: { type: Boolean, default: false }, + menu: { type: Object, default: null }, + parentMenuOptions: { type: Array, default: () => [] }, + dialogType: { type: String, default: 'add' }, + parentTitle: { type: String, default: '' } +}) + +const emit = defineEmits(['update:visible', 'save']) + +const formRef = ref(null) + +const form = reactive({ + id: 0, + pid: 0, + title: '', + type: 1, + path: '', + component_path: '', + icon: '', + sort: 0, + status: 1, + is_visible: 1, + permission: '' +}) + +const dialogTitle = computed(() => { + switch (props.dialogType) { + case 'edit': + return '编辑菜单' + case 'addSub': + return `添加子菜单(父级:${props.parentTitle || '顶级菜单'})` + default: + return '添加菜单' + } +}) + +const rules = { + title: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }], + path: [ + { + validator: (rule, value, callback) => { + if (!value || !String(value).trim()) { + callback(new Error(form.type === 3 ? '请输入外链地址' : '请输入路由地址')) + } else if (form.type === 3 && !/^https?:\/\//i.test(String(value).trim())) { + callback(new Error('外链地址需以 http:// 或 https:// 开头')) + } else { + callback() + } + }, + trigger: 'blur' + } + ], + component_path: [ + { + validator: (rule, value, callback) => { + if (form.type === 2 && (!value || !String(value).trim())) { + callback(new Error('页面类型需填写组件路径')) + } else { + callback() + } + }, + trigger: 'blur' + } + ] +} + +// 同步传入菜单数据到表单 +watch( + () => props.menu, + (val) => { + if (val) { + Object.assign(form, { + id: val.id ?? 0, + pid: val.pid ?? 0, + title: val.title || '', + type: val.type ?? 1, + path: val.path || '', + component_path: val.component_path || '', + icon: val.icon || '', + sort: val.sort ?? 0, + status: val.status ?? 1, + is_visible: val.is_visible ?? 1, + permission: val.permission || '' + }) + } + }, + { immediate: true } +) + +// 切换类型时清理无关字段 +watch( + () => form.type, + (newType, oldType) => { + if (newType === oldType) return + if (newType === 1 || newType === 4) { + form.component_path = '' + } + if (newType === 3) { + form.component_path = '' + if (/^\//.test(form.path)) form.path = '' + } + } +) + +const handleVisibleChange = (val) => { + if (!val) emit('update:visible', false) +} + +const handleCancel = () => { + emit('update:visible', false) +} + +const handleSave = async () => { + if (!formRef.value) return + await formRef.value.validate((valid) => { + if (!valid) return + if (form.type === 1) { + ElMessage.warning('目录仅用于分组,无需填写组件路径') + } + emit('save', { ...form }) + }) +} +</script> + +<style lang="less" scoped> +.field-tip { + margin-top: 6px; + font-size: 12px; + line-height: 1.7; + color: var(--el-text-color-secondary); + + &.inline { + margin-left: 10px; + } +} +</style> diff --git a/platform/src/views/website/site/nav/index.vue b/platform/src/views/website/site/nav/index.vue new file mode 100644 index 0000000..3cc6a82 --- /dev/null +++ b/platform/src/views/website/site/nav/index.vue @@ -0,0 +1,340 @@ +<template> + <div class="container-box nav-manage"> + <div class="header-bar"> + <h2>导航菜单</h2> + <div class="header-actions"> + <el-button @click="toggleExpandAll"> + <el-icon><Sort /></el-icon> + {{ expandAll ? '全部折叠' : '全部展开' }} + </el-button> + <el-button type="primary" @click="handleAddMenu"> + <el-icon><Plus /></el-icon> + 添加菜单 + </el-button> + <el-button @click="fetchMenus" :loading="loading"> + <el-icon><Refresh /></el-icon> + 刷新 + </el-button> + </div> + </div> + + <!-- <el-alert type="info" :closable="false" class="tips"> + <template #title> + <div> + 企业官网前台站点(website)的顶部导航 / 页脚菜单。 + 菜单类型:<b>目录</b>(仅分组)、<b>页面</b>(需填写组件路径)、<b>外链</b>(跳转外部地址)、<b>单页</b>(内容取自单页管理)。 + 租户端(tenant_id = 0)的默认菜单不可修改。 + </div> + </template> + </el-alert> --> + + <el-table + v-if="tableVisible" + ref="tableRef" + :data="menuTree" + v-loading="loading" + row-key="id" + border + default-expand-all + :tree-props="{ children: 'children' }" + style="width: 100%" + > + <el-table-column prop="title" label="菜单名称" min-width="220"> + <template #default="{ row }"> + <div class="menu-item"> + <i v-if="row.icon" :class="row.icon" class="menu-icon"></i> + <span>{{ row.title }}</span> + <el-tag v-if="row.tenant_id === 0" size="small" type="warning">系统默认</el-tag> + </div> + </template> + </el-table-column> + + <el-table-column prop="path" label="路由地址" min-width="160" show-overflow-tooltip /> + + <el-table-column label="类型" width="100" align="center"> + <template #default="{ row }"> + <el-tag :type="typeMap[row.type]?.tag || 'info'" size="small"> + {{ typeMap[row.type]?.label || '未知' }} + </el-tag> + </template> + </el-table-column> + + <el-table-column label="显示" width="90" align="center"> + <template #default="{ row }"> + <el-tag :type="row.is_visible === 0 ? 'info' : 'success'" size="small"> + {{ row.is_visible === 0 ? '隐藏' : '显示' }} + </el-tag> + </template> + </el-table-column> + + <el-table-column prop="sort" label="排序" width="80" align="center" /> + + <el-table-column label="状态" width="90" align="center"> + <template #default="{ row }"> + <el-tag :type="row.status === 1 ? 'success' : 'info'" size="small"> + {{ row.status === 1 ? '启用' : '禁用' }} + </el-tag> + </template> + </el-table-column> + + <el-table-column label="操作" width="250" fixed="right" align="center"> + <template #default="{ row }"> + <el-button + size="small" + text + :disabled="row.type === 3" + @click="handleAddSubMenu(row)" + > + <el-icon><CirclePlus /></el-icon> + 子菜单 + </el-button> + <el-button + size="small" + text + type="primary" + :disabled="row.tenant_id === 0" + @click="handleEditMenu(row)" + > + <el-icon><Edit /></el-icon> + 编辑 + </el-button> + <el-button + size="small" + text + type="danger" + :disabled="row.tenant_id === 0" + @click="handleDeleteMenu(row)" + > + <el-icon><Delete /></el-icon> + 删除 + </el-button> + </template> + </el-table-column> + + <template #empty> + <el-empty description="暂无导航菜单" :image-size="80" /> + </template> + </el-table> + + <NavEdit + v-model:visible="dialogVisible" + :menu="dialogMenu" + :parent-menu-options="parentMenuOptions" + :dialog-type="dialogType" + :parent-title="dialogParentTitle" + @save="handleMenuSave" + /> + </div> +</template> + +<script setup> +import { ref, computed, onMounted, nextTick } from 'vue' +import { ElMessage, ElMessageBox } from 'element-plus' +import { Plus, Refresh, Edit, Delete, CirclePlus, Sort } from '@element-plus/icons-vue' +import { getFrontMenus, createFrontMenu, editFrontMenu, deleteFrontMenu } from '@/api/frontMenu' +import NavEdit from './components/edit.vue' + +const typeMap = { + 1: { label: '目录', tag: 'primary' }, + 2: { label: '页面', tag: 'success' }, + 3: { label: '外链', tag: 'info' }, + 4: { label: '单页', tag: 'warning' } +} + +const loading = ref(false) +const menus = ref([]) +const tableRef = ref(null) +const expandAll = ref(true) +const tableVisible = ref(true) + +const dialogVisible = ref(false) +const dialogMenu = ref(null) +const dialogType = ref('add') +const dialogParentTitle = ref('') + +// 平铺列表 -> 树(兼容 pid 父子字段) +const buildTree = (list) => { + const nodes = list.map((item) => ({ ...item, children: [] })) + const map = new Map(nodes.map((n) => [n.id, n])) + const roots = [] + nodes.forEach((node) => { + const parent = map.get(node.pid) + if (parent && parent.id !== node.id) { + parent.children.push(node) + } else { + roots.push(node) + } + }) + const prune = (arr) => + arr.forEach((n) => { + if (n.children.length === 0) { + delete n.children + } else { + prune(n.children) + } + }) + prune(roots) + return roots +} + +const menuTree = computed(() => buildTree(menus.value)) +const parentMenuOptions = computed(() => [ + { id: 0, title: '顶级菜单', children: menuTree.value } +]) + +const fetchMenus = async () => { + loading.value = true + try { + const res = await getFrontMenus() + if (res.code === 200) { + menus.value = Array.isArray(res.data) ? res.data : res.data?.list || [] + } else { + ElMessage.error(res.msg || '获取导航菜单失败') + } + } catch (e) { + ElMessage.error('获取导航菜单失败') + } finally { + loading.value = false + } +} + +const toggleExpandAll = async () => { + expandAll.value = !expandAll.value + tableVisible.value = false + await nextTick() + tableVisible.value = true +} + +const handleAddMenu = () => { + dialogType.value = 'add' + dialogMenu.value = { + id: 0, + pid: 0, + title: '', + type: 1, + path: '', + component_path: '', + icon: '', + sort: 0, + status: 1, + is_visible: 1, + permission: '' + } + dialogVisible.value = true +} + +const handleAddSubMenu = (parent) => { + dialogType.value = 'addSub' + dialogParentTitle.value = parent.title + dialogMenu.value = { + id: 0, + pid: parent.id, + title: '', + type: parent.type === 1 ? 2 : parent.type, + path: parent.path || '', + component_path: '', + icon: '', + sort: 0, + status: 1, + is_visible: 1, + permission: '' + } + dialogVisible.value = true +} + +const handleEditMenu = (row) => { + dialogType.value = 'edit' + dialogMenu.value = { ...row } + dialogVisible.value = true +} + +const handleDeleteMenu = async (row) => { + try { + await ElMessageBox.confirm( + `确定要删除菜单「${row.title}」吗?其下所有子菜单也会一并删除。`, + '删除确认', + { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' } + ) + } catch (e) { + return + } + try { + const res = await deleteFrontMenu(row.id) + if (res.code === 200) { + ElMessage.success('删除成功') + fetchMenus() + } else { + ElMessage.error(res.msg || '删除失败') + } + } catch (e) { + ElMessage.error('删除失败') + } +} + +const handleMenuSave = async (menu) => { + // 后端 pid 为整型,统一转换避免类型错误 + const payload = { ...menu } + const pid = parseInt(payload.pid, 10) + payload.pid = Number.isNaN(pid) ? 0 : pid + + const isCreate = dialogType.value !== 'edit' || !payload.id + try { + const res = isCreate + ? await createFrontMenu(payload) + : await editFrontMenu(payload.id, payload) + if (res.code === 200) { + ElMessage.success(res.msg || (isCreate ? '添加成功' : '保存成功')) + dialogVisible.value = false + fetchMenus() + } else { + ElMessage.error(res.msg || '操作失败') + } + } catch (e) { + ElMessage.error('操作失败') + } +} + +onMounted(() => { + fetchMenus() +}) +</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; +} + +.menu-item { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.menu-icon { + width: 18px; + text-align: center; +} +</style> diff --git a/platform/src/views/website/site/page/components/edit.vue b/platform/src/views/website/site/page/components/edit.vue new file mode 100644 index 0000000..b0f4703 --- /dev/null +++ b/platform/src/views/website/site/page/components/edit.vue @@ -0,0 +1,160 @@ +<template> + <el-dialog + :model-value="visible" + :title="dialogTitle" + width="820px" + :close-on-click-modal="false" + destroy-on-close + @update:model-value="handleVisibleChange" + > + <el-form ref="formRef" :model="form" :rules="rules" label-width="90px"> + <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="path"> + <el-input v-model="form.path" placeholder="例如:about、contact、policy/privacy" /> + <div class="field-tip"> + 官网前台的访问路径,只能包含字母、数字、下划线、中划线和斜杠,例如 about 对应 /about。 + 需与「导航菜单」中单页菜单的路由保持一致。 + </div> + </el-form-item> + + <el-form-item label="内容" prop="content"> + <el-input + v-model="form.content" + type="textarea" + :rows="16" + class="content-editor" + placeholder="请输入单页内容,支持 HTML 代码" + /> + <div class="field-tip"> + 支持 HTML 代码与文本内容;也可使用官网模板标签,如 + <code>{yz:sitename}</code>、<code>{yz:companyname}</code>。 + </div> + </el-form-item> + + <el-row :gutter="20"> + <el-col :span="12"> + <el-form-item label="排序" prop="sort"> + <el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" style="width: 100%" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="状态" prop="status"> + <el-switch v-model="form.status" :active-value="1" :inactive-value="0" /> + <span class="field-tip inline">启用后官网前台可访问该单页</span> + </el-form-item> + </el-col> + </el-row> + </el-form> + + <template #footer> + <el-button @click="handleCancel">取消</el-button> + <el-button type="primary" @click="handleSave">确定</el-button> + </template> + </el-dialog> +</template> + +<script setup> +import { ref, reactive, computed, watch } from 'vue' + +const props = defineProps({ + modelValue: { type: Boolean, default: false }, + onepage: { type: Object, default: null } +}) + +const emit = defineEmits(['update:modelValue', 'save']) + +const visible = ref(props.modelValue) +const formRef = ref(null) + +const form = reactive({ + id: 0, + title: '', + path: '', + content: '', + sort: 0, + status: 1 +}) + +const dialogTitle = computed(() => (form.id && form.id > 0 ? '编辑单页' : '添加单页')) + +const rules = { + title: [{ required: true, message: '请输入单页标题', trigger: 'blur' }], + path: [ + { required: true, message: '请输入路由', trigger: 'blur' }, + { + pattern: /^\/?[a-zA-Z0-9/_-]+$/, + message: '路由只能包含字母、数字、下划线、中划线和斜杠', + trigger: 'blur' + } + ], + content: [{ required: true, message: '请输入单页内容', trigger: 'blur' }] +} + +watch( + () => props.modelValue, + (val) => { + visible.value = val + if (!val) return + formRef.value?.clearValidate() + if (props.onepage) { + Object.assign(form, { + id: props.onepage.id ?? 0, + title: props.onepage.title || '', + path: props.onepage.path || '', + content: props.onepage.content || '', + sort: props.onepage.sort ?? 0, + status: props.onepage.status ?? 1 + }) + } else { + Object.assign(form, { id: 0, title: '', path: '', content: '', sort: 0, status: 1 }) + } + } +) + +watch(visible, (val) => { + emit('update:modelValue', val) +}) + +const handleVisibleChange = (val) => { + if (!val) emit('update:modelValue', false) +} + +const handleCancel = () => { + emit('update:modelValue', false) +} + +const handleSave = async () => { + if (!formRef.value) return + await formRef.value.validate((valid) => { + if (!valid) return + // 统一去掉路由开头的斜杠,与后端存储约定保持一致 + const payload = { ...form, path: String(form.path || '').replace(/^\/+/, '') } + emit('save', payload) + }) +} +</script> + +<style lang="less" scoped> +.content-editor { + :deep(.el-textarea__inner) { + font-family: Consolas, Monaco, monospace; + font-size: 13px; + line-height: 1.7; + resize: vertical; + } +} + +.field-tip { + margin-top: 6px; + font-size: 12px; + line-height: 1.7; + color: var(--el-text-color-secondary); + + &.inline { + margin-left: 10px; + } +} +</style> diff --git a/platform/src/views/website/site/page/index.vue b/platform/src/views/website/site/page/index.vue new file mode 100644 index 0000000..8290884 --- /dev/null +++ b/platform/src/views/website/site/page/index.vue @@ -0,0 +1,265 @@ +<template> + <div class="container-box onepage-manage"> + <div class="header-bar"> + <h2>单页管理</h2> + <div class="header-actions"> + <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> + 单页用于「关于我们 / 联系我们 / 隐私政策」等固定内容页面,官网通过 + <code>{yz:onepage path="路由" field="content"/}</code> 调用,也可在「导航菜单」中选择类型为 + <b>单页</b> 的菜单项,路由需与单页路由一致。 + </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.status" + 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="pageList" v-loading="loading" border stripe style="width: 100%"> + <el-table-column type="index" label="序号" width="70" align="center" /> + <el-table-column prop="title" label="标题" min-width="180" show-overflow-tooltip /> + + <el-table-column label="路由" width="200"> + <template #default="{ row }"> + <el-tag type="info" size="small">{{ row.path }}</el-tag> + </template> + </el-table-column> + + <el-table-column label="内容预览" min-width="240" show-overflow-tooltip> + <template #default="{ row }"> + <span class="content-preview">{{ getContentPreview(row.content) }}</span> + </template> + </el-table-column> + + <el-table-column prop="sort" label="排序" width="90" align="center" /> + + <el-table-column label="状态" width="100" align="center"> + <template #default="{ row }"> + <el-tag :type="row.status === 1 ? 'success' : 'info'" size="small"> + {{ row.status === 1 ? '启用' : '禁用' }} + </el-tag> + </template> + </el-table-column> + + <el-table-column label="更新时间" width="180" align="center"> + <template #default="{ row }">{{ formatDateTime(row.update_time || row.create_time) }}</template> + </el-table-column> + + <el-table-column label="操作" width="160" 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="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> + + <PageEdit v-model="dialogVisible" :onepage="dialogPage" @save="handleSave" /> + </div> +</template> + +<script setup> +import { ref, reactive, onMounted } from 'vue' +import { ElMessage, ElMessageBox } from 'element-plus' +import { Plus, Refresh, Search, Edit, Delete } from '@element-plus/icons-vue' +import { formatDateTime } from '@/utils/datetime' +import { getOnePages, createOnePage, editOnePage, deleteOnePage } from '@/api/onepage' +import PageEdit from './components/edit.vue' + +const loading = ref(false) +const rawList = ref([]) +const searchForm = reactive({ keyword: '', status: '' }) + +const dialogVisible = ref(false) +const dialogPage = ref(null) + +// 单页数据量通常较小,接口一次性返回全部,前端做筛选 +const pageList = ref([]) + +const applyFilter = () => { + const keyword = searchForm.keyword.trim().toLowerCase() + pageList.value = rawList.value.filter((item) => { + const matchKeyword = + !keyword || + String(item.title || '').toLowerCase().includes(keyword) || + String(item.path || '').toLowerCase().includes(keyword) + const matchStatus = searchForm.status === '' || item.status === searchForm.status + return matchKeyword && matchStatus + }) +} + +const getContentPreview = (content) => { + if (!content) return '-' + const text = String(content).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim() + return text.length > 80 ? text.slice(0, 80) + '...' : text || '-' +} + +const fetchList = async () => { + loading.value = true + try { + const res = await getOnePages() + if (res.code === 200) { + rawList.value = Array.isArray(res.data) ? res.data : res.data?.list || [] + applyFilter() + } else { + ElMessage.error(res.msg || '获取单页列表失败') + } + } catch (e) { + ElMessage.error('获取单页列表失败') + } finally { + loading.value = false + } +} + +const handleSearch = () => { + applyFilter() +} + +const resetSearch = () => { + searchForm.keyword = '' + searchForm.status = '' + applyFilter() +} + +const handleAdd = () => { + dialogPage.value = null + dialogVisible.value = true +} + +const handleEdit = (row) => { + dialogPage.value = { ...row } + dialogVisible.value = true +} + +const handleDelete = async (row) => { + try { + await ElMessageBox.confirm(`确定要删除单页「${row.title}」吗?`, '删除确认', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning' + }) + } catch (e) { + return + } + try { + const res = await deleteOnePage(row.id) + if (res.code === 200) { + ElMessage.success('删除成功') + fetchList() + } else { + ElMessage.error(res.msg || '删除失败') + } + } catch (e) { + ElMessage.error('删除失败') + } +} + +const handleSave = async (onepage) => { + try { + const res = + onepage.id && onepage.id > 0 + ? await editOnePage(onepage.id, onepage) + : await createOnePage(onepage) + if (res.code === 200) { + ElMessage.success(res.msg || (onepage.id ? '保存成功' : '添加成功')) + dialogVisible.value = false + 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; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.content-preview { + font-family: Consolas, Monaco, monospace; + font-size: 12.5px; + color: var(--el-text-color-regular); + white-space: pre-wrap; +} +</style> diff --git a/platform/src/views/website/setting/components/TagTip.vue b/platform/src/views/website/site/setting/components/TagTip.vue similarity index 100% rename from platform/src/views/website/setting/components/TagTip.vue rename to platform/src/views/website/site/setting/components/TagTip.vue diff --git a/platform/src/views/website/setting/components/tab_basic.vue b/platform/src/views/website/site/setting/components/tab_basic.vue similarity index 94% rename from platform/src/views/website/setting/components/tab_basic.vue rename to platform/src/views/website/site/setting/components/tab_basic.vue index 619182e..b8601c7 100644 --- a/platform/src/views/website/setting/components/tab_basic.vue +++ b/platform/src/views/website/site/setting/components/tab_basic.vue @@ -57,7 +57,7 @@ import { ref } from 'vue' import { ElMessage } from 'element-plus' import { Plus } from '@element-plus/icons-vue' -import { uploadFile } from '@/api/file' +import { uploadToWebsiteMaterial } from '@/utils/websiteUpload' import TagTip from './TagTip.vue' const props = defineProps({ @@ -82,13 +82,9 @@ const previewUrl = (url) => { return API_BASE_URL + url.replace(/^\//, '/') } -// Logo 上传:上传成功后回填图片地址 +// Logo 上传:上传成功后回填图片地址(统一归入「官网素材」分组) const handleUpload = (file, field) => { - const fd = new FormData() - fd.append('file', file.raw) - fd.append('cate', 'site') - - uploadFile(fd).then((res) => { + uploadToWebsiteMaterial(file.raw).then((res) => { if ((res.code === 200 || res.code === 201) && res.data && res.data.url) { props.form[field] = res.data.url.replace(/\\/g, '/') } else { diff --git a/platform/src/views/website/setting/components/tab_company.vue b/platform/src/views/website/site/setting/components/tab_company.vue similarity index 100% rename from platform/src/views/website/setting/components/tab_company.vue rename to platform/src/views/website/site/setting/components/tab_company.vue diff --git a/platform/src/views/website/setting/components/tab_policy.vue b/platform/src/views/website/site/setting/components/tab_policy.vue similarity index 100% rename from platform/src/views/website/setting/components/tab_policy.vue rename to platform/src/views/website/site/setting/components/tab_policy.vue diff --git a/platform/src/views/website/setting/components/tab_record.vue b/platform/src/views/website/site/setting/components/tab_record.vue similarity index 100% rename from platform/src/views/website/setting/components/tab_record.vue rename to platform/src/views/website/site/setting/components/tab_record.vue diff --git a/platform/src/views/website/setting/index.vue b/platform/src/views/website/site/setting/index.vue similarity index 100% rename from platform/src/views/website/setting/index.vue rename to platform/src/views/website/site/setting/index.vue diff --git a/website/src/api/content.ts b/website/src/api/content.ts index 6d04d28..111bd03 100644 --- a/website/src/api/content.ts +++ b/website/src/api/content.ts @@ -1,10 +1,15 @@ import type { + Block, + Category, ContentItem, + CoverTone, ListQuery, ListResult, SectionKey, SinglePage, } from "@/types/content"; +import { sections } from "@/config/sections"; +import { assetUrl, getJSON, postJSON } from "./request"; import { products } from "@/data/products"; import { solutions } from "@/data/solutions"; import { cases } from "@/data/cases"; @@ -14,10 +19,20 @@ import { singlePages } from "@/data/pages"; /** * 内容数据访问层 - * 目前读取本地 mock 数据,接入后端 CMS 后只需把下面几个方法 - * 换成 request.get("/cms/...") 即可,调用方无需改动。 + * + * 已接入后端真实数据的栏目见 REMOTE_SECTIONS(数据来自 platform 端「官网管理」, + * 落库 yz_platform_website_*,由 Go 的 /site/* 接口对外提供)。 + * 其余栏目仍读本地 mock,后续可逐个迁移 —— 接入时只需把栏目 key 加进 + * REMOTE_SECTIONS 并补上对应的 fetchXxx 分支,调用方(list/detail 页)无需改动。 */ +/** 已接入后端接口的栏目 */ +const REMOTE_SECTIONS: SectionKey[] = ["news"]; + +const isRemote = (section: SectionKey): boolean => + REMOTE_SECTIONS.includes(section); + +/** 本地 mock 数据(未接入后端的栏目使用) */ const registry: Record<SectionKey, ContentItem[]> = { news, products, @@ -26,14 +41,121 @@ const registry: Record<SectionKey, ContentItem[]> = { events, }; +/* ============================================================ + * 后端数据 -> 页面内容模型 + * ============================================================ */ + +/** /site/news 系列接口返回的文章结构 */ +interface RemoteArticle { + id: number; + title: string; + summary?: string; + /** 文章自身的封面图 */ + image?: string; + content?: string; + author?: string; + views?: number; + top?: number; + recommend?: number; + date?: string | null; + category_id?: number; + category_name?: string; + category_desc?: string; + /** 所属分类的图片,文章没有封面图时作为兜底 */ + category_image?: string; + tags?: string[]; +} + +interface RemoteListData { + list: RemoteArticle[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} + +interface RemoteCategoryItem { + id: number; + name: string; + desc?: string; + image?: string; + count: number; +} + +/** 封面配色池:后端数据没有配色字段,按内容ID稳定取色,避免每次渲染跳色 */ +const TONES: CoverTone[] = ["navy", "blue", "teal", "indigo"]; +const toneOf = (id: number): CoverTone => + TONES[Math.abs(id || 0) % TONES.length]; + +/** 后端时间(RFC3339)-> YYYY-MM-DD */ +function formatDate(value?: string | null): string { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return String(value).slice(0, 10); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + +/** 后端文章 -> 页面内容模型 */ +function toContentItem(raw: RemoteArticle, withContent = false): ContentItem { + const blocks: Block[] = []; + // 正文由平台「文章管理」用富文本编辑器录入,这里整体作为一个 html 区块交给渲染器 + if (withContent && raw.content) { + blocks.push({ type: "html", html: raw.content }); + } + + return { + slug: String(raw.id), + section: "news", + category: raw.category_id ? String(raw.category_id) : "", + categoryName: raw.category_name || "", + title: raw.title || "", + // 后端没有独立的副标题字段,列表卡片的 eyebrow 留空,不编造内容 + subtitle: "", + summary: raw.summary || "", + tone: toneOf(raw.id), + date: formatDate(raw.date), + author: raw.author || "", + tags: raw.tags ?? [], + featured: raw.recommend === 1 || raw.top === 1, + // 封面兜底链:文章封面 -> 所属分类图片 -> 空(为空时 ContentCard 按 tone 渲染渐变占位) + image: assetUrl(raw.image) || assetUrl(raw.category_image), + blocks, + }; +} + +/* ============================================================ + * 对外方法 + * ============================================================ */ + /** 模拟一次异步请求,保证调用方始终按异步处理 */ const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0)); /** 列表 / 分类 / 搜索(带分页) */ export async function fetchList(query: ListQuery): Promise<ListResult> { - await tick(); - const { section, category, keyword, page = 1, pageSize = 6 } = query; + + if (isRemote(section)) { + const data = await getJSON<RemoteListData>("/site/news", { + page, + pageSize, + category, + keyword, + }); + // 接口异常时不回落 mock:官网展示真实数据,无数据就展示空态,便于发现问题 + if (!data) { + return { list: [], total: 0, page, pageSize, totalPages: 1 }; + } + return { + list: (data.list ?? []).map((raw) => toContentItem(raw)), + total: data.total ?? 0, + page: data.page ?? page, + pageSize: data.pageSize ?? pageSize, + totalPages: data.totalPages ?? 1, + }; + } + + await tick(); let list = [...(registry[section] ?? [])]; if (category) { @@ -71,6 +193,16 @@ export async function fetchDetail( section: SectionKey, slug: string ): Promise<ContentItem | undefined> { + if (isRemote(section)) { + const data = await getJSON<RemoteArticle>( + `/site/news/${encodeURIComponent(slug)}` + ); + if (!data) return undefined; + // 浏览量 +1,失败不影响正文展示 + void postJSON(`/site/news/${encodeURIComponent(slug)}/view`); + return toContentItem(data, true); + } + await tick(); return (registry[section] ?? []).find((item) => item.slug === slug); } @@ -81,8 +213,15 @@ export async function fetchRelated( slug: string, limit = 3 ): Promise<ContentItem[]> { - await tick(); + if (isRemote(section)) { + const data = await getJSON<{ list: RemoteArticle[] }>( + `/site/news/${encodeURIComponent(slug)}/related`, + { limit } + ); + return (data?.list ?? []).map((raw) => toContentItem(raw)); + } + await tick(); const all = registry[section] ?? []; const current = all.find((item) => item.slug === slug); if (!current) return all.slice(0, limit); @@ -97,12 +236,23 @@ export async function fetchRelated( return [...sameCategory, ...others].slice(0, limit); } -/** 统计各分类下的内容数量,用于筛选条上的角标 */ +/** 统计各分类下的内容数量,用于筛选条上的角标;__all__ 为总数 */ export async function fetchCategoryCounts( section: SectionKey ): Promise<Record<string, number>> { - await tick(); + if (isRemote(section)) { + const counts: Record<string, number> = {}; + const data = await getJSON<{ list: RemoteCategoryItem[]; total: number }>( + "/site/news/categories" + ); + (data?.list ?? []).forEach((item) => { + counts[String(item.id)] = item.count ?? 0; + }); + counts.__all__ = data?.total ?? 0; + return counts; + } + await tick(); const counts: Record<string, number> = {}; (registry[section] ?? []).forEach((item) => { counts[item.category] = (counts[item.category] ?? 0) + 1; @@ -111,6 +261,24 @@ export async function fetchCategoryCounts( return counts; } +/** + * 分类列表:已接入后端的栏目取后端分类,其余取 config/sections 的静态配置。 + * 分类 slug 统一用字符串,便于路由参数传递。 + */ +export async function fetchCategories(section: SectionKey): Promise<Category[]> { + if (isRemote(section)) { + 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 sections[section].categories; +} + /** 单页:key 为去掉前导斜杠的路径,例如 about、support/docs */ export async function fetchSinglePage( key: string diff --git a/website/src/api/request.ts b/website/src/api/request.ts new file mode 100644 index 0000000..9f87043 --- /dev/null +++ b/website/src/api/request.ts @@ -0,0 +1,69 @@ +/** + * 官网 SPA 轻量请求封装 + * + * 基地址规则与 api/site.ts 保持一致: + * 开发期 .env.development 的 VITE_API_BASE_URL 指向本地 Go 服务(http://localhost:9000), + * 未配置时回落 /api,由 vite 代理到后端。 + * + * 约定:所有接口返回后端统一结构 { code, msg, data }, + * 这里只把 data 交给调用方;请求失败或 code !== 200 时返回 null, + * 由调用方决定展示空态还是降级,避免异常直接冒泡到页面。 + */ + +const API_BASE = ( + (import.meta as any).env?.VITE_API_BASE_URL || "/api" +).replace(/\/$/, ""); + +/** 拼接口完整地址 */ +export function apiUrl(path: string): string { + return `${API_BASE}${path.startsWith("/") ? "" : "/"}${path}`; +} + +/** + * 后端返回的资源地址补全为可访问地址。 + * 图片等资源存的是 /uploads/... 相对路径,需要拼上接口域名。 + */ +export function assetUrl(path?: string): string { + if (!path) return ""; + if (/^https?:\/\//i.test(path)) return path; + return apiUrl(path); +} + +/** GET 请求,返回 data;失败返回 null */ +export async function getJSON<T>( + path: string, + params?: Record<string, unknown> +): Promise<T | null> { + try { + const qs = new URLSearchParams(); + Object.entries(params ?? {}).forEach(([key, value]) => { + if (value === undefined || value === null || value === "") return; + qs.append(key, String(value)); + }); + const query = qs.toString(); + const res = await fetch(apiUrl(path) + (query ? `?${query}` : ""), { + headers: { Accept: "application/json" }, + }); + if (!res.ok) return null; + const body = await res.json(); + if (body?.code !== 200) return null; + return (body.data ?? null) as T | null; + } catch (error) { + console.warn(`[api] GET ${path} 请求失败`, error); + return null; + } +} + +/** POST 请求(无请求体),用于浏览量累加等副作用接口;失败静默 */ +export async function postJSON(path: string): Promise<boolean> { + try { + const res = await fetch(apiUrl(path), { + method: "POST", + headers: { Accept: "application/json" }, + }); + return res.ok; + } catch (error) { + console.warn(`[api] POST ${path} 请求失败`, error); + return false; + } +} diff --git a/website/src/components/BackTop.vue b/website/src/components/BackTop.vue new file mode 100644 index 0000000..8f45f48 --- /dev/null +++ b/website/src/components/BackTop.vue @@ -0,0 +1,116 @@ +<script setup lang="ts"> +/** + * 全站「回到顶部」按钮 + * + * 已在 main.ts 注册为全局组件,DefaultLayout 中渲染一次即对全站生效。 + * 页面滚动超过 SHOW_OFFSET 后淡入,点击平滑回到页面顶部。 + */ +import { onBeforeUnmount, onMounted, ref } from "vue"; + +/** 页面滚动超过该距离才显示按钮 */ +const SHOW_OFFSET = 320; + +const visible = ref(false); + +const onScroll = () => { + visible.value = window.scrollY > SHOW_OFFSET; +}; + +const toTop = () => { + // html 上已有 scroll-behavior: smooth,这里显式声明以避免被局部样式覆盖 + window.scrollTo({ top: 0, behavior: "smooth" }); +}; + +onMounted(() => { + onScroll(); + window.addEventListener("scroll", onScroll, { passive: true }); +}); + +onBeforeUnmount(() => { + window.removeEventListener("scroll", onScroll); +}); +</script> + +<template> + <transition name="backtop"> + <button + v-show="visible" + class="backtop" + type="button" + title="回到顶部" + aria-label="回到顶部" + @click="toTop" + > + <svg viewBox="0 0 24 24" aria-hidden="true"> + <path + d="M12 19.5V5m0 0-6.4 6.4M12 5l6.4 6.4" + fill="none" + stroke="currentColor" + stroke-width="1.9" + stroke-linecap="round" + stroke-linejoin="round" + /> + </svg> + </button> + </transition> +</template> + +<style scoped lang="less"> +.backtop { + position: fixed; + right: 36px; + bottom: 44px; + z-index: 60; + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 50%; + color: @white; + background: linear-gradient(140deg, @navy-700, @navy-900); + box-shadow: 0 12px 28px rgba(11, 20, 48, 0.26); + transition: transform 0.25s ease, box-shadow 0.25s ease; + + svg { + width: 20px; + height: 20px; + } + + &:hover { + transform: translateY(-3px); + background: linear-gradient(140deg, @blue-600, @navy-700); + box-shadow: 0 16px 34px rgba(47, 107, 243, 0.34); + } + + &:focus-visible { + outline: 2px solid @blue-600; + outline-offset: 3px; + } +} + +.backtop-enter-active, +.backtop-leave-active { + transition: opacity 0.28s ease, transform 0.28s ease; +} + +.backtop-enter-from, +.backtop-leave-to { + opacity: 0; + transform: translateY(14px) scale(0.9); +} + +@media (max-width: 768px) { + .backtop { + right: 18px; + bottom: 24px; + width: 42px; + height: 42px; + + svg { + width: 17px; + height: 17px; + } + } +} +</style> diff --git a/website/src/components/BlockRenderer.vue b/website/src/components/BlockRenderer.vue index c2c2bfe..bda7d45 100644 --- a/website/src/components/BlockRenderer.vue +++ b/website/src/components/BlockRenderer.vue @@ -8,8 +8,15 @@ defineProps<{ blocks: Block[] }>(); <template> <div class="blocks"> <template v-for="(block, index) in blocks" :key="index"> + <!-- 富文本正文:后端(平台「文章管理」)录入的 HTML 原样渲染 --> + <div + v-if="block.type === 'html'" + class="blk-html" + v-html="block.html" + ></div> + <!-- 小标题 --> - <header v-if="block.type === 'heading'" class="blk-heading"> + <header v-else-if="block.type === 'heading'" class="blk-heading"> <h2>{{ block.text }}</h2> <p v-if="block.sub">{{ block.sub }}</p> </header> @@ -543,12 +550,176 @@ defineProps<{ blocks: Block[] }>(); } } +/* ---------------- 富文本正文 ---------------- */ +.blk-html { + font-size: 15.5px; + line-height: 2.05; + color: @ink-600; + word-break: break-word; + + > :first-child { + margin-top: 0; + } + + h1, + h2, + h3, + h4 { + margin: 34px 0 14px; + padding-left: 16px; + border-left: 3px solid @blue-600; + font-weight: 700; + line-height: 1.5; + color: @ink-900; + } + + h1 { + font-size: 24px; + } + + h2 { + font-size: 22px; + } + + h3 { + font-size: 18px; + } + + h4 { + font-size: 16px; + } + + p { + margin: 16px 0; + } + + a { + color: @blue-600; + text-decoration: underline; + + &:hover { + opacity: 0.82; + } + } + + strong { + font-weight: 700; + color: @ink-800; + } + + ul, + ol { + margin: 16px 0; + padding-left: 26px; + + li { + margin: 8px 0; + line-height: 1.95; + } + } + + ul li { + list-style: disc; + } + + ol li { + list-style: decimal; + } + + img { + max-width: 100%; + height: auto; + margin: 18px 0; + border-radius: 10px; + } + + blockquote { + margin: 22px 0; + padding: 22px 26px; + border-radius: 12px; + border-left: 3px solid @blue-600; + background: linear-gradient(140deg, #f5f8ff 0%, #eef3fd 100%); + color: @ink-800; + } + + hr { + margin: 30px 0; + border: 0; + border-top: 1px solid @line; + } + + pre { + margin: 18px 0; + padding: 18px 22px; + border-radius: 10px; + background: @bg-soft; + overflow-x: auto; + font-size: 13.5px; + line-height: 1.75; + } + + code { + padding: 2px 7px; + border-radius: 4px; + background: @bg-soft; + font-family: Consolas, Monaco, "Courier New", monospace; + font-size: 13.5px; + color: @ink-800; + } + + pre code { + padding: 0; + background: transparent; + } + + table { + width: 100%; + margin: 20px 0; + border-collapse: collapse; + font-size: 14px; + + th, + td { + padding: 13px 18px; + border: 1px solid @line; + text-align: left; + line-height: 1.75; + } + + th { + background: @bg-soft; + font-weight: 600; + color: @ink-600; + } + } +} + /* ---------------- 响应式 ---------------- */ @media (max-width: 768px) { .blocks { gap: 24px; } + .blk-html { + font-size: 14.5px; + line-height: 1.95; + + h1, + h2, + h3, + h4 { + margin-top: 26px; + } + + h1 { + font-size: 21px; + } + + h2 { + font-size: 19px; + } + } + .blk-list, .blk-features, .blk-steps, diff --git a/website/src/components/ContentCard.vue b/website/src/components/ContentCard.vue index 1594cac..4e2d63a 100644 --- a/website/src/components/ContentCard.vue +++ b/website/src/components/ContentCard.vue @@ -17,8 +17,10 @@ const to = computed( () => `${sections[props.item.section].detailPath}/${props.item.slug}` ); -const categoryLabel = computed(() => - categoryName(props.item.section, props.item.category) +// 后端数据自带分类名;本地 mock 走 config/sections 查表 +const categoryLabel = computed( + () => + props.item.categoryName || categoryName(props.item.section, props.item.category) ); /** 封面上的装饰文字:取标题首字,中文视觉上很像一个字标 */ @@ -28,8 +30,18 @@ const glyph = computed(() => props.item.title.slice(0, 1)); <template> <RouterLink :to="to" class="content-card" :class="`content-card--${layout}`"> <div class="content-card__cover" :class="`tone-${item.tone}`"> - <span class="content-card__deco" aria-hidden="true"></span> - <span class="content-card__glyph" aria-hidden="true">{{ glyph }}</span> + <!-- 有真实封面图时优先展示,否则用渐变 + 首字占位 --> + <img + v-if="item.image" + class="content-card__img" + :src="item.image" + :alt="item.title" + loading="lazy" + /> + <template v-else> + <span class="content-card__deco" aria-hidden="true"></span> + <span class="content-card__glyph" aria-hidden="true">{{ glyph }}</span> + </template> <span class="content-card__badge">{{ categoryLabel }}</span> </div> @@ -93,6 +105,19 @@ const glyph = computed(() => props.item.title.slice(0, 1)); color: @white; } + &__img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.6s cubic-bezier(0.22, 0.61, 0.36, 1); + } + + &:hover &__img { + transform: scale(1.05); + } + &__deco { position: absolute; right: -34px; diff --git a/website/src/layout/SiteFooter.vue b/website/src/components/SiteFooter.vue similarity index 100% rename from website/src/layout/SiteFooter.vue rename to website/src/components/SiteFooter.vue diff --git a/website/src/layout/SiteHeader.vue b/website/src/components/SiteHeader.vue similarity index 60% rename from website/src/layout/SiteHeader.vue rename to website/src/components/SiteHeader.vue index 78072fc..213b71d 100644 --- a/website/src/layout/SiteHeader.vue +++ b/website/src/components/SiteHeader.vue @@ -1,7 +1,9 @@ <script setup lang="ts"> import { onBeforeUnmount, onMounted, ref, watch } from "vue"; import { useRoute } from "vue-router"; -import { navItems, siteInfo, utilityLinks } from "@/config/site"; +import { siteInfo, utilityLinks } from "@/config/site"; +// 页头主导航来自后端「官网管理 - 导航菜单」,未配置时回退 config/site.ts 的静态菜单 +import { navMenus } from "@/config/nav"; const route = useRoute(); @@ -28,6 +30,13 @@ watch( menuOpen.value = false; } ); + +// 点击「首页」:已在首页时属于重复导航,不会触发 router 的滚动重置,这里手动回到顶部 +const onHomeClick = () => { + if (route.path === "/") { + window.scrollTo({ top: 0, behavior: "smooth" }); + } +}; </script> <template> @@ -65,14 +74,48 @@ watch( </RouterLink> <nav class="main-nav"> - <RouterLink - v-for="item in navItems" - :key="item.path" - :to="item.path" - class="main-nav__link" + <!-- 静态首页入口:固定在最左,不受后台「导航菜单」配置影响 --> + <div class="main-nav__item"> + <RouterLink to="/" class="main-nav__link is-home" @click="onHomeClick">首页</RouterLink> + </div> + + <div + v-for="item in navMenus" + :key="item.key" + class="main-nav__item" + :class="{ 'is-parent': item.children.length > 0 }" > - {{ item.label }} - </RouterLink> + <!-- 外链菜单新窗口打开 --> + <a + v-if="item.external" + :href="item.path" + target="_blank" + rel="noopener" + class="main-nav__link" + > + {{ item.label }} + </a> + <RouterLink v-else :to="item.path" class="main-nav__link"> + {{ item.label }} + </RouterLink> + + <!-- 二级菜单:后台「目录」类型下配置的子菜单 --> + <div v-if="item.children.length" class="main-nav__sub"> + <template v-for="child in item.children" :key="child.key"> + <a + v-if="child.external" + :href="child.path" + target="_blank" + rel="noopener" + > + {{ child.label }} + </a> + <RouterLink v-else :to="child.path"> + {{ child.label }} + </RouterLink> + </template> + </div> + </div> </nav> <button @@ -94,14 +137,42 @@ watch( <transition name="drawer"> <nav v-show="menuOpen" class="mobile-nav"> <div class="container"> - <RouterLink - v-for="item in navItems" - :key="item.path" - :to="item.path" - class="mobile-nav__link" - > - {{ item.label }} - </RouterLink> + <!-- 静态首页入口:与主导航保持一致,固定在最前 --> + <RouterLink to="/" class="mobile-nav__link" @click="onHomeClick">首页</RouterLink> + + <template v-for="item in navMenus" :key="item.key"> + <a + v-if="item.external" + :href="item.path" + target="_blank" + rel="noopener" + class="mobile-nav__link" + > + {{ item.label }} + </a> + <RouterLink v-else :to="item.path" class="mobile-nav__link"> + {{ item.label }} + </RouterLink> + + <template v-for="child in item.children" :key="child.key"> + <a + v-if="child.external" + :href="child.path" + target="_blank" + rel="noopener" + class="mobile-nav__link mobile-nav__link--sub" + > + {{ child.label }} + </a> + <RouterLink + v-else + :to="child.path" + class="mobile-nav__link mobile-nav__link--sub" + > + {{ child.label }} + </RouterLink> + </template> + </template> <div class="mobile-nav__util"> <RouterLink v-for="item in utilityLinks" @@ -274,6 +345,61 @@ watch( transform: scaleX(1); } } + + /* + * 静态「首页」入口:to="/" 的 router-link-active 会命中所有子路由, + * 因此仅在精确匹配或悬停时才高亮,避免首页菜单全程处于选中态。 + */ + &.is-home.router-link-active:not(.router-link-exact-active):not(:hover) { + color: @ink-800; + + &::after { + transform: scaleX(0); + } + } + } + + /* 一级菜单项容器:作为二级下拉的定位参照 */ + &__item { + position: relative; + + &:hover > .main-nav__sub { + opacity: 1; + visibility: visible; + transform: translate(-50%, 0); + } + } + + /* 二级菜单(后台「目录」菜单下的子菜单) */ + &__sub { + position: absolute; + left: 50%; + top: 100%; + z-index: 30; + min-width: 172px; + padding: 8px 0; + border-radius: 0 0 10px 10px; + background: @white; + box-shadow: 0 18px 40px rgba(11, 20, 48, 0.14); + opacity: 0; + visibility: hidden; + transform: translate(-50%, 8px); + transition: opacity 0.22s ease, transform 0.22s ease, + visibility 0.22s ease; + + a { + display: block; + padding: 10px 22px; + font-size: 14.5px; + color: @ink-700; + white-space: nowrap; + transition: color 0.2s ease, background-color 0.2s ease; + + &:hover { + background: @bg-soft; + color: @blue-600; + } + } } } @@ -322,6 +448,14 @@ watch( font-weight: 500; color: @ink-800; border-bottom: 1px solid @line; + + /* 二级菜单:缩进以体现层级 */ + &--sub { + padding-left: 22px; + font-size: 15px; + font-weight: 400; + color: @ink-600; + } } &__util { diff --git a/website/src/config/nav.ts b/website/src/config/nav.ts new file mode 100644 index 0000000..0f1ae91 --- /dev/null +++ b/website/src/config/nav.ts @@ -0,0 +1,77 @@ +/** + * 页头导航菜单配置 + * + * 数据来源:后端「官网管理 - 导航菜单」(yz_platform_website_nav), + * 由 GET /site/nav 下发(仅启用且显示中的菜单,已按 pid 组好树)。 + * 接口异常或后台未配置任何菜单时,回退到 config/site.ts 里的静态 navItems, + * 保证页头导航不会为空。 + * + * 菜单类型(与平台端一致): + * 1 目录 仅分组,本身可带 path;配置了子菜单时页头渲染为下拉 + * 2 页面 路由地址 + * 3 外链 完整 URL,新窗口打开 + * 4 单页 路由地址(内容取自「单页管理」) + */ +import { ref } from "vue"; +import { getJSON } from "@/api/request"; +import { navItems } from "./site"; + +export interface NavMenuItem { + /** v-for key:后端菜单用 nav-{id},静态回退用 static-{path} */ + key: string; + label: string; + path: string; + /** 是否站外链接:后台菜单类型为「外链」或 path 填写了完整 URL */ + external: boolean; + children: NavMenuItem[]; +} + +/** /site/nav 返回的节点结构 */ +interface RemoteNavNode { + id: number; + pid: number; + title: string; + type: number; + path: string; + icon?: string; + external?: boolean; + children?: RemoteNavNode[]; +} + +const isExternalPath = (path: string): boolean => /^https?:\/\//i.test(path); + +function toMenuItem(node: RemoteNavNode): NavMenuItem { + const path = node.path || "/"; + return { + key: `nav-${node.id}`, + label: node.title || "", + path, + external: node.external ?? (node.type === 3 || isExternalPath(path)), + children: (node.children ?? []).map(toMenuItem), + }; +} + +/** 静态回退菜单:后台未配置时使用 */ +const fallbackMenus = (): NavMenuItem[] => + navItems.map((item) => ({ + key: `static-${item.path}`, + label: item.label, + path: item.path, + external: isExternalPath(item.path), + children: [], + })); + +/** 页头导航菜单(应用启动时由 loadNavMenus 拉取,初始为静态回退) */ +export const navMenus = ref<NavMenuItem[]>(fallbackMenus()); + +/** + * 拉取后台配置的导航菜单(应用启动时调用一次)。 + * 接口异常或后台未配置时保持静态回退菜单,不置空。 + */ +export async function loadNavMenus(): Promise<void> { + const data = await getJSON<{ list: RemoteNavNode[] }>("/site/nav"); + const list = data?.list ?? []; + if (list.length > 0) { + navMenus.value = list.map(toMenuItem); + } +} diff --git a/website/src/layout/DefaultLayout.vue b/website/src/layout/DefaultLayout.vue index 8e15d6c..10f2f4d 100644 --- a/website/src/layout/DefaultLayout.vue +++ b/website/src/layout/DefaultLayout.vue @@ -1,6 +1,10 @@ <script setup lang="ts"> -import SiteHeader from "./SiteHeader.vue"; -import SiteFooter from "./SiteFooter.vue"; +/** + * 官网根布局:页头 + 路由出口 + 页脚 + * + * SiteHeader / SiteFooter 已在 main.ts 注册为全局组件, + * 本文件(以及任意页面)直接使用标签即可,无需 import。 + */ </script> <template> @@ -10,6 +14,8 @@ import SiteFooter from "./SiteFooter.vue"; <RouterView /> </main> <SiteFooter /> + <!-- 全站回到顶部 --> + <BackTop /> </div> </template> diff --git a/website/src/main.ts b/website/src/main.ts index 0c32107..9de1036 100644 --- a/website/src/main.ts +++ b/website/src/main.ts @@ -3,15 +3,27 @@ import App from "./App.vue"; import router from "./router"; // 配置路由 import store from "./store"; // 配置vuex import { loadSiteInfo } from "./config/site"; // 站点设置:品牌/Logo/备案/版权/隐私政策等(与官网模板标签同源) +import { loadNavMenus } from "./config/nav"; // 页头导航菜单(后台「官网管理 - 导航菜单」) import { yz } from "./directives/yz"; // {yz:xxx} 站点标签解析指令 +// 全局组件:官网页头 / 页脚 / 回到顶部 +import SiteHeader from "./components/SiteHeader.vue"; +import SiteFooter from "./components/SiteFooter.vue"; +import BackTop from "./components/BackTop.vue"; import "./styles/index.less"; const app = createApp(App); +// 注册全局组件:任意页面直接写 <SiteHeader /> / <SiteFooter /> / <BackTop /> 即可,无需各自 import +app.component("SiteHeader", SiteHeader); +app.component("SiteFooter", SiteFooter); +app.component("BackTop", BackTop); + // 注册 {yz:xxx} 标签指令:模板里写 <p v-yz>{yz:icp}</p> 即可 app.directive("yz", yz); // 站点设置先于渲染发起,接口异常时静默回退到 config/site.ts 内的默认配置 void loadSiteInfo(); +// 页头导航菜单:接口异常或后台未配置时回退静态菜单 +void loadNavMenus(); app.use(router).use(store).mount("#app"); diff --git a/website/src/router/index.ts b/website/src/router/index.ts index 1f58728..99d6672 100644 --- a/website/src/router/index.ts +++ b/website/src/router/index.ts @@ -173,6 +173,15 @@ const routes: RouteRecordRaw[] = [ meta: { title: "政策条款" }, }, + // ---------------- 兼容后台「导航菜单」中配置的旧路径 ---------------- + // 平台端「官网管理 - 导航菜单」当前配的是 CMS 风格路径,与 SPA 实际路由不一致, + // 这里做一层重定向,保证点菜单能落到正确页面。 + // 推荐把导航菜单的路径改成 /news、/products、/about(/solutions、/cases 本来就一致), + // 改完后以下 3 条即可删除。 + { path: "newscenter", redirect: "/news" }, + { path: "productcenter", redirect: "/products" }, + { path: "aboutus", redirect: "/about" }, + // ---------------- 兜底 ---------------- { path: ":pathMatch(.*)*", diff --git a/website/src/types/content.ts b/website/src/types/content.ts index 45a3f6f..9630999 100644 --- a/website/src/types/content.ts +++ b/website/src/types/content.ts @@ -53,6 +53,8 @@ export interface QuickLink { /** 正文区块:详情页与单页共用同一套渲染器 */ export type Block = + /** 后端富文本正文(平台「文章管理」录入的 HTML),由 BlockRenderer 原样渲染 */ + | { type: "html"; html: string } | { type: "heading"; text: string; sub?: string } | { type: "paragraph"; text: string } | { type: "quote"; text: string; author?: string } @@ -69,8 +71,10 @@ export type Block = export interface ContentItem { slug: string; section: SectionKey; - /** 分类 slug */ + /** 分类 slug(接入后端真实数据时为分类ID的字符串形式) */ category: string; + /** 分类展示名:后端数据直接带回,本地 mock 走 config/sections 查表 */ + categoryName?: string; title: string; subtitle: string; summary: string; @@ -79,6 +83,11 @@ export interface ContentItem { author?: string; tags?: string[]; featured?: boolean; + /** + * 封面图完整地址。 + * 兜底链:文章封面 -> 所属分类的图片 -> 空(为空时按 tone 渲染渐变占位封面) + */ + image?: string; blocks: Block[]; } diff --git a/website/src/types/global-components.d.ts b/website/src/types/global-components.d.ts new file mode 100644 index 0000000..504637a --- /dev/null +++ b/website/src/types/global-components.d.ts @@ -0,0 +1,16 @@ +/** + * 全局组件类型声明 + * + * 与 main.ts 中 app.component(...) 的注册名保持一致, + * 让模板里的 <SiteHeader /> / <SiteFooter /> 获得类型提示与跳转。 + * 新增全局组件时,两处都要补上。 + */ +import type SiteHeader from "@/components/SiteHeader.vue"; +import type SiteFooter from "@/components/SiteFooter.vue"; + +declare module "vue" { + export interface GlobalComponents { + SiteHeader: typeof SiteHeader; + SiteFooter: typeof SiteFooter; + } +} diff --git a/website/src/views/detail.vue b/website/src/views/detail.vue index b6fdcc2..b137737 100644 --- a/website/src/views/detail.vue +++ b/website/src/views/detail.vue @@ -31,9 +31,16 @@ const item = ref<ContentItem | null>(null); const related = ref<ContentItem[]>([]); const loading = ref(true); +/** 分类展示名:后端数据直接带回,本地 mock 走 config/sections 查表 */ +const categoryLabel = computed(() => + item.value + ? item.value.categoryName || categoryName(section.value.key, item.value.category) + : "" +); + const crumbs = computed(() => item.value - ? [section.value.title, categoryName(section.value.key, item.value.category)] + ? [section.value.title, categoryLabel.value] : [section.value.title] ); @@ -133,7 +140,7 @@ watch(() => `${section.value.key}|${slug.value}`, load, { immediate: true }); <li> <span>分类</span> <RouterLink :to="backPath"> - {{ categoryName(section.key, item.category) }} + {{ categoryLabel }} </RouterLink> </li> <li v-if="item.date"> diff --git a/website/src/views/home/components/HeroBanner.vue b/website/src/views/home/components/HeroBanner.vue index 6d199b6..0d1d459 100644 --- a/website/src/views/home/components/HeroBanner.vue +++ b/website/src/views/home/components/HeroBanner.vue @@ -4,35 +4,35 @@ import { siteInfo } from "@/config/site"; </script> <template> - <section class="hero"> - <div class="hero__media"> + <section class="banner"> + <div class="banner__media"> <img :src="heroImage" alt="云泽科技团队协作场景" /> </div> - <div class="hero__veil"></div> + <div class="banner__veil"></div> - <div class="container hero__inner"> + <div class="container banner__inner"> <!-- 站点设置 - 站点名称({yz:sitename}) --> - <h1 class="hero__title">{{ siteInfo.brandName }}</h1> + <h1 class="banner__title">{{ siteInfo.brandName }}</h1> - <p class="hero__desc"> + <p class="banner__desc"> 以云计算与数据智能为核心,为企业提供从咨询规划、产品研发到落地运维的<br /> 一站式数字化解决方案,让技术真正服务于业务增长。 </p> - <div class="hero__rule"></div> + <div class="banner__rule"></div> - <div class="hero__meta"> - <div class="hero__stat"> + <div class="banner__meta"> + <div class="banner__stat"> <strong>12 年</strong> - <span class="hero__stat-label">行业深耕</span> + <span class="banner__stat-label">行业深耕</span> <em>服务 1000+ 企业客户</em> </div> - <div class="hero__divider"></div> + <div class="banner__divider"></div> - <div class="hero__stat hero__stat--contact"> - <span class="hero__stat-icon" aria-hidden="true"> + <div class="banner__stat banner__stat--contact"> + <span class="banner__stat-icon" aria-hidden="true"> <svg viewBox="0 0 24 24"> <path d="M6.6 3.5h2.6l1.5 3.7-1.9 1.3a12.4 12.4 0 0 0 5.7 5.7l1.3-1.9 3.7 1.5v2.6a2 2 0 0 1-2.2 2C10.7 17.7 6.3 13.3 4.6 5.7a2 2 0 0 1 2-2.2Z" @@ -44,7 +44,7 @@ import { siteInfo } from "@/config/site"; </svg> </span> <strong>400-000-0000</strong> - <span class="hero__stat-label">售前咨询专线</span> + <span class="banner__stat-label">售前咨询专线</span> <em>工作日 9:00 - 18:00</em> </div> </div> @@ -53,7 +53,7 @@ import { siteInfo } from "@/config/site"; </template> <style scoped lang="less"> -.hero { +.banner { position: relative; overflow: hidden; background: @navy-900; @@ -194,7 +194,7 @@ import { siteInfo } from "@/config/site"; letter-spacing: 0.02em; } - .hero__stat-label { + .banner__stat-label { margin-top: 0; } @@ -232,7 +232,7 @@ import { siteInfo } from "@/config/site"; /* ---------- 响应式 ---------- */ @media (max-width: 1200px) { - .hero { + .banner { &__title { font-size: 58px; } @@ -254,7 +254,7 @@ import { siteInfo } from "@/config/site"; } @media (max-width: 768px) { - .hero { + .banner { &__inner { min-height: auto; padding-top: 64px; diff --git a/website/src/views/list.vue b/website/src/views/list.vue index 7e69889..37cb324 100644 --- a/website/src/views/list.vue +++ b/website/src/views/list.vue @@ -1,9 +1,9 @@ <script setup lang="ts"> import { computed, ref, watch } from "vue"; import { useRoute, useRouter } from "vue-router"; -import type { ContentItem } from "@/types/content"; -import { categoryName, sections } from "@/config/sections"; -import { fetchCategoryCounts, fetchList } from "@/api/content"; +import type { Category, ContentItem } from "@/types/content"; +import { sections } from "@/config/sections"; +import { fetchCategories, fetchCategoryCounts, fetchList } from "@/api/content"; import PageHero from "@/components/PageHero.vue"; import FilterBar from "@/components/FilterBar.vue"; import ContentCard from "@/components/ContentCard.vue"; @@ -35,27 +35,35 @@ const counts = ref<Record<string, number>>({}); const total = ref(0); const totalPages = ref(1); const loading = ref(true); +/** + * 分类列表:已接入后端真实数据的栏目由接口返回,其余取 config/sections 静态配置。 + * 初始为空,避免先闪出静态分类再被接口数据替换。 + */ +const categories = ref<Category[]>([]); /** 瀑布式列表每页 6 条,卡片网格每页 9 条 */ const pageSize = computed(() => (section.value.layout === "grid" ? 9 : 6)); +/** 当前选中的分类(来自接口或静态配置) */ +const activeCategoryItem = computed(() => + activeCategory.value + ? categories.value.find((item) => item.slug === activeCategory.value) + : undefined +); + const heroTitle = computed(() => activeCategory.value - ? categoryName(section.value.key, activeCategory.value) + ? activeCategoryItem.value?.name || section.value.title : section.value.title ); -const heroSubtitle = computed(() => { - if (!activeCategory.value) return section.value.subtitle; - return ( - section.value.categories.find((item) => item.slug === activeCategory.value) - ?.desc || section.value.subtitle - ); -}); +const heroSubtitle = computed( + () => activeCategoryItem.value?.desc || section.value.subtitle +); const crumbs = computed(() => activeCategory.value - ? [section.value.title, categoryName(section.value.key, activeCategory.value)] + ? [section.value.title, heroTitle.value] : [section.value.title] ); @@ -65,7 +73,7 @@ async function load() { loading.value = true; const current = section.value; - const [result, categoryCounts] = await Promise.all([ + const [result, categoryCounts, categoryList] = await Promise.all([ fetchList({ section: current.key, category: activeCategory.value || undefined, @@ -74,12 +82,14 @@ async function load() { pageSize: pageSize.value, }), fetchCategoryCounts(current.key), + fetchCategories(current.key), ]); items.value = result.list; total.value = result.total; totalPages.value = result.totalPages; counts.value = categoryCounts; + categories.value = categoryList; loading.value = false; } @@ -108,7 +118,7 @@ function handleKeywordChange(value: string) { <div class="container list-page__body"> <FilterBar - :categories="section.categories" + :categories="categories" :active="activeCategory" :counts="counts" :total="counts.__all__ ?? 0"