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
@@ -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>
<!--#
}
#-->