优化企业网站功能

This commit is contained in:
2026-08-20 16:21:07 +08:00
parent 112d60b993
commit 3f0fcb88e7
45 changed files with 8758 additions and 5058 deletions
+54
View File
@@ -51,3 +51,57 @@ export function deleteService(id) {
method: 'delete'
})
}
////////////////////////////分类相关////////////////////////////
/**
* 获取解决方案分类列表
* @param {Object} params - 查询参数
* @returns {Promise}
*/
export function getServicesTypesList(params) {
return request({
url: '/backend/servicesTypesList',
method: 'get',
params
})
}
/**
* 添加解决方案分类
* @param {Object} data - 分类数据
* @returns {Promise}
*/
export function addServicesTypes(data) {
return request({
url: '/backend/addServicesTypes',
method: 'post',
data
})
}
/**
* 更新解决方案分类
* @param {number} id - 分类ID
* @param {Object} data - 分类数据
* @returns {Promise}
*/
export function updateServicesTypes(id, data) {
return request({
url: `/backend/editServicesTypes/${id}`,
method: 'put',
data
})
}
/**
* 删除解决方案分类
* @param {number} id - 分类ID
* @returns {Promise}
*/
export function deleteServicesTypes(id) {
return request({
url: `/backend/deleteServicesTypes/${id}`,
method: 'delete'
})
}
+35 -3
View File
@@ -292,13 +292,13 @@ const processMenus = (menus) => {
const fixedCmsMenu = {
id: -200,
path: "/apps/cms",
title: "文章中心",
title: "内容管理",
icon: "Document",
order: -200,
children: [
{
id: -201,
path: "/apps/cms/articles",
path: "/apps/cms/article",
title: "文章管理",
icon: "Document",
order: 1,
@@ -306,11 +306,43 @@ const fixedCmsMenu = {
},
{
id: -202,
path: "/apps/cms/articles/category",
path: "/apps/cms/article/type",
title: "文章分类",
icon: "Folder",
order: 2,
children: []
},
{
id: -203,
path: "/apps/cms/products",
title: "产品管理",
icon: "Goods",
order: 3,
children: []
},
{
id: -204,
path: "/apps/cms/products/types",
title: "产品分类",
icon: "Folder",
order: 4,
children: []
},
{
id: -205,
path: "/apps/cms/solutions",
title: "解决方案管理",
icon: "Promotion",
order: 5,
children: []
},
{
id: -206,
path: "/apps/cms/solutions/types",
title: "解决方案分类",
icon: "Folder",
order: 6,
children: []
}
]
};
+46 -6
View File
@@ -3,19 +3,60 @@ import { convertMenusToRoutes } from "./dynamicRoutes";
// 静态子路由:需要在 Main 框架内显示的页面
const staticMainChildren = [
// CMS 文章中心是系统内置功能,不依赖数据库菜单配置。
// 默认首页:访问 / 时直接展示工作台,URL 保持为 /
{
path: "/apps/cms/articles",
path: "",
name: "Dashboard",
component: () => import("@/views/dashboard/index.vue"),
meta: { requiresAuth: true, title: "工作台" }
},
// CMS 文章/产品/解决方案是系统内置功能,不依赖数据库菜单配置。
// 统一结构:index 为列表页,type 为分类页。
{
path: "/apps/cms/article",
name: "CmsArticles",
component: () => import("@/views/apps/cms/articles/index.vue"),
component: () => import("@/views/apps/cms/article/index/index.vue"),
meta: { requiresAuth: true, title: "文章管理", modulePath: "/apps/cms" }
},
{
path: "/apps/cms/articles/category",
path: "/apps/cms/article/type",
name: "CmsArticleCategories",
component: () => import("@/views/apps/cms/articles/category.vue"),
component: () => import("@/views/apps/cms/article/type/index.vue"),
meta: { requiresAuth: true, title: "文章分类", modulePath: "/apps/cms" }
},
{
path: "/apps/cms/products",
name: "CmsProducts",
component: () => import("@/views/apps/cms/product/index/index.vue"),
meta: { requiresAuth: true, title: "产品管理", modulePath: "/apps/cms" }
},
{
path: "/apps/cms/products/types",
name: "CmsProductCategories",
component: () => import("@/views/apps/cms/product/type/index.vue"),
meta: { requiresAuth: true, title: "产品分类", modulePath: "/apps/cms" }
},
{
path: "/apps/cms/solutions",
name: "CmsSolutions",
component: () => import("@/views/apps/cms/solution/index/index.vue"),
meta: { requiresAuth: true, title: "解决方案管理", modulePath: "/apps/cms" }
},
{
path: "/apps/cms/solutions/types",
name: "CmsSolutionCategories",
component: () => import("@/views/apps/cms/solution/type/index.vue"),
meta: { requiresAuth: true, title: "解决方案分类", modulePath: "/apps/cms" }
},
// 兼容旧路径:articles/* -> article/*
{
path: "/apps/cms/articles",
redirect: "/apps/cms/article"
},
{
path: "/apps/cms/articles/category",
redirect: "/apps/cms/article/type"
},
{
path: "/user/userProfile",
name: "userProfile",
@@ -138,7 +179,6 @@ function addDynamicRoutes(menus) {
path: "/",
name: "Main",
component: () => import("@/views/Main.vue"),
redirect: "/dashboard",
meta: { requiresAuth: true },
children: [...staticMainChildren, ...dynamicRoutes] // 合并静态和动态子路由
});
@@ -1,349 +1,349 @@
<template>
<el-drawer v-model="visible" title="文章预览" size="60%">
<div class="article-preview">
<div class="article-header">
<h1 class="article-title">{{ model?.title || "无标题" }}</h1>
<div class="article-meta">
<span class="meta-item">
<i class="el-icon-price-tag"></i>
<el-tag type="primary">{{ getCategoryName(model?.cate) }}</el-tag>
</span>
<span class="meta-item">
<i class="el-icon-user"></i>
作者:{{ model?.author }}
</span>
<span class="meta-item">
<i class="el-icon-view"></i>
发布人:{{ model?.publisher || "暂未发布" }}
</span>
<span class="meta-item">
<i class="el-icon-view"></i>
阅读量:{{ model?.views || 0 }}
</span>
<span class="meta-item">
<i class="el-icon-view"></i>
收藏量:{{ model?.likes || 0 }}
</span>
<span class="meta-item" v-if="model?.publish_time">
<i class="el-icon-time"></i>
{{ formatDate(model.publish_time) }}
</span>
<span class="meta-item">
<i class="el-icon-view"></i>
创建日期:{{ model?.create_time }}
</span>
</div>
</div>
<div class="article-content">
<div
v-if="model?.content"
v-html="model.content"
class="content-html"
></div>
<div v-else class="no-content">
<el-empty description="暂无内容"></el-empty>
</div>
</div>
</div>
</el-drawer>
</template>
<script lang="ts" setup>
import { ref, watch, onMounted } from "vue";
import { listCategories } from "@/api/article";
import { ElMessage, ElMessageBox } from "element-plus";
const categoryOptions = ref([]);
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
model: {
type: Object,
default: () => null,
},
});
// 获取分类列表
const fetchCategories = async () => {
try {
const res = await listCategories({ page: 1, limit: 1000 });
if (res.code === 200) {
categoryOptions.value = Array.isArray(res.data)
? res.data
: Array.isArray(res.data?.list)
? res.data.list
: [];
} else {
ElMessage.error(res?.msg || "获取分类列表失败");
}
} catch (error) {
ElMessage.error("获取分类列表失败");
}
};
// 根据分类ID获取分类名称
const getCategoryName = (cate) => {
if (!cate && cate !== 0) return "无分类";
// 确保比较时类型一致
const cateId = Number(cate);
// 传入的不是数字(已经是分类名),直接展示
if (Number.isNaN(cateId)) return String(cate);
const category = categoryOptions.value.find(
(item) => Number(item.id) === cateId,
);
return category?.name || "无分类";
};
const emit = defineEmits(["update:modelValue"]);
const visible = ref(false);
// 监听对话框显示状态
watch(
() => props.modelValue,
(newVal) => {
visible.value = newVal;
},
);
// 监听visible变化,同步给父组件
watch(visible, (newVal) => {
emit("update:modelValue", newVal);
});
onMounted(() => {
fetchCategories();
});
</script>
<style lang="less" scoped>
.article-preview {
padding: 20px;
.article-header {
border-bottom: 1px solid #ebeef5;
padding-bottom: 20px;
margin-bottom: 20px;
.article-title {
margin: 0 0 15px 0;
font-size: 24px;
font-weight: 600;
color: #303133;
line-height: 1.4;
}
.article-meta {
display: flex;
flex-wrap: wrap;
gap: 15px;
color: #909399;
font-size: 14px;
.meta-item {
display: flex;
align-items: center;
gap: 5px;
i {
font-size: 16px;
}
}
}
}
.article-content {
/* 确保article-content直接子元素的样式 */
& > div {
line-height: 1.8;
color: #606266;
padding: 20px 0;
}
/* 支持所有子元素的内联样式 */
* {
box-sizing: border-box;
}
/* 段落样式 */
p {
margin: 10px 0;
line-height: 1.8;
}
/* 标题样式 */
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 20px 0 10px 0;
font-weight: 600;
}
h1 {
font-size: 28px;
}
h2 {
font-size: 24px;
}
h3 {
font-size: 20px;
}
h4 {
font-size: 18px;
}
h5 {
font-size: 16px;
}
h6 {
font-size: 14px;
}
/* 列表样式 */
ul,
ol {
margin: 10px 0;
padding-left: 30px;
}
/* 引用样式 */
blockquote {
border-left: 4px solid #ebeef5;
padding-left: 15px;
margin: 15px 0;
color: #909399;
font-style: italic;
}
/* 代码样式 */
:deep(code) {
border-radius: 4px;
padding: 2px 6px;
font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace;
font-size: 13px;
color: #f06b6b;
}
/* 代码块样式 */
:deep(pre) {
background: linear-gradient(135deg, #1e1e2e 0%, #2d2d3f 100%);
border-radius: 12px;
padding: 10px;
overflow-x: auto;
margin: 20px 0;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
/* 图片样式 */
img {
max-width: 100%;
height: auto;
border-radius: 4px;
margin: 10px 0;
}
/* 表格样式 */
table {
width: 100%;
border-collapse: collapse;
margin: 15px 0;
th,
td {
border: 1px solid #ebeef5;
padding: 8px 12px;
text-align: left;
}
th {
background-color: #fafafa;
font-weight: 600;
}
}
/* 确保内联样式能够生效 */
[style] {
/* 允许内联样式正常工作 */
all: unset;
/* 重新应用基本样式 */
line-height: 1.8;
color: #606266;
/* 允许特定内联样式覆盖 */
&[style*="text-align"] {
text-align: var(--text-align, inherit) !important;
}
&[style*="color"] {
color: var(--color, inherit) !important;
}
&[style*="font-size"] {
font-size: var(--font-size, inherit) !important;
}
&[style*="font-weight"] {
font-weight: var(--font-weight, inherit) !important;
}
&[style*="font-style"] {
font-style: var(--font-style, inherit) !important;
}
&[style*="text-decoration"] {
text-decoration: var(--text-decoration, inherit) !important;
}
&[style*="margin"] {
margin: var(--margin, inherit) !important;
}
&[style*="padding"] {
padding: var(--padding, inherit) !important;
}
}
/* 直接支持text-align属性 */
[align] {
text-align: attr(align) !important;
}
/* 确保content-html类的样式 */
.content-html {
& > * {
margin: 10px 0;
}
& > h1,
& > h2,
& > h3,
& > h4,
& > h5,
& > h6 {
margin: 20px 0 10px 0;
}
& > p {
margin: 10px 0;
}
/* 支持居中样式 */
& > p[style*="text-align: center"],
& > p[align="center"] {
text-align: center !important;
}
/* 图片居中 */
& > p[style*="text-align: center"] img,
& > p[align="center"] img {
display: inline-block;
margin: 10px auto;
}
}
.no-content {
text-align: center;
padding: 40px 0;
}
}
}
</style>
<template>
<el-drawer v-model="visible" title="文章预览" size="60%">
<div class="article-preview">
<div class="article-header">
<h1 class="article-title">{{ model?.title || "无标题" }}</h1>
<div class="article-meta">
<span class="meta-item">
<i class="el-icon-price-tag"></i>
<el-tag type="primary">{{ getCategoryName(model?.cate) }}</el-tag>
</span>
<span class="meta-item">
<i class="el-icon-user"></i>
作者:{{ model?.author }}
</span>
<span class="meta-item">
<i class="el-icon-view"></i>
发布人:{{ model?.publisher || "暂未发布" }}
</span>
<span class="meta-item">
<i class="el-icon-view"></i>
阅读量:{{ model?.views || 0 }}
</span>
<span class="meta-item">
<i class="el-icon-view"></i>
收藏量:{{ model?.likes || 0 }}
</span>
<span class="meta-item" v-if="model?.publish_time">
<i class="el-icon-time"></i>
{{ formatDate(model.publish_time) }}
</span>
<span class="meta-item">
<i class="el-icon-view"></i>
创建日期:{{ model?.create_time }}
</span>
</div>
</div>
<div class="article-content">
<div
v-if="model?.content"
v-html="model.content"
class="content-html"
></div>
<div v-else class="no-content">
<el-empty description="暂无内容"></el-empty>
</div>
</div>
</div>
</el-drawer>
</template>
<script lang="ts" setup>
import { ref, watch, onMounted } from "vue";
import { listCategories } from "@/api/article";
import { ElMessage, ElMessageBox } from "element-plus";
const categoryOptions = ref([]);
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
model: {
type: Object,
default: () => null,
},
});
// 获取分类列表
const fetchCategories = async () => {
try {
const res = await listCategories({ page: 1, limit: 1000 });
if (res.code === 200) {
categoryOptions.value = Array.isArray(res.data)
? res.data
: Array.isArray(res.data?.list)
? res.data.list
: [];
} else {
ElMessage.error(res?.msg || "获取分类列表失败");
}
} catch (error) {
ElMessage.error("获取分类列表失败");
}
};
// 根据分类ID获取分类名称
const getCategoryName = (cate) => {
if (!cate && cate !== 0) return "无分类";
// 确保比较时类型一致
const cateId = Number(cate);
// 传入的不是数字(已经是分类名),直接展示
if (Number.isNaN(cateId)) return String(cate);
const category = categoryOptions.value.find(
(item) => Number(item.id) === cateId,
);
return category?.name || "无分类";
};
const emit = defineEmits(["update:modelValue"]);
const visible = ref(false);
// 监听对话框显示状态
watch(
() => props.modelValue,
(newVal) => {
visible.value = newVal;
},
);
// 监听visible变化,同步给父组件
watch(visible, (newVal) => {
emit("update:modelValue", newVal);
});
onMounted(() => {
fetchCategories();
});
</script>
<style lang="less" scoped>
.article-preview {
padding: 20px;
.article-header {
border-bottom: 1px solid #ebeef5;
padding-bottom: 20px;
margin-bottom: 20px;
.article-title {
margin: 0 0 15px 0;
font-size: 24px;
font-weight: 600;
color: #303133;
line-height: 1.4;
}
.article-meta {
display: flex;
flex-wrap: wrap;
gap: 15px;
color: #909399;
font-size: 14px;
.meta-item {
display: flex;
align-items: center;
gap: 5px;
i {
font-size: 16px;
}
}
}
}
.article-content {
/* 确保article-content直接子元素的样式 */
& > div {
line-height: 1.8;
color: #606266;
padding: 20px 0;
}
/* 支持所有子元素的内联样式 */
* {
box-sizing: border-box;
}
/* 段落样式 */
p {
margin: 10px 0;
line-height: 1.8;
}
/* 标题样式 */
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 20px 0 10px 0;
font-weight: 600;
}
h1 {
font-size: 28px;
}
h2 {
font-size: 24px;
}
h3 {
font-size: 20px;
}
h4 {
font-size: 18px;
}
h5 {
font-size: 16px;
}
h6 {
font-size: 14px;
}
/* 列表样式 */
ul,
ol {
margin: 10px 0;
padding-left: 30px;
}
/* 引用样式 */
blockquote {
border-left: 4px solid #ebeef5;
padding-left: 15px;
margin: 15px 0;
color: #909399;
font-style: italic;
}
/* 代码样式 */
:deep(code) {
border-radius: 4px;
padding: 2px 6px;
font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace;
font-size: 13px;
color: #f06b6b;
}
/* 代码块样式 */
:deep(pre) {
background: linear-gradient(135deg, #1e1e2e 0%, #2d2d3f 100%);
border-radius: 12px;
padding: 10px;
overflow-x: auto;
margin: 20px 0;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
/* 图片样式 */
img {
max-width: 100%;
height: auto;
border-radius: 4px;
margin: 10px 0;
}
/* 表格样式 */
table {
width: 100%;
border-collapse: collapse;
margin: 15px 0;
th,
td {
border: 1px solid #ebeef5;
padding: 8px 12px;
text-align: left;
}
th {
background-color: #fafafa;
font-weight: 600;
}
}
/* 确保内联样式能够生效 */
[style] {
/* 允许内联样式正常工作 */
all: unset;
/* 重新应用基本样式 */
line-height: 1.8;
color: #606266;
/* 允许特定内联样式覆盖 */
&[style*="text-align"] {
text-align: var(--text-align, inherit) !important;
}
&[style*="color"] {
color: var(--color, inherit) !important;
}
&[style*="font-size"] {
font-size: var(--font-size, inherit) !important;
}
&[style*="font-weight"] {
font-weight: var(--font-weight, inherit) !important;
}
&[style*="font-style"] {
font-style: var(--font-style, inherit) !important;
}
&[style*="text-decoration"] {
text-decoration: var(--text-decoration, inherit) !important;
}
&[style*="margin"] {
margin: var(--margin, inherit) !important;
}
&[style*="padding"] {
padding: var(--padding, inherit) !important;
}
}
/* 直接支持text-align属性 */
[align] {
text-align: attr(align) !important;
}
/* 确保content-html类的样式 */
.content-html {
& > * {
margin: 10px 0;
}
& > h1,
& > h2,
& > h3,
& > h4,
& > h5,
& > h6 {
margin: 20px 0 10px 0;
}
& > p {
margin: 10px 0;
}
/* 支持居中样式 */
& > p[style*="text-align: center"],
& > p[align="center"] {
text-align: center !important;
}
/* 图片居中 */
& > p[style*="text-align: center"] img,
& > p[align="center"] img {
display: inline-block;
margin: 10px auto;
}
}
.no-content {
text-align: center;
padding: 40px 0;
}
}
}
</style>
@@ -1,278 +1,278 @@
<template>
<div class="category-node-wrapper">
<div
class="category-item"
:class="{ 'child-item': level > 0, disabled: !item.status }"
>
<div
class="category-main"
:style="{ paddingLeft: (level * 24 + 20) + 'px' }"
@click="handleRowClick"
>
<div class="expand-btn" v-if="item.children && item.children.length > 0">
<el-button
type="text"
size="small"
@click="toggleExpand"
class="expand-button"
>
<el-icon>
<component :is="item.expanded ? 'ArrowDown' : 'ArrowRight'" />
</el-icon>
</el-button>
</div>
<div class="expand-spacer" v-else></div>
<div class="category-info">
<div class="color-dot" :style="{ backgroundColor: item.color }"></div>
<div class="category-icon">
<el-icon v-if="item.icon"><component :is="item.icon" /></el-icon>
<el-icon v-else class="default-icon"><Document /></el-icon>
</div>
<div class="category-text">
<div class="category-title">
<span class="name">{{ item.label }}</span>
<el-tag
:type="item.status ? 'success' : 'danger'"
size="small"
class="status-tag"
>
{{ item.status ? "启用" : "禁用" }}
</el-tag>
</div>
<div class="category-desc" v-if="item.remark">{{ item.remark }}</div>
</div>
</div>
<div class="category-actions">
<el-button-group size="small">
<el-button type="text" title="编辑" @click.stop="$emit('edit', item)">
<el-icon><Edit /></el-icon>
</el-button>
<el-button type="text" title="添加子分类" @click.stop="$emit('add-child', item)">
<el-icon><Plus /></el-icon>
</el-button>
<el-button
v-if="item.tid !== 0"
type="text"
class="danger"
title="删除"
@click.stop="$emit('delete', item)"
>
<el-icon><Delete /></el-icon>
</el-button>
<el-tag v-else type="info" size="small" title="全局分类不可删除">
全局
</el-tag>
</el-button-group>
</div>
</div>
</div>
<div
v-if="item.children && item.children.length > 0 && item.expanded"
class="category-children"
>
<category-node
v-for="child in item.children"
:key="child.id"
:item="child"
:level="level + 1"
@edit="$emit('edit', $event)"
@add-child="$emit('add-child', $event)"
@delete="$emit('delete', $event)"
/>
</div>
</div>
</template>
<script setup>
import { ArrowDown, ArrowRight, Edit, Plus, Delete, Document } from "@element-plus/icons-vue";
const props = defineProps({
item: { type: Object, required: true },
level: { type: Number, default: 0 }
});
const emit = defineEmits(['edit', 'add-child', 'delete']);
const toggleExpand = () => {
props.item.expanded = !props.item.expanded;
};
const handleRowClick = () => {
if (props.item.children && props.item.children.length > 0) {
toggleExpand();
}
};
</script>
<style lang="less" scoped>
.category-node-wrapper {
.category-item {
border-bottom: 1px solid var(--el-border-color-lighter);
transition: all 0.2s;
&:last-child {
border-bottom: none;
}
&.disabled {
.category-main {
opacity: 0.6;
background-color: var(--el-fill-color-lightest);
}
}
&.child-item {
.category-main {
background-color: rgba(245, 247, 250, 0.2);
}
}
.category-main {
display: flex;
align-items: center;
padding: 14px 24px;
transition: background-color 0.2s;
position: relative;
cursor: pointer;
&:hover {
background-color: var(--el-fill-color-light) !important;
}
.expand-btn,
.expand-spacer {
width: 28px;
flex-shrink: 0;
}
.expand-btn {
.expand-button {
padding: 4px;
color: var(--el-text-color-placeholder);
&:hover {
color: var(--el-color-primary);
}
}
}
.category-info {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
.color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 12px;
flex-shrink: 0;
}
.category-icon {
width: 32px;
height: 32px;
border-radius: 6px;
background-color: var(--el-fill-color-light);
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
flex-shrink: 0;
.el-icon {
font-size: 16px;
color: var(--el-text-color-regular);
}
}
.category-text {
flex: 1;
min-width: 0;
.category-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 2px;
.name {
font-weight: 600;
font-size: 14px;
color: var(--el-text-color-primary);
}
.status-tag {
font-size: 10px;
height: 20px;
padding: 0 6px;
}
}
.category-desc {
color: var(--el-text-color-placeholder);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.category-actions {
flex-shrink: 0;
margin-left: 16px;
opacity: 0.4;
transition: opacity 0.2s;
.el-button-group .el-button {
padding: 8px;
border-radius: 4px;
margin-left: 4px;
&.danger:hover {
color: var(--el-color-danger);
}
&:hover {
color: var(--el-color-primary);
}
}
}
&:hover .category-actions {
opacity: 1;
}
}
}
.category-children {
.category-node-wrapper {
margin-top: 0;
}
}
}
@media (max-width: 768px) {
.category-node-wrapper {
.category-item {
.category-main {
padding: 12px !important;
.category-actions {
opacity: 1;
}
.category-desc {
display: none;
}
}
}
}
}
<template>
<div class="category-node-wrapper">
<div
class="category-item"
:class="{ 'child-item': level > 0, disabled: !item.status }"
>
<div
class="category-main"
:style="{ paddingLeft: (level * 24 + 20) + 'px' }"
@click="handleRowClick"
>
<div class="expand-btn" v-if="item.children && item.children.length > 0">
<el-button
type="text"
size="small"
@click="toggleExpand"
class="expand-button"
>
<el-icon>
<component :is="item.expanded ? 'ArrowDown' : 'ArrowRight'" />
</el-icon>
</el-button>
</div>
<div class="expand-spacer" v-else></div>
<div class="category-info">
<div class="color-dot" :style="{ backgroundColor: item.color }"></div>
<div class="category-icon">
<el-icon v-if="item.icon"><component :is="item.icon" /></el-icon>
<el-icon v-else class="default-icon"><Document /></el-icon>
</div>
<div class="category-text">
<div class="category-title">
<span class="name">{{ item.label }}</span>
<el-tag
:type="item.status ? 'success' : 'danger'"
size="small"
class="status-tag"
>
{{ item.status ? "启用" : "禁用" }}
</el-tag>
</div>
<div class="category-desc" v-if="item.remark">{{ item.remark }}</div>
</div>
</div>
<div class="category-actions">
<el-button-group size="small">
<el-button type="text" title="编辑" @click.stop="$emit('edit', item)">
<el-icon><Edit /></el-icon>
</el-button>
<el-button type="text" title="添加子分类" @click.stop="$emit('add-child', item)">
<el-icon><Plus /></el-icon>
</el-button>
<el-button
v-if="item.tid !== 0"
type="text"
class="danger"
title="删除"
@click.stop="$emit('delete', item)"
>
<el-icon><Delete /></el-icon>
</el-button>
<el-tag v-else type="info" size="small" title="全局分类不可删除">
全局
</el-tag>
</el-button-group>
</div>
</div>
</div>
<div
v-if="item.children && item.children.length > 0 && item.expanded"
class="category-children"
>
<category-node
v-for="child in item.children"
:key="child.id"
:item="child"
:level="level + 1"
@edit="$emit('edit', $event)"
@add-child="$emit('add-child', $event)"
@delete="$emit('delete', $event)"
/>
</div>
</div>
</template>
<script setup>
import { ArrowDown, ArrowRight, Edit, Plus, Delete, Document } from "@element-plus/icons-vue";
const props = defineProps({
item: { type: Object, required: true },
level: { type: Number, default: 0 }
});
const emit = defineEmits(['edit', 'add-child', 'delete']);
const toggleExpand = () => {
props.item.expanded = !props.item.expanded;
};
const handleRowClick = () => {
if (props.item.children && props.item.children.length > 0) {
toggleExpand();
}
};
</script>
<style lang="less" scoped>
.category-node-wrapper {
.category-item {
border-bottom: 1px solid var(--el-border-color-lighter);
transition: all 0.2s;
&:last-child {
border-bottom: none;
}
&.disabled {
.category-main {
opacity: 0.6;
background-color: var(--el-fill-color-lightest);
}
}
&.child-item {
.category-main {
background-color: rgba(245, 247, 250, 0.2);
}
}
.category-main {
display: flex;
align-items: center;
padding: 14px 24px;
transition: background-color 0.2s;
position: relative;
cursor: pointer;
&:hover {
background-color: var(--el-fill-color-light) !important;
}
.expand-btn,
.expand-spacer {
width: 28px;
flex-shrink: 0;
}
.expand-btn {
.expand-button {
padding: 4px;
color: var(--el-text-color-placeholder);
&:hover {
color: var(--el-color-primary);
}
}
}
.category-info {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
.color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 12px;
flex-shrink: 0;
}
.category-icon {
width: 32px;
height: 32px;
border-radius: 6px;
background-color: var(--el-fill-color-light);
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
flex-shrink: 0;
.el-icon {
font-size: 16px;
color: var(--el-text-color-regular);
}
}
.category-text {
flex: 1;
min-width: 0;
.category-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 2px;
.name {
font-weight: 600;
font-size: 14px;
color: var(--el-text-color-primary);
}
.status-tag {
font-size: 10px;
height: 20px;
padding: 0 6px;
}
}
.category-desc {
color: var(--el-text-color-placeholder);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.category-actions {
flex-shrink: 0;
margin-left: 16px;
opacity: 0.4;
transition: opacity 0.2s;
.el-button-group .el-button {
padding: 8px;
border-radius: 4px;
margin-left: 4px;
&.danger:hover {
color: var(--el-color-danger);
}
&:hover {
color: var(--el-color-primary);
}
}
}
&:hover .category-actions {
opacity: 1;
}
}
}
.category-children {
.category-node-wrapper {
margin-top: 0;
}
}
}
@media (max-width: 768px) {
.category-node-wrapper {
.category-item {
.category-main {
padding: 12px !important;
.category-actions {
opacity: 1;
}
.category-desc {
display: none;
}
}
}
}
}
</style>
@@ -1,324 +1,324 @@
<template>
<el-dialog v-model="visibleInternal" :title="dialogTitle" width="600px" :close-on-click-modal="false"
@close="closeDialog">
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
<el-form-item label="分类名称" prop="label">
<el-input v-model="formData.label" placeholder="请输入分类名称" clearable />
</el-form-item>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="父级分类" prop="parentId">
<el-tree-select v-model="formData.parentId" :data="treeData"
:props="{ label: 'label', value: 'id', children: 'children' }" placeholder="选择父级分类(可选)" clearable
filterable check-strictly style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" :max="999" controls-position="right"
style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="默认图片" prop="image">
<div class="uploads">
<el-upload v-model:file-list="fileList" :auto-upload="false" :before-upload="beforeImgUpload" list-type="picture-card" :limit="1" :on-change="handleUploadChange">
<el-icon>
<Plus />
</el-icon>
<template #file="{ file }">
<div>
<img class="el-upload-list__item-thumbnail" :src="file.url" alt="" />
<span class="el-upload-list__item-actions">
<span class="el-upload-list__item-preview" @click="handlePictureCardPreview(file)">
<el-icon>
<ZoomIn />
</el-icon>
</span>
<span class="el-upload-list__item-delete" @click="handleRemove(file)">
<el-icon>
<Delete />
</el-icon>
</span>
</span>
</div>
</template>
</el-upload>
<el-dialog v-model="dialogVisible">
<img w-full :src="dialogImageUrl" alt="Preview Image" />
</el-dialog>
<div class="upload-tip">
<span>建议尺寸:250px × 140px</span>
</div>
</div>
</el-form-item>
<el-form-item label="分类描述" prop="remark">
<el-input v-model="formData.remark" type="textarea" :rows="4" placeholder="请输入分类描述..." maxlength="200"
show-word-limit />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="closeDialog">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
{{ isEdit ? '更新' : '创建' }}
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { Plus, ZoomIn, Delete } from '@element-plus/icons-vue'
import { ElMessage, ElUpload } from 'element-plus'
import { createCategory, editCategory, listCategories } from '@/api/article'
import { uploadFile } from '@/api/file.js'
// Props
const props = defineProps({
modelValue: { type: Boolean, default: false },
category: { type: Object as () => any | null, default: null }, // 传入的分类对象,null 表示新增
})
// Emits
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved'): void
}>()
// 可见性同步
const visibleInternal = ref(props.modelValue)
watch(() => props.modelValue, v => (visibleInternal.value = v))
watch(visibleInternal, v => {
emit('update:modelValue', v)
if (v) loadTreeData()
})
// 数据加载:父级分类树
const treeData = ref<any[]>([])
watch(treeData, () => {
// 触发tree-select重新渲染已选 label
formData.parentId = formData.parentId ?? 0
})
async function loadTreeData() {
const res = await listCategories({ page: 1, pageSize: 1000 })
if (res.code === 200) {
const list = res.data?.records || res.data || []
treeData.value = buildTree(list)
}
}
// 构建树形
function buildTree(data: any[]) {
const map = new Map()
const roots: any[] = []
data.forEach((item) => {
map.set(item.id, { ...item, label: item.name ?? item.label, children: [] })
})
map.forEach((item: any) => {
if (item.cid && map.has(item.cid)) {
map.get(item.cid).children.push(item)
} else {
roots.push(item)
}
})
return [{ id: 0, label: '顶级', children: roots }]
}
// 表单
const formRef = ref()
const formData = reactive({
id: null as number | null,
label: '',
image: '',
remark: '',
sort: 0,
status: 1,
parentId: 0 as number,
})
function resetForm() {
if (formRef.value) formRef.value.clearValidate()
Object.assign(formData, {
id: null,
label: '',
image: '',
remark: '',
sort: 0,
status: 1,
parentId: 0,
})
}
// 当传入 category 变化时同步
watch(
() => props.category,
(val) => {
if (val) {
Object.assign(formData, {
id: val.id,
label: val.label,
image: val.image,
remark: val.remark,
sort: val.sort,
status: val.status,
parentId: val.parentId ?? val.cid ?? 0,
})
} else {
resetForm()
}
},
{ immediate: true }
)
// 上传相关
const fileList = ref<any[]>([])
const dialogVisible = ref(false)
const dialogImageUrl = ref('')
function beforeImgUpload(file: File) {
const isImage = file.type.startsWith('image/')
const isLt10M = file.size / 1024 / 1024 < 10
if (!isImage) ElMessage.error('仅支持图片格式')
if (!isLt10M) ElMessage.error('图片大小不能超过10MB')
return isImage && isLt10M
}
function handleRemove(file: any) {
fileList.value = []
formData.image = ''
}
async function handleUploadChange(file: any) {
if (file.raw) {
const isImage = file.raw.type.startsWith('image/')
const isLt10M = file.raw.size / 1024 / 1024 < 10
if (!isImage || !isLt10M) {
fileList.value = []
ElMessage.error(isImage ? '图片大小不能超过10MB' : '仅支持图片格式')
return
}
}
}
function handlePictureCardPreview(file: any) {
dialogImageUrl.value = file.url
dialogVisible.value = true
}
// 校验
const formRules = {
label: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{ min: 2, max: 50, message: '分类名称长度在 2 到 50 个字符', trigger: 'blur' },
],
remark: [{ max: 200, message: '描述不能超过200个字符', trigger: 'blur' }],
}
const isEdit = computed(() => !!formData.id)
// 预览对话框
const dialogTitle = computed(() => (isEdit.value ? '编辑分类' : '新增分类'))
const submitLoading = ref(false)
async function handleSubmit() {
formRef.value.validate(async (valid: boolean) => {
if (!valid) return
try {
submitLoading.value = true
let imageUrl = formData.image
if (fileList.value.length > 0 && fileList.value[0].raw) {
const uploadFormData = new FormData()
uploadFormData.append('file', fileList.value[0].raw)
uploadFormData.append('cate', 'category')
const uploadRes = await uploadFile(uploadFormData)
if (uploadRes?.data?.url) {
imageUrl = uploadRes.data.url
}
}
const payload = {
name: formData.label,
image: imageUrl,
desc: formData.remark,
sort: formData.sort,
status: formData.status,
cid: formData.parentId,
}
if (isEdit.value) {
await editCategory(formData.id, payload)
ElMessage.success('更新成功')
} else {
await createCategory(payload)
ElMessage.success('创建成功')
}
emit('saved')
closeDialog()
} catch (err) {
console.error(err)
ElMessage.error(isEdit.value ? '更新失败' : '创建失败')
} finally {
submitLoading.value = false
}
})
}
function closeDialog() {
visibleInternal.value = false
resetForm()
}
</script>
<style scoped>
/* 如果需要额外样式,可在此处编写 */
.avatar-uploader {
.el-upload {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
}
.avatar {
width: 80px;
height: 80px;
display: block;
border-radius: 6px;
}
.avatar-uploader-icon {
font-size: 32px;
color: #8c939d;
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
}
}
.uploads{
display: flex;
flex-direction: column;
}
.upload-tip {
font-size: 12px;
color: #999;
}
</style>
<template>
<el-dialog v-model="visibleInternal" :title="dialogTitle" width="600px" :close-on-click-modal="false"
@close="closeDialog">
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
<el-form-item label="分类名称" prop="label">
<el-input v-model="formData.label" placeholder="请输入分类名称" clearable />
</el-form-item>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="父级分类" prop="parentId">
<el-tree-select v-model="formData.parentId" :data="treeData"
:props="{ label: 'label', value: 'id', children: 'children' }" placeholder="选择父级分类(可选)" clearable
filterable check-strictly style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" :max="999" controls-position="right"
style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="默认图片" prop="image">
<div class="uploads">
<el-upload v-model:file-list="fileList" :auto-upload="false" :before-upload="beforeImgUpload" list-type="picture-card" :limit="1" :on-change="handleUploadChange">
<el-icon>
<Plus />
</el-icon>
<template #file="{ file }">
<div>
<img class="el-upload-list__item-thumbnail" :src="file.url" alt="" />
<span class="el-upload-list__item-actions">
<span class="el-upload-list__item-preview" @click="handlePictureCardPreview(file)">
<el-icon>
<ZoomIn />
</el-icon>
</span>
<span class="el-upload-list__item-delete" @click="handleRemove(file)">
<el-icon>
<Delete />
</el-icon>
</span>
</span>
</div>
</template>
</el-upload>
<el-dialog v-model="dialogVisible">
<img w-full :src="dialogImageUrl" alt="Preview Image" />
</el-dialog>
<div class="upload-tip">
<span>建议尺寸:250px × 140px</span>
</div>
</div>
</el-form-item>
<el-form-item label="分类描述" prop="remark">
<el-input v-model="formData.remark" type="textarea" :rows="4" placeholder="请输入分类描述..." maxlength="200"
show-word-limit />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="closeDialog">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
{{ isEdit ? '更新' : '创建' }}
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { Plus, ZoomIn, Delete } from '@element-plus/icons-vue'
import { ElMessage, ElUpload } from 'element-plus'
import { createCategory, editCategory, listCategories } from '@/api/article'
import { uploadFile } from '@/api/file.js'
// Props
const props = defineProps({
modelValue: { type: Boolean, default: false },
category: { type: Object as () => any | null, default: null }, // 传入的分类对象,null 表示新增
})
// Emits
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved'): void
}>()
// 可见性同步
const visibleInternal = ref(props.modelValue)
watch(() => props.modelValue, v => (visibleInternal.value = v))
watch(visibleInternal, v => {
emit('update:modelValue', v)
if (v) loadTreeData()
})
// 数据加载:父级分类树
const treeData = ref<any[]>([])
watch(treeData, () => {
// 触发tree-select重新渲染已选 label
formData.parentId = formData.parentId ?? 0
})
async function loadTreeData() {
const res = await listCategories({ page: 1, pageSize: 1000 })
if (res.code === 200) {
const list = res.data?.records || res.data || []
treeData.value = buildTree(list)
}
}
// 构建树形
function buildTree(data: any[]) {
const map = new Map()
const roots: any[] = []
data.forEach((item) => {
map.set(item.id, { ...item, label: item.name ?? item.label, children: [] })
})
map.forEach((item: any) => {
if (item.cid && map.has(item.cid)) {
map.get(item.cid).children.push(item)
} else {
roots.push(item)
}
})
return [{ id: 0, label: '顶级', children: roots }]
}
// 表单
const formRef = ref()
const formData = reactive({
id: null as number | null,
label: '',
image: '',
remark: '',
sort: 0,
status: 1,
parentId: 0 as number,
})
function resetForm() {
if (formRef.value) formRef.value.clearValidate()
Object.assign(formData, {
id: null,
label: '',
image: '',
remark: '',
sort: 0,
status: 1,
parentId: 0,
})
}
// 当传入 category 变化时同步
watch(
() => props.category,
(val) => {
if (val) {
Object.assign(formData, {
id: val.id,
label: val.label,
image: val.image,
remark: val.remark,
sort: val.sort,
status: val.status,
parentId: val.parentId ?? val.cid ?? 0,
})
} else {
resetForm()
}
},
{ immediate: true }
)
// 上传相关
const fileList = ref<any[]>([])
const dialogVisible = ref(false)
const dialogImageUrl = ref('')
function beforeImgUpload(file: File) {
const isImage = file.type.startsWith('image/')
const isLt10M = file.size / 1024 / 1024 < 10
if (!isImage) ElMessage.error('仅支持图片格式')
if (!isLt10M) ElMessage.error('图片大小不能超过10MB')
return isImage && isLt10M
}
function handleRemove(file: any) {
fileList.value = []
formData.image = ''
}
async function handleUploadChange(file: any) {
if (file.raw) {
const isImage = file.raw.type.startsWith('image/')
const isLt10M = file.raw.size / 1024 / 1024 < 10
if (!isImage || !isLt10M) {
fileList.value = []
ElMessage.error(isImage ? '图片大小不能超过10MB' : '仅支持图片格式')
return
}
}
}
function handlePictureCardPreview(file: any) {
dialogImageUrl.value = file.url
dialogVisible.value = true
}
// 校验
const formRules = {
label: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{ min: 2, max: 50, message: '分类名称长度在 2 到 50 个字符', trigger: 'blur' },
],
remark: [{ max: 200, message: '描述不能超过200个字符', trigger: 'blur' }],
}
const isEdit = computed(() => !!formData.id)
// 预览对话框
const dialogTitle = computed(() => (isEdit.value ? '编辑分类' : '新增分类'))
const submitLoading = ref(false)
async function handleSubmit() {
formRef.value.validate(async (valid: boolean) => {
if (!valid) return
try {
submitLoading.value = true
let imageUrl = formData.image
if (fileList.value.length > 0 && fileList.value[0].raw) {
const uploadFormData = new FormData()
uploadFormData.append('file', fileList.value[0].raw)
uploadFormData.append('cate', 'category')
const uploadRes = await uploadFile(uploadFormData)
if (uploadRes?.data?.url) {
imageUrl = uploadRes.data.url
}
}
const payload = {
name: formData.label,
image: imageUrl,
desc: formData.remark,
sort: formData.sort,
status: formData.status,
cid: formData.parentId,
}
if (isEdit.value) {
await editCategory(formData.id, payload)
ElMessage.success('更新成功')
} else {
await createCategory(payload)
ElMessage.success('创建成功')
}
emit('saved')
closeDialog()
} catch (err) {
console.error(err)
ElMessage.error(isEdit.value ? '更新失败' : '创建失败')
} finally {
submitLoading.value = false
}
})
}
function closeDialog() {
visibleInternal.value = false
resetForm()
}
</script>
<style scoped>
/* 如果需要额外样式,可在此处编写 */
.avatar-uploader {
.el-upload {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
}
.avatar {
width: 80px;
height: 80px;
display: block;
border-radius: 6px;
}
.avatar-uploader-icon {
font-size: 32px;
color: #8c939d;
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
}
}
.uploads{
display: flex;
flex-direction: column;
}
.upload-tip {
font-size: 12px;
color: #999;
}
</style>
@@ -1,498 +1,498 @@
<template>
<div class="category-manager">
<!-- 页面头部 -->
<div class="page-header">
<div class="page-title">
<h3>文章分类</h3>
<p>共 {{ totalCount }} 个分类</p>
</div>
<div class="page-actions">
<el-button type="primary" :icon="Plus" @click="handleAdd">
新增分类
</el-button>
<el-button :icon="Refresh" @click="handleRefresh">刷新</el-button>
</div>
</div>
<div class="category-list" v-loading="loading">
<div v-if="filteredCategories.length === 0" class="empty-state">
<el-empty description="暂无文章分类" />
</div>
<div v-else class="category-tree">
<category-node
v-for="category in filteredCategories"
:key="category.id"
:item="category"
:level="0"
@edit="handleEdit"
@add-child="handleAddChild"
@delete="handleDelete"
/>
</div>
</div>
<edit-cate
v-model="dialogVisible"
:category="currentEdit"
@saved="fetchCategories"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import {
Plus,
Edit,
Delete,
Check,
Close,
Search,
Refresh,
Document,
Picture,
ArrowDown,
ArrowRight,
} from "@element-plus/icons-vue";
import { allCategories, deleteCategory } from "@/api/article";
import EditCate from "@/views/apps/cms/articles/components/edit-cate.vue";
import CategoryNode from "./components/CategoryNode.vue";
// 颜色预设
// 响应式数据
const loading = ref(false);
const dialogVisible = ref(false);
const currentEdit = ref(null);
const searchText = ref("");
const categories = ref([]);
// 新增分类
const addCategory = ref(null);
const handleAdd = () => {
addCategory.value = { parentId: 0 };
dialogVisible.value = true;
}
// 计算属性
const totalCount = computed(() => categories.value.length);
const filteredCategories = computed(() => {
if (!searchText.value) {
return categories.value;
}
const search = searchText.value.toLowerCase();
return categories.value.filter(
(category) =>
category.label.toLowerCase().includes(search) ||
(category.remark && category.remark.toLowerCase().includes(search)),
);
});
// 工具函数
function buildTree(data) {
// 1. 标准化数据字段
const transformed = data.map((item) => ({
...item,
label: item.name ?? item.label ?? "",
remark: item.desc ?? item.remark ?? "",
parentId: item.parentId ?? item.cid ?? 0,
children: [],
expanded: true, // 默认展开所有级
}));
const map = new Map();
transformed.forEach((item) => map.set(item.id, item));
const roots = [];
transformed.forEach((item) => {
const parent = map.get(item.parentId);
if (parent) {
parent.children.push(item);
} else {
// 如果没有父节点,或者是顶级节点 (parentId 为 0)
roots.push(item);
}
});
return roots;
}
// 事件处理
function handleCreate() {
currentEdit.value = null;
dialogVisible.value = true;
}
function handleEdit(category) {
currentEdit.value = category;
dialogVisible.value = true;
}
function handleAddChild(parent) {
currentEdit.value = { parentId: parent.id };
dialogVisible.value = true;
}
function handleRefresh() {
searchText.value = "";
fetchCategories();
}
async function handleSearch() {
await fetchCategories();
}
function clearSearch() {
searchText.value = "";
fetchCategories();
}
function toggleExpand(category) {
category.expanded = !category.expanded;
}
function handleDelete(category) {
if (Number(category.tid) === 0 || category.is_global === true) {
ElMessage.warning("全局分类不可删除");
return;
}
ElMessageBox.confirm(`确定要删除分类\"${category.label}\"吗?`, "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}).then(() => {
deleteCategory(category.id)
.then(() => {
ElMessage.success("删除成功");
fetchCategories();
})
.catch((error) => {
console.error("删除分类失败:", error);
ElMessage.error("删除失败");
});
});
}
// API调用
async function fetchCategories() {
try {
loading.value = true;
const response = await allCategories({ keyword: searchText.value });
if (response.code === 200) {
const categoryList = response.data || [];
// 这里会递归生成三级、四级等
categories.value = buildTree(categoryList);
}
} catch (error) {
ElMessage.error("获取失败");
} finally {
loading.value = false;
}
}
// 生命周期
onMounted(() => {
fetchCategories();
});
</script>
<style lang="less" scoped>
.category-manager {
min-height: 100vh;
padding: 20px;
background-color: var(--el-bg-color-page);
// 页面头部样式
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
background: var(--el-bg-color);
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.page-title {
h3 {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 600;
}
p {
margin: 0;
color: #86909c;
font-size: 14px;
}
}
.page-actions {
display: flex;
gap: 12px;
.el-button {
border-radius: 6px;
font-weight: 500;
.el-icon {
margin-right: 6px;
}
}
}
}
// 搜索栏样式
.search-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
background: var(--el-bg-color);
padding: 16px 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.search {
display: flex;
gap: 12px;
}
.el-input {
border-radius: 6px;
}
.search-stats {
color: #86909c;
font-size: 14px;
}
}
// 分类列表容器
.category-list {
background: var(--el-bg-color);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
overflow: hidden;
.empty-state {
padding: 80px 40px;
.empty-icon {
color: #c9cdd4;
}
.el-button {
border-radius: 6px;
font-weight: 500;
}
}
// 递归树结构核心样式
.category-tree {
.category-item {
border-bottom: 1px solid var(--el-border-color-lighter);
transition: all 0.2s;
&:last-child {
border-bottom: none;
}
// 禁用状态
&.disabled {
.category-main {
opacity: 0.6;
background-color: var(--el-fill-color-lightest);
}
}
// 层级背景区分 (可选:让子级背景稍微深一点点)
&.child-item {
.category-main {
background-color: rgba(245, 247, 250, 0.2);
}
}
.category-main {
display: flex;
align-items: center;
padding: 14px 24px; // 左右 padding 保持,左侧缩进通过内联 style 控制
transition: background-color 0.2s;
position: relative;
&:hover {
background-color: var(--el-fill-color-light) !important;
}
// 展开收起按钮占位
.expand-btn,
.expand-spacer {
width: 28px;
flex-shrink: 0;
}
.expand-btn {
.expand-button {
padding: 4px;
color: var(--el-text-color-placeholder);
&:hover {
color: var(--el-color-primary);
}
}
}
// 分类具体内容
.category-info {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
.color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 12px;
flex-shrink: 0;
}
.category-icon {
width: 32px;
height: 32px;
border-radius: 6px;
background-color: var(--el-fill-color-light);
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
flex-shrink: 0;
.el-icon {
font-size: 16px;
color: var(--el-text-color-regular);
}
}
.category-text {
flex: 1;
min-width: 0;
.category-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 2px;
.name {
font-weight: 600;
font-size: 14px;
color: var(--el-text-color-primary);
}
.status-tag {
font-size: 10px;
height: 20px;
padding: 0 6px;
}
}
.category-desc {
color: var(--el-text-color-placeholder);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
// 操作按钮组
.category-actions {
flex-shrink: 0;
margin-left: 16px;
opacity: 0.4; // 默认低透明度,鼠标悬浮时高亮
transition: opacity 0.2s;
.el-button-group .el-button {
padding: 8px;
border-radius: 4px;
margin-left: 4px;
&.danger:hover {
color: var(--el-color-danger);
}
&:hover {
color: var(--el-color-primary);
}
}
}
// 鼠标悬停时显示按钮
&:hover .category-actions {
opacity: 1;
}
}
}
// 子分类容器样式
.category-children {
margin-left: 0;
.category-node-wrapper {
margin-top: 0;
}
}
}
}
}
// Element Plus 对话框深度样式美化
:deep(.el-dialog) {
border-radius: 12px;
.el-dialog__header {
padding: 20px 24px;
margin: 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.el-dialog__title {
font-size: 16px;
font-weight: 600;
}
.el-dialog__body {
padding: 24px;
}
.el-dialog__footer {
padding: 16px 24px 24px;
}
.el-form-item__label {
font-weight: 500;
}
.el-input__inner,
.el-textarea__inner {
border-radius: 6px;
}
}
// 移动端适配
@media (max-width: 768px) {
.category-manager {
padding: 12px;
.page-header {
flex-direction: column;
gap: 16px;
padding: 16px;
.page-actions {
width: 100%;
}
}
.search-bar {
flex-direction: column;
gap: 12px;
padding: 16px;
}
.category-list .category-tree .category-item .category-main {
padding: 12px !important; // 移动端取消大缩进,改用其他视觉暗示
.category-actions {
opacity: 1;
}
.category-desc {
display: none;
} // 隐藏描述节省空间
}
}
}
</style>
<template>
<div class="category-manager">
<!-- 页面头部 -->
<div class="page-header">
<div class="page-title">
<h3>文章分类</h3>
<p>共 {{ totalCount }} 个分类</p>
</div>
<div class="page-actions">
<el-button type="primary" :icon="Plus" @click="handleAdd">
新增分类
</el-button>
<el-button :icon="Refresh" @click="handleRefresh">刷新</el-button>
</div>
</div>
<div class="category-list" v-loading="loading">
<div v-if="filteredCategories.length === 0" class="empty-state">
<el-empty description="暂无文章分类" />
</div>
<div v-else class="category-tree">
<category-node
v-for="category in filteredCategories"
:key="category.id"
:item="category"
:level="0"
@edit="handleEdit"
@add-child="handleAddChild"
@delete="handleDelete"
/>
</div>
</div>
<edit-cate
v-model="dialogVisible"
:category="currentEdit"
@saved="fetchCategories"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import {
Plus,
Edit,
Delete,
Check,
Close,
Search,
Refresh,
Document,
Picture,
ArrowDown,
ArrowRight,
} from "@element-plus/icons-vue";
import { allCategories, deleteCategory } from "@/api/article";
import EditCate from "./components/edit.vue";
import CategoryNode from "./components/CategoryNode.vue";
// 颜色预设
// 响应式数据
const loading = ref(false);
const dialogVisible = ref(false);
const currentEdit = ref(null);
const searchText = ref("");
const categories = ref([]);
// 新增分类
const addCategory = ref(null);
const handleAdd = () => {
addCategory.value = { parentId: 0 };
dialogVisible.value = true;
}
// 计算属性
const totalCount = computed(() => categories.value.length);
const filteredCategories = computed(() => {
if (!searchText.value) {
return categories.value;
}
const search = searchText.value.toLowerCase();
return categories.value.filter(
(category) =>
category.label.toLowerCase().includes(search) ||
(category.remark && category.remark.toLowerCase().includes(search)),
);
});
// 工具函数
function buildTree(data) {
// 1. 标准化数据字段
const transformed = data.map((item) => ({
...item,
label: item.name ?? item.label ?? "",
remark: item.desc ?? item.remark ?? "",
parentId: item.parentId ?? item.cid ?? 0,
children: [],
expanded: true, // 默认展开所有级
}));
const map = new Map();
transformed.forEach((item) => map.set(item.id, item));
const roots = [];
transformed.forEach((item) => {
const parent = map.get(item.parentId);
if (parent) {
parent.children.push(item);
} else {
// 如果没有父节点,或者是顶级节点 (parentId 为 0)
roots.push(item);
}
});
return roots;
}
// 事件处理
function handleCreate() {
currentEdit.value = null;
dialogVisible.value = true;
}
function handleEdit(category) {
currentEdit.value = category;
dialogVisible.value = true;
}
function handleAddChild(parent) {
currentEdit.value = { parentId: parent.id };
dialogVisible.value = true;
}
function handleRefresh() {
searchText.value = "";
fetchCategories();
}
async function handleSearch() {
await fetchCategories();
}
function clearSearch() {
searchText.value = "";
fetchCategories();
}
function toggleExpand(category) {
category.expanded = !category.expanded;
}
function handleDelete(category) {
if (Number(category.tid) === 0 || category.is_global === true) {
ElMessage.warning("全局分类不可删除");
return;
}
ElMessageBox.confirm(`确定要删除分类\"${category.label}\"吗?`, "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}).then(() => {
deleteCategory(category.id)
.then(() => {
ElMessage.success("删除成功");
fetchCategories();
})
.catch((error) => {
console.error("删除分类失败:", error);
ElMessage.error("删除失败");
});
});
}
// API调用
async function fetchCategories() {
try {
loading.value = true;
const response = await allCategories({ keyword: searchText.value });
if (response.code === 200) {
const categoryList = response.data || [];
// 这里会递归生成三级、四级等
categories.value = buildTree(categoryList);
}
} catch (error) {
ElMessage.error("获取失败");
} finally {
loading.value = false;
}
}
// 生命周期
onMounted(() => {
fetchCategories();
});
</script>
<style lang="less" scoped>
.category-manager {
min-height: 100vh;
padding: 20px;
background-color: var(--el-bg-color-page);
// 页面头部样式
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
background: var(--el-bg-color);
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.page-title {
h3 {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 600;
}
p {
margin: 0;
color: #86909c;
font-size: 14px;
}
}
.page-actions {
display: flex;
gap: 12px;
.el-button {
border-radius: 6px;
font-weight: 500;
.el-icon {
margin-right: 6px;
}
}
}
}
// 搜索栏样式
.search-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
background: var(--el-bg-color);
padding: 16px 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.search {
display: flex;
gap: 12px;
}
.el-input {
border-radius: 6px;
}
.search-stats {
color: #86909c;
font-size: 14px;
}
}
// 分类列表容器
.category-list {
background: var(--el-bg-color);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
overflow: hidden;
.empty-state {
padding: 80px 40px;
.empty-icon {
color: #c9cdd4;
}
.el-button {
border-radius: 6px;
font-weight: 500;
}
}
// 递归树结构核心样式
.category-tree {
.category-item {
border-bottom: 1px solid var(--el-border-color-lighter);
transition: all 0.2s;
&:last-child {
border-bottom: none;
}
// 禁用状态
&.disabled {
.category-main {
opacity: 0.6;
background-color: var(--el-fill-color-lightest);
}
}
// 层级背景区分 (可选:让子级背景稍微深一点点)
&.child-item {
.category-main {
background-color: rgba(245, 247, 250, 0.2);
}
}
.category-main {
display: flex;
align-items: center;
padding: 14px 24px; // 左右 padding 保持,左侧缩进通过内联 style 控制
transition: background-color 0.2s;
position: relative;
&:hover {
background-color: var(--el-fill-color-light) !important;
}
// 展开收起按钮占位
.expand-btn,
.expand-spacer {
width: 28px;
flex-shrink: 0;
}
.expand-btn {
.expand-button {
padding: 4px;
color: var(--el-text-color-placeholder);
&:hover {
color: var(--el-color-primary);
}
}
}
// 分类具体内容
.category-info {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
.color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 12px;
flex-shrink: 0;
}
.category-icon {
width: 32px;
height: 32px;
border-radius: 6px;
background-color: var(--el-fill-color-light);
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
flex-shrink: 0;
.el-icon {
font-size: 16px;
color: var(--el-text-color-regular);
}
}
.category-text {
flex: 1;
min-width: 0;
.category-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 2px;
.name {
font-weight: 600;
font-size: 14px;
color: var(--el-text-color-primary);
}
.status-tag {
font-size: 10px;
height: 20px;
padding: 0 6px;
}
}
.category-desc {
color: var(--el-text-color-placeholder);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
// 操作按钮组
.category-actions {
flex-shrink: 0;
margin-left: 16px;
opacity: 0.4; // 默认低透明度,鼠标悬浮时高亮
transition: opacity 0.2s;
.el-button-group .el-button {
padding: 8px;
border-radius: 4px;
margin-left: 4px;
&.danger:hover {
color: var(--el-color-danger);
}
&:hover {
color: var(--el-color-primary);
}
}
}
// 鼠标悬停时显示按钮
&:hover .category-actions {
opacity: 1;
}
}
}
// 子分类容器样式
.category-children {
margin-left: 0;
.category-node-wrapper {
margin-top: 0;
}
}
}
}
}
// Element Plus 对话框深度样式美化
:deep(.el-dialog) {
border-radius: 12px;
.el-dialog__header {
padding: 20px 24px;
margin: 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.el-dialog__title {
font-size: 16px;
font-weight: 600;
}
.el-dialog__body {
padding: 24px;
}
.el-dialog__footer {
padding: 16px 24px 24px;
}
.el-form-item__label {
font-weight: 500;
}
.el-input__inner,
.el-textarea__inner {
border-radius: 6px;
}
}
// 移动端适配
@media (max-width: 768px) {
.category-manager {
padding: 12px;
.page-header {
flex-direction: column;
gap: 16px;
padding: 16px;
.page-actions {
width: 100%;
}
}
.search-bar {
flex-direction: column;
gap: 12px;
padding: 16px;
}
.category-list .category-tree .category-item .category-main {
padding: 12px !important; // 移动端取消大缩进,改用其他视觉暗示
.category-actions {
opacity: 1;
}
.category-desc {
display: none;
} // 隐藏描述节省空间
}
}
}
</style>
+11 -1
View File
@@ -56,6 +56,9 @@
class="menu-icon"
></i>
<span>{{ scope.row.title }}</span>
<el-tag v-if="scope.row.tenant_id === 0" size="small" type="warning">
系统默认
</el-tag>
</div>
</template>
</el-table-column>
@@ -110,7 +113,12 @@
<span>子菜单</span>
</el-button>
<el-button size="small" text @click="handleEditMenu(scope.row)">
<el-button
size="small"
text
@click="handleEditMenu(scope.row)"
:disabled="scope.row.tenant_id === 0"
>
<el-icon>
<Edit />
</el-icon>
@@ -122,6 +130,7 @@
text
type="danger"
@click="handleDeleteMenu(scope.row)"
:disabled="scope.row.tenant_id === 0"
>
<el-icon>
<Delete />
@@ -168,6 +177,7 @@ import MenuEdit from "./components/edit.vue";
// 定义菜单数据类型
interface Menu {
id: number;
tenant_id?: number; // 0 表示系统默认菜单(不可编辑/删除)
pid: number;
title: string;
type: number;
@@ -1,319 +1,321 @@
<template>
<el-dialog
v-model="visible"
:title="title"
width="600px"
destroy-on-close
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="产品名称" prop="title">
<el-input v-model="formData.title" placeholder="请输入产品名称" />
</el-form-item>
<el-form-item label="产品描述" prop="desc">
<el-input
v-model="formData.desc"
type="textarea"
:rows="5"
placeholder="请输入产品描述"
/>
</el-form-item>
<el-form-item label="产品内容" prop="content">
<div class="editor-container">
<UmoEditor v-model="formData.content" style="height: 450px;" />
</div>
</el-form-item>
<el-form-item label="产品图片" prop="thumb">
<div class="flex-direction">
<el-upload
class="image-uploader"
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleImageSuccess"
:on-error="handleImageError"
:before-upload="beforeImageUpload"
accept="image/*"
>
<img
v-if="formData.thumb"
:src="getImageUrl(formData.thumb)"
class="image-preview"
/>
<div v-else class="upload-placeholder">
<el-icon class="image-uploader-icon"><Plus /></el-icon>
<div class="el-upload__text">点击上传产品图片</div>
</div>
</el-upload>
<div
style="
margin-top: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
"
>
建议尺寸:200x200,支持 jpg、png、gif 格式,大小不超过 2MB
</div>
</div>
<el-button
v-if="formData.thumb"
size="small"
type="danger"
text
@click="handleRemoveImage"
style="margin-top: 8px"
>
删除产品图片
</el-button>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" :max="999" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="submitLoading"
>
确定
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from "vue";
import { ElMessage } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { addProducts, updateProducts } from "@/api/products";
// @ts-ignore
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
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 visible = ref(false);
const submitLoading = ref(false);
const formRef = ref(null);
// 上传配置
const uploadUrl = ref(API_BASE_URL + "/admin/uploadfiles");
const uploadHeaders = ref({
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
});
// 图片上传前校验
const beforeImageUpload = (file) => {
const isImage = file.type.startsWith("image/");
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isImage) {
ElMessage.error("请上传图片文件!");
return false;
}
if (!isLt2M) {
ElMessage.error("图片大小不能超过 2MB!");
return false;
}
return true;
};
// 图片上传成功
const handleImageSuccess = (response) => {
if (response.code === 200 || response.code === 201) {
formData.thumb = response.data.url || response.data.path;
ElMessage.success(
response.code === 201 ? "文件已存在,直接使用" : "图片上传成功",
);
} else {
ElMessage.error(response.msg || "图片上传失败");
}
};
// 图片上传失败
const handleImageError = () => {
ElMessage.error("图片上传失败,请重试");
};
// 删除图片
const handleRemoveImage = () => {
formData.thumb = "";
ElMessage.success("图片已删除");
};
// 获取图片完整URL(用于预览)
const getImageUrl = (imagePath) => {
if (!imagePath) return "";
if (imagePath.startsWith("http")) return imagePath;
return API_BASE_URL + imagePath;
};
const formData = reactive({
title: "",
desc: "",
thumb: "",
url: "",
sort: 0,
status: 1,
});
const formRules = {
title: [
{ required: true, message: "请输入产品名称", trigger: "blur" },
{ max: 100, message: "长度不超过100个字符", trigger: "blur" },
],
desc: [
{ required: true, message: "请输入产品描述", trigger: "blur" },
{ max: 200, message: "描述长度不超过200个字符", trigger: "blur" },
],
};
// 监听 modelValue 变化
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (val) {
resetForm();
if (props.isEdit && props.rowData) {
Object.assign(formData, props.rowData);
}
}
},
);
// 监听 visible 变化
watch(visible, (val) => {
emit("update:modelValue", val);
});
// 重置表单
const resetForm = () => {
formData.title = "";
formData.desc = "";
formData.thumb = "";
formData.url = "";
formData.sort = 0;
formData.status = 1;
};
// 关闭
const handleClose = () => {
visible.value = false;
};
// 提交
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true;
try {
let res;
if (props.isEdit) {
res = await updateProducts(props.rowData.id, formData);
} else {
res = await addProducts(formData);
}
if (res.code === 200) {
ElMessage.success(props.isEdit ? "更新成功" : "添加成功");
handleClose();
emit("success");
} else {
ElMessage.error(res.msg || "操作失败");
}
} catch (error) {
console.error("提交失败:", error);
ElMessage.error("操作失败");
} finally {
submitLoading.value = false;
}
}
});
};
</script>
<style scoped>
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* 图片上传样式 */
.image-uploader {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
width: 120px;
height: 120px;
}
.image-uploader:hover {
border-color: var(--el-color-primary);
}
.upload-placeholder {
width: 120px;
height: 120px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--el-text-color-secondary);
}
.image-uploader-icon {
font-size: 28px;
color: #8c939d;
margin-bottom: 8px;
}
.el-upload__text {
font-size: 12px;
}
.image-preview {
width: 120px;
height: 120px;
object-fit: contain;
display: block;
}
.flex-direction {
display: flex;
flex-direction: column;
align-items: flex-start;
}
</style>
<template>
<el-dialog
v-model="visible"
:title="title"
width="600px"
destroy-on-close
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="产品名称" prop="title">
<el-input v-model="formData.title" placeholder="请输入产品名称" />
</el-form-item>
<el-form-item label="产品描述" prop="desc">
<el-input
v-model="formData.desc"
type="textarea"
:rows="5"
placeholder="请输入产品描述"
/>
</el-form-item>
<el-form-item label="产品内容" prop="content">
<div class="editor-container">
<UmoEditor v-model="formData.content" style="height: 450px;" />
</div>
</el-form-item>
<el-form-item label="产品图片" prop="thumb">
<div class="flex-direction">
<el-upload
class="image-uploader"
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleImageSuccess"
:on-error="handleImageError"
:before-upload="beforeImageUpload"
accept="image/*"
>
<img
v-if="formData.thumb"
:src="getImageUrl(formData.thumb)"
class="image-preview"
/>
<div v-else class="upload-placeholder">
<el-icon class="image-uploader-icon"><Plus /></el-icon>
<div class="el-upload__text">点击上传产品图片</div>
</div>
</el-upload>
<div
style="
margin-top: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
"
>
建议尺寸:200x200,支持 jpg、png、gif 格式,大小不超过 2MB
</div>
</div>
<el-button
v-if="formData.thumb"
size="small"
type="danger"
text
@click="handleRemoveImage"
style="margin-top: 8px"
>
删除产品图片
</el-button>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" :max="999" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="submitLoading"
>
确定
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from "vue";
import { ElMessage } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { addProducts, updateProducts } from "@/api/products";
// @ts-ignore
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
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 visible = ref(false);
const submitLoading = ref(false);
const formRef = ref(null);
// 上传配置
const uploadUrl = ref(API_BASE_URL + "/admin/uploadfiles");
const uploadHeaders = ref({
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
});
// 图片上传前校验
const beforeImageUpload = (file) => {
const isImage = file.type.startsWith("image/");
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isImage) {
ElMessage.error("请上传图片文件!");
return false;
}
if (!isLt2M) {
ElMessage.error("图片大小不能超过 2MB!");
return false;
}
return true;
};
// 图片上传成功
const handleImageSuccess = (response) => {
if (response.code === 200 || response.code === 201) {
formData.thumb = response.data.url || response.data.path;
ElMessage.success(
response.code === 201 ? "文件已存在,直接使用" : "图片上传成功",
);
} else {
ElMessage.error(response.msg || "图片上传失败");
}
};
// 图片上传失败
const handleImageError = () => {
ElMessage.error("图片上传失败,请重试");
};
// 删除图片
const handleRemoveImage = () => {
formData.thumb = "";
ElMessage.success("图片已删除");
};
// 获取图片完整URL(用于预览)
const getImageUrl = (imagePath) => {
if (!imagePath) return "";
if (imagePath.startsWith("http")) return imagePath;
return API_BASE_URL + imagePath;
};
const formData = reactive({
title: "",
desc: "",
content: "",
thumb: "",
url: "",
sort: 0,
status: 1,
});
const formRules = {
title: [
{ required: true, message: "请输入产品名称", trigger: "blur" },
{ max: 100, message: "长度不超过100个字符", trigger: "blur" },
],
desc: [
{ required: true, message: "请输入产品描述", trigger: "blur" },
{ max: 200, message: "描述长度不超过200个字符", trigger: "blur" },
],
};
// 监听 modelValue 变化
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (val) {
resetForm();
if (props.isEdit && props.rowData) {
Object.assign(formData, props.rowData);
}
}
},
);
// 监听 visible 变化
watch(visible, (val) => {
emit("update:modelValue", val);
});
// 重置表单
const resetForm = () => {
formData.title = "";
formData.desc = "";
formData.content = "";
formData.thumb = "";
formData.url = "";
formData.sort = 0;
formData.status = 1;
};
// 关闭
const handleClose = () => {
visible.value = false;
};
// 提交
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true;
try {
let res;
if (props.isEdit) {
res = await updateProducts(props.rowData.id, formData);
} else {
res = await addProducts(formData);
}
if (res.code === 200) {
ElMessage.success(props.isEdit ? "更新成功" : "添加成功");
handleClose();
emit("success");
} else {
ElMessage.error(res.msg || "操作失败");
}
} catch (error) {
console.error("提交失败:", error);
ElMessage.error("操作失败");
} finally {
submitLoading.value = false;
}
}
});
};
</script>
<style scoped>
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* 图片上传样式 */
.image-uploader {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
width: 120px;
height: 120px;
}
.image-uploader:hover {
border-color: var(--el-color-primary);
}
.upload-placeholder {
width: 120px;
height: 120px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--el-text-color-secondary);
}
.image-uploader-icon {
font-size: 28px;
color: #8c939d;
margin-bottom: 8px;
}
.el-upload__text {
font-size: 12px;
}
.image-preview {
width: 120px;
height: 120px;
object-fit: contain;
display: block;
}
.flex-direction {
display: flex;
flex-direction: column;
align-items: flex-start;
}
</style>
@@ -1,295 +1,295 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>企业产品管理</h2>
<div class="header-actions">
<el-button type="primary" @click="handleTypes">
<i class="fa-solid fa-layer-group"></i>
分类管理
</el-button>
<el-button type="primary" @click="handleAdd">
<i class="fa-solid fa-plus"></i>
添加产品
</el-button>
<el-button @click="refresh" :loading="loading">
<i class="fa-solid fa-refresh"></i>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input v-model="searchForm.keyword" placeholder="请输入产品名称搜索" clearable style="width: 200px; margin-right: 10px"
@keyup.enter="handleSearch" />
<el-select v-model="searchForm.status" placeholder="状态筛选" clearable style="width: 120px; margin-right: 10px">
<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="productsList" style="width: 100%" v-loading="loading" @selection-change="handleSelectionChange">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="thumb" label="产品图片" min-width="150" align="center">
<template #default="{ row }">
<el-image v-if="row.thumb" :src="getImageUrl(row.thumb)" :preview-src-list="[getImageUrl(row.thumb)]"
fit="cover" class="product-thumb" :preview-teleported="true" lazy>
<template #error>
<div class="image-slot">
<el-icon>
<Picture />
</el-icon>
</div>
</template>
</el-image>
<span v-else class="text-gray">暂无图片</span>
</template>
</el-table-column>
<el-table-column prop="title" label="产品名称" min-width="150" align="center" />
<el-table-column prop="desc" label="产品描述" min-width="150" align="center" />
<el-table-column prop="url" label="跳转地址" min-width="150" align="center" />
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column prop="create_time" label="创建时间" width="180" align="center" />
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">
<el-icon>
<Edit />
</el-icon>
编辑
</el-button>
<el-button type="danger" link @click="handleDelete(row)">
<el-icon>
<Delete />
</el-icon>
删除
</el-button>
</template>
</el-table-column>
</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>
<!-- 添加/编辑组件 -->
<EditDialog v-model="dialogVisible" :title="dialogTitle" :is-edit="isEdit" :row-data="currentRow"
@success="handleSuccess" />
</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 { useRouter } from 'vue-router'
import {
getProductsList,
updateProducts,
deleteProducts
} from '@/api/products'
import EditDialog from './components/edit.vue'
// @ts-ignore
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const router = useRouter()
// 获取图片完整URL
// 接口可能返回:
// 1) 绝对地址:http(s)://...
// 2) 以 / 开头的相对路径:/storage/uploads/xxx.png
// 3) 不以 / 开头的相对路径:storage/uploads/xxx.png
const getImageUrl = (imagePath) => {
if (!imagePath) return "";
if (typeof imagePath !== 'string') imagePath = String(imagePath);
if (imagePath.startsWith("http")) return imagePath;
const base = (API_BASE_URL ?? '').replace(/\/$/, '');
const path = imagePath.startsWith('/') ? imagePath : `/${imagePath}`;
return base + path;
};
// 加载状态
const loading = ref(false)
// 搜索表单
const searchForm = reactive({
keyword: '',
status: ''
})
// 分页
const pagination = reactive({
page: 1,
limit: 10,
total: 0
})
// 数据列表
const productsList = ref([])
const selectedIds = ref([])
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
const isEdit = ref(false)
const currentRow = ref({})
// 获取列表
const fetchList = async () => {
loading.value = true
try {
const res = await getProductsList({
page: pagination.page,
limit: pagination.limit,
keyword: searchForm.keyword,
status: searchForm.status
})
if (res.code === 200) {
productsList.value = res.data.list
pagination.total = res.data.total
}
} catch (error) {
console.error('获取企业产品列表失败:', error)
ElMessage.error('获取企业产品列表失败')
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.page = 1
fetchList()
}
// 重置搜索
const resetSearch = () => {
searchForm.keyword = ''
searchForm.status = ''
pagination.page = 1
fetchList()
}
// 刷新
const refresh = () => {
fetchList()
}
// 分页变化
const handleSizeChange = (val) => {
pagination.limit = val
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 handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该企业产品吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const res = await deleteProducts(row.id)
if (res.code === 200) {
ElMessage.success('删除成功')
fetchList()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除企业产品失败:', error)
ElMessage.error('删除企业产品失败')
}
}
}
// 编辑成功回调
const handleSuccess = () => {
fetchList()
}
// 跳转到产品分类管理页
const handleTypes = () => {
router.push('/apps/cms/products/types')
}
// 初始化
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header-bar h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.search-bar {
margin-bottom: 20px;
display: flex;
align-items: center;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
<template>
<div class="container-box">
<div class="header-bar">
<h2>企业产品管理</h2>
<div class="header-actions">
<el-button type="primary" @click="handleTypes">
<i class="fa-solid fa-layer-group"></i>
分类管理
</el-button>
<el-button type="primary" @click="handleAdd">
<i class="fa-solid fa-plus"></i>
添加产品
</el-button>
<el-button @click="refresh" :loading="loading">
<i class="fa-solid fa-refresh"></i>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input v-model="searchForm.keyword" placeholder="请输入产品名称搜索" clearable style="width: 200px; margin-right: 10px"
@keyup.enter="handleSearch" />
<el-select v-model="searchForm.status" placeholder="状态筛选" clearable style="width: 120px; margin-right: 10px">
<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="productsList" style="width: 100%" v-loading="loading" @selection-change="handleSelectionChange">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="thumb" label="产品图片" min-width="150" align="center">
<template #default="{ row }">
<el-image v-if="row.thumb" :src="getImageUrl(row.thumb)" :preview-src-list="[getImageUrl(row.thumb)]"
fit="cover" class="product-thumb" :preview-teleported="true" lazy>
<template #error>
<div class="image-slot">
<el-icon>
<Picture />
</el-icon>
</div>
</template>
</el-image>
<span v-else class="text-gray">暂无图片</span>
</template>
</el-table-column>
<el-table-column prop="title" label="产品名称" min-width="150" align="center" />
<el-table-column prop="desc" label="产品描述" min-width="150" align="center" />
<el-table-column prop="url" label="跳转地址" min-width="150" align="center" />
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column prop="create_time" label="创建时间" width="180" align="center" />
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">
<el-icon>
<Edit />
</el-icon>
编辑
</el-button>
<el-button type="danger" link @click="handleDelete(row)">
<el-icon>
<Delete />
</el-icon>
删除
</el-button>
</template>
</el-table-column>
</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>
<!-- 添加/编辑组件 -->
<EditDialog v-model="dialogVisible" :title="dialogTitle" :is-edit="isEdit" :row-data="currentRow"
@success="handleSuccess" />
</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 { useRouter } from 'vue-router'
import {
getProductsList,
updateProducts,
deleteProducts
} from '@/api/products'
import EditDialog from './components/edit.vue'
// @ts-ignore
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const router = useRouter()
// 获取图片完整URL
// 接口可能返回:
// 1) 绝对地址:http(s)://...
// 2) 以 / 开头的相对路径:/storage/uploads/xxx.png
// 3) 不以 / 开头的相对路径:storage/uploads/xxx.png
const getImageUrl = (imagePath) => {
if (!imagePath) return "";
if (typeof imagePath !== 'string') imagePath = String(imagePath);
if (imagePath.startsWith("http")) return imagePath;
const base = (API_BASE_URL ?? '').replace(/\/$/, '');
const path = imagePath.startsWith('/') ? imagePath : `/${imagePath}`;
return base + path;
};
// 加载状态
const loading = ref(false)
// 搜索表单
const searchForm = reactive({
keyword: '',
status: ''
})
// 分页
const pagination = reactive({
page: 1,
limit: 10,
total: 0
})
// 数据列表
const productsList = ref([])
const selectedIds = ref([])
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
const isEdit = ref(false)
const currentRow = ref({})
// 获取列表
const fetchList = async () => {
loading.value = true
try {
const res = await getProductsList({
page: pagination.page,
limit: pagination.limit,
keyword: searchForm.keyword,
status: searchForm.status
})
if (res.code === 200) {
productsList.value = res.data.list
pagination.total = res.data.total
}
} catch (error) {
console.error('获取企业产品列表失败:', error)
ElMessage.error('获取企业产品列表失败')
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.page = 1
fetchList()
}
// 重置搜索
const resetSearch = () => {
searchForm.keyword = ''
searchForm.status = ''
pagination.page = 1
fetchList()
}
// 刷新
const refresh = () => {
fetchList()
}
// 分页变化
const handleSizeChange = (val) => {
pagination.limit = val
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 handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该企业产品吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const res = await deleteProducts(row.id)
if (res.code === 200) {
ElMessage.success('删除成功')
fetchList()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除企业产品失败:', error)
ElMessage.error('删除企业产品失败')
}
}
}
// 编辑成功回调
const handleSuccess = () => {
fetchList()
}
// 跳转到产品分类管理页
const handleTypes = () => {
router.push('/apps/cms/products/types')
}
// 初始化
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header-bar h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.search-bar {
margin-bottom: 20px;
display: flex;
align-items: center;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -1,211 +1,211 @@
<template>
<el-dialog
v-model="visible"
:title="title"
width="600px"
destroy-on-close
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="分类名称" prop="title">
<el-input v-model="formData.title" placeholder="请输入分类名称" />
</el-form-item>
<el-form-item label="父级分类" prop="pid">
<el-select
v-model="formData.pid"
placeholder="请选择父级分类"
clearable
style="width: 100%"
>
<el-option label="顶级分类" :value="0" />
<el-option
v-for="item in typeOptions"
:key="item.id"
:label="item.title"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="分类描述" prop="desc">
<el-input
v-model="formData.desc"
type="textarea"
:rows="4"
placeholder="请输入分类描述(可选)"
/>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" :max="999" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="submitLoading"
>
确定
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import {
addProductsTypes,
updateProductsTypes,
getProductsTypesList
} from '@/api/products'
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 visible = ref(false)
const submitLoading = ref(false)
const formRef = ref(null)
const typeOptions = ref([])
const formData = reactive({
title: '',
pid: 0,
desc: '',
sort: 0
})
const formRules = {
title: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{ max: 100, message: '长度不超过100个字符', trigger: 'blur' }
]
}
const resetForm = () => {
formData.title = ''
formData.pid = 0
formData.desc = ''
formData.sort = 0
}
const loadTypeOptions = async () => {
try {
const res = await getProductsTypesList({ page: 1, limit: 1000, keyword: '' })
if (res.code === 200 && Array.isArray(res.data?.list)) {
const curId = props.isEdit ? Number(props.rowData?.id ?? 0) : 0
typeOptions.value = curId
? res.data.list.filter((item) => Number(item.id) !== curId)
: res.data.list
} else {
typeOptions.value = []
}
} catch (e) {
// 下拉选项失败不影响弹窗可用性
console.error('加载父级分类失败:', e)
typeOptions.value = []
}
}
// 打开/关闭
watch(
() => props.modelValue,
async (val) => {
visible.value = val
if (val) {
resetForm()
if (props.isEdit && props.rowData) {
Object.assign(formData, props.rowData)
formData.pid = Number(props.rowData?.pid ?? 0)
formData.sort = Number(props.rowData?.sort ?? 0)
}
await loadTypeOptions()
}
}
)
watch(visible, (val) => {
emit('update:modelValue', val)
})
const handleClose = () => {
visible.value = false
}
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
let res
const payload = {
title: formData.title,
pid: Number(formData.pid ?? 0),
desc: formData.desc ?? '',
sort: Number(formData.sort ?? 0)
}
if (props.isEdit) {
res = await updateProductsTypes(Number(props.rowData?.id), payload)
} else {
res = await addProductsTypes(payload)
}
if (res.code === 200) {
ElMessage.success(props.isEdit ? '更新分类成功' : '添加分类成功')
handleClose()
emit('success')
} else {
ElMessage.error(res.msg || '操作失败')
}
} catch (e) {
console.error('提交分类失败:', e)
ElMessage.error('操作失败')
} finally {
submitLoading.value = false
}
})
}
</script>
<style scoped>
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>
<template>
<el-dialog
v-model="visible"
:title="title"
width="600px"
destroy-on-close
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="分类名称" prop="title">
<el-input v-model="formData.title" placeholder="请输入分类名称" />
</el-form-item>
<el-form-item label="父级分类" prop="pid">
<el-select
v-model="formData.pid"
placeholder="请选择父级分类"
clearable
style="width: 100%"
>
<el-option label="顶级分类" :value="0" />
<el-option
v-for="item in typeOptions"
:key="item.id"
:label="item.title"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="分类描述" prop="desc">
<el-input
v-model="formData.desc"
type="textarea"
:rows="4"
placeholder="请输入分类描述(可选)"
/>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" :max="999" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="submitLoading"
>
确定
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import {
addProductsTypes,
updateProductsTypes,
getProductsTypesList
} from '@/api/products'
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 visible = ref(false)
const submitLoading = ref(false)
const formRef = ref(null)
const typeOptions = ref([])
const formData = reactive({
title: '',
pid: 0,
desc: '',
sort: 0
})
const formRules = {
title: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{ max: 100, message: '长度不超过100个字符', trigger: 'blur' }
]
}
const resetForm = () => {
formData.title = ''
formData.pid = 0
formData.desc = ''
formData.sort = 0
}
const loadTypeOptions = async () => {
try {
const res = await getProductsTypesList({ page: 1, limit: 1000, keyword: '' })
if (res.code === 200 && Array.isArray(res.data?.list)) {
const curId = props.isEdit ? Number(props.rowData?.id ?? 0) : 0
typeOptions.value = curId
? res.data.list.filter((item) => Number(item.id) !== curId)
: res.data.list
} else {
typeOptions.value = []
}
} catch (e) {
// 下拉选项失败不影响弹窗可用性
console.error('加载父级分类失败:', e)
typeOptions.value = []
}
}
// 打开/关闭
watch(
() => props.modelValue,
async (val) => {
visible.value = val
if (val) {
resetForm()
if (props.isEdit && props.rowData) {
Object.assign(formData, props.rowData)
formData.pid = Number(props.rowData?.pid ?? 0)
formData.sort = Number(props.rowData?.sort ?? 0)
}
await loadTypeOptions()
}
}
)
watch(visible, (val) => {
emit('update:modelValue', val)
})
const handleClose = () => {
visible.value = false
}
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
let res
const payload = {
title: formData.title,
pid: Number(formData.pid ?? 0),
desc: formData.desc ?? '',
sort: Number(formData.sort ?? 0)
}
if (props.isEdit) {
res = await updateProductsTypes(Number(props.rowData?.id), payload)
} else {
res = await addProductsTypes(payload)
}
if (res.code === 200) {
ElMessage.success(props.isEdit ? '更新分类成功' : '添加分类成功')
handleClose()
emit('success')
} else {
ElMessage.error(res.msg || '操作失败')
}
} catch (e) {
console.error('提交分类失败:', e)
ElMessage.error('操作失败')
} finally {
submitLoading.value = false
}
})
}
</script>
<style scoped>
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>
@@ -1,240 +1,240 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>企业产品分类管理</h2>
<div class="header-actions">
<el-button type="primary" @click="handleAdd">
<i class="fa-solid fa-plus"></i>
添加分类
</el-button>
<el-button @click="refresh" :loading="loading">
<i class="fa-solid fa-refresh"></i>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="searchForm.keyword"
placeholder="请输入分类名称搜索"
clearable
style="width: 200px; margin-right: 10px"
@keyup.enter="handleSearch" />
<el-button type="primary" @click="handleSearch">
<el-icon>
<Search />
</el-icon>
搜索
</el-button>
<el-button @click="resetSearch">重置</el-button>
</div>
<!-- 数据表格 -->
<el-table :data="typesList" style="width: 100%" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="title" label="分类名称" min-width="180" align="center" />
<el-table-column prop="desc" label="分类描述" min-width="200" align="center" />
<el-table-column prop="pid" label="父级ID" width="110" align="center" />
<el-table-column prop="sort" label="排序" width="90" align="center" />
<el-table-column prop="create_time" label="创建时间" min-width="180" align="center" />
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">
<el-icon>
<Edit />
</el-icon>
编辑
</el-button>
<el-button type="danger" link @click="handleDelete(row)">
<el-icon>
<Delete />
</el-icon>
删除
</el-button>
</template>
</el-table-column>
</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>
<!-- 添加/编辑组件 -->
<EditDialog v-model="dialogVisible" :title="dialogTitle" :is-edit="isEdit" :row-data="currentRow"
@success="handleSuccess" />
</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 {
getProductsTypesList,
deleteProductsTypes
} from '@/api/products'
import EditDialog from './components/edit.vue'
// 加载状态
const loading = ref(false)
// 搜索表单
const searchForm = reactive({
keyword: ''
})
// 分页
const pagination = reactive({
page: 1,
limit: 10,
total: 0
})
// 数据列表
const typesList = ref([])
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
const isEdit = ref(false)
const currentRow = ref({})
// 获取列表
const fetchList = async () => {
loading.value = true
try {
const res = await getProductsTypesList({
page: pagination.page,
limit: pagination.limit,
keyword: searchForm.keyword
})
if (res.code === 200) {
typesList.value = res.data.list
pagination.total = res.data.total
}
} catch (error) {
console.error('获取企业产品分类列表失败:', error)
ElMessage.error('获取企业产品分类列表失败')
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.page = 1
fetchList()
}
// 重置搜索
const resetSearch = () => {
searchForm.keyword = ''
pagination.page = 1
fetchList()
}
// 刷新
const refresh = () => {
fetchList()
}
// 分页变化
const handleSizeChange = (val) => {
pagination.limit = val
fetchList()
}
const handlePageChange = (val) => {
pagination.page = val
fetchList()
}
// 添加
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 handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该企业产品分类吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const res = await deleteProductsTypes(row.id)
if (res.code === 200) {
ElMessage.success('删除分类成功')
fetchList()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除企业产品分类失败:', error)
ElMessage.error('删除企业产品分类失败')
}
}
}
// 编辑成功回调
const handleSuccess = () => {
fetchList()
}
// 初始化
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header-bar h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.search-bar {
margin-bottom: 20px;
display: flex;
align-items: center;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
<template>
<div class="container-box">
<div class="header-bar">
<h2>企业产品分类管理</h2>
<div class="header-actions">
<el-button type="primary" @click="handleAdd">
<i class="fa-solid fa-plus"></i>
添加分类
</el-button>
<el-button @click="refresh" :loading="loading">
<i class="fa-solid fa-refresh"></i>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="searchForm.keyword"
placeholder="请输入分类名称搜索"
clearable
style="width: 200px; margin-right: 10px"
@keyup.enter="handleSearch" />
<el-button type="primary" @click="handleSearch">
<el-icon>
<Search />
</el-icon>
搜索
</el-button>
<el-button @click="resetSearch">重置</el-button>
</div>
<!-- 数据表格 -->
<el-table :data="typesList" style="width: 100%" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="title" label="分类名称" min-width="180" align="center" />
<el-table-column prop="desc" label="分类描述" min-width="200" align="center" />
<el-table-column prop="pid" label="父级ID" width="110" align="center" />
<el-table-column prop="sort" label="排序" width="90" align="center" />
<el-table-column prop="create_time" label="创建时间" min-width="180" align="center" />
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">
<el-icon>
<Edit />
</el-icon>
编辑
</el-button>
<el-button type="danger" link @click="handleDelete(row)">
<el-icon>
<Delete />
</el-icon>
删除
</el-button>
</template>
</el-table-column>
</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>
<!-- 添加/编辑组件 -->
<EditDialog v-model="dialogVisible" :title="dialogTitle" :is-edit="isEdit" :row-data="currentRow"
@success="handleSuccess" />
</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 {
getProductsTypesList,
deleteProductsTypes
} from '@/api/products'
import EditDialog from './components/edit.vue'
// 加载状态
const loading = ref(false)
// 搜索表单
const searchForm = reactive({
keyword: ''
})
// 分页
const pagination = reactive({
page: 1,
limit: 10,
total: 0
})
// 数据列表
const typesList = ref([])
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
const isEdit = ref(false)
const currentRow = ref({})
// 获取列表
const fetchList = async () => {
loading.value = true
try {
const res = await getProductsTypesList({
page: pagination.page,
limit: pagination.limit,
keyword: searchForm.keyword
})
if (res.code === 200) {
typesList.value = res.data.list
pagination.total = res.data.total
}
} catch (error) {
console.error('获取企业产品分类列表失败:', error)
ElMessage.error('获取企业产品分类列表失败')
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.page = 1
fetchList()
}
// 重置搜索
const resetSearch = () => {
searchForm.keyword = ''
pagination.page = 1
fetchList()
}
// 刷新
const refresh = () => {
fetchList()
}
// 分页变化
const handleSizeChange = (val) => {
pagination.limit = val
fetchList()
}
const handlePageChange = (val) => {
pagination.page = val
fetchList()
}
// 添加
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 handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该企业产品分类吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const res = await deleteProductsTypes(row.id)
if (res.code === 200) {
ElMessage.success('删除分类成功')
fetchList()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除企业产品分类失败:', error)
ElMessage.error('删除企业产品分类失败')
}
}
}
// 编辑成功回调
const handleSuccess = () => {
fetchList()
}
// 初始化
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header-bar h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.search-bar {
margin-bottom: 20px;
display: flex;
align-items: center;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -1,315 +1,315 @@
<template>
<el-dialog
v-model="visible"
:title="title"
width="600px"
destroy-on-close
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="服务名称" prop="title">
<el-input v-model="formData.title" placeholder="请输入服务名称" />
</el-form-item>
<el-form-item label="服务描述" prop="desc">
<el-input
v-model="formData.desc"
type="textarea"
:rows="5"
placeholder="请输入服务描述"
/>
</el-form-item>
<el-form-item label="Logo" prop="thumb">
<div class="flex-direction">
<el-upload
class="image-uploader"
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleImageSuccess"
:on-error="handleImageError"
:before-upload="beforeImageUpload"
accept="image/*"
>
<img
v-if="formData.thumb"
:src="getImageUrl(formData.thumb)"
class="image-preview"
/>
<div v-else class="upload-placeholder">
<el-icon class="image-uploader-icon"><Plus /></el-icon>
<div class="el-upload__text">点击上传服务图标</div>
</div>
</el-upload>
<div
style="
margin-top: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
"
>
建议尺寸:200x200,支持 jpg、png、gif 格式,大小不超过 2MB
</div>
</div>
<el-button
v-if="formData.thumb"
size="small"
type="danger"
text
@click="handleRemoveImage"
style="margin-top: 8px"
>
删除图标
</el-button>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" :max="999" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="submitLoading"
>
确定
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from "vue";
import { ElMessage } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { addService, updateService } from "@/api/services";
// @ts-ignore
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
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 visible = ref(false);
const submitLoading = ref(false);
const formRef = ref(null);
// 上传配置
const uploadUrl = ref(API_BASE_URL + "/admin/uploadfiles");
const uploadHeaders = ref({
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
});
// 图片上传前校验
const beforeImageUpload = (file) => {
const isImage = file.type.startsWith("image/");
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isImage) {
ElMessage.error("请上传图片文件!");
return false;
}
if (!isLt2M) {
ElMessage.error("图片大小不能超过 2MB!");
return false;
}
return true;
};
// 图片上传成功
const handleImageSuccess = (response) => {
if (response.code === 200 || response.code === 201) {
formData.link_logo = response.data.url || response.data.path;
ElMessage.success(
response.code === 201 ? "文件已存在,直接使用" : "图片上传成功",
);
} else {
ElMessage.error(response.msg || "图片上传失败");
}
};
// 图片上传失败
const handleImageError = () => {
ElMessage.error("图片上传失败,请重试");
};
// 删除图片
const handleRemoveImage = () => {
formData.link_logo = "";
ElMessage.success("图片已删除");
};
// 获取图片完整URL(用于预览)
const getImageUrl = (imagePath) => {
if (!imagePath) return "";
if (imagePath.startsWith("http")) return imagePath;
return API_BASE_URL + imagePath;
};
const formData = reactive({
title: "",
desc: "",
thumb: "",
url: "",
sort: 0,
status: 1,
});
const formRules = {
title: [
{ required: true, message: "请输入服务名称", trigger: "blur" },
{ max: 100, message: "长度不超过100个字符", trigger: "blur" },
],
desc: [
{ required: true, message: "请输入服务描述", trigger: "blur" },
{ max: 200, message: "描述长度不超过200个字符", trigger: "blur" },
],
};
// 监听 modelValue 变化
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (val) {
resetForm();
if (props.isEdit && props.rowData) {
Object.assign(formData, props.rowData);
}
}
},
);
// 监听 visible 变化
watch(visible, (val) => {
emit("update:modelValue", val);
});
// 重置表单
const resetForm = () => {
formData.title = "";
formData.desc = "";
formData.thumb = "";
formData.url = "";
formData.sort = 0;
formData.status = 1;
};
// 关闭
const handleClose = () => {
visible.value = false;
};
// 提交
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true;
try {
let res;
if (props.isEdit) {
res = await updateService(props.rowData.id, formData);
} else {
res = await addService(formData);
}
if (res.code === 200) {
ElMessage.success(props.isEdit ? "更新成功" : "添加成功");
handleClose();
emit("success");
} else {
ElMessage.error(res.msg || "操作失败");
}
} catch (error) {
console.error("提交失败:", error);
ElMessage.error("操作失败");
} finally {
submitLoading.value = false;
}
}
});
};
</script>
<style scoped>
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* 图片上传样式 */
.image-uploader {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
width: 120px;
height: 120px;
}
.image-uploader:hover {
border-color: var(--el-color-primary);
}
.upload-placeholder {
width: 120px;
height: 120px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--el-text-color-secondary);
}
.image-uploader-icon {
font-size: 28px;
color: #8c939d;
margin-bottom: 8px;
}
.el-upload__text {
font-size: 12px;
}
.image-preview {
width: 120px;
height: 120px;
object-fit: contain;
display: block;
}
.flex-direction {
display: flex;
flex-direction: column;
align-items: flex-start;
}
</style>
<template>
<el-dialog
v-model="visible"
:title="title"
width="600px"
destroy-on-close
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="服务名称" prop="title">
<el-input v-model="formData.title" placeholder="请输入服务名称" />
</el-form-item>
<el-form-item label="服务描述" prop="desc">
<el-input
v-model="formData.desc"
type="textarea"
:rows="5"
placeholder="请输入服务描述"
/>
</el-form-item>
<el-form-item label="Logo" prop="thumb">
<div class="flex-direction">
<el-upload
class="image-uploader"
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleImageSuccess"
:on-error="handleImageError"
:before-upload="beforeImageUpload"
accept="image/*"
>
<img
v-if="formData.thumb"
:src="getImageUrl(formData.thumb)"
class="image-preview"
/>
<div v-else class="upload-placeholder">
<el-icon class="image-uploader-icon"><Plus /></el-icon>
<div class="el-upload__text">点击上传服务图标</div>
</div>
</el-upload>
<div
style="
margin-top: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
"
>
建议尺寸:200x200,支持 jpg、png、gif 格式,大小不超过 2MB
</div>
</div>
<el-button
v-if="formData.thumb"
size="small"
type="danger"
text
@click="handleRemoveImage"
style="margin-top: 8px"
>
删除图标
</el-button>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" :max="999" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="submitLoading"
>
确定
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from "vue";
import { ElMessage } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { addService, updateService } from "@/api/services";
// @ts-ignore
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
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 visible = ref(false);
const submitLoading = ref(false);
const formRef = ref(null);
// 上传配置
const uploadUrl = ref(API_BASE_URL + "/admin/uploadfiles");
const uploadHeaders = ref({
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
});
// 图片上传前校验
const beforeImageUpload = (file) => {
const isImage = file.type.startsWith("image/");
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isImage) {
ElMessage.error("请上传图片文件!");
return false;
}
if (!isLt2M) {
ElMessage.error("图片大小不能超过 2MB!");
return false;
}
return true;
};
// 图片上传成功
const handleImageSuccess = (response) => {
if (response.code === 200 || response.code === 201) {
formData.link_logo = response.data.url || response.data.path;
ElMessage.success(
response.code === 201 ? "文件已存在,直接使用" : "图片上传成功",
);
} else {
ElMessage.error(response.msg || "图片上传失败");
}
};
// 图片上传失败
const handleImageError = () => {
ElMessage.error("图片上传失败,请重试");
};
// 删除图片
const handleRemoveImage = () => {
formData.link_logo = "";
ElMessage.success("图片已删除");
};
// 获取图片完整URL(用于预览)
const getImageUrl = (imagePath) => {
if (!imagePath) return "";
if (imagePath.startsWith("http")) return imagePath;
return API_BASE_URL + imagePath;
};
const formData = reactive({
title: "",
desc: "",
thumb: "",
url: "",
sort: 0,
status: 1,
});
const formRules = {
title: [
{ required: true, message: "请输入服务名称", trigger: "blur" },
{ max: 100, message: "长度不超过100个字符", trigger: "blur" },
],
desc: [
{ required: true, message: "请输入服务描述", trigger: "blur" },
{ max: 200, message: "描述长度不超过200个字符", trigger: "blur" },
],
};
// 监听 modelValue 变化
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (val) {
resetForm();
if (props.isEdit && props.rowData) {
Object.assign(formData, props.rowData);
}
}
},
);
// 监听 visible 变化
watch(visible, (val) => {
emit("update:modelValue", val);
});
// 重置表单
const resetForm = () => {
formData.title = "";
formData.desc = "";
formData.thumb = "";
formData.url = "";
formData.sort = 0;
formData.status = 1;
};
// 关闭
const handleClose = () => {
visible.value = false;
};
// 提交
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true;
try {
let res;
if (props.isEdit) {
res = await updateService(props.rowData.id, formData);
} else {
res = await addService(formData);
}
if (res.code === 200) {
ElMessage.success(props.isEdit ? "更新成功" : "添加成功");
handleClose();
emit("success");
} else {
ElMessage.error(res.msg || "操作失败");
}
} catch (error) {
console.error("提交失败:", error);
ElMessage.error("操作失败");
} finally {
submitLoading.value = false;
}
}
});
};
</script>
<style scoped>
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* 图片上传样式 */
.image-uploader {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
width: 120px;
height: 120px;
}
.image-uploader:hover {
border-color: var(--el-color-primary);
}
.upload-placeholder {
width: 120px;
height: 120px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--el-text-color-secondary);
}
.image-uploader-icon {
font-size: 28px;
color: #8c939d;
margin-bottom: 8px;
}
.el-upload__text {
font-size: 12px;
}
.image-preview {
width: 120px;
height: 120px;
object-fit: contain;
display: block;
}
.flex-direction {
display: flex;
flex-direction: column;
align-items: flex-start;
}
</style>
@@ -1,281 +1,295 @@
<template>
<div class="container-box">
<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="refresh" :loading="loading">
<el-icon>
<Refresh />
</el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input v-model="searchForm.keyword" placeholder="请输入内容搜索" clearable style="width: 200px; margin-right: 10px"
@keyup.enter="handleSearch" />
<el-select v-model="searchForm.status" placeholder="状态筛选" clearable style="width: 120px; margin-right: 10px">
<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="servicesList" style="width: 100%" v-loading="loading" @selection-change="handleSelectionChange">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="thumb" label="服务图片" min-width="150" align="center" />
<el-table-column prop="title" label="服务名称" min-width="150" align="center" />
<el-table-column prop="desc" label="服务描述" min-width="150" align="center" />
<el-table-column prop="url" label="跳转地址" min-width="150" align="center" />
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column prop="create_time" label="创建时间" width="180" align="center" />
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">
<el-icon>
<Edit />
</el-icon>
编辑
</el-button>
<el-button type="danger" link @click="handleDelete(row)">
<el-icon>
<Delete />
</el-icon>
删除
</el-button>
</template>
</el-table-column>
</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>
<!-- 添加/编辑组件 -->
<EditDialog v-model="dialogVisible" :title="dialogTitle" :is-edit="isEdit" :row-data="currentRow"
@success="handleSuccess" />
</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 {
getServiceList,
updateService,
deleteService
} from '@/api/services'
import EditDialog from './components/edit.vue'
// @ts-ignore
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
// 获取图片完整URL
const getImageUrl = (imagePath) => {
if (!imagePath) return "";
if (imagePath.startsWith("http")) return imagePath;
return API_BASE_URL + imagePath;
};
// 加载状态
const loading = ref(false)
// 搜索表单
const searchForm = reactive({
keyword: '',
status: ''
})
// 分页
const pagination = reactive({
page: 1,
limit: 10,
total: 0
})
// 数据列表
const servicesList = ref([])
const selectedIds = ref([])
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
const isEdit = ref(false)
const currentRow = ref({})
// 获取列表
const fetchList = async () => {
loading.value = true
try {
const res = await getServiceList({
page: pagination.page,
limit: pagination.limit,
keyword: searchForm.keyword,
status: searchForm.status
})
if (res.code === 200) {
servicesList.value = res.data.list
pagination.total = res.data.total
}
} catch (error) {
console.error('获取特色服务列表失败:', error)
ElMessage.error('获取特色服务列表失败')
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.page = 1
fetchList()
}
// 重置搜索
const resetSearch = () => {
searchForm.keyword = ''
searchForm.status = ''
pagination.page = 1
fetchList()
}
// 刷新
const refresh = () => {
fetchList()
}
// 分页变化
const handleSizeChange = (val) => {
pagination.limit = val
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 handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该特色服务吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const res = await deleteService(row.id)
if (res.code === 200) {
ElMessage.success('删除成功')
fetchList()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除特色服务失败:', error)
ElMessage.error('删除特色服务失败')
}
}
}
// 状态变化
const handleStatusChange = async (row, val) => {
try {
const res = await updateService(row.id, { status: val })
if (res.code === 200) {
ElMessage.success('状态更新成功')
} else {
ElMessage.error(res.msg || '状态更新失败')
row.status = val === 1 ? 0 : 1
}
} catch (error) {
console.error('更新特色服务状态失败:', error)
ElMessage.error('更新特色服务状态失败')
row.status = val === 1 ? 0 : 1
}
}
// 编辑成功回调
const handleSuccess = () => {
fetchList()
}
// 初始化
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header-bar h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.search-bar {
margin-bottom: 20px;
display: flex;
align-items: center;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
<template>
<div class="container-box">
<div class="header-bar">
<h2>特色服务管理</h2>
<div class="header-actions">
<el-button type="primary" @click="handleTypes">
<el-icon>
<Menu />
</el-icon>
分类管理
</el-button>
<el-button type="primary" @click="handleAdd">
<el-icon>
<Plus />
</el-icon>
添加内容
</el-button>
<el-button @click="refresh" :loading="loading">
<el-icon>
<Refresh />
</el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input v-model="searchForm.keyword" placeholder="请输入内容搜索" clearable style="width: 200px; margin-right: 10px"
@keyup.enter="handleSearch" />
<el-select v-model="searchForm.status" placeholder="状态筛选" clearable style="width: 120px; margin-right: 10px">
<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="servicesList" style="width: 100%" v-loading="loading" @selection-change="handleSelectionChange">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="thumb" label="服务图片" min-width="150" align="center" />
<el-table-column prop="title" label="服务名称" min-width="150" align="center" />
<el-table-column prop="desc" label="服务描述" min-width="150" align="center" />
<el-table-column prop="url" label="跳转地址" min-width="150" align="center" />
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column prop="create_time" label="创建时间" width="180" align="center" />
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">
<el-icon>
<Edit />
</el-icon>
编辑
</el-button>
<el-button type="danger" link @click="handleDelete(row)">
<el-icon>
<Delete />
</el-icon>
删除
</el-button>
</template>
</el-table-column>
</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>
<!-- 添加/编辑组件 -->
<EditDialog v-model="dialogVisible" :title="dialogTitle" :is-edit="isEdit" :row-data="currentRow"
@success="handleSuccess" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, Edit, Delete, Menu } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import {
getServiceList,
updateService,
deleteService
} from '@/api/services'
import EditDialog from './components/edit.vue'
// @ts-ignore
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const router = useRouter()
// 获取图片完整URL
const getImageUrl = (imagePath) => {
if (!imagePath) return "";
if (imagePath.startsWith("http")) return imagePath;
return API_BASE_URL + imagePath;
};
// 加载状态
const loading = ref(false)
// 搜索表单
const searchForm = reactive({
keyword: '',
status: ''
})
// 分页
const pagination = reactive({
page: 1,
limit: 10,
total: 0
})
// 数据列表
const servicesList = ref([])
const selectedIds = ref([])
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
const isEdit = ref(false)
const currentRow = ref({})
// 获取列表
const fetchList = async () => {
loading.value = true
try {
const res = await getServiceList({
page: pagination.page,
limit: pagination.limit,
keyword: searchForm.keyword,
status: searchForm.status
})
if (res.code === 200) {
servicesList.value = res.data.list
pagination.total = res.data.total
}
} catch (error) {
console.error('获取特色服务列表失败:', error)
ElMessage.error('获取特色服务列表失败')
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.page = 1
fetchList()
}
// 重置搜索
const resetSearch = () => {
searchForm.keyword = ''
searchForm.status = ''
pagination.page = 1
fetchList()
}
// 刷新
const refresh = () => {
fetchList()
}
// 分页变化
const handleSizeChange = (val) => {
pagination.limit = val
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 handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该特色服务吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const res = await deleteService(row.id)
if (res.code === 200) {
ElMessage.success('删除成功')
fetchList()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除特色服务失败:', error)
ElMessage.error('删除特色服务失败')
}
}
}
// 状态变化
const handleStatusChange = async (row, val) => {
try {
const res = await updateService(row.id, { status: val })
if (res.code === 200) {
ElMessage.success('状态更新成功')
} else {
ElMessage.error(res.msg || '状态更新失败')
row.status = val === 1 ? 0 : 1
}
} catch (error) {
console.error('更新特色服务状态失败:', error)
ElMessage.error('更新特色服务状态失败')
row.status = val === 1 ? 0 : 1
}
}
// 编辑成功回调
const handleSuccess = () => {
fetchList()
}
// 跳转到解决方案分类管理页
const handleTypes = () => {
router.push('/apps/cms/solutions/types')
}
// 初始化
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header-bar h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.search-bar {
margin-bottom: 20px;
display: flex;
align-items: center;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,210 @@
<template>
<el-dialog
v-model="visible"
:title="title"
width="600px"
destroy-on-close
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="分类名称" prop="title">
<el-input v-model="formData.title" placeholder="请输入分类名称" />
</el-form-item>
<el-form-item label="父级分类" prop="pid">
<el-select
v-model="formData.pid"
placeholder="请选择父级分类"
clearable
style="width: 100%"
>
<el-option label="顶级分类" :value="0" />
<el-option
v-for="item in typeOptions"
:key="item.id"
:label="item.title"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="分类描述" prop="desc">
<el-input
v-model="formData.desc"
type="textarea"
:rows="4"
placeholder="请输入分类描述(可选)"
/>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" :max="999" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="submitLoading"
>
确定
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import {
addServicesTypes,
updateServicesTypes,
getServicesTypesList
} from '@/api/services'
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 visible = ref(false)
const submitLoading = ref(false)
const formRef = ref(null)
const typeOptions = ref([])
const formData = reactive({
title: '',
pid: 0,
desc: '',
sort: 0
})
const formRules = {
title: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{ max: 100, message: '长度不超过100个字符', trigger: 'blur' }
]
}
const resetForm = () => {
formData.title = ''
formData.pid = 0
formData.desc = ''
formData.sort = 0
}
const loadTypeOptions = async () => {
try {
const res = await getServicesTypesList({ page: 1, limit: 1000, keyword: '' })
if (res.code === 200 && Array.isArray(res.data?.list)) {
const curId = props.isEdit ? Number(props.rowData?.id ?? 0) : 0
typeOptions.value = curId
? res.data.list.filter((item) => Number(item.id) !== curId)
: res.data.list
} else {
typeOptions.value = []
}
} catch (e) {
// 下拉选项失败不影响弹窗可用性
console.error('加载父级分类失败:', e)
typeOptions.value = []
}
}
// 打开/关闭
watch(
() => props.modelValue,
async (val) => {
visible.value = val
if (val) {
resetForm()
if (props.isEdit && props.rowData) {
Object.assign(formData, props.rowData)
formData.pid = Number(props.rowData?.pid ?? 0)
formData.sort = Number(props.rowData?.sort ?? 0)
}
await loadTypeOptions()
}
}
)
watch(visible, (val) => {
emit('update:modelValue', val)
})
const handleClose = () => {
visible.value = false
}
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
let res
const payload = {
title: formData.title,
pid: Number(formData.pid ?? 0),
desc: formData.desc ?? '',
sort: Number(formData.sort ?? 0)
}
if (props.isEdit) {
res = await updateServicesTypes(Number(props.rowData?.id), payload)
} else {
res = await addServicesTypes(payload)
}
if (res.code === 200) {
ElMessage.success(props.isEdit ? '更新分类成功' : '添加分类成功')
handleClose()
emit('success')
} else {
ElMessage.error(res.msg || '操作失败')
}
} catch (e) {
console.error('提交分类失败:', e)
ElMessage.error('操作失败')
} finally {
submitLoading.value = false
}
})
}
</script>
<style scoped>
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>
@@ -0,0 +1,239 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>解决方案分类管理</h2>
<div class="header-actions">
<el-button type="primary" @click="handleAdd">
<i class="fa-solid fa-plus"></i>
添加分类
</el-button>
<el-button @click="refresh" :loading="loading">
<i class="fa-solid fa-refresh"></i>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="searchForm.keyword"
placeholder="请输入分类名称搜索"
clearable
style="width: 200px; margin-right: 10px"
@keyup.enter="handleSearch" />
<el-button type="primary" @click="handleSearch">
<el-icon>
<Search />
</el-icon>
搜索
</el-button>
<el-button @click="resetSearch">重置</el-button>
</div>
<!-- 数据表格 -->
<el-table :data="typesList" style="width: 100%" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="title" label="分类名称" min-width="180" align="center" />
<el-table-column prop="desc" label="分类描述" min-width="200" align="center" />
<el-table-column prop="pid" label="父级ID" width="110" align="center" />
<el-table-column prop="sort" label="排序" width="90" align="center" />
<el-table-column prop="create_time" label="创建时间" min-width="180" align="center" />
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">
<el-icon>
<Edit />
</el-icon>
编辑
</el-button>
<el-button type="danger" link @click="handleDelete(row)">
<el-icon>
<Delete />
</el-icon>
删除
</el-button>
</template>
</el-table-column>
</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>
<!-- 添加/编辑组件 -->
<EditDialog v-model="dialogVisible" :title="dialogTitle" :is-edit="isEdit" :row-data="currentRow"
@success="handleSuccess" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Edit, Delete } from '@element-plus/icons-vue'
import {
getServicesTypesList,
deleteServicesTypes
} from '@/api/services'
import EditDialog from './components/edit.vue'
// 加载状态
const loading = ref(false)
// 搜索表单
const searchForm = reactive({
keyword: ''
})
// 分页
const pagination = reactive({
page: 1,
limit: 10,
total: 0
})
// 数据列表
const typesList = ref([])
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
const isEdit = ref(false)
const currentRow = ref({})
// 获取列表
const fetchList = async () => {
loading.value = true
try {
const res = await getServicesTypesList({
page: pagination.page,
limit: pagination.limit,
keyword: searchForm.keyword
})
if (res.code === 200) {
typesList.value = res.data.list
pagination.total = res.data.total
}
} catch (error) {
console.error('获取解决方案分类列表失败:', error)
ElMessage.error('获取解决方案分类列表失败')
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.page = 1
fetchList()
}
// 重置搜索
const resetSearch = () => {
searchForm.keyword = ''
pagination.page = 1
fetchList()
}
// 刷新
const refresh = () => {
fetchList()
}
// 分页变化
const handleSizeChange = (val) => {
pagination.limit = val
fetchList()
}
const handlePageChange = (val) => {
pagination.page = val
fetchList()
}
// 添加
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 handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该解决方案分类吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const res = await deleteServicesTypes(row.id)
if (res.code === 200) {
ElMessage.success('删除分类成功')
fetchList()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除解决方案分类失败:', error)
ElMessage.error('删除解决方案分类失败')
}
}
}
// 编辑成功回调
const handleSuccess = () => {
fetchList()
}
// 初始化
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header-bar h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.search-bar {
margin-bottom: 20px;
display: flex;
align-items: center;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>