增加网站管理功能

This commit is contained in:
2026-09-17 23:00:06 +08:00
parent 1224735aa8
commit 67e819c629
98 changed files with 8711 additions and 2254 deletions
+75 -51
View File
@@ -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 },
});
}
}
+57
View File
@@ -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 },
});
}
+36 -32
View File
@@ -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 },
});
}
+20 -20
View File
@@ -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
View File
@@ -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",
});
}
+66
View File
@@ -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 },
});
}
+1 -1
View File
@@ -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: "站点设置" }
},
{
+61
View File
@@ -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 });
}
+7 -1
View File
@@ -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>
@@ -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 {