增加网站管理功能
This commit is contained in:
+75
-51
@@ -1,37 +1,42 @@
|
||||
// 文章管理相关API
|
||||
import request from "@/utils/request";
|
||||
|
||||
// 获取文章列表
|
||||
// ==================== 官网管理 - 文章 / 分类(平台端) ====================
|
||||
// 数据表:yz_platform_website_article / yz_platform_website_category
|
||||
// 建表 SQL:go/docs/sql/create_platform_website.sql
|
||||
|
||||
// ---------- 文章 ----------
|
||||
|
||||
// 文章列表(分页):keyword / cate / status / startDate / endDate / page / pageSize
|
||||
export function listArticles(params) {
|
||||
return request({
|
||||
url: `/platform/articlesList`,
|
||||
url: "/platform/website/article/list",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取文章所有文章
|
||||
// 全部文章(不分页,供选择/关联使用)
|
||||
export function listAllArticles(params) {
|
||||
return request({
|
||||
url: `/platform/allarticles`,
|
||||
url: "/platform/website/article/all",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取文章详情
|
||||
// 文章详情(含标签ID列表 tags)
|
||||
export function getArticle(id) {
|
||||
return request({
|
||||
url: `/platform/articles/${id}`,
|
||||
url: `/platform/website/article/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
// 创建文章
|
||||
// 新增文章
|
||||
export function createArticle(data) {
|
||||
return request({
|
||||
url: '/platform/createarticle',
|
||||
method: 'post',
|
||||
url: "/platform/website/article",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
@@ -39,8 +44,8 @@ export function createArticle(data) {
|
||||
// 编辑文章
|
||||
export function editArticle(id, data) {
|
||||
return request({
|
||||
url: `/platform/editarticle/${id}`,
|
||||
method: 'post',
|
||||
url: `/platform/website/article/${id}`,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
@@ -48,105 +53,124 @@ export function editArticle(id, data) {
|
||||
// 删除文章
|
||||
export function deleteArticle(id) {
|
||||
return request({
|
||||
url: `/platform/deletearticle/${id}`,
|
||||
url: `/platform/website/article/${id}`,
|
||||
method: "delete",
|
||||
});
|
||||
}
|
||||
|
||||
// 发布文章
|
||||
export function publishArticle(id,uid) {
|
||||
// 回收站列表(已软删除的文章):keyword / page / pageSize
|
||||
export function recycleArticles(params) {
|
||||
return request({
|
||||
url: `/platform/publisharticle/${id}`,
|
||||
method: 'post',
|
||||
data: {
|
||||
uid
|
||||
}
|
||||
url: "/platform/website/article/recycle",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 下架文章
|
||||
// 从回收站恢复文章
|
||||
export function restoreArticle(id) {
|
||||
return request({
|
||||
url: `/platform/website/article/${id}/restore`,
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
// 彻底删除文章(仅限回收站中的记录,不可恢复)
|
||||
export function purgeArticle(id) {
|
||||
return request({
|
||||
url: `/platform/website/article/${id}/purge`,
|
||||
method: "delete",
|
||||
});
|
||||
}
|
||||
|
||||
// 发布文章(uid 选填,用于记录发布人)
|
||||
export function publishArticle(id, uid) {
|
||||
return request({
|
||||
url: `/platform/website/article/${id}/publish`,
|
||||
method: "post",
|
||||
data: { uid },
|
||||
});
|
||||
}
|
||||
|
||||
// 下架文章(状态置为「已隐藏」)
|
||||
export function unPublishArticle(id) {
|
||||
return request({
|
||||
url: `/platform/unPublisharticle/${id}`,
|
||||
method: 'post'
|
||||
url: `/platform/website/article/${id}/unpublish`,
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
// 文章推荐
|
||||
// 文章推荐 / 取消推荐
|
||||
export function articleRecommend(id) {
|
||||
return request({
|
||||
url: `/platform/articleRecommend/${id}`,
|
||||
method: 'post'
|
||||
url: `/platform/website/article/${id}/recommend`,
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
// 取消文章推荐
|
||||
export function unArticleRecommend(id) {
|
||||
return request({
|
||||
url: `/platform/unArticleRecommend/${id}`,
|
||||
method: 'post'
|
||||
url: `/platform/website/article/${id}/unrecommend`,
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
// 文章置顶
|
||||
// 文章置顶 / 取消置顶
|
||||
export function articleTop(id) {
|
||||
return request({
|
||||
url: `/platform/articleTop/${id}`,
|
||||
method: 'post'
|
||||
url: `/platform/website/article/${id}/top`,
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
// 取消文章置顶
|
||||
export function unArticleTop(id) {
|
||||
return request({
|
||||
url: `/platform/unArticleTop/${id}`,
|
||||
method: 'post'
|
||||
url: `/platform/website/article/${id}/untop`,
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 分类 ----------
|
||||
|
||||
|
||||
////////////////////////////分类相关////////////////////////////
|
||||
|
||||
// 获取所有分类列表
|
||||
// 全量分类(前端自行组树,支持 keyword)
|
||||
export function allCategories(params) {
|
||||
return request({
|
||||
url: `/platform/allcategories`,
|
||||
url: "/platform/website/category/all",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取分类列表
|
||||
// 分类分页列表
|
||||
export function listCategories(params) {
|
||||
return request({
|
||||
url: `/platform/categories`,
|
||||
url: "/platform/website/category/list",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取分类详情
|
||||
// 分类详情
|
||||
export function getCategory(id) {
|
||||
return request({
|
||||
url: `/platform/categories/${id}`,
|
||||
url: `/platform/website/category/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
// 创建分类
|
||||
// 新增分类
|
||||
export function createCategory(data) {
|
||||
return request({
|
||||
url: `/platform/createCategory`,
|
||||
url: "/platform/website/category",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 更新分类
|
||||
// 编辑分类
|
||||
export function editCategory(id, data) {
|
||||
return request({
|
||||
url: `/platform/editCategory/${id}`,
|
||||
url: `/platform/website/category/${id}`,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
@@ -155,7 +179,7 @@ export function editCategory(id, data) {
|
||||
// 删除分类
|
||||
export function deleteCategory(id) {
|
||||
return request({
|
||||
url: `/platform/categories/${id}`,
|
||||
url: `/platform/website/category/${id}`,
|
||||
method: "delete",
|
||||
});
|
||||
}
|
||||
@@ -163,8 +187,8 @@ export function deleteCategory(id) {
|
||||
// 更新分类状态
|
||||
export function updateCategoryStatus(id, status) {
|
||||
return request({
|
||||
url: `/platform/categories/${id}/status`,
|
||||
url: `/platform/website/category/${id}/status`,
|
||||
method: "patch",
|
||||
data: { status },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
// ==================== 官网管理 - 评论管理(平台端) ====================
|
||||
// 数据表:yz_platform_website_comment
|
||||
// status:0 待审核,1 已通过,2 已拒绝
|
||||
|
||||
// 评论列表(分页):keyword / status / article_id / page / pageSize
|
||||
export function listComments(params) {
|
||||
return request({
|
||||
url: "/platform/website/comment/list",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 评论详情
|
||||
export function getComment(id) {
|
||||
return request({
|
||||
url: `/platform/website/comment/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
// 审核评论(status:1 通过 / 2 拒绝)
|
||||
export function auditComment(id, status) {
|
||||
return request({
|
||||
url: `/platform/website/comment/${id}/audit`,
|
||||
method: "post",
|
||||
data: { status },
|
||||
});
|
||||
}
|
||||
|
||||
// 回复评论
|
||||
export function replyComment(id, reply) {
|
||||
return request({
|
||||
url: `/platform/website/comment/${id}/reply`,
|
||||
method: "post",
|
||||
data: { reply },
|
||||
});
|
||||
}
|
||||
|
||||
// 删除评论
|
||||
export function deleteComment(id) {
|
||||
return request({
|
||||
url: `/platform/website/comment/${id}`,
|
||||
method: "delete",
|
||||
});
|
||||
}
|
||||
|
||||
// 批量删除评论
|
||||
export function batchDeleteComments(ids) {
|
||||
return request({
|
||||
url: "/platform/website/comment/batchdelete",
|
||||
method: "post",
|
||||
data: { ids },
|
||||
});
|
||||
}
|
||||
@@ -1,77 +1,81 @@
|
||||
import request from '@/utils/request'
|
||||
import request from "@/utils/request";
|
||||
|
||||
// ==================== 官网管理 - 友情链接(平台端) ====================
|
||||
// 数据表:yz_platform_website_link
|
||||
// 建表 SQL:go/docs/sql/create_platform_website.sql
|
||||
|
||||
/**
|
||||
* 获取友情链接列表
|
||||
* @param {Object} params - 查询参数
|
||||
* 获取友情链接列表(分页)
|
||||
* @param {Object} params page / pageSize / keyword / status
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function getFriendlinkList(params) {
|
||||
return request({
|
||||
url: '/platform/friendlinks',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
url: "/platform/website/link/list",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有友情链接(下拉选择用)
|
||||
* 获取所有启用中的友情链接
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function getAllFriendlinks() {
|
||||
return request({
|
||||
url: '/platform/friendlinks/all',
|
||||
method: 'get'
|
||||
})
|
||||
url: "/platform/website/link/all",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加友情链接
|
||||
* @param {Object} data - 链接数据
|
||||
* @param {Object} data 链接数据
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function addFriendlink(data) {
|
||||
return request({
|
||||
url: '/platform/friendlinks',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
url: "/platform/website/link",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新友情链接
|
||||
* @param {number} id - 链接ID
|
||||
* @param {Object} data - 链接数据
|
||||
* 更新友情链接(支持只传部分字段,如 {status:1})
|
||||
* @param {number} id 链接ID
|
||||
* @param {Object} data 链接数据
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function updateFriendlink(id, data) {
|
||||
return request({
|
||||
url: `/platform/friendlinks/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
url: `/platform/website/link/${id}`,
|
||||
method: "put",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除友情链接
|
||||
* @param {number} id - 链接ID
|
||||
* @param {number} id 链接ID
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function deleteFriendlink(id) {
|
||||
return request({
|
||||
url: `/platform/friendlinks/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
url: `/platform/website/link/${id}`,
|
||||
method: "delete",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除友情链接
|
||||
* @param {Array} ids - 链接ID数组
|
||||
* @param {Array} ids 链接ID数组
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function batchDeleteFriendlinks(ids) {
|
||||
return request({
|
||||
url: '/platform/friendlinks/batchdelete',
|
||||
method: 'post',
|
||||
data: { ids }
|
||||
})
|
||||
url: "/platform/website/link/batchdelete",
|
||||
method: "post",
|
||||
data: { ids },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
// ==================== 官网管理 - 导航菜单(平台端) ====================
|
||||
// 数据表:yz_platform_website_nav
|
||||
// 建表 SQL:go/docs/sql/create_platform_website.sql
|
||||
|
||||
/**
|
||||
* 获取所有前端导航
|
||||
* 获取全部导航菜单(平铺返回,前端按 pid 组树)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function getFrontMenus() {
|
||||
return request({
|
||||
url: "/platform/frontmenus",
|
||||
url: "/platform/website/nav/list",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建前端导航
|
||||
* @param {Object} frontMenuData 前端导航数据
|
||||
* 创建导航菜单
|
||||
* @param {Object} data 菜单数据
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function createFrontMenu(formData, options = {}) {
|
||||
export function createFrontMenu(data) {
|
||||
return request({
|
||||
url: "/platform/createfrontmenu",
|
||||
url: "/platform/website/nav",
|
||||
method: "post",
|
||||
data: formData,
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data"
|
||||
}
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑前端导航
|
||||
* @param {number|string} id 前端导航ID
|
||||
* @param {Object} frontMenuData 更新的数据
|
||||
* 编辑导航菜单
|
||||
* @param {number|string} id 菜单ID
|
||||
* @param {Object} data 更新的数据
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function editFrontMenu(id, frontMenuData) {
|
||||
export function editFrontMenu(id, data) {
|
||||
return request({
|
||||
url: `/platform/editfrontmenu/${id}`,
|
||||
url: `/platform/website/nav/${id}`,
|
||||
method: "post",
|
||||
data: frontMenuData,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除前端导航
|
||||
* @param {number|string} id 前端导航ID
|
||||
* 删除导航菜单
|
||||
* @param {number|string} id 菜单ID
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function deleteFrontMenu(id) {
|
||||
return request({
|
||||
url: `/platform/deletefrontmenu/${id}`,
|
||||
url: `/platform/website/nav/${id}`,
|
||||
method: "delete",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+28
-13
@@ -1,40 +1,56 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
// ==================== 官网管理 - 单页管理(平台端) ====================
|
||||
// 数据表:yz_platform_website_page
|
||||
// 建表 SQL:go/docs/sql/create_platform_website.sql
|
||||
|
||||
/**
|
||||
* 获取所有单页
|
||||
* 获取全部单页
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function getOnePages() {
|
||||
return request({
|
||||
url: "/platform/allonepages",
|
||||
url: "/platform/website/page/all",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 单页详情
|
||||
* @param {number|string} id 单页ID
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function getOnePage(id) {
|
||||
return request({
|
||||
url: `/platform/website/page/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单页
|
||||
* @param {Object} onePageData 单页数据
|
||||
* @param {Object} data 单页数据
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function createOnePage(formData) {
|
||||
export function createOnePage(data) {
|
||||
return request({
|
||||
url: "/platform/createonepage",
|
||||
url: "/platform/website/page",
|
||||
method: "post",
|
||||
data: formData,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑单页
|
||||
* @param {number|string} id 单页ID
|
||||
* @param {Object} onePageData 更新的数据
|
||||
* @param {Object} data 更新的数据
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function editOnePage(id, onePageData) {
|
||||
export function editOnePage(id, data) {
|
||||
return request({
|
||||
url: `/platform/editonepage/${id}`,
|
||||
url: `/platform/website/page/${id}`,
|
||||
method: "post",
|
||||
data: onePageData,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,13 +61,13 @@ export function editOnePage(id, onePageData) {
|
||||
*/
|
||||
export function deleteOnePage(id) {
|
||||
return request({
|
||||
url: `/platform/deleteonepage/${id}`,
|
||||
url: `/platform/website/page/${id}`,
|
||||
method: "delete",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据路径获取单页(前端使用)
|
||||
* 租户官网前台:根据路径获取单页(公开接口,非平台管理)
|
||||
* @param {string} path 路由路径
|
||||
* @returns {Promise}
|
||||
*/
|
||||
@@ -61,4 +77,3 @@ export function getOnePageByPath(path) {
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
// ==================== 官网管理 - 标签管理(平台端) ====================
|
||||
// 数据表:yz_platform_website_tag(+ 关联表 yz_platform_website_article_tag)
|
||||
// 建表 SQL:go/docs/sql/create_platform_website.sql
|
||||
|
||||
// 标签列表(分页):keyword / status / page / pageSize
|
||||
export function listTags(params) {
|
||||
return request({
|
||||
url: "/platform/website/tag/list",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 全部标签(不分页,供文章编辑选择)
|
||||
export function allTags(params) {
|
||||
return request({
|
||||
url: "/platform/website/tag/all",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 标签详情
|
||||
export function getTag(id) {
|
||||
return request({
|
||||
url: `/platform/website/tag/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
// 新增标签
|
||||
export function createTag(data) {
|
||||
return request({
|
||||
url: "/platform/website/tag",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 编辑标签
|
||||
export function editTag(id, data) {
|
||||
return request({
|
||||
url: `/platform/website/tag/${id}`,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除标签
|
||||
export function deleteTag(id) {
|
||||
return request({
|
||||
url: `/platform/website/tag/${id}`,
|
||||
method: "delete",
|
||||
});
|
||||
}
|
||||
|
||||
// 更新标签状态(1 启用 / 0 禁用)
|
||||
export function updateTagStatus(id, status) {
|
||||
return request({
|
||||
url: `/platform/website/tag/${id}/status`,
|
||||
method: "patch",
|
||||
data: { status },
|
||||
});
|
||||
}
|
||||
@@ -30,7 +30,7 @@ const staticMainChildren = [
|
||||
{
|
||||
path: "/website/setting",
|
||||
name: "PlatformWebsiteSetting",
|
||||
component: () => import("@/views/website/setting/index.vue"),
|
||||
component: () => import("@/views/website/site/setting/index.vue"),
|
||||
meta: { requiresAuth: true, title: "站点设置" }
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 官网管理上传辅助(platform/src/views/website 专用)
|
||||
*
|
||||
* 官网管理各页面(文章封面 / 分类图片 / Logo / 友情链接 Logo / 富文本正文图片)
|
||||
* 上传的文件统一归入「文件管理」的「官网素材」分组,便于在
|
||||
* /system/fileManager 中集中查找与复用。
|
||||
*
|
||||
* 分组按名称查找,不存在时补建 —— 与 platform/softwareupgrade 的
|
||||
* appsupgrade 分组使用同一套做法;该默认分组同时由
|
||||
* go/docs/sql/init_platform_website_file_cate.sql 预置。
|
||||
*/
|
||||
import { getUserCate, createFileCate, uploadFile } from "@/api/file";
|
||||
|
||||
export const WEBSITE_MATERIAL_CATE_NAME = "官网素材";
|
||||
|
||||
// 分组ID缓存(同一会话内只解析一次,并发调用复用同一个 Promise)
|
||||
let cateIdPromise = null;
|
||||
|
||||
/**
|
||||
* 取「官网素材」分组ID。
|
||||
* 查询/创建任一环节失败时返回 0(未分类),不阻断上传本身。
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export function ensureWebsiteMaterialCate() {
|
||||
if (!cateIdPromise) {
|
||||
cateIdPromise = (async () => {
|
||||
try {
|
||||
const res = await getUserCate();
|
||||
if (res?.code === 200 && Array.isArray(res.data)) {
|
||||
const found = res.data.find(
|
||||
(item) => String(item.name || "").trim() === WEBSITE_MATERIAL_CATE_NAME
|
||||
);
|
||||
if (found?.id) {
|
||||
return Number(found.id);
|
||||
}
|
||||
}
|
||||
|
||||
const created = await createFileCate({ name: WEBSITE_MATERIAL_CATE_NAME });
|
||||
if (created?.code === 200 && created.data?.id != null) {
|
||||
return Number(created.data.id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("解析「官网素材」文件分组失败:", e);
|
||||
}
|
||||
return 0;
|
||||
})();
|
||||
}
|
||||
return cateIdPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到「官网素材」分组
|
||||
* @param {File} file 待上传文件
|
||||
* @returns {Promise} 与 uploadFile 返回值一致(code 200 新上传 / 201 已存在)
|
||||
*/
|
||||
export async function uploadToWebsiteMaterial(file) {
|
||||
const cate = await ensureWebsiteMaterialCate();
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return uploadFile(formData, { cate });
|
||||
}
|
||||
@@ -18,6 +18,12 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// 上传文件归属的文件管理分组ID(0 表示未分类)。
|
||||
// 官网文章编辑传入「官网素材」分组ID,正文内插入的图片即归入该分组。
|
||||
cate: {
|
||||
type: [Number, String],
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
@@ -35,7 +41,7 @@ const options = {
|
||||
const onFileUpload = async (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const res = await uploadFile(formData)
|
||||
const res = await uploadFile(formData, { cate: props.cate })
|
||||
return {
|
||||
id: res.data.id,
|
||||
url: res.data.url,
|
||||
|
||||
@@ -50,10 +50,19 @@
|
||||
</div>
|
||||
<div class="group-actions" v-if="group.id !== 0">
|
||||
<div class="action-buttons" @click.stop>
|
||||
<el-button type="primary" link size="small" :icon="Edit" @click="handleRenameCategory(group)"
|
||||
title="重命名" />
|
||||
<el-button type="danger" link size="small" :icon="Delete" @click="deleteFileCateData(group.id)"
|
||||
title="删除" />
|
||||
<!-- 系统默认分组(如「官网素材」)由功能模块按名称定位,禁止改名/删除 -->
|
||||
<template v-if="isProtectedCategory(group)">
|
||||
<el-button type="primary" link size="small" :icon="Lock" disabled
|
||||
title="系统默认分组,不可重命名" />
|
||||
<el-button type="danger" link size="small" :icon="Lock" disabled
|
||||
title="系统默认分组,不可删除" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button type="primary" link size="small" :icon="Edit" @click="handleRenameCategory(group)"
|
||||
title="重命名" />
|
||||
<el-button type="danger" link size="small" :icon="Delete" @click="deleteFileCateData(group.id)"
|
||||
title="删除" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -302,6 +311,7 @@ import {
|
||||
ZoomOut,
|
||||
RefreshLeft,
|
||||
FullScreen,
|
||||
Lock,
|
||||
} from "@element-plus/icons-vue";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import {
|
||||
@@ -426,6 +436,21 @@ const uncategorizedGroup = ref<Group>({
|
||||
description: "未分类的文件",
|
||||
});
|
||||
|
||||
// 系统默认分组:由功能模块按「名称」定位(如官网管理统一上传到「官网素材」、
|
||||
// 软件升级安装包归入 appsupgrade),改名或删除都会让模块找不到分组,故禁止操作。
|
||||
// 需与后端 controllers/platform_file.go 的 protectedFileCateNames 保持一致。
|
||||
const PROTECTED_CATEGORY_NAMES = ["官网素材", "appsupgrade", "图片"];
|
||||
|
||||
/** 是否为受保护的系统默认分组 */
|
||||
const isProtectedCategory = (group: any) =>
|
||||
PROTECTED_CATEGORY_NAMES.includes(String(group?.name || "").trim());
|
||||
|
||||
/** 按分组ID判断是否受保护(删除入口的兜底校验) */
|
||||
const isProtectedCategoryId = (id: number) => {
|
||||
const target = groups.value.find((item) => item.id === id);
|
||||
return !!target && isProtectedCategory(target);
|
||||
};
|
||||
|
||||
// 过滤后的分组列表(未分类组置顶)
|
||||
const filteredGroups = computed(() => {
|
||||
let result = [uncategorizedGroup.value, ...groups.value];
|
||||
@@ -484,6 +509,10 @@ const handleRenameCategory = (group: any) => {
|
||||
ElMessage.error("分组信息不完整");
|
||||
return;
|
||||
}
|
||||
if (isProtectedCategory(group)) {
|
||||
ElMessage.warning("系统默认分组不可重命名");
|
||||
return;
|
||||
}
|
||||
currentRenameCategoryId.value = group.id;
|
||||
currentRenameCategoryName.value = group.name || "";
|
||||
showRenameCategoryDialog.value = true;
|
||||
@@ -519,6 +548,10 @@ const handleRenameCategoryClose = () => {
|
||||
|
||||
//删除文件分组
|
||||
const deleteFileCateData = async (id: number) => {
|
||||
if (isProtectedCategoryId(id)) {
|
||||
ElMessage.warning("系统默认分组不可删除");
|
||||
return;
|
||||
}
|
||||
ElMessageBox.confirm("确定删除该文件分组吗?", "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
<template>
|
||||
<div class="article-form" v-loading="loading">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="article-form__form"
|
||||
>
|
||||
<div class="form-layout">
|
||||
<!-- ==================== 左侧:基础信息 ==================== -->
|
||||
<div class="form-side">
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input
|
||||
v-model="form.title"
|
||||
placeholder="请输入文章标题"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="所属分类" prop="cate">
|
||||
<div class="cate-panel">
|
||||
<div class="cate-panel__head">
|
||||
<el-input
|
||||
v-model="cateFilter"
|
||||
size="small"
|
||||
placeholder="筛选分类"
|
||||
clearable
|
||||
:prefix-icon="Search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="cate-panel__tree">
|
||||
<el-tree
|
||||
ref="cateTreeRef"
|
||||
:data="categoryTree"
|
||||
:props="{ label: 'name', children: 'children' }"
|
||||
node-key="id"
|
||||
highlight-current
|
||||
:expand-on-click-node="false"
|
||||
default-expand-all
|
||||
:filter-node-method="filterCateNode"
|
||||
empty-text="暂无分类,请先到「分类管理」创建"
|
||||
@node-click="handleCateSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="cate-panel__foot">
|
||||
<span class="cate-panel__current">
|
||||
已选:{{ selectedCateName || '未选择' }}
|
||||
</span>
|
||||
<el-button
|
||||
v-if="form.cate"
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleCateClear"
|
||||
>
|
||||
清除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="14">
|
||||
<el-form-item label="作者" prop="author">
|
||||
<el-input v-model="form.author" placeholder="请输入作者" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="10">
|
||||
<el-form-item label="是否转载" prop="is_trans">
|
||||
<el-radio-group v-model="form.is_trans">
|
||||
<el-radio-button :value="0">否</el-radio-button>
|
||||
<el-radio-button :value="1">是</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item v-if="form.is_trans === 1" label="原文地址" prop="transurl">
|
||||
<el-input v-model="form.transurl" placeholder="请输入转载原文地址" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="标签" prop="tags">
|
||||
<el-select
|
||||
v-model="form.tags"
|
||||
multiple
|
||||
filterable
|
||||
clearable
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
placeholder="选择标签(可多选)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in tagOptions"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="isEdit" label="状态" prop="status">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio :value="0">草稿</el-radio>
|
||||
<el-radio :value="1">待审核</el-radio>
|
||||
<el-radio :value="2">已发布</el-radio>
|
||||
</el-radio-group>
|
||||
<div class="field-tip">选择「已发布」后会在官网前台展示</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="简介" prop="desc">
|
||||
<el-input
|
||||
v-model="form.desc"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="用于列表页摘要展示"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="封面图" prop="image">
|
||||
<el-upload
|
||||
class="cover-uploader"
|
||||
:show-file-list="false"
|
||||
:auto-upload="false"
|
||||
accept="image/*"
|
||||
:on-change="handleCoverChange"
|
||||
>
|
||||
<img v-if="form.image" :src="previewUrl(form.image)" class="cover-image" />
|
||||
<div v-else class="cover-placeholder">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>上传封面</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div class="field-tip">建议尺寸 750 × 420,支持 jpg / png,单张不超过 10MB</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 右侧:正文编辑 ==================== -->
|
||||
<div class="form-main">
|
||||
<el-form-item label="内容" prop="content" class="form-main__item">
|
||||
<div class="editor-container">
|
||||
<UmoEditor v-model="form.content" :cate="materialCateId" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<div class="form-footer">
|
||||
<el-button @click="emit('cancel')">取消</el-button>
|
||||
<el-button v-if="!isEdit" :loading="submitLoading" @click="handleSubmit(0)">
|
||||
存为草稿
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="submitLoading"
|
||||
@click="handleSubmit(isEdit ? form.status : 1)"
|
||||
>
|
||||
{{ isEdit ? '保存' : '提交' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, nextTick, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, Search } from '@element-plus/icons-vue'
|
||||
import { createArticle, editArticle, getArticle, allCategories } from '@/api/article'
|
||||
import { allTags } from '@/api/tag'
|
||||
import { uploadToWebsiteMaterial, ensureWebsiteMaterialCate } from '@/utils/websiteUpload'
|
||||
|
||||
const props = defineProps({
|
||||
isEdit: { type: Boolean, default: false },
|
||||
articleId: { type: Number, default: 0 }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['saved', 'cancel'])
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref(null)
|
||||
const coverFile = ref(null)
|
||||
const categoryOptions = ref([])
|
||||
const tagOptions = ref([])
|
||||
// 「官网素材」文件分组ID:富文本正文内插入的图片也归入该分组
|
||||
const materialCateId = ref(0)
|
||||
|
||||
// 分类树相关
|
||||
const cateTreeRef = ref(null)
|
||||
const cateFilter = ref('')
|
||||
const selectedCateName = ref('')
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
author: '',
|
||||
cate: '',
|
||||
tags: [],
|
||||
content: '',
|
||||
desc: '',
|
||||
image: '',
|
||||
is_trans: 0,
|
||||
transurl: null,
|
||||
status: 0
|
||||
})
|
||||
|
||||
const rules = {
|
||||
title: [{ required: true, message: '请输入文章标题', trigger: 'blur' }],
|
||||
content: [{ required: true, message: '请输入文章内容', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const previewUrl = (url) => {
|
||||
if (!url) return ''
|
||||
if (/^https?:\/\//i.test(url)) return url
|
||||
return API_BASE_URL + url
|
||||
}
|
||||
|
||||
// 平铺分类 -> 树(兼容 cid 父子字段)
|
||||
const categoryTree = computed(() => {
|
||||
const nodes = categoryOptions.value.map((item) => ({ ...item, children: [] }))
|
||||
const map = new Map(nodes.map((n) => [n.id, n]))
|
||||
const roots = []
|
||||
nodes.forEach((node) => {
|
||||
const parent = map.get(node.cid)
|
||||
if (parent && parent.id !== node.id) {
|
||||
parent.children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
})
|
||||
// 无子节点时移除空 children,避免出现空展开箭头
|
||||
const prune = (arr) =>
|
||||
arr.forEach((n) => {
|
||||
if (n.children.length === 0) {
|
||||
delete n.children
|
||||
} else {
|
||||
prune(n.children)
|
||||
}
|
||||
})
|
||||
prune(roots)
|
||||
return roots
|
||||
})
|
||||
|
||||
watch(cateFilter, (val) => {
|
||||
cateTreeRef.value?.filter(val)
|
||||
})
|
||||
|
||||
const filterCateNode = (value, data) => {
|
||||
if (!value) return true
|
||||
return String(data.name || '')
|
||||
.toLowerCase()
|
||||
.includes(String(value).toLowerCase())
|
||||
}
|
||||
|
||||
const handleCateSelect = (data) => {
|
||||
form.cate = data.id
|
||||
selectedCateName.value = data.name
|
||||
formRef.value?.clearValidate('cate')
|
||||
}
|
||||
|
||||
const handleCateClear = () => {
|
||||
form.cate = ''
|
||||
selectedCateName.value = ''
|
||||
cateTreeRef.value?.setCurrentKey(null)
|
||||
}
|
||||
|
||||
// 分类数据/文章详情到达后,把树上的选中态同步过来
|
||||
const syncCateSelection = async () => {
|
||||
await nextTick()
|
||||
if (form.cate) {
|
||||
const matched = categoryOptions.value.find((c) => c.id === form.cate)
|
||||
selectedCateName.value = matched?.name || ''
|
||||
cateTreeRef.value?.setCurrentKey(form.cate)
|
||||
} else {
|
||||
selectedCateName.value = ''
|
||||
cateTreeRef.value?.setCurrentKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑模式:拉取详情回显
|
||||
const loadDetail = async (id) => {
|
||||
if (!id) {
|
||||
ElMessage.error('缺少文章ID,无法编辑')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getArticle(id)
|
||||
if (res.code === 200 && res.data) {
|
||||
const m = res.data
|
||||
Object.assign(form, {
|
||||
title: m.title || '',
|
||||
author: m.author || '',
|
||||
cate: m.cate_id ?? m.cate ?? '',
|
||||
tags: Array.isArray(m.tags) ? [...m.tags] : [],
|
||||
content: m.content || '',
|
||||
desc: m.desc || '',
|
||||
image: m.image || '',
|
||||
is_trans: m.is_trans || 0,
|
||||
transurl: m.transurl || null,
|
||||
status: m.status ?? 0
|
||||
})
|
||||
await syncCateSelection()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取文章详情失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('获取文章详情失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchOptions = async () => {
|
||||
try {
|
||||
const res = await allCategories()
|
||||
if (res.code === 200) {
|
||||
const data = res.data
|
||||
categoryOptions.value = Array.isArray(data) ? data : data?.list || []
|
||||
await syncCateSelection()
|
||||
}
|
||||
} catch (e) {
|
||||
// 分类加载失败不阻断表单
|
||||
}
|
||||
try {
|
||||
const res = await allTags()
|
||||
if (res.code === 200) {
|
||||
const list = Array.isArray(res.data) ? res.data : res.data?.list || []
|
||||
tagOptions.value = list.filter((item) => item.status === 1)
|
||||
}
|
||||
} catch (e) {
|
||||
// 标签加载失败不阻断表单
|
||||
}
|
||||
}
|
||||
|
||||
const handleCoverChange = (file) => {
|
||||
const raw = file.raw
|
||||
if (!raw) return
|
||||
if (!raw.type.startsWith('image/')) {
|
||||
ElMessage.error('封面仅支持图片格式')
|
||||
return
|
||||
}
|
||||
if (raw.size / 1024 / 1024 >= 10) {
|
||||
ElMessage.error('封面图片不能超过 10MB')
|
||||
return
|
||||
}
|
||||
coverFile.value = raw
|
||||
form.image = URL.createObjectURL(raw)
|
||||
}
|
||||
|
||||
const handleSubmit = async (status) => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
let imageUrl = form.image
|
||||
// 本地预览地址(blob)说明用户新选了文件,先上传
|
||||
if (coverFile.value) {
|
||||
const uploadRes = await uploadToWebsiteMaterial(coverFile.value)
|
||||
if ((uploadRes.code === 200 || uploadRes.code === 201) && uploadRes.data?.url) {
|
||||
imageUrl = uploadRes.data.url.replace(/\\/g, '/')
|
||||
} else {
|
||||
ElMessage.error(uploadRes.msg || '封面上传失败')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
title: form.title,
|
||||
author: form.author,
|
||||
cate: form.cate,
|
||||
tags: form.tags,
|
||||
content: form.content,
|
||||
desc: form.desc,
|
||||
image: imageUrl,
|
||||
is_trans: form.is_trans,
|
||||
transurl: form.is_trans === 1 ? form.transurl : null,
|
||||
status
|
||||
}
|
||||
|
||||
const res = props.isEdit
|
||||
? await editArticle(props.articleId, payload)
|
||||
: await createArticle(payload)
|
||||
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(
|
||||
!props.isEdit && status === 0 ? '已存为草稿' : props.isEdit ? '保存成功' : '发布成功'
|
||||
)
|
||||
emit('saved')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '保存失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.articleId,
|
||||
(id) => {
|
||||
if (props.isEdit) loadDetail(id)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
fetchOptions()
|
||||
// 提前解析「官网素材」分组,供正文内插入图片时使用
|
||||
ensureWebsiteMaterialCate().then((id) => {
|
||||
materialCateId.value = id
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.article-form {
|
||||
&__form {
|
||||
max-width: 1600px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 左右两栏 ---------- */
|
||||
.form-layout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-side {
|
||||
flex: 0 0 400px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
&__item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 分类树 ---------- */
|
||||
.cate-panel {
|
||||
width: 100%;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
&__head {
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&__tree {
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
padding: 4px 0;
|
||||
|
||||
:deep(.el-tree) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:deep(.el-tree-node__content) {
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
&__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&__current {
|
||||
font-size: 12.5px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 正文编辑器 ---------- */
|
||||
.editor-container {
|
||||
width: 100%;
|
||||
height: calc(100vh - 300px);
|
||||
min-height: 480px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---------- 封面 ---------- */
|
||||
.cover-uploader {
|
||||
:deep(.el-upload) {
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 180px;
|
||||
height: 105px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
width: 180px;
|
||||
height: 105px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
|
||||
.el-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.field-tip {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
/* ---------- 底部操作栏:吸附在内容区底部 ----------
|
||||
滚动容器是 Main.vue 的 .right-main(el-main,overflow-y: auto),
|
||||
用 sticky 而不是 fixed:侧边栏折叠/窗口缩放时无需重算左右偏移。 */
|
||||
.form-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* ---------- 窄屏改为上下排列 ---------- */
|
||||
@media (max-width: 1200px) {
|
||||
.form-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-side {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-main {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
height: 560px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,621 @@
|
||||
<template>
|
||||
<div class="article-list">
|
||||
<div class="header-bar">
|
||||
<h2>{{ pageTitle }}</h2>
|
||||
<div class="header-actions">
|
||||
<el-button v-if="mode !== 'recycle'" type="primary" @click="goCreate">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增文章
|
||||
</el-button>
|
||||
<el-button @click="fetchList" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert type="info" :closable="false" class="tips">
|
||||
<template #title>
|
||||
<div>{{ pageTips }}</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<!-- 搜索 / 筛选 -->
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchForm.keyword"
|
||||
placeholder="搜索文章标题"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
|
||||
<el-select
|
||||
v-if="mode === 'all'"
|
||||
v-model="searchForm.cate"
|
||||
placeholder="全部分类"
|
||||
clearable
|
||||
style="width: 160px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in categoryOptions"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-select
|
||||
v-if="mode === 'all'"
|
||||
v-model="searchForm.status"
|
||||
placeholder="全部状态"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="(item, value) in statusMap"
|
||||
:key="value"
|
||||
:label="item.label"
|
||||
:value="Number(value)"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-date-picker
|
||||
v-if="mode !== 'recycle'"
|
||||
v-model="searchForm.dateRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
style="width: 260px"
|
||||
@change="handleSearch"
|
||||
/>
|
||||
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<el-table :data="list" v-loading="loading" border stripe style="width: 100%">
|
||||
<el-table-column prop="title" label="标题" min-width="300" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<div class="title-cell">
|
||||
<el-tag v-if="row.top === 1" type="danger" size="small" effect="dark">置顶</el-tag>
|
||||
<el-tag v-if="row.recommend === 1" type="warning" size="small" effect="dark">推荐</el-tag>
|
||||
<el-link
|
||||
v-if="mode !== 'recycle'"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="handleView(row)"
|
||||
>
|
||||
{{ row.title }}
|
||||
</el-link>
|
||||
<span v-else>{{ row.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="cate" label="分类" width="130" show-overflow-tooltip />
|
||||
<el-table-column prop="author" label="作者" width="110" show-overflow-tooltip />
|
||||
|
||||
<el-table-column v-if="mode !== 'recycle'" label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusMap[row.status]?.type || 'info'" size="small">
|
||||
{{ statusMap[row.status]?.label || '未知' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<template v-if="mode === 'all'">
|
||||
<el-table-column prop="views" label="浏览" width="80" align="center" />
|
||||
<el-table-column prop="likes" label="点赞" width="80" align="center" />
|
||||
</template>
|
||||
|
||||
<el-table-column
|
||||
v-if="mode !== 'recycle'"
|
||||
label="发布时间"
|
||||
width="170"
|
||||
align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.publish_time || row.create_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
:label="mode === 'recycle' ? '删除时间' : '更新时间'"
|
||||
width="170"
|
||||
align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(mode === 'recycle' ? row.delete_time : row.update_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" :width="actionWidth" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<!-- 回收站:恢复 / 彻底删除 -->
|
||||
<template v-if="mode === 'recycle'">
|
||||
<el-button size="small" text type="primary" @click="handleRestore(row)">
|
||||
<el-icon><RefreshLeft /></el-icon>
|
||||
恢复
|
||||
</el-button>
|
||||
<el-button size="small" text type="danger" @click="handlePurge(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
彻底删除
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 正常列表 / 草稿箱 -->
|
||||
<template v-else>
|
||||
<el-button
|
||||
v-if="mode === 'all' && row.status !== 2"
|
||||
size="small"
|
||||
text
|
||||
type="success"
|
||||
@click="handlePublish(row)"
|
||||
>
|
||||
发布
|
||||
</el-button>
|
||||
|
||||
<el-button size="small" text type="primary" @click="handleEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
|
||||
<template v-if="mode === 'all'">
|
||||
<el-button size="small" text type="warning" @click="toggleRecommend(row)">
|
||||
{{ row.recommend === 1 ? '取消推荐' : '推荐' }}
|
||||
</el-button>
|
||||
|
||||
<el-button size="small" text type="warning" @click="toggleTop(row)">
|
||||
{{ row.top === 1 ? '取消置顶' : '置顶' }}
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
v-if="row.status === 2"
|
||||
size="small"
|
||||
text
|
||||
type="info"
|
||||
@click="handleUnPublish(row)"
|
||||
>
|
||||
下架
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<el-button
|
||||
v-if="mode === 'draft'"
|
||||
size="small"
|
||||
text
|
||||
type="success"
|
||||
@click="handlePublish(row)"
|
||||
>
|
||||
发布
|
||||
</el-button>
|
||||
|
||||
<el-button size="small" text type="danger" @click="handleDelete(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<template #empty>
|
||||
<el-empty :description="emptyText" :image-size="80" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-container">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ArticlePreview v-model="previewVisible" :model="currentRow" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, RefreshLeft, Search, Delete } from '@element-plus/icons-vue'
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
listArticles,
|
||||
recycleArticles,
|
||||
getArticle,
|
||||
deleteArticle,
|
||||
restoreArticle,
|
||||
purgeArticle,
|
||||
publishArticle,
|
||||
unPublishArticle,
|
||||
articleRecommend,
|
||||
articleTop,
|
||||
unArticleRecommend,
|
||||
unArticleTop,
|
||||
allCategories
|
||||
} from '@/api/article'
|
||||
import ArticlePreview from './preview.vue'
|
||||
|
||||
// mode:all 文章列表 / draft 草稿箱 / recycle 回收站
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'all',
|
||||
validator: (v) => ['all', 'draft', 'recycle'].includes(v)
|
||||
}
|
||||
})
|
||||
|
||||
// 状态字典:0 草稿、1 待审核、2 已发布、3 已隐藏
|
||||
const statusMap = {
|
||||
0: { label: '草稿', type: 'info' },
|
||||
1: { label: '待审核', type: 'warning' },
|
||||
2: { label: '已发布', type: 'success' },
|
||||
3: { label: '已隐藏', type: 'danger' }
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
if (props.mode === 'draft') return '草稿箱'
|
||||
if (props.mode === 'recycle') return '回收站'
|
||||
return '文章列表'
|
||||
})
|
||||
|
||||
const pageTips = computed(() => {
|
||||
if (props.mode === 'draft') {
|
||||
return '草稿状态的文章不会在官网前台展示,完善内容后点击「发布」即可上线。'
|
||||
}
|
||||
if (props.mode === 'recycle') {
|
||||
return '被删除的文章会保留在此,「恢复」后回到文章列表;「彻底删除」不可恢复,请谨慎操作。'
|
||||
}
|
||||
return '官网文章内容在此统一维护。文章状态:草稿 → 待审核 → 已发布,仅已发布 的文章会在企业官网前台展示。'
|
||||
})
|
||||
|
||||
const emptyText = computed(() => {
|
||||
if (props.mode === 'draft') return '暂无草稿'
|
||||
if (props.mode === 'recycle') return '回收站是空的'
|
||||
return '暂无文章'
|
||||
})
|
||||
|
||||
const actionWidth = computed(() => (props.mode === 'all' ? 300 : props.mode === 'draft' ? 200 : 200))
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const categoryOptions = ref([])
|
||||
|
||||
const searchForm = reactive({
|
||||
keyword: '',
|
||||
cate: '',
|
||||
status: '',
|
||||
dateRange: null
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const previewVisible = ref(false)
|
||||
const currentRow = ref(null)
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
if (props.mode === 'recycle') {
|
||||
const res = await recycleArticles({
|
||||
keyword: searchForm.keyword,
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize
|
||||
})
|
||||
if (res.code === 200) {
|
||||
list.value = res.data?.list || []
|
||||
pagination.total = res.data?.total || 0
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取回收站失败')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const res = await listArticles({
|
||||
keyword: searchForm.keyword,
|
||||
cate: searchForm.cate,
|
||||
// 草稿箱固定只看草稿
|
||||
status: props.mode === 'draft' ? 0 : searchForm.status === '' ? '' : searchForm.status,
|
||||
startDate: searchForm.dateRange?.[0] || '',
|
||||
endDate: searchForm.dateRange?.[1] || '',
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize
|
||||
})
|
||||
if (res.code === 200) {
|
||||
list.value = res.data?.list || []
|
||||
pagination.total = res.data?.total || 0
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取文章列表失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('获取列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const res = await allCategories()
|
||||
if (res.code === 200) {
|
||||
const data = res.data
|
||||
categoryOptions.value = Array.isArray(data) ? data : data?.list || []
|
||||
}
|
||||
} catch (e) {
|
||||
// 分类获取失败不影响列表展示
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const resetSearch = () => {
|
||||
searchForm.keyword = ''
|
||||
searchForm.cate = ''
|
||||
searchForm.status = ''
|
||||
searchForm.dateRange = null
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleSizeChange = (val) => {
|
||||
pagination.pageSize = val
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handlePageChange = (val) => {
|
||||
pagination.page = val
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const goCreate = () => {
|
||||
router.push('/website/content/article/create')
|
||||
}
|
||||
|
||||
const handleEdit = (row) => {
|
||||
router.push({ path: '/website/content/article/edit', query: { id: row.id } })
|
||||
}
|
||||
|
||||
const handleView = async (row) => {
|
||||
currentRow.value = { ...row }
|
||||
previewVisible.value = true
|
||||
try {
|
||||
const res = await getArticle(row.id)
|
||||
if (res.code === 200 && res.data) {
|
||||
currentRow.value = { ...row, ...res.data, _raw: res.data }
|
||||
}
|
||||
} catch (e) {
|
||||
// 详情失败时用列表行数据兜底
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除文章「${row.title}」吗?删除后可在回收站找回。`,
|
||||
'删除确认',
|
||||
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await deleteArticle(row.id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('已移入回收站')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '删除失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = async (row) => {
|
||||
try {
|
||||
const res = await restoreArticle(row.id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('恢复成功')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '恢复失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('恢复失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePurge = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要彻底删除「${row.title}」吗?该操作不可恢复。`,
|
||||
'彻底删除确认',
|
||||
{ confirmButtonText: '彻底删除', cancelButtonText: '取消', type: 'error' }
|
||||
)
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await purgeArticle(row.id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('已彻底删除')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '删除失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePublish = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认发布该文章吗?发布后将在官网前台展示。', '确认发布', {
|
||||
confirmButtonText: '确认发布',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await publishArticle(row.id, authStore.user?.id || undefined)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('发布成功')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '发布失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('发布失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnPublish = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认下架该文章吗?下架后官网前台将不再展示。', '确认下架', {
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await unPublishArticle(row.id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('下架成功')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '下架失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('下架失败')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRecommend = async (row) => {
|
||||
const next = row.recommend === 1 ? 0 : 1
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
next === 1 ? '确认推荐该文章吗?' : '确认取消推荐该文章吗?',
|
||||
'提示',
|
||||
{ confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = next === 1 ? await articleRecommend(row.id) : await unArticleRecommend(row.id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(next === 1 ? '推荐成功' : '已取消推荐')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '操作失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleTop = async (row) => {
|
||||
const next = row.top === 1 ? 0 : 1
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
next === 1 ? '确认置顶该文章吗?' : '确认取消置顶该文章吗?',
|
||||
'提示',
|
||||
{ confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = next === 1 ? await articleTop(row.id) : await unArticleTop(row.id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(next === 1 ? '置顶成功' : '已取消置顶')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '操作失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
if (props.mode === 'all') {
|
||||
fetchCategories()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.title-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<el-drawer v-model="visible" title="文章预览" size="60%">
|
||||
<div class="preview" v-loading="!model">
|
||||
<h1 class="preview__title">{{ model?.title || '-' }}</h1>
|
||||
|
||||
<div class="preview__meta">
|
||||
<span v-if="model?.cate">分类:{{ model.cate }}</span>
|
||||
<span v-if="model?.author">作者:{{ model.author }}</span>
|
||||
<span>发布时间:{{ formatDateTime(model?.publish_time || model?.publish_date) }}</span>
|
||||
<span v-if="model?.views">浏览:{{ model.views }}</span>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<p v-if="model?.desc" class="preview__desc">{{ model.desc }}</p>
|
||||
|
||||
<img v-if="model?.image" :src="previewUrl(model.image)" class="preview__cover" alt="封面" />
|
||||
|
||||
<div v-if="model?.is_trans === 1 && model?.transurl" class="preview__trans">
|
||||
本文为转载内容,原文地址:
|
||||
<el-link type="primary" :href="model.transurl" target="_blank">{{ model.transurl }}</el-link>
|
||||
</div>
|
||||
|
||||
<div class="preview__content" v-html="contentHtml"></div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
model: { type: Object, default: null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''
|
||||
const visible = ref(props.modelValue)
|
||||
|
||||
// 无正文时给出占位提示,避免抽屉内容区空白
|
||||
const contentHtml = computed(
|
||||
() => props.model?.content || '<p style="color:#999">暂无正文</p>'
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
visible.value = val
|
||||
}
|
||||
)
|
||||
|
||||
watch(visible, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const previewUrl = (url) => {
|
||||
if (!url) return ''
|
||||
if (/^https?:\/\//i.test(url)) return url
|
||||
return API_BASE_URL + url
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.preview {
|
||||
padding: 0 8px 40px;
|
||||
|
||||
&__title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
&__desc {
|
||||
margin: 0 0 16px;
|
||||
padding: 12px 14px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
&__cover {
|
||||
width: 100%;
|
||||
max-height: 260px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__trans {
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&__content {
|
||||
font-size: 15px;
|
||||
line-height: 1.9;
|
||||
color: var(--el-text-color-primary);
|
||||
word-break: break-word;
|
||||
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:deep(pre) {
|
||||
padding: 12px 16px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
:deep(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid var(--el-border-color);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>发布文章</h2>
|
||||
<el-button @click="goList">
|
||||
<el-icon><Back /></el-icon>
|
||||
返回文章列表
|
||||
</el-button>
|
||||
</div>
|
||||
<!--
|
||||
<el-alert type="info" :closable="false" class="tips">
|
||||
<template #title>
|
||||
<div>
|
||||
点击「提交」后文章进入<b>待审核</b>状态;点击「存为草稿」保留为草稿,可稍后在「草稿箱」继续编辑。
|
||||
文章需在文章列表点击「发布」后才会在官网前台展示。
|
||||
</div>
|
||||
</template>
|
||||
</el-alert> -->
|
||||
|
||||
<ArticleForm :is-edit="false" @saved="goList" @cancel="goList" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Back } from '@element-plus/icons-vue'
|
||||
import ArticleForm from './components/ArticleForm.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goList = () => {
|
||||
router.push('/website/content/article/list')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.container-box {
|
||||
padding: 16px;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<ArticleList mode="draft" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ArticleList from './components/ArticleList.vue'
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.container-box {
|
||||
padding: 16px;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>编辑文章</h2>
|
||||
<el-button @click="goList">
|
||||
<el-icon><Back /></el-icon>
|
||||
返回文章列表
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-empty
|
||||
v-if="!articleId"
|
||||
description="缺少文章ID,请从「文章列表」或「草稿箱」点击「编辑」进入"
|
||||
>
|
||||
<el-button type="primary" @click="goList">返回文章列表</el-button>
|
||||
</el-empty>
|
||||
|
||||
<ArticleForm
|
||||
v-else
|
||||
:is-edit="true"
|
||||
:article-id="articleId"
|
||||
@saved="goList"
|
||||
@cancel="goList"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Back } from '@element-plus/icons-vue'
|
||||
import ArticleForm from './components/ArticleForm.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const articleId = computed(() => Number(route.query.id) || 0)
|
||||
|
||||
const goList = () => {
|
||||
router.push('/website/content/article/list')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.container-box {
|
||||
padding: 16px;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<ArticleList mode="all" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ArticleList from './components/ArticleList.vue'
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.container-box {
|
||||
padding: 16px;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<ArticleList mode="all" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ArticleList from './components/ArticleList.vue'
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.container-box {
|
||||
padding: 16px;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<ArticleList mode="recycle" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ArticleList from './components/ArticleList.vue'
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.container-box {
|
||||
padding: 16px;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="isEdit ? '编辑分类' : '新增分类'"
|
||||
width="620px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="分类名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入分类名称" maxlength="50" show-word-limit />
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="14">
|
||||
<el-form-item label="父级分类" prop="cid">
|
||||
<el-tree-select
|
||||
v-model="form.cid"
|
||||
:data="treeData"
|
||||
:props="{ label: 'name', value: 'id', children: 'children' }"
|
||||
placeholder="不选则为顶级分类"
|
||||
clearable
|
||||
check-strictly
|
||||
:render-after-expand="false"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="10">
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="分类图片" prop="image">
|
||||
<el-upload
|
||||
class="image-uploader"
|
||||
:show-file-list="false"
|
||||
:auto-upload="false"
|
||||
accept="image/*"
|
||||
:on-change="handleImageChange"
|
||||
>
|
||||
<img v-if="form.image" :src="previewUrl(form.image)" class="image-preview" />
|
||||
<div v-else class="upload-placeholder">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>上传图片</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div class="upload-tip">建议尺寸 250 × 140,支持 jpg / png,单张不超过 10MB</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="分类描述" prop="desc">
|
||||
<el-input
|
||||
v-model="form.desc"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="请输入分类描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio :value="1">启用</el-radio>
|
||||
<el-radio :value="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
|
||||
{{ isEdit ? '保存' : '创建' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { createCategory, editCategory } from '@/api/article'
|
||||
import { uploadToWebsiteMaterial } from '@/utils/websiteUpload'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
category: { type: Object, default: null },
|
||||
treeData: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'saved'])
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''
|
||||
const visible = ref(props.modelValue)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref(null)
|
||||
const imageFile = ref(null)
|
||||
|
||||
const form = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
cid: 0,
|
||||
image: '',
|
||||
desc: '',
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入分类名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const isEdit = computed(() => !!form.id)
|
||||
|
||||
const previewUrl = (url) => {
|
||||
if (!url) return ''
|
||||
if (/^https?:\/\//i.test(url)) return url
|
||||
return API_BASE_URL + url
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
visible.value = val
|
||||
if (!val) return
|
||||
nextTick(() => {
|
||||
imageFile.value = null
|
||||
formRef.value?.clearValidate()
|
||||
const c = props.category
|
||||
if (c && c.id) {
|
||||
Object.assign(form, {
|
||||
id: c.id,
|
||||
name: c.name || '',
|
||||
cid: c.cid ?? 0,
|
||||
image: c.image || '',
|
||||
desc: c.desc || '',
|
||||
sort: c.sort ?? 0,
|
||||
status: c.status ?? 1
|
||||
})
|
||||
} else if (c && c.cid) {
|
||||
// 新增子分类:仅带上父级,其余重置
|
||||
Object.assign(form, {
|
||||
id: null,
|
||||
name: '',
|
||||
cid: c.cid,
|
||||
image: '',
|
||||
desc: '',
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
} else {
|
||||
handleClosed()
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
watch(visible, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const handleImageChange = (file) => {
|
||||
const raw = file.raw
|
||||
if (!raw) return
|
||||
if (!raw.type.startsWith('image/')) {
|
||||
ElMessage.error('仅支持图片格式')
|
||||
return
|
||||
}
|
||||
if (raw.size / 1024 / 1024 >= 10) {
|
||||
ElMessage.error('图片不能超过 10MB')
|
||||
return
|
||||
}
|
||||
imageFile.value = raw
|
||||
form.image = URL.createObjectURL(raw)
|
||||
}
|
||||
|
||||
const handleClosed = () => {
|
||||
imageFile.value = null
|
||||
formRef.value?.clearValidate()
|
||||
Object.assign(form, {
|
||||
id: null,
|
||||
name: '',
|
||||
cid: 0,
|
||||
image: '',
|
||||
desc: '',
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
let imageUrl = form.image
|
||||
if (imageFile.value) {
|
||||
const uploadRes = await uploadToWebsiteMaterial(imageFile.value)
|
||||
if ((uploadRes.code === 200 || uploadRes.code === 201) && uploadRes.data?.url) {
|
||||
imageUrl = uploadRes.data.url.replace(/\\/g, '/')
|
||||
} else {
|
||||
ElMessage.error(uploadRes.msg || '图片上传失败')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: form.name,
|
||||
image: imageUrl,
|
||||
desc: form.desc,
|
||||
sort: form.sort,
|
||||
status: form.status,
|
||||
cid: form.cid || 0
|
||||
}
|
||||
|
||||
const res = isEdit.value
|
||||
? await editCategory(form.id, payload)
|
||||
: await createCategory(payload)
|
||||
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(isEdit.value ? '保存成功' : '创建成功')
|
||||
visible.value = false
|
||||
emit('saved')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '操作失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.image-uploader {
|
||||
:deep(.el-upload) {
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 140px;
|
||||
height: 90px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
width: 140px;
|
||||
height: 90px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
|
||||
.el-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-tip {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<div class="container-box category-manage">
|
||||
<div class="header-bar">
|
||||
<h2>分类管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button @click="toggleExpandAll">
|
||||
<el-icon><Sort /></el-icon>
|
||||
{{ expandAll ? '全部折叠' : '全部展开' }}
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增分类
|
||||
</el-button>
|
||||
<el-button @click="fetchCategories" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert type="info" :closable="false" class="tips">
|
||||
<template #title>
|
||||
<div>
|
||||
分类用于组织官网文章(对应模板标签 <code>{yz:category}</code> 等)。
|
||||
支持多级父子结构,分类图片用于列表页/栏目页展示。
|
||||
</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchForm.keyword"
|
||||
placeholder="搜索分类名称"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@keyup.enter="fetchCategories"
|
||||
@clear="fetchCategories"
|
||||
/>
|
||||
<el-button type="primary" @click="fetchCategories">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-if="tableVisible"
|
||||
:data="categoryTree"
|
||||
v-loading="loading"
|
||||
row-key="id"
|
||||
border
|
||||
default-expand-all
|
||||
:tree-props="{ children: 'children' }"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column prop="name" label="分类名称" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="分类图片" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.image"
|
||||
:src="previewUrl(row.image)"
|
||||
:preview-src-list="[previewUrl(row.image)]"
|
||||
preview-teleported
|
||||
fit="cover"
|
||||
style="width: 60px; height: 40px; border-radius: 4px"
|
||||
/>
|
||||
<span v-else class="muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="desc" label="描述" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.desc || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort" label="排序" width="90" align="center" />
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" text @click="handleAddChild(row)">
|
||||
<el-icon><CirclePlus /></el-icon>
|
||||
子分类
|
||||
</el-button>
|
||||
<el-button size="small" text type="primary" @click="handleEdit(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button size="small" text type="danger" @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<template #empty>
|
||||
<el-empty description="暂无分类" :image-size="80" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<CategoryEdit
|
||||
v-model="dialogVisible"
|
||||
:category="currentCategory"
|
||||
:tree-data="treeOptions"
|
||||
@saved="fetchCategories"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, Edit, Delete, CirclePlus, Sort } from '@element-plus/icons-vue'
|
||||
import { allCategories, deleteCategory } from '@/api/article'
|
||||
import CategoryEdit from './components/edit.vue'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''
|
||||
|
||||
const loading = ref(false)
|
||||
const categories = ref([])
|
||||
const searchForm = reactive({ keyword: '' })
|
||||
const dialogVisible = ref(false)
|
||||
const currentCategory = ref(null)
|
||||
const expandAll = ref(true)
|
||||
// 通过 v-if 重建表格以应用「全部展开/折叠」
|
||||
const tableVisible = ref(true)
|
||||
|
||||
const previewUrl = (url) => {
|
||||
if (!url) return ''
|
||||
if (/^https?:\/\//i.test(url)) return url
|
||||
return API_BASE_URL + url
|
||||
}
|
||||
|
||||
// 平铺列表 -> 树(兼容 cid 父子字段)
|
||||
const buildTree = (list) => {
|
||||
const nodes = list.map((item) => ({
|
||||
...item,
|
||||
children: []
|
||||
}))
|
||||
const map = new Map(nodes.map((n) => [n.id, n]))
|
||||
const roots = []
|
||||
nodes.forEach((node) => {
|
||||
const parent = map.get(node.cid)
|
||||
if (parent && parent.id !== node.id) {
|
||||
parent.children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
})
|
||||
// 无子节点时移除空 children,避免树表出现空展开箭头
|
||||
const prune = (arr) =>
|
||||
arr.forEach((n) => {
|
||||
if (n.children.length === 0) {
|
||||
delete n.children
|
||||
} else {
|
||||
prune(n.children)
|
||||
}
|
||||
})
|
||||
prune(roots)
|
||||
return roots
|
||||
}
|
||||
|
||||
const categoryTree = computed(() => buildTree(categories.value))
|
||||
const treeOptions = computed(() => [
|
||||
{ id: 0, name: '顶级分类', children: categoryTree.value }
|
||||
])
|
||||
|
||||
const fetchCategories = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await allCategories({ keyword: searchForm.keyword })
|
||||
if (res.code === 200) {
|
||||
categories.value = Array.isArray(res.data) ? res.data : res.data?.list || []
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取分类失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('获取分类失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetSearch = () => {
|
||||
searchForm.keyword = ''
|
||||
fetchCategories()
|
||||
}
|
||||
|
||||
const toggleExpandAll = async () => {
|
||||
expandAll.value = !expandAll.value
|
||||
// 重建表格:default-expand-all 是初始化属性,需要重挂载才会生效
|
||||
tableVisible.value = false
|
||||
await nextTick()
|
||||
tableVisible.value = true
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
currentCategory.value = null
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleAddChild = (row) => {
|
||||
currentCategory.value = { cid: row.id }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row) => {
|
||||
currentCategory.value = { ...row }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (row) => {
|
||||
if (row.children && row.children.length > 0) {
|
||||
ElMessage.warning('该分类下存在子分类,请先删除子分类')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除分类「${row.name}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await deleteCategory(row.id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchCategories()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '删除失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCategories()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.container-box {
|
||||
padding: 16px;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,417 @@
|
||||
<template>
|
||||
<div class="container-box comment-manage">
|
||||
<div class="header-bar">
|
||||
<h2>评论管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
:disabled="selectedIds.length === 0"
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
批量删除
|
||||
</el-button>
|
||||
<el-button @click="fetchList" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert type="info" :closable="false" class="tips">
|
||||
<template #title>
|
||||
<div>
|
||||
官网前台访客留言统一在此审核。<b>待审核</b> 评论不会在官网展示,通过后才会显示。
|
||||
</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchForm.keyword"
|
||||
placeholder="搜索评论内容 / 昵称"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
<el-select
|
||||
v-model="searchForm.status"
|
||||
placeholder="全部状态"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="(item, value) in statusMap"
|
||||
:key="value"
|
||||
:label="item.label"
|
||||
:value="Number(value)"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="commentList"
|
||||
v-loading="loading"
|
||||
border
|
||||
stripe
|
||||
style="width: 100%"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="50" align="center" />
|
||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||
|
||||
<el-table-column label="评论内容" min-width="300">
|
||||
<template #default="{ row }">
|
||||
<div class="comment-content">{{ row.content }}</div>
|
||||
<div v-if="row.reply" class="comment-reply">
|
||||
<b>管理员回复:</b>{{ row.reply }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="所属文章" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.article_title || `#${row.article_id || '-'}` }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="评论人" width="150">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.nickname || '匿名' }}</div>
|
||||
<div class="muted">{{ row.email || '-' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusMap[row.status]?.type || 'info'" size="small">
|
||||
{{ statusMap[row.status]?.label || '未知' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="评论时间" width="180" align="center">
|
||||
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="230" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.status !== 1"
|
||||
size="small"
|
||||
text
|
||||
type="success"
|
||||
@click="handleAudit(row, 1)"
|
||||
>
|
||||
通过
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status !== 2"
|
||||
size="small"
|
||||
text
|
||||
type="warning"
|
||||
@click="handleAudit(row, 2)"
|
||||
>
|
||||
拒绝
|
||||
</el-button>
|
||||
<el-button size="small" text type="primary" @click="handleReply(row)">
|
||||
回复
|
||||
</el-button>
|
||||
<el-button size="small" text type="danger" @click="handleDelete(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<template #empty>
|
||||
<el-empty description="暂无评论" :image-size="80" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-container">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 回复评论 -->
|
||||
<el-dialog v-model="replyVisible" title="回复评论" width="520px" destroy-on-close>
|
||||
<div class="reply-quote">
|
||||
<b>{{ replyTarget?.nickname || '匿名' }}:</b>{{ replyTarget?.content }}
|
||||
</div>
|
||||
<el-input
|
||||
v-model="replyContent"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入回复内容"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="replyVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="replyLoading" @click="submitReply">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh, Search, Delete } from '@element-plus/icons-vue'
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import {
|
||||
listComments,
|
||||
auditComment,
|
||||
replyComment,
|
||||
deleteComment,
|
||||
batchDeleteComments
|
||||
} from '@/api/comment'
|
||||
|
||||
// 0 待审核、1 已通过、2 已拒绝
|
||||
const statusMap = {
|
||||
0: { label: '待审核', type: 'warning' },
|
||||
1: { label: '已通过', type: 'success' },
|
||||
2: { label: '已拒绝', type: 'danger' }
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const commentList = ref([])
|
||||
const selectedIds = ref([])
|
||||
const searchForm = reactive({ keyword: '', status: '' })
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
|
||||
const replyVisible = ref(false)
|
||||
const replyLoading = ref(false)
|
||||
const replyContent = ref('')
|
||||
const replyTarget = ref(null)
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listComments({
|
||||
keyword: searchForm.keyword,
|
||||
status: searchForm.status === '' ? '' : searchForm.status,
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize
|
||||
})
|
||||
if (res.code === 200) {
|
||||
const data = res.data
|
||||
commentList.value = Array.isArray(data) ? data : data?.list || []
|
||||
pagination.total = Array.isArray(data) ? data.length : data?.total || 0
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取评论列表失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('获取评论列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const resetSearch = () => {
|
||||
searchForm.keyword = ''
|
||||
searchForm.status = ''
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleSizeChange = (val) => {
|
||||
pagination.pageSize = val
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handlePageChange = (val) => {
|
||||
pagination.page = val
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleSelectionChange = (selection) => {
|
||||
selectedIds.value = selection.map((item) => item.id)
|
||||
}
|
||||
|
||||
const handleAudit = async (row, status) => {
|
||||
try {
|
||||
const res = await auditComment(row.id, status)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(status === 1 ? '已通过' : '已拒绝')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '操作失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleReply = (row) => {
|
||||
replyTarget.value = row
|
||||
replyContent.value = row.reply || ''
|
||||
replyVisible.value = true
|
||||
}
|
||||
|
||||
const submitReply = async () => {
|
||||
if (!replyContent.value.trim()) {
|
||||
ElMessage.warning('请输入回复内容')
|
||||
return
|
||||
}
|
||||
replyLoading.value = true
|
||||
try {
|
||||
const res = await replyComment(replyTarget.value.id, replyContent.value.trim())
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('回复成功')
|
||||
replyVisible.value = false
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '回复失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('回复失败')
|
||||
} finally {
|
||||
replyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该评论吗?', '删除确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await deleteComment(row.id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '删除失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.value.length === 0) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除选中的 ${selectedIds.value.length} 条评论吗?`,
|
||||
'批量删除确认',
|
||||
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await batchDeleteComments(selectedIds.value)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
selectedIds.value = []
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '删除失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.container-box {
|
||||
padding: 16px;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
line-height: 1.7;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.comment-reply {
|
||||
margin-top: 6px;
|
||||
padding: 6px 10px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
font-size: 12.5px;
|
||||
color: var(--el-text-color-regular);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.reply-quote {
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-regular);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.muted {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<div class="container-box tag-manage">
|
||||
<div class="header-bar">
|
||||
<h2>标签管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增标签
|
||||
</el-button>
|
||||
<el-button @click="fetchList" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert type="info" :closable="false" class="tips">
|
||||
<template #title>
|
||||
<div>
|
||||
标签用于给官网内容打标并按主题聚合,前台可通过模板标签
|
||||
<code>{yz:taglist}</code> 调用。标签名建议简短唯一。
|
||||
</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchForm.keyword"
|
||||
placeholder="搜索标签名称"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
<el-select
|
||||
v-model="searchForm.status"
|
||||
placeholder="全部状态"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="tagList" v-loading="loading" border stripe style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||
<el-table-column prop="name" label="标签名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="sort" label="排序" width="90" align="center" />
|
||||
<el-table-column label="状态" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
:model-value="row.status === 1"
|
||||
@change="(val) => handleStatusChange(row, val)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="180" align="center">
|
||||
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" text type="primary" @click="handleEdit(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button size="small" text type="danger" @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<template #empty>
|
||||
<el-empty description="暂无标签" :image-size="80" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-container">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑标签' : '新增标签'"
|
||||
width="480px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@closed="resetForm"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||
<el-form-item label="标签名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入标签名称" maxlength="50" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio :value="1">启用</el-radio>
|
||||
<el-radio :value="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
|
||||
{{ isEdit ? '保存' : '创建' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import { listTags, createTag, editTag, deleteTag, updateTagStatus } from '@/api/tag'
|
||||
|
||||
const loading = ref(false)
|
||||
const tagList = ref([])
|
||||
const searchForm = reactive({ keyword: '', status: '' })
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const editId = ref(0)
|
||||
const formRef = ref(null)
|
||||
|
||||
const form = reactive({ name: '', sort: 0, status: 1 })
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入标签名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listTags({
|
||||
keyword: searchForm.keyword,
|
||||
status: searchForm.status === '' ? '' : searchForm.status,
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize
|
||||
})
|
||||
if (res.code === 200) {
|
||||
const data = res.data
|
||||
tagList.value = Array.isArray(data) ? data : data?.list || []
|
||||
pagination.total = Array.isArray(data) ? data.length : data?.total || 0
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取标签列表失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('获取标签列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const resetSearch = () => {
|
||||
searchForm.keyword = ''
|
||||
searchForm.status = ''
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleSizeChange = (val) => {
|
||||
pagination.pageSize = val
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handlePageChange = (val) => {
|
||||
pagination.page = val
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
formRef.value?.clearValidate()
|
||||
isEdit.value = false
|
||||
editId.value = 0
|
||||
Object.assign(form, { name: '', sort: 0, status: 1 })
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row) => {
|
||||
isEdit.value = true
|
||||
editId.value = row.id
|
||||
Object.assign(form, {
|
||||
name: row.name || '',
|
||||
sort: row.sort ?? 0,
|
||||
status: row.status ?? 1
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const res = isEdit.value
|
||||
? await editTag(editId.value, { ...form })
|
||||
: await createTag({ ...form })
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(isEdit.value ? '保存成功' : '创建成功')
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '操作失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleStatusChange = async (row, val) => {
|
||||
const next = val ? 1 : 0
|
||||
try {
|
||||
const res = await updateTagStatus(row.id, next)
|
||||
if (res.code === 200) {
|
||||
row.status = next
|
||||
ElMessage.success(next === 1 ? '已启用' : '已禁用')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '状态更新失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('状态更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除标签「${row.name}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await deleteTag(row.id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '删除失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.container-box {
|
||||
padding: 16px;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="title"
|
||||
width="560px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@update:model-value="handleVisibleChange"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="链接名称" prop="link_name">
|
||||
<el-input v-model="form.link_name" placeholder="请输入链接名称" maxlength="100" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="链接地址" prop="link_url">
|
||||
<el-input v-model="form.link_url" placeholder="例如:https://www.example.com" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Logo" prop="link_logo">
|
||||
<el-upload
|
||||
class="logo-uploader"
|
||||
:show-file-list="false"
|
||||
:auto-upload="false"
|
||||
accept="image/*"
|
||||
:on-change="handleLogoChange"
|
||||
>
|
||||
<img v-if="form.link_logo" :src="previewUrl(form.link_logo)" class="logo-preview" />
|
||||
<div v-else class="upload-placeholder">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>上传 Logo</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div class="field-tip">建议尺寸 200 × 200,支持 jpg / png,单张不超过 2MB</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="请输入描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio :value="1">启用</el-radio>
|
||||
<el-radio :value="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
|
||||
{{ isEdit ? '保存' : '添加' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { addFriendlink, updateFriendlink } from '@/api/friendlink'
|
||||
import { uploadToWebsiteMaterial } from '@/utils/websiteUpload'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
title: { type: String, default: '添加友情链接' },
|
||||
isEdit: { type: Boolean, default: false },
|
||||
rowData: { type: Object, default: () => ({}) }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'success'])
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''
|
||||
const visible = ref(props.modelValue)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref(null)
|
||||
const logoFile = ref(null)
|
||||
|
||||
const form = reactive({
|
||||
link_name: '',
|
||||
link_url: '',
|
||||
link_logo: '',
|
||||
description: '',
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
|
||||
const rules = {
|
||||
link_name: [{ required: true, message: '请输入链接名称', trigger: 'blur' }],
|
||||
link_url: [
|
||||
{ required: true, message: '请输入链接地址', trigger: 'blur' },
|
||||
{ type: 'url', message: '请输入正确的 URL 地址', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const isEdit = computed(() => props.isEdit)
|
||||
|
||||
const previewUrl = (url) => {
|
||||
if (!url) return ''
|
||||
if (/^https?:\/\//i.test(url)) return url
|
||||
return API_BASE_URL + url
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
visible.value = val
|
||||
if (!val) return
|
||||
formRef.value?.clearValidate()
|
||||
logoFile.value = null
|
||||
if (props.isEdit && props.rowData) {
|
||||
Object.assign(form, {
|
||||
link_name: props.rowData.link_name || '',
|
||||
link_url: props.rowData.link_url || '',
|
||||
link_logo: props.rowData.link_logo || '',
|
||||
description: props.rowData.description || '',
|
||||
sort: props.rowData.sort ?? 0,
|
||||
status: props.rowData.status ?? 1
|
||||
})
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(visible, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
formRef.value?.clearValidate()
|
||||
logoFile.value = null
|
||||
Object.assign(form, {
|
||||
link_name: '',
|
||||
link_url: '',
|
||||
link_logo: '',
|
||||
description: '',
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
}
|
||||
|
||||
const handleLogoChange = (file) => {
|
||||
const raw = file.raw
|
||||
if (!raw) return
|
||||
if (!raw.type.startsWith('image/')) {
|
||||
ElMessage.error('仅支持图片格式')
|
||||
return
|
||||
}
|
||||
if (raw.size / 1024 / 1024 >= 2) {
|
||||
ElMessage.error('Logo 不能超过 2MB')
|
||||
return
|
||||
}
|
||||
logoFile.value = raw
|
||||
form.link_logo = URL.createObjectURL(raw)
|
||||
}
|
||||
|
||||
const handleVisibleChange = (val) => {
|
||||
if (!val) emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
let logoUrl = form.link_logo
|
||||
if (logoFile.value) {
|
||||
const uploadRes = await uploadToWebsiteMaterial(logoFile.value)
|
||||
if ((uploadRes.code === 200 || uploadRes.code === 201) && uploadRes.data?.url) {
|
||||
logoUrl = uploadRes.data.url.replace(/\\/g, '/')
|
||||
} else {
|
||||
ElMessage.error(uploadRes.msg || 'Logo 上传失败')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
link_name: form.link_name,
|
||||
link_url: form.link_url,
|
||||
link_logo: logoUrl,
|
||||
description: form.description,
|
||||
sort: form.sort,
|
||||
status: form.status
|
||||
}
|
||||
|
||||
const res = isEdit.value
|
||||
? await updateFriendlink(props.rowData.id, payload)
|
||||
: await addFriendlink(payload)
|
||||
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(isEdit.value ? '保存成功' : '添加成功')
|
||||
visible.value = false
|
||||
emit('success')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '操作失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.logo-uploader {
|
||||
:deep(.el-upload) {
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logo-preview {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
|
||||
.el-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.field-tip {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,359 @@
|
||||
<template>
|
||||
<div class="container-box link-manage">
|
||||
<div class="header-bar">
|
||||
<h2>友情链接</h2>
|
||||
<div class="header-actions">
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
:disabled="selectedIds.length === 0"
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
批量删除
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加链接
|
||||
</el-button>
|
||||
<el-button @click="fetchList" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert type="info" :closable="false" class="tips">
|
||||
<template #title>
|
||||
<div>
|
||||
官网页脚 / 友情链接区的合作站点,对应模板标签 <code>{yz:friendlink}</code>。
|
||||
仅 <b>启用</b> 状态的链接会在官网展示。
|
||||
</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchForm.keyword"
|
||||
placeholder="搜索链接名称"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
<el-select
|
||||
v-model="searchForm.status"
|
||||
placeholder="全部状态"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="linkList"
|
||||
v-loading="loading"
|
||||
border
|
||||
stripe
|
||||
style="width: 100%"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="50" align="center" />
|
||||
<el-table-column prop="sort" label="排序" width="80" align="center" />
|
||||
|
||||
<el-table-column prop="link_name" label="链接名称" min-width="150" show-overflow-tooltip />
|
||||
|
||||
<el-table-column label="链接地址" min-width="220" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-link :href="row.link_url" target="_blank" type="primary">{{ row.link_url }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="Logo" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.link_logo"
|
||||
:src="previewUrl(row.link_logo)"
|
||||
:preview-src-list="[previewUrl(row.link_logo)]"
|
||||
preview-teleported
|
||||
fit="contain"
|
||||
style="width: 60px; height: 40px"
|
||||
/>
|
||||
<span v-else class="muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.description || '-' }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
:model-value="row.status === 1"
|
||||
@change="(val) => handleStatusChange(row, val)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="创建时间" width="180" align="center">
|
||||
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="160" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" text type="primary" @click="handleEdit(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button size="small" text type="danger" @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<template #empty>
|
||||
<el-empty description="暂无友情链接" :image-size="80" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-container">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.limit"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LinkEdit
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
:is-edit="isEdit"
|
||||
:row-data="currentRow"
|
||||
@success="fetchList"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import {
|
||||
getFriendlinkList,
|
||||
updateFriendlink,
|
||||
deleteFriendlink,
|
||||
batchDeleteFriendlinks
|
||||
} from '@/api/friendlink'
|
||||
import LinkEdit from './components/edit.vue'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''
|
||||
|
||||
const loading = ref(false)
|
||||
const linkList = ref([])
|
||||
const selectedIds = ref([])
|
||||
const searchForm = reactive({ keyword: '', status: '' })
|
||||
const pagination = reactive({ page: 1, limit: 10, total: 0 })
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('添加友情链接')
|
||||
const isEdit = ref(false)
|
||||
const currentRow = ref({})
|
||||
|
||||
const previewUrl = (url) => {
|
||||
if (!url) return ''
|
||||
if (/^https?:\/\//i.test(url)) return url
|
||||
return API_BASE_URL + url
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getFriendlinkList({
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
keyword: searchForm.keyword,
|
||||
status: searchForm.status === '' ? '' : searchForm.status
|
||||
})
|
||||
if (res.code === 200) {
|
||||
const data = res.data
|
||||
linkList.value = Array.isArray(data) ? data : data?.list || []
|
||||
pagination.total = Array.isArray(data) ? data.length : data?.total || 0
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取友情链接失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('获取友情链接失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const resetSearch = () => {
|
||||
searchForm.keyword = ''
|
||||
searchForm.status = ''
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleSizeChange = (val) => {
|
||||
pagination.limit = val
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handlePageChange = (val) => {
|
||||
pagination.page = val
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleSelectionChange = (selection) => {
|
||||
selectedIds.value = selection.map((item) => item.id)
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '添加友情链接'
|
||||
currentRow.value = {}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row) => {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑友情链接'
|
||||
currentRow.value = { ...row }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleStatusChange = async (row, val) => {
|
||||
const next = val ? 1 : 0
|
||||
try {
|
||||
const res = await updateFriendlink(row.id, { status: next })
|
||||
if (res.code === 200) {
|
||||
row.status = next
|
||||
ElMessage.success(next === 1 ? '已启用' : '已禁用')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '状态更新失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('状态更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除友情链接「${row.link_name}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await deleteFriendlink(row.id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '删除失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.value.length === 0) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除选中的 ${selectedIds.value.length} 条友情链接吗?`,
|
||||
'批量删除确认',
|
||||
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await batchDeleteFriendlinks(selectedIds.value)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
selectedIds.value = []
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '删除失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.container-box {
|
||||
padding: 16px;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="dialogTitle"
|
||||
width="560px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@update:model-value="handleVisibleChange"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="父级菜单" prop="pid">
|
||||
<el-tree-select
|
||||
v-model="form.pid"
|
||||
:data="parentMenuOptions"
|
||||
:props="{ value: 'id', label: 'title', children: 'children' }"
|
||||
placeholder="不选则为顶级菜单"
|
||||
clearable
|
||||
check-strictly
|
||||
:render-after-expand="false"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="菜单名称" prop="title">
|
||||
<el-input v-model="form.title" placeholder="请输入菜单名称" maxlength="50" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="菜单类型" prop="type">
|
||||
<el-radio-group v-model="form.type">
|
||||
<el-radio-button :value="1">目录</el-radio-button>
|
||||
<el-radio-button :value="2">页面</el-radio-button>
|
||||
<el-radio-button :value="3">外链</el-radio-button>
|
||||
<el-radio-button :value="4">单页</el-radio-button>
|
||||
</el-radio-group>
|
||||
<div class="field-tip">
|
||||
<div>目录:只做分组,本身不跳转</div>
|
||||
<div>页面:需填写路由地址 + 组件路径</div>
|
||||
<div>外链:填写完整外部地址(https://...)</div>
|
||||
<div>单页:路由地址需与「单页管理」中的路由一致</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="form.type !== 3" label="路由地址" prop="path">
|
||||
<el-input v-model="form.path" placeholder="例如:products / /products" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="form.type === 2" label="组件路径" prop="component_path">
|
||||
<el-input v-model="form.component_path" placeholder="例如:/views/list.vue" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="form.type === 3" label="外链地址" prop="path">
|
||||
<el-input v-model="form.path" placeholder="例如:https://www.example.com" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="图标" prop="icon">
|
||||
<el-input v-model="form.icon" placeholder="图标类名,例如 fa-solid fa-house" />
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio :value="1">启用</el-radio>
|
||||
<el-radio :value="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="是否显示" prop="is_visible">
|
||||
<el-switch v-model="form.is_visible" :active-value="1" :inactive-value="0" />
|
||||
<span class="field-tip inline">关闭后该菜单不在官网前台显示</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="权限标识" prop="permission">
|
||||
<el-input v-model="form.permission" placeholder="选填,例如:website:nav:list" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
menu: { type: Object, default: null },
|
||||
parentMenuOptions: { type: Array, default: () => [] },
|
||||
dialogType: { type: String, default: 'add' },
|
||||
parentTitle: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'save'])
|
||||
|
||||
const formRef = ref(null)
|
||||
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
pid: 0,
|
||||
title: '',
|
||||
type: 1,
|
||||
path: '',
|
||||
component_path: '',
|
||||
icon: '',
|
||||
sort: 0,
|
||||
status: 1,
|
||||
is_visible: 1,
|
||||
permission: ''
|
||||
})
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
switch (props.dialogType) {
|
||||
case 'edit':
|
||||
return '编辑菜单'
|
||||
case 'addSub':
|
||||
return `添加子菜单(父级:${props.parentTitle || '顶级菜单'})`
|
||||
default:
|
||||
return '添加菜单'
|
||||
}
|
||||
})
|
||||
|
||||
const rules = {
|
||||
title: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
|
||||
path: [
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (!value || !String(value).trim()) {
|
||||
callback(new Error(form.type === 3 ? '请输入外链地址' : '请输入路由地址'))
|
||||
} else if (form.type === 3 && !/^https?:\/\//i.test(String(value).trim())) {
|
||||
callback(new Error('外链地址需以 http:// 或 https:// 开头'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
component_path: [
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (form.type === 2 && (!value || !String(value).trim())) {
|
||||
callback(new Error('页面类型需填写组件路径'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 同步传入菜单数据到表单
|
||||
watch(
|
||||
() => props.menu,
|
||||
(val) => {
|
||||
if (val) {
|
||||
Object.assign(form, {
|
||||
id: val.id ?? 0,
|
||||
pid: val.pid ?? 0,
|
||||
title: val.title || '',
|
||||
type: val.type ?? 1,
|
||||
path: val.path || '',
|
||||
component_path: val.component_path || '',
|
||||
icon: val.icon || '',
|
||||
sort: val.sort ?? 0,
|
||||
status: val.status ?? 1,
|
||||
is_visible: val.is_visible ?? 1,
|
||||
permission: val.permission || ''
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 切换类型时清理无关字段
|
||||
watch(
|
||||
() => form.type,
|
||||
(newType, oldType) => {
|
||||
if (newType === oldType) return
|
||||
if (newType === 1 || newType === 4) {
|
||||
form.component_path = ''
|
||||
}
|
||||
if (newType === 3) {
|
||||
form.component_path = ''
|
||||
if (/^\//.test(form.path)) form.path = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleVisibleChange = (val) => {
|
||||
if (!val) emit('update:visible', false)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate((valid) => {
|
||||
if (!valid) return
|
||||
if (form.type === 1) {
|
||||
ElMessage.warning('目录仅用于分组,无需填写组件路径')
|
||||
}
|
||||
emit('save', { ...form })
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.field-tip {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
&.inline {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,340 @@
|
||||
<template>
|
||||
<div class="container-box nav-manage">
|
||||
<div class="header-bar">
|
||||
<h2>导航菜单</h2>
|
||||
<div class="header-actions">
|
||||
<el-button @click="toggleExpandAll">
|
||||
<el-icon><Sort /></el-icon>
|
||||
{{ expandAll ? '全部折叠' : '全部展开' }}
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleAddMenu">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加菜单
|
||||
</el-button>
|
||||
<el-button @click="fetchMenus" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <el-alert type="info" :closable="false" class="tips">
|
||||
<template #title>
|
||||
<div>
|
||||
企业官网前台站点(website)的顶部导航 / 页脚菜单。
|
||||
菜单类型:<b>目录</b>(仅分组)、<b>页面</b>(需填写组件路径)、<b>外链</b>(跳转外部地址)、<b>单页</b>(内容取自单页管理)。
|
||||
租户端(tenant_id = 0)的默认菜单不可修改。
|
||||
</div>
|
||||
</template>
|
||||
</el-alert> -->
|
||||
|
||||
<el-table
|
||||
v-if="tableVisible"
|
||||
ref="tableRef"
|
||||
:data="menuTree"
|
||||
v-loading="loading"
|
||||
row-key="id"
|
||||
border
|
||||
default-expand-all
|
||||
:tree-props="{ children: 'children' }"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column prop="title" label="菜单名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="menu-item">
|
||||
<i v-if="row.icon" :class="row.icon" class="menu-icon"></i>
|
||||
<span>{{ row.title }}</span>
|
||||
<el-tag v-if="row.tenant_id === 0" size="small" type="warning">系统默认</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="path" label="路由地址" min-width="160" show-overflow-tooltip />
|
||||
|
||||
<el-table-column label="类型" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="typeMap[row.type]?.tag || 'info'" size="small">
|
||||
{{ typeMap[row.type]?.label || '未知' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="显示" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_visible === 0 ? 'info' : 'success'" size="small">
|
||||
{{ row.is_visible === 0 ? '隐藏' : '显示' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="sort" label="排序" width="80" align="center" />
|
||||
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="250" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
:disabled="row.type === 3"
|
||||
@click="handleAddSubMenu(row)"
|
||||
>
|
||||
<el-icon><CirclePlus /></el-icon>
|
||||
子菜单
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
type="primary"
|
||||
:disabled="row.tenant_id === 0"
|
||||
@click="handleEditMenu(row)"
|
||||
>
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
type="danger"
|
||||
:disabled="row.tenant_id === 0"
|
||||
@click="handleDeleteMenu(row)"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<template #empty>
|
||||
<el-empty description="暂无导航菜单" :image-size="80" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<NavEdit
|
||||
v-model:visible="dialogVisible"
|
||||
:menu="dialogMenu"
|
||||
:parent-menu-options="parentMenuOptions"
|
||||
:dialog-type="dialogType"
|
||||
:parent-title="dialogParentTitle"
|
||||
@save="handleMenuSave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Edit, Delete, CirclePlus, Sort } from '@element-plus/icons-vue'
|
||||
import { getFrontMenus, createFrontMenu, editFrontMenu, deleteFrontMenu } from '@/api/frontMenu'
|
||||
import NavEdit from './components/edit.vue'
|
||||
|
||||
const typeMap = {
|
||||
1: { label: '目录', tag: 'primary' },
|
||||
2: { label: '页面', tag: 'success' },
|
||||
3: { label: '外链', tag: 'info' },
|
||||
4: { label: '单页', tag: 'warning' }
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const menus = ref([])
|
||||
const tableRef = ref(null)
|
||||
const expandAll = ref(true)
|
||||
const tableVisible = ref(true)
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMenu = ref(null)
|
||||
const dialogType = ref('add')
|
||||
const dialogParentTitle = ref('')
|
||||
|
||||
// 平铺列表 -> 树(兼容 pid 父子字段)
|
||||
const buildTree = (list) => {
|
||||
const nodes = list.map((item) => ({ ...item, children: [] }))
|
||||
const map = new Map(nodes.map((n) => [n.id, n]))
|
||||
const roots = []
|
||||
nodes.forEach((node) => {
|
||||
const parent = map.get(node.pid)
|
||||
if (parent && parent.id !== node.id) {
|
||||
parent.children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
})
|
||||
const prune = (arr) =>
|
||||
arr.forEach((n) => {
|
||||
if (n.children.length === 0) {
|
||||
delete n.children
|
||||
} else {
|
||||
prune(n.children)
|
||||
}
|
||||
})
|
||||
prune(roots)
|
||||
return roots
|
||||
}
|
||||
|
||||
const menuTree = computed(() => buildTree(menus.value))
|
||||
const parentMenuOptions = computed(() => [
|
||||
{ id: 0, title: '顶级菜单', children: menuTree.value }
|
||||
])
|
||||
|
||||
const fetchMenus = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getFrontMenus()
|
||||
if (res.code === 200) {
|
||||
menus.value = Array.isArray(res.data) ? res.data : res.data?.list || []
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取导航菜单失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('获取导航菜单失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExpandAll = async () => {
|
||||
expandAll.value = !expandAll.value
|
||||
tableVisible.value = false
|
||||
await nextTick()
|
||||
tableVisible.value = true
|
||||
}
|
||||
|
||||
const handleAddMenu = () => {
|
||||
dialogType.value = 'add'
|
||||
dialogMenu.value = {
|
||||
id: 0,
|
||||
pid: 0,
|
||||
title: '',
|
||||
type: 1,
|
||||
path: '',
|
||||
component_path: '',
|
||||
icon: '',
|
||||
sort: 0,
|
||||
status: 1,
|
||||
is_visible: 1,
|
||||
permission: ''
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleAddSubMenu = (parent) => {
|
||||
dialogType.value = 'addSub'
|
||||
dialogParentTitle.value = parent.title
|
||||
dialogMenu.value = {
|
||||
id: 0,
|
||||
pid: parent.id,
|
||||
title: '',
|
||||
type: parent.type === 1 ? 2 : parent.type,
|
||||
path: parent.path || '',
|
||||
component_path: '',
|
||||
icon: '',
|
||||
sort: 0,
|
||||
status: 1,
|
||||
is_visible: 1,
|
||||
permission: ''
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEditMenu = (row) => {
|
||||
dialogType.value = 'edit'
|
||||
dialogMenu.value = { ...row }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDeleteMenu = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除菜单「${row.title}」吗?其下所有子菜单也会一并删除。`,
|
||||
'删除确认',
|
||||
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await deleteFrontMenu(row.id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchMenus()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '删除失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleMenuSave = async (menu) => {
|
||||
// 后端 pid 为整型,统一转换避免类型错误
|
||||
const payload = { ...menu }
|
||||
const pid = parseInt(payload.pid, 10)
|
||||
payload.pid = Number.isNaN(pid) ? 0 : pid
|
||||
|
||||
const isCreate = dialogType.value !== 'edit' || !payload.id
|
||||
try {
|
||||
const res = isCreate
|
||||
? await createFrontMenu(payload)
|
||||
: await editFrontMenu(payload.id, payload)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(res.msg || (isCreate ? '添加成功' : '保存成功'))
|
||||
dialogVisible.value = false
|
||||
fetchMenus()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '操作失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchMenus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.container-box {
|
||||
padding: 16px;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="dialogTitle"
|
||||
width="820px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@update:model-value="handleVisibleChange"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="form.title" placeholder="请输入单页标题" maxlength="255" show-word-limit />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="路由" prop="path">
|
||||
<el-input v-model="form.path" placeholder="例如:about、contact、policy/privacy" />
|
||||
<div class="field-tip">
|
||||
官网前台的访问路径,只能包含字母、数字、下划线、中划线和斜杠,例如 about 对应 /about。
|
||||
需与「导航菜单」中单页菜单的路由保持一致。
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="内容" prop="content">
|
||||
<el-input
|
||||
v-model="form.content"
|
||||
type="textarea"
|
||||
:rows="16"
|
||||
class="content-editor"
|
||||
placeholder="请输入单页内容,支持 HTML 代码"
|
||||
/>
|
||||
<div class="field-tip">
|
||||
支持 HTML 代码与文本内容;也可使用官网模板标签,如
|
||||
<code>{yz:sitename}</code>、<code>{yz:companyname}</code>。
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
|
||||
<span class="field-tip inline">启用后官网前台可访问该单页</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
onepage: { type: Object, default: null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'save'])
|
||||
|
||||
const visible = ref(props.modelValue)
|
||||
const formRef = ref(null)
|
||||
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
title: '',
|
||||
path: '',
|
||||
content: '',
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
|
||||
const dialogTitle = computed(() => (form.id && form.id > 0 ? '编辑单页' : '添加单页'))
|
||||
|
||||
const rules = {
|
||||
title: [{ required: true, message: '请输入单页标题', trigger: 'blur' }],
|
||||
path: [
|
||||
{ required: true, message: '请输入路由', trigger: 'blur' },
|
||||
{
|
||||
pattern: /^\/?[a-zA-Z0-9/_-]+$/,
|
||||
message: '路由只能包含字母、数字、下划线、中划线和斜杠',
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
content: [{ required: true, message: '请输入单页内容', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
visible.value = val
|
||||
if (!val) return
|
||||
formRef.value?.clearValidate()
|
||||
if (props.onepage) {
|
||||
Object.assign(form, {
|
||||
id: props.onepage.id ?? 0,
|
||||
title: props.onepage.title || '',
|
||||
path: props.onepage.path || '',
|
||||
content: props.onepage.content || '',
|
||||
sort: props.onepage.sort ?? 0,
|
||||
status: props.onepage.status ?? 1
|
||||
})
|
||||
} else {
|
||||
Object.assign(form, { id: 0, title: '', path: '', content: '', sort: 0, status: 1 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(visible, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const handleVisibleChange = (val) => {
|
||||
if (!val) emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate((valid) => {
|
||||
if (!valid) return
|
||||
// 统一去掉路由开头的斜杠,与后端存储约定保持一致
|
||||
const payload = { ...form, path: String(form.path || '').replace(/^\/+/, '') }
|
||||
emit('save', payload)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.content-editor {
|
||||
:deep(.el-textarea__inner) {
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.field-tip {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
&.inline {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<div class="container-box onepage-manage">
|
||||
<div class="header-bar">
|
||||
<h2>单页管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加单页
|
||||
</el-button>
|
||||
<el-button @click="fetchList" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert type="info" :closable="false" class="tips">
|
||||
<template #title>
|
||||
<div>
|
||||
单页用于「关于我们 / 联系我们 / 隐私政策」等固定内容页面,官网通过
|
||||
<code>{yz:onepage path="路由" field="content"/}</code> 调用,也可在「导航菜单」中选择类型为
|
||||
<b>单页</b> 的菜单项,路由需与单页路由一致。
|
||||
</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchForm.keyword"
|
||||
placeholder="搜索标题 / 路由"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
<el-select
|
||||
v-model="searchForm.status"
|
||||
placeholder="全部状态"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="pageList" v-loading="loading" border stripe style="width: 100%">
|
||||
<el-table-column type="index" label="序号" width="70" align="center" />
|
||||
<el-table-column prop="title" label="标题" min-width="180" show-overflow-tooltip />
|
||||
|
||||
<el-table-column label="路由" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="info" size="small">{{ row.path }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="内容预览" min-width="240" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="content-preview">{{ getContentPreview(row.content) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="sort" label="排序" width="90" align="center" />
|
||||
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="更新时间" width="180" align="center">
|
||||
<template #default="{ row }">{{ formatDateTime(row.update_time || row.create_time) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="160" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" text type="primary" @click="handleEdit(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button size="small" text type="danger" @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<template #empty>
|
||||
<el-empty description="暂无单页" :image-size="80" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<PageEdit v-model="dialogVisible" :onepage="dialogPage" @save="handleSave" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import { getOnePages, createOnePage, editOnePage, deleteOnePage } from '@/api/onepage'
|
||||
import PageEdit from './components/edit.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const rawList = ref([])
|
||||
const searchForm = reactive({ keyword: '', status: '' })
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogPage = ref(null)
|
||||
|
||||
// 单页数据量通常较小,接口一次性返回全部,前端做筛选
|
||||
const pageList = ref([])
|
||||
|
||||
const applyFilter = () => {
|
||||
const keyword = searchForm.keyword.trim().toLowerCase()
|
||||
pageList.value = rawList.value.filter((item) => {
|
||||
const matchKeyword =
|
||||
!keyword ||
|
||||
String(item.title || '').toLowerCase().includes(keyword) ||
|
||||
String(item.path || '').toLowerCase().includes(keyword)
|
||||
const matchStatus = searchForm.status === '' || item.status === searchForm.status
|
||||
return matchKeyword && matchStatus
|
||||
})
|
||||
}
|
||||
|
||||
const getContentPreview = (content) => {
|
||||
if (!content) return '-'
|
||||
const text = String(content).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
|
||||
return text.length > 80 ? text.slice(0, 80) + '...' : text || '-'
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getOnePages()
|
||||
if (res.code === 200) {
|
||||
rawList.value = Array.isArray(res.data) ? res.data : res.data?.list || []
|
||||
applyFilter()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取单页列表失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('获取单页列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
applyFilter()
|
||||
}
|
||||
|
||||
const resetSearch = () => {
|
||||
searchForm.keyword = ''
|
||||
searchForm.status = ''
|
||||
applyFilter()
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
dialogPage.value = null
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row) => {
|
||||
dialogPage.value = { ...row }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除单页「${row.title}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await deleteOnePage(row.id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '删除失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async (onepage) => {
|
||||
try {
|
||||
const res =
|
||||
onepage.id && onepage.id > 0
|
||||
? await editOnePage(onepage.id, onepage)
|
||||
: await createOnePage(onepage)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(res.msg || (onepage.id ? '保存成功' : '添加成功'))
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '操作失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.container-box {
|
||||
padding: 16px;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.content-preview {
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 12.5px;
|
||||
color: var(--el-text-color-regular);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
+3
-7
@@ -57,7 +57,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { uploadFile } from '@/api/file'
|
||||
import { uploadToWebsiteMaterial } from '@/utils/websiteUpload'
|
||||
import TagTip from './TagTip.vue'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -82,13 +82,9 @@ const previewUrl = (url) => {
|
||||
return API_BASE_URL + url.replace(/^\//, '/')
|
||||
}
|
||||
|
||||
// Logo 上传:上传成功后回填图片地址
|
||||
// Logo 上传:上传成功后回填图片地址(统一归入「官网素材」分组)
|
||||
const handleUpload = (file, field) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file.raw)
|
||||
fd.append('cate', 'site')
|
||||
|
||||
uploadFile(fd).then((res) => {
|
||||
uploadToWebsiteMaterial(file.raw).then((res) => {
|
||||
if ((res.code === 200 || res.code === 201) && res.data && res.data.url) {
|
||||
props.form[field] = res.data.url.replace(/\\/g, '/')
|
||||
} else {
|
||||
Reference in New Issue
Block a user