web端-新增风采墙模块(工会信息传播与文化展示);

This commit is contained in:
2026-07-30 15:42:06 +08:00
parent ff3e8075b5
commit 5043cee747
22 changed files with 2270 additions and 0 deletions
@@ -0,0 +1,101 @@
package com.budwk.app.zhgh.spread.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.spread.models.SpreadCategory;
import com.budwk.app.zhgh.spread.service.SpreadCategoryService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
@IocBean
@Ok("json:full")
@Api("风采墙-栏目管理")
@At("/platform/spread/category")
public class SpreadCategoryController {
@Inject
private SpreadCategoryService categoryService;
@At("")
@Ok("beetl:/platform/zhgh/spread/category/index.html")
@SaCheckPermission("spread.category")
public void index() {
}
@At
@ApiOperation("栏目列表")
@SaCheckPermission("spread.category")
public Result listData() {
return Result.success(categoryService.listCategories(false));
}
@At
@ApiOperation("栏目树")
@SaCheckPermission("spread.category")
public Result treeData() {
return Result.success(categoryService.categoryTree(false));
}
@At
@ApiOperation("新增栏目")
@SaCheckPermission("spread.category")
@SLog(tag = "风采墙-栏目管理", msg = "新增栏目:${args[0].name}")
@Aop(TransAop.READ_COMMITTED)
public Result doAdd(SpreadCategory category) {
try {
categoryService.saveCategory(category);
return Result.success();
} catch (RuntimeException e) {
return Result.error(e.getMessage());
}
}
@At
@ApiOperation("编辑栏目")
@SaCheckPermission("spread.category")
@SLog(tag = "风采墙-栏目管理", msg = "编辑栏目:${args[0].id}")
@Aop(TransAop.READ_COMMITTED)
public Result doEdit(SpreadCategory category) {
try {
categoryService.updateCategory(category);
return Result.success();
} catch (RuntimeException e) {
return Result.error(e.getMessage());
}
}
@At
@ApiOperation("删除栏目")
@SaCheckPermission("spread.category")
@SLog(tag = "风采墙-栏目管理", msg = "删除栏目:${args[0]}")
@Aop(TransAop.READ_COMMITTED)
public Result doDelete(String id) {
try {
categoryService.deleteCategory(id);
return Result.success();
} catch (RuntimeException e) {
return Result.error(e.getMessage());
}
}
@At
@ApiOperation("设置栏目显示状态")
@SaCheckPermission("spread.category")
@SLog(tag = "风采墙-栏目管理", msg = "栏目:${args[0]},显示状态:${args[1]}")
@Aop(TransAop.READ_COMMITTED)
public Result doVisible(String id, Integer visible) {
try {
categoryService.setVisible(id, visible);
return Result.success();
} catch (RuntimeException e) {
return Result.error(e.getMessage());
}
}
}
@@ -0,0 +1,81 @@
package com.budwk.app.zhgh.spread.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.spread.models.SpreadContent;
import com.budwk.app.zhgh.spread.service.SpreadCategoryService;
import com.budwk.app.zhgh.spread.service.SpreadContentService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
@IocBean
@Ok("json:full")
@Api("风采墙-内容发布")
@At("/platform/spread/creation")
public class SpreadCreationController {
@Inject
private SpreadCategoryService categoryService;
@Inject
private SpreadContentService contentService;
@At("")
@Ok("beetl:/platform/zhgh/spread/creation/index.html")
@SaCheckPermission("spread.creation")
public void index() {
}
@At
@ApiOperation("可选栏目树")
@SaCheckPermission("spread.creation")
public Result categoryTree() {
return Result.success(categoryService.categoryTree(true));
}
@At
@ApiOperation("内容详情")
@SaCheckPermission("spread.creation")
public Result detailData(String id) {
try {
return Result.success(contentService.getContent(id));
} catch (RuntimeException e) {
return Result.error(e.getMessage());
}
}
@At
@ApiOperation("新增内容")
@SaCheckPermission("spread.creation")
@SLog(tag = "风采墙-内容发布", msg = "新增内容:${args[0].title}")
@Aop(TransAop.READ_COMMITTED)
public Result doAdd(SpreadContent content) {
try {
contentService.saveContent(content);
return Result.success();
} catch (RuntimeException e) {
return Result.error(e.getMessage());
}
}
@At
@ApiOperation("编辑内容")
@SaCheckPermission("spread.creation")
@SLog(tag = "风采墙-内容发布", msg = "编辑内容:${args[0].id}")
@Aop(TransAop.READ_COMMITTED)
public Result doEdit(SpreadContent content) {
try {
contentService.updateContent(content);
return Result.success();
} catch (RuntimeException e) {
return Result.error(e.getMessage());
}
}
}
@@ -0,0 +1,76 @@
package com.budwk.app.zhgh.spread.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.spread.service.SpreadCategoryService;
import com.budwk.app.zhgh.spread.service.SpreadContentService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
@IocBean
@Ok("json:full")
@Api("风采墙-展示")
@At("/platform/spread/display")
public class SpreadDisplayController {
@Inject
private SpreadCategoryService categoryService;
@Inject
private SpreadContentService contentService;
@At("")
@Ok("beetl:/platform/zhgh/spread/display/index.html")
@SaCheckPermission("spread.display")
public void index() {
}
@At
@ApiOperation("可见栏目树")
@SaCheckPermission("spread.display")
public Result categoryTree() {
return Result.success(categoryService.categoryTree(true));
}
@At
@ApiOperation("已发布内容分页")
@SaCheckPermission("spread.display")
public Result pageData(PageForm pageForm, String categoryId) {
return Result.success(contentService.pagePublished(pageForm, categoryId));
}
@At
@ApiOperation("内容详情")
@SaCheckPermission("spread.display")
public Result detailData(String id) {
try {
return Result.success(contentService.getContent(id));
} catch (RuntimeException e) {
return Result.error(e.getMessage());
}
}
@At
@ApiOperation("轮播内容")
@SaCheckPermission("spread.display")
public Result sliderData() {
return Result.success(contentService.sliderContent());
}
@At
@ApiOperation("增加浏览量")
@SaCheckPermission("spread.display")
public Result addView(String id) {
try {
contentService.addViewCount(id);
return Result.success();
} catch (RuntimeException e) {
return Result.error(e.getMessage());
}
}
}
@@ -0,0 +1,78 @@
package com.budwk.app.zhgh.spread.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.spread.param.SpreadContentQuery;
import com.budwk.app.zhgh.spread.service.SpreadCategoryService;
import com.budwk.app.zhgh.spread.service.SpreadContentService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
@IocBean
@Ok("json:full")
@Api("风采墙-内容管理")
@At("/platform/spread/manage")
public class SpreadManageController {
@Inject
private SpreadCategoryService categoryService;
@Inject
private SpreadContentService contentService;
@At("")
@Ok("beetl:/platform/zhgh/spread/manage/index.html")
@SaCheckPermission("spread.manage")
public void index() {
}
@At
@ApiOperation("内容分页")
@SaCheckPermission("spread.manage")
public Result pageData(PageForm pageForm, SpreadContentQuery query) {
return Result.success(contentService.pageContent(pageForm, query));
}
@At
@ApiOperation("栏目树")
@SaCheckPermission("spread.manage")
public Result categoryTree() {
return Result.success(categoryService.categoryTree(false));
}
@At
@ApiOperation("删除内容")
@SaCheckPermission("spread.manage")
@SLog(tag = "风采墙-内容管理", msg = "删除内容:${args[0]}")
@Aop(TransAop.READ_COMMITTED)
public Result doDelete(String id) {
try {
contentService.deleteContent(id);
return Result.success();
} catch (RuntimeException e) {
return Result.error(e.getMessage());
}
}
@At
@ApiOperation("发布或撤回内容")
@SaCheckPermission("spread.manage")
@SLog(tag = "风采墙-内容管理", msg = "内容:${args[0]},发布状态:${args[1]}")
@Aop(TransAop.READ_COMMITTED)
public Result doPublish(String id, Integer status) {
try {
contentService.publishContent(id, status);
return Result.success();
} catch (RuntimeException e) {
return Result.error(e.getMessage());
}
}
}
@@ -0,0 +1,60 @@
package com.budwk.app.zhgh.spread.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.spread.param.SpreadStatisticsQuery;
import com.budwk.app.zhgh.spread.service.SpreadCategoryService;
import com.budwk.app.zhgh.spread.service.SpreadStatisticsService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
@IocBean
@Ok("json:full")
@Api("风采墙-统计分析")
@At("/platform/spread/statistics")
public class SpreadStatisticsController {
@Inject
private SpreadCategoryService categoryService;
@Inject
private SpreadStatisticsService statisticsService;
@At("")
@Ok("beetl:/platform/zhgh/spread/statistics/index.html")
@SaCheckPermission("spread.statistics")
public void index() {
}
@At
@ApiOperation("栏目树")
@SaCheckPermission("spread.statistics")
public Result categoryTree() {
return Result.success(categoryService.categoryTree(false));
}
@At
@ApiOperation("统计卡片")
@SaCheckPermission("spread.statistics")
public Result summary(SpreadStatisticsQuery query) {
return Result.success(statisticsService.summary(query));
}
@At
@ApiOperation("浏览量前十")
@SaCheckPermission("spread.statistics")
public Result top10(SpreadStatisticsQuery query) {
return Result.success(statisticsService.top10(query));
}
@At
@ApiOperation("栏目统计")
@SaCheckPermission("spread.statistics")
public Result categoryData(SpreadStatisticsQuery query) {
return Result.success(statisticsService.categoryStatistics(query));
}
}
@@ -0,0 +1,55 @@
package com.budwk.app.zhgh.spread.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.interceptor.annotation.PrevInsert;
@Data
@Table("spread_category")
@EqualsAndHashCode(callSuper = true)
public class SpreadCategory extends BaseModel {
@Column
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("父栏目ID0表示顶级栏目")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String parentId;
@Column
@Comment("栏目名称")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String name;
@Column
@Comment("栏目编码")
@ColDefine(type = ColType.VARCHAR, width = 50)
private String code;
@Column
@Comment("排序")
@ColDefine(type = ColType.INT)
private Integer sortOrder;
@Column
@Comment("是否可见")
@ColDefine(type = ColType.INT)
private Integer visible;
@Column
@Comment("是否参与轮播")
@ColDefine(type = ColType.INT)
private Integer slider;
}
@@ -0,0 +1,99 @@
package com.budwk.app.zhgh.spread.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.interceptor.annotation.PrevInsert;
@Data
@Table("spread_content")
@EqualsAndHashCode(callSuper = true)
public class SpreadContent extends BaseModel {
@Column
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("栏目ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String categoryId;
@Column
@Comment("栏目名称")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String categoryName;
@Column
@Comment("标题")
@ColDefine(type = ColType.VARCHAR, width = 300)
private String title;
@Column
@Comment("副标题")
@ColDefine(type = ColType.VARCHAR, width = 255)
private String subTitle;
@Column
@Comment("摘要")
@ColDefine(type = ColType.VARCHAR, width = 1000)
private String summary;
@Column
@Comment("正文")
@ColDefine(customType = "longtext")
private String contentBody;
@Column
@Comment("封面图")
@ColDefine(type = ColType.VARCHAR, width = 1000)
private String thumbUrl;
@Column
@Comment("轮播图片")
@ColDefine(customType = "longtext")
private String sliderImages;
@Column
@Comment("是否置顶")
@ColDefine(type = ColType.INT)
private Integer isTop;
@Column
@Comment("是否推荐")
@ColDefine(type = ColType.INT)
private Integer isRecommend;
@Column
@Comment("是否头条")
@ColDefine(type = ColType.INT)
private Integer isHeadline;
@Column
@Comment("浏览量")
@ColDefine(type = ColType.INT)
private Integer viewCount;
@Column
@Comment("排序")
@ColDefine(type = ColType.INT)
private Integer sortOrder;
@Column
@Comment("是否发布")
@ColDefine(type = ColType.INT)
private Integer isPublished;
@Column
@Comment("发布时间")
private Long publishedAt;
}
@@ -0,0 +1,12 @@
package com.budwk.app.zhgh.spread.param;
import lombok.Data;
@Data
public class SpreadContentQuery {
private String categoryId;
private String title;
private Integer isPublished;
private String beginTime;
private String endTime;
}
@@ -0,0 +1,11 @@
package com.budwk.app.zhgh.spread.param;
import lombok.Data;
@Data
public class SpreadStatisticsQuery {
private String categoryId;
private Integer isPublished;
private String beginTime;
private String endTime;
}
@@ -0,0 +1,22 @@
package com.budwk.app.zhgh.spread.service;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.spread.models.SpreadCategory;
import org.nutz.lang.util.NutMap;
import java.util.List;
public interface SpreadCategoryService extends BaseService<SpreadCategory> {
List<SpreadCategory> listCategories(boolean visibleOnly);
List<NutMap> categoryTree(boolean visibleOnly);
void saveCategory(SpreadCategory category);
void updateCategory(SpreadCategory category);
void deleteCategory(String id);
void setVisible(String id, Integer visible);
}
@@ -0,0 +1,31 @@
package com.budwk.app.zhgh.spread.service;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.spread.models.SpreadContent;
import com.budwk.app.zhgh.spread.param.SpreadContentQuery;
import org.nutz.lang.util.NutMap;
import java.util.List;
public interface SpreadContentService extends BaseService<SpreadContent> {
Pagination<NutMap> pageContent(PageForm pageForm, SpreadContentQuery query);
Pagination<NutMap> pagePublished(PageForm pageForm, String categoryId);
SpreadContent getContent(String id);
void saveContent(SpreadContent content);
void updateContent(SpreadContent content);
void deleteContent(String id);
void publishContent(String id, Integer status);
void addViewCount(String id);
List<NutMap> sliderContent();
}
@@ -0,0 +1,15 @@
package com.budwk.app.zhgh.spread.service;
import com.budwk.app.zhgh.spread.param.SpreadStatisticsQuery;
import org.nutz.lang.util.NutMap;
import java.util.List;
public interface SpreadStatisticsService {
NutMap summary(SpreadStatisticsQuery query);
List<NutMap> top10(SpreadStatisticsQuery query);
List<NutMap> categoryStatistics(SpreadStatisticsQuery query);
}
@@ -0,0 +1,180 @@
package com.budwk.app.zhgh.spread.service.impl;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.zhgh.spread.models.SpreadCategory;
import com.budwk.app.zhgh.spread.models.SpreadContent;
import com.budwk.app.zhgh.spread.service.SpreadCategoryService;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@IocBean(args = {"refer:dao"})
public class SpreadCategoryServiceImpl extends BaseServiceImpl<SpreadCategory> implements SpreadCategoryService {
public SpreadCategoryServiceImpl(Dao dao) {
super(dao);
}
@Override
public List<SpreadCategory> listCategories(boolean visibleOnly) {
Cnd cnd = Cnd.where("delFlag", "=", false);
if (visibleOnly) {
cnd.and("visible", "=", 1);
}
cnd.asc("sortOrder").asc("createdAt");
return query(cnd);
}
@Override
public List<NutMap> categoryTree(boolean visibleOnly) {
List<SpreadCategory> categories = listCategories(visibleOnly);
Map<String, NutMap> nodeMap = new LinkedHashMap<>();
for (SpreadCategory category : categories) {
nodeMap.put(category.getId(), NutMap.NEW()
.addv("id", category.getId())
.addv("parentId", category.getParentId())
.addv("name", category.getName())
.addv("code", category.getCode())
.addv("sortOrder", category.getSortOrder())
.addv("visible", category.getVisible())
.addv("slider", category.getSlider())
.addv("children", new ArrayList<>()));
}
List<NutMap> roots = new ArrayList<>();
for (NutMap node : nodeMap.values()) {
String parentId = node.getString("parentId");
if ("0".equals(parentId) || !nodeMap.containsKey(parentId)) {
roots.add(node);
} else {
nodeMap.get(parentId).getList("children", NutMap.class).add(node);
}
}
return roots;
}
@Override
public void saveCategory(SpreadCategory category) {
validateCategory(category, null);
applyDefaults(category);
insert(category);
syncParentSlider(category);
}
@Override
public void updateCategory(SpreadCategory category) {
if (category == null || StrUtil.isBlank(category.getId())) {
throw new RuntimeException("请选择要编辑的栏目");
}
SpreadCategory dbCategory = fetch(category.getId());
if (dbCategory == null || Boolean.TRUE.equals(dbCategory.getDelFlag())) {
throw new RuntimeException("栏目不存在");
}
validateCategory(category, category.getId());
if (!dbCategory.getParentId().equals(category.getParentId())
&& count(Cnd.where("parentId", "=", category.getId()).and("delFlag", "=", false)) > 0) {
throw new RuntimeException("该栏目下存在子栏目,不能移动");
}
applyDefaults(category);
updateIgnoreNull(category);
syncParentSlider(category);
if ("0".equals(category.getParentId()) && Integer.valueOf(0).equals(category.getSlider())) {
SpreadCategory child = new SpreadCategory();
child.setSlider(0);
dao().update(SpreadCategory.class, org.nutz.dao.Chain.make("slider", 0),
Cnd.where("parentId", "=", category.getId()).and("delFlag", "=", false));
}
}
@Override
public void deleteCategory(String id) {
if (StrUtil.isBlank(id)) {
throw new RuntimeException("请选择要删除的栏目");
}
List<SpreadCategory> children = query(Cnd.where("parentId", "=", id).and("delFlag", "=", false));
List<String> ids = new ArrayList<>();
ids.add(id);
children.forEach(item -> ids.add(item.getId()));
if (dao().count(SpreadContent.class,
Cnd.where("categoryId", "in", ids).and("delFlag", "=", false)) > 0) {
throw new RuntimeException("栏目下存在内容,请先删除相关内容");
}
dao().clear(SpreadCategory.class, Cnd.where("id", "in", ids));
}
@Override
public void setVisible(String id, Integer visible) {
if (StrUtil.isBlank(id) || (visible == null || (visible != 0 && visible != 1))) {
throw new RuntimeException("栏目或显示状态不正确");
}
dao().update(SpreadCategory.class, org.nutz.dao.Chain.make("visible", visible),
Cnd.where("id", "=", id).and("delFlag", "=", false));
if (visible == 0) {
dao().update(SpreadCategory.class, org.nutz.dao.Chain.make("visible", 0),
Cnd.where("parentId", "=", id).and("delFlag", "=", false));
}
}
private void validateCategory(SpreadCategory category, String excludeId) {
if (category == null || StrUtil.isBlank(category.getName())) {
throw new RuntimeException("栏目名称不能为空");
}
if (StrUtil.isBlank(category.getCode())) {
throw new RuntimeException("栏目编码不能为空");
}
category.setName(category.getName().trim());
category.setCode(category.getCode().trim());
category.setParentId(StrUtil.blankToDefault(category.getParentId(), "0"));
if (category.getId() != null && category.getId().equals(category.getParentId())) {
throw new RuntimeException("上级栏目不能选择自身");
}
if (!"0".equals(category.getParentId())) {
SpreadCategory parent = fetch(category.getParentId());
if (parent == null || Boolean.TRUE.equals(parent.getDelFlag())) {
throw new RuntimeException("上级栏目不存在");
}
if (!"0".equals(parent.getParentId())) {
throw new RuntimeException("风采墙栏目最多支持两级");
}
}
Cnd nameCnd = Cnd.where("name", "=", category.getName())
.and("parentId", "=", category.getParentId())
.and("delFlag", "=", false);
Cnd codeCnd = Cnd.where("code", "=", category.getCode()).and("delFlag", "=", false);
if (StrUtil.isNotBlank(excludeId)) {
nameCnd.and("id", "!=", excludeId);
codeCnd.and("id", "!=", excludeId);
}
if (count(nameCnd) > 0) {
throw new RuntimeException("同级栏目名称已存在");
}
if (count(codeCnd) > 0) {
throw new RuntimeException("栏目编码已存在");
}
}
private void applyDefaults(SpreadCategory category) {
if (category.getSortOrder() == null) {
category.setSortOrder(255);
}
if (category.getVisible() == null) {
category.setVisible(1);
}
if (category.getSlider() == null) {
category.setSlider(0);
}
}
private void syncParentSlider(SpreadCategory category) {
if (!"0".equals(category.getParentId()) && Integer.valueOf(1).equals(category.getSlider())) {
dao().update(SpreadCategory.class, org.nutz.dao.Chain.make("slider", 1),
Cnd.where("id", "=", category.getParentId()).and("delFlag", "=", false));
}
}
}
@@ -0,0 +1,234 @@
package com.budwk.app.zhgh.spread.service.impl;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.zhgh.spread.models.SpreadCategory;
import com.budwk.app.zhgh.spread.models.SpreadContent;
import com.budwk.app.zhgh.spread.param.SpreadContentQuery;
import com.budwk.app.zhgh.spread.service.SpreadContentService;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import java.util.ArrayList;
import java.util.List;
@IocBean(args = {"refer:dao"})
public class SpreadContentServiceImpl extends BaseServiceImpl<SpreadContent> implements SpreadContentService {
public SpreadContentServiceImpl(Dao dao) {
super(dao);
}
@Override
public Pagination<NutMap> pageContent(PageForm pageForm, SpreadContentQuery query) {
Cnd cnd = buildCondition(query);
Sql countSql = Sqls.create("""
SELECT COUNT(1)
FROM spread_content c
$condition
""");
countSql.setCondition(cnd);
countSql.setCallback(Sqls.callback.integer());
dao().execute(countSql);
Sql listSql = Sqls.create("""
SELECT c.*
FROM spread_content c
$condition
ORDER BY c.isTop DESC, c.sortOrder ASC, c.createdAt DESC
""");
listSql.setCondition(cnd);
listSql.setPager(dao().createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
dao().execute(listSql);
return new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(),
countSql.getInt(), listSql.getList(NutMap.class));
}
@Override
public Pagination<NutMap> pagePublished(PageForm pageForm, String categoryId) {
SpreadContentQuery query = new SpreadContentQuery();
query.setCategoryId(categoryId);
query.setIsPublished(1);
Cnd cnd = buildCondition(query);
Sql countSql = Sqls.create("SELECT COUNT(1) FROM spread_content c $condition");
countSql.setCondition(cnd);
countSql.setCallback(Sqls.callback.integer());
dao().execute(countSql);
Sql listSql = Sqls.create("""
SELECT c.*
FROM spread_content c
$condition
ORDER BY c.isTop DESC, c.publishedAt DESC, c.sortOrder ASC, c.createdAt DESC
""");
listSql.setCondition(cnd);
listSql.setPager(dao().createPager(pageForm.getPageNumber(), pageForm.getPageSize()));
listSql.setCallback(Sqls.callback.maps());
dao().execute(listSql);
return new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(),
countSql.getInt(), listSql.getList(NutMap.class));
}
@Override
public SpreadContent getContent(String id) {
if (StrUtil.isBlank(id)) {
throw new RuntimeException("请选择内容");
}
SpreadContent content = fetch(Cnd.where("id", "=", id).and("delFlag", "=", false));
if (content == null) {
throw new RuntimeException("内容不存在");
}
return content;
}
@Override
public void saveContent(SpreadContent content) {
validateContent(content);
applyDefaults(content);
if (Integer.valueOf(1).equals(content.getIsPublished())) {
content.setPublishedAt(System.currentTimeMillis());
}
insert(content);
}
@Override
public void updateContent(SpreadContent content) {
if (content == null || StrUtil.isBlank(content.getId())) {
throw new RuntimeException("请选择要编辑的内容");
}
SpreadContent dbContent = getContent(content.getId());
validateContent(content);
applyDefaults(content);
if (Integer.valueOf(1).equals(content.getIsPublished()) && !Integer.valueOf(1).equals(dbContent.getIsPublished())) {
content.setPublishedAt(System.currentTimeMillis());
}
updateIgnoreNull(content);
}
@Override
public void deleteContent(String id) {
getContent(id);
dao().clear(SpreadContent.class, Cnd.where("id", "=", id));
}
@Override
public void publishContent(String id, Integer status) {
getContent(id);
if (status == null || (status != 0 && status != 1)) {
throw new RuntimeException("发布状态不正确");
}
org.nutz.dao.Chain chain = org.nutz.dao.Chain.make("isPublished", status);
if (status == 1) {
chain.add("publishedAt", System.currentTimeMillis());
}
dao().update(SpreadContent.class, chain, Cnd.where("id", "=", id).and("delFlag", "=", false));
}
@Override
public void addViewCount(String id) {
getContent(id);
Sql sql = Sqls.create("""
UPDATE spread_content
SET viewCount = IFNULL(viewCount, 0) + 1,
updatedAt = @updatedAt
WHERE id = @id AND delFlag = 0
""");
sql.params().set("id", id);
sql.params().set("updatedAt", System.currentTimeMillis());
dao().execute(sql);
}
@Override
public List<NutMap> sliderContent() {
Sql sql = Sqls.create("""
SELECT c.id, c.title, c.summary, c.thumbUrl, c.sliderImages, c.categoryId, c.categoryName
FROM spread_content c
INNER JOIN spread_category child ON child.id = c.categoryId
LEFT JOIN spread_category parent ON parent.id = child.parentId
WHERE c.delFlag = 0
AND c.isPublished = 1
AND child.delFlag = 0
AND child.visible = 1
AND child.slider = 1
AND (child.parentId = '0' OR (parent.delFlag = 0 AND parent.visible = 1 AND parent.slider = 1))
ORDER BY c.isTop DESC, c.publishedAt DESC, c.createdAt DESC
LIMIT 10
""");
sql.setCallback(Sqls.callback.maps());
dao().execute(sql);
return sql.getList(NutMap.class);
}
private Cnd buildCondition(SpreadContentQuery query) {
Cnd cnd = Cnd.where("c.delFlag", "=", false);
if (query == null) {
return cnd;
}
if (StrUtil.isNotBlank(query.getCategoryId())) {
List<String> categoryIds = new ArrayList<>();
categoryIds.add(query.getCategoryId());
List<SpreadCategory> children = dao().query(SpreadCategory.class,
Cnd.where("parentId", "=", query.getCategoryId()).and("delFlag", "=", false));
children.forEach(item -> categoryIds.add(item.getId()));
cnd.and("c.categoryId", "in", categoryIds);
}
cnd.and(Cnd.likeEX("c.title", query.getTitle()));
cnd.andEX("c.isPublished", "=", query.getIsPublished());
if (StrUtil.isNotBlank(query.getBeginTime())) {
cnd.and("c.createdAt", ">=", DateUtil.parse(query.getBeginTime()).getTime());
}
if (StrUtil.isNotBlank(query.getEndTime())) {
cnd.and("c.createdAt", "<=", DateUtil.parse(query.getEndTime()).getTime());
}
return cnd;
}
private void validateContent(SpreadContent content) {
if (content == null || StrUtil.isBlank(content.getCategoryId())) {
throw new RuntimeException("请选择栏目");
}
SpreadCategory category = dao().fetch(SpreadCategory.class,
Cnd.where("id", "=", content.getCategoryId()).and("delFlag", "=", false));
if (category == null) {
throw new RuntimeException("栏目不存在");
}
if (dao().count(SpreadCategory.class,
Cnd.where("parentId", "=", category.getId()).and("delFlag", "=", false)) > 0) {
throw new RuntimeException("内容只能发布到末级栏目");
}
if (StrUtil.isBlank(content.getTitle())) {
throw new RuntimeException("标题不能为空");
}
content.setTitle(content.getTitle().trim());
content.setCategoryName(category.getName());
}
private void applyDefaults(SpreadContent content) {
if (content.getIsTop() == null) {
content.setIsTop(0);
}
if (content.getIsRecommend() == null) {
content.setIsRecommend(0);
}
if (content.getIsHeadline() == null) {
content.setIsHeadline(0);
}
if (content.getViewCount() == null) {
content.setViewCount(0);
}
if (content.getSortOrder() == null) {
content.setSortOrder(255);
}
if (content.getIsPublished() == null) {
content.setIsPublished(0);
}
}
}
@@ -0,0 +1,113 @@
package com.budwk.app.zhgh.spread.service.impl;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.zhgh.spread.param.SpreadStatisticsQuery;
import com.budwk.app.zhgh.spread.service.SpreadStatisticsService;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import java.util.List;
@IocBean
public class SpreadStatisticsServiceImpl implements SpreadStatisticsService {
@Inject
private Dao dao;
@Override
public NutMap summary(SpreadStatisticsQuery query) {
String condition = buildContentCondition(query, "c");
Sql sql = Sqls.create("""
SELECT
(SELECT COUNT(1) FROM spread_category cg WHERE cg.delFlag = 0) AS categoryCount,
COUNT(1) AS contentCount,
IFNULL(SUM(CASE WHEN c.isPublished = 1 THEN 1 ELSE 0 END), 0) AS publishedCount,
IFNULL(SUM(c.viewCount), 0) AS viewCount,
IFNULL(ROUND(AVG(c.viewCount)), 0) AS avgViewCount
FROM spread_content c
WHERE c.delFlag = 0
$condition
""");
sql.setVar("condition", condition);
setParams(sql, query);
sql.setCallback(Sqls.callback.map());
dao.execute(sql);
NutMap result = sql.getObject(NutMap.class);
return result == null ? NutMap.NEW() : result;
}
@Override
public List<NutMap> top10(SpreadStatisticsQuery query) {
Sql sql = Sqls.create("""
SELECT c.id, c.title, c.categoryName, c.viewCount, c.isPublished, c.publishedAt
FROM spread_content c
WHERE c.delFlag = 0
$condition
ORDER BY c.viewCount DESC, c.updatedAt DESC
LIMIT 10
""");
sql.setVar("condition", buildContentCondition(query, "c"));
setParams(sql, query);
sql.setCallback(Sqls.callback.maps());
dao.execute(sql);
return sql.getList(NutMap.class);
}
@Override
public List<NutMap> categoryStatistics(SpreadStatisticsQuery query) {
Sql sql = Sqls.create("""
SELECT c.categoryId, c.categoryName, COUNT(1) AS contentCount,
IFNULL(SUM(c.viewCount), 0) AS viewCount
FROM spread_content c
WHERE c.delFlag = 0
$condition
GROUP BY c.categoryId, c.categoryName
ORDER BY viewCount DESC, contentCount DESC
""");
sql.setVar("condition", buildContentCondition(query, "c"));
setParams(sql, query);
sql.setCallback(Sqls.callback.maps());
dao.execute(sql);
return sql.getList(NutMap.class);
}
private String buildContentCondition(SpreadStatisticsQuery query, String alias) {
if (query == null) {
return "";
}
StringBuilder condition = new StringBuilder();
if (StrUtil.isNotBlank(query.getCategoryId())) {
condition.append(" AND ").append(alias)
.append(".categoryId IN (SELECT id FROM spread_category WHERE id = @categoryId OR parentId = @categoryId)");
}
if (query.getIsPublished() != null) {
condition.append(" AND ").append(alias).append(".isPublished = @isPublished");
}
if (StrUtil.isNotBlank(query.getBeginTime())) {
condition.append(" AND ").append(alias).append(".createdAt >= @beginAt");
}
if (StrUtil.isNotBlank(query.getEndTime())) {
condition.append(" AND ").append(alias).append(".createdAt <= @endAt");
}
return condition.toString();
}
private void setParams(Sql sql, SpreadStatisticsQuery query) {
if (query == null) {
return;
}
sql.params().set("categoryId", query.getCategoryId());
sql.params().set("isPublished", query.getIsPublished());
if (StrUtil.isNotBlank(query.getBeginTime())) {
sql.params().set("beginAt", DateUtil.parse(query.getBeginTime()).getTime());
}
if (StrUtil.isNotBlank(query.getEndTime())) {
sql.params().set("endAt", DateUtil.parse(query.getEndTime()).getTime());
}
}
}
@@ -0,0 +1,79 @@
-- 风采墙电脑端菜单。使用 permission 做幂等判断,脚本可重复执行。
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT
'9f5c0200000000000000000000000001',
'',
LPAD(IFNULL(MAX(CAST(path AS UNSIGNED)), 0) + 1, 4, '0'),
'风采墙',
'Style Wall',
'menu',
'',
'',
'ti-medall',
1,
0,
'spread',
NULL,
992,
1,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
'',
UNIX_TIMESTAMP(NOW()) * 1000,
0,
'PC',
NULL,
NULL,
'f',
0,
0
FROM sys_menu
WHERE (parentId = '' OR parentId IS NULL)
AND CHAR_LENGTH(path) = 4
HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'spread') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT '9f5c0200000000000000000000000002', p.id, CONCAT(p.path, '0001'), '风采展示', 'Style Display', 'menu', '/platform/spread/display', 'data-pjax', '', 1, 0, 'spread.display', NULL, 1, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'f', 0, 0
FROM sys_menu p
WHERE p.permission = 'spread'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'spread.display') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT '9f5c0200000000000000000000000003', p.id, CONCAT(p.path, '0002'), '内容发布', 'Content Creation', 'menu', '/platform/spread/creation', 'data-pjax', '', 1, 0, 'spread.creation', NULL, 2, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'n', 0, 0
FROM sys_menu p
WHERE p.permission = 'spread'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'spread.creation') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT '9f5c0200000000000000000000000004', p.id, CONCAT(p.path, '0003'), '内容管理', 'Content Manage', 'menu', '/platform/spread/manage', 'data-pjax', '', 1, 0, 'spread.manage', NULL, 3, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'n', 0, 0
FROM sys_menu p
WHERE p.permission = 'spread'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'spread.manage') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT '9f5c0200000000000000000000000005', p.id, CONCAT(p.path, '0004'), '栏目管理', 'Category Manage', 'menu', '/platform/spread/category', 'data-pjax', '', 1, 0, 'spread.category', NULL, 4, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'l', 0, 0
FROM sys_menu p
WHERE p.permission = 'spread'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'spread.category') t);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT '9f5c0200000000000000000000000006', p.id, CONCAT(p.path, '0005'), '统计分析', 'Statistics', 'menu', '/platform/spread/statistics', 'data-pjax', '', 1, 0, 'spread.statistics', NULL, 5, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 't', 0, 0
FROM sys_menu p
WHERE p.permission = 'spread'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'spread.statistics') t);
-- 系统管理员默认拥有风采墙全部权限,其他角色可在角色管理中按需分配。
INSERT INTO sys_role_menu (roleId, menuId)
SELECT r.id, m.id
FROM sys_role r
JOIN sys_menu m ON m.permission IN (
'spread',
'spread.display',
'spread.creation',
'spread.manage',
'spread.category',
'spread.statistics'
)
LEFT JOIN sys_role_menu rm ON rm.roleId = r.id AND rm.menuId = m.id
WHERE r.code = 'SYSADMIN'
AND rm.roleId IS NULL;
+76
View File
@@ -0,0 +1,76 @@
-- 风采墙栏目表,适用于 MySQL 8。
CREATE TABLE IF NOT EXISTS `spread_category` (
`id` varchar(32) NOT NULL COMMENT 'ID',
`parentId` varchar(32) NOT NULL DEFAULT '0' COMMENT '父栏目ID0表示顶级栏目',
`name` varchar(100) NOT NULL COMMENT '栏目名称',
`code` varchar(50) NOT NULL COMMENT '栏目编码',
`sortOrder` int NOT NULL DEFAULT 255 COMMENT '排序,数值越小越靠前',
`visible` tinyint NOT NULL DEFAULT 1 COMMENT '是否可见:0否,1是',
`slider` tinyint NOT NULL DEFAULT 0 COMMENT '是否参与轮播:0否,1是',
`createdBy` varchar(32) DEFAULT NULL COMMENT '创建人',
`createdAt` bigint DEFAULT NULL COMMENT '创建时间',
`updatedBy` varchar(32) DEFAULT NULL COMMENT '修改人',
`updatedAt` bigint DEFAULT NULL COMMENT '修改时间',
`delFlag` tinyint NOT NULL DEFAULT 0 COMMENT '删除标记',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_spread_category_code` (`code`),
KEY `idx_spread_category_parent` (`parentId`),
KEY `idx_spread_category_visible_sort` (`visible`, `sortOrder`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='风采墙栏目';
-- 风采墙内容表。
CREATE TABLE IF NOT EXISTS `spread_content` (
`id` varchar(32) NOT NULL COMMENT 'ID',
`categoryId` varchar(32) NOT NULL COMMENT '栏目ID',
`categoryName` varchar(100) NOT NULL COMMENT '栏目名称',
`title` varchar(300) NOT NULL COMMENT '标题',
`subTitle` varchar(255) DEFAULT NULL COMMENT '副标题',
`summary` varchar(1000) DEFAULT NULL COMMENT '摘要',
`contentBody` longtext COMMENT 'HTML正文',
`thumbUrl` varchar(1000) DEFAULT NULL COMMENT '封面图',
`sliderImages` longtext COMMENT '轮播图片JSON',
`isTop` tinyint NOT NULL DEFAULT 0 COMMENT '是否置顶',
`isRecommend` tinyint NOT NULL DEFAULT 0 COMMENT '是否推荐',
`isHeadline` tinyint NOT NULL DEFAULT 0 COMMENT '是否头条',
`viewCount` int NOT NULL DEFAULT 0 COMMENT '浏览量',
`sortOrder` int NOT NULL DEFAULT 255 COMMENT '排序',
`isPublished` tinyint NOT NULL DEFAULT 0 COMMENT '是否发布',
`publishedAt` bigint DEFAULT NULL COMMENT '发布时间',
`createdBy` varchar(32) DEFAULT NULL COMMENT '创建人',
`createdAt` bigint DEFAULT NULL COMMENT '创建时间',
`updatedBy` varchar(32) DEFAULT NULL COMMENT '修改人',
`updatedAt` bigint DEFAULT NULL COMMENT '修改时间',
`delFlag` tinyint NOT NULL DEFAULT 0 COMMENT '删除标记',
PRIMARY KEY (`id`),
KEY `idx_spread_content_category` (`categoryId`),
KEY `idx_spread_content_publish` (`isPublished`, `publishedAt`),
KEY `idx_spread_content_top_sort` (`isTop`, `sortOrder`),
KEY `idx_spread_content_created` (`createdAt`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='风采墙内容';
-- 初始化栏目,仅在对应编码不存在时插入,脚本可重复执行。
INSERT INTO `spread_category`
(`id`, `parentId`, `name`, `code`, `sortOrder`, `visible`, `slider`, `createdBy`, `createdAt`, `updatedBy`, `updatedAt`, `delFlag`)
SELECT '9f5c0100000000000000000000000001', '0', '风采展示', 'style_wall', 1, 1, 1, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0
WHERE NOT EXISTS (SELECT 1 FROM `spread_category` WHERE `code` = 'style_wall');
INSERT INTO `spread_category`
(`id`, `parentId`, `name`, `code`, `sortOrder`, `visible`, `slider`, `createdBy`, `createdAt`, `updatedBy`, `updatedAt`, `delFlag`)
SELECT '9f5c0100000000000000000000000002', p.id, '劳模风采', 'model_worker_style', 1, 1, 1, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0
FROM `spread_category` p
WHERE p.code = 'style_wall'
AND NOT EXISTS (SELECT 1 FROM `spread_category` WHERE `code` = 'model_worker_style');
INSERT INTO `spread_category`
(`id`, `parentId`, `name`, `code`, `sortOrder`, `visible`, `slider`, `createdBy`, `createdAt`, `updatedBy`, `updatedAt`, `delFlag`)
SELECT '9f5c0100000000000000000000000003', p.id, '巾帼风采', 'women_style', 2, 1, 1, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0
FROM `spread_category` p
WHERE p.code = 'style_wall'
AND NOT EXISTS (SELECT 1 FROM `spread_category` WHERE `code` = 'women_style');
INSERT INTO `spread_category`
(`id`, `parentId`, `name`, `code`, `sortOrder`, `visible`, `slider`, `createdBy`, `createdAt`, `updatedBy`, `updatedAt`, `delFlag`)
SELECT '9f5c0100000000000000000000000004', p.id, '教工风采', 'faculty_style', 3, 1, 1, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0
FROM `spread_category` p
WHERE p.code = 'style_wall'
AND NOT EXISTS (SELECT 1 FROM `spread_category` WHERE `code` = 'faculty_style');
@@ -0,0 +1,176 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<guava ref="guava">
<el-card shadow="never">
<table-tool :app="this" label="风采墙栏目">
<el-button size="medium" type="primary" icon="el-icon-plus" @click="openAdd">新增栏目</el-button>
</table-tool>
<el-table v-loading="loading" :data="tableData" border row-key="id"
:tree-props="{children: 'children'}" default-expand-all>
<el-table-column prop="name" label="栏目名称" min-width="220"></el-table-column>
<el-table-column prop="code" label="栏目编码" min-width="180"></el-table-column>
<el-table-column prop="sortOrder" label="排序" width="90" align="center"></el-table-column>
<el-table-column label="显示" width="100" align="center">
<template slot-scope="{row}">
<el-switch :value="row.visible === 1" @change="changeVisible(row, $event)"></el-switch>
</template>
</el-table-column>
<el-table-column label="轮播" width="100" align="center">
<template slot-scope="{row}">
<el-tag :type="row.slider === 1 ? 'success' : 'info'" size="mini">
{{row.slider === 1 ? '开启' : '关闭'}}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="190" align="center">
<template slot-scope="{row}">
<el-button type="primary" size="mini" @click="openEdit(row)">编辑</el-button>
<el-button type="danger" size="mini" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</guava>
<el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="560px" :close-on-click-modal="false">
<el-form ref="categoryForm" :model="formData" :rules="rules" label-width="100px">
<el-form-item label="上级栏目" prop="parentId">
<el-select v-model="formData.parentId" style="width: 100%" placeholder="请选择上级栏目">
<el-option label="顶级栏目" value="0"></el-option>
<el-option v-for="item in rootOptions" :key="item.id" :label="item.name" :value="item.id"
:disabled="item.id === formData.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="栏目名称" prop="name">
<el-input v-model.trim="formData.name" maxlength="100" show-word-limit></el-input>
</el-form-item>
<el-form-item label="栏目编码" prop="code">
<el-input v-model.trim="formData.code" maxlength="50" placeholder="例如:teacher_style"></el-input>
</el-form-item>
<el-form-item label="排序" prop="sortOrder">
<el-input-number v-model="formData.sortOrder" :min="0" :max="9999" style="width: 100%"></el-input-number>
</el-form-item>
<el-form-item label="显示状态">
<el-radio-group v-model="formData.visible">
<el-radio :label="1">显示</el-radio>
<el-radio :label="0">隐藏</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="参与轮播">
<el-radio-group v-model="formData.slider">
<el-radio :label="1">开启</el-radio>
<el-radio :label="0">关闭</el-radio>
</el-radio-group>
<div class="el-form-item__tip">子栏目开启轮播后,其上级栏目会自动开启。</div>
</el-form-item>
</el-form>
<span slot="footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">确定</el-button>
</span>
</el-dialog>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
loading: false,
submitting: false,
tableData: [],
rootOptions: [],
dialogVisible: false,
dialogTitle: "",
formData: {},
rules: {
parentId: [{required: true, message: "请选择上级栏目", trigger: "change"}],
name: [{required: true, message: "请输入栏目名称", trigger: "blur"}],
code: [
{required: true, message: "请输入栏目编码", trigger: "blur"},
{pattern: /^[A-Za-z][A-Za-z0-9_-]*$/, message: "编码须以字母开头,仅支持字母、数字、下划线和短横线", trigger: "blur"}
],
sortOrder: [{required: true, message: "请输入排序", trigger: "change"}]
}
}
},
methods: {
// 加载栏目树,同时提取顶级栏目作为上级栏目选项。
loadData() {
this.loading = true
this.$axios.post(loc() + "/treeData").then(resp => {
if (resp.code === 0) {
this.tableData = resp.data || []
this.rootOptions = (resp.data || []).slice()
} else {
this.$message.warning(resp.msg)
}
}).finally(() => this.loading = false)
},
// 构造新增栏目默认值。
openAdd() {
this.dialogTitle = "新增栏目"
this.formData = {parentId: "0", name: "", code: "", sortOrder: 255, visible: 1, slider: 0}
this.dialogVisible = true
this.$nextTick(() => this.$refs.categoryForm && this.$refs.categoryForm.clearValidate())
},
// 将当前行复制到表单,避免直接修改表格数据。
openEdit(row) {
this.dialogTitle = "编辑栏目"
this.formData = Object.assign({}, row)
delete this.formData.children
this.dialogVisible = true
this.$nextTick(() => this.$refs.categoryForm && this.$refs.categoryForm.clearValidate())
},
// 校验后调用新增或编辑接口。
submit() {
this.$refs.categoryForm.validate(valid => {
if (!valid) return
this.submitting = true
const api = this.formData.id ? "/doEdit" : "/doAdd"
this.$axios.post(loc() + api, this.formData).then(resp => {
if (resp.code === 0) {
this.$message.success(resp.msg || "保存成功")
this.dialogVisible = false
this.loadData()
} else {
this.$message.warning(resp.msg)
}
}).finally(() => this.submitting = false)
})
},
// 修改栏目显示状态,失败时重新加载以恢复开关。
changeVisible(row, checked) {
this.$axios.post(loc() + "/doVisible", {id: row.id, visible: checked ? 1 : 0}).then(resp => {
if (resp.code === 0) {
this.$message.success("设置成功")
} else {
this.$message.warning(resp.msg)
}
this.loadData()
})
},
// 删除前进行二次确认;存在内容时后端会阻止删除。
remove(row) {
this.$confirm("确定删除栏目“" + row.name + "”吗?", "提示", {type: "warning"}).then(() => {
this.$axios.post(loc() + "/doDelete", {id: row.id}).then(resp => {
if (resp.code === 0) {
this.$message.success("删除成功")
this.loadData()
} else {
this.$message.warning(resp.msg)
}
})
})
}
},
created() {
this.loadData()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,198 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
.spread-editor-page .form-card { max-width: 1180px; margin: 0 auto; }
.spread-editor-page .form-tip { color: #909399; font-size: 12px; line-height: 20px; }
.spread-editor-page .footer-actions { padding: 18px 0 6px; text-align: center; }
</style>
<div id="app" class="spread-editor-page" v-cloak>
<el-card class="form-card" shadow="never" v-loading="loading">
<div slot="header">
<span style="font-size: 17px;font-weight: 600">{{formData.id ? '编辑风采内容' : '发布风采内容'}}</span>
</div>
<el-form ref="contentForm" :model="formData" :rules="rules" label-width="105px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="所属栏目" prop="categoryId">
<el-cascader v-model="formData.categoryId" :options="categoryOptions"
:props="{value:'id', label:'name', children:'children', emitPath:false, checkStrictly:false}"
filterable clearable style="width: 100%" placeholder="请选择末级栏目"></el-cascader>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序" prop="sortOrder">
<el-input-number v-model="formData.sortOrder" :min="0" :max="9999" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="标题" prop="title">
<el-input v-model.trim="formData.title" maxlength="300" show-word-limit></el-input>
</el-form-item>
<el-form-item label="副标题">
<el-input v-model.trim="formData.subTitle" maxlength="255" show-word-limit></el-input>
</el-form-item>
<el-form-item label="摘要" prop="summary">
<el-input v-model="formData.summary" type="textarea" :rows="3" maxlength="1000" show-word-limit
placeholder="用于风采墙列表展示"></el-input>
</el-form-item>
<el-form-item label="封面图" prop="thumbUrl">
<file-upload :upload_number="1" :upload_size="10 * 1024 * 1024"
:value.sync="formData.thumbUrl" accept=".jpg,.jpeg,.png,.webp"
upload_mode="image" upload_result_category="interval"></file-upload>
<div class="form-tip">建议使用横版图片,单张不超过 10MB。</div>
</el-form-item>
<el-form-item label="轮播图片">
<file-upload :upload_number="10" :upload_size="10 * 1024 * 1024"
:value.sync="sliderFiles" accept=".jpg,.jpeg,.png,.webp"
upload_mode="image" upload_result_category="array"></file-upload>
<div class="form-tip">仅栏目开启“参与轮播”后生效,最多上传 10 张。</div>
</el-form-item>
<el-form-item label="正文" prop="contentBody">
<text-editor v-model="formData.contentBody" :height="360" placeholder="请输入风采内容"></text-editor>
</el-form-item>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="置顶">
<el-switch v-model="formData.isTop" :active-value="1" :inactive-value="0"></el-switch>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="推荐">
<el-switch v-model="formData.isRecommend" :active-value="1" :inactive-value="0"></el-switch>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="头条">
<el-switch v-model="formData.isHeadline" :active-value="1" :inactive-value="0"></el-switch>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="发布状态">
<el-radio-group v-model="formData.isPublished">
<el-radio :label="0">保存草稿</el-radio>
<el-radio :label="1">立即发布</el-radio>
</el-radio-group>
</el-form-item>
<div class="footer-actions">
<el-button @click="resetForm">重置</el-button>
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
</div>
</el-form>
</el-card>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
loading: false,
submitting: false,
categoryOptions: [],
sliderFiles: [],
formData: {},
rules: {
categoryId: [{required: true, message: "请选择所属栏目", trigger: "change"}],
title: [{required: true, message: "请输入标题", trigger: "blur"}],
summary: [{required: true, message: "请输入摘要", trigger: "blur"}],
contentBody: [{required: true, message: "请输入正文", trigger: "change"}]
}
}
},
methods: {
// 生成新增内容时使用的默认表单。
defaultForm() {
return {
categoryId: "", title: "", subTitle: "", summary: "", contentBody: "",
thumbUrl: "", sliderImages: "", isTop: 0, isRecommend: 0,
isHeadline: 0, viewCount: 0, sortOrder: 255, isPublished: 0
}
},
// 读取地址栏中的编辑内容 ID。
getEditId() {
return new URLSearchParams(window.location.search).get("id") || ""
},
// 加载栏目树,内容只能选择末级栏目。
loadCategories() {
return this.$axios.post(loc() + "/categoryTree").then(resp => {
if (resp.code === 0) {
this.categoryOptions = resp.data || []
} else {
this.$message.warning(resp.msg)
}
})
},
// 编辑状态下加载内容详情并还原轮播图片数组。
loadDetail(id) {
if (!id) return Promise.resolve()
this.loading = true
return this.$axios.post(loc() + "/detailData", {id}).then(resp => {
if (resp.code === 0) {
this.formData = Object.assign(this.defaultForm(), resp.data || {})
this.sliderFiles = this.parseSliderFiles(this.formData.sliderImages)
} else {
this.$message.warning(resp.msg)
}
}).finally(() => this.loading = false)
},
// 兼容数据库中的 JSON 数组和历史逗号分隔地址。
parseSliderFiles(value) {
if (!value) return []
if (Array.isArray(value)) return value
try {
const urls = JSON.parse(value)
return Array.isArray(urls) ? urls : []
} catch (e) {
return String(value).split(",").filter(Boolean)
}
},
// 将上传组件返回的图片数组转换为可持久化 JSON。
buildSliderImages() {
const urls = (this.sliderFiles || []).map(item => {
if (typeof item === "string") return item
return item.url || item.data || (item.response && item.response.data) || ""
}).filter(Boolean)
return JSON.stringify(urls)
},
// 重置新增表单;编辑状态下恢复数据库内容。
resetForm() {
const id = this.getEditId()
if (id) {
this.loadDetail(id)
} else {
this.formData = this.defaultForm()
this.sliderFiles = []
}
this.$nextTick(() => this.$refs.contentForm && this.$refs.contentForm.clearValidate())
},
// 校验并提交新增或编辑数据。
submit() {
this.$refs.contentForm.validate(valid => {
if (!valid) return
const data = Object.assign({}, this.formData, {sliderImages: this.buildSliderImages()})
const api = data.id ? "/doEdit" : "/doAdd"
this.submitting = true
this.$axios.post(loc() + api, data).then(resp => {
if (resp.code === 0) {
this.$message.success(resp.msg || "保存成功")
if (!data.id) {
this.resetForm()
}
} else {
this.$message.warning(resp.msg)
}
}).finally(() => this.submitting = false)
})
}
},
created() {
this.formData = this.defaultForm()
const id = this.getEditId()
Promise.all([this.loadCategories(), this.loadDetail(id)])
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,215 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
#sub-app-container-main-content { padding: 0 !important; background: #f6f2ea; }
.spread-wall { min-height: 100%; color: #3c2f2a; background: linear-gradient(180deg, #8f171d 0, #bd352b 330px, #f6f2ea 330px); }
.spread-hero { max-width: 1280px; margin: 0 auto; padding: 34px 32px 26px; color: #fff; }
.spread-title { margin: 0; font-family: "Microsoft YaHei", serif; font-size: 34px; letter-spacing: 5px; }
.spread-subtitle { margin-top: 9px; color: rgba(255,255,255,.78); font-size: 14px; letter-spacing: 2px; }
.hero-slider { height: 205px; margin-top: 24px; overflow: hidden; border-radius: 12px; box-shadow: 0 14px 34px rgba(64, 10, 12, .28); background: #7d1b1f; }
.hero-slider .el-carousel, .hero-slider .el-carousel__container { height: 205px; }
.slider-item { height: 100%; position: relative; cursor: pointer; background: linear-gradient(135deg, #781116, #c44834); }
.slider-item img { width: 100%; height: 100%; object-fit: cover; opacity: .78; }
.slider-caption { position: absolute; left: 0; right: 0; bottom: 0; padding: 34px 28px 18px; color: #fff; font-size: 21px; font-weight: 600; background: linear-gradient(transparent, rgba(28,4,5,.78)); }
.slider-empty { height: 100%; display: flex; align-items: center; justify-content: center; font-size: 25px; letter-spacing: 6px; }
.spread-main { max-width: 1280px; margin: 0 auto; padding: 0 32px 38px; display: grid; grid-template-columns: 245px minmax(0, 1fr); gap: 22px; }
.category-panel, .content-panel { background: #fff; border-radius: 12px; box-shadow: 0 8px 28px rgba(90,53,34,.09); }
.category-panel { align-self: start; overflow: hidden; }
.category-header { padding: 17px 20px; color: #fff; font-size: 17px; font-weight: 600; background: #941d22; }
.category-parent { padding: 15px 18px 9px; color: #7e2224; font-weight: 700; border-top: 1px solid #f0e7df; }
.category-child { padding: 11px 18px 11px 29px; cursor: pointer; color: #655650; border-left: 3px solid transparent; transition: .2s; }
.category-child:hover, .category-child.active { color: #a32227; background: #fff3ed; border-left-color: #a32227; }
.content-panel { min-height: 570px; padding: 24px 28px; }
.section-heading { margin-bottom: 12px; padding-bottom: 14px; display: flex; align-items: flex-end; justify-content: space-between; border-bottom: 1px solid #eee2d9; }
.section-heading h2 { margin: 0; color: #7c1b20; font-size: 23px; }
.section-heading span { color: #a0938c; font-size: 13px; }
.content-card { padding: 17px 4px; display: grid; grid-template-columns: 72px minmax(0,1fr) 80px; gap: 18px; align-items: center; cursor: pointer; border-bottom: 1px solid #f0e9e4; transition: .2s; }
.content-card:hover { padding-left: 10px; background: #fffaf7; }
.content-date { color: #8f7770; text-align: center; border-right: 1px solid #eadfd7; }
.content-date strong { display: block; color: #8f1d22; font-size: 25px; }
.content-title { margin-bottom: 7px; color: #392c28; font-size: 17px; font-weight: 700; }
.content-summary { overflow: hidden; color: #7c6d67; font-size: 13px; line-height: 21px; text-overflow: ellipsis; white-space: nowrap; }
.content-view { color: #aa9d96; font-size: 12px; text-align: right; }
.wall-pagination { margin-top: 25px; text-align: center; }
.detail-title { margin: 8px 0 12px; color: #672025; font-size: 28px; text-align: center; }
.detail-meta { padding-bottom: 18px; color: #9c8d86; text-align: center; border-bottom: 1px solid #eee2d9; }
.detail-cover { max-width: 100%; max-height: 430px; margin: 24px auto; display: block; object-fit: contain; border-radius: 8px; }
.detail-body { color: #493b36; font-size: 16px; line-height: 1.9; word-break: break-word; }
.back-link { margin-bottom: 12px; }
@media (max-width: 800px) { .spread-main { grid-template-columns: 1fr; padding: 0 12px 24px; } .spread-hero { padding: 24px 12px; } .category-panel { position: static; } }
</style>
<div id="app" class="spread-wall" v-cloak>
<header class="spread-hero">
<h1 class="spread-title">风采墙</h1>
<div class="spread-subtitle">记录奋斗身影 · 展示教工风采 · 凝聚榜样力量</div>
<div class="hero-slider">
<el-carousel v-if="sliderItems.length" :interval="5000" arrow="hover">
<el-carousel-item v-for="item in sliderItems" :key="item.id">
<div class="slider-item" @click="openDetail(item)">
<img v-if="getSliderImage(item)" :src="getSliderImage(item)" :alt="item.title">
<div class="slider-caption">{{item.title}}</div>
</div>
</el-carousel-item>
</el-carousel>
<div v-else class="slider-empty">榜样引领 · 风采绽放</div>
</div>
</header>
<div class="spread-main">
<aside class="category-panel">
<div class="category-header"><i class="el-icon-collection-tag"></i> 风采栏目</div>
<template v-for="parent in categories">
<div :key="parent.id + '-p'" class="category-parent">{{parent.name}}</div>
<div v-if="!parent.children || !parent.children.length" :key="parent.id"
class="category-child" :class="{active: currentCategory.id === parent.id}"
@click="selectCategory(parent)">{{parent.name}}</div>
<div v-for="child in parent.children" :key="child.id"
class="category-child" :class="{active: currentCategory.id === child.id}"
@click="selectCategory(child)">{{child.name}}</div>
</template>
</aside>
<main class="content-panel" v-loading="loading">
<template v-if="!detail">
<div class="section-heading">
<h2>{{currentCategory.name || '风采展示'}}</h2>
<span>展示教职工的先进事迹与精彩瞬间</span>
</div>
<article v-for="item in pageData.list" :key="item.id" class="content-card" @click="openDetail(item)">
<div class="content-date">
<strong>{{datePart(item.publishedAt, 'DD')}}</strong>
{{datePart(item.publishedAt, 'YYYY.MM')}}
</div>
<div>
<div class="content-title">
<el-tag v-if="item.isTop === 1" type="danger" size="mini">置顶</el-tag>
{{item.title}}
</div>
<div class="content-summary">{{item.summary || item.subTitle || '点击查看详细内容'}}</div>
</div>
<div class="content-view"><i class="el-icon-view"></i> {{item.viewCount || 0}}</div>
</article>
<el-empty v-if="!pageData.list.length && !loading" description="暂无风采内容"></el-empty>
<el-pagination v-if="pageData.totalCount > pageData.pageSize" class="wall-pagination"
background layout="prev, pager, next" :current-page.sync="pageData.pageNumber"
:page-size="pageData.pageSize" :total="pageData.totalCount"
@current-change="loadPage"></el-pagination>
</template>
<template v-else>
<el-button class="back-link" icon="el-icon-back" plain size="small" @click="detail = null">返回列表</el-button>
<h1 class="detail-title">{{detail.title}}</h1>
<div class="detail-meta">
{{formatTime(detail.publishedAt || detail.createdAt)}} 
{{detail.categoryName}}  <i class="el-icon-view"></i> {{detail.viewCount || 0}}
</div>
<img v-if="getCoverUrl(detail.thumbUrl)" class="detail-cover" :src="getCoverUrl(detail.thumbUrl)" :alt="detail.title">
<div class="detail-body" v-html="detail.contentBody"></div>
</template>
</main>
</div>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
apiBase: "/platform/spread/display",
loading: false,
categories: [],
sliderItems: [],
currentCategory: {},
detail: null,
pageData: {pageNumber: 1, pageSize: 8, totalCount: 0, list: []}
}
},
methods: {
// 加载可见栏目并自动选择第一个末级栏目。
loadCategories() {
return this.$axios.post(this.apiBase + "/categoryTree").then(resp => {
if (resp.code !== 0) return
this.categories = resp.data || []
const firstParent = this.categories[0]
this.currentCategory = firstParent && firstParent.children && firstParent.children.length
? firstParent.children[0] : (firstParent || {})
})
},
// 加载开启轮播栏目的最新发布内容。
loadSlider() {
return this.$axios.post(this.apiBase + "/sliderData").then(resp => {
if (resp.code === 0) this.sliderItems = resp.data || []
})
},
// 根据当前栏目加载已发布内容。
loadPage() {
if (!this.currentCategory.id) {
this.pageData.list = []
return
}
this.loading = true
this.$axios.post(this.apiBase + "/pageData", {
categoryId: this.currentCategory.id,
pageNumber: this.pageData.pageNumber,
pageSize: this.pageData.pageSize
}).then(resp => {
if (resp.code === 0) this.pageData = resp.data
}).finally(() => this.loading = false)
},
// 切换栏目时回到第一页和列表状态。
selectCategory(category) {
this.currentCategory = category
this.detail = null
this.pageData.pageNumber = 1
this.loadPage()
},
// 获取完整详情并登记一次浏览。
openDetail(item) {
this.loading = true
Promise.all([
this.$axios.post(this.apiBase + "/detailData", {id: item.id}),
this.$axios.post(this.apiBase + "/addView", {id: item.id})
]).then(values => {
if (values[0].code === 0) {
this.detail = values[0].data
this.detail.viewCount = Number(this.detail.viewCount || 0) + 1
}
}).finally(() => this.loading = false)
},
// 从上传字段中取得第一张图片地址。
getCoverUrl(value) {
if (!value) return ""
if (Array.isArray(value)) return value.length ? (value[0].url || value[0].data || value[0]) : ""
if (typeof value === "string" && value.trim().startsWith("[")) {
try {
const files = JSON.parse(value)
const first = files[0]
return first ? (first.url || first.data || first) : ""
} catch (e) {
return ""
}
}
return String(value).split(",")[0]
},
// 轮播图优先使用轮播图片,未配置时回退到封面。
getSliderImage(item) {
return this.getCoverUrl(item.sliderImages) || this.getCoverUrl(item.thumbUrl)
},
// 格式化列表日期的指定部分。
datePart(value, format) {
return value ? this.$moment(Number(value)).format(format) : "--"
},
// 格式化详情发布时间。
formatTime(value) {
return value ? this.$moment(Number(value)).format("YYYY-MM-DD HH:mm") : ""
}
},
created() {
Promise.all([this.loadCategories(), this.loadSlider()]).then(() => this.loadPage())
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,180 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<guava ref="guava">
<el-card shadow="never">
<search :is-search-button="false">
<search-item label="内容标题">
<el-input v-model="pageForm.title" clearable placeholder="请输入标题" @keyup.enter.native="doSearch"></el-input>
</search-item>
<search-item label="所属栏目">
<el-cascader v-model="pageForm.categoryId" :options="categoryOptions"
:props="{value:'id',label:'name',children:'children',emitPath:false,checkStrictly:true}"
clearable filterable style="width: 100%"></el-cascader>
</search-item>
<search-item label="发布状态">
<el-select v-model="pageForm.isPublished" clearable placeholder="请选择">
<el-option label="草稿" :value="0"></el-option>
<el-option label="已发布" :value="1"></el-option>
</el-select>
</search-item>
<search-item label="创建时间">
<el-date-picker v-model="pageForm.createTime" type="datetimerange"
start-placeholder="开始时间" end-placeholder="结束时间"
value-format="yyyy-MM-dd HH:mm:ss" style="width: 100%"></el-date-picker>
</search-item>
<div class="search-query">
<el-button type="primary" size="medium" icon="el-icon-search" @click="doSearch">搜索</el-button>
<el-button size="medium" @click="resetSearch">重置</el-button>
</div>
</search>
</el-card>
<el-card class="mt10" shadow="never">
<table-tool :app="this" label="风采内容列表">
<el-button type="primary" size="medium" icon="el-icon-plus" @click="goCreate">发布内容</el-button>
</table-tool>
<el-table v-loading="tableLoading" :data="tableData" :size="tableSize" border>
<el-table-column :index="indexMethod" type="index" label="序号" width="70" align="center"></el-table-column>
<el-table-column prop="title" label="标题" min-width="240" show-overflow-tooltip>
<template slot-scope="{row}">
<el-tag v-if="row.isTop === 1" type="danger" size="mini" style="margin-right: 5px">置顶</el-tag>
{{row.title}}
</template>
</el-table-column>
<el-table-column prop="categoryName" label="栏目" min-width="130"></el-table-column>
<el-table-column prop="viewCount" label="浏览量" width="100" align="center"></el-table-column>
<el-table-column prop="sortOrder" label="排序" width="90" align="center"></el-table-column>
<el-table-column label="状态" width="100" align="center">
<template slot-scope="{row}">
<el-tag :type="row.isPublished === 1 ? 'success' : 'info'" size="mini">
{{row.isPublished === 1 ? '已发布' : '草稿'}}
</el-tag>
</template>
</el-table-column>
<el-table-column label="发布时间" width="170" align="center">
<template slot-scope="{row}">{{formatTime(row.publishedAt)}}</template>
</el-table-column>
<el-table-column label="操作" width="270" align="center" fixed="right">
<template slot-scope="{row}">
<el-button size="mini" type="primary" @click="goEdit(row)">编辑</el-button>
<el-button size="mini" :type="row.isPublished === 1 ? 'warning' : 'success'"
@click="togglePublish(row)">
{{row.isPublished === 1 ? '撤回' : '发布'}}
</el-button>
<el-button size="mini" type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</guava>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
mixins: [initTableMixins],
data() {
return {
apiBase: "/platform/spread/manage",
categoryOptions: []
}
},
methods: {
// 组装分页查询参数,将时间范围拆成后端字段。
buildPageParams() {
const params = Object.assign({}, this.pageForm)
params.beginTime = params.createTime && params.createTime.length ? params.createTime[0] : ""
params.endTime = params.createTime && params.createTime.length ? params.createTime[1] : ""
delete params.createTime
return params
},
// 加载内容管理分页数据。
pageData() {
this.tableLoading = true
this.$axios.post(this.apiBase + "/pageData", this.buildPageParams()).then(resp => {
if (resp.code === 0) {
this.tableData = resp.data.list || []
this.pageForm.totalCount = resp.data.totalCount || 0
} else {
this.$message.warning(resp.msg)
}
}).finally(() => this.tableLoading = false)
},
// 加载可筛选的栏目树。
loadCategories() {
return this.$axios.post(this.apiBase + "/categoryTree").then(resp => {
if (resp.code === 0) this.categoryOptions = resp.data || []
})
},
// 清空筛选条件并重新查询。
resetSearch() {
this.pageForm.title = ""
this.pageForm.categoryId = ""
this.pageForm.isPublished = ""
this.pageForm.createTime = []
this.doSearch()
},
// 跳转到独立发布页面。
goCreate() {
this.navigate("/platform/spread/creation")
},
// 跳转到发布页面并携带编辑 ID。
goEdit(row) {
this.navigate("/platform/spread/creation?id=" + encodeURIComponent(row.id))
},
// 兼容 pjax 与普通页面跳转。
navigate(url) {
if (window.commonUtil && commonUtil.pjaxPush) {
commonUtil.pjaxPush(url)
} else {
window.location.href = url
}
},
// 发布已保存的草稿,或撤回已发布内容。
togglePublish(row) {
const status = row.isPublished === 1 ? 0 : 1
const action = status === 1 ? "发布" : "撤回"
this.$confirm("确定" + action + "“" + row.title + "”吗?", "提示", {type: "warning"}).then(() => {
this.$axios.post(this.apiBase + "/doPublish", {id: row.id, status}).then(resp => {
if (resp.code === 0) {
this.$message.success(action + "成功")
this.pageData()
} else {
this.$message.warning(resp.msg)
}
})
})
},
// 删除内容前进行二次确认。
remove(row) {
this.$confirm("删除后无法恢复,确定删除该内容吗?", "提示", {type: "warning"}).then(() => {
this.$axios.post(this.apiBase + "/doDelete", {id: row.id}).then(resp => {
if (resp.code === 0) {
this.$message.success("删除成功")
this.pageData()
} else {
this.$message.warning(resp.msg)
}
})
})
},
// 将毫秒时间戳格式化为日期时间。
formatTime(value) {
return value ? this.$moment(Number(value)).format("YYYY-MM-DD HH:mm:ss") : ""
}
},
created() {
this.$set(this.pageForm, "title", "")
this.$set(this.pageForm, "categoryId", "")
this.$set(this.pageForm, "isPublished", "")
this.$set(this.pageForm, "createTime", [])
this.loadCategories().then(() => this.pageData())
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,178 @@
<!--#
layout("/layouts/platform.html"){
#-->
<script src="${base!}/assets/platform/plugins/echarts/echarts.min.js" nonce="${cspNonce!}"></script>
<style>
.spread-statistics .stat-grid { margin: 10px 0; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 14px; }
.spread-statistics .stat-card { padding: 20px; position: relative; overflow: hidden; border-radius: 9px; color: #fff; box-shadow: 0 8px 22px rgba(30,50,80,.1); }
.spread-statistics .stat-card:nth-child(1) { background: linear-gradient(135deg,#3378d1,#58a1ef); }
.spread-statistics .stat-card:nth-child(2) { background: linear-gradient(135deg,#7357c8,#9d7ee7); }
.spread-statistics .stat-card:nth-child(3) { background: linear-gradient(135deg,#2b9c73,#50c499); }
.spread-statistics .stat-card:nth-child(4) { background: linear-gradient(135deg,#d87a2e,#efa55b); }
.spread-statistics .stat-card:nth-child(5) { background: linear-gradient(135deg,#bd4552,#e36c78); }
.spread-statistics .stat-label { color: rgba(255,255,255,.82); font-size: 13px; }
.spread-statistics .stat-value { margin-top: 8px; font-size: 29px; font-weight: 700; }
.spread-statistics .chart-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, .75fr); gap: 14px; }
.spread-statistics .chart-box { height: 390px; }
@media(max-width: 980px) { .spread-statistics .stat-grid { grid-template-columns: repeat(2, 1fr); } .spread-statistics .chart-grid { grid-template-columns: 1fr; } }
</style>
<div id="app" class="spread-statistics" v-cloak>
<guava ref="guava">
<el-card shadow="never">
<search :is-search-button="false">
<search-item label="所属栏目">
<el-cascader v-model="query.categoryId" :options="categoryOptions"
:props="{value:'id',label:'name',children:'children',emitPath:false,checkStrictly:true}"
clearable filterable style="width: 100%"></el-cascader>
</search-item>
<search-item label="发布状态">
<el-select v-model="query.isPublished" clearable placeholder="全部">
<el-option label="草稿" :value="0"></el-option>
<el-option label="已发布" :value="1"></el-option>
</el-select>
</search-item>
<search-item label="创建时间">
<el-date-picker v-model="query.createTime" type="datetimerange"
value-format="yyyy-MM-dd HH:mm:ss" start-placeholder="开始时间"
end-placeholder="结束时间" style="width: 100%"></el-date-picker>
</search-item>
<div class="search-query">
<el-button type="primary" size="medium" icon="el-icon-search" @click="loadAll">查询</el-button>
<el-button size="medium" @click="resetQuery">重置</el-button>
</div>
</search>
</el-card>
<div class="stat-grid" v-loading="loading">
<div class="stat-card"><div class="stat-label">栏目数量</div><div class="stat-value">{{summary.categoryCount || 0}}</div></div>
<div class="stat-card"><div class="stat-label">内容总数</div><div class="stat-value">{{summary.contentCount || 0}}</div></div>
<div class="stat-card"><div class="stat-label">已发布</div><div class="stat-value">{{summary.publishedCount || 0}}</div></div>
<div class="stat-card"><div class="stat-label">累计浏览</div><div class="stat-value">{{summary.viewCount || 0}}</div></div>
<div class="stat-card"><div class="stat-label">平均浏览</div><div class="stat-value">{{summary.avgViewCount || 0}}</div></div>
</div>
<div class="chart-grid">
<el-card shadow="never">
<div slot="header"><b>浏览量 TOP 10</b></div>
<div ref="topChart" class="chart-box"></div>
</el-card>
<el-card shadow="never">
<div slot="header"><b>栏目浏览量占比</b></div>
<div ref="categoryChart" class="chart-box"></div>
</el-card>
</div>
<el-card class="mt10" shadow="never">
<div slot="header"><b>栏目数据明细</b></div>
<el-table :data="categoryData" border>
<el-table-column type="index" label="序号" width="70" align="center"></el-table-column>
<el-table-column prop="categoryName" label="栏目名称" min-width="180"></el-table-column>
<el-table-column prop="contentCount" label="内容数量" width="140" align="center"></el-table-column>
<el-table-column prop="viewCount" label="浏览量" width="140" align="center"></el-table-column>
</el-table>
</el-card>
</guava>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
apiBase: "/platform/spread/statistics",
loading: false,
query: {categoryId: "", isPublished: "", createTime: []},
categoryOptions: [],
summary: {},
topData: [],
categoryData: [],
topChart: null,
categoryChart: null
}
},
methods: {
// 将时间范围拆分成统计接口参数。
buildParams() {
return {
categoryId: this.query.categoryId,
isPublished: this.query.isPublished,
beginTime: this.query.createTime && this.query.createTime.length ? this.query.createTime[0] : "",
endTime: this.query.createTime && this.query.createTime.length ? this.query.createTime[1] : ""
}
},
// 加载栏目筛选树。
loadCategories() {
return this.$axios.post(this.apiBase + "/categoryTree").then(resp => {
if (resp.code === 0) this.categoryOptions = resp.data || []
})
},
// 并行加载卡片、排行和栏目统计,减少页面等待时间。
loadAll() {
this.loading = true
const params = this.buildParams()
Promise.all([
this.$axios.post(this.apiBase + "/summary", params),
this.$axios.post(this.apiBase + "/top10", params),
this.$axios.post(this.apiBase + "/categoryData", params)
]).then(values => {
this.summary = values[0].code === 0 ? (values[0].data || {}) : {}
this.topData = values[1].code === 0 ? (values[1].data || []) : []
this.categoryData = values[2].code === 0 ? (values[2].data || []) : []
this.$nextTick(this.renderCharts)
}).finally(() => this.loading = false)
},
// 清空筛选并重新统计。
resetQuery() {
this.query = {categoryId: "", isPublished: "", createTime: []}
this.loadAll()
},
// 创建或刷新两个统计图表。
renderCharts() {
if (!window.echarts) return
if (!this.topChart) this.topChart = echarts.init(this.$refs.topChart)
if (!this.categoryChart) this.categoryChart = echarts.init(this.$refs.categoryChart)
this.topChart.setOption({
color: ["#b43135"],
tooltip: {trigger: "axis"},
grid: {left: 45, right: 20, top: 25, bottom: 95},
xAxis: {
type: "category",
data: this.topData.map(item => item.title),
axisLabel: {interval: 0, rotate: 35, formatter: value => value.length > 10 ? value.slice(0, 10) + "…" : value}
},
yAxis: {type: "value", minInterval: 1},
series: [{type: "bar", barMaxWidth: 34, data: this.topData.map(item => item.viewCount || 0)}]
}, true)
this.categoryChart.setOption({
color: ["#9c252a", "#d78643", "#d4ad62", "#547a86", "#7d669e", "#5a9a7c"],
tooltip: {trigger: "item"},
legend: {bottom: 0, type: "scroll"},
series: [{
type: "pie", radius: ["38%", "68%"], center: ["50%", "43%"],
label: {formatter: "{b}\n{d}%"},
data: this.categoryData.map(item => ({name: item.categoryName, value: item.viewCount || 0}))
}]
}, true)
},
// 浏览器尺寸变化时重算图表尺寸。
resizeCharts() {
if (this.topChart) this.topChart.resize()
if (this.categoryChart) this.categoryChart.resize()
}
},
created() {
this.loadCategories().then(() => this.loadAll())
},
mounted() {
window.addEventListener("resize", this.resizeCharts)
},
beforeDestroy() {
window.removeEventListener("resize", this.resizeCharts)
if (this.topChart) this.topChart.dispose()
if (this.categoryChart) this.categoryChart.dispose()
}
})
</script>
<!--#
}
#-->