Merge remote-tracking branch 'refs/remotes/origin/feature_风采墙' into feature_界面UI优化

This commit is contained in:
2026-08-04 09:07:29 +08:00
26 changed files with 2938 additions and 46 deletions
@@ -79,14 +79,19 @@ public class ProposalDashboardController {
// 节点
ProcessDefine define = processDefineService.getLastByName("JDHTA_NC");
ProcessModel processModel = processDefineService.processDefineToModel(define);
List<TaskModel> taskModels = processModel.getTasks();
// 按流程图的连线关系遍历任务节点,保证看板展示顺序与流程图一致。
List<TaskModel> taskModels = processModel.getModels(TaskModel.class);
List<String> excludeNodes = new ArrayList<>(3);
excludeNodes.add("撰写提案");
excludeNodes.add("邀请附议人");
excludeNodes.add("提案附议");
List<NutMap> taskNodes = taskModels.stream().filter(taskModel -> !excludeNodes.contains(taskModel.getDisplayName()))
// opinion_unit_reply、two_unit_reply 为临时屏蔽的承办单位答复节点,不在看板展示。
List<String> hiddenNodeIds = List.of("opinion_unit_reply", "two_unit_reply");
List<NutMap> taskNodes = new ArrayList<>(taskModels.stream()
.filter(taskModel -> !excludeNodes.contains(taskModel.getDisplayName()))
.filter(taskModel -> !hiddenNodeIds.contains(taskModel.getName()))
.map(taskModel -> NutMap.NEW()
.addv("name", taskModel.getDisplayName())
.addv("id", taskModel.getName())
@@ -94,19 +99,35 @@ public class ProposalDashboardController {
.addv("count", 0)
.addv("todoCount", 0)
.addv("doneCount", 0)
).toList();
nodes.addAll(taskNodes);
).toList());
// 立案结果
List<Sys_dict> caseFilingResult = sysDictService.getSubListByCode("PROPOSAL_CASE_FILING_RESULT");
List<NutMap> caseFilingResultNodes = new ArrayList<>();
for (Sys_dict dict : caseFilingResult) {
nodes.add(NutMap.NEW()
caseFilingResultNodes.add(NutMap.NEW()
.addv("name", dict.getName())
.addv("id", dict.getCode())
.addv("type", "caseFilingResult")
.addv("count", 0));
}
// 立案结果需展示在委员会确认承办单位之前,反馈评价统一固定到看板末尾。
List<NutMap> feedbackNodes = taskNodes.stream()
.filter(taskNode -> "feedback".equals(taskNode.getString("id")))
.toList();
taskNodes.removeIf(taskNode -> "feedback".equals(taskNode.getString("id")));
int committeeFilingUnitIndex = taskNodes.size();
for (int i = 0; i < taskNodes.size(); i++) {
if ("committeeFilingUnit".equals(taskNodes.get(i).getString("id"))) {
committeeFilingUnitIndex = i;
break;
}
}
taskNodes.addAll(committeeFilingUnitIndex, caseFilingResultNodes);
nodes.addAll(taskNodes);
nodes.addAll(feedbackNodes);
// 查询待办任务
Sql todoSql = Sqls.create("""
SELECT
@@ -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,88 @@
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 unionOptions() {
return Result.success(contentService.selectableUnions());
}
@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,109 @@
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("所属工会ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String unionId;
@Column
@Comment("所属工会名称")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String unionName;
@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,37 @@
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 com.budwk.app.sys.models.Sys_union;
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();
/**
* 查询当前登录人员可选择的工会。
*/
List<Sys_union> selectableUnions();
}
@@ -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,183 @@
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<>();
Map<String, List<NutMap>> childrenMap = new LinkedHashMap<>();
for (SpreadCategory category : categories) {
List<NutMap> children = new ArrayList<>();
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", children));
childrenMap.put(category.getId(), children);
}
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 {
// 直接向节点持有的原始集合追加,避免 NutMap 类型转换后子节点没有写回。
childrenMap.get(parentId).add(node);
}
}
// 叶子栏目不返回空 children,避免级联选择器误判为仍有下一级。
nodeMap.forEach((id, node) -> {
if (childrenMap.get(id).isEmpty()) {
node.remove("children");
}
});
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("栏目名称不能为空");
}
category.setName(category.getName().trim());
// 参考模块不要求用户维护栏目编码,空值统一保存为 NULL。
category.setCode(StrUtil.isBlank(category.getCode()) ? null : 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);
if (StrUtil.isNotBlank(excludeId)) {
nameCnd.and("id", "!=", excludeId);
}
if (count(nameCnd) > 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,318 @@
package com.budwk.app.zhgh.spread.service.impl;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.budwk.app.base.constant.RoleConstant;
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.sys.models.Sys_union;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
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.Collections;
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, c.unionId, c.unionName
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);
}
@Override
public List<Sys_union> selectableUnions() {
Cnd cnd = Cnd.NEW();
if (!canSelectAllUnions()) {
String currentUnionId = SecurityUtil.getUnionId();
if (StrUtil.isBlank(currentUnionId)) {
return Collections.emptyList();
}
cnd.and("id", "=", currentUnionId);
}
cnd.asc("unionCode");
return dao().query(Sys_union.class, cnd);
}
/**
* 校工会管理员和系统管理员可选择全部工会,其他人员只能选择登录人员自己的分工会。
*/
private boolean canSelectAllUnions() {
return AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name());
}
/**
* 校验工会是否存在且在当前登录人员的可选择范围内。
*/
private Sys_union requireSelectableUnion(String unionId) {
if (StrUtil.isBlank(unionId)) {
throw new RuntimeException("请选择所属工会");
}
Sys_union union = dao().fetch(Sys_union.class, unionId);
if (union == null) {
throw new RuntimeException("所属工会不存在");
}
if (!canSelectAllUnions() && !unionId.equals(SecurityUtil.getUnionId())) {
throw new RuntimeException("无权选择该所属工会");
}
return union;
}
/**
* 校验并规范每张轮播图的标题,轮播图统一继承内容所属工会。
*/
private void validateSliderImages(SpreadContent content) {
if (StrUtil.isBlank(content.getSliderImages())) {
return;
}
JSONArray sliderArray;
try {
sliderArray = JSONUtil.parseArray(content.getSliderImages());
} catch (RuntimeException e) {
throw new RuntimeException("轮播图片数据格式不正确");
}
JSONArray normalizedArray = new JSONArray();
for (int i = 0; i < sliderArray.size(); i++) {
Object rawItem = sliderArray.get(i);
if (!(rawItem instanceof JSONObject)) {
throw new RuntimeException("请完善第" + (i + 1) + "张轮播图标题");
}
JSONObject item = (JSONObject) rawItem;
if (StrUtil.isBlank(item.getStr("src"))) {
throw new RuntimeException("" + (i + 1) + "张轮播图地址不能为空");
}
if (StrUtil.isBlank(item.getStr("title"))) {
throw new RuntimeException("请输入第" + (i + 1) + "张轮播图标题");
}
JSONObject normalizedItem = new JSONObject();
normalizedItem.set("src", item.getStr("src"));
normalizedItem.set("title", item.getStr("title").trim());
normalizedArray.add(normalizedItem);
}
content.setSliderImages(JSONUtil.toJsonStr(normalizedArray));
}
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("内容只能发布到末级栏目");
}
Sys_union union = requireSelectableUnion(content.getUnionId());
if (StrUtil.isBlank(content.getTitle())) {
throw new RuntimeException("标题不能为空");
}
content.setTitle(content.getTitle().trim());
content.setCategoryName(category.getName());
content.setUnionName(union.getName());
validateSliderImages(content);
}
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,115 @@
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.summary, c.categoryName, c.unionId, c.unionName, c.viewCount, c.isPublished, c.publishedAt
FROM spread_content c
WHERE c.delFlag = 0
AND c.isPublished = 1
$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
AND c.isPublished = 1
$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;
+79
View File
@@ -0,0 +1,79 @@
-- 风采墙栏目表,适用于 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) DEFAULT 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 '栏目名称',
`unionId` varchar(32) DEFAULT NULL COMMENT '所属工会ID',
`unionName` varchar(100) DEFAULT 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_union` (`unionId`),
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');
Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

+69 -39
View File
@@ -6,41 +6,39 @@ const act = {
<!-- Swiper容器 -->
<div v-if="actList && actList.length > 0" class="activity-swiper-container">
<div class="activity-swiper-container">
<!-- 活动数据 - Swiper -->
<div class="swiper activity-swiper">
<div class="swiper-wrapper">
<div class="swiper-slide" v-for="(activity, index) in actList" :key="activity.id">
<div class="item" @click="go(activity)">
<div class="img-box">
<img :src="activity.cover || 'https://www.ncu.edu.cn/__local/8/52/2C/644986B4C9A030F7B65300178A6_8679CB08_18467.jpg'"
alt="">
<span class="status">
{{getStatusText(activity)}}
</span>
</div>
<h4 class="title">
{{ activity.name}}
</h4>
<!--报名时间-->
<div class="time">
<p>
<i class="fa fa-clock-o"></i>
时间:{{ activity.startDate }}至{{ activity.endDate }}
</p>
</div>
<!-- 活动数据 - Swiper -->
<div ref="activitySwiper" class="swiper activity-swiper">
<div class="swiper-wrapper">
<div class="swiper-slide" v-for="(activity, index) in actList" :key="activity.id">
<div class="item" @click="go(activity)">
<div class="img-box">
<img :src="activity.cover || 'https://www.ncu.edu.cn/__local/8/52/2C/644986B4C9A030F7B65300178A6_8679CB08_18467.jpg'"
alt="">
<span class="status">
{{getStatusText(activity)}}
</span>
</div>
<h4 class="title">
{{ activity.name}}
</h4>
<!--报名时间-->
<div class="time">
<p>
<i class="fa fa-clock-o"></i>
时间:{{ activity.startDate }}至{{ activity.endDate }}
</p>
</div>
</div>
</div>
</div>
<!-- 导航按钮 -->
<div class="swiper-button-next"></div>
<div class="swiper-button-prev"></div>
<!-- 进度条 -->
<div class="swiper-pagination"></div>
</div>
<!-- 导航按钮 -->
<div ref="nextButton" class="swiper-button-next"></div>
<div ref="prevButton" class="swiper-button-prev"></div>
<!-- 进度条 -->
<div ref="pagination" class="swiper-pagination"></div>
</div>
<div v-else class="no-activity-placeholder">
@@ -80,17 +78,33 @@ const act = {
this.swiper.destroy(true, true)
this.swiper = null
}
this.swiper = new Swiper('.activity-swiper', {
const swiperElement = this.$refs.activitySwiper
if (!swiperElement) {
return
}
// 仅操作当前组件的轮播节点,避免页面其他轮播或尺寸观察器影响卡片宽度。
this.swiper = new Swiper(swiperElement, {
slidesPerView: 1,
spaceBetween: 18,
centeredSlides: false,
loop: false,
watchOverflow: true,
observer: false,
observeParents: false,
resizeObserver: false,
// 鼠标停留在活动区域时可横向浏览,到首尾后释放页面滚动。
mousewheel: {
enabled: true,
forceToAxis: false,
releaseOnEdges: true,
sensitivity: 0.8,
},
navigation: {
nextEl: '.swiper-button-next',
prevEl: '.swiper-button-prev',
nextEl: this.$refs.nextButton,
prevEl: this.$refs.prevButton,
},
pagination: {
el: '.swiper-pagination',
el: this.$refs.pagination,
type: 'progressbar',
},
breakpoints: {
@@ -134,10 +148,25 @@ const act = {
mounted() {
this.listAct()
},
beforeDestroy() {
if (this.swiper) {
this.swiper.destroy(true, true)
this.swiper = null
}
},
style: /*language=CSS*/ `
.activity-section-wrapper {
width: 100%;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
overflow: hidden;
}
.section-act {
width: 100%;
min-width: 0;
max-width: 100%;
}
.section-act .section-act-title span {
@@ -182,8 +211,9 @@ const act = {
position: relative;
width: 100%;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
padding: 9px 14px;
padding: 9px 54px 12px;
/*margin-top: 30px;*/
overflow: hidden;
}
@@ -207,6 +237,8 @@ const act = {
background: white;
width: 40px;
height: 40px;
top: 72px;
margin-top: 0;
border-radius: 50%;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
@@ -225,11 +257,9 @@ const act = {
font-weight: bold;
}
/* 进度条样式 */
/* 进度条不展示,活动通过左右箭头或区域内滚轮浏览。 */
.activity-swiper-container .swiper-pagination {
position: relative;
margin-top: 12px;
height: 4px;
display: none;
}
.activity-swiper-container .swiper-pagination-progressbar {
@@ -53,16 +53,23 @@ layout("/layouts/v4/baseLayout.html"){
.act-card {
display: flex;
align-items: center;
min-width: 320px;
width: 100%;
min-width: 0;
max-width: 100%;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
padding: 11px;
padding: 22px 11px;
box-sizing: border-box;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
.home-section {
width: 100%;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
.home-section-header {
@@ -0,0 +1,194 @@
<!--#
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>
<el-button size="medium" icon="el-icon-s-unfold" @click="expandAll">展开全部</el-button>
<el-button size="medium" icon="el-icon-s-fold" @click="collapseAll">折叠全部</el-button>
</table-tool>
<el-table ref="categoryTable" 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" align="left"></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="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"}],
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()
// 栏目数据为异步加载,赋值后显式展开,避免组件只展示顶级栏目。
this.$nextTick(this.expandAll)
} else {
this.$message.warning(resp.msg)
}
}).finally(() => this.loading = false)
},
// 构造新增栏目默认值。
openAdd() {
this.dialogTitle = "新增栏目"
this.formData = {parentId: "0", name: "", 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())
},
// 展开当前栏目树中的全部父级栏目。
expandAll() {
this.setAllExpanded(true)
},
// 折叠当前栏目树中的全部父级栏目。
collapseAll() {
this.setAllExpanded(false)
},
// Element UI 异步树表格需要逐行设置展开状态。
setAllExpanded(expanded) {
const table = this.$refs.categoryTable
if (!table) return
const walk = rows => {
(rows || []).forEach(row => {
if (row.children && row.children.length) {
table.toggleRowExpansion(row, expanded)
walk(row.children)
}
})
}
walk(this.tableData)
},
// 校验后调用新增或编辑接口。
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,381 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
.spread-editor-page { width: 100%; }
.spread-editor-page .form-card { width: 100%; max-width: none; margin: 0; box-sizing: border-box; }
.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; }
.spread-editor-page .slider-upload-head { width: 100%; margin-bottom: 10px; display: flex; align-items: center; justify-content: space-between; }
.spread-editor-page .slider-upload-title { color: #303133; font-weight: 600; }
.spread-editor-page .slider-upload-count { color: #909399; font-size: 13px; font-weight: 400; }
.spread-editor-page .slider-meta-list { width: 100%; margin-top: 14px; }
.spread-editor-page .slider-meta-toolbar { margin-bottom: 10px; display: flex; align-items: center; justify-content: space-between; }
.spread-editor-page .slider-meta-item { margin-bottom: 12px; padding: 14px; display: grid; grid-template-columns: 130px minmax(220px, 1fr) 92px; gap: 14px; align-items: center; border: 1px solid #e4e7ed; border-radius: 8px; background: #fafafa; transition: border-color .2s, box-shadow .2s; }
.spread-editor-page .slider-meta-item:hover { border-color: #c6e2ff; box-shadow: 0 5px 16px rgba(64,158,255,.10); }
.spread-editor-page .slider-preview-wrap { position: relative; }
.spread-editor-page .slider-index { min-width: 26px; height: 26px; padding: 0 7px; position: absolute; top: 6px; left: 6px; z-index: 2; box-sizing: border-box; color: #fff; font-size: 12px; line-height: 26px; text-align: center; border-radius: 13px; background: rgba(0,0,0,.58); }
.spread-editor-page .slider-meta-image { width: 130px; height: 82px; display: block; object-fit: cover; border: 4px solid #fff; border-radius: 5px; box-sizing: border-box; background: #fff; box-shadow: 0 2px 8px rgba(0,0,0,.12); }
.spread-editor-page .slider-field-label { margin-bottom: 7px; display: block; color: #606266; font-size: 13px; }
.spread-editor-page .color-danger { color: #f56c6c; }
.spread-editor-page .slider-item-actions { display: flex; flex-direction: column; align-items: stretch; }
.spread-editor-page .slider-item-actions .el-button + .el-button { margin-top: 6px; margin-left: 0; }
@media(max-width: 1050px) {
.spread-editor-page .slider-meta-item { grid-template-columns: 110px minmax(0, 1fr) 92px; }
.spread-editor-page .slider-meta-image { width: 110px; }
.spread-editor-page .slider-item-actions { grid-column: 1 / -1; flex-direction: row; justify-content: flex-end; }
.spread-editor-page .slider-item-actions .el-button + .el-button { margin-top: 0; margin-left: 8px; }
}
@media(max-width: 680px) {
.spread-editor-page .slider-meta-item { grid-template-columns: 1fr; }
.spread-editor-page .slider-meta-image { width: 100%; height: 180px; }
.spread-editor-page .slider-item-actions { grid-column: auto; }
}
</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="8">
<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="8">
<el-form-item label="所属工会" prop="unionId">
<el-select v-model="formData.unionId" filterable clearable style="width: 100%"
placeholder="请选择所属工会" @change="contentUnionChanged">
<el-option v-for="item in unionOptions" :key="item.id"
:label="item.name" :value="item.id"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<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 v-if="sliderEnabled" label="轮播图片">
<div class="slider-upload-head">
<span class="slider-upload-title">点击下方“+”新增轮播图片</span>
<span class="slider-upload-count">已添加 {{sliderMeta.length}} / 10 张</span>
</div>
<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>
<div v-if="sliderMeta.length" class="slider-meta-list">
<div class="slider-meta-toolbar">
<span class="form-tip">可调整顺序,轮播将按当前顺序展示。</span>
<el-button type="text" icon="el-icon-delete" class="color-danger" @click="clearSliderImages">
清空全部
</el-button>
</div>
<div v-for="(img, index) in sliderMeta" :key="img.src + '-' + index" class="slider-meta-item">
<div class="slider-preview-wrap">
<span class="slider-index">{{index + 1}}</span>
<img class="slider-meta-image" :src="img.src" :alt="img.title || ('轮播图' + (index + 1))">
</div>
<div>
<span class="slider-field-label"><i class="color-danger">*</i> 图片标题</span>
<el-input v-model.trim="img.title" maxlength="100" show-word-limit
:placeholder="'请输入第' + (index + 1) + '张图片标题'"></el-input>
</div>
<div class="slider-item-actions">
<el-button size="mini" icon="el-icon-top" :disabled="index === 0"
@click="moveSliderImage(index, -1)">上移</el-button>
<el-button size="mini" icon="el-icon-bottom" :disabled="index === sliderMeta.length - 1"
@click="moveSliderImage(index, 1)">下移</el-button>
<el-button size="mini" type="danger" plain icon="el-icon-delete"
@click="removeSliderImage(index)">删除</el-button>
</div>
</div>
</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-form-item label="置顶">
<el-switch v-model="formData.isTop" :active-value="1" :inactive-value="0"
active-text="置顶" inactive-text="不置顶"></el-switch>
</el-form-item>
<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: [],
unionOptions: [],
sliderFiles: [],
sliderMeta: [],
formData: {},
rules: {
categoryId: [{required: true, message: "请选择所属栏目", trigger: "change"}],
unionId: [{required: true, message: "请选择所属工会", trigger: "change"}],
title: [{required: true, message: "请输入标题", trigger: "blur"}],
summary: [{required: true, message: "请输入摘要", trigger: "blur"}],
contentBody: [{required: true, message: "请输入正文", trigger: "change"}]
}
}
},
computed: {
// 仅所选末级栏目及其上级栏目都开启轮播时,显示轮播图片配置。
sliderEnabled() {
if (!this.formData.categoryId) return false
const findPath = (nodes, categoryId, parents) => {
for (const node of nodes || []) {
const path = parents.concat(node)
if (node.id === categoryId) return path
const childPath = findPath(node.children, categoryId, path)
if (childPath) return childPath
}
return null
}
const categoryPath = findPath(this.categoryOptions, this.formData.categoryId, [])
return !!categoryPath && categoryPath.every(item => Number(item.slider) === 1)
}
},
methods: {
// 生成新增内容时使用的默认表单。
defaultForm() {
return {
categoryId: "", unionId: "", unionName: "", title: "", subTitle: "", summary: "", contentBody: "",
thumbUrl: "", sliderImages: "", isTop: 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)
}
})
},
// 按当前登录人员权限加载可选择工会。
loadUnionOptions() {
return this.$axios.post(loc() + "/unionOptions").then(resp => {
if (resp.code === 0) {
this.unionOptions = resp.data || []
} else {
this.$message.warning(resp.msg)
}
})
},
// 内容所属工会变化时同步保存工会名称。
contentUnionChanged(unionId) {
const union = this.unionOptions.find(item => item.id === unionId)
this.formData.unionName = union ? union.name : ""
},
// 分工会人员只有一个可选工会时自动带入,校工会管理员仍需主动选择。
applyUnionDefaults() {
if (this.unionOptions.length !== 1) return
const union = this.unionOptions[0]
if (!this.formData.unionId) {
this.formData.unionId = union.id
this.formData.unionName = union.name
}
},
// 编辑状态下加载内容详情并还原轮播图片数组。
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.sliderMeta = this.parseSliderImages(this.formData.sliderImages)
this.sliderFiles = this.sliderMeta.map(item => item.src)
} else {
this.$message.warning(resp.msg)
}
}).finally(() => this.loading = false)
},
// 兼容历史图片地址数组,并转换为只包含图片地址和标题的轮播对象。
parseSliderImages(value) {
if (!value) return []
let items = value
if (!Array.isArray(items)) {
try {
items = JSON.parse(value)
} catch (e) {
items = String(value).split(",").filter(Boolean)
}
}
if (!Array.isArray(items)) return []
return items.map(item => {
if (typeof item === "string") {
return {src: item, title: ""}
}
return {
src: item.src || item.url || item.data || (item.response && item.response.data) || "",
title: item.title || ""
}
}).filter(item => item.src)
},
// 取得上传组件返回的图片地址。
getSliderFileUrl(item) {
if (typeof item === "string") return item
return item && (item.url || item.data || (item.response && item.response.data)) || ""
},
// 上传或删除图片后同步图片元数据,并保留已经填写的标题和工会。
syncSliderMeta() {
const oldItems = {}
this.sliderMeta.forEach(item => oldItems[item.src] = item)
this.sliderMeta = (this.sliderFiles || []).map(item => this.getSliderFileUrl(item)).filter(Boolean).map(src => {
return oldItems[src] || {src: src, title: ""}
})
this.applyUnionDefaults()
},
// 删除单张轮播图片,同时更新上传组件和图片配置。
removeSliderImage(index) {
const files = (this.sliderFiles || []).slice()
files.splice(index, 1)
this.sliderFiles = files
this.$message.success("已删除第" + (index + 1) + "张轮播图片")
},
// 调整轮播图片顺序,保存和展示均使用调整后的顺序。
moveSliderImage(index, offset) {
const targetIndex = index + offset
if (targetIndex < 0 || targetIndex >= this.sliderMeta.length) return
const files = (this.sliderFiles || []).slice()
const file = files[index]
files.splice(index, 1)
files.splice(targetIndex, 0, file)
this.sliderFiles = files
},
// 清空全部轮播图片,二次确认避免误操作。
clearSliderImages() {
this.$confirm("确定清空全部轮播图片吗?", "提示", {type: "warning"}).then(() => {
this.sliderFiles = []
this.sliderMeta = []
this.$message.success("轮播图片已清空")
}).catch(() => {})
},
// 将轮播图片地址和标题转换为可持久化 JSON。
buildSliderImages() {
this.syncSliderMeta()
return JSON.stringify(this.sliderMeta.map(item => ({
src: item.src,
title: item.title
})))
},
// 校验每张轮播图的标题。
validateSliderMeta() {
this.syncSliderMeta()
const invalidIndex = this.sliderMeta.findIndex(item => !item.title)
if (invalidIndex < 0) return true
this.$message.warning("请输入第" + (invalidIndex + 1) + "张轮播图标题")
return false
},
// 重置新增表单;编辑状态下恢复数据库内容。
resetForm() {
const id = this.getEditId()
if (id) {
this.loadDetail(id)
} else {
this.formData = this.defaultForm()
this.sliderFiles = []
this.sliderMeta = []
this.applyUnionDefaults()
}
this.$nextTick(() => this.$refs.contentForm && this.$refs.contentForm.clearValidate())
},
// 校验并提交新增或编辑数据。
submit() {
this.$refs.contentForm.validate(valid => {
if (!valid) return
if (this.sliderEnabled && !this.validateSliderMeta()) return
const data = Object.assign({}, this.formData)
if (this.sliderEnabled) {
data.sliderImages = this.buildSliderImages()
} else {
// 未开启轮播的栏目不提交隐藏字段,编辑时保留已有数据,避免切换栏目造成误删。
delete data.sliderImages
}
// 推荐和头条没有对应业务逻辑,提交时不再携带这两个字段。
delete data.isRecommend
delete data.isHeadline
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)
})
}
},
watch: {
// 监听上传组件结果,及时生成对应的标题和工会填写行。
sliderFiles: {
deep: true,
handler() {
this.syncSliderMeta()
}
}
},
created() {
this.formData = this.defaultForm()
const id = this.getEditId()
Promise.all([this.loadCategories(), this.loadUnionOptions(), this.loadDetail(id)]).then(this.applyUnionDefaults)
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,366 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
#sub-app-container-main-content { padding: 0 !important; background: #f5f5f5; }
.spread-wall { min-height: 100%; overflow-x: hidden; color: #2c3e50; background: #f5f5f5; }
.spread-hero { min-height: 450px; position: relative; padding: 18px 0 72px; display: flex; flex-direction: column; justify-content: center; overflow: visible; }
.spread-hero::before { content: ""; position: absolute; top: 0; left: 0; right: 0; height: 320px; background: url("${base!}/assets/platform/img/spread-bg.png?v=20260731") top center / cover no-repeat; z-index: 0; }
.spread-hero > * { position: relative; z-index: 1; }
.spread-brand { margin: 0 auto 24px; padding: 10px 16px; display: flex; align-items: center; gap: 12px; color: #2c3e50; border-radius: 12px; background: rgba(255,255,255,.76); box-shadow: 0 10px 26px rgba(0,0,0,.12); backdrop-filter: blur(6px); }
.spread-brand-icon { width: 42px; height: 42px; display: flex; align-items: center; justify-content: center; color: #c0141a; font-size: 24px; border-radius: 50%; background: #fff0f0; }
.spread-brand-title { font-family: "Microsoft YaHei", serif; font-size: 24px; font-weight: 700; letter-spacing: 3px; }
.carousel-bg-text { position: absolute; top: 48%; left: 50%; transform: translate(-50%, -50%); color: rgba(255,255,255,.04); font-size: 170px; font-weight: 900; letter-spacing: 24px; pointer-events: none; user-select: none; }
.hero-slider { width: calc(100% - 32px); max-width: none; min-height: 380px; height: auto; aspect-ratio: 32 / 9; margin: 0 auto; }
.hero-slider .el-carousel, .hero-slider .el-carousel__container { height: 100%; }
.hero-slider .el-carousel__arrow { width: 42px; height: 42px; color: #fff; border: 1px solid rgba(255,255,255,.25); background: rgba(44,62,80,.42); backdrop-filter: blur(4px); }
.hero-slider .el-carousel__item--card { overflow: visible; display: flex; align-items: center; justify-content: center; }
.hero-slider .el-carousel__mask { opacity: 0; background: transparent; }
.slider-item { width: 97%; height: auto; aspect-ratio: 16 / 9; margin: 0 auto; position: relative; overflow: hidden; display: flex; align-items: center; justify-content: center; box-sizing: border-box; cursor: pointer; border: 8px solid #fff; background: #fff; box-shadow: 0 12px 30px rgba(0,0,0,.18), 0 2px 5px rgba(0,0,0,.12); }
.slider-item img { width: auto; height: auto; max-width: 100%; max-height: 100%; display: block; flex: 0 0 auto; object-fit: contain !important; object-position: center; background: #fff; }
.slider-caption { min-height: 25%; padding: 10px 18px 9px; position: absolute; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; align-items: flex-start; justify-content: flex-end; box-sizing: border-box; color: #fff; background: linear-gradient(to bottom, rgba(82,82,82,.30) 0%, rgba(52,52,52,.52) 45%, rgba(24,24,24,.78) 100%); }
.slider-caption-line { width: 100%; }
.slider-caption-title { color: #fff; font-size: 15px; font-weight: 600; line-height: 1.45; text-shadow: 0 1px 3px rgba(0,0,0,.42); white-space: normal; word-break: break-all; }
.slider-caption-tag { max-width: 32%; margin: 0 8px 0 0; padding: 1px 7px; display: inline-block; overflow: hidden; box-sizing: border-box; color: rgba(255,255,255,.92); font-size: 10px; font-weight: 400; line-height: 18px; vertical-align: middle; text-align: center; text-overflow: ellipsis; white-space: nowrap; border: 1px solid rgba(255,255,255,.42); border-radius: 3px; background: rgba(255,255,255,.10); }
.slider-empty { height: 380px; display: flex; align-items: center; justify-content: center; color: #8a6f68; font-size: 22px; letter-spacing: 5px; border-radius: 12px; background: rgba(255,255,255,.86); box-shadow: 0 8px 28px rgba(0,0,0,.08); }
.spread-main { width: calc(100% - 32px); max-width: none; margin: -42px auto 40px; padding: 20px 0 38px; position: relative; z-index: 5; display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 24px; align-items: start; }
.category-panel, .content-panel { background: #fff; border-radius: 12px; box-shadow: 0 8px 28px rgba(90,53,34,.09); }
.category-panel { position: sticky; top: 20px; overflow: hidden; }
.category-header { padding: 18px 15px; color: #fff; font-family: "Microsoft YaHei", serif; font-size: 18px; font-weight: 700; text-align: center; letter-spacing: 2px; background: #c0141a; }
.category-group { border-bottom: 1px solid #f0e7e5; }
.category-parent { padding: 18px 20px; display: flex; align-items: center; gap: 10px; color: #2c3e50; cursor: pointer; transition: color .2s, background .2s; user-select: none; }
.category-parent:hover, .category-parent.open, .category-parent.active { color: #c0141a; background: #fafafa; }
.category-parent-name { flex: 1; font-size: 15px; }
.category-arrow { font-size: 13px; transition: transform .25s ease; }
.category-parent.open .category-arrow { transform: rotate(90deg); }
.category-children { display: grid; grid-template-rows: 0fr; transition: grid-template-rows .3s ease; }
.category-children.open { grid-template-rows: 1fr; }
.category-children-inner { min-height: 0; overflow: hidden; }
.category-child { padding: 14px 20px 14px 32px; display: flex; align-items: center; gap: 10px; color: #606266; cursor: pointer; border-left: 3px solid transparent; transition: color .2s, background .2s; }
.category-child::before { content: ""; width: 5px; height: 5px; flex: 0 0 5px; border-radius: 50%; background: #c0c4cc; }
.category-child:hover, .category-child.active { color: #c0141a; background: #fff5f5; border-left-color: #c0141a; }
.category-child:hover::before, .category-child.active::before { background: #c0141a; }
.content-panel { min-height: 600px; padding: 30px; }
.section-heading { margin-bottom: 22px; padding-bottom: 16px; display: flex; align-items: flex-end; justify-content: space-between; border-bottom: 1px solid #ebeef5; }
.section-heading h2 { margin: 0 0 4px; color: #2c3e50; font-family: "Microsoft YaHei", serif; font-size: 24px; }
.section-heading span { color: #909399; font-size: 14px; }
.content-card { margin-bottom: 16px; padding: 20px; display: grid; grid-template-columns: 80px minmax(0,1fr) 90px; gap: 20px; align-items: center; cursor: pointer; border: 1px solid #ebeef5; border-radius: 8px; transition: transform .2s, border-color .2s, box-shadow .2s; }
.content-card:hover { transform: translateY(-2px); border-color: #f3c8c6; box-shadow: 0 8px 20px rgba(192,20,26,.08); }
.content-date { color: #909399; text-align: center; border-right: 1px solid #ebeef5; }
.content-date strong { display: block; color: #2c3e50; font-size: 25px; }
.content-card:hover .content-date strong { color: #c0141a; }
.content-title { margin-bottom: 8px; color: #303133; 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: 520px; margin: 24px auto; display: block; object-fit: contain; border-radius: 8px; }
.detail-body { width: 100%; overflow: hidden; color: #493b36; font-size: 16px; line-height: 1.9; word-break: break-word; }
.detail-body img, .detail-body video { max-width: 100% !important; height: auto !important; }
.detail-body table { max-width: 100% !important; }
.back-link { margin-bottom: 12px; }
@media (max-width: 900px) {
.spread-hero { min-height: 360px; padding-bottom: 50px; }
.spread-hero::before { height: 260px; }
.hero-slider, .hero-slider .el-carousel, .hero-slider .el-carousel__container { height: 280px; }
.spread-main { width: calc(100% - 24px); grid-template-columns: 1fr; margin-top: -20px; }
.category-panel { position: static; }
.content-panel { padding: 20px 15px; }
.content-card { grid-template-columns: 64px minmax(0,1fr); }
.content-view { display: none; }
}
</style>
<div id="app" class="spread-wall" v-cloak>
<header class="spread-hero">
<div class="spread-brand">
<span class="spread-brand-icon"><i class="el-icon-trophy"></i></span>
<span class="spread-brand-title">风采墙</span>
</div>
<div class="carousel-bg-text">HONOR</div>
<div class="hero-slider">
<el-carousel v-if="sliderItems.length" type="card" height="100%" :interval="4000"
indicator-position="none" arrow="hover" :autoplay="sliderItems.length > 1" loop>
<el-carousel-item v-for="item in sliderItems" :key="item.slideKey">
<div class="slider-item" @click="openDetail(item)">
<img v-if="item.sliderImage" :src="item.sliderImage" :alt="item.title">
<div class="slider-caption">
<div class="slider-caption-line">
<div class="slider-caption-title" :title="item.title">
<span class="slider-caption-tag">{{item.unionName || item.categoryName || '风采展示'}}</span>{{item.title}}
</div>
</div>
</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">工会信息传播与文化展示</div>
<div v-for="parent in categories" :key="parent.id" class="category-group">
<div class="category-parent" :class="{open: isParentOpen(parent), active: isParentActive(parent)}" @click="toggleParent(parent)">
<i class="el-icon-tickets"></i>
<span class="category-parent-name">{{parent.name}}</span>
<i v-if="parent.children && parent.children.length" class="el-icon-arrow-right category-arrow"></i>
</div>
<div v-if="parent.children && parent.children.length"
class="category-children" :class="{open: isParentOpen(parent)}">
<div class="category-children-inner">
<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>
</div>
</div>
</div>
</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="backToList">返回列表</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: [],
openParents: [],
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.openParents = firstParent ? [firstParent.id] : []
this.currentCategory = firstParent && firstParent.children && firstParent.children.length
? firstParent.children[0] : (firstParent || {})
})
},
// 判断父栏目是否处于展开状态。
isParentOpen(parent) {
return this.openParents.indexOf(parent.id) !== -1
},
// 当前栏目属于该父栏目时保持父级高亮。
isParentActive(parent) {
if (this.currentCategory.id === parent.id) return true
return (parent.children || []).some(child => child.id === this.currentCategory.id)
},
// 点击父栏目时展开或关闭子节点;无子节点时直接查询该栏目。
toggleParent(parent) {
const children = parent.children || []
if (!children.length) {
this.selectCategory(parent)
return
}
const index = this.openParents.indexOf(parent.id)
if (index === -1) {
this.openParents.push(parent.id)
} else {
this.openParents.splice(index, 1)
}
},
// 加载开启轮播栏目的最新发布内容。
loadSlider() {
return this.$axios.post(this.apiBase + "/sliderData").then(resp => {
if (resp.code === 0) {
this.sliderItems = this.buildSliderItems(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
const parent = this.categories.find(item => (item.children || []).some(child => child.id === category.id))
if (parent && !this.isParentOpen(parent)) {
this.openParents.push(parent.id)
}
this.loadPage()
},
// 根据内容所属栏目同步左侧栏目选中状态,并展开对应父栏目。
syncCategorySelection(categoryId) {
if (!categoryId) return
for (const parent of this.categories) {
if (parent.id === categoryId) {
this.currentCategory = parent
return
}
const child = (parent.children || []).find(item => item.id === categoryId)
if (child) {
this.currentCategory = child
if (!this.isParentOpen(parent)) this.openParents.push(parent.id)
return
}
}
},
// 从详情返回时,按当前回显栏目重新加载内容列表。
backToList() {
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
this.syncCategorySelection(this.detail.categoryId || item.categoryId)
// 轮播区域较高,打开详情后滚动到正文区域完整展示内容。
this.$nextTick(() => {
const main = document.querySelector(".spread-main")
if (main) main.scrollIntoView({behavior: "smooth", block: "start"})
})
}
}).finally(() => this.loading = false)
},
// 将上传字段转换为完整图片地址数组。
getImageUrls(value) {
if (!value) return []
let files = value
if (typeof value === "string" && value.trim().startsWith("[")) {
try {
files = JSON.parse(value)
} catch (e) {
files = []
}
}
if (!Array.isArray(files)) {
files = String(files).split(",")
}
return files.map(file => {
if (typeof file === "string") return file.trim()
return file && (file.src || file.url || file.data || (file.response && file.response.data)) || ""
}).filter(Boolean)
},
// 从上传字段中取得第一张图片地址。
getCoverUrl(value) {
const urls = this.getImageUrls(value)
return urls.length ? urls[0] : ""
},
// 将轮播图片解析为图片地址和标题,兼容历史纯地址数组。
getSliderEntries(value) {
if (!value) return []
let items = value
if (!Array.isArray(items)) {
try {
items = JSON.parse(value)
} catch (e) {
items = String(value).split(",").filter(Boolean)
}
}
if (!Array.isArray(items)) return []
return items.map(item => {
if (typeof item === "string") return {src: item}
return {
src: item.src || item.url || item.data || (item.response && item.response.data) || "",
title: item.title || ""
}
}).filter(item => item.src)
},
// 每篇内容的多张轮播图片分别生成轮播项,图片标题独立,工会名称继承内容。
buildSliderItems(contents) {
const result = []
const source = contents || []
source.forEach(item => {
let slides = this.getSliderEntries(item.sliderImages)
if (!slides.length) {
slides = this.getImageUrls(item.thumbUrl).map(url => ({src: url}))
}
slides.forEach((slide, index) => {
result.push(Object.assign({}, item, {
title: slide.title || item.title,
sliderImage: slide.src,
slideKey: item.id + "-" + index
}))
})
})
return result
},
// 格式化列表日期的指定部分。
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,203 @@
<!--#
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></el-table-column>
<el-table-column prop="categoryName" label="栏目" min-width="180">
<template slot-scope="{row}">
<span>{{row.categoryName}}</span>
<el-tag v-if="categorySliderEnabled(row.categoryId)" type="success" size="mini"
effect="plain" style="margin-left: 6px">轮播</el-tag>
</template>
</el-table-column>
<el-table-column prop="unionName" label="所属工会" min-width="150" show-overflow-tooltip></el-table-column>
<el-table-column prop="isTop" label="置顶" width="100" align="center">
<template slot-scope="{row}">
<el-tag :type="row.isTop === 1 ? 'success' : 'info'" size="mini">
{{row.isTop === 1 ? '置顶' : '未置顶'}}
</el-tag>
</template>
</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 || []
})
},
// 判断内容所属栏目及其全部上级栏目是否都已开启轮播。
categorySliderEnabled(categoryId) {
const findPath = (nodes, parents) => {
for (const node of nodes || []) {
const path = parents.concat(node)
if (node.id === categoryId) return path
const childPath = findPath(node.children, path)
if (childPath) return childPath
}
return null
}
const categoryPath = findPath(this.categoryOptions, [])
return !!categoryPath && categoryPath.every(item => Number(item.slider) === 1)
},
// 清空筛选条件并重新查询。
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,252 @@
<!--#
layout("/layouts/platform.html"){
#-->
<script src="${base!}/assets/platform/plugins/echarts/echarts.min.js" nonce="${cspNonce!}"></script>
<style>
#sub-app-container-main-content { background: #f4f6f8; }
.spread-statistics { width: 100%; }
.spread-statistics .filter-card { margin-bottom: 12px; }
.spread-statistics .metrics-card { margin-bottom: 18px; border: 0; border-radius: 12px; }
.spread-statistics .metrics-card > .el-card__body { padding: 24px; }
.spread-statistics .stat-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 18px; }
.spread-statistics .metric-tile { min-width: 0; min-height: 108px; padding: 20px; display: flex; align-items: center; gap: 16px; box-sizing: border-box; border: 1px solid #e8edf3; border-radius: 13px; background: #fff; box-shadow: 0 3px 14px rgba(15,23,42,.06); transition: transform .25s, box-shadow .25s; }
.spread-statistics .metric-tile:hover { transform: translateY(-2px); box-shadow: 0 8px 22px rgba(15,23,42,.11); }
.spread-statistics .metric-icon { width: 58px; height: 58px; flex: 0 0 58px; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 27px; border-radius: 14px; }
.spread-statistics .metric-icon.purple { background: linear-gradient(135deg,#667eea,#764ba2); }
.spread-statistics .metric-icon.green { background: linear-gradient(135deg,#43e97b,#38f9d7); }
.spread-statistics .metric-icon.orange { background: linear-gradient(135deg,#f7971e,#ffd200); }
.spread-statistics .metric-icon.blue { background: linear-gradient(135deg,#4facfe,#00d7ef); }
.spread-statistics .metric-icon.pink { background: linear-gradient(135deg,#f093fb,#f5576c); }
.spread-statistics .metric-info { min-width: 0; }
.spread-statistics .stat-label { margin-bottom: 6px; overflow: hidden; color: #8492a6; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; }
.spread-statistics .stat-value { color: #17233d; font-size: 31px; font-weight: 700; line-height: 1; }
.spread-statistics .chart-grid { display: grid; grid-template-columns: minmax(360px, 1fr) minmax(0, 2fr); gap: 14px; }
.spread-statistics .chart-card { min-width: 0; border: 0; border-radius: 11px; }
.spread-statistics .chart-card > .el-card__header { padding: 26px 26px 18px; font-size: 18px; border-bottom: 1px solid #e4e7ed; }
.spread-statistics .chart-card > .el-card__body { padding: 8px 24px 18px; }
.spread-statistics .chart-box { width: 100%; height: 410px; }
.spread-statistics .top-table-card { margin-top: 14px; border: 0; border-radius: 11px; }
.spread-statistics .top-table-card > .el-card__header { padding: 20px 24px; font-size: 17px; }
@media(max-width: 1199px) { .spread-statistics .stat-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media(max-width: 980px) { .spread-statistics .stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .spread-statistics .chart-grid { grid-template-columns: 1fr; } }
@media(max-width: 560px) { .spread-statistics .stat-grid { grid-template-columns: 1fr; } .spread-statistics .metrics-card > .el-card__body { padding: 12px; } }
</style>
<div id="app" class="spread-statistics" v-cloak>
<guava ref="guava">
<el-card class="filter-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>
<el-card class="metrics-card" shadow="never" v-loading="loading">
<div class="stat-grid">
<div class="metric-tile">
<div class="metric-icon purple"><i class="el-icon-folder-opened"></i></div>
<div class="metric-info"><div class="stat-label">栏目总数</div><div class="stat-value">{{summary.categoryCount || 0}}</div></div>
</div>
<div class="metric-tile">
<div class="metric-icon purple"><i class="el-icon-document"></i></div>
<div class="metric-info"><div class="stat-label">内容总数</div><div class="stat-value">{{summary.contentCount || 0}}</div></div>
</div>
<div class="metric-tile">
<div class="metric-icon green"><i class="el-icon-circle-check"></i></div>
<div class="metric-info"><div class="stat-label">已发布</div><div class="stat-value">{{summary.publishedCount || 0}}</div></div>
</div>
<div class="metric-tile">
<div class="metric-icon orange"><i class="el-icon-time"></i></div>
<div class="metric-info"><div class="stat-label">未发布</div><div class="stat-value">{{unpublishedCount}}</div></div>
</div>
<div class="metric-tile">
<div class="metric-icon blue"><i class="el-icon-view"></i></div>
<div class="metric-info"><div class="stat-label">总访问量</div><div class="stat-value">{{summary.viewCount || 0}}</div></div>
</div>
<div class="metric-tile">
<div class="metric-icon pink"><i class="el-icon-data-line"></i></div>
<div class="metric-info"><div class="stat-label">平均访问量</div><div class="stat-value">{{summary.avgViewCount || 0}}</div></div>
</div>
</div>
</el-card>
<div class="chart-grid">
<el-card class="chart-card" shadow="never">
<div slot="header"><b>栏目访问量占比</b></div>
<div ref="categoryChart" class="chart-box"></div>
</el-card>
<el-card class="chart-card" shadow="never">
<div slot="header"><b>新闻内容访问量 Top10</b></div>
<div ref="topChart" class="chart-box"></div>
</el-card>
</div>
<el-card class="top-table-card" shadow="never" v-loading="loading">
<div slot="header"><b>新闻内容访问量 Top10</b></div>
<el-table :data="topData" stripe border style="width: 100%">
<el-table-column type="index" label="序号" width="70" align="center"></el-table-column>
<el-table-column prop="categoryName" label="栏目" min-width="120" align="center"></el-table-column>
<el-table-column prop="title" label="标题" min-width="260" show-overflow-tooltip></el-table-column>
<el-table-column prop="summary" label="摘要" min-width="260" show-overflow-tooltip></el-table-column>
<el-table-column prop="unionName" label="所属工会" min-width="150" show-overflow-tooltip align="center"></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 prop="viewCount" label="访问量" width="100" 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
}
},
computed: {
// 未发布数量由内容总数减去已发布数量得到。
unpublishedCount() {
return Math.max(Number(this.summary.contentCount || 0) - Number(this.summary.publishedCount || 0), 0)
}
},
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: ["#5b8ff9"],
tooltip: {trigger: "axis", axisPointer: {type: "shadow"}},
grid: {left: 58, right: 28, top: 42, bottom: 82},
xAxis: {
type: "category",
data: this.topData.map(item => item.title),
axisTick: {show: false},
axisLine: {lineStyle: {color: "#cfd5df"}},
axisLabel: {
interval: 0,
rotate: 24,
color: "#606266",
formatter: value => value.length > 12 ? value.slice(0, 12) + "…" : value
}
},
yAxis: {
type: "value",
minInterval: 1,
axisLine: {show: false},
axisTick: {show: false},
splitLine: {lineStyle: {color: "#dfe3e8", type: "dashed"}}
},
series: [{
type: "bar",
barMaxWidth: 44,
itemStyle: {color: "#5b8ff9"},
data: this.topData.map(item => item.viewCount || 0)
}]
}, true)
this.categoryChart.setOption({
color: ["#5b8ff9", "#5ad8a6", "#6f86ad", "#f6bd16", "#e8684a", "#9270ca"],
tooltip: {trigger: "item", formatter: "{b}<br/>访问量:{c}<br/>占比:{d}%"},
series: [{
type: "pie",
radius: ["35%", "64%"],
center: ["50%", "56%"],
avoidLabelOverlap: true,
itemStyle: {borderColor: "#fff", borderWidth: 3, borderRadius: 10},
label: {color: "#303133", formatter: "{b}: {d}%"},
labelLine: {length: 16, length2: 18},
data: this.categoryData.map(item => ({name: item.categoryName, value: item.viewCount || 0}))
}]
}, true)
},
// 格式化 Top10 表格的发布时间。
formatTime(value) {
return value ? this.$moment(Number(value)).format("YYYY-MM-DD HH:mm:ss") : ""
},
// 浏览器尺寸变化时重算图表尺寸。
resizeCharts() {
if (this.topChart) this.topChart.resize()
if (this.categoryChart) this.categoryChart.resize()
}
},
mounted() {
window.addEventListener("resize", this.resizeCharts)
// 图表容器挂载完成后自动执行首次查询,避免必须点击“查询”才显示数据。
this.$nextTick(() => {
this.loadCategories().then(() => this.loadAll())
})
},
beforeDestroy() {
window.removeEventListener("resize", this.resizeCharts)
if (this.topChart) this.topChart.dispose()
if (this.categoryChart) this.categoryChart.dispose()
}
})
</script>
<!--#
}
#-->