first commit
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div class="content-stats-container">
|
||||
<el-row :gutter="20" class="stat-cards">
|
||||
<el-col :span="6" v-for="card in topCards" :key="card.label">
|
||||
<el-card shadow="never" class="stat-card">
|
||||
<div class="label">{{ card.label }}</div>
|
||||
<div class="count">{{ card.count.toLocaleString() }}</div>
|
||||
<div class="sub-info" v-if="card.yesterday !== undefined">
|
||||
昨日 {{ card.yesterday || 0 }}
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="main-charts">
|
||||
<el-col :span="16">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>内容发布活跃度</span>
|
||||
<el-radio-group v-model="timeRange" size="small">
|
||||
<el-radio-button label="week">近一周</el-radio-button>
|
||||
<el-radio-button label="month">近一月</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="barChartRef" class="chart-box"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover" header="内容分类占比">
|
||||
<div ref="categoryChartRef" class="chart-box"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="bottom-row">
|
||||
<el-col :span="24">
|
||||
<el-card shadow="hover" header="热门内容TOP 5">
|
||||
<el-table :data="hotContent" style="width: 100%" stripe>
|
||||
<el-table-column type="index" label="排名" width="80" />
|
||||
<el-table-column prop="title" label="标题" show-overflow-tooltip />
|
||||
<el-table-column prop="cate" label="分类" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag>{{ row.cate || '未分类' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="views" label="阅读量" sortable />
|
||||
<el-table-column prop="likes" label="点赞数" width="120" />
|
||||
<el-table-column prop="publish_date" label="发布时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ row.publish_date ? row.publish_date.slice(0, 16).replace('T', ' ') : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, shallowRef } from "vue";
|
||||
import * as echarts from "echarts";
|
||||
|
||||
import { getContentStats } from "@/api/analytics";
|
||||
|
||||
interface TopCard {
|
||||
label: string;
|
||||
count: number;
|
||||
yesterday?: number;
|
||||
}
|
||||
|
||||
interface HotArticle {
|
||||
id: number;
|
||||
title: string;
|
||||
cate: string;
|
||||
views: number;
|
||||
likes: number;
|
||||
publish_date: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
const timeRange = ref("week");
|
||||
const barChartRef = ref<HTMLElement | null>(null);
|
||||
const categoryChartRef = ref<HTMLElement | null>(null);
|
||||
const barChart = shallowRef<echarts.ECharts | null>(null);
|
||||
const categoryChart = shallowRef<echarts.ECharts | null>(null);
|
||||
|
||||
const topCards = ref<TopCard[]>([
|
||||
{ label: "总发布量", count: 0 },
|
||||
{ label: "本月新增", count: 0 },
|
||||
{ label: "总点赞量", count: 0 },
|
||||
{ label: "总访问量", count: 0 },
|
||||
]);
|
||||
|
||||
const hotContent = ref<HotArticle[]>([]);
|
||||
|
||||
async function fetchContentStats() {
|
||||
const res = await getContentStats();
|
||||
if (res.code === 200 && res.data) {
|
||||
const { total_articles, month_articles, total_likes, total_views, hot_articles } = res.data;
|
||||
|
||||
topCards.value = [
|
||||
{ label: "总发布量", count: total_articles || 0 },
|
||||
{ label: "本月新增", count: month_articles || 0 },
|
||||
{ label: "总点赞量", count: total_likes || 0 },
|
||||
{ label: "总访问量", count: total_views || 0 },
|
||||
];
|
||||
|
||||
hotContent.value = hot_articles || [];
|
||||
}
|
||||
}
|
||||
|
||||
// --- 图表初始化 ---
|
||||
const initCharts = () => {
|
||||
// 柱状图:发布趋势
|
||||
if (barChartRef.value) {
|
||||
barChart.value = echarts.init(barChartRef.value);
|
||||
barChart.value.setOption({
|
||||
tooltip: { trigger: "axis" },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
|
||||
axisLine: { lineStyle: { color: "#DCDFE6" } },
|
||||
},
|
||||
yAxis: { type: "value" },
|
||||
series: [
|
||||
{
|
||||
name: "发布篇数",
|
||||
data: [45, 52, 38, 65, 48, 23, 31],
|
||||
type: "bar",
|
||||
barWidth: "40%",
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: "#83bff6" },
|
||||
{ offset: 0.5, color: "#188df0" },
|
||||
{ offset: 1, color: "#188df0" },
|
||||
]),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// 环形图:分类占比
|
||||
if (categoryChartRef.value) {
|
||||
categoryChart.value = echarts.init(categoryChartRef.value);
|
||||
categoryChart.value.setOption({
|
||||
tooltip: { trigger: "item" },
|
||||
series: [
|
||||
{
|
||||
type: "pie",
|
||||
radius: ["50%", "70%"],
|
||||
data: [
|
||||
{ value: 40, name: "技术文章" },
|
||||
{ value: 30, name: "行业资讯" },
|
||||
{ value: 20, name: "视频教程" },
|
||||
{ value: 10, name: "资源分享" },
|
||||
],
|
||||
emphasis: {
|
||||
label: { show: true, fontSize: "16", fontWeight: "bold" },
|
||||
},
|
||||
label: { show: false, position: "center" },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
barChart.value?.resize();
|
||||
categoryChart.value?.resize();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchContentStats();
|
||||
initCharts();
|
||||
window.addEventListener("resize", handleResize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.content-stats-container {
|
||||
min-height: 100vh;
|
||||
|
||||
.stat-cards {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.stat-card {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
.label {
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.count {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.sub-info {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
.plus {
|
||||
color: #52c41a;
|
||||
}
|
||||
.minus {
|
||||
color: #f5222d;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-charts {
|
||||
margin-bottom: 24px;
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.chart-box {
|
||||
height: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-row {
|
||||
:deep(.el-table) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,493 @@
|
||||
<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) {
|
||||
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>
|
||||
@@ -0,0 +1,269 @@
|
||||
<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 type="text" class="danger" title="删除" @click.stop="$emit('delete', item)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</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>
|
||||
@@ -0,0 +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>
|
||||
@@ -0,0 +1,686 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="visible"
|
||||
:title="isEdit ? '编辑文章' : '新增文章'"
|
||||
size="60%"
|
||||
:before-close="handleBeforeClose"
|
||||
>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px">
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="form.title" placeholder="请输入文章标题" />
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="分类" prop="cate">
|
||||
<el-cascader
|
||||
v-model="form.cate"
|
||||
:options="cateOptions"
|
||||
placeholder="选择分类"
|
||||
clearable
|
||||
:props="{ expandTrigger: 'hover', emitPath: false }"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="作者" prop="author">
|
||||
<el-input v-model="form.author" placeholder="请输入作者姓名" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="简介" prop="desc">
|
||||
<el-input
|
||||
v-model="form.desc"
|
||||
:rows="4"
|
||||
type="textarea"
|
||||
placeholder="请输入简介"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="封面图片" prop="image">
|
||||
<div class="uploads">
|
||||
<!-- 已有图片显示 -->
|
||||
<div
|
||||
v-if="form.image && fileList.length === 0"
|
||||
class="existing-image"
|
||||
>
|
||||
<img
|
||||
:src="API_BASE_URL + form.image.replace(/\\\//g, '/')"
|
||||
alt="已有图片"
|
||||
/>
|
||||
<div class="image-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="previewExistingImage"
|
||||
>预览</el-button
|
||||
>
|
||||
<el-button type="danger" size="small" @click="removeExistingImage"
|
||||
>删除</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传组件 -->
|
||||
<el-upload
|
||||
v-model:file-list="fileList"
|
||||
:auto-upload="false"
|
||||
:before-upload="beforeImgUpload"
|
||||
list-type="picture-card"
|
||||
:limit="1"
|
||||
:show-file-list="fileList.length > 0"
|
||||
>
|
||||
<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-drawer v-model="drawerVisible" width="60%" center>
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
"
|
||||
>
|
||||
<img
|
||||
:src="drawerImageUrl"
|
||||
alt="Preview Image"
|
||||
style="max-width: 100%; max-height: 70vh; object-fit: contain"
|
||||
/>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<div class="upload-tip">
|
||||
<span>建议尺寸:250px × 140px</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<el-form-item label="是否转载" prop="is_trans">
|
||||
<el-radio-group v-model="form.is_trans">
|
||||
<el-radio-button :value="0">否</el-radio-button>
|
||||
<el-radio-button :value="1">是</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="发布地址" prop="transurl" v-if="form.is_trans === 1">
|
||||
<el-input v-model="form.transurl" placeholder="请输入转载文章地址" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="内容" prop="content">
|
||||
<div class="editor-container">
|
||||
<WangEditor v-model="form.content" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-divider></el-divider>
|
||||
<span class="drawer-footer">
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button
|
||||
v-if="!isEdit"
|
||||
type="warning"
|
||||
@click="handleConfirm(0)"
|
||||
:loading="submitLoading"
|
||||
>草稿</el-button
|
||||
>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleConfirm(1)"
|
||||
:loading="submitLoading"
|
||||
>提交</el-button
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, watch, nextTick, onMounted, computed } from "vue";
|
||||
import { ElMessage, ElUpload } from "element-plus";
|
||||
import WangEditor from "@/views/components/WangEditor.vue";
|
||||
import { createArticle, editArticle, listCategories } from "@/api/article.js";
|
||||
import { uploadFile } from "@/api/file.js";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isEdit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
model: {
|
||||
type: Object,
|
||||
default: () => null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "saved"]);
|
||||
|
||||
const visible = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const formRef = ref(null);
|
||||
|
||||
const handleBeforeClose = (done: () => void) => {
|
||||
handleCancel();
|
||||
done();
|
||||
};
|
||||
|
||||
const cateOptions = ref([]);
|
||||
|
||||
const form = reactive({
|
||||
title: "",
|
||||
author: "",
|
||||
cate: "",
|
||||
content: "",
|
||||
image: "",
|
||||
desc: "",
|
||||
is_trans: 0,
|
||||
transurl: null,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
title: [
|
||||
{ required: true, message: "请输入文章标题", trigger: "blur" },
|
||||
{
|
||||
min: 2,
|
||||
max: 200,
|
||||
message: "标题长度在 2 到 200 个字符",
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
author: [
|
||||
{ required: true, message: "请输入作者", trigger: "blur" },
|
||||
{ max: 50, message: "作者姓名不能超过50个字符", trigger: "blur" },
|
||||
],
|
||||
content: [{ required: true, message: "请输入文章内容", trigger: "blur" }],
|
||||
};
|
||||
|
||||
// 获取分类列表
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
const res = await listCategories();
|
||||
if (res.code === 200) {
|
||||
const categories = Array.isArray(res.data) ? res.data : [];
|
||||
// 将分类转换为树形结构
|
||||
cateOptions.value = buildCategoryTree(categories);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取分类失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 构建分类树形结构
|
||||
function buildCategoryTree(categories, parentCid = 0) {
|
||||
return categories
|
||||
.filter((cate) => Number(cate.cid) === Number(parentCid))
|
||||
.map((cate) => {
|
||||
const children = buildCategoryTree(categories, cate.id);
|
||||
return {
|
||||
...cate,
|
||||
label: cate.name,
|
||||
value: cate.id,
|
||||
children: children.length > 0 ? children : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 上传相关
|
||||
const fileList = ref<any[]>([]);
|
||||
const drawerVisible = ref(false);
|
||||
const drawerImageUrl = ref("");
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
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 handlePictureCardPreview(file: any) {
|
||||
drawerImageUrl.value = file.url;
|
||||
drawerVisible.value = true;
|
||||
}
|
||||
|
||||
function handleRemove(file: any) {
|
||||
fileList.value = [];
|
||||
form.image = "";
|
||||
}
|
||||
|
||||
function removeExistingImage() {
|
||||
form.image = "";
|
||||
}
|
||||
|
||||
function previewExistingImage() {
|
||||
const imagePath = form.image.replace(/\\\//g, "/");
|
||||
drawerImageUrl.value = API_BASE_URL + imagePath;
|
||||
drawerVisible.value = true;
|
||||
}
|
||||
|
||||
// 监听对话框显示状态
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
visible.value = newVal;
|
||||
if (newVal) {
|
||||
if (props.isEdit && props.model) {
|
||||
// 编辑模式,填充表单数据
|
||||
const modelData = props.model._raw || props.model;
|
||||
nextTick(() => {
|
||||
Object.assign(form, {
|
||||
title: modelData.title || "",
|
||||
author: modelData.author || "",
|
||||
cate: modelData.cate || "",
|
||||
content: modelData.content || "",
|
||||
desc: modelData.desc || "",
|
||||
is_trans: modelData.is_trans || 0,
|
||||
transurl: modelData.transurl || null,
|
||||
image: modelData.image || "",
|
||||
});
|
||||
fileList.value = []; // 重置文件列表
|
||||
});
|
||||
} else {
|
||||
// 新增模式,重置表单
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function handleCancel() {
|
||||
emit("update:modelValue", false);
|
||||
resetForm();
|
||||
}
|
||||
|
||||
async function handleConfirm(status = 1) {
|
||||
formRef.value?.validate(async (valid) => {
|
||||
if (valid) {
|
||||
submitLoading.value = true;
|
||||
|
||||
try {
|
||||
let imageUrl = form.image;
|
||||
|
||||
// 如果有新选择的文件,先上传图片
|
||||
if (fileList.value.length > 0 && fileList.value[0].raw) {
|
||||
const uploadFormData = new FormData();
|
||||
uploadFormData.append("file", fileList.value[0].raw);
|
||||
uploadFormData.append("cate", "article");
|
||||
|
||||
const uploadRes = await uploadFile(uploadFormData);
|
||||
// 200=新文件上传成功,201=文件已存在(使用已有文件的链接)
|
||||
if (
|
||||
(uploadRes.code === 200 || uploadRes.code === 201) &&
|
||||
uploadRes.data &&
|
||||
uploadRes.data.url
|
||||
) {
|
||||
imageUrl = uploadRes.data.url;
|
||||
} else {
|
||||
ElMessage.error("图片上传失败:" + (uploadRes.msg || "未知错误"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加状态参数,0为草稿,1为提交
|
||||
const submitData = {
|
||||
title: form.title,
|
||||
author: form.author,
|
||||
cate: form.cate,
|
||||
content: form.content,
|
||||
desc: form.desc,
|
||||
image: imageUrl,
|
||||
is_trans: form.is_trans,
|
||||
transurl: form.is_trans === 1 ? form.transurl : null,
|
||||
status: status, // 0为草稿,1为提交
|
||||
};
|
||||
|
||||
// 根据新增/编辑模式选择不同的API调用函数
|
||||
const isCreateMode = !props.isEdit;
|
||||
let res;
|
||||
let resp;
|
||||
|
||||
if (isCreateMode) {
|
||||
// 新增模式,使用createArticle接口
|
||||
const createArticleWithCheck = async (ignoreSimilarity = false) => {
|
||||
const data = {
|
||||
...submitData,
|
||||
...(ignoreSimilarity && { ignore_similarity: 1 }),
|
||||
};
|
||||
return await createArticle(data);
|
||||
};
|
||||
|
||||
res = await createArticleWithCheck();
|
||||
resp =
|
||||
res && typeof res.code !== "undefined"
|
||||
? res
|
||||
: res && res.data
|
||||
? res.data
|
||||
: res;
|
||||
|
||||
// 检测到相似标题,显示确认对话框
|
||||
if (resp && resp.code === 409) {
|
||||
const similarArticles = resp.data?.similar_articles || [];
|
||||
// 构建带样式的确认消息
|
||||
const similarArticlesList = similarArticles
|
||||
.map(
|
||||
(article: any) =>
|
||||
`\n<div style="margin: 8px 0; padding: 8px; background: #f5f7fa; border-radius: 4px;">\n<div style="font-weight: bold; color: #303133;">${article.title}</div>\n<div style="color: #606266; font-size: 14px; margin-top: 4px;">相似度:${article.similarity}%</div>\n</div>\n`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const confirmMessage = `\n<div>\n\n${similarArticlesList} \n<p style="margin-top: 16px; color: #303133;">是否继续创建?</p>\n</div>\n`;
|
||||
|
||||
// 使用Element Plus的confirm对话框
|
||||
const { ElMessageBox } = await import("element-plus");
|
||||
try {
|
||||
await ElMessageBox.confirm(confirmMessage, "检测到相似标题", {
|
||||
confirmButtonText: "继续创建",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
dangerouslyUseHTMLString: true,
|
||||
});
|
||||
// 用户确认继续,再次调用接口并忽略相似度检测
|
||||
res = await createArticleWithCheck(true);
|
||||
resp =
|
||||
res && typeof res.code !== "undefined"
|
||||
? res
|
||||
: res && res.data
|
||||
? res.data
|
||||
: res;
|
||||
} catch (error) {
|
||||
// 用户取消创建
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 编辑模式,使用editArticle接口
|
||||
// 获取文章ID
|
||||
const modelData = props.model._raw || props.model;
|
||||
const articleId = modelData.id;
|
||||
|
||||
if (!articleId) {
|
||||
ElMessage.error("文章ID不存在,无法更新");
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建编辑请求数据,不包含文章ID(ID通过URL参数传递)
|
||||
const editData = {
|
||||
...submitData,
|
||||
};
|
||||
|
||||
res = await editArticle(articleId, editData);
|
||||
resp =
|
||||
res && typeof res.code !== "undefined"
|
||||
? res
|
||||
: res && res.data
|
||||
? res.data
|
||||
: res;
|
||||
}
|
||||
|
||||
if (
|
||||
resp &&
|
||||
resp.code === 200 &&
|
||||
(resp.message === "success" || resp.msg === "success")
|
||||
) {
|
||||
ElMessage.success(
|
||||
status === 0
|
||||
? "保存草稿成功"
|
||||
: props.isEdit
|
||||
? "更新成功"
|
||||
: "创建成功",
|
||||
);
|
||||
emit("update:modelValue", false);
|
||||
emit("saved");
|
||||
} else {
|
||||
ElMessage.error(
|
||||
(resp && resp.message) ||
|
||||
(resp && resp.msg) ||
|
||||
(status === 0
|
||||
? "保存草稿失败"
|
||||
: props.isEdit
|
||||
? "更新失败"
|
||||
: "创建失败"),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(
|
||||
status === 0
|
||||
? "保存草稿失败"
|
||||
: props.isEdit
|
||||
? "更新失败"
|
||||
: "创建失败",
|
||||
);
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
// 重置表单
|
||||
if (formRef.value) {
|
||||
formRef.value.resetFields();
|
||||
}
|
||||
// 重置文件列表
|
||||
fileList.value = [];
|
||||
// 重置表单数据
|
||||
Object.assign(form, {
|
||||
title: "",
|
||||
author: "",
|
||||
cate: "",
|
||||
content: "",
|
||||
image: "",
|
||||
desc: "",
|
||||
is_trans: 0,
|
||||
transurl: null,
|
||||
});
|
||||
}
|
||||
|
||||
// 暴露重置方法(如果需要的话)
|
||||
defineExpose({
|
||||
resetForm: () => {
|
||||
formRef.value?.resetFields();
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchCategories();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.editor-container {
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
&:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
:deep(.w-e-text),
|
||||
:deep(.w-e-text-container) {
|
||||
p {
|
||||
color: #1a1a2e !important;
|
||||
margin: 0.5em 0 !important;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: #1a1a2e !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #3973ff !important;
|
||||
text-decoration: underline !important;
|
||||
|
||||
&:hover {
|
||||
color: #3973ff !important;
|
||||
opacity: 0.8 !important;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: #f5f7fa !important;
|
||||
color: #1a1a2e !important;
|
||||
border: 1px solid #e4e7ed !important;
|
||||
padding: 2px 6px !important;
|
||||
border-radius: 3px !important;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: #f5f7fa !important;
|
||||
border: 1px solid #e4e7ed !important;
|
||||
color: #1a1a2e !important;
|
||||
border-radius: 4px !important;
|
||||
padding: 12px 16px;
|
||||
margin: 12px 0;
|
||||
overflow-x: auto;
|
||||
|
||||
code {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 4px solid #3973ff !important;
|
||||
background-color: #f5f7fa !important;
|
||||
color: #606266 !important;
|
||||
padding: 0.6em 1.2em !important;
|
||||
margin: 1em 0 !important;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse !important;
|
||||
border: 1px solid #e4e7ed !important;
|
||||
|
||||
th, td {
|
||||
border: 1px solid #e4e7ed !important;
|
||||
background-color: #ffffff !important;
|
||||
color: #1a1a2e !important;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f5f7fa !important;
|
||||
}
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
color: #1a1a2e !important;
|
||||
padding-left: 24px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
li {
|
||||
color: #1a1a2e !important;
|
||||
line-height: 1.8;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-top: 1px solid #e4e7ed !important;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.uploads {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.upload-tip {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.existing-image {
|
||||
position: relative;
|
||||
width: 148px;
|
||||
height: 148px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.existing-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.existing-image:hover .image-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
::deep(.el-message-box) {
|
||||
width: 800px !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,347 @@
|
||||
<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) return "无分类";
|
||||
// 确保比较时类型一致
|
||||
const cateId = Number(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>
|
||||
@@ -0,0 +1,670 @@
|
||||
<template>
|
||||
<div class="cms-articles">
|
||||
<div class="articles-container">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="handleAdd">新增文章</el-button>
|
||||
<el-button @click="handleRefresh">刷新</el-button>
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索文章标题"
|
||||
clearable
|
||||
@clear="handleSearch"
|
||||
>
|
||||
<template #append>
|
||||
<el-button :icon="Search" @click="handleSearch" />
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选条件 -->
|
||||
<div class="filters">
|
||||
<!-- 根据分类筛选 -->
|
||||
<el-select
|
||||
v-model="categoryFilter"
|
||||
placeholder="选择分类"
|
||||
clearable
|
||||
@change="handleFilterChange"
|
||||
style="width: 150px; margin-right: 10px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in categoryOptions"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<!-- 根据日期筛选 -->
|
||||
<el-date-picker
|
||||
v-model="dateFilter"
|
||||
type="daterange"
|
||||
value-format="yyyy-MM-dd"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
style="width: 240px; margin-right: 10px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 文章列表 -->
|
||||
<el-table
|
||||
:data="articleList"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
border
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column
|
||||
prop="title"
|
||||
label="标题"
|
||||
min-width="400"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div style="display: flex; align-items: center; gap: 4px">
|
||||
<!-- 置顶标签 -->
|
||||
<el-tag
|
||||
v-if="row.top === 1"
|
||||
type="danger"
|
||||
size="small"
|
||||
effect="dark"
|
||||
>
|
||||
置顶
|
||||
</el-tag>
|
||||
<!-- 推荐标签 -->
|
||||
<el-tag
|
||||
v-if="row.recommend === 1"
|
||||
type="warning"
|
||||
size="small"
|
||||
effect="dark"
|
||||
>
|
||||
推荐
|
||||
</el-tag>
|
||||
<!-- 标题链接 -->
|
||||
<el-link
|
||||
type="primary"
|
||||
@click="handleView(row)"
|
||||
underline="never"
|
||||
>
|
||||
{{ row.title }}
|
||||
</el-link>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="cate" label="文章分类" width="120" />
|
||||
<el-table-column prop="author" label="作者" width="120" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)" size="small">
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="views"
|
||||
label="浏览量"
|
||||
width="100"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="likes"
|
||||
label="点赞量"
|
||||
width="100"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column prop="publish_date" label="发布时间" width="160" />
|
||||
<el-table-column prop="update_time" label="更新时间" width="160" />
|
||||
<el-table-column prop="publisher" label="发布人" width="120" />
|
||||
<el-table-column label="操作" width="260" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.status != 2 && row.status != 0"
|
||||
size="small"
|
||||
type=""
|
||||
@click="handlePulish(row)"
|
||||
>发布</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status != 2"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handleEdit(row)"
|
||||
>编辑</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status != 2"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleDelete(row)"
|
||||
>删除</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status === 2"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleUnPulish(row)"
|
||||
>下架</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status === 2 && row.recommend === 0"
|
||||
size="small"
|
||||
type=""
|
||||
@click="handleRecommend(row)"
|
||||
>推荐</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status === 2 && row.recommend === 1"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleUnRecommend(row)"
|
||||
>取消推荐</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status === 2 && row.top === 0"
|
||||
size="small"
|
||||
type=""
|
||||
@click="handleTop(row)"
|
||||
>置顶</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status === 2 && row.top === 1"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleUnTop(row)"
|
||||
>取消置顶</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<Edit
|
||||
v-model="dialogVisible"
|
||||
:is-edit="isEdit"
|
||||
:model="currentRow"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
|
||||
<!-- 预览抽屉 -->
|
||||
<Preview v-model="previewVisible" :model="currentRow" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Search } from "@element-plus/icons-vue";
|
||||
import Edit from "./components/edit.vue";
|
||||
import Preview from "./components/preview.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import {
|
||||
listArticles,
|
||||
deleteArticle,
|
||||
listCategories,
|
||||
publishArticle,
|
||||
unPublishArticle,
|
||||
getArticle,
|
||||
articleRecommend,
|
||||
articleTop,
|
||||
unArticleRecommend,
|
||||
unArticleTop,
|
||||
} from "@/api/article.js";
|
||||
|
||||
const loading = ref(false);
|
||||
const articleList = ref([]);
|
||||
const searchQuery = ref("");
|
||||
const categoryFilter = ref("");
|
||||
const categoryOptions = ref([]);
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const total = ref(0);
|
||||
const dialogVisible = ref(false);
|
||||
const previewVisible = ref(false);
|
||||
const isEdit = ref(false);
|
||||
const currentRow = ref(null);
|
||||
|
||||
// 使用 auth store 获取用户信息
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// 获取用户信息
|
||||
const userInfo = authStore.user;
|
||||
if (userInfo && userInfo.id) {
|
||||
// console.log('用户名:', userInfo.account || userInfo.name);
|
||||
// console.log('用户ID:', userInfo.id);
|
||||
// console.log('角色:', userInfo.role);
|
||||
} else {
|
||||
console.log("未找到用户信息或用户未登录");
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
function getStatusText(status) {
|
||||
const statusMap = {
|
||||
0: "草稿",
|
||||
1: "待审核",
|
||||
2: "已发布",
|
||||
3: "已隐藏",
|
||||
};
|
||||
return statusMap[status] || "未知";
|
||||
}
|
||||
|
||||
// 获取状态对应的标签类型
|
||||
function getStatusType(status) {
|
||||
const typeMap = {
|
||||
0: "info",
|
||||
1: "warning",
|
||||
2: "success",
|
||||
3: "danger",
|
||||
};
|
||||
return typeMap[status] || "info";
|
||||
}
|
||||
|
||||
// 获取推荐文本
|
||||
function getRecommendText(status) {
|
||||
const statusMap = {
|
||||
0: "未推荐",
|
||||
1: "推荐",
|
||||
};
|
||||
return statusMap[status] || "未知";
|
||||
}
|
||||
|
||||
// 获取推荐对应的标签类型
|
||||
function getRecommendType(status) {
|
||||
const typeMap = {
|
||||
0: "info",
|
||||
1: "success",
|
||||
};
|
||||
return typeMap[status] || "info";
|
||||
}
|
||||
|
||||
// 获取文章列表
|
||||
async function fetchArticleList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
keyword: searchQuery.value,
|
||||
cate: categoryFilter.value,
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
};
|
||||
|
||||
const res = await listArticles(params);
|
||||
|
||||
if (res.code === 200) {
|
||||
articleList.value = res.data.list || [];
|
||||
total.value = res.data.total || 0;
|
||||
} else {
|
||||
console.error("获取文章列表失败:", res.msg);
|
||||
ElMessage.error(res.msg || "获取文章列表失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("请求异常:", error);
|
||||
ElMessage.error("网络请求失败,请稍后重试");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
// 处理筛选变化
|
||||
function handleFilterChange() {
|
||||
currentPage.value = 1;
|
||||
fetchArticleList();
|
||||
}
|
||||
|
||||
// 处理每页条数变化
|
||||
function handleSizeChange(val) {
|
||||
pageSize.value = val;
|
||||
fetchArticleList();
|
||||
}
|
||||
|
||||
// 处理当前页变化
|
||||
function handleCurrentChange(val) {
|
||||
fetchArticleList();
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
isEdit.value = false;
|
||||
currentRow.value = null;
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEdit(row) {
|
||||
isEdit.value = true;
|
||||
// 获取详情
|
||||
getArticle(row.id).then((res) => {
|
||||
const resp =
|
||||
res && typeof res.code !== "undefined"
|
||||
? res
|
||||
: res && res.data
|
||||
? res.data
|
||||
: res;
|
||||
if (resp && resp.code === 200 && resp.data) {
|
||||
const m = resp.data;
|
||||
currentRow.value = {
|
||||
id: m.id,
|
||||
title: m.title || "",
|
||||
author: m.author || "",
|
||||
cate: m.cate || "",
|
||||
content: m.content || "",
|
||||
desc: m.desc || "",
|
||||
publish_time: m.publish_time || null,
|
||||
_raw: m,
|
||||
};
|
||||
} else {
|
||||
currentRow.value = { ...row };
|
||||
}
|
||||
dialogVisible.value = true;
|
||||
});
|
||||
}
|
||||
|
||||
function handleView(row) {
|
||||
// 获取最新详情再预览
|
||||
getArticle(row.id).then((res) => {
|
||||
const resp =
|
||||
res && typeof res.code !== "undefined"
|
||||
? res
|
||||
: res && res.data
|
||||
? res.data
|
||||
: res;
|
||||
if (resp && resp.code === 200 && resp.data) {
|
||||
const m = resp.data;
|
||||
currentRow.value = {
|
||||
id: m.id,
|
||||
title: m.title || "",
|
||||
author: m.author || "",
|
||||
cate: m.cate || "",
|
||||
content: m.content || "",
|
||||
desc: m.desc || "",
|
||||
view_count: m.view_count || 0,
|
||||
publisher: m.publisher || "",
|
||||
create_time: m.create_time || null,
|
||||
publish_time: m.publish_time || null,
|
||||
update_time: m.update_time || null,
|
||||
_raw: m,
|
||||
};
|
||||
previewVisible.value = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete(row) {
|
||||
ElMessageBox.confirm("确定要删除这篇文章吗?此操作不可恢复。", "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
deleteArticle(row.id).then((res) => {
|
||||
const resp =
|
||||
res && typeof res.code !== "undefined"
|
||||
? res
|
||||
: res && res.data
|
||||
? res.data
|
||||
: res;
|
||||
if (resp && resp.code === 200) {
|
||||
ElMessage.success("删除成功");
|
||||
fetchArticleList();
|
||||
} else {
|
||||
ElMessage.error((resp && resp.msg) || "删除失败");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const uid = userInfo.id;
|
||||
|
||||
//发布文章
|
||||
function handlePulish(row) {
|
||||
ElMessageBox.confirm("确认发布该文章吗?发布后将在前台显示。", "确认发布", {
|
||||
confirmButtonText: "确认发布",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(() => {
|
||||
publishArticle(row.id, uid)
|
||||
.then((res) => {
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("发布成功");
|
||||
fetchArticleList();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "发布失败");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("发布失败:", error);
|
||||
ElMessage.error(error.msg || "发布失败");
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// 下架文章
|
||||
function handleUnPulish(row) {
|
||||
ElMessageBox.confirm("确认下架该文章吗?", "确认", {
|
||||
confirmButtonText: "确认",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(() => {
|
||||
unPublishArticle(row.id)
|
||||
.then((res) => {
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("下架成功");
|
||||
fetchArticleList();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "下架失败");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("下架失败:", error);
|
||||
ElMessage.error(error.msg || "下架失败");
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
//推荐文章
|
||||
function handleRecommend(row) {
|
||||
ElMessageBox.confirm("确认推荐该文章吗?推荐后将在前台显示。", "确认推荐", {
|
||||
confirmButtonText: "确认推荐",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(() => {
|
||||
articleRecommend(row.id)
|
||||
.then((res) => {
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("推荐成功");
|
||||
fetchArticleList();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "推荐失败");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("推荐失败:", error);
|
||||
ElMessage.error(error.msg || "推荐失败");
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// 取消推荐文章
|
||||
function handleUnRecommend(row) {
|
||||
ElMessageBox.confirm("确认取消推荐该文章吗?", "确认", {
|
||||
confirmButtonText: "确认",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(() => {
|
||||
unArticleRecommend(row.id)
|
||||
.then((res) => {
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("取消推荐成功");
|
||||
fetchArticleList();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "取消推荐失败");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("取消推荐失败:", error);
|
||||
ElMessage.error(error.msg || "取消推荐失败");
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
//置顶文章
|
||||
function handleTop(row) {
|
||||
ElMessageBox.confirm("确认置顶该文章吗?置顶后将在前台显示。", "确认置顶", {
|
||||
confirmButtonText: "确认置顶",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(() => {
|
||||
articleTop(row.id)
|
||||
.then((res) => {
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("置顶成功");
|
||||
fetchArticleList();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "置顶失败");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("置顶失败:", error);
|
||||
ElMessage.error(error.msg || "置顶失败");
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// 取消置顶文章
|
||||
function handleUnTop(row) {
|
||||
ElMessageBox.confirm("确认取消置顶该文章吗?", "确认", {
|
||||
confirmButtonText: "确认",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(() => {
|
||||
unArticleTop(row.id)
|
||||
.then((res) => {
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("取消置顶成功");
|
||||
fetchArticleList();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "取消置顶失败");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("取消置顶失败:", error);
|
||||
ElMessage.error(error.msg || "取消置顶失败");
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
currentPage.value = 1;
|
||||
fetchArticleList();
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
searchQuery.value = "";
|
||||
categoryFilter.value = "";
|
||||
currentPage.value = 1;
|
||||
fetchArticleList();
|
||||
}
|
||||
|
||||
// 处理保存成功回调
|
||||
function onSaved() {
|
||||
dialogVisible.value = false;
|
||||
fetchArticleList();
|
||||
ElMessage.success("保存成功");
|
||||
}
|
||||
|
||||
// 获取分类列表
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const res = await listCategories({ page: 1, limit: 1000 });
|
||||
|
||||
if (res && res.code === 200) {
|
||||
if (res.data && Array.isArray(res.data.list)) {
|
||||
categoryOptions.value = res.data.list;
|
||||
} else if (Array.isArray(res.data)) {
|
||||
categoryOptions.value = res.data;
|
||||
} else if (Array.isArray(res.list)) {
|
||||
categoryOptions.value = res.list;
|
||||
}
|
||||
|
||||
if (categoryOptions.value.length === 0) {
|
||||
ElMessage.warning("未获取到分类数据");
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "获取分类列表失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("获取分类列表失败");
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
fetchArticleList(),
|
||||
fetchCategories(), // 获取分类数据
|
||||
]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.cms-articles {
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
height: 100%;
|
||||
background: var(--el-bg-color);
|
||||
|
||||
.articles-container {
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.search-bar {
|
||||
margin-left: auto;
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,332 @@
|
||||
<template>
|
||||
<!-- 添加/编辑Banner对话框 -->
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="dialogTitle"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="handleDialogClose"
|
||||
>
|
||||
<el-form
|
||||
:model="currentBanner"
|
||||
label-width="100px"
|
||||
:rules="formRules"
|
||||
ref="bannerFormRef"
|
||||
>
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input
|
||||
v-model="currentBanner.title"
|
||||
placeholder="请输入Banner标题"
|
||||
maxlength="255"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="简介" prop="desc">
|
||||
<el-input
|
||||
v-model="currentBanner.desc"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入Banner简介"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="跳转地址" prop="url">
|
||||
<el-input
|
||||
v-model="currentBanner.url"
|
||||
placeholder="例如:https://www.example.com 或 /page/detail"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
支持外部链接(http://)和内部路由(/)
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Banner图片" prop="image">
|
||||
<el-upload
|
||||
class="image-uploader"
|
||||
:action="uploadUrl"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="handleImageSuccess"
|
||||
:on-error="handleImageError"
|
||||
:before-upload="beforeImageUpload"
|
||||
accept="*"
|
||||
>
|
||||
<img
|
||||
v-if="currentBanner.image"
|
||||
:src="getImageUrl(currentBanner.image)"
|
||||
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);">
|
||||
建议尺寸:1920x600,支持 jpg、png、gif 格式,大小不超过 5MB
|
||||
</div>
|
||||
<el-button
|
||||
v-if="currentBanner.image"
|
||||
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="currentBanner.sort"
|
||||
:min="0"
|
||||
placeholder="数字越小越靠前"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
数字越小,排序越靠前
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">确定</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { ElMessage, ElForm } from "element-plus";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
|
||||
// 定义Banner数据类型
|
||||
interface Banner {
|
||||
id: number;
|
||||
title: string;
|
||||
desc: string;
|
||||
url: string;
|
||||
image: string;
|
||||
sort: number;
|
||||
create_time?: number;
|
||||
update_time?: number;
|
||||
delete_time?: number;
|
||||
}
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
banner: Partial<Banner> | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
visible: false,
|
||||
banner: null,
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: "update:visible", value: boolean): void;
|
||||
(e: "save", banner: Partial<Banner>): void;
|
||||
(e: "cancel"): void;
|
||||
}>();
|
||||
|
||||
// 表单引用
|
||||
const bannerFormRef = ref<InstanceType<typeof ElForm>>();
|
||||
|
||||
// 当前操作的Banner
|
||||
const currentBanner = ref<Partial<Banner>>({
|
||||
id: 0,
|
||||
title: "",
|
||||
desc: "",
|
||||
url: "",
|
||||
image: "",
|
||||
sort: 0,
|
||||
});
|
||||
|
||||
// 上传配置
|
||||
// @ts-ignore
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||
const uploadUrl = ref(API_BASE_URL + "/admin/uploadfiles");
|
||||
const uploadHeaders = ref({
|
||||
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
|
||||
});
|
||||
|
||||
// 对话框标题
|
||||
const dialogTitle = computed(() => {
|
||||
return props.banner?.id && props.banner.id > 0 ? "编辑Banner" : "添加Banner";
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = ref({
|
||||
title: [{ required: true, message: "请输入Banner标题", trigger: "blur" }],
|
||||
sort: [{ required: true, message: "请输入排序号", trigger: "blur" }],
|
||||
});
|
||||
|
||||
// 监听props变化,更新当前Banner
|
||||
watch(
|
||||
() => props.banner,
|
||||
(newBanner) => {
|
||||
if (newBanner) {
|
||||
currentBanner.value = {
|
||||
...newBanner,
|
||||
};
|
||||
} else {
|
||||
// 重置表单
|
||||
currentBanner.value = {
|
||||
id: 0,
|
||||
title: "",
|
||||
desc: "",
|
||||
url: "",
|
||||
image: "",
|
||||
sort: 0,
|
||||
};
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 监听props.visible变化
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVisible) => {
|
||||
if (newVisible && (!props.banner || !props.banner.id)) {
|
||||
// 新增时重置表单
|
||||
currentBanner.value = {
|
||||
id: 0,
|
||||
title: "",
|
||||
desc: "",
|
||||
url: "",
|
||||
image: "",
|
||||
sort: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
emit("update:visible", false);
|
||||
emit("cancel");
|
||||
};
|
||||
|
||||
// 处理对话框关闭
|
||||
const handleDialogClose = (value: boolean) => {
|
||||
if (!value) {
|
||||
emit("update:visible", false);
|
||||
emit("cancel");
|
||||
}
|
||||
};
|
||||
|
||||
// 图片上传前校验
|
||||
const beforeImageUpload = (file: any) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
// 图片上传成功
|
||||
const handleImageSuccess = (response: any) => {
|
||||
if (response.code === 200) {
|
||||
// 直接保存相对路径,不拼接API_BASE_URL
|
||||
currentBanner.value.image = response.data.url || response.data.path;
|
||||
ElMessage.success("图片上传成功");
|
||||
} else {
|
||||
ElMessage.error(response.msg || "图片上传失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 图片上传失败
|
||||
const handleImageError = () => {
|
||||
ElMessage.error("图片上传失败,请重试");
|
||||
};
|
||||
|
||||
// 删除图片
|
||||
const handleRemoveImage = () => {
|
||||
currentBanner.value.image = "";
|
||||
ElMessage.success("图片已删除");
|
||||
};
|
||||
|
||||
// 获取图片完整URL(用于预览)
|
||||
const getImageUrl = (imagePath: string) => {
|
||||
if (!imagePath) return "";
|
||||
// 如果是绝对路径,直接返回
|
||||
if (imagePath.startsWith("http")) return imagePath;
|
||||
// 如果是相对路径,拼接API基础地址
|
||||
return API_BASE_URL + imagePath;
|
||||
};
|
||||
|
||||
// 保存Banner
|
||||
const handleSave = async () => {
|
||||
// 表单验证
|
||||
if (!bannerFormRef.value) return;
|
||||
const valid = await bannerFormRef.value.validate();
|
||||
if (!valid) return;
|
||||
|
||||
// 准备提交数据
|
||||
const payload = { ...currentBanner.value };
|
||||
|
||||
// 触发保存事件
|
||||
emit("save", payload);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
/* 对话框样式精简 */
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 图片上传样式 */
|
||||
.image-uploader {
|
||||
:deep(.el-upload) {
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: var(--el-transition-duration-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
height: auto;
|
||||
max-height: 300px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
.image-uploader-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.el-upload__text {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,301 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>Banner管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="handleAddBanner">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加Banner
|
||||
</el-button>
|
||||
<el-button @click="refresh" :loading="loading">
|
||||
<el-icon>
|
||||
<Refresh />
|
||||
</el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table
|
||||
:data="bannerList"
|
||||
style="width: 100%"
|
||||
border
|
||||
v-loading="loading"
|
||||
element-loading-text="正在加载..."
|
||||
>
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
|
||||
<el-table-column prop="title" label="标题" min-width="150" />
|
||||
|
||||
<el-table-column label="图片" width="200" align="center">
|
||||
<template #default="scope">
|
||||
<el-image
|
||||
v-if="scope.row.image"
|
||||
:src="getImageUrl(scope.row.image)"
|
||||
:preview-src-list="[getImageUrl(scope.row.image)]"
|
||||
:preview-teleported="true"
|
||||
fit="cover"
|
||||
style="width: 100px; height: 60px; border-radius: 4px; cursor: pointer;"
|
||||
/>
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="desc" label="简介" min-width="200" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.desc">{{ scope.row.desc }}</span>
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="url" label="跳转地址" min-width="200" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<el-link v-if="scope.row.url" :href="scope.row.url" target="_blank" type="primary">
|
||||
{{ scope.row.url }}
|
||||
</el-link>
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
prop="sort"
|
||||
label="排序"
|
||||
width="100"
|
||||
align="center"
|
||||
sortable
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag>{{ scope.row.sort || 0 }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="200" fixed="right" align="center">
|
||||
<template #default="scope">
|
||||
<el-button size="small" text @click="handleEditBanner(scope.row)">
|
||||
<el-icon>
|
||||
<Edit />
|
||||
</el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
type="danger"
|
||||
@click="handleDeleteBanner(scope.row)"
|
||||
>
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 引入编辑组件 -->
|
||||
<BannerEdit
|
||||
v-model:visible="dialogVisible"
|
||||
:banner="dialogBanner"
|
||||
@save="handleBannerSave"
|
||||
@cancel="handleBannerCancel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus, Edit, Delete, Refresh } from "@element-plus/icons-vue";
|
||||
import {
|
||||
getBanners,
|
||||
createBanner,
|
||||
editBanner,
|
||||
deleteBanner,
|
||||
} from "@/api/banner";
|
||||
import BannerEdit from "./components/edit.vue";
|
||||
|
||||
// 定义Banner数据类型
|
||||
interface Banner {
|
||||
id: number;
|
||||
title: string;
|
||||
desc: string;
|
||||
url: string;
|
||||
image: string;
|
||||
sort: number;
|
||||
create_time?: number;
|
||||
update_time?: number;
|
||||
delete_time?: number;
|
||||
}
|
||||
|
||||
// Banner列表
|
||||
const bannerList = ref<Banner[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// 对话框相关变量
|
||||
const dialogVisible = ref(false);
|
||||
const dialogBanner = ref<Partial<Banner> | null>(null);
|
||||
|
||||
// 获取Banner列表
|
||||
const fetchBanners = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await getBanners();
|
||||
if (result.code === 200) {
|
||||
bannerList.value = result.data || [];
|
||||
} else {
|
||||
ElMessage.error("获取Banner列表失败: " + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("获取Banner列表失败: " + (error as Error).message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新界面
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await fetchBanners();
|
||||
ElMessage.success("刷新成功");
|
||||
} catch (error) {
|
||||
ElMessage.error("刷新失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加Banner
|
||||
const handleAddBanner = () => {
|
||||
dialogBanner.value = {
|
||||
id: 0,
|
||||
title: "",
|
||||
desc: "",
|
||||
url: "",
|
||||
image: "",
|
||||
sort: 0,
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 编辑Banner
|
||||
const handleEditBanner = (banner: Banner) => {
|
||||
dialogBanner.value = { ...banner };
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 删除Banner
|
||||
const handleDeleteBanner = (banner: Banner) => {
|
||||
ElMessageBox.confirm(
|
||||
`确定要删除Banner "${banner.title}" 吗?`,
|
||||
"确认删除",
|
||||
{
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}
|
||||
).then(async () => {
|
||||
try {
|
||||
const result = await deleteBanner(banner.id);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success("删除成功");
|
||||
fetchBanners();
|
||||
} else {
|
||||
ElMessage.error("删除失败: " + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("删除失败: " + (error as Error).message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 处理Banner保存
|
||||
const handleBannerSave = async (banner: Partial<Banner>) => {
|
||||
try {
|
||||
const payload = { ...banner };
|
||||
|
||||
// 判断是新增还是编辑
|
||||
if (!banner.id || banner.id === 0) {
|
||||
// 新增Banner
|
||||
const result = await createBanner(payload as Banner);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success(result.msg || "Banner添加成功");
|
||||
dialogVisible.value = false;
|
||||
await fetchBanners();
|
||||
} else {
|
||||
ElMessage.error(result.msg || "添加失败");
|
||||
}
|
||||
} else {
|
||||
// 编辑已存在的Banner
|
||||
const result = await editBanner(banner.id!, payload as Banner);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success(result.msg || "更新成功");
|
||||
dialogVisible.value = false;
|
||||
await fetchBanners();
|
||||
} else {
|
||||
ElMessage.error(result.msg || "更新失败");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("操作失败: " + (error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理Banner取消
|
||||
const handleBannerCancel = () => {
|
||||
dialogVisible.value = false;
|
||||
};
|
||||
|
||||
// 获取图片完整URL(用于显示)
|
||||
// @ts-ignore
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
const getImageUrl = (imagePath: string) => {
|
||||
if (!imagePath) return '';
|
||||
// 如果是绝对路径,直接返回
|
||||
if (imagePath.startsWith('http')) return imagePath;
|
||||
// 如果是相对路径,拼接API基础地址
|
||||
return API_BASE_URL + imagePath;
|
||||
};
|
||||
|
||||
// 组件挂载时加载Banner列表
|
||||
onMounted(() => {
|
||||
fetchBanners();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.header-bar h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
:deep(.el-table) {
|
||||
border-radius: 0;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__body td) {
|
||||
padding: 12px 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="600px"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="需求标题" required>
|
||||
<el-input v-model="form.title" placeholder="请输入需求标题" />
|
||||
</el-form-item>
|
||||
<el-form-item label="需求描述" required>
|
||||
<el-input
|
||||
v-model="form.desc"
|
||||
type="textarea"
|
||||
rows="4"
|
||||
placeholder="请输入需求描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="申请人">
|
||||
<el-input v-model="form.applicant" placeholder="请输入申请人" />
|
||||
</el-form-item>
|
||||
<el-form-item label="电话">
|
||||
<el-input v-model="form.phone" placeholder="请输入电话" />
|
||||
</el-form-item>
|
||||
<el-form-item label="需求状态">
|
||||
<el-select v-model="form.status" placeholder="请选择状态">
|
||||
<el-option label="待处理" :value="1" />
|
||||
<el-option label="处理中" :value="2" />
|
||||
<el-option label="已完成" :value="3" />
|
||||
<el-option label="已拒绝" :value="4" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { addDemand, editDemand } from "@/api/demand";
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "新增需求",
|
||||
},
|
||||
demand: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
id: "",
|
||||
title: "",
|
||||
desc: "",
|
||||
applicant: "",
|
||||
status: 1,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(["close", "submit"]);
|
||||
|
||||
// 状态
|
||||
const dialogVisible = ref(props.visible);
|
||||
const dialogTitle = ref(props.title);
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
id: "",
|
||||
title: "",
|
||||
desc: "",
|
||||
applicant: "",
|
||||
status: 1,
|
||||
});
|
||||
|
||||
// 监听props变化
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVal) => {
|
||||
dialogVisible.value = newVal;
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.title,
|
||||
(newVal) => {
|
||||
dialogTitle.value = newVal;
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.demand,
|
||||
(newVal) => {
|
||||
Object.assign(form, newVal);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// 处理关闭
|
||||
const handleClose = () => {
|
||||
emit("close");
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
// 表单验证
|
||||
if (!form.title || !form.desc) {
|
||||
ElMessage.warning("请填写必填项");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let result;
|
||||
if (form.id) {
|
||||
// 编辑
|
||||
result = await editDemand(form.id, form);
|
||||
} else {
|
||||
// 新增
|
||||
result = await addDemand(form);
|
||||
}
|
||||
|
||||
if (result.code === 200) {
|
||||
ElMessage.success(form.id ? "编辑成功" : "新增成功");
|
||||
emit("submit", { ...form });
|
||||
} else {
|
||||
ElMessage.error(result.msg || "操作失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("网络错误,请稍后重试");
|
||||
console.error("提交表单失败:", error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.dialog-footer {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,333 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>需求管理</h2>
|
||||
<div>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增需求
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleRefush">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="search-bar">
|
||||
<el-form :inline="true" :model="searchForm" class="mb-4">
|
||||
<el-form-item label="需求标题">
|
||||
<el-input
|
||||
v-model="searchForm.title"
|
||||
placeholder="请输入需求标题"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="需求状态">
|
||||
<el-select
|
||||
v-model="searchForm.status"
|
||||
placeholder="请选择状态"
|
||||
style="width: 150px"
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="待处理" :value="1" />
|
||||
<el-option label="处理中" :value="2" />
|
||||
<el-option label="已完成" :value="3" />
|
||||
<el-option label="已拒绝" :value="4" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 需求列表 -->
|
||||
<el-table v-loading="loading" :data="demandList" style="width: 100%" border>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="title" label="标题" min-width="100" />
|
||||
<el-table-column
|
||||
prop="desc"
|
||||
label="描述"
|
||||
min-width="300"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column prop="applicant" label="申请人" width="120" />
|
||||
<el-table-column prop="phone" label="电话" width="180" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getStatusType(scope.row.status)">
|
||||
{{ getStatusText(scope.row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="handleEdit(scope.row)"
|
||||
>编辑</el-button
|
||||
>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row.id)"
|
||||
>删除</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination" v-if="total > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.current"
|
||||
v-model:page-size="pagination.size"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 需求表单组件 -->
|
||||
<Edit
|
||||
:visible="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
:demand="form"
|
||||
@close="handleFormClose"
|
||||
@submit="handleFormSubmit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { Plus, Refresh } from "@element-plus/icons-vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import Edit from "./components/edit.vue";
|
||||
import { getDemandList, deleteDemand } from "@/api/demand";
|
||||
|
||||
// 状态管理
|
||||
const loading = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const dialogTitle = ref("新增需求");
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
title: "",
|
||||
status: "",
|
||||
});
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
size: 10,
|
||||
});
|
||||
|
||||
// 总数据量
|
||||
const total = ref(0);
|
||||
|
||||
// 需求列表
|
||||
const demandList = ref<any[]>([]);
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
id: "",
|
||||
title: "",
|
||||
desc: "",
|
||||
applicant: "",
|
||||
status: 1,
|
||||
});
|
||||
|
||||
// 状态类型映射
|
||||
const getStatusType = (status: string | number) => {
|
||||
const typeMap: Record<string | number, string> = {
|
||||
1: "info",
|
||||
2: "warning",
|
||||
3: "success",
|
||||
4: "danger",
|
||||
};
|
||||
return typeMap[status] || "info";
|
||||
};
|
||||
|
||||
// 状态文本映射
|
||||
const getStatusText = (status: string | number) => {
|
||||
const textMap: Record<string | number, string> = {
|
||||
1: "待处理",
|
||||
2: "处理中",
|
||||
3: "已完成",
|
||||
4: "已拒绝",
|
||||
};
|
||||
return textMap[status] || String(status);
|
||||
};
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1;
|
||||
fetchDemandList();
|
||||
};
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchForm.title = "";
|
||||
searchForm.status = "";
|
||||
pagination.current = 1;
|
||||
fetchDemandList();
|
||||
};
|
||||
|
||||
// 获取需求列表
|
||||
const fetchDemandList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getDemandList();
|
||||
|
||||
if (data.code === 200) {
|
||||
let filteredList = [...data.list];
|
||||
|
||||
// 根据搜索条件过滤数据
|
||||
if (searchForm.title) {
|
||||
filteredList = filteredList.filter((item) =>
|
||||
item.title.includes(searchForm.title),
|
||||
);
|
||||
}
|
||||
if (searchForm.status) {
|
||||
filteredList = filteredList.filter(
|
||||
(item) => item.status === searchForm.status,
|
||||
);
|
||||
}
|
||||
|
||||
// 模拟分页
|
||||
total.value = filteredList.length;
|
||||
const start = (pagination.current - 1) * pagination.size;
|
||||
const end = start + pagination.size;
|
||||
demandList.value = filteredList.slice(start, end);
|
||||
} else {
|
||||
ElMessage.error("获取需求列表失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("网络错误,请稍后重试");
|
||||
console.error("获取需求列表失败:", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 分页大小变化
|
||||
const handleSizeChange = (size: number) => {
|
||||
pagination.size = size;
|
||||
fetchDemandList();
|
||||
};
|
||||
|
||||
// 页码变化
|
||||
const handleCurrentChange = (current: number) => {
|
||||
pagination.current = current;
|
||||
fetchDemandList();
|
||||
};
|
||||
|
||||
// 新增需求
|
||||
const handleAdd = () => {
|
||||
dialogTitle.value = "新增需求";
|
||||
Object.assign(form, {
|
||||
id: "",
|
||||
title: "",
|
||||
desc: "",
|
||||
applicant: "",
|
||||
status: 1,
|
||||
});
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 编辑需求
|
||||
const handleEdit = (row: any) => {
|
||||
dialogTitle.value = "编辑需求";
|
||||
Object.assign(form, row);
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 删除需求
|
||||
const handleDelete = async (id: number) => {
|
||||
ElMessageBox.confirm("确定要删除这个需求吗?", "删除确认", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
const result = await deleteDemand(id);
|
||||
|
||||
if (result.code === 200) {
|
||||
ElMessage.success("删除成功");
|
||||
fetchDemandList();
|
||||
} else {
|
||||
ElMessage.error(result.msg || "删除失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("网络错误,请稍后重试");
|
||||
console.error("删除需求失败:", error);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 取消删除
|
||||
});
|
||||
};
|
||||
|
||||
// 刷新需求
|
||||
const handleRefush = () => {
|
||||
fetchDemandList();
|
||||
};
|
||||
|
||||
// 处理表单关闭
|
||||
const handleFormClose = () => {
|
||||
dialogVisible.value = false;
|
||||
};
|
||||
|
||||
// 处理表单提交
|
||||
const handleFormSubmit = () => {
|
||||
dialogVisible.value = false;
|
||||
fetchDemandList();
|
||||
};
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchDemandList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.container-box {
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>租户域名审核</h2>
|
||||
<el-button @click="fetchData" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 搜索区域 -->
|
||||
<div class="search-box">
|
||||
<el-input
|
||||
v-model="searchForm.sub_domain"
|
||||
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="0" />
|
||||
<el-option label="已生效" :value="1" />
|
||||
<el-option label="已禁用" :value="2" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table
|
||||
:data="tableData"
|
||||
style="width: 100%"
|
||||
border
|
||||
v-loading="loading"
|
||||
element-loading-text="正在加载..."
|
||||
>
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="tenant_name" label="租户名称" min-width="150" />
|
||||
<el-table-column prop="sub_domain" label="二级域名前缀" width="150" />
|
||||
<el-table-column prop="main_domain" label="主域名" min-width="150" />
|
||||
<el-table-column prop="full_domain" label="完整域名" min-width="200" />
|
||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.status === 0" type="warning">审核中</el-tag>
|
||||
<el-tag v-else-if="scope.row.status === 1" type="success">已生效</el-tag>
|
||||
<el-tag v-else type="danger">已禁用</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time" label="申请时间" width="180" />
|
||||
<el-table-column label="操作" width="200" fixed="right" align="center">
|
||||
<template #default="scope">
|
||||
<template v-if="scope.row.status === 0">
|
||||
<el-button size="small" type="success" @click="handleAudit(scope.row, 'approve')">
|
||||
通过
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" @click="handleAudit(scope.row, 'reject')">
|
||||
拒绝
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else-if="scope.row.status === 1">
|
||||
<el-button size="small" text type="danger" @click="handleDisable(scope.row)">
|
||||
禁用
|
||||
</el-button>
|
||||
</template>
|
||||
<span v-else style="color: #999;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="fetchData"
|
||||
@current-change="fetchData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { getTenantDomainList, auditTenantDomain, toggleTenantDomainStatus } from '@/api/domain'
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const searchForm = reactive({
|
||||
sub_domain: '',
|
||||
status: ''
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const tableData = ref<any[]>([])
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getTenantDomainList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
...searchForm
|
||||
})
|
||||
if (res.code === 200) {
|
||||
tableData.value = res.data.list || []
|
||||
pagination.total = res.data.total || 0
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handleAudit = async (row: any, action: string) => {
|
||||
const msg = action === 'approve' ? '确定要通过该域名申请吗?' : '确定要拒绝该域名申请吗?'
|
||||
await ElMessageBox.confirm(msg, '提示', { type: 'warning' })
|
||||
|
||||
const res = await auditTenantDomain({ id: row.id, action })
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(res.msg)
|
||||
fetchData()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisable = async (row: any) => {
|
||||
await ElMessageBox.confirm('确定要禁用该域名吗?', '提示', { type: 'warning' })
|
||||
|
||||
const res = await toggleTenantDomainStatus(row.id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(res.msg)
|
||||
fetchData()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.container-box {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.search-box {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template></template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<style lang="less" scoped></style>
|
||||
@@ -0,0 +1,287 @@
|
||||
<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="fetchData" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 搜索区域 -->
|
||||
<div class="search-box">
|
||||
<el-input
|
||||
v-model="searchForm.main_domain"
|
||||
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="0" />
|
||||
<el-option label="启用" :value="1" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table
|
||||
:data="tableData"
|
||||
style="width: 100%"
|
||||
border
|
||||
v-loading="loading"
|
||||
element-loading-text="正在加载..."
|
||||
>
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="main_domain" label="主域名" min-width="200" />
|
||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">
|
||||
{{ scope.row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="240" fixed="right" align="center">
|
||||
<template #default="scope">
|
||||
<el-button size="small" text @click="handleEdit(scope.row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" text @click="handleToggleStatus(scope.row)">
|
||||
<el-icon><Switch /></el-icon>
|
||||
<span>{{ scope.row.status === 1 ? '禁用' : '启用' }}</span>
|
||||
</el-button>
|
||||
<el-button size="small" text type="danger" @click="handleDelete(scope.row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="fetchData"
|
||||
@current-change="fetchData"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑弹窗 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑主域名' : '添加主域名'"
|
||||
width="500px"
|
||||
>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
||||
<el-form-item label="主域名" prop="main_domain">
|
||||
<el-input v-model="form.main_domain" placeholder="请输入主域名,如: example.com" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh, Switch } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getDomainPoolList,
|
||||
createDomainPool,
|
||||
updateDomainPool,
|
||||
deleteDomainPool,
|
||||
toggleDomainPoolStatus
|
||||
} from '@/api/domain'
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
main_domain: '',
|
||||
status: ''
|
||||
})
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 表格数据
|
||||
const tableData = ref<any[]>([])
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
main_domain: '',
|
||||
status: 1
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
main_domain: [
|
||||
{ required: true, message: '请输入主域名', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getDomainPoolList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
...searchForm
|
||||
})
|
||||
if (res.code === 200) {
|
||||
tableData.value = res.data.list || []
|
||||
pagination.total = res.data.total || 0
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 添加
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
form.id = 0
|
||||
form.main_domain = ''
|
||||
form.status = 1
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (row: any) => {
|
||||
isEdit.value = true
|
||||
form.id = row.id
|
||||
form.main_domain = row.main_domain
|
||||
form.status = row.status
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
await formRef.value.validate()
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
const res = await updateDomainPool(form)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('更新成功')
|
||||
dialogVisible.value = false
|
||||
fetchData()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '更新失败')
|
||||
}
|
||||
} else {
|
||||
const res = await createDomainPool(form)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('创建成功')
|
||||
dialogVisible.value = false
|
||||
fetchData()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '创建失败')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = (row: any) => {
|
||||
ElMessageBox.confirm('确定要删除该主域名吗?', '提示', {
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
const res = await deleteDomainPool(row.id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchData()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '删除失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 切换状态
|
||||
const handleToggleStatus = async (row: any) => {
|
||||
const res = await toggleDomainPoolStatus(row.id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('状态更新成功')
|
||||
fetchData()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.container-box {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-box {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,302 @@
|
||||
<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="link_name">
|
||||
<el-input v-model="formData.link_name" placeholder="请输入链接名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="链接地址" prop="link_url">
|
||||
<el-input v-model="formData.link_url" placeholder="请输入链接地址,如:https://www.example.com" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Logo" prop="link_logo">
|
||||
<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.link_logo"
|
||||
:src="getImageUrl(formData.link_logo)"
|
||||
class="image-preview"
|
||||
/>
|
||||
<div v-else class="upload-placeholder">
|
||||
<el-icon class="image-uploader-icon"><Plus /></el-icon>
|
||||
<div class="el-upload__text">点击上传Logo</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
建议尺寸:200x200,支持 jpg、png、gif 格式,大小不超过 2MB
|
||||
</div>
|
||||
<el-button
|
||||
v-if="formData.link_logo"
|
||||
size="small"
|
||||
type="danger"
|
||||
text
|
||||
@click="handleRemoveImage"
|
||||
style="margin-top: 8px;"
|
||||
>
|
||||
删除图片
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
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-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>
|
||||
<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 { addFriendlink, updateFriendlink } from '@/api/friendlink'
|
||||
|
||||
// @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({
|
||||
link_name: '',
|
||||
link_url: '',
|
||||
link_logo: '',
|
||||
description: '',
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
link_name: [
|
||||
{ required: true, message: '请输入链接名称', trigger: 'blur' },
|
||||
{ max: 100, message: '长度不超过100个字符', trigger: 'blur' }
|
||||
],
|
||||
link_url: [
|
||||
{ required: true, message: '请输入链接地址', trigger: 'blur' },
|
||||
{ type: 'url', message: '请输入正确的URL地址', 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.link_name = ''
|
||||
formData.link_url = ''
|
||||
formData.link_logo = ''
|
||||
formData.description = ''
|
||||
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 updateFriendlink(props.rowData.id, formData)
|
||||
} else {
|
||||
res = await addFriendlink(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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,324 @@
|
||||
<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="friendlinkList"
|
||||
style="width: 100%"
|
||||
v-loading="loading"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||
<el-table-column prop="sort" label="排序" width="80" align="center" />
|
||||
<el-table-column prop="link_name" label="链接名称" min-width="150" align="center" />
|
||||
<el-table-column prop="link_url" label="链接地址" min-width="200" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link :href="row.link_url" target="_blank" type="primary">
|
||||
{{ row.link_url }}
|
||||
</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="link_logo" label="Logo" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.link_logo"
|
||||
:src="getImageUrl(row.link_logo)"
|
||||
style="width: 50px; height: 50px; object-fit: contain"
|
||||
preview-teleported
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="150" align="center" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-model="row.status"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
@change="(val) => handleStatusChange(row, val)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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 {
|
||||
getFriendlinkList,
|
||||
updateFriendlink,
|
||||
deleteFriendlink
|
||||
} from '@/api/friendlink'
|
||||
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 friendlinkList = 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 getFriendlinkList({
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
keyword: searchForm.keyword,
|
||||
status: searchForm.status
|
||||
})
|
||||
if (res.code === 200) {
|
||||
friendlinkList.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 deleteFriendlink(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 updateFriendlink(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>
|
||||
@@ -0,0 +1,514 @@
|
||||
<template>
|
||||
<!-- 添加/编辑菜单对话框 -->
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="dialogTitle"
|
||||
width="500px"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="handleDialogClose"
|
||||
>
|
||||
<el-form
|
||||
:model="currentMenu"
|
||||
label-width="100px"
|
||||
:rules="formRules"
|
||||
ref="menuFormRef"
|
||||
>
|
||||
<el-form-item label="父级菜单" prop="pid">
|
||||
<el-tree-select
|
||||
v-model="currentMenu.pid"
|
||||
:data="parentMenuOptions"
|
||||
:props="{ value: 'id', label: 'title', children: 'children' }"
|
||||
placeholder="请选择父级菜单"
|
||||
clearable
|
||||
check-strictly
|
||||
:render-after-expand="false"
|
||||
style="width: 100%"
|
||||
@change="handleParentChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="菜单名称" prop="title">
|
||||
<el-input v-model="currentMenu.title" placeholder="请输入菜单名称" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="菜单类型" prop="type">
|
||||
<el-radio-group v-model="currentMenu.type" style="width: 100%">
|
||||
<el-radio-button :value="1">目录</el-radio-button>
|
||||
<el-radio-button :value="2">页面</el-radio-button>
|
||||
<el-radio-button :value="3">外链</el-radio-button>
|
||||
<el-radio-button :value="4">单页</el-radio-button>
|
||||
</el-radio-group>
|
||||
<div
|
||||
style="
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
"
|
||||
>
|
||||
<div>
|
||||
• 目录:只有路由地址,用于<span
|
||||
style="color: var(--el-color-primary)"
|
||||
>目录管理</span
|
||||
>和<span style="color: var(--el-color-primary)">菜单分组</span>
|
||||
</div>
|
||||
<div>
|
||||
• 页面:有路由和组件地址,用于<span
|
||||
style="color: var(--el-color-primary)"
|
||||
>页面管理</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
• 外链:无路由和组件,用于<span
|
||||
style="color: var(--el-color-primary)"
|
||||
>外链管理</span
|
||||
>和<span style="color: var(--el-color-primary)">权限控制</span>
|
||||
</div>
|
||||
<div>
|
||||
• 单页:根据路由从单页表获取内容显示,无需填写组件路径
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="路由地址" prop="path" v-if="currentMenu.type !== 3">
|
||||
<el-input v-model="currentMenu.path" placeholder="例如:/system" />
|
||||
<div v-if="currentMenu.type === 4" style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
单页类型:路由需与单页管理中的路由一致,系统会自动从单页表获取内容
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item
|
||||
label="组件路径"
|
||||
prop="component_path"
|
||||
v-if="currentMenu.type === 2"
|
||||
>
|
||||
<el-input
|
||||
v-model="currentMenu.component_path"
|
||||
placeholder="例如:/apps/knowledge/index.vue"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item
|
||||
label="外链地址"
|
||||
prop="link_url"
|
||||
v-if="currentMenu.type === 3"
|
||||
>
|
||||
<el-input
|
||||
v-model="currentMenu.link_url"
|
||||
required
|
||||
placeholder="例如:https://www.baidu.com"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="菜单图片" prop="image">
|
||||
<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="currentMenu.image" :src="getImageUrl(currentMenu.image)" 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);">
|
||||
建议尺寸:400x300,支持 jpg、png、gif 格式,大小不超过 2MB
|
||||
</div>
|
||||
<el-button
|
||||
v-if="currentMenu.image"
|
||||
size="small"
|
||||
type="danger"
|
||||
text
|
||||
@click="handleRemoveImage"
|
||||
style="margin-top: 8px;"
|
||||
>
|
||||
删除图片
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="描述" prop="desc">
|
||||
<el-input
|
||||
v-model="currentMenu.desc"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="例如:系统管理"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number
|
||||
v-model="currentMenu.sort"
|
||||
:min="0"
|
||||
placeholder="数字越小越靠前"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">确定</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { ElMessage, ElForm } from "element-plus";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
|
||||
// 定义菜单数据类型
|
||||
interface Menu {
|
||||
id: number;
|
||||
pid: number;
|
||||
title: string;
|
||||
type: number;
|
||||
path: string;
|
||||
component_path: string;
|
||||
link_url?: string;
|
||||
image?: string;
|
||||
sort: number;
|
||||
desc: string;
|
||||
children?: Menu[];
|
||||
hasChildren?: boolean;
|
||||
}
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
menu: Partial<Menu> | null;
|
||||
parentMenuOptions: Menu[];
|
||||
dialogType: "add" | "edit" | "addSub";
|
||||
parentTitle?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
visible: false,
|
||||
menu: null,
|
||||
parentMenuOptions: () => [],
|
||||
dialogType: "add",
|
||||
parentTitle: "",
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: "update:visible", value: boolean): void;
|
||||
(e: "save", menu: Partial<Menu>): void;
|
||||
(e: "cancel"): void;
|
||||
}>();
|
||||
|
||||
// 表单引用
|
||||
const menuFormRef = ref<InstanceType<typeof ElForm>>();
|
||||
|
||||
// 当前操作的菜单
|
||||
const currentMenu = ref<Partial<Menu>>({
|
||||
id: 0,
|
||||
pid: 0,
|
||||
title: "",
|
||||
type: 1,
|
||||
path: "",
|
||||
component_path: "",
|
||||
image: "",
|
||||
sort: 0,
|
||||
desc: "",
|
||||
});
|
||||
|
||||
// 上传配置
|
||||
// @ts-ignore
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||
const uploadUrl = ref(API_BASE_URL + '/admin/uploadfiles');
|
||||
const uploadHeaders = ref({
|
||||
'Authorization': 'Bearer ' + (localStorage.getItem('token') || '')
|
||||
});
|
||||
|
||||
// 查找父级菜单路径的递归函数
|
||||
const findMenuPath = (menuList: Menu[], targetId: number): string => {
|
||||
for (const menu of menuList) {
|
||||
if (menu.id === targetId) {
|
||||
return menu.path || "";
|
||||
}
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const childPath = findMenuPath(menu.children, targetId);
|
||||
if (childPath) return childPath;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
// 处理父级菜单变化 - 自动填充父级路径到路由地址
|
||||
const handleParentChange = (value: number) => {
|
||||
if (value === 0) {
|
||||
// 选择顶级菜单,清空路径
|
||||
currentMenu.value.path = "";
|
||||
} else {
|
||||
// 选择子菜单,自动填充父级路径
|
||||
const parentPath = findMenuPath(props.parentMenuOptions, value);
|
||||
if (parentPath) {
|
||||
currentMenu.value.path = parentPath;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 监听props变化,更新当前菜单
|
||||
watch(
|
||||
() => props.menu,
|
||||
(newMenu) => {
|
||||
if (newMenu) {
|
||||
currentMenu.value = {
|
||||
...newMenu,
|
||||
// 确保 pid 有默认值
|
||||
pid: newMenu.pid ?? 0,
|
||||
};
|
||||
} else {
|
||||
// 重置表单
|
||||
currentMenu.value = {
|
||||
id: 0,
|
||||
pid: 0,
|
||||
title: "",
|
||||
path: "",
|
||||
component_path: "",
|
||||
sort: 0,
|
||||
desc: "",
|
||||
type: 1,
|
||||
};
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 监听props.visible变化
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVisible) => {
|
||||
if (newVisible && props.dialogType === "add") {
|
||||
// 新增时重置表单
|
||||
currentMenu.value = {
|
||||
id: 0,
|
||||
pid: 0,
|
||||
title: "",
|
||||
path: "",
|
||||
component_path: "",
|
||||
image: "",
|
||||
sort: 0,
|
||||
desc: "",
|
||||
type: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 对话框标题
|
||||
const dialogTitle = computed(() => {
|
||||
switch (props.dialogType) {
|
||||
case "add":
|
||||
return "添加菜单";
|
||||
case "edit":
|
||||
return "编辑菜单";
|
||||
case "addSub":
|
||||
return `添加子菜单 (父菜单: ${props.parentTitle || "顶级菜单"})`;
|
||||
default:
|
||||
return "操作菜单";
|
||||
}
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = ref({
|
||||
title: [{ required: true, message: "请输入菜单名称", trigger: "blur" }],
|
||||
path: [
|
||||
{
|
||||
required: true,
|
||||
validator: (rule: any, value: any, callback: any) => {
|
||||
if (currentMenu.value.type === 3) {
|
||||
// 外链类型不需要路径
|
||||
callback();
|
||||
} else if (!value || value.trim() === "") {
|
||||
callback(new Error("请输入路由地址"));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
component_path: [
|
||||
{
|
||||
required: true,
|
||||
validator: (rule: any, value: any, callback: any) => {
|
||||
if (currentMenu.value.type === 2) {
|
||||
// 页面类型需要组件路径
|
||||
if (!value || value.trim() === "") {
|
||||
callback(new Error("请输入组件路径"));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
} else {
|
||||
// 其他类型(目录、外链、单页)不需要组件路径
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
sort: [{ required: true, message: "请输入排序号", trigger: "blur" }],
|
||||
});
|
||||
|
||||
|
||||
// 监听菜单类型变化,自动清空不相关的字段
|
||||
watch(
|
||||
() => currentMenu.value.type,
|
||||
(newType, oldType) => {
|
||||
if (newType === oldType) return; // 避免初始化时的触发
|
||||
|
||||
if (newType === 1) {
|
||||
// 目录:清空组件路径,保留路径
|
||||
currentMenu.value.component_path = "";
|
||||
} else if (newType === 2) {
|
||||
// 页面:保留路径和组件路径
|
||||
// 不清空,保持现有值
|
||||
} else if (newType === 3) {
|
||||
// 外链:清空路径和组件路径
|
||||
currentMenu.value.path = "";
|
||||
currentMenu.value.component_path = "";
|
||||
} else if (newType === 4) {
|
||||
// 单页:清空组件路径,保留路径(路径用于匹配单页表)
|
||||
currentMenu.value.component_path = "";
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
emit("update:visible", false);
|
||||
emit("cancel");
|
||||
};
|
||||
|
||||
// 处理对话框关闭
|
||||
const handleDialogClose = (value: boolean) => {
|
||||
if (!value) {
|
||||
emit("update:visible", false);
|
||||
emit("cancel");
|
||||
}
|
||||
};
|
||||
|
||||
// 图片上传前校验
|
||||
const beforeImageUpload = (file: any) => {
|
||||
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: any) => {
|
||||
if (response.code === 200) {
|
||||
// 直接保存相对路径,不拼接API_BASE_URL
|
||||
currentMenu.value.image = response.data.url || response.data.path;
|
||||
ElMessage.success('图片上传成功');
|
||||
} else {
|
||||
ElMessage.error(response.msg || '图片上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 图片上传失败
|
||||
const handleImageError = () => {
|
||||
ElMessage.error('图片上传失败,请重试');
|
||||
};
|
||||
|
||||
// 删除图片
|
||||
const handleRemoveImage = () => {
|
||||
currentMenu.value.image = '';
|
||||
ElMessage.success('图片已删除');
|
||||
};
|
||||
|
||||
// 获取图片完整URL(用于预览)
|
||||
const getImageUrl = (imagePath: string) => {
|
||||
if (!imagePath) return '';
|
||||
// 如果是绝对路径,直接返回
|
||||
if (imagePath.startsWith('http')) return imagePath;
|
||||
// 如果是相对路径,拼接API基础地址
|
||||
return API_BASE_URL + imagePath;
|
||||
};
|
||||
|
||||
// 保存菜单
|
||||
const handleSave = async () => {
|
||||
// 表单验证
|
||||
if (!menuFormRef.value) return;
|
||||
const valid = await menuFormRef.value.validate();
|
||||
if (!valid) return;
|
||||
|
||||
// 解决后端时间字段问题:过滤掉不需要的字段
|
||||
const payload = { ...currentMenu.value };
|
||||
|
||||
// 触发保存事件
|
||||
emit("save", payload);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
/* 对话框样式精简 */
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 图片上传样式 */
|
||||
.image-uploader {
|
||||
:deep(.el-upload) {
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: var(--el-transition-duration-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 178px;
|
||||
height: 178px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
width: 178px;
|
||||
height: 178px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
.image-uploader-icon {
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.el-upload__text {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,563 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>前端导航管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button @click="expandAll">
|
||||
<el-icon>
|
||||
<FolderOpened />
|
||||
</el-icon>
|
||||
全部展开
|
||||
</el-button>
|
||||
<el-button @click="collapseAll">
|
||||
<el-icon>
|
||||
<Folder />
|
||||
</el-icon>
|
||||
全部折叠
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleAddMenu">
|
||||
<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>
|
||||
|
||||
<!-- 树形表格 -->
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
:data="menuTree"
|
||||
style="width: 100%"
|
||||
row-key="id"
|
||||
border
|
||||
v-loading="loading"
|
||||
element-loading-text="正在加载..."
|
||||
:tree-props="{
|
||||
children: 'children',
|
||||
hasChildren: 'hasChildren',
|
||||
}"
|
||||
@row-click="handleRowClick"
|
||||
>
|
||||
<el-table-column prop="title" label="菜单名称" width="200">
|
||||
<template #default="scope">
|
||||
<div class="menu-item">
|
||||
<i
|
||||
v-if="scope.row.icon"
|
||||
:class="scope.row.icon"
|
||||
class="menu-icon"
|
||||
></i>
|
||||
<span>{{ scope.row.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="path" label="路由地址"></el-table-column>
|
||||
|
||||
<el-table-column label="图片" width="200" align="center">
|
||||
<template #default="scope">
|
||||
<el-image
|
||||
v-if="scope.row.image"
|
||||
:src="getImageUrl(scope.row.image)"
|
||||
:preview-src-list="[getImageUrl(scope.row.image)]"
|
||||
:preview-teleported="true"
|
||||
fit="cover"
|
||||
style="width: 50px; height: 50px; border-radius: 4px;"
|
||||
/>
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
prop="MenuType"
|
||||
label="菜单类型"
|
||||
width="120"
|
||||
align="center"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag :type="getMenuTypeTagType(scope.row.type)">
|
||||
{{ getMenuTypeTitle(scope.row.type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
prop="sort"
|
||||
label="排序"
|
||||
width="80"
|
||||
align="center"
|
||||
></el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="280" fixed="right" align="center">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
@click="handleAddSubMenu(scope.row)"
|
||||
:disabled="scope.row.type === 3"
|
||||
>
|
||||
<el-icon>
|
||||
<CirclePlus />
|
||||
</el-icon>
|
||||
<span>子菜单</span>
|
||||
</el-button>
|
||||
|
||||
<el-button size="small" text @click="handleEditMenu(scope.row)">
|
||||
<el-icon>
|
||||
<Edit />
|
||||
</el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
type="danger"
|
||||
@click="handleDeleteMenu(scope.row)"
|
||||
>
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 引入编辑组件 -->
|
||||
<MenuEdit
|
||||
v-model:visible="dialogVisible"
|
||||
:menu="dialogMenu"
|
||||
:parent-menu-options="parentMenuOptions"
|
||||
:dialog-type="dialogType"
|
||||
:parent-title="dialogParentTitle"
|
||||
@save="handleMenuSave"
|
||||
@cancel="handleMenuCancel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { ElMessage, ElMessageBox, ElForm } from "element-plus";
|
||||
import {
|
||||
Plus,
|
||||
CirclePlus,
|
||||
Edit,
|
||||
Delete,
|
||||
Refresh,
|
||||
FolderOpened,
|
||||
Folder,
|
||||
} from "@element-plus/icons-vue";
|
||||
import {
|
||||
getFrontMenus,
|
||||
createFrontMenu,
|
||||
editFrontMenu,
|
||||
deleteFrontMenu,
|
||||
} from "@/api/frontMenu";
|
||||
import MenuEdit from "./components/edit.vue";
|
||||
|
||||
// 定义菜单数据类型
|
||||
interface Menu {
|
||||
id: number;
|
||||
pid: number;
|
||||
title: string;
|
||||
type: number;
|
||||
path: string;
|
||||
component_path: string;
|
||||
image?: string;
|
||||
sort: number;
|
||||
desc: string;
|
||||
children?: Menu[];
|
||||
hasChildren?: boolean;
|
||||
}
|
||||
|
||||
// 菜单树形数据
|
||||
const menuTree = ref<Menu[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// 表格引用
|
||||
const tableRef = ref<any>(null);
|
||||
|
||||
// 对话框相关变量
|
||||
const dialogVisible = ref(false);
|
||||
const dialogMenu = ref<Partial<Menu> | null>(null);
|
||||
const dialogType = ref<"add" | "edit" | "addSub">("add");
|
||||
const dialogParentTitle = ref("");
|
||||
|
||||
// 父级菜单选项
|
||||
const parentMenuOptions = ref<Menu[]>([]);
|
||||
|
||||
let fetchMenusPromise: Promise<any> | null = null;
|
||||
|
||||
const fetchMenus = async () => {
|
||||
if (fetchMenusPromise) {
|
||||
return fetchMenusPromise;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
fetchMenusPromise = (async () => {
|
||||
try {
|
||||
const result = await getFrontMenus();
|
||||
if (result.code === 200) {
|
||||
menuTree.value = result.data;
|
||||
parentMenuOptions.value = [
|
||||
{
|
||||
id: 0,
|
||||
pid: -1,
|
||||
title: "顶级菜单",
|
||||
type: 1,
|
||||
path: "",
|
||||
component_path: "",
|
||||
sort: 0,
|
||||
desc: "",
|
||||
children: result.data,
|
||||
} as Menu,
|
||||
];
|
||||
} else {
|
||||
ElMessage.error("获取前端导航失败: " + result.msg);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("获取前端导航数据失败: " + (error as Error).message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
fetchMenusPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return fetchMenusPromise;
|
||||
};
|
||||
|
||||
// 刷新界面
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await fetchMenus();
|
||||
ElMessage.success("刷新成功");
|
||||
} catch (error) {
|
||||
ElMessage.error("刷新失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有前端导航行数据(包括子节点)
|
||||
function getAllMenuRows(menuList: Menu[]): Menu[] {
|
||||
const rows: Menu[] = [];
|
||||
menuList.forEach((frontMenu) => {
|
||||
rows.push(frontMenu);
|
||||
if (frontMenu.children && frontMenu.children.length > 0) {
|
||||
rows.push(...getAllMenuRows(frontMenu.children));
|
||||
}
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
// 全部展开
|
||||
function expandAll() {
|
||||
if (!tableRef.value) return;
|
||||
const allRows = getAllMenuRows(menuTree.value);
|
||||
allRows.forEach((row) => {
|
||||
if (row.children && row.children.length > 0) {
|
||||
tableRef.value.toggleRowExpansion(row, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 全部折叠
|
||||
function collapseAll() {
|
||||
if (!tableRef.value) return;
|
||||
const allRows = getAllMenuRows(menuTree.value);
|
||||
allRows.forEach((row) => {
|
||||
if (row.children && row.children.length > 0) {
|
||||
tableRef.value.toggleRowExpansion(row, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 处理行点击事件 - 展开/收缩
|
||||
function handleRowClick(row: Menu, column: any, event: Event) {
|
||||
// 如果点击的是操作列,不触发展开/收缩
|
||||
if (column && column.label === '操作') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果该行有子菜单,切换展开/收缩状态
|
||||
if (row.children && row.children.length > 0) {
|
||||
if (tableRef.value) {
|
||||
tableRef.value.toggleRowExpansion(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建菜单树(处理父子关系)
|
||||
const buildMenuTree = (menuList: Menu[]): Menu[] => {
|
||||
return menuList;
|
||||
};
|
||||
|
||||
// 获取菜单类型名称
|
||||
const getMenuTypeTitle = (type: number) => {
|
||||
const typeMap = { 1: "目录", 2: "页面", 3: "外链", 4: "单页" };
|
||||
return typeMap[type as keyof typeof typeMap] || "未知类型";
|
||||
};
|
||||
|
||||
// 获取菜单类型标签样式
|
||||
const getMenuTypeTagType = (type: number) => {
|
||||
const typeMap = { 1: "primary", 2: "success", 3: "info", 4: "warning" };
|
||||
return typeMap[type as keyof typeof typeMap] || "default";
|
||||
};
|
||||
|
||||
// 添加子菜单
|
||||
const handleAddSubMenu = (parentMenu: Menu) => {
|
||||
dialogType.value = "addSub";
|
||||
dialogParentTitle.value = parentMenu.title;
|
||||
dialogMenu.value = {
|
||||
id: 0, // 明确设置为 0,表示是新增
|
||||
pid: parentMenu.id,
|
||||
title: "",
|
||||
path: parentMenu.path || "", // 自动填充父级路径
|
||||
component_path: "",
|
||||
desc: "",
|
||||
sort: 0,
|
||||
type: parentMenu.type === 1 ? 2 : parentMenu.type, // 如果父菜单是目录,子菜单默认为页面
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 编辑菜单
|
||||
const handleEditMenu = (menu: Menu) => {
|
||||
dialogType.value = "edit";
|
||||
dialogMenu.value = { ...menu };
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 删除菜单
|
||||
const handleDeleteMenu = (menu: Menu) => {
|
||||
ElMessageBox.confirm(
|
||||
`确定要删除菜单 "${menu.title}" 吗?${
|
||||
menu.hasChildren ? "其下所有子菜单也将被删除。" : ""
|
||||
}`,
|
||||
"确认删除",
|
||||
{
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}
|
||||
).then(async () => {
|
||||
try {
|
||||
const result = await deleteFrontMenu(menu.id);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success("删除成功");
|
||||
fetchMenus();
|
||||
} else {
|
||||
ElMessage.error("删除失败: " + result.msg);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("删除失败: " + (error as Error).message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 添加菜单
|
||||
const handleAddMenu = () => {
|
||||
dialogType.value = "add";
|
||||
dialogMenu.value = {
|
||||
id: 0,
|
||||
pid: 0,
|
||||
title: "",
|
||||
type: 1,
|
||||
path: "",
|
||||
component_path: "",
|
||||
sort: 0,
|
||||
desc: "",
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 处理菜单保存
|
||||
const handleMenuSave = async (menu: Partial<Menu>) => {
|
||||
try {
|
||||
// 解决后端时间字段问题:过滤掉不需要的字段
|
||||
const payload = { ...menu };
|
||||
|
||||
// 确保 pid 是整数类型(后端要求必须是整数)
|
||||
// 处理数组情况:如果 pid 是数组,取第一个元素
|
||||
let pidValue: any = payload.pid;
|
||||
if (Array.isArray(pidValue)) {
|
||||
pidValue = pidValue.length > 0 ? pidValue[0] : null;
|
||||
}
|
||||
|
||||
// 强制转换为整数
|
||||
if (pidValue === null || pidValue === undefined || pidValue === '') {
|
||||
payload.pid = 0;
|
||||
} else {
|
||||
const parsedPid = parseInt(String(pidValue), 10);
|
||||
if (isNaN(parsedPid)) {
|
||||
payload.pid = 0;
|
||||
} else {
|
||||
payload.pid = parsedPid;
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是新增还是编辑:没有 id 或 id 为 0 或 dialogType 为 add/addSub 时为新增
|
||||
if (!menu.id || menu.id === 0 || dialogType.value === 'add' || dialogType.value === 'addSub') {
|
||||
// 新增菜单(包括添加顶级菜单和添加子菜单)
|
||||
const result = await createFrontMenu(payload as Menu);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success(result.msg || "菜单添加成功");
|
||||
dialogVisible.value = false;
|
||||
await fetchMenus();
|
||||
} else {
|
||||
ElMessage.error(result.msg || "添加失败");
|
||||
}
|
||||
} else {
|
||||
// 编辑已存在的菜单
|
||||
const result = await editFrontMenu(menu.id!, payload as Menu);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success(result.msg || "更新成功");
|
||||
dialogVisible.value = false;
|
||||
await fetchMenus();
|
||||
} else {
|
||||
ElMessage.error(result.msg || "更新失败");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("操作失败: " + (error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理菜单取消
|
||||
const handleMenuCancel = () => {
|
||||
dialogVisible.value = false;
|
||||
};
|
||||
|
||||
// 获取图片完整URL(用于显示)
|
||||
// @ts-ignore
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
const getImageUrl = (imagePath: string) => {
|
||||
if (!imagePath) return '';
|
||||
// 如果是绝对路径,直接返回
|
||||
if (imagePath.startsWith('http')) return imagePath;
|
||||
// 如果是相对路径,拼接API基础地址
|
||||
return API_BASE_URL + imagePath;
|
||||
};
|
||||
|
||||
// 组件挂载时加载菜单
|
||||
onMounted(() => {
|
||||
fetchMenus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.header-bar h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f2f3f5;
|
||||
}
|
||||
|
||||
.card-header span {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 表格核心样式 */
|
||||
:deep(.el-table) {
|
||||
border-radius: 0;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__body td) {
|
||||
padding: 12px 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 有子菜单的行显示手型光标 */
|
||||
:deep(.el-table__body tr.el-table__row--level-0) {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-table__body tr) {
|
||||
&:has(.el-table__expand-icon:not(.el-table__expand-icon--hidden)) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* 展开图标与菜单内容对齐 */
|
||||
:deep(.el-table__expand-icon) {
|
||||
margin: 0 !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
:deep(.el-table__expand-icon-cell) {
|
||||
padding: 0 8px !important;
|
||||
}
|
||||
|
||||
/* 菜单项样式 */
|
||||
.menu-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 16px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 隐藏无子女菜单的展开图标 */
|
||||
:deep(.el-table__expand-icon--hidden) {
|
||||
visibility: hidden;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
:deep(.el-table__expand-icon) {
|
||||
margin-right: 8px !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 对话框样式精简 */
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<style lang="less" scoped></style>
|
||||
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<!-- 添加/编辑单页对话框 -->
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="dialogTitle"
|
||||
width="80%"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="handleDialogClose"
|
||||
>
|
||||
<el-form
|
||||
:model="currentOnePage"
|
||||
label-width="100px"
|
||||
:rules="formRules"
|
||||
ref="onePageFormRef"
|
||||
>
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input
|
||||
v-model="currentOnePage.title"
|
||||
placeholder="请输入单页标题"
|
||||
maxlength="255"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="路由" prop="path">
|
||||
<el-input
|
||||
v-model="currentOnePage.path"
|
||||
placeholder="例如:/about、/contact、/privacy"
|
||||
maxlength="200"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
路由地址,必须以 / 开头,例如:/about
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="内容" prop="content">
|
||||
<el-input
|
||||
v-model="currentOnePage.content"
|
||||
type="textarea"
|
||||
:rows="20"
|
||||
placeholder="请输入单页内容(支持代码)"
|
||||
style="font-family: 'Courier New', monospace;"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
支持代码和文本内容,使用等宽字体显示
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number
|
||||
v-model="currentOnePage.sort"
|
||||
:min="0"
|
||||
placeholder="数字越小越靠前"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
数字越小,排序越靠前
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-switch
|
||||
v-model="currentOnePage.status"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
启用后,前端可以访问该单页
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">确定</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { ElMessage, ElForm } from "element-plus";
|
||||
|
||||
// 定义单页数据类型
|
||||
interface OnePage {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
path: string;
|
||||
sort: number;
|
||||
status: 0 | 1;
|
||||
create_time?: string;
|
||||
update_time?: string;
|
||||
delete_time?: string;
|
||||
}
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onepage: Partial<OnePage> | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
visible: false,
|
||||
onepage: null,
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: "update:visible", value: boolean): void;
|
||||
(e: "save", onepage: Partial<OnePage>): void;
|
||||
(e: "cancel"): void;
|
||||
}>();
|
||||
|
||||
// 表单引用
|
||||
const onePageFormRef = ref<InstanceType<typeof ElForm>>();
|
||||
|
||||
// 当前操作的单页
|
||||
const currentOnePage = ref<Partial<OnePage>>({
|
||||
id: 0,
|
||||
title: "",
|
||||
content: "",
|
||||
path: "",
|
||||
sort: 0,
|
||||
status: 1,
|
||||
});
|
||||
|
||||
// 对话框标题
|
||||
const dialogTitle = computed(() => {
|
||||
return props.onepage?.id && props.onepage.id > 0 ? "编辑单页" : "添加单页";
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = ref({
|
||||
title: [{ required: true, message: "请输入单页标题", trigger: "blur" }],
|
||||
path: [
|
||||
{ required: true, message: "请输入路由", trigger: "blur" },
|
||||
{
|
||||
pattern: /^\/[a-zA-Z0-9\/_-]*$/,
|
||||
message: "路由必须以 / 开头,只能包含字母、数字、下划线、横线和斜线",
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
content: [{ required: true, message: "请输入单页内容", trigger: "blur" }],
|
||||
sort: [{ required: true, message: "请输入排序号", trigger: "blur" }],
|
||||
});
|
||||
|
||||
// 监听props变化,更新当前单页
|
||||
watch(
|
||||
() => props.onepage,
|
||||
(newOnePage) => {
|
||||
if (newOnePage) {
|
||||
currentOnePage.value = {
|
||||
...newOnePage,
|
||||
};
|
||||
} else {
|
||||
// 重置表单
|
||||
currentOnePage.value = {
|
||||
id: 0,
|
||||
title: "",
|
||||
content: "",
|
||||
path: "",
|
||||
sort: 0,
|
||||
status: 1,
|
||||
};
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 监听props.visible变化
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVisible) => {
|
||||
if (newVisible && (!props.onepage || !props.onepage.id)) {
|
||||
// 新增时重置表单
|
||||
currentOnePage.value = {
|
||||
id: 0,
|
||||
title: "",
|
||||
content: "",
|
||||
path: "",
|
||||
sort: 0,
|
||||
status: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
emit("update:visible", false);
|
||||
emit("cancel");
|
||||
};
|
||||
|
||||
// 处理对话框关闭
|
||||
const handleDialogClose = (value: boolean) => {
|
||||
if (!value) {
|
||||
emit("update:visible", false);
|
||||
emit("cancel");
|
||||
}
|
||||
};
|
||||
|
||||
// 保存单页
|
||||
const handleSave = async () => {
|
||||
// 表单验证
|
||||
if (!onePageFormRef.value) return;
|
||||
const valid = await onePageFormRef.value.validate();
|
||||
if (!valid) return;
|
||||
|
||||
// 准备提交数据
|
||||
const payload = { ...currentOnePage.value };
|
||||
|
||||
// 触发保存事件
|
||||
emit("save", payload);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
/* 对话框样式精简 */
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 20px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* 代码编辑器样式 */
|
||||
:deep(.el-textarea__inner) {
|
||||
font-family: 'Courier New', 'Consolas', 'Monaco', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>单页管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="handleAddOnePage">
|
||||
<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>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table
|
||||
:data="onePageList"
|
||||
style="width: 100%"
|
||||
border
|
||||
v-loading="loading"
|
||||
element-loading-text="正在加载..."
|
||||
>
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
|
||||
<el-table-column prop="title" label="标题" min-width="150" />
|
||||
|
||||
<el-table-column prop="path" label="路由" width="200">
|
||||
<template #default="scope">
|
||||
<el-tag type="info">{{ scope.row.path }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="content" label="内容" min-width="200" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<div style="font-family: 'Courier New', monospace; white-space: pre-wrap;">{{ getContentPreview(scope.row.content) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
prop="status"
|
||||
label="状态"
|
||||
width="100"
|
||||
align="center"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === 1 ? 'success' : 'info'">
|
||||
{{ scope.row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
prop="sort"
|
||||
label="排序"
|
||||
width="100"
|
||||
align="center"
|
||||
sortable
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag>{{ scope.row.sort || 0 }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="200" fixed="right" align="center">
|
||||
<template #default="scope">
|
||||
<el-button size="small" text @click="handleEditOnePage(scope.row)">
|
||||
<el-icon>
|
||||
<Edit />
|
||||
</el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
type="danger"
|
||||
@click="handleDeleteOnePage(scope.row)"
|
||||
>
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 引入编辑组件 -->
|
||||
<OnePageEdit
|
||||
v-model:visible="dialogVisible"
|
||||
:onepage="dialogOnePage"
|
||||
@save="handleOnePageSave"
|
||||
@cancel="handleOnePageCancel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus, Edit, Delete, Refresh } from "@element-plus/icons-vue";
|
||||
import {
|
||||
getOnePages,
|
||||
createOnePage,
|
||||
editOnePage,
|
||||
deleteOnePage,
|
||||
} from "@/api/onepage";
|
||||
import OnePageEdit from "./components/edit.vue";
|
||||
|
||||
// 定义单页数据类型
|
||||
interface OnePage {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
path: string;
|
||||
sort: number;
|
||||
status: 0 | 1;
|
||||
create_time?: string;
|
||||
update_time?: string;
|
||||
delete_time?: string;
|
||||
}
|
||||
|
||||
// 单页列表
|
||||
const onePageList = ref<OnePage[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// 对话框相关变量
|
||||
const dialogVisible = ref(false);
|
||||
const dialogOnePage = ref<Partial<OnePage> | null>(null);
|
||||
|
||||
// 获取单页列表
|
||||
const fetchOnePages = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await getOnePages();
|
||||
if (result.code === 200) {
|
||||
onePageList.value = result.data || [];
|
||||
} else {
|
||||
ElMessage.error("获取单页列表失败: " + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("获取单页列表失败: " + (error as Error).message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新界面
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await fetchOnePages();
|
||||
ElMessage.success("刷新成功");
|
||||
} catch (error) {
|
||||
ElMessage.error("刷新失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取内容预览
|
||||
const getContentPreview = (content: string) => {
|
||||
if (!content) return '-';
|
||||
// 限制长度,保留原始格式
|
||||
return content.length > 100 ? content.substring(0, 100) + '...' : content;
|
||||
};
|
||||
|
||||
// 添加单页
|
||||
const handleAddOnePage = () => {
|
||||
dialogOnePage.value = {
|
||||
id: 0,
|
||||
title: "",
|
||||
content: "",
|
||||
path: "",
|
||||
sort: 0,
|
||||
status: 1,
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 编辑单页
|
||||
const handleEditOnePage = (onePage: OnePage) => {
|
||||
dialogOnePage.value = { ...onePage };
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 删除单页
|
||||
const handleDeleteOnePage = (onePage: OnePage) => {
|
||||
ElMessageBox.confirm(
|
||||
`确定要删除单页 "${onePage.title}" 吗?`,
|
||||
"确认删除",
|
||||
{
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}
|
||||
).then(async () => {
|
||||
try {
|
||||
const result = await deleteOnePage(onePage.id);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success("删除成功");
|
||||
fetchOnePages();
|
||||
} else {
|
||||
ElMessage.error("删除失败: " + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("删除失败: " + (error as Error).message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 处理单页保存
|
||||
const handleOnePageSave = async (onePage: Partial<OnePage>) => {
|
||||
try {
|
||||
const payload = { ...onePage };
|
||||
|
||||
// 判断是新增还是编辑
|
||||
if (!onePage.id || onePage.id === 0) {
|
||||
// 新增单页
|
||||
const result = await createOnePage(payload as OnePage);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success(result.msg || "单页添加成功");
|
||||
dialogVisible.value = false;
|
||||
await fetchOnePages();
|
||||
} else {
|
||||
ElMessage.error(result.msg || "添加失败");
|
||||
}
|
||||
} else {
|
||||
// 编辑已存在的单页
|
||||
const result = await editOnePage(onePage.id!, payload as OnePage);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success(result.msg || "更新成功");
|
||||
dialogVisible.value = false;
|
||||
await fetchOnePages();
|
||||
} else {
|
||||
ElMessage.error(result.msg || "更新失败");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("操作失败: " + (error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理单页取消
|
||||
const handleOnePageCancel = () => {
|
||||
dialogVisible.value = false;
|
||||
};
|
||||
|
||||
// 组件挂载时加载单页列表
|
||||
onMounted(() => {
|
||||
fetchOnePages();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.container-box {
|
||||
padding: 24px;
|
||||
background-color: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.header-bar h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
:deep(.el-table) {
|
||||
border-radius: 0;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__body td) {
|
||||
padding: 12px 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +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">
|
||||
<WangEditor 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";
|
||||
import WangEditor from "@/views/components/WangEditor.vue";
|
||||
|
||||
// @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>
|
||||
@@ -0,0 +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>
|
||||
@@ -0,0 +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>
|
||||
|
||||
@@ -0,0 +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>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>这是cate</template>
|
||||
<script lang="ts" setup></script>
|
||||
<style lang="less" scoped></style>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>这是list</template>
|
||||
<script lang="ts" setup></script>
|
||||
<style lang="less" scoped></style>
|
||||
@@ -0,0 +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>
|
||||
@@ -0,0 +1,281 @@
|
||||
<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>
|
||||
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<div class="template-layout">
|
||||
<aside class="category-sidebar">
|
||||
<div class="sidebar-title">模板管理</div>
|
||||
<ul class="category-list">
|
||||
<li class="current-theme">
|
||||
<span>当前使用:</span>
|
||||
<el-tag type="success">{{ currentTheme }}</el-tag>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<main class="content-area">
|
||||
<header class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="result-count">共 {{ templateList.length }} 个模板</span>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<el-button type="primary" @click="fetchTemplates" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新模板
|
||||
</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>加载模板中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 模板列表 -->
|
||||
<div v-else class="template-grid">
|
||||
<div v-for="item in templateList" :key="item.key" class="template-card" :class="{ active: item.key === currentTheme }">
|
||||
<div class="card-preview">
|
||||
<img :src="getPreviewUrl(item.preview)" alt="preview" @error="handleImageError($event)" />
|
||||
<div v-if="item.key === currentTheme" class="current-tag">
|
||||
<el-tag type="success" size="small">使用中</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-info">
|
||||
<h4 class="title">{{ item.name }}</h4>
|
||||
<p class="description">{{ item.description }}</p>
|
||||
<div class="meta">
|
||||
<span class="version">v{{ item.version }}</span>
|
||||
<span v-if="item.author" class="author">{{ item.author }}</span>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<el-button
|
||||
v-if="item.key !== currentTheme"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleUse(item)"
|
||||
:loading="switching === item.key"
|
||||
>
|
||||
启用
|
||||
</el-button>
|
||||
<el-button v-else type="info" size="small" disabled>
|
||||
当前使用
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh, Loading } from '@element-plus/icons-vue'
|
||||
import { getThemeList, switchTheme } from '@/api/theme'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const switching = ref('')
|
||||
const templateList = ref<any[]>([])
|
||||
const currentTheme = ref('default')
|
||||
const currentTid = ref<number>(0)
|
||||
|
||||
// 获取完整预览图URL
|
||||
const getPreviewUrl = (path: string) => {
|
||||
if (!path) return ''
|
||||
if (path.startsWith('http')) return path
|
||||
return API_BASE_URL + path
|
||||
}
|
||||
|
||||
// 获取模板列表
|
||||
const fetchTemplates = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
currentTid.value = (authStore.user as any)?.tid || 0
|
||||
const res = await getThemeList({ tid: currentTid.value })
|
||||
if (res.code === 200) {
|
||||
templateList.value = res.data.list || []
|
||||
currentTheme.value = res.data.currentTheme || 'default'
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取模板列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('获取模板列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换模板
|
||||
const handleUse = async (item: any) => {
|
||||
if (!currentTid.value) {
|
||||
ElMessage.error('请先选择租户')
|
||||
return
|
||||
}
|
||||
switching.value = item.key
|
||||
try {
|
||||
const res = await switchTheme({ tid: currentTid.value, theme_key: item.key })
|
||||
if (res.code === 200) {
|
||||
currentTheme.value = item.key
|
||||
ElMessage.success('切换成功')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '切换失败')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('切换失败')
|
||||
} finally {
|
||||
switching.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 图片加载失败处理
|
||||
const handleImageError = (event: Event) => {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.src = 'https://picsum.photos/300/200?random=1'
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
fetchTemplates()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.template-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background-color: #f0f2f5;
|
||||
|
||||
.category-sidebar {
|
||||
width: 240px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #e8e8e8;
|
||||
padding: 20px 0;
|
||||
|
||||
.sidebar-title {
|
||||
padding: 0 24px 16px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.current-theme {
|
||||
padding: 12px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
padding: 0 24px 24px;
|
||||
overflow-y: auto;
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 64px;
|
||||
|
||||
.result-count {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px;
|
||||
color: #999;
|
||||
gap: 10px;
|
||||
|
||||
.el-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 2px solid #f0f0f0;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.active {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
.card-preview {
|
||||
height: 200px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #f5f5f5;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.current-tag {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.card-info {
|
||||
padding: 16px;
|
||||
|
||||
.title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin: 0 0 12px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<div class="exam-workbench">
|
||||
<!-- 数据统计 -->
|
||||
<div class="statistics-section">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<i class="fa-solid fa-file-lines"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ statistics.examCount }}</div>
|
||||
<div class="stat-label">考试总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ statistics.practiceCount }}</div>
|
||||
<div class="stat-label">练习总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<i class="fa-solid fa-book"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ statistics.courseCount }}</div>
|
||||
<div class="stat-label">课程总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<i class="fa-solid fa-users"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ statistics.studentCount }}</div>
|
||||
<div class="stat-label">考生总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 快捷功能 -->
|
||||
<div class="quick-actions">
|
||||
<h3 class="section-title">快捷功能</h3>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="4">
|
||||
<div class="action-card" @click="handleCreateExam">
|
||||
<div class="action-icon">
|
||||
<i class="fa-solid fa-file-circle-plus"></i>
|
||||
</div>
|
||||
<div class="action-label">创建考试</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<div class="action-card" @click="handleCreatePractice">
|
||||
<div class="action-icon">
|
||||
<i class="fa-solid fa-pen-to-square"></i>
|
||||
</div>
|
||||
<div class="action-label">创建练习</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<div class="action-card" @click="handleCreateCourse">
|
||||
<div class="action-icon">
|
||||
<i class="fa-solid fa-book-open"></i>
|
||||
</div>
|
||||
<div class="action-label">创建课程</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<div class="action-card" @click="handleBatchImport">
|
||||
<div class="action-icon">
|
||||
<i class="fa-solid fa-file-import"></i>
|
||||
</div>
|
||||
<div class="action-label">批量导题</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<div class="action-card" @click="handleStudentManage">
|
||||
<div class="action-icon">
|
||||
<i class="fa-solid fa-user-group"></i>
|
||||
</div>
|
||||
<div class="action-label">考生管理</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<div class="action-card" @click="handleQuestionBank">
|
||||
<div class="action-icon">
|
||||
<i class="fa-solid fa-database"></i>
|
||||
</div>
|
||||
<div class="action-label">试题库</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const statistics = ref({
|
||||
examCount: 0,
|
||||
practiceCount: 0,
|
||||
courseCount: 0,
|
||||
studentCount: 0
|
||||
});
|
||||
|
||||
const handleCreateExam = () => {
|
||||
router.push('/apps/exams/exam');
|
||||
};
|
||||
|
||||
const handleCreatePractice = () => {
|
||||
ElMessage.info('创建练习功能开发中');
|
||||
};
|
||||
|
||||
const handleCreateCourse = () => {
|
||||
ElMessage.info('创建课程功能开发中');
|
||||
};
|
||||
|
||||
const handleBatchImport = () => {
|
||||
ElMessage.info('批量导题功能开发中');
|
||||
};
|
||||
|
||||
const handleStudentManage = () => {
|
||||
ElMessage.info('考生管理功能开发中');
|
||||
};
|
||||
|
||||
const handleQuestionBank = () => {
|
||||
ElMessage.info('试题库功能开发中');
|
||||
};
|
||||
|
||||
const fetchStatistics = async () => {
|
||||
try {
|
||||
// TODO: 调用API获取统计数据
|
||||
statistics.value = {
|
||||
examCount: 0,
|
||||
practiceCount: 0,
|
||||
courseCount: 0,
|
||||
studentCount: 0
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchStatistics();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.exam-workbench {
|
||||
padding: 20px;
|
||||
|
||||
.statistics-section {
|
||||
margin-bottom: 30px;
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
i {
|
||||
font-size: 28px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 32px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
|
||||
.action-icon {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
|
||||
i {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: #f5f7fa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 16px;
|
||||
transition: all 0.3s;
|
||||
|
||||
i {
|
||||
font-size: 32px;
|
||||
color: #667eea;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
}
|
||||
|
||||
.action-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<div class="erp-dashboard">
|
||||
<!-- 第一行统计卡片 -->
|
||||
<el-row :gutter="16" class="stat-row">
|
||||
<el-col :span="4" v-for="(item, index) in firstRowStats" :key="'first-' + index">
|
||||
<div class="stat-card">
|
||||
<div class="stat-header">
|
||||
<span class="stat-label">{{ item.label }}</span>
|
||||
<el-icon class="stat-icon"><Info-Filled /></el-icon>
|
||||
</div>
|
||||
<div class="stat-value">
|
||||
<span class="currency">¥</span>
|
||||
<span class="number">{{ formatMoney(item.value) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 第二行统计卡片 -->
|
||||
<el-row :gutter="16" class="stat-row" style="margin-top: 16px;">
|
||||
<el-col :span="4" v-for="(item, index) in secondRowStats" :key="'second-' + index">
|
||||
<div class="stat-card">
|
||||
<div class="stat-header">
|
||||
<span class="stat-label">{{ item.label }}</span>
|
||||
<el-icon class="stat-icon"><Info-Filled /></el-icon>
|
||||
</div>
|
||||
<div class="stat-value">
|
||||
<span class="currency">¥</span>
|
||||
<span class="number">{{ formatMoney(item.value) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<el-row :gutter="16" class="chart-row" style="margin-top: 16px;">
|
||||
<el-col :span="8">
|
||||
<div class="chart-card">
|
||||
<div class="chart-title">销售统计</div>
|
||||
<div ref="salesChartRef" class="chart-container"></div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="chart-card">
|
||||
<div class="chart-title">零售统计</div>
|
||||
<div ref="retailChartRef" class="chart-container"></div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="chart-card">
|
||||
<div class="chart-title">采购统计</div>
|
||||
<div ref="purchaseChartRef" class="chart-container"></div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, shallowRef, nextTick } from 'vue';
|
||||
import * as echarts from 'echarts';
|
||||
import { InfoFilled } from '@element-plus/icons-vue';
|
||||
|
||||
// 统计卡片数据 - 第一行
|
||||
const firstRowStats = ref([
|
||||
{ label: '今日销售', value: 0 },
|
||||
{ label: '今日零售', value: 0 },
|
||||
{ label: '今日采购', value: 0 },
|
||||
{ label: '本月累计销售', value: 0 },
|
||||
{ label: '本月累计零售', value: 0 },
|
||||
{ label: '本月累计采购', value: 0 },
|
||||
]);
|
||||
|
||||
// 统计卡片数据 - 第二行
|
||||
const secondRowStats = ref([
|
||||
{ label: '昨日销售', value: 0 },
|
||||
{ label: '昨日零售', value: 0 },
|
||||
{ label: '昨日采购', value: 0 },
|
||||
{ label: '今年累计销售', value: 0 },
|
||||
{ label: '今年累计零售', value: 0 },
|
||||
{ label: '今年累计采购', value: 0 },
|
||||
]);
|
||||
|
||||
// 图表实例
|
||||
const salesChartRef = ref<HTMLElement | null>(null);
|
||||
const retailChartRef = ref<HTMLElement | null>(null);
|
||||
const purchaseChartRef = ref<HTMLElement | null>(null);
|
||||
const salesChartInstance = shallowRef<echarts.ECharts | null>(null);
|
||||
const retailChartInstance = shallowRef<echarts.ECharts | null>(null);
|
||||
const purchaseChartInstance = shallowRef<echarts.ECharts | null>(null);
|
||||
|
||||
// 格式化金额
|
||||
const formatMoney = (value: number): string => {
|
||||
return value.toLocaleString();
|
||||
};
|
||||
|
||||
// 生成最近6个月的月份标签
|
||||
const getMonthLabels = (): string[] => {
|
||||
const labels: string[] = [];
|
||||
const now = new Date();
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
labels.push(`${year}-${month}`);
|
||||
}
|
||||
return labels;
|
||||
};
|
||||
|
||||
// 初始化图表通用配置
|
||||
const initChart = (refEl: HTMLElement, title: string, color: string) => {
|
||||
const chart = echarts.init(refEl);
|
||||
const months = getMonthLabels();
|
||||
|
||||
chart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'cross' }
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
top: '15%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: months,
|
||||
axisLine: { lineStyle: { color: '#E0E6ED' } },
|
||||
axisLabel: { color: '#606266', fontSize: 11 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: { lineStyle: { color: '#F2F6FC' } },
|
||||
axisLabel: { color: '#606266', fontSize: 11 }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: title,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: { color: color, width: 2 },
|
||||
itemStyle: { color: color },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: color + '40' },
|
||||
{ offset: 1, color: color + '10' }
|
||||
])
|
||||
},
|
||||
data: [0, 0, 0, 0, 0, 0]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return chart;
|
||||
};
|
||||
|
||||
// 初始化所有图表
|
||||
const initCharts = () => {
|
||||
if (salesChartRef.value) {
|
||||
salesChartInstance.value = initChart(salesChartRef.value, '销售', '#409EFF');
|
||||
}
|
||||
if (retailChartRef.value) {
|
||||
retailChartInstance.value = initChart(retailChartRef.value, '零售', '#67C23A');
|
||||
}
|
||||
if (purchaseChartRef.value) {
|
||||
purchaseChartInstance.value = initChart(purchaseChartRef.value, '采购', '#E6A23C');
|
||||
}
|
||||
};
|
||||
|
||||
// 加载数据(模拟接口调用)
|
||||
const loadData = async () => {
|
||||
// TODO: 调用接口获取真实数据
|
||||
// const res = await getErpDashboard();
|
||||
|
||||
// 模拟数据更新
|
||||
firstRowStats.value = [
|
||||
{ label: '今日销售', value: 12580 },
|
||||
{ label: '今日零售', value: 8560 },
|
||||
{ label: '今日采购', value: 23400 },
|
||||
{ label: '本月累计销售', value: 358600 },
|
||||
{ label: '本月累计零售', value: 245800 },
|
||||
{ label: '本月累计采购', value: 568900 },
|
||||
];
|
||||
|
||||
secondRowStats.value = [
|
||||
{ label: '昨日销售', value: 15230 },
|
||||
{ label: '昨日零售', value: 9870 },
|
||||
{ label: '昨日采购', value: 18900 },
|
||||
{ label: '今年累计销售', value: 2856000 },
|
||||
{ label: '今年累计零售', value: 1985000 },
|
||||
{ label: '今年累计采购', value: 4568000 },
|
||||
];
|
||||
|
||||
// 更新图表数据
|
||||
updateCharts();
|
||||
};
|
||||
|
||||
// 更新图表数据
|
||||
const updateCharts = () => {
|
||||
const salesData = [28000, 32000, 35000, 38000, 42000, 358600];
|
||||
const retailData = [18000, 21000, 22000, 25000, 28000, 245800];
|
||||
const purchaseData = [45000, 48000, 52000, 55000, 58000, 568900];
|
||||
|
||||
if (salesChartInstance.value) {
|
||||
salesChartInstance.value.setOption({ series: [{ data: salesData }] });
|
||||
}
|
||||
if (retailChartInstance.value) {
|
||||
retailChartInstance.value.setOption({ series: [{ data: retailData }] });
|
||||
}
|
||||
if (purchaseChartInstance.value) {
|
||||
purchaseChartInstance.value.setOption({ series: [{ data: purchaseData }] });
|
||||
}
|
||||
};
|
||||
|
||||
// 自适应窗口大小
|
||||
const handleResize = () => {
|
||||
salesChartInstance.value?.resize();
|
||||
retailChartInstance.value?.resize();
|
||||
purchaseChartInstance.value?.resize();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
initCharts();
|
||||
await nextTick();
|
||||
await loadData();
|
||||
window.addEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
salesChartInstance.value?.dispose();
|
||||
retailChartInstance.value?.dispose();
|
||||
purchaseChartInstance.value?.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.erp-dashboard {
|
||||
background-color: #f5f7fa;
|
||||
min-height: calc(100vh - 84px);
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
.el-col {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.stat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 14px;
|
||||
color: #c0c4cc;
|
||||
cursor: help;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
.currency {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.number {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
font-family: 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chart-row {
|
||||
.el-col {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||
height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.chart-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="修改密码" width="420px" :close-on-click-modal="false">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||
<el-form-item label="用户">
|
||||
<span>{{ currentUser.name || currentUser.account || "-" }}</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="新密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入新密码"
|
||||
show-password
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="确认密码" prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
placeholder="请再次输入新密码"
|
||||
show-password
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from "vue";
|
||||
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
|
||||
import { changePassword } from "@/api/user";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "submit"): void;
|
||||
}>();
|
||||
|
||||
const visible = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const currentUser = ref<any>({});
|
||||
|
||||
const form = reactive({
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
|
||||
const validateConfirmPassword = (_rule: any, value: string, callback: (error?: Error) => void) => {
|
||||
if (!value) {
|
||||
callback(new Error("请再次输入新密码"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (value !== form.password) {
|
||||
callback(new Error("两次输入的密码不一致"));
|
||||
return;
|
||||
}
|
||||
|
||||
callback();
|
||||
};
|
||||
|
||||
const rules: FormRules = {
|
||||
password: [
|
||||
{ required: true, message: "请输入新密码", trigger: "blur" },
|
||||
{ min: 6, message: "密码长度不能少于 6 位", trigger: "blur" },
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: "请再次输入新密码", trigger: "blur" },
|
||||
{ validator: validateConfirmPassword, trigger: "blur" },
|
||||
],
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
form.password = "";
|
||||
form.confirmPassword = "";
|
||||
formRef.value?.clearValidate();
|
||||
};
|
||||
|
||||
const openChangePass = (row: any) => {
|
||||
currentUser.value = row || {};
|
||||
resetForm();
|
||||
visible.value = true;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!currentUser.value?.id) {
|
||||
ElMessage.error("用户 ID 不存在");
|
||||
return;
|
||||
}
|
||||
|
||||
await formRef.value?.validate();
|
||||
|
||||
submitLoading.value = true;
|
||||
try {
|
||||
await changePassword(currentUser.value.id, {
|
||||
newPassword: form.password,
|
||||
});
|
||||
|
||||
ElMessage.success("密码修改成功");
|
||||
visible.value = false;
|
||||
emit("submit");
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || e?.message || "密码修改失败");
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
openChangePass,
|
||||
open: openChangePass,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped></style>
|
||||
@@ -0,0 +1,583 @@
|
||||
<template>
|
||||
<el-drawer v-model="visible" :title="dialogTitle" width="500px">
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
||||
<div class="form-title">账号信息</div>
|
||||
<!-- 账号 -->
|
||||
<el-form-item label="账号" required>
|
||||
<el-input v-model="form.account" :disabled="!isAdd" placeholder="请输入账号" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 密码 -->
|
||||
<el-form-item label="密码" prop="password" v-if="isAdd" required>
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
show-password
|
||||
:placeholder="isAdd ? '请输入密码(至少6位)' : '留空则不修改密码'"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 确认密码 -->
|
||||
<el-form-item label="确认密码" prop="confirmPassword" v-if="isAdd" required>
|
||||
<el-input
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
show-password
|
||||
:placeholder="isAdd ? '请再次输入密码' : '留空则不修改密码'"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<div class="form-title">个人信息</div>
|
||||
<!-- 姓名 -->
|
||||
<el-form-item label="姓名" required>
|
||||
<el-input v-model="form.name" placeholder="请输入姓名" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 性别 -->
|
||||
<el-form-item label="性别" required>
|
||||
<el-radio-group v-model="form.sex" placeholder="请选择性别">
|
||||
<el-radio-button label="未知" :value="0" />
|
||||
<el-radio-button label="男" :value="1" />
|
||||
<el-radio-button label="女" :value="2" />
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 生日 -->
|
||||
<el-form-item label="生日" required>
|
||||
<el-date-picker
|
||||
v-model="form.birthday"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="请选择生日"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 隶属单位 -->
|
||||
<el-form-item label="隶属单位" required>
|
||||
<el-select v-model="form.affiliate_unit" placeholder="请选择隶属单位" clearable style="width: 100%">
|
||||
<el-option v-for="item in companyList" :key="item.id" :label="item.org_name" :value="String(item.id)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 部门 -->
|
||||
<el-form-item label="部门" required>
|
||||
<el-select v-model="form.department" placeholder="请先选择隶属单位" clearable style="width: 100%" :disabled="!form.affiliate_unit">
|
||||
<el-option v-for="item in departmentList" :key="item.id" :label="item.org_name" :value="String(item.id)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 职位 -->
|
||||
<el-form-item label="职位" required>
|
||||
<el-input v-model="form.position" placeholder="请输入职位" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 学历 -->
|
||||
<el-form-item label="学历">
|
||||
<el-input v-model="form.education" placeholder="请输入学历" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 民族 -->
|
||||
<el-form-item label="民族" required>
|
||||
<el-input v-model="form.nation" placeholder="请输入民族" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 手机 -->
|
||||
<el-form-item label="手机" required>
|
||||
<el-input v-model="form.phone" placeholder="请输入手机号" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 微信 -->
|
||||
<el-form-item label="微信">
|
||||
<el-input v-model="form.wechat" placeholder="请输入微信" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 邮箱 -->
|
||||
<el-form-item label="邮箱" required>
|
||||
<el-input v-model="form.email" placeholder="请输入邮箱" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 家庭住址 -->
|
||||
<el-form-item label="家庭住址" required>
|
||||
<el-input v-model="form.home_address" placeholder="请输入家庭住址" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 状态 -->
|
||||
<el-form-item label="状态">
|
||||
<el-select
|
||||
v-model="form.account_status"
|
||||
placeholder="请选择状态"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option label="离职" :value="2" v-if="!isAdd" />
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 对话框脚部 -->
|
||||
<template #footer>
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { createEmployee, editEmployee, getEmployeeDetail, getCompanys, getDepartments } from "@/api/erp";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isEdit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
statusDict: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "submit", "close"]);
|
||||
|
||||
const visible = ref(false);
|
||||
const formRef = ref<any>(null);
|
||||
const isAdd = ref(false);
|
||||
const companyList = ref<any[]>([]);
|
||||
const departmentList = ref<any[]>([]);
|
||||
const isHydratingUserData = ref(false);
|
||||
|
||||
// 获取公司列表
|
||||
const loadCompanyList = async () => {
|
||||
try {
|
||||
const res = await getCompanys();
|
||||
companyList.value = res.data || res || [];
|
||||
} catch (e) {
|
||||
console.error("获取公司列表失败", e);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 获取部门列表
|
||||
const loadDepartments = async (parentId?: number) => {
|
||||
try {
|
||||
const res = await getDepartments(parentId);
|
||||
departmentList.value = res.data || res || [];
|
||||
} catch (e) {
|
||||
console.error("获取部门列表失败", e);
|
||||
}
|
||||
};
|
||||
|
||||
const form = ref<any>({
|
||||
id: null,
|
||||
tid: 1,
|
||||
tenant_id: 1,
|
||||
account: "",
|
||||
name: "",
|
||||
phone: "",
|
||||
qq: "",
|
||||
sex: 0,
|
||||
birthday: "",
|
||||
affiliate_unit: "",
|
||||
department: "",
|
||||
position: "",
|
||||
education: "",
|
||||
nation: "",
|
||||
wechat: "",
|
||||
home_address: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
email: "",
|
||||
account_status: 1,
|
||||
status: 1,
|
||||
});
|
||||
|
||||
// 监听隶属单位变化,加载对应部门
|
||||
watch(() => form.value.affiliate_unit, (newVal) => {
|
||||
if (isHydratingUserData.value) {
|
||||
return;
|
||||
}
|
||||
form.value.department = "";
|
||||
departmentList.value = [];
|
||||
if (newVal) {
|
||||
loadDepartments(Number(newVal));
|
||||
}
|
||||
});
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
return isAdd.value ? "添加用户" : "编辑用户";
|
||||
});
|
||||
|
||||
// 密码验证规则
|
||||
const validatePassword = (rule: any, value: any, callback: any) => {
|
||||
if (isAdd.value) {
|
||||
// 新增用户时,密码必填
|
||||
if (!value) {
|
||||
callback(new Error("请输入密码"));
|
||||
return;
|
||||
}
|
||||
if (value.length < 6) {
|
||||
callback(new Error("密码长度不能少于6位"));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 编辑用户时,如果填写了密码,则必须符合规则
|
||||
if (value && value.length < 6) {
|
||||
callback(new Error("密码长度不能少于6位"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
callback();
|
||||
};
|
||||
|
||||
// 确认密码验证规则
|
||||
const validateConfirmPassword = (rule: any, value: any, callback: any) => {
|
||||
if (isAdd.value) {
|
||||
// 新增用户时,确认密码必填
|
||||
if (!value) {
|
||||
callback(new Error("请再次输入密码"));
|
||||
return;
|
||||
}
|
||||
if (value !== form.value.password) {
|
||||
callback(new Error("两次输入的密码不一致"));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 编辑用户时,如果填写了密码,则确认密码必须一致
|
||||
if (form.value.password && value !== form.value.password) {
|
||||
callback(new Error("两次输入的密码不一致"));
|
||||
return;
|
||||
}
|
||||
// 如果填写了确认密码但没填密码,提示错误
|
||||
if (value && !form.value.password) {
|
||||
callback(new Error("请先输入密码"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
callback();
|
||||
};
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
account: [
|
||||
{ required: true, message: "请输入账号", trigger: "blur" },
|
||||
{ min: 3, max: 20, message: "账号长度在 3 到 20 个字符", trigger: "blur" },
|
||||
],
|
||||
name: [{ required: true, message: "请输入姓名", trigger: "blur" }],
|
||||
password: [{ validator: validatePassword, trigger: "blur" }],
|
||||
confirmPassword: [{ validator: validateConfirmPassword, trigger: "blur" }],
|
||||
email: [{ type: "email", message: "请输入正确的邮箱地址", trigger: "blur" }],
|
||||
};
|
||||
|
||||
// 监听 modelValue
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
visible.value = newVal;
|
||||
}
|
||||
);
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(visible, (newVal) => {
|
||||
if (!newVal) {
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
});
|
||||
|
||||
// 监听 statusDict 变化,用于调试
|
||||
watch(
|
||||
() => props.statusDict,
|
||||
(newVal) => {},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
const loadUserData = async (user: any) => {
|
||||
try {
|
||||
// 处理两种调用方式:传递用户对象或用户 ID
|
||||
const userId = typeof user === "number" ? user : user?.id || user?.userId;
|
||||
if (!userId) {
|
||||
throw new Error("未提供有效的用户 ID");
|
||||
}
|
||||
|
||||
const res = await getEmployeeDetail(userId);
|
||||
|
||||
const data = res.data || res;
|
||||
|
||||
// 确保 sex 和 status 都是数字类型
|
||||
const sexValue =
|
||||
data.sex !== undefined && data.sex !== null
|
||||
? Number(data.sex)
|
||||
: data.gender !== undefined && data.gender !== null
|
||||
? Number(data.gender)
|
||||
: 0;
|
||||
|
||||
const statusValue =
|
||||
data.account_status !== undefined && data.account_status !== null
|
||||
? Number(data.account_status)
|
||||
: data.status !== undefined && data.status !== null
|
||||
? Number(data.status)
|
||||
: 1;
|
||||
|
||||
const affiliateUnitValue =
|
||||
data.affiliate_unit !== undefined && data.affiliate_unit !== null
|
||||
? String(data.affiliate_unit)
|
||||
: "";
|
||||
const departmentValue =
|
||||
data.department !== undefined && data.department !== null
|
||||
? String(data.department)
|
||||
: "";
|
||||
|
||||
isHydratingUserData.value = true;
|
||||
form.value = {
|
||||
id: data.id,
|
||||
tid: data.tid || data.tenant_id || 1,
|
||||
tenant_id: data.tenant_id || data.tid || 1,
|
||||
account: data.account || "",
|
||||
name: data.name || "",
|
||||
phone: data.phone || "",
|
||||
qq: data.qq || "",
|
||||
sex: sexValue,
|
||||
birthday: data.birthday || "",
|
||||
affiliate_unit: affiliateUnitValue,
|
||||
department: departmentValue,
|
||||
position: data.position || "",
|
||||
education: data.education || "",
|
||||
nation: data.nation || data.nationality || "",
|
||||
wechat: data.wechat || "",
|
||||
home_address: data.home_address || "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
email: data.email || "",
|
||||
account_status: statusValue,
|
||||
status: statusValue,
|
||||
};
|
||||
|
||||
await nextTick();
|
||||
|
||||
departmentList.value = [];
|
||||
if (form.value.affiliate_unit) {
|
||||
await loadDepartments(Number(form.value.affiliate_unit));
|
||||
}
|
||||
form.value.department = departmentValue;
|
||||
isHydratingUserData.value = false;
|
||||
} catch (e: any) {
|
||||
console.error("Failed to load user data:", e);
|
||||
const errorMsg = e?.response?.data?.message || e?.message || "加载用户失败";
|
||||
ElMessage.error(errorMsg);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
visible.value = false;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// 表单验证
|
||||
if (!formRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch (error) {
|
||||
ElMessage.warning("请检查表单填写是否正确");
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证密码一致性
|
||||
if (isAdd.value) {
|
||||
// 新增用户时,密码必填
|
||||
if (!form.value.password) {
|
||||
ElMessage.error("请输入密码");
|
||||
return;
|
||||
}
|
||||
if (form.value.password !== form.value.confirmPassword) {
|
||||
ElMessage.error("两次输入的密码不一致");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 编辑用户时,如果填写了密码,则必须填写确认密码且一致
|
||||
if (form.value.password) {
|
||||
if (form.value.password !== form.value.confirmPassword) {
|
||||
ElMessage.error("两次输入的密码不一致");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (isAdd.value) {
|
||||
// 新增用户
|
||||
const submitData: any = {
|
||||
tid: form.value.tid,
|
||||
tenant_id: form.value.tenant_id,
|
||||
account: form.value.account,
|
||||
name: form.value.name,
|
||||
phone: form.value.phone,
|
||||
qq: form.value.qq,
|
||||
sex: form.value.sex,
|
||||
birthday: form.value.birthday,
|
||||
affiliate_unit: form.value.affiliate_unit,
|
||||
department: form.value.department,
|
||||
position: form.value.position,
|
||||
education: form.value.education,
|
||||
nation: form.value.nation,
|
||||
wechat: form.value.wechat,
|
||||
email: form.value.email,
|
||||
home_address: form.value.home_address,
|
||||
account_status: form.value.account_status,
|
||||
status: form.value.account_status,
|
||||
password: form.value.password,
|
||||
};
|
||||
|
||||
await createEmployee(submitData);
|
||||
ElMessage.success("添加成功");
|
||||
} else {
|
||||
// 编辑用户
|
||||
if (!form.value.id || form.value.id === 0) {
|
||||
ElMessage.error("用户ID不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
const submitData: any = {
|
||||
id: form.value.id,
|
||||
tid: form.value.tid,
|
||||
tenant_id: form.value.tenant_id,
|
||||
account: form.value.account,
|
||||
name: form.value.name,
|
||||
phone: form.value.phone,
|
||||
qq: form.value.qq,
|
||||
sex: form.value.sex,
|
||||
birthday: form.value.birthday,
|
||||
affiliate_unit: form.value.affiliate_unit,
|
||||
department: form.value.department,
|
||||
position: form.value.position,
|
||||
education: form.value.education,
|
||||
nation: form.value.nation,
|
||||
wechat: form.value.wechat,
|
||||
email: form.value.email,
|
||||
home_address: form.value.home_address,
|
||||
account_status: form.value.account_status,
|
||||
status: form.value.account_status,
|
||||
};
|
||||
|
||||
// 只有在填写了密码时才添加到提交数据中
|
||||
if (form.value.password) {
|
||||
submitData.password = form.value.password;
|
||||
}
|
||||
|
||||
await editEmployee(form.value.id, submitData);
|
||||
ElMessage.success("更新成功");
|
||||
}
|
||||
|
||||
visible.value = false;
|
||||
emit("submit");
|
||||
} catch (e: any) {
|
||||
const errorMsg = e?.response?.data?.message || e?.message || "操作失败";
|
||||
ElMessage.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
loadUserData,
|
||||
openAdd: () => {
|
||||
isAdd.value = true;
|
||||
form.value = {
|
||||
id: 0,
|
||||
tid: 1,
|
||||
tenant_id: 1,
|
||||
account: "",
|
||||
name: "",
|
||||
phone: "",
|
||||
qq: "",
|
||||
sex: 0,
|
||||
birthday: "",
|
||||
affiliate_unit: "",
|
||||
department: "",
|
||||
position: "",
|
||||
education: "",
|
||||
nation: "",
|
||||
wechat: "",
|
||||
home_address: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
email: "",
|
||||
account_status: 1,
|
||||
status: 1,
|
||||
};
|
||||
loadCompanyList();
|
||||
visible.value = true;
|
||||
// 清除表单验证
|
||||
if (formRef.value) {
|
||||
formRef.value.clearValidate();
|
||||
}
|
||||
},
|
||||
openEdit: (user: any) => {
|
||||
isAdd.value = false;
|
||||
loadCompanyList();
|
||||
visible.value = true;
|
||||
// 清除表单验证
|
||||
if (formRef.value) {
|
||||
formRef.value.clearValidate();
|
||||
}
|
||||
// 异步加载用户详细信息
|
||||
loadUserData(user);
|
||||
},
|
||||
open: (user?: any) => {
|
||||
loadCompanyList();
|
||||
if (user) {
|
||||
isAdd.value = false;
|
||||
loadUserData(user);
|
||||
} else {
|
||||
isAdd.value = true;
|
||||
form.value = {
|
||||
id: 0,
|
||||
tid: 1,
|
||||
tenant_id: 1,
|
||||
account: "",
|
||||
name: "",
|
||||
phone: "",
|
||||
qq: "",
|
||||
sex: 0,
|
||||
birthday: "",
|
||||
affiliate_unit: "",
|
||||
department: "",
|
||||
position: "",
|
||||
education: "",
|
||||
nation: "",
|
||||
wechat: "",
|
||||
home_address: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
email: "",
|
||||
account_status: 1,
|
||||
status: 1,
|
||||
};
|
||||
}
|
||||
visible.value = true;
|
||||
// 清除表单验证
|
||||
if (formRef.value) {
|
||||
formRef.value.clearValidate();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.form-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<el-drawer v-model="visible" title="查看用户" width="500px">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="ID">{{ detail.id || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="账号">{{ detail.account || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="姓名">{{ detail.name || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="性别">{{ genderText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="生日">{{ detail.birthday || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="隶属单位">{{ detail.affiliate_unit_name || detail.affiliate_unit || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="部门">{{ detail.department_name || detail.department || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="职位">{{ detail.position || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="学历">{{ detail.education || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="民族">{{ detail.nation || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机">{{ detail.phone || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="微信">{{ detail.wechat || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="邮箱">{{ detail.email || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="家庭住址">{{ detail.home_address || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="账号状态">
|
||||
<el-tag :type="statusValue === 1 ? 'success' : 'danger'">
|
||||
{{ statusText }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { getEmployeeDetail } from "@/api/erp";
|
||||
|
||||
const visible = ref(false);
|
||||
const detail = ref<any>({});
|
||||
|
||||
const genderValue = computed(() => {
|
||||
const value = detail.value.sex ?? detail.value.gender ?? 0;
|
||||
return Number(value);
|
||||
});
|
||||
|
||||
const genderText = computed(() => {
|
||||
if (genderValue.value === 1) {
|
||||
return "男";
|
||||
}
|
||||
if (genderValue.value === 2) {
|
||||
return "女";
|
||||
}
|
||||
return "未知";
|
||||
});
|
||||
|
||||
const statusValue = computed(() => {
|
||||
const value = detail.value.account_status ?? detail.value.status ?? 0;
|
||||
return Number(value);
|
||||
});
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (statusValue.value === 1) {
|
||||
return "启用";
|
||||
}
|
||||
if (statusValue.value === 2) {
|
||||
return "离职";
|
||||
}
|
||||
return "禁用";
|
||||
});
|
||||
|
||||
const loadUserData = async (user: any) => {
|
||||
try {
|
||||
const userId = typeof user === "number" ? user : user?.id || user?.userId;
|
||||
if (!userId) {
|
||||
throw new Error("未提供有效的用户 ID");
|
||||
}
|
||||
|
||||
const res = await getEmployeeDetail(userId);
|
||||
detail.value = res.data || res || {};
|
||||
} catch (e: any) {
|
||||
const errorMsg = e?.response?.data?.message || e?.message || "加载用户失败";
|
||||
ElMessage.error(errorMsg);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const openView = async (user: any) => {
|
||||
visible.value = true;
|
||||
await loadUserData(user);
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
loadUserData,
|
||||
openView,
|
||||
open: openView,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped></style>
|
||||
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>用户管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="handleAddUser">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加用户
|
||||
</el-button>
|
||||
<el-button @click="refresh">
|
||||
<el-icon>
|
||||
<Refresh />
|
||||
</el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 用户列表表格 -->
|
||||
<el-table :data="users" style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" align="center" fixed="left" width="80" />
|
||||
<el-table-column prop="account" label="账号" align="center" width="120" />
|
||||
<el-table-column prop="name" label="姓名" align="center" width="120">
|
||||
<template #default="scope">
|
||||
<span class="name-link" @click="handlePreview(scope.row)">{{ scope.row.name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="隶属单位" align="center">
|
||||
<template #default="scope">
|
||||
{{ scope.row.affiliate_unit_name || scope.row.affiliate_unit || "-" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="部门" align="center">
|
||||
<template #default="scope">
|
||||
{{ scope.row.department_name || scope.row.department || "-" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="position" label="职位" align="center" />
|
||||
<el-table-column prop="account_status" label="账号状态" width="80" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getStatusValue(scope.row) === 1 ? 'success' : 'danger'">{{
|
||||
getStatusValue(scope.row) === 1 ? "启用" : getStatusValue(scope.row) === 2 ? "离职" : "禁用"
|
||||
}}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" align="center" fixed="right">
|
||||
<template #default="scope"><!-- -->
|
||||
<el-button size="small" @click="handlePreview(scope.row)">查看</el-button>
|
||||
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
||||
<el-button size="small" type="warning" @click="handleChangePassword(scope.row)">
|
||||
修改密码
|
||||
</el-button>
|
||||
<el-button v-if="scope.row.username !== 'admin' && scope.row.id !== 1" size="small" type="danger"
|
||||
@click="handleDelete(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-bar">
|
||||
<el-pagination :current-page="page" :page-size="pageSize" :total="total" @current-change="handlePageChange"
|
||||
layout="total, prev, pager, next" />
|
||||
</div>
|
||||
|
||||
<!-- 查看弹窗 -->
|
||||
<View ref="viewRef" />
|
||||
|
||||
<!-- 修改密码弹窗 -->
|
||||
<ChangePass ref="changePassRef" @submit="fetchData" />
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<Edit ref="editRef" @submit="fetchData" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus, Refresh } from "@element-plus/icons-vue";
|
||||
import { getEmployeeList, deleteEmployee } from "@/api/erp";
|
||||
import Edit from "./components/edit.vue";
|
||||
import View from "./components/view.vue";
|
||||
import ChangePass from "./components/changepass.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const users = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const total = ref(0);
|
||||
const editRef = ref<any>(null);
|
||||
const viewRef = ref<any>(null);
|
||||
const changePassRef = ref<any>(null);
|
||||
|
||||
const getStatusValue = (row: any) => {
|
||||
const status = row.account_status ?? row.status ?? 0;
|
||||
return Number(status);
|
||||
};
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const tenantId = (authStore.user as any)?.tid;
|
||||
|
||||
// console.log(tenantId);
|
||||
|
||||
// 获取列表数据
|
||||
const fetchData = async (currentTenantId = tenantId) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getEmployeeList(currentTenantId);
|
||||
users.value = res.data || res || [];
|
||||
total.value = users.value.length;
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || "获取列表失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新
|
||||
const refresh = () => {
|
||||
fetchData(tenantId);
|
||||
};
|
||||
|
||||
// 添加用户
|
||||
const handleAddUser = () => {
|
||||
editRef.value?.openAdd();
|
||||
};
|
||||
|
||||
// 编辑用户
|
||||
const handleEdit = (row: any) => {
|
||||
editRef.value?.openEdit(row);
|
||||
};
|
||||
|
||||
const handleChangePassword = (row: any) => {
|
||||
changePassRef.value?.openChangePass(row);
|
||||
};
|
||||
|
||||
// 查看
|
||||
const handlePreview = (row: any) => {
|
||||
viewRef.value?.openView(row);
|
||||
};
|
||||
|
||||
// 删除用户
|
||||
const handleDelete = async (row: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除用户 "${row.name}" 吗?`, "提示", {
|
||||
type: "warning",
|
||||
});
|
||||
await deleteEmployee(row.id);
|
||||
ElMessage.success("删除成功");
|
||||
fetchData(tenantId);
|
||||
} catch (e: any) {
|
||||
if (e !== "cancel") {
|
||||
ElMessage.error(e?.message || "删除失败");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 分页变化
|
||||
const handlePageChange = (val: number) => {
|
||||
page.value = val;
|
||||
fetchData(tenantId);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchData(tenantId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-alert__title) {
|
||||
color: #f56c6c !important;
|
||||
}
|
||||
|
||||
.name-link {
|
||||
color: #3973ff;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #66b1ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup></script>
|
||||
|
||||
<style lang="less" scoped></style>
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" :title="isEdit ? '编辑组织' : '添加组织'" width="500px" :close-on-click-modal="false">
|
||||
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
|
||||
|
||||
<el-form-item label="上级组织" prop="parent_id">
|
||||
<el-tree-select v-model="formData.parent_id" :data="extendedTreeData"
|
||||
:props="props.treeProps || defaultTreeProps" check-strictly placeholder="请选择上级组织" clearable
|
||||
style="width: 100%" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="组织名称" prop="org_name">
|
||||
<el-input v-model="formData.org_name" placeholder="请输入组织名称,例如:连云港云泽集团" />
|
||||
</el-form-item>
|
||||
<el-form-item label="组织编码" prop="org_code">
|
||||
<el-input v-model="formData.org_code" placeholder="请输入组织编码,例如:LYG-YZ-GROUP" />
|
||||
</el-form-item>
|
||||
<el-form-item label="负责人" prop="leader_id">
|
||||
<el-select v-model="formData.leader_id" placeholder="请选择负责人" filterable clearable style="width: 100%">
|
||||
<el-option
|
||||
v-for="item in leaderList"
|
||||
:key="item.id"
|
||||
:label="item.name || item.account || String(item.id)"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<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-form-item label="是否是公司" prop="is_company">
|
||||
<el-radio-group v-model="formData.is_company">
|
||||
<el-radio :label="1">是</el-radio>
|
||||
<el-radio :label="0">否</el-radio>
|
||||
</el-radio-group>
|
||||
</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-item label="备注" prop="remark">
|
||||
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入备注" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { createOrganization, editOrganization } from '@/api/erp'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { getTenantUsers } from '@/api/user'
|
||||
|
||||
interface TreeNode {
|
||||
id: number
|
||||
org_name: string
|
||||
org_code?: string
|
||||
parent_id?: number
|
||||
parent_name?: string
|
||||
leader_id?: number
|
||||
sort?: number
|
||||
status?: number
|
||||
is_company?: number
|
||||
remark?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
treeData: TreeNode[]
|
||||
treeProps?: {
|
||||
label: string
|
||||
children: string
|
||||
value: string
|
||||
}
|
||||
}>()
|
||||
|
||||
// 默认 treeProps
|
||||
const defaultTreeProps = {
|
||||
label: 'org_name',
|
||||
children: 'children',
|
||||
value: 'id'
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const isEdit = ref(false)
|
||||
const authStore = useAuthStore()
|
||||
const tenantId = (authStore.user as any)?.tid
|
||||
const leaderList = ref<Array<{ id: number; name?: string; account?: string }>>([])
|
||||
const formData = reactive({
|
||||
id: 0,
|
||||
parent_id: undefined as number | undefined,
|
||||
org_name: '',
|
||||
org_code: '',
|
||||
leader_id: undefined as number | undefined,
|
||||
sort: 0,
|
||||
is_company: 0,
|
||||
status: 1,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const formRules: FormRules = {
|
||||
org_name: [{ required: true, message: '请输入组织名称', trigger: 'blur' }],
|
||||
org_code: [{ required: true, message: '请输入组织编码', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const treeOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
id: 0,
|
||||
org_name: '顶级组织', // 显示名称
|
||||
children: props.treeData // 将原有的树结构作为其子节点(或者并列,取决于你的业务)
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// 如果你希望“顶级组织”是作为一个独立的根选项,而不是所有节点的父级:
|
||||
const extendedTreeData = computed(() => {
|
||||
const rootNode = { id: 0, org_name: '顶级组织' }
|
||||
return [rootNode, ...props.treeData]
|
||||
})
|
||||
|
||||
const getLeaderList = async () => {
|
||||
if (!tenantId) {
|
||||
leaderList.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const res: any = await getTenantUsers(tenantId)
|
||||
const responseData = res?.data?.code !== undefined ? res.data : res
|
||||
|
||||
if (responseData?.code === 200) {
|
||||
const list = responseData.data?.list || responseData.data || []
|
||||
leaderList.value = list.map((item: any) => ({
|
||||
...item,
|
||||
id: Number(item.id)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// 打开弹窗
|
||||
const open = (data?: TreeNode, parentId?: number) => {
|
||||
getLeaderList()
|
||||
if (data) {
|
||||
// 编辑:只拷贝需要的字段,避免把 children 等多余字段带到表单
|
||||
isEdit.value = true
|
||||
formData.id = data.id
|
||||
formData.parent_id = data.parent_id ?? 0
|
||||
formData.org_name = data.org_name
|
||||
formData.org_code = data.org_code || ''
|
||||
formData.leader_id = data.leader_id === undefined || data.leader_id === null ? undefined : Number(data.leader_id)
|
||||
formData.is_company = data.is_company ?? 0
|
||||
formData.sort = data.sort ?? 0
|
||||
formData.status = data.status ?? 1
|
||||
formData.remark = data.remark || ''
|
||||
} else {
|
||||
// 新增
|
||||
isEdit.value = false
|
||||
formData.id = 0
|
||||
formData.parent_id = parentId ?? 0
|
||||
formData.org_name = ''
|
||||
formData.org_code = ''
|
||||
formData.leader_id = undefined
|
||||
formData.is_company = 0
|
||||
formData.sort = 0
|
||||
formData.status = 1
|
||||
formData.remark = ''
|
||||
}
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate()
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
let res
|
||||
|
||||
|
||||
// 只允许提交到后台的字段白名单
|
||||
const allowedKeys = ['parent_id', 'org_name', 'org_code', 'leader_id', 'sort', 'is_company', 'status', 'remark']
|
||||
|
||||
if (isEdit.value && formData.id) {
|
||||
// 编辑:使用普通对象提交
|
||||
const submitData: Record<string, any> = {}
|
||||
allowedKeys.forEach(key => {
|
||||
const value = (formData as any)[key]
|
||||
if (value !== undefined && value !== null) {
|
||||
submitData[key] = value
|
||||
}
|
||||
})
|
||||
if (tenantId) {
|
||||
submitData.tid = tenantId
|
||||
}
|
||||
res = await editOrganization(formData.id, submitData)
|
||||
} else {
|
||||
// 新增:使用 FormData 以兼容后端 multipart/form-data
|
||||
const submitData = new FormData()
|
||||
allowedKeys.forEach(key => {
|
||||
const value = (formData as any)[key]
|
||||
if (value !== undefined && value !== null) {
|
||||
submitData.append(key, String(value))
|
||||
}
|
||||
})
|
||||
if (tenantId) {
|
||||
submitData.append('tid', String(tenantId))
|
||||
}
|
||||
res = await createOrganization(submitData)
|
||||
}
|
||||
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(isEdit.value ? '编辑成功' : '添加成功')
|
||||
emit('success')
|
||||
handleClose()
|
||||
} else {
|
||||
ElMessage.error(res.msg || (isEdit.value ? '编辑失败' : '添加失败'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
ElMessage.error('提交失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
getLeaderList()
|
||||
})
|
||||
|
||||
defineExpose({ open })
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,358 @@
|
||||
<template>
|
||||
<div class="organization-container">
|
||||
<el-row :gutter="16">
|
||||
<!-- 左侧:组织架构树 -->
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover" class="tree-card" v-loading="loading">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>组织架构</span>
|
||||
<el-button type="primary" link @click="handleAddRoot">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-tree
|
||||
ref="treeRef"
|
||||
:data="treeData"
|
||||
:props="treeProps"
|
||||
node-key="id"
|
||||
default-expand-all
|
||||
:expand-on-click-node="false"
|
||||
@node-click="handleNodeClick"
|
||||
>
|
||||
<template #default="{ node, data }">
|
||||
<div class="tree-node">
|
||||
<span class="node-label">{{ node.label }}</span>
|
||||
<div class="node-actions" @click.stop>
|
||||
<el-button link type="primary" size="small" @click="handleAdd(data)">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-button>
|
||||
<el-button link type="primary" size="small" @click="handleEdit(data)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(data)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-tree>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 右侧:详情面板 -->
|
||||
<el-col :span="16">
|
||||
<el-card shadow="hover" class="detail-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>{{ detailTitle }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="selectedNode" class="detail-content">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="组织名称">
|
||||
{{ selectedNode.org_name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="组织编码">
|
||||
{{ selectedNode.org_code || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="上级组织">
|
||||
{{ selectedNode.parent_name || '顶级组织' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="负责人">
|
||||
{{ selectedNode.leader_name || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="是否是公司">
|
||||
<el-tag :type="selectedNode.is_company === 1 ? 'primary' : 'info'">
|
||||
{{ selectedNode.is_company === 1 ? '是' : '否' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="selectedNode.status === 1 ? 'success' : 'danger'">
|
||||
{{ selectedNode.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">
|
||||
{{ selectedNode.remark || '-' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="detail-actions">
|
||||
<el-button type="primary" @click="handleEdit(selectedNode)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" @click="handleDelete(selectedNode)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-else description="请选择左侧组织节点查看详情" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 添加/编辑对话框 -->
|
||||
<OrganizationEdit
|
||||
ref="editRef"
|
||||
:tree-data="treeData"
|
||||
:tree-props="treeProps"
|
||||
@success="handleSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import OrganizationEdit from './components/edit.vue'
|
||||
import { getOrganizationList, getOrganizationDetail, deleteOrganization } from '@/api/erp'
|
||||
|
||||
// 树形结构数据(与后台字段对应)
|
||||
interface TreeNode {
|
||||
id: number
|
||||
tid?: number
|
||||
org_name: string
|
||||
org_code?: string
|
||||
parent_id?: number
|
||||
parent_name?: string
|
||||
leader_id?: number
|
||||
leader_name?: string
|
||||
is_company?: number
|
||||
sort?: number
|
||||
status?: number
|
||||
remark?: string
|
||||
children?: TreeNode[]
|
||||
}
|
||||
|
||||
// 树形数据
|
||||
const treeData = ref<TreeNode[]>([])
|
||||
|
||||
const treeProps = {
|
||||
label: 'org_name',
|
||||
children: 'children',
|
||||
value: 'id'
|
||||
}
|
||||
|
||||
const treeRef = ref()
|
||||
const editRef = ref()
|
||||
const selectedNode = ref<TreeNode | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// 将扁平列表构建为树形结构
|
||||
const buildTree = (list: any[]): TreeNode[] => {
|
||||
const nodeMap = new Map<number, TreeNode>()
|
||||
const roots: TreeNode[] = []
|
||||
|
||||
list.forEach(item => {
|
||||
const node: TreeNode = {
|
||||
id: item.id,
|
||||
tid: item.tid,
|
||||
org_name: item.org_name,
|
||||
org_code: item.org_code,
|
||||
parent_id: item.parent_id,
|
||||
parent_name: item.parent_name,
|
||||
leader_id: item.leader_id,
|
||||
leader_name: item.leader_name,
|
||||
is_company: item.is_company,
|
||||
sort: item.sort,
|
||||
status: item.status,
|
||||
remark: item.remark,
|
||||
children: []
|
||||
}
|
||||
nodeMap.set(node.id, node)
|
||||
})
|
||||
|
||||
nodeMap.forEach(node => {
|
||||
if (node.parent_id && nodeMap.has(node.parent_id)) {
|
||||
const parent = nodeMap.get(node.parent_id)!
|
||||
if (!parent.children) {
|
||||
parent.children = []
|
||||
}
|
||||
parent.children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
})
|
||||
|
||||
return roots
|
||||
}
|
||||
|
||||
// 获取组织机构详情
|
||||
const loadOrganizationDetail = async (id: number) => {
|
||||
try {
|
||||
const res = await getOrganizationDetail(id)
|
||||
if (res.code === 200) {
|
||||
const data = res.data || null
|
||||
if (data) {
|
||||
selectedNode.value = {
|
||||
id: data.id,
|
||||
tid: data.tid,
|
||||
org_name: data.org_name,
|
||||
org_code: data.org_code,
|
||||
parent_id: data.parent_id,
|
||||
parent_name: data.parent_name,
|
||||
leader_id: data.leader_id,
|
||||
leader_name: data.leader_name,
|
||||
is_company: data.is_company,
|
||||
sort: data.sort,
|
||||
status: data.status,
|
||||
remark: data.remark,
|
||||
children: []
|
||||
}
|
||||
} else {
|
||||
selectedNode.value = null
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取组织机构详情失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取组织机构详情失败:', error)
|
||||
ElMessage.error('获取组织机构详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取组织机构列表
|
||||
const fetchOrganizationList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getOrganizationList()
|
||||
if (res.code === 200) {
|
||||
const list = res.data || []
|
||||
treeData.value = buildTree(list)
|
||||
|
||||
// 如果当前没有选中的节点且有数据,默认加载第一个节点详情
|
||||
if (!selectedNode.value && list.length) {
|
||||
await loadOrganizationDetail(list[0].id)
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取组织机构列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取组织机构列表失败:', error)
|
||||
ElMessage.error('获取组织机构列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 详情标题
|
||||
const detailTitle = computed(() => {
|
||||
return selectedNode.value ? `部门详情 - ${selectedNode.value.org_name}` : '部门详情'
|
||||
})
|
||||
|
||||
// 点击节点,加载详情
|
||||
const handleNodeClick = (data: TreeNode) => {
|
||||
loadOrganizationDetail(data.id)
|
||||
}
|
||||
|
||||
// 添加根节点
|
||||
const handleAddRoot = () => {
|
||||
editRef.value?.open(undefined, undefined)
|
||||
}
|
||||
|
||||
// 添加子节点
|
||||
const handleAdd = (data: TreeNode) => {
|
||||
editRef.value?.open(undefined, data.id)
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (data: TreeNode) => {
|
||||
editRef.value?.open(data)
|
||||
}
|
||||
|
||||
// 提交成功回调,重新加载列表
|
||||
const handleSuccess = () => {
|
||||
fetchOrganizationList()
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = (data: TreeNode) => {
|
||||
ElMessageBox.confirm(`确定要删除部门「${data.org_name}」吗?`, '提示', {
|
||||
type: 'warning'
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await deleteOrganization(data.id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchOrganizationList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
ElMessage.error('删除失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchOrganizationList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.organization-container {
|
||||
// padding: 16px;
|
||||
}
|
||||
|
||||
.tree-card {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding-right: 8px;
|
||||
|
||||
.node-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.node-actions {
|
||||
display: none;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&:hover .node-actions {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
.el-descriptions {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
// Apps 父路由组件,用于显示子路由
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Apps 父路由容器 */
|
||||
</style>
|
||||
Reference in New Issue
Block a user