增加网站管理功能
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<host> -P<port> -u<user> -p <db> < 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;
|
||||
@@ -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=已回款
|
||||
@@ -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='回款计划表';
|
||||
@@ -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;
|
||||
@@ -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<host> -P<port> -u<user> -p <db> < 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;
|
||||
@@ -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<host> -P<port> -u<user> -p <db> < 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` <> '';
|
||||
@@ -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<host> -P<port> -u<user> -p <db> < 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` = '未分类';
|
||||
@@ -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<host> -P<port> -u<user> -p <db> < 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 ('套', '台', '个', '件', '批', '次', '项', '人月', '人天', '年', '月', '米', '平方米', '吨', '千克', '箱');
|
||||
@@ -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<host> -P<port> -u<user> -p <db> < 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;
|
||||
@@ -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<host> -P<port> -u<user> -p <db> < 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;
|
||||
@@ -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<host> -P<port> -u<user> -p <db> < 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%';
|
||||
@@ -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<host> -P<port> -u<user> -p <db> < 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;
|
||||
@@ -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<host> -P<port> -u<user> -p <db> < 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;
|
||||
@@ -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<host> -P<port> -u<user> -p <db> < 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;
|
||||
@@ -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<host> -P<port> -u<user> -p <db> < 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;
|
||||
@@ -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<host> -P<port> -u<user> -p <db> < 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;
|
||||
@@ -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;
|
||||
@@ -1,47 +0,0 @@
|
||||
-- =============================================================
|
||||
-- CRM合同表:yz_backend_crm_contract
|
||||
-- 说明:
|
||||
-- 含「我方角色 our_role」「合同形式 party_count」等字段;
|
||||
-- 产品清单 / 各方签约主体以 JSON 文本存储。
|
||||
-- 可重复执行(CREATE TABLE IF NOT EXISTS)。
|
||||
-- 运行:mysql -h<host> -P<port> -u<user> -p <db> < 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合同表';
|
||||
@@ -1,62 +0,0 @@
|
||||
-- =============================================================
|
||||
-- CRM回款相关表:回款计划 / 回款记录(回款进度流水)
|
||||
-- yz_backend_crm_payback 回款计划
|
||||
-- yz_backend_crm_payback_record 回款记录(每次「新建回款」追加一条,
|
||||
-- 以回款计划 payback_id 为先导条件)
|
||||
-- 可重复执行(CREATE TABLE IF NOT EXISTS)。
|
||||
-- 运行:mysql -h<host> -P<port> -u<user> -p <db> < 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回款记录表(回款进度流水)';
|
||||
@@ -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计量单位表';
|
||||
@@ -1,49 +0,0 @@
|
||||
-- =============================================================
|
||||
-- CMS 文章相关表:文章分类 / 文章
|
||||
-- yz_cms_article_category 文章分类
|
||||
-- yz_cms_article 文章
|
||||
-- 可重复执行(CREATE TABLE IF NOT EXISTS)。
|
||||
-- 运行:mysql -h<host> -P<port> -u<user> -p <db> < 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;
|
||||
@@ -1,39 +0,0 @@
|
||||
-- =============================================================
|
||||
-- CMS 产品相关表:产品 / 产品分类
|
||||
-- yz_cms_product 产品
|
||||
-- yz_cms_product_category 产品分类
|
||||
-- 可重复执行(CREATE TABLE IF NOT EXISTS)。
|
||||
-- 运行:mysql -h<host> -P<port> -u<user> -p <db> < 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;
|
||||
@@ -1,46 +0,0 @@
|
||||
-- =============================================================
|
||||
-- CMS 解决方案(特色服务)相关表:解决方案 / 解决方案分类
|
||||
-- yz_cms_solution 解决方案
|
||||
-- yz_cms_solution_category 解决方案分类
|
||||
-- 说明:建表可重复执行;末尾的历史库补列重复执行报
|
||||
-- "Duplicate column name" 属正常,跳过即可。
|
||||
-- 运行:mysql -h<host> -P<port> -u<user> -p <db> < 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;
|
||||
@@ -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<host> -P<port> -u<user> -p <db> < 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;
|
||||
@@ -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<host> -P<port> -u<user> -p <db> < 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通知公告表';
|
||||
@@ -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<host> -P<port> -u<user> -p <db> < 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='平台推广佣金单表(台账)';
|
||||
@@ -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<host> -P<port> -u<user> -p <db> < 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='平台支付手动原路退回记录表(兜底能力)';
|
||||
@@ -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<host> -P<port> -u<user> -p <db> < 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);
|
||||
@@ -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='平台官网站点设置表';
|
||||
@@ -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<host> -P<port> -u<user> -p <db> < 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='微信公众号关注取码/绑定验证码表';
|
||||
@@ -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<host> -P<port> -u<user> -p <db> < 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;
|
||||
@@ -1,20 +0,0 @@
|
||||
-- =============================================================
|
||||
-- 租户站点扩展设置表:yz_system_tenant_setting_items
|
||||
-- 说明:租户后台「站点设置」的扩展键值项(tid + setting_key 唯一)。
|
||||
-- 可重复执行(CREATE TABLE IF NOT EXISTS)。
|
||||
-- 运行:mysql -h<host> -P<port> -u<user> -p <db> < 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='租户站点扩展设置';
|
||||
@@ -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<host> -P<port> -u<user> -p <db> < 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;
|
||||
@@ -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<host> -P<port> -u<user> -p <db> < 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;
|
||||
@@ -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', '常用于 <title> 和页头 | 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;
|
||||
@@ -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
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
+75
-51
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+28
-13
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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: "站点设置" }
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "取消",
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+3
-7
@@ -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 {
|
||||
+175
-7
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(.*)*",
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
+16
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
+24
-14
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user