web端-新增风采墙模块(工会信息传播与文化展示): 补充所属工会;统计,内容展示等样式调整;

This commit is contained in:
2026-07-31 16:50:17 +08:00
parent b3406f8b00
commit e00fdc0c78
12 changed files with 693 additions and 151 deletions
@@ -40,6 +40,13 @@ public class SpreadCreationController {
return Result.success(categoryService.categoryTree(true));
}
@At
@ApiOperation("当前登录人员可选择的工会")
@SaCheckPermission("spread.creation")
public Result unionOptions() {
return Result.success(contentService.selectableUnions());
}
@At
@ApiOperation("内容详情")
@SaCheckPermission("spread.creation")
@@ -33,6 +33,16 @@ public class SpreadContent extends BaseModel {
@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)
@@ -5,6 +5,7 @@ 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;
@@ -28,4 +29,9 @@ public interface SpreadContentService extends BaseService<SpreadContent> {
void addViewCount(String id);
List<NutMap> sliderContent();
/**
* 查询当前登录人员可选择的工会。
*/
List<Sys_union> selectableUnions();
}
@@ -2,9 +2,16 @@ 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;
@@ -17,6 +24,7 @@ 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"})
@@ -149,7 +157,7 @@ public class SpreadContentServiceImpl extends BaseServiceImpl<SpreadContent> imp
@Override
public List<NutMap> sliderContent() {
Sql sql = Sqls.create("""
SELECT c.id, c.title, c.summary, c.thumbUrl, c.sliderImages, c.categoryId, c.categoryName
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
@@ -167,6 +175,77 @@ public class SpreadContentServiceImpl extends BaseServiceImpl<SpreadContent> imp
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) {
@@ -204,11 +283,16 @@ public class SpreadContentServiceImpl extends BaseServiceImpl<SpreadContent> imp
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) {
@@ -44,9 +44,10 @@ public class SpreadStatisticsServiceImpl implements SpreadStatisticsService {
@Override
public List<NutMap> top10(SpreadStatisticsQuery query) {
Sql sql = Sqls.create("""
SELECT c.id, c.title, c.categoryName, c.viewCount, c.isPublished, c.publishedAt
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
@@ -65,6 +66,7 @@ public class SpreadStatisticsServiceImpl implements SpreadStatisticsService {
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
+3
View File
@@ -23,6 +23,8 @@ 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 '摘要',
@@ -43,6 +45,7 @@ CREATE TABLE IF NOT EXISTS `spread_content` (
`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`)
Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

@@ -11,7 +11,7 @@ layout("/layouts/platform.html"){
</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"></el-table-column>
<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}">
@@ -2,9 +2,35 @@
layout("/layouts/platform.html"){
#-->
<style>
.spread-editor-page .form-card { max-width: 1180px; margin: 0 auto; }
.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">
@@ -13,14 +39,23 @@ layout("/layouts/platform.html"){
</div>
<el-form ref="contentForm" :model="formData" :rules="rules" label-width="105px">
<el-row :gutter="20">
<el-col :span="12">
<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="12">
<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>
@@ -29,9 +64,12 @@ layout("/layouts/platform.html"){
<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>
@@ -42,32 +80,51 @@ layout("/layouts/platform.html"){
upload_mode="image" upload_result_category="interval"></file-upload>
<div class="form-tip">建议使用横版图片,单张不超过 10MB。</div>
</el-form-item>
<el-form-item label="轮播图片">
<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 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-row :gutter="20">
<el-col :span="8">
<el-form-item label="置顶">
<el-switch v-model="formData.isTop" :active-value="1" :inactive-value="0"></el-switch>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="推荐">
<el-switch v-model="formData.isRecommend" :active-value="1" :inactive-value="0"></el-switch>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="头条">
<el-switch v-model="formData.isHeadline" :active-value="1" :inactive-value="0"></el-switch>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="置顶">
<el-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>
@@ -90,23 +147,43 @@ layout("/layouts/platform.html"){
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: "", title: "", subTitle: "", summary: "", contentBody: "",
thumbUrl: "", sliderImages: "", isTop: 0, isRecommend: 0,
isHeadline: 0, viewCount: 0, sortOrder: 255, isPublished: 0
categoryId: "", unionId: "", unionName: "", title: "", subTitle: "", summary: "", contentBody: "",
thumbUrl: "", sliderImages: "", isTop: 0,
viewCount: 0, sortOrder: 255, isPublished: 0
}
},
// 读取地址栏中的编辑内容 ID。
@@ -123,6 +200,32 @@ layout("/layouts/platform.html"){
}
})
},
// 按当前登录人员权限加载可选择工会。
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()
@@ -130,30 +233,89 @@ layout("/layouts/platform.html"){
return this.$axios.post(loc() + "/detailData", {id}).then(resp => {
if (resp.code === 0) {
this.formData = Object.assign(this.defaultForm(), resp.data || {})
this.sliderFiles = this.parseSliderFiles(this.formData.sliderImages)
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)
},
// 兼容数据库中的 JSON 数组和历史逗号分隔地址
parseSliderFiles(value) {
// 兼容历史图片地址数组,并转换为只包含图片地址和标题的轮播对象
parseSliderImages(value) {
if (!value) return []
if (Array.isArray(value)) return value
try {
const urls = JSON.parse(value)
return Array.isArray(urls) ? urls : []
} catch (e) {
return String(value).split(",").filter(Boolean)
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)
},
// 上传组件返回的图片数组转换为可持久化 JSON
// 取得上传组件返回的图片地址
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() {
const urls = (this.sliderFiles || []).map(item => {
if (typeof item === "string") return item
return item.url || item.data || (item.response && item.response.data) || ""
}).filter(Boolean)
return JSON.stringify(urls)
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() {
@@ -163,6 +325,8 @@ layout("/layouts/platform.html"){
} else {
this.formData = this.defaultForm()
this.sliderFiles = []
this.sliderMeta = []
this.applyUnionDefaults()
}
this.$nextTick(() => this.$refs.contentForm && this.$refs.contentForm.clearValidate())
},
@@ -170,7 +334,17 @@ layout("/layouts/platform.html"){
submit() {
this.$refs.contentForm.validate(valid => {
if (!valid) return
const data = Object.assign({}, this.formData, {sliderImages: this.buildSliderImages()})
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 => {
@@ -186,10 +360,19 @@ layout("/layouts/platform.html"){
})
}
},
watch: {
// 监听上传组件结果,及时生成对应的标题和工会填写行。
sliderFiles: {
deep: true,
handler() {
this.syncSliderMeta()
}
}
},
created() {
this.formData = this.defaultForm()
const id = this.getEditId()
Promise.all([this.loadCategories(), this.loadDetail(id)])
Promise.all([this.loadCategories(), this.loadUnionOptions(), this.loadDetail(id)]).then(this.applyUnionDefaults)
}
})
</script>
@@ -2,54 +2,96 @@
layout("/layouts/platform.html"){
#-->
<style>
#sub-app-container-main-content { padding: 0 !important; background: #f6f2ea; }
.spread-wall { min-height: 100%; color: #3c2f2a; background: linear-gradient(180deg, #8f171d 0, #bd352b 330px, #f6f2ea 330px); }
.spread-hero { max-width: 1280px; margin: 0 auto; padding: 34px 32px 26px; color: #fff; }
.spread-title { margin: 0; font-family: "Microsoft YaHei", serif; font-size: 34px; letter-spacing: 5px; }
.spread-subtitle { margin-top: 9px; color: rgba(255,255,255,.78); font-size: 14px; letter-spacing: 2px; }
.hero-slider { height: 205px; margin-top: 24px; overflow: hidden; border-radius: 12px; box-shadow: 0 14px 34px rgba(64, 10, 12, .28); background: #7d1b1f; }
.hero-slider .el-carousel, .hero-slider .el-carousel__container { height: 205px; }
.slider-item { height: 100%; position: relative; cursor: pointer; background: linear-gradient(135deg, #781116, #c44834); }
.slider-item img { width: 100%; height: 100%; object-fit: cover; opacity: .78; }
.slider-caption { position: absolute; left: 0; right: 0; bottom: 0; padding: 34px 28px 18px; color: #fff; font-size: 21px; font-weight: 600; background: linear-gradient(transparent, rgba(28,4,5,.78)); }
.slider-empty { height: 100%; display: flex; align-items: center; justify-content: center; font-size: 25px; letter-spacing: 6px; }
.spread-main { max-width: 1280px; margin: 0 auto; padding: 0 32px 38px; display: grid; grid-template-columns: 245px minmax(0, 1fr); gap: 22px; }
#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; height: 380px; margin: 0 auto; }
.hero-slider .el-carousel, .hero-slider .el-carousel__container { height: 380px; }
.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; }
.hero-slider .el-carousel__mask { opacity: 0; background: transparent; }
.slider-item { width: 97%; height: 95%; 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 { align-self: start; overflow: hidden; }
.category-header { padding: 17px 20px; color: #fff; font-size: 17px; font-weight: 600; background: #941d22; }
.category-parent { padding: 15px 18px 9px; color: #7e2224; font-weight: 700; border-top: 1px solid #f0e7df; }
.category-child { padding: 11px 18px 11px 29px; cursor: pointer; color: #655650; border-left: 3px solid transparent; transition: .2s; }
.category-child:hover, .category-child.active { color: #a32227; background: #fff3ed; border-left-color: #a32227; }
.content-panel { min-height: 570px; padding: 24px 28px; }
.section-heading { margin-bottom: 12px; padding-bottom: 14px; display: flex; align-items: flex-end; justify-content: space-between; border-bottom: 1px solid #eee2d9; }
.section-heading h2 { margin: 0; color: #7c1b20; font-size: 23px; }
.section-heading span { color: #a0938c; font-size: 13px; }
.content-card { padding: 17px 4px; display: grid; grid-template-columns: 72px minmax(0,1fr) 80px; gap: 18px; align-items: center; cursor: pointer; border-bottom: 1px solid #f0e9e4; transition: .2s; }
.content-card:hover { padding-left: 10px; background: #fffaf7; }
.content-date { color: #8f7770; text-align: center; border-right: 1px solid #eadfd7; }
.content-date strong { display: block; color: #8f1d22; font-size: 25px; }
.content-title { margin-bottom: 7px; color: #392c28; font-size: 17px; font-weight: 700; }
.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: 430px; margin: 24px auto; display: block; object-fit: contain; border-radius: 8px; }
.detail-body { color: #493b36; font-size: 16px; line-height: 1.9; word-break: break-word; }
.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: 800px) { .spread-main { grid-template-columns: 1fr; padding: 0 12px 24px; } .spread-hero { padding: 24px 12px; } .category-panel { position: static; } }
@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">
<h1 class="spread-title">风采墙</h1>
<div class="spread-subtitle">记录奋斗身影 · 展示教工风采 · 凝聚榜样力量</div>
<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" :interval="5000" arrow="hover">
<el-carousel-item v-for="item in sliderItems" :key="item.id">
<el-carousel v-if="sliderItems.length" type="card" height="380px" :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="getSliderImage(item)" :src="getSliderImage(item)" :alt="item.title">
<div class="slider-caption">{{item.title}}</div>
<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>
@@ -59,16 +101,22 @@ layout("/layouts/platform.html"){
<div class="spread-main">
<aside class="category-panel">
<div class="category-header"><i class="el-icon-collection-tag"></i> 风采栏目</div>
<template v-for="parent in categories">
<div :key="parent.id + '-p'" class="category-parent">{{parent.name}}</div>
<div v-if="!parent.children || !parent.children.length" :key="parent.id"
class="category-child" :class="{active: currentCategory.id === parent.id}"
@click="selectCategory(parent)">{{parent.name}}</div>
<div v-for="child in parent.children" :key="child.id"
class="category-child" :class="{active: currentCategory.id === child.id}"
@click="selectCategory(child)">{{child.name}}</div>
</template>
<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">
@@ -98,7 +146,7 @@ layout("/layouts/platform.html"){
@current-change="loadPage"></el-pagination>
</template>
<template v-else>
<el-button class="back-link" icon="el-icon-back" plain size="small" @click="detail = null">返回列表</el-button>
<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)}} 
@@ -120,6 +168,7 @@ layout("/layouts/platform.html"){
loading: false,
categories: [],
sliderItems: [],
openParents: [],
currentCategory: {},
detail: null,
pageData: {pageNumber: 1, pageSize: 8, totalCount: 0, list: []}
@@ -132,14 +181,40 @@ layout("/layouts/platform.html"){
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 = resp.data || []
if (resp.code === 0) {
this.sliderItems = this.buildSliderItems(resp.data || [])
}
})
},
// 根据当前栏目加载已发布内容。
@@ -160,6 +235,32 @@ layout("/layouts/platform.html"){
// 切换栏目时回到第一页和列表状态。
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()
@@ -174,27 +275,77 @@ layout("/layouts/platform.html"){
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)
},
// 上传字段中取得第一张图片地址。
getCoverUrl(value) {
if (!value) return ""
if (Array.isArray(value)) return value.length ? (value[0].url || value[0].data || value[0]) : ""
// 上传字段转换为完整图片地址数组
getImageUrls(value) {
if (!value) return []
let files = value
if (typeof value === "string" && value.trim().startsWith("[")) {
try {
const files = JSON.parse(value)
const first = files[0]
return first ? (first.url || first.data || first) : ""
files = JSON.parse(value)
} catch (e) {
return ""
files = []
}
}
return String(value).split(",")[0]
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)
},
// 轮播图优先使用轮播图片,未配置时回退到封面
getSliderImage(item) {
return this.getCoverUrl(item.sliderImages) || this.getCoverUrl(item.thumbUrl)
// 从上传字段中取得第一张图片地址
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) {
@@ -37,13 +37,22 @@ layout("/layouts/platform.html"){
</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 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}">
<el-tag v-if="row.isTop === 1" type="danger" size="mini" style="margin-right: 5px">置顶</el-tag>
{{row.title}}
<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="categoryName" label="栏目" min-width="130"></el-table-column>
<el-table-column prop="viewCount" label="浏览量" width="100" align="center"></el-table-column>
<el-table-column prop="sortOrder" label="排序" width="90" align="center"></el-table-column>
<el-table-column label="状态" width="100" align="center">
@@ -109,6 +118,20 @@ layout("/layouts/platform.html"){
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 = ""
@@ -3,22 +3,37 @@ layout("/layouts/platform.html"){
#-->
<script src="${base!}/assets/platform/plugins/echarts/echarts.min.js" nonce="${cspNonce!}"></script>
<style>
.spread-statistics .stat-grid { margin: 10px 0; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 14px; }
.spread-statistics .stat-card { padding: 20px; position: relative; overflow: hidden; border-radius: 9px; color: #fff; box-shadow: 0 8px 22px rgba(30,50,80,.1); }
.spread-statistics .stat-card:nth-child(1) { background: linear-gradient(135deg,#3378d1,#58a1ef); }
.spread-statistics .stat-card:nth-child(2) { background: linear-gradient(135deg,#7357c8,#9d7ee7); }
.spread-statistics .stat-card:nth-child(3) { background: linear-gradient(135deg,#2b9c73,#50c499); }
.spread-statistics .stat-card:nth-child(4) { background: linear-gradient(135deg,#d87a2e,#efa55b); }
.spread-statistics .stat-card:nth-child(5) { background: linear-gradient(135deg,#bd4552,#e36c78); }
.spread-statistics .stat-label { color: rgba(255,255,255,.82); font-size: 13px; }
.spread-statistics .stat-value { margin-top: 8px; font-size: 29px; font-weight: 700; }
.spread-statistics .chart-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, .75fr); gap: 14px; }
.spread-statistics .chart-box { height: 390px; }
@media(max-width: 980px) { .spread-statistics .stat-grid { grid-template-columns: repeat(2, 1fr); } .spread-statistics .chart-grid { grid-template-columns: 1fr; } }
#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 shadow="never">
<el-card class="filter-card" shadow="never">
<search :is-search-button="false">
<search-item label="所属栏目">
<el-cascader v-model="query.categoryId" :options="categoryOptions"
@@ -43,32 +58,58 @@ layout("/layouts/platform.html"){
</search>
</el-card>
<div class="stat-grid" v-loading="loading">
<div class="stat-card"><div class="stat-label">栏目数量</div><div class="stat-value">{{summary.categoryCount || 0}}</div></div>
<div class="stat-card"><div class="stat-label">内容总数</div><div class="stat-value">{{summary.contentCount || 0}}</div></div>
<div class="stat-card"><div class="stat-label">已发布</div><div class="stat-value">{{summary.publishedCount || 0}}</div></div>
<div class="stat-card"><div class="stat-label">累计浏览</div><div class="stat-value">{{summary.viewCount || 0}}</div></div>
<div class="stat-card"><div class="stat-label">平均浏览</div><div class="stat-value">{{summary.avgViewCount || 0}}</div></div>
</div>
<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 shadow="never">
<div slot="header"><b>浏览量 TOP 10</b></div>
<div ref="topChart" class="chart-box"></div>
</el-card>
<el-card shadow="never">
<div slot="header"><b>栏目浏览量占比</b></div>
<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="mt10" shadow="never">
<div slot="header"><b>栏目数据明细</b></div>
<el-table :data="categoryData" border>
<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="180"></el-table-column>
<el-table-column prop="contentCount" label="内容数量" width="140" align="center"></el-table-column>
<el-table-column prop="viewCount" label="浏览量" width="140" align="center"></el-table-column>
<el-table-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>
@@ -90,6 +131,12 @@ layout("/layouts/platform.html"){
categoryChart: null
}
},
computed: {
// 未发布数量由内容总数减去已发布数量得到。
unpublishedCount() {
return Math.max(Number(this.summary.contentCount || 0) - Number(this.summary.publishedCount || 0), 0)
}
},
methods: {
// 将时间范围拆分成统计接口参数。
buildParams() {
@@ -132,28 +179,54 @@ layout("/layouts/platform.html"){
if (!this.topChart) this.topChart = echarts.init(this.$refs.topChart)
if (!this.categoryChart) this.categoryChart = echarts.init(this.$refs.categoryChart)
this.topChart.setOption({
color: ["#b43135"],
tooltip: {trigger: "axis"},
grid: {left: 45, right: 20, top: 25, bottom: 95},
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),
axisLabel: {interval: 0, rotate: 35, formatter: value => value.length > 10 ? value.slice(0, 10) + "…" : value}
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},
series: [{type: "bar", barMaxWidth: 34, data: this.topData.map(item => item.viewCount || 0)}]
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: ["#9c252a", "#d78643", "#d4ad62", "#547a86", "#7d669e", "#5a9a7c"],
tooltip: {trigger: "item"},
legend: {bottom: 0, type: "scroll"},
color: ["#5b8ff9", "#5ad8a6", "#6f86ad", "#f6bd16", "#e8684a", "#9270ca"],
tooltip: {trigger: "item", formatter: "{b}<br/>访问量:{c}<br/>占比:{d}%"},
series: [{
type: "pie", radius: ["38%", "68%"], center: ["50%", "43%"],
label: {formatter: "{b}\n{d}%"},
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()