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

This commit is contained in:
2026-07-30 15:42:06 +08:00
parent ff3e8075b5
commit 5043cee747
22 changed files with 2270 additions and 0 deletions
@@ -0,0 +1,176 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<guava ref="guava">
<el-card shadow="never">
<table-tool :app="this" label="风采墙栏目">
<el-button size="medium" type="primary" icon="el-icon-plus" @click="openAdd">新增栏目</el-button>
</table-tool>
<el-table v-loading="loading" :data="tableData" border row-key="id"
:tree-props="{children: 'children'}" default-expand-all>
<el-table-column prop="name" label="栏目名称" min-width="220"></el-table-column>
<el-table-column prop="code" label="栏目编码" min-width="180"></el-table-column>
<el-table-column prop="sortOrder" label="排序" width="90" align="center"></el-table-column>
<el-table-column label="显示" width="100" align="center">
<template slot-scope="{row}">
<el-switch :value="row.visible === 1" @change="changeVisible(row, $event)"></el-switch>
</template>
</el-table-column>
<el-table-column label="轮播" width="100" align="center">
<template slot-scope="{row}">
<el-tag :type="row.slider === 1 ? 'success' : 'info'" size="mini">
{{row.slider === 1 ? '开启' : '关闭'}}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="190" align="center">
<template slot-scope="{row}">
<el-button type="primary" size="mini" @click="openEdit(row)">编辑</el-button>
<el-button type="danger" size="mini" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</guava>
<el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="560px" :close-on-click-modal="false">
<el-form ref="categoryForm" :model="formData" :rules="rules" label-width="100px">
<el-form-item label="上级栏目" prop="parentId">
<el-select v-model="formData.parentId" style="width: 100%" placeholder="请选择上级栏目">
<el-option label="顶级栏目" value="0"></el-option>
<el-option v-for="item in rootOptions" :key="item.id" :label="item.name" :value="item.id"
:disabled="item.id === formData.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="栏目名称" prop="name">
<el-input v-model.trim="formData.name" maxlength="100" show-word-limit></el-input>
</el-form-item>
<el-form-item label="栏目编码" prop="code">
<el-input v-model.trim="formData.code" maxlength="50" placeholder="例如:teacher_style"></el-input>
</el-form-item>
<el-form-item label="排序" prop="sortOrder">
<el-input-number v-model="formData.sortOrder" :min="0" :max="9999" style="width: 100%"></el-input-number>
</el-form-item>
<el-form-item label="显示状态">
<el-radio-group v-model="formData.visible">
<el-radio :label="1">显示</el-radio>
<el-radio :label="0">隐藏</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="参与轮播">
<el-radio-group v-model="formData.slider">
<el-radio :label="1">开启</el-radio>
<el-radio :label="0">关闭</el-radio>
</el-radio-group>
<div class="el-form-item__tip">子栏目开启轮播后,其上级栏目会自动开启。</div>
</el-form-item>
</el-form>
<span slot="footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">确定</el-button>
</span>
</el-dialog>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
loading: false,
submitting: false,
tableData: [],
rootOptions: [],
dialogVisible: false,
dialogTitle: "",
formData: {},
rules: {
parentId: [{required: true, message: "请选择上级栏目", trigger: "change"}],
name: [{required: true, message: "请输入栏目名称", trigger: "blur"}],
code: [
{required: true, message: "请输入栏目编码", trigger: "blur"},
{pattern: /^[A-Za-z][A-Za-z0-9_-]*$/, message: "编码须以字母开头,仅支持字母、数字、下划线和短横线", trigger: "blur"}
],
sortOrder: [{required: true, message: "请输入排序", trigger: "change"}]
}
}
},
methods: {
// 加载栏目树,同时提取顶级栏目作为上级栏目选项。
loadData() {
this.loading = true
this.$axios.post(loc() + "/treeData").then(resp => {
if (resp.code === 0) {
this.tableData = resp.data || []
this.rootOptions = (resp.data || []).slice()
} else {
this.$message.warning(resp.msg)
}
}).finally(() => this.loading = false)
},
// 构造新增栏目默认值。
openAdd() {
this.dialogTitle = "新增栏目"
this.formData = {parentId: "0", name: "", code: "", sortOrder: 255, visible: 1, slider: 0}
this.dialogVisible = true
this.$nextTick(() => this.$refs.categoryForm && this.$refs.categoryForm.clearValidate())
},
// 将当前行复制到表单,避免直接修改表格数据。
openEdit(row) {
this.dialogTitle = "编辑栏目"
this.formData = Object.assign({}, row)
delete this.formData.children
this.dialogVisible = true
this.$nextTick(() => this.$refs.categoryForm && this.$refs.categoryForm.clearValidate())
},
// 校验后调用新增或编辑接口。
submit() {
this.$refs.categoryForm.validate(valid => {
if (!valid) return
this.submitting = true
const api = this.formData.id ? "/doEdit" : "/doAdd"
this.$axios.post(loc() + api, this.formData).then(resp => {
if (resp.code === 0) {
this.$message.success(resp.msg || "保存成功")
this.dialogVisible = false
this.loadData()
} else {
this.$message.warning(resp.msg)
}
}).finally(() => this.submitting = false)
})
},
// 修改栏目显示状态,失败时重新加载以恢复开关。
changeVisible(row, checked) {
this.$axios.post(loc() + "/doVisible", {id: row.id, visible: checked ? 1 : 0}).then(resp => {
if (resp.code === 0) {
this.$message.success("设置成功")
} else {
this.$message.warning(resp.msg)
}
this.loadData()
})
},
// 删除前进行二次确认;存在内容时后端会阻止删除。
remove(row) {
this.$confirm("确定删除栏目“" + row.name + "”吗?", "提示", {type: "warning"}).then(() => {
this.$axios.post(loc() + "/doDelete", {id: row.id}).then(resp => {
if (resp.code === 0) {
this.$message.success("删除成功")
this.loadData()
} else {
this.$message.warning(resp.msg)
}
})
})
}
},
created() {
this.loadData()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,198 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
.spread-editor-page .form-card { max-width: 1180px; margin: 0 auto; }
.spread-editor-page .form-tip { color: #909399; font-size: 12px; line-height: 20px; }
.spread-editor-page .footer-actions { padding: 18px 0 6px; text-align: center; }
</style>
<div id="app" class="spread-editor-page" v-cloak>
<el-card class="form-card" shadow="never" v-loading="loading">
<div slot="header">
<span style="font-size: 17px;font-weight: 600">{{formData.id ? '编辑风采内容' : '发布风采内容'}}</span>
</div>
<el-form ref="contentForm" :model="formData" :rules="rules" label-width="105px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="所属栏目" prop="categoryId">
<el-cascader v-model="formData.categoryId" :options="categoryOptions"
:props="{value:'id', label:'name', children:'children', emitPath:false, checkStrictly:false}"
filterable clearable style="width: 100%" placeholder="请选择末级栏目"></el-cascader>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序" prop="sortOrder">
<el-input-number v-model="formData.sortOrder" :min="0" :max="9999" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="标题" prop="title">
<el-input v-model.trim="formData.title" maxlength="300" show-word-limit></el-input>
</el-form-item>
<el-form-item label="副标题">
<el-input v-model.trim="formData.subTitle" maxlength="255" show-word-limit></el-input>
</el-form-item>
<el-form-item label="摘要" prop="summary">
<el-input v-model="formData.summary" type="textarea" :rows="3" maxlength="1000" show-word-limit
placeholder="用于风采墙列表展示"></el-input>
</el-form-item>
<el-form-item label="封面图" prop="thumbUrl">
<file-upload :upload_number="1" :upload_size="10 * 1024 * 1024"
:value.sync="formData.thumbUrl" accept=".jpg,.jpeg,.png,.webp"
upload_mode="image" upload_result_category="interval"></file-upload>
<div class="form-tip">建议使用横版图片,单张不超过 10MB。</div>
</el-form-item>
<el-form-item label="轮播图片">
<file-upload :upload_number="10" :upload_size="10 * 1024 * 1024"
:value.sync="sliderFiles" accept=".jpg,.jpeg,.png,.webp"
upload_mode="image" upload_result_category="array"></file-upload>
<div class="form-tip">仅栏目开启“参与轮播”后生效,最多上传 10 张。</div>
</el-form-item>
<el-form-item label="正文" prop="contentBody">
<text-editor v-model="formData.contentBody" :height="360" placeholder="请输入风采内容"></text-editor>
</el-form-item>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="置顶">
<el-switch v-model="formData.isTop" :active-value="1" :inactive-value="0"></el-switch>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="推荐">
<el-switch v-model="formData.isRecommend" :active-value="1" :inactive-value="0"></el-switch>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="头条">
<el-switch v-model="formData.isHeadline" :active-value="1" :inactive-value="0"></el-switch>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="发布状态">
<el-radio-group v-model="formData.isPublished">
<el-radio :label="0">保存草稿</el-radio>
<el-radio :label="1">立即发布</el-radio>
</el-radio-group>
</el-form-item>
<div class="footer-actions">
<el-button @click="resetForm">重置</el-button>
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
</div>
</el-form>
</el-card>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
loading: false,
submitting: false,
categoryOptions: [],
sliderFiles: [],
formData: {},
rules: {
categoryId: [{required: true, message: "请选择所属栏目", trigger: "change"}],
title: [{required: true, message: "请输入标题", trigger: "blur"}],
summary: [{required: true, message: "请输入摘要", trigger: "blur"}],
contentBody: [{required: true, message: "请输入正文", trigger: "change"}]
}
}
},
methods: {
// 生成新增内容时使用的默认表单。
defaultForm() {
return {
categoryId: "", title: "", subTitle: "", summary: "", contentBody: "",
thumbUrl: "", sliderImages: "", isTop: 0, isRecommend: 0,
isHeadline: 0, viewCount: 0, sortOrder: 255, isPublished: 0
}
},
// 读取地址栏中的编辑内容 ID。
getEditId() {
return new URLSearchParams(window.location.search).get("id") || ""
},
// 加载栏目树,内容只能选择末级栏目。
loadCategories() {
return this.$axios.post(loc() + "/categoryTree").then(resp => {
if (resp.code === 0) {
this.categoryOptions = resp.data || []
} else {
this.$message.warning(resp.msg)
}
})
},
// 编辑状态下加载内容详情并还原轮播图片数组。
loadDetail(id) {
if (!id) return Promise.resolve()
this.loading = true
return this.$axios.post(loc() + "/detailData", {id}).then(resp => {
if (resp.code === 0) {
this.formData = Object.assign(this.defaultForm(), resp.data || {})
this.sliderFiles = this.parseSliderFiles(this.formData.sliderImages)
} else {
this.$message.warning(resp.msg)
}
}).finally(() => this.loading = false)
},
// 兼容数据库中的 JSON 数组和历史逗号分隔地址。
parseSliderFiles(value) {
if (!value) return []
if (Array.isArray(value)) return value
try {
const urls = JSON.parse(value)
return Array.isArray(urls) ? urls : []
} catch (e) {
return String(value).split(",").filter(Boolean)
}
},
// 将上传组件返回的图片数组转换为可持久化 JSON。
buildSliderImages() {
const urls = (this.sliderFiles || []).map(item => {
if (typeof item === "string") return item
return item.url || item.data || (item.response && item.response.data) || ""
}).filter(Boolean)
return JSON.stringify(urls)
},
// 重置新增表单;编辑状态下恢复数据库内容。
resetForm() {
const id = this.getEditId()
if (id) {
this.loadDetail(id)
} else {
this.formData = this.defaultForm()
this.sliderFiles = []
}
this.$nextTick(() => this.$refs.contentForm && this.$refs.contentForm.clearValidate())
},
// 校验并提交新增或编辑数据。
submit() {
this.$refs.contentForm.validate(valid => {
if (!valid) return
const data = Object.assign({}, this.formData, {sliderImages: this.buildSliderImages()})
const api = data.id ? "/doEdit" : "/doAdd"
this.submitting = true
this.$axios.post(loc() + api, data).then(resp => {
if (resp.code === 0) {
this.$message.success(resp.msg || "保存成功")
if (!data.id) {
this.resetForm()
}
} else {
this.$message.warning(resp.msg)
}
}).finally(() => this.submitting = false)
})
}
},
created() {
this.formData = this.defaultForm()
const id = this.getEditId()
Promise.all([this.loadCategories(), this.loadDetail(id)])
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,215 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
#sub-app-container-main-content { padding: 0 !important; background: #f6f2ea; }
.spread-wall { min-height: 100%; color: #3c2f2a; background: linear-gradient(180deg, #8f171d 0, #bd352b 330px, #f6f2ea 330px); }
.spread-hero { max-width: 1280px; margin: 0 auto; padding: 34px 32px 26px; color: #fff; }
.spread-title { margin: 0; font-family: "Microsoft YaHei", serif; font-size: 34px; letter-spacing: 5px; }
.spread-subtitle { margin-top: 9px; color: rgba(255,255,255,.78); font-size: 14px; letter-spacing: 2px; }
.hero-slider { height: 205px; margin-top: 24px; overflow: hidden; border-radius: 12px; box-shadow: 0 14px 34px rgba(64, 10, 12, .28); background: #7d1b1f; }
.hero-slider .el-carousel, .hero-slider .el-carousel__container { height: 205px; }
.slider-item { height: 100%; position: relative; cursor: pointer; background: linear-gradient(135deg, #781116, #c44834); }
.slider-item img { width: 100%; height: 100%; object-fit: cover; opacity: .78; }
.slider-caption { position: absolute; left: 0; right: 0; bottom: 0; padding: 34px 28px 18px; color: #fff; font-size: 21px; font-weight: 600; background: linear-gradient(transparent, rgba(28,4,5,.78)); }
.slider-empty { height: 100%; display: flex; align-items: center; justify-content: center; font-size: 25px; letter-spacing: 6px; }
.spread-main { max-width: 1280px; margin: 0 auto; padding: 0 32px 38px; display: grid; grid-template-columns: 245px minmax(0, 1fr); gap: 22px; }
.category-panel, .content-panel { background: #fff; border-radius: 12px; box-shadow: 0 8px 28px rgba(90,53,34,.09); }
.category-panel { align-self: start; overflow: hidden; }
.category-header { padding: 17px 20px; color: #fff; font-size: 17px; font-weight: 600; background: #941d22; }
.category-parent { padding: 15px 18px 9px; color: #7e2224; font-weight: 700; border-top: 1px solid #f0e7df; }
.category-child { padding: 11px 18px 11px 29px; cursor: pointer; color: #655650; border-left: 3px solid transparent; transition: .2s; }
.category-child:hover, .category-child.active { color: #a32227; background: #fff3ed; border-left-color: #a32227; }
.content-panel { min-height: 570px; padding: 24px 28px; }
.section-heading { margin-bottom: 12px; padding-bottom: 14px; display: flex; align-items: flex-end; justify-content: space-between; border-bottom: 1px solid #eee2d9; }
.section-heading h2 { margin: 0; color: #7c1b20; font-size: 23px; }
.section-heading span { color: #a0938c; font-size: 13px; }
.content-card { padding: 17px 4px; display: grid; grid-template-columns: 72px minmax(0,1fr) 80px; gap: 18px; align-items: center; cursor: pointer; border-bottom: 1px solid #f0e9e4; transition: .2s; }
.content-card:hover { padding-left: 10px; background: #fffaf7; }
.content-date { color: #8f7770; text-align: center; border-right: 1px solid #eadfd7; }
.content-date strong { display: block; color: #8f1d22; font-size: 25px; }
.content-title { margin-bottom: 7px; color: #392c28; font-size: 17px; font-weight: 700; }
.content-summary { overflow: hidden; color: #7c6d67; font-size: 13px; line-height: 21px; text-overflow: ellipsis; white-space: nowrap; }
.content-view { color: #aa9d96; font-size: 12px; text-align: right; }
.wall-pagination { margin-top: 25px; text-align: center; }
.detail-title { margin: 8px 0 12px; color: #672025; font-size: 28px; text-align: center; }
.detail-meta { padding-bottom: 18px; color: #9c8d86; text-align: center; border-bottom: 1px solid #eee2d9; }
.detail-cover { max-width: 100%; max-height: 430px; margin: 24px auto; display: block; object-fit: contain; border-radius: 8px; }
.detail-body { color: #493b36; font-size: 16px; line-height: 1.9; word-break: break-word; }
.back-link { margin-bottom: 12px; }
@media (max-width: 800px) { .spread-main { grid-template-columns: 1fr; padding: 0 12px 24px; } .spread-hero { padding: 24px 12px; } .category-panel { position: static; } }
</style>
<div id="app" class="spread-wall" v-cloak>
<header class="spread-hero">
<h1 class="spread-title">风采墙</h1>
<div class="spread-subtitle">记录奋斗身影 · 展示教工风采 · 凝聚榜样力量</div>
<div class="hero-slider">
<el-carousel v-if="sliderItems.length" :interval="5000" arrow="hover">
<el-carousel-item v-for="item in sliderItems" :key="item.id">
<div class="slider-item" @click="openDetail(item)">
<img v-if="getSliderImage(item)" :src="getSliderImage(item)" :alt="item.title">
<div class="slider-caption">{{item.title}}</div>
</div>
</el-carousel-item>
</el-carousel>
<div v-else class="slider-empty">榜样引领 · 风采绽放</div>
</div>
</header>
<div class="spread-main">
<aside class="category-panel">
<div class="category-header"><i class="el-icon-collection-tag"></i> 风采栏目</div>
<template v-for="parent in categories">
<div :key="parent.id + '-p'" class="category-parent">{{parent.name}}</div>
<div v-if="!parent.children || !parent.children.length" :key="parent.id"
class="category-child" :class="{active: currentCategory.id === parent.id}"
@click="selectCategory(parent)">{{parent.name}}</div>
<div v-for="child in parent.children" :key="child.id"
class="category-child" :class="{active: currentCategory.id === child.id}"
@click="selectCategory(child)">{{child.name}}</div>
</template>
</aside>
<main class="content-panel" v-loading="loading">
<template v-if="!detail">
<div class="section-heading">
<h2>{{currentCategory.name || '风采展示'}}</h2>
<span>展示教职工的先进事迹与精彩瞬间</span>
</div>
<article v-for="item in pageData.list" :key="item.id" class="content-card" @click="openDetail(item)">
<div class="content-date">
<strong>{{datePart(item.publishedAt, 'DD')}}</strong>
{{datePart(item.publishedAt, 'YYYY.MM')}}
</div>
<div>
<div class="content-title">
<el-tag v-if="item.isTop === 1" type="danger" size="mini">置顶</el-tag>
{{item.title}}
</div>
<div class="content-summary">{{item.summary || item.subTitle || '点击查看详细内容'}}</div>
</div>
<div class="content-view"><i class="el-icon-view"></i> {{item.viewCount || 0}}</div>
</article>
<el-empty v-if="!pageData.list.length && !loading" description="暂无风采内容"></el-empty>
<el-pagination v-if="pageData.totalCount > pageData.pageSize" class="wall-pagination"
background layout="prev, pager, next" :current-page.sync="pageData.pageNumber"
:page-size="pageData.pageSize" :total="pageData.totalCount"
@current-change="loadPage"></el-pagination>
</template>
<template v-else>
<el-button class="back-link" icon="el-icon-back" plain size="small" @click="detail = null">返回列表</el-button>
<h1 class="detail-title">{{detail.title}}</h1>
<div class="detail-meta">
{{formatTime(detail.publishedAt || detail.createdAt)}} 
{{detail.categoryName}}  <i class="el-icon-view"></i> {{detail.viewCount || 0}}
</div>
<img v-if="getCoverUrl(detail.thumbUrl)" class="detail-cover" :src="getCoverUrl(detail.thumbUrl)" :alt="detail.title">
<div class="detail-body" v-html="detail.contentBody"></div>
</template>
</main>
</div>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
apiBase: "/platform/spread/display",
loading: false,
categories: [],
sliderItems: [],
currentCategory: {},
detail: null,
pageData: {pageNumber: 1, pageSize: 8, totalCount: 0, list: []}
}
},
methods: {
// 加载可见栏目并自动选择第一个末级栏目。
loadCategories() {
return this.$axios.post(this.apiBase + "/categoryTree").then(resp => {
if (resp.code !== 0) return
this.categories = resp.data || []
const firstParent = this.categories[0]
this.currentCategory = firstParent && firstParent.children && firstParent.children.length
? firstParent.children[0] : (firstParent || {})
})
},
// 加载开启轮播栏目的最新发布内容。
loadSlider() {
return this.$axios.post(this.apiBase + "/sliderData").then(resp => {
if (resp.code === 0) this.sliderItems = resp.data || []
})
},
// 根据当前栏目加载已发布内容。
loadPage() {
if (!this.currentCategory.id) {
this.pageData.list = []
return
}
this.loading = true
this.$axios.post(this.apiBase + "/pageData", {
categoryId: this.currentCategory.id,
pageNumber: this.pageData.pageNumber,
pageSize: this.pageData.pageSize
}).then(resp => {
if (resp.code === 0) this.pageData = resp.data
}).finally(() => this.loading = false)
},
// 切换栏目时回到第一页和列表状态。
selectCategory(category) {
this.currentCategory = category
this.detail = null
this.pageData.pageNumber = 1
this.loadPage()
},
// 获取完整详情并登记一次浏览。
openDetail(item) {
this.loading = true
Promise.all([
this.$axios.post(this.apiBase + "/detailData", {id: item.id}),
this.$axios.post(this.apiBase + "/addView", {id: item.id})
]).then(values => {
if (values[0].code === 0) {
this.detail = values[0].data
this.detail.viewCount = Number(this.detail.viewCount || 0) + 1
}
}).finally(() => this.loading = false)
},
// 从上传字段中取得第一张图片地址。
getCoverUrl(value) {
if (!value) return ""
if (Array.isArray(value)) return value.length ? (value[0].url || value[0].data || value[0]) : ""
if (typeof value === "string" && value.trim().startsWith("[")) {
try {
const files = JSON.parse(value)
const first = files[0]
return first ? (first.url || first.data || first) : ""
} catch (e) {
return ""
}
}
return String(value).split(",")[0]
},
// 轮播图优先使用轮播图片,未配置时回退到封面。
getSliderImage(item) {
return this.getCoverUrl(item.sliderImages) || this.getCoverUrl(item.thumbUrl)
},
// 格式化列表日期的指定部分。
datePart(value, format) {
return value ? this.$moment(Number(value)).format(format) : "--"
},
// 格式化详情发布时间。
formatTime(value) {
return value ? this.$moment(Number(value)).format("YYYY-MM-DD HH:mm") : ""
}
},
created() {
Promise.all([this.loadCategories(), this.loadSlider()]).then(() => this.loadPage())
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,180 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<guava ref="guava">
<el-card shadow="never">
<search :is-search-button="false">
<search-item label="内容标题">
<el-input v-model="pageForm.title" clearable placeholder="请输入标题" @keyup.enter.native="doSearch"></el-input>
</search-item>
<search-item label="所属栏目">
<el-cascader v-model="pageForm.categoryId" :options="categoryOptions"
:props="{value:'id',label:'name',children:'children',emitPath:false,checkStrictly:true}"
clearable filterable style="width: 100%"></el-cascader>
</search-item>
<search-item label="发布状态">
<el-select v-model="pageForm.isPublished" clearable placeholder="请选择">
<el-option label="草稿" :value="0"></el-option>
<el-option label="已发布" :value="1"></el-option>
</el-select>
</search-item>
<search-item label="创建时间">
<el-date-picker v-model="pageForm.createTime" type="datetimerange"
start-placeholder="开始时间" end-placeholder="结束时间"
value-format="yyyy-MM-dd HH:mm:ss" style="width: 100%"></el-date-picker>
</search-item>
<div class="search-query">
<el-button type="primary" size="medium" icon="el-icon-search" @click="doSearch">搜索</el-button>
<el-button size="medium" @click="resetSearch">重置</el-button>
</div>
</search>
</el-card>
<el-card class="mt10" shadow="never">
<table-tool :app="this" label="风采内容列表">
<el-button type="primary" size="medium" icon="el-icon-plus" @click="goCreate">发布内容</el-button>
</table-tool>
<el-table v-loading="tableLoading" :data="tableData" :size="tableSize" border>
<el-table-column :index="indexMethod" type="index" label="序号" width="70" align="center"></el-table-column>
<el-table-column prop="title" label="标题" min-width="240" show-overflow-tooltip>
<template slot-scope="{row}">
<el-tag v-if="row.isTop === 1" type="danger" size="mini" style="margin-right: 5px">置顶</el-tag>
{{row.title}}
</template>
</el-table-column>
<el-table-column prop="categoryName" label="栏目" min-width="130"></el-table-column>
<el-table-column prop="viewCount" label="浏览量" width="100" align="center"></el-table-column>
<el-table-column prop="sortOrder" label="排序" width="90" align="center"></el-table-column>
<el-table-column label="状态" width="100" align="center">
<template slot-scope="{row}">
<el-tag :type="row.isPublished === 1 ? 'success' : 'info'" size="mini">
{{row.isPublished === 1 ? '已发布' : '草稿'}}
</el-tag>
</template>
</el-table-column>
<el-table-column label="发布时间" width="170" align="center">
<template slot-scope="{row}">{{formatTime(row.publishedAt)}}</template>
</el-table-column>
<el-table-column label="操作" width="270" align="center" fixed="right">
<template slot-scope="{row}">
<el-button size="mini" type="primary" @click="goEdit(row)">编辑</el-button>
<el-button size="mini" :type="row.isPublished === 1 ? 'warning' : 'success'"
@click="togglePublish(row)">
{{row.isPublished === 1 ? '撤回' : '发布'}}
</el-button>
<el-button size="mini" type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</guava>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
mixins: [initTableMixins],
data() {
return {
apiBase: "/platform/spread/manage",
categoryOptions: []
}
},
methods: {
// 组装分页查询参数,将时间范围拆成后端字段。
buildPageParams() {
const params = Object.assign({}, this.pageForm)
params.beginTime = params.createTime && params.createTime.length ? params.createTime[0] : ""
params.endTime = params.createTime && params.createTime.length ? params.createTime[1] : ""
delete params.createTime
return params
},
// 加载内容管理分页数据。
pageData() {
this.tableLoading = true
this.$axios.post(this.apiBase + "/pageData", this.buildPageParams()).then(resp => {
if (resp.code === 0) {
this.tableData = resp.data.list || []
this.pageForm.totalCount = resp.data.totalCount || 0
} else {
this.$message.warning(resp.msg)
}
}).finally(() => this.tableLoading = false)
},
// 加载可筛选的栏目树。
loadCategories() {
return this.$axios.post(this.apiBase + "/categoryTree").then(resp => {
if (resp.code === 0) this.categoryOptions = resp.data || []
})
},
// 清空筛选条件并重新查询。
resetSearch() {
this.pageForm.title = ""
this.pageForm.categoryId = ""
this.pageForm.isPublished = ""
this.pageForm.createTime = []
this.doSearch()
},
// 跳转到独立发布页面。
goCreate() {
this.navigate("/platform/spread/creation")
},
// 跳转到发布页面并携带编辑 ID。
goEdit(row) {
this.navigate("/platform/spread/creation?id=" + encodeURIComponent(row.id))
},
// 兼容 pjax 与普通页面跳转。
navigate(url) {
if (window.commonUtil && commonUtil.pjaxPush) {
commonUtil.pjaxPush(url)
} else {
window.location.href = url
}
},
// 发布已保存的草稿,或撤回已发布内容。
togglePublish(row) {
const status = row.isPublished === 1 ? 0 : 1
const action = status === 1 ? "发布" : "撤回"
this.$confirm("确定" + action + "“" + row.title + "”吗?", "提示", {type: "warning"}).then(() => {
this.$axios.post(this.apiBase + "/doPublish", {id: row.id, status}).then(resp => {
if (resp.code === 0) {
this.$message.success(action + "成功")
this.pageData()
} else {
this.$message.warning(resp.msg)
}
})
})
},
// 删除内容前进行二次确认。
remove(row) {
this.$confirm("删除后无法恢复,确定删除该内容吗?", "提示", {type: "warning"}).then(() => {
this.$axios.post(this.apiBase + "/doDelete", {id: row.id}).then(resp => {
if (resp.code === 0) {
this.$message.success("删除成功")
this.pageData()
} else {
this.$message.warning(resp.msg)
}
})
})
},
// 将毫秒时间戳格式化为日期时间。
formatTime(value) {
return value ? this.$moment(Number(value)).format("YYYY-MM-DD HH:mm:ss") : ""
}
},
created() {
this.$set(this.pageForm, "title", "")
this.$set(this.pageForm, "categoryId", "")
this.$set(this.pageForm, "isPublished", "")
this.$set(this.pageForm, "createTime", [])
this.loadCategories().then(() => this.pageData())
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,178 @@
<!--#
layout("/layouts/platform.html"){
#-->
<script src="${base!}/assets/platform/plugins/echarts/echarts.min.js" nonce="${cspNonce!}"></script>
<style>
.spread-statistics .stat-grid { margin: 10px 0; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 14px; }
.spread-statistics .stat-card { padding: 20px; position: relative; overflow: hidden; border-radius: 9px; color: #fff; box-shadow: 0 8px 22px rgba(30,50,80,.1); }
.spread-statistics .stat-card:nth-child(1) { background: linear-gradient(135deg,#3378d1,#58a1ef); }
.spread-statistics .stat-card:nth-child(2) { background: linear-gradient(135deg,#7357c8,#9d7ee7); }
.spread-statistics .stat-card:nth-child(3) { background: linear-gradient(135deg,#2b9c73,#50c499); }
.spread-statistics .stat-card:nth-child(4) { background: linear-gradient(135deg,#d87a2e,#efa55b); }
.spread-statistics .stat-card:nth-child(5) { background: linear-gradient(135deg,#bd4552,#e36c78); }
.spread-statistics .stat-label { color: rgba(255,255,255,.82); font-size: 13px; }
.spread-statistics .stat-value { margin-top: 8px; font-size: 29px; font-weight: 700; }
.spread-statistics .chart-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, .75fr); gap: 14px; }
.spread-statistics .chart-box { height: 390px; }
@media(max-width: 980px) { .spread-statistics .stat-grid { grid-template-columns: repeat(2, 1fr); } .spread-statistics .chart-grid { grid-template-columns: 1fr; } }
</style>
<div id="app" class="spread-statistics" v-cloak>
<guava ref="guava">
<el-card shadow="never">
<search :is-search-button="false">
<search-item label="所属栏目">
<el-cascader v-model="query.categoryId" :options="categoryOptions"
:props="{value:'id',label:'name',children:'children',emitPath:false,checkStrictly:true}"
clearable filterable style="width: 100%"></el-cascader>
</search-item>
<search-item label="发布状态">
<el-select v-model="query.isPublished" clearable placeholder="全部">
<el-option label="草稿" :value="0"></el-option>
<el-option label="已发布" :value="1"></el-option>
</el-select>
</search-item>
<search-item label="创建时间">
<el-date-picker v-model="query.createTime" type="datetimerange"
value-format="yyyy-MM-dd HH:mm:ss" start-placeholder="开始时间"
end-placeholder="结束时间" style="width: 100%"></el-date-picker>
</search-item>
<div class="search-query">
<el-button type="primary" size="medium" icon="el-icon-search" @click="loadAll">查询</el-button>
<el-button size="medium" @click="resetQuery">重置</el-button>
</div>
</search>
</el-card>
<div class="stat-grid" v-loading="loading">
<div class="stat-card"><div class="stat-label">栏目数量</div><div class="stat-value">{{summary.categoryCount || 0}}</div></div>
<div class="stat-card"><div class="stat-label">内容总数</div><div class="stat-value">{{summary.contentCount || 0}}</div></div>
<div class="stat-card"><div class="stat-label">已发布</div><div class="stat-value">{{summary.publishedCount || 0}}</div></div>
<div class="stat-card"><div class="stat-label">累计浏览</div><div class="stat-value">{{summary.viewCount || 0}}</div></div>
<div class="stat-card"><div class="stat-label">平均浏览</div><div class="stat-value">{{summary.avgViewCount || 0}}</div></div>
</div>
<div class="chart-grid">
<el-card shadow="never">
<div slot="header"><b>浏览量 TOP 10</b></div>
<div ref="topChart" class="chart-box"></div>
</el-card>
<el-card shadow="never">
<div slot="header"><b>栏目浏览量占比</b></div>
<div ref="categoryChart" class="chart-box"></div>
</el-card>
</div>
<el-card class="mt10" shadow="never">
<div slot="header"><b>栏目数据明细</b></div>
<el-table :data="categoryData" border>
<el-table-column type="index" label="序号" width="70" align="center"></el-table-column>
<el-table-column prop="categoryName" label="栏目名称" min-width="180"></el-table-column>
<el-table-column prop="contentCount" label="内容数量" width="140" align="center"></el-table-column>
<el-table-column prop="viewCount" label="浏览量" width="140" align="center"></el-table-column>
</el-table>
</el-card>
</guava>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
apiBase: "/platform/spread/statistics",
loading: false,
query: {categoryId: "", isPublished: "", createTime: []},
categoryOptions: [],
summary: {},
topData: [],
categoryData: [],
topChart: null,
categoryChart: null
}
},
methods: {
// 将时间范围拆分成统计接口参数。
buildParams() {
return {
categoryId: this.query.categoryId,
isPublished: this.query.isPublished,
beginTime: this.query.createTime && this.query.createTime.length ? this.query.createTime[0] : "",
endTime: this.query.createTime && this.query.createTime.length ? this.query.createTime[1] : ""
}
},
// 加载栏目筛选树。
loadCategories() {
return this.$axios.post(this.apiBase + "/categoryTree").then(resp => {
if (resp.code === 0) this.categoryOptions = resp.data || []
})
},
// 并行加载卡片、排行和栏目统计,减少页面等待时间。
loadAll() {
this.loading = true
const params = this.buildParams()
Promise.all([
this.$axios.post(this.apiBase + "/summary", params),
this.$axios.post(this.apiBase + "/top10", params),
this.$axios.post(this.apiBase + "/categoryData", params)
]).then(values => {
this.summary = values[0].code === 0 ? (values[0].data || {}) : {}
this.topData = values[1].code === 0 ? (values[1].data || []) : []
this.categoryData = values[2].code === 0 ? (values[2].data || []) : []
this.$nextTick(this.renderCharts)
}).finally(() => this.loading = false)
},
// 清空筛选并重新统计。
resetQuery() {
this.query = {categoryId: "", isPublished: "", createTime: []}
this.loadAll()
},
// 创建或刷新两个统计图表。
renderCharts() {
if (!window.echarts) return
if (!this.topChart) this.topChart = echarts.init(this.$refs.topChart)
if (!this.categoryChart) this.categoryChart = echarts.init(this.$refs.categoryChart)
this.topChart.setOption({
color: ["#b43135"],
tooltip: {trigger: "axis"},
grid: {left: 45, right: 20, top: 25, bottom: 95},
xAxis: {
type: "category",
data: this.topData.map(item => item.title),
axisLabel: {interval: 0, rotate: 35, formatter: value => value.length > 10 ? value.slice(0, 10) + "…" : value}
},
yAxis: {type: "value", minInterval: 1},
series: [{type: "bar", barMaxWidth: 34, data: this.topData.map(item => item.viewCount || 0)}]
}, true)
this.categoryChart.setOption({
color: ["#9c252a", "#d78643", "#d4ad62", "#547a86", "#7d669e", "#5a9a7c"],
tooltip: {trigger: "item"},
legend: {bottom: 0, type: "scroll"},
series: [{
type: "pie", radius: ["38%", "68%"], center: ["50%", "43%"],
label: {formatter: "{b}\n{d}%"},
data: this.categoryData.map(item => ({name: item.categoryName, value: item.viewCount || 0}))
}]
}, true)
},
// 浏览器尺寸变化时重算图表尺寸。
resizeCharts() {
if (this.topChart) this.topChart.resize()
if (this.categoryChart) this.categoryChart.resize()
}
},
created() {
this.loadCategories().then(() => this.loadAll())
},
mounted() {
window.addEventListener("resize", this.resizeCharts)
},
beforeDestroy() {
window.removeEventListener("resize", this.resizeCharts)
if (this.topChart) this.topChart.dispose()
if (this.categoryChart) this.categoryChart.dispose()
}
})
</script>
<!--#
}
#-->