2026-07-17 15:28:41 +08:00

729 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="article-admin-page">
<div class="page-title-wrap">
<h1>企业动态</h1>
</div>
<div class="stats-grid">
<div
v-for="item in statsCards"
:key="item.type"
class="stats-card"
>
<div class="stats-icon" :class="item.iconClass">
<i :class="item.icon"></i>
</div>
<div>
<div class="stats-label">{{ item.label }}</div>
<div class="stats-value">{{ item.count }} </div>
<div class="stats-read">阅读 {{ item.views }}</div>
</div>
</div>
</div>
<div class="filter-card">
<el-form :model="queryForm" inline class="filter-form">
<el-form-item label="文章类型">
<el-select v-model="queryForm.type" size="small" clearable placeholder="全部类型" @change="handleSearch">
<el-option label="企业动态" value="企业动态" />
<el-option label="产品动态" value="产品动态" />
<el-option label="行业洞察" value="行业洞察" />
<el-option label="活动资讯" value="活动资讯" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryForm.status" size="small" clearable placeholder="全部状态" @change="handleSearch">
<el-option label="已发布" value="published" />
<el-option label="草稿" value="draft" />
</el-select>
</el-form-item>
<el-form-item>
<el-input
v-model.trim="queryForm.keyword"
size="small"
clearable
placeholder="搜索文章标题..."
@keyup.enter.native="handleSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" size="small" icon="el-icon-search" @click="handleSearch">搜索</el-button>
<el-button type="primary" size="small" icon="el-icon-plus" @click="openArticleEditor">新建文章</el-button>
</el-form-item>
</el-form>
</div>
<div class="table-card">
<el-table
:data="pagedArticles"
v-loading="loading"
class="article-table"
style="width: 100%"
empty-text="暂无文章点击新建文章开始创作"
>
<el-table-column label="序号" width="80">
<template slot-scope="scope">
{{ getRowNo(scope.$index) }}
</template>
</el-table-column>
<el-table-column
prop="title"
label="标题"
min-width="240"
align="left"
header-align="left"
class-name="title-column"
label-class-name="title-column-header"
show-overflow-tooltip
>
<template slot-scope="scope">
<span class="article-title">{{ scope.row.title }}</span>
</template>
</el-table-column>
<el-table-column label="类型" width="120">
<template slot-scope="scope">
<span class="type-tag" :class="getTypeClass(scope.row.type)">{{ scope.row.type }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template slot-scope="scope">
<span class="status-tag" :class="scope.row.status === 'published' ? 'is-published' : 'is-draft'">
<i></i>{{ scope.row.status === 'published' ? '已发布' : '草稿' }}
</span>
</template>
</el-table-column>
<el-table-column prop="updateTime" label="创建时间" width="170" />
<el-table-column prop="publishTime" label="发布时间" width="140" />
<el-table-column label="阅读数据" width="130">
<template slot-scope="scope">
<span v-if="scope.row.status === 'published'" class="views-text">
<i class="el-icon-view"></i>{{ scope.row.views }}
</span>
<span v-else class="empty-read">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template slot-scope="scope">
<el-button
type="text"
size="mini"
:disabled="scope.row.status === 'published'"
@click="editArticle(scope.row)"
>
编辑
</el-button>
<el-button
v-if="scope.row.status === 'published'"
type="text"
size="mini"
class="warning-text"
@click="unpublishArticle(scope.row)"
>
下架
</el-button>
<el-button
v-else
type="text"
size="mini"
class="success-text"
@click="publishArticle(scope.row)"
>
发布
</el-button>
<el-button
type="text"
size="mini"
class="danger-text"
:disabled="scope.row.status === 'published'"
@click="deleteArticle(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-pagination">
<span class="pagination-info">显示 {{ pageStart }}-{{ pageEnd }} {{ total }} </span>
<el-pagination
background
layout="prev, pager, next, jumper"
:current-page.sync="page"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
/>
</div>
</div>
<article-editor-dialog
:visible.sync="editorVisible"
:article="currentArticle"
@save="handleSaveArticle"
/>
</div>
</template>
<script>
import ArticleEditorDialog from './ArticleEditorDialog.vue'
import {
reqAddNews,
reqDeleteNews,
reqEditNews,
reqNewsListAdmin,
reqPublishNews,
reqUnpublishNews
} from '@/api/newsapi/newsapi'
export default {
name: 'NewsLog',
components: { ArticleEditorDialog },
data() {
return {
page: 1,
pageSize: 10,
total: 0,
loading: false,
editorVisible: false,
currentArticle: null,
queryForm: {
type: '',
status: '',
keyword: ''
},
articleList: []
}
},
computed: {
filteredArticles() {
return this.articleList
},
pagedArticles() {
return this.articleList
},
pageStart() {
if (!this.total) return 0
return (this.page - 1) * this.pageSize + 1
},
pageEnd() {
if (!this.total) return 0
return Math.min(this.page * this.pageSize, this.total)
},
statsCards() {
const map = {
企业动态: { label: '企业动态', type: '企业动态', icon: 'el-icon-document', iconClass: 'blue' },
产品动态: { label: '产品动态', type: '产品动态', icon: 'el-icon-box', iconClass: 'green' },
行业洞察: { label: '行业洞察', type: '行业洞察', icon: 'el-icon-data-analysis', iconClass: 'amber' },
活动资讯: { label: '活动资讯', type: '活动资讯', icon: 'el-icon-date', iconClass: 'purple' }
}
return Object.values(map).map(item => {
const list = this.articleList.filter(article => article.type === item.type)
return {
...item,
count: list.length,
views: list.reduce((total, article) => total + Number(article.views || 0), 0)
}
})
}
},
created() {
// 页面创建后立即拉取第一页文章列表。
this.getArticleList()
},
methods: {
getRowNo(index) {
// 根据当前分页计算表格序号。
return (this.page - 1) * this.pageSize + index + 1
},
getTypeClass(type) {
// 按文章类型返回对应的标签样式类。
const classMap = {
企业动态: 'type-blue',
产品动态: 'type-green',
行业洞察: 'type-amber',
活动资讯: 'type-violet'
}
return classMap[type] || 'type-blue'
},
handleSearch() {
// 筛选条件变化后从第一页重新查询。
this.page = 1
this.getArticleList()
},
handlePageChange(page) {
// 用户切换分页后更新页码并重新拉取数据。
this.page = page
this.getArticleList()
},
openArticleEditor() {
// 清空当前文章,打开新建弹窗。
this.currentArticle = null
this.editorVisible = true
},
editArticle(row) {
// 编辑回显直接读取当前表格行数据,不再请求详情接口。
this.currentArticle = { ...row }
this.editorVisible = true
},
getApiStatus(status) {
// 页面状态转成接口需要的状态值1 上架0 草稿/下架。
return String(status) === '1' || status === 'published' ? '1' : '0'
},
getViewStatus(status) {
// 接口状态转成页面使用的状态枚举。
return String(status) === '1' || status === 'published' ? 'published' : 'draft'
},
isSuccess(res) {
// 兼容不同接口的成功标识。
return res && (res.status === true || res.status === 'true' || res.code === 200)
},
buildQueryParams() {
// 组装后台列表查询参数。
return {
url_link: window.location.href,
current_page: String(this.page),
page_size: String(this.pageSize),
title: this.queryForm.keyword.trim(),
article_type: this.queryForm.type,
status: this.queryForm.status ? this.getApiStatus(this.queryForm.status) : ''
}
},
normalizeArticle(row) {
// 将接口字段统一转换成页面表格和编辑弹窗使用的字段。
return {
id: row.id,
title: row.title || '',
summary: row.summary || '',
type: row.article_type || row.type || '企业动态',
status: this.getViewStatus(row.status),
publishTime: row.publish_time || row.publishTime || '',
updateTime: row.update_time || row.updateTime || '',
views: row.read_count || row.views || 0,
content: row.content || '',
coverUrl: row.cover_img || row.coverUrl || '',
coverName: row.cover_name || row.coverName || ''
}
},
getResponseList(res) {
// 兼容接口可能返回的多种列表结构。
if (Array.isArray(res.data)) return res.data
if (res.data && Array.isArray(res.data.data)) return res.data.data
if (res.data && Array.isArray(res.data.list)) return res.data.list
if (Array.isArray(res.list)) return res.list
return []
},
getResponseTotal(res, list) {
// 兼容接口可能返回的多种总数字段。
const data = res.data && !Array.isArray(res.data) ? res.data : {}
const pagination = res.pagination || data.pagination || data.page || {}
return Number(
res.total ||
res.total_count ||
data.total ||
data.total_count ||
pagination.total ||
list.length
)
},
buildArticlePayload(article) {
// 将编辑弹窗字段转换成新增/编辑接口需要的字段。
return {
...(article.id ? { id: article.id } : {}),
url_link: window.location.href,
title: article.title,
article_type: article.type,
summary: article.summary,
update_time: article.update_time || article.updateTime,
publish_time: article.publish_time || article.publishTime,
cover_img: article.coverUrl,
content: article.content,
status: this.getApiStatus(article.status)
}
},
async confirmAction(title, message, type = 'warning', confirmButtonText = '确定') {
// 操作前弹出二次确认,用户取消时直接终止后续请求。
const isDanger = type === 'error'
const isSuccess = type === 'success'
try {
await this.$confirm(message, title, {
confirmButtonText,
cancelButtonText: '取消',
type,
customClass: `news-confirm-box ${isDanger ? 'news-confirm-box--danger' : isSuccess ? 'news-confirm-box--success' : 'news-confirm-box--warning'}`,
confirmButtonClass: `news-confirm-btn news-confirm-btn--confirm ${isDanger ? 'is-danger' : isSuccess ? 'is-success' : ''}`,
cancelButtonClass: 'news-confirm-btn news-confirm-btn--cancel'
})
return true
} catch (error) {
return false
}
},
async getArticleList() {
// 开启表格 loading防止用户误以为页面没有响应。
this.loading = true
try {
// 按当前筛选条件和分页请求后台文章列表。
const res = await reqNewsListAdmin(this.buildQueryParams())
if (this.isSuccess(res)) {
// 请求成功后解析列表数据。
const list = this.getResponseList(res)
// 将接口数据映射成页面展示字段。
this.articleList = list.map(item => this.normalizeArticle(item))
// 读取接口总数,用于分页器和底部统计。
this.total = this.getResponseTotal(res, list)
return
}
// 接口返回失败时清空列表,避免展示旧数据。
this.articleList = []
this.total = 0
} catch (error) {
// 请求异常时清空列表并提示用户。
this.articleList = []
this.total = 0
this.$message.error('文章列表加载失败')
} finally {
// 无论成功失败都关闭 loading。
this.loading = false
}
},
async handleSaveArticle(article) {
// 根据是否存在 id 判断是编辑还是新增。
const request = article.id ? reqEditNews : reqAddNews
try {
// 调用新增或编辑接口保存文章。
const res = await request(this.buildArticlePayload(article))
if (!this.isSuccess(res)) {
// 接口明确返回失败时展示后端错误信息。
this.$message.error((res && (res.message || res.msg)) || '保存失败')
return
}
// 保存成功后提示,并刷新列表展示最新数据。
this.$message.success(this.getApiStatus(article.status) === '1' ? '发布成功' : '草稿已保存')
this.getArticleList()
} catch (error) {
// 网络或程序异常时提示保存失败。
this.$message.error('保存失败')
}
},
async publishArticle(row) {
// 上架前先二次确认,避免误操作。
const confirmed = await this.confirmAction('确认发布企业动态文章?', '请确认该文章不包含敏感词汇。', 'success', '确定发布')
if (!confirmed) return
try {
// 调用上架接口。
const res = await reqPublishNews({ id: row.id })
if (!this.isSuccess(res)) {
// 接口返回失败时展示错误并停止刷新。
this.$message.error((res && (res.message || res.msg)) || '发布失败')
return
}
// 上架成功后刷新列表。
this.$message.success('发布成功')
this.getArticleList()
} catch (error) {
// 请求异常时提示用户。
this.$message.error('发布失败')
}
},
async unpublishArticle(row) {
// 下架前先二次确认,避免误操作。
const confirmed = await this.confirmAction('确定要下架这篇文章吗?', '下架后将在企业动态页面隐藏。')
if (!confirmed) return
try {
// 调用下架接口。
const res = await reqUnpublishNews({ id: row.id })
if (!this.isSuccess(res)) {
// 接口返回失败时展示错误并停止刷新。
this.$message.error((res && (res.message || res.msg)) || '下架失败')
return
}
// 下架成功后刷新列表。
this.$message.success('下架成功')
this.getArticleList()
} catch (error) {
// 请求异常时提示用户。
this.$message.error('下架失败')
}
},
async deleteArticle(row) {
// 删除前先二次确认,避免误删文章。
const confirmed = await this.confirmAction('确认删除', `删除后不可恢复,确定要删除「${row.title}」吗?`, 'error')
if (!confirmed) return
try {
// 调用删除接口。
const res = await reqDeleteNews({ id: row.id })
if (!this.isSuccess(res)) {
// 接口返回失败时展示错误并停止刷新。
this.$message.error((res && (res.message || res.msg)) || '删除失败')
return
}
// 删除当前页最后一条时,自动回到上一页。
if (this.articleList.length === 1 && this.page > 1) this.page -= 1
// 删除成功后刷新列表。
this.$message.success('删除成功')
this.getArticleList()
} catch (error) {
// 请求异常时提示用户。
this.$message.error('删除失败')
}
}
}
}
</script>
<style scoped>
.article-admin-page {
min-height: calc(100vh - 84px);
padding: 24px;
background: #f3f7ff;
}
.page-title-wrap {
margin-bottom: 24px;
}
.page-title-wrap h1 {
margin: 0;
color: #1f2937;
font-size: 28px;
font-weight: 700;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stats-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #fff;
border: 1px solid #edf0f5;
border-radius: 14px;
}
.stats-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
font-size: 20px;
}
.stats-icon.blue {
color: #3b82f6;
background: #eff6ff;
}
.stats-icon.green {
color: #10b981;
background: #ecfdf5;
}
.stats-icon.amber {
color: #f59e0b;
background: #fffbeb;
}
.stats-icon.purple {
color: #8b5cf6;
background: #f5f3ff;
}
.stats-label,
.stats-read {
color: #9ca3af;
font-size: 12px;
}
.stats-value {
margin: 3px 0;
color: #1f2937;
font-size: 18px;
font-weight: 700;
}
.filter-card,
.table-card {
background: #fff;
border: 1px solid #edf0f5;
border-radius: 14px;
}
.filter-card {
padding: 16px;
margin-bottom: 24px;
}
.filter-form {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.filter-form ::v-deep .el-form-item {
margin-right: 14px;
margin-bottom: 0;
}
.filter-form ::v-deep .el-input,
.filter-form ::v-deep .el-select {
width: 170px;
}
.filter-form ::v-deep .el-input__inner {
border-radius: 8px;
}
.filter-form ::v-deep .el-button--small {
height: 32px;
border-radius: 8px;
}
.table-card {
overflow: hidden;
}
.article-table ::v-deep th {
background: #f8fafc;
color: #6b7280;
font-size: 14px;
font-weight: 600;
}
.article-table ::v-deep .title-column .cell,
.article-table ::v-deep .title-column-header .cell {
justify-content: flex-start;
/* text-align: left !important; */
}
.article-title {
display: block;
width: 100%;
color: #1f2937;
font-weight: 600;
text-align: left;
}
.type-tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
}
.type-blue {
color: #2563eb;
background: #eff6ff;
}
.type-green {
color: #059669;
background: #ecfdf5;
}
.type-amber {
color: #d97706;
background: #fffbeb;
}
.type-violet {
color: #db2777;
background: #fdf2f8;
}
.status-tag {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.status-tag i {
width: 6px;
height: 6px;
border-radius: 50%;
}
.status-tag.is-published {
color: #16a34a;
}
.status-tag.is-published i {
background: #22c55e;
}
.status-tag.is-draft {
color: #9ca3af;
}
.status-tag.is-draft i {
background: #d1d5db;
}
.views-text {
display: inline-flex;
align-items: center;
gap: 4px;
color: #6b7280;
font-size: 12px;
}
.empty-read {
color: #d1d5db;
font-size: 12px;
}
.success-text {
color: #16a34a;
}
.warning-text {
color: #f59e0b;
}
.danger-text {
color: #f87171;
}
.table-pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
border-top: 1px solid #edf0f5;
}
.pagination-info {
color: #6b7280;
font-size: 14px;
}
@media (max-width: 1200px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.article-admin-page {
padding: 12px;
}
.stats-grid {
grid-template-columns: 1fr;
}
.table-pagination {
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
}
</style>