first commit

This commit is contained in:
2026-01-26 09:32:17 +08:00
commit 9429a700d0
2928 changed files with 30330 additions and 0 deletions
+332
View File
@@ -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支持 jpgpnggif 格式大小不超过 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>
+301
View File
@@ -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,335 @@
<template>
<el-dialog
:title="isEditing ? '编辑字典项' : '添加字典项'"
v-model="visible"
width="600px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
:model="dictItemForm"
:rules="formRules"
ref="dictItemFormRef"
label-width="100px"
>
<el-form-item label="字典类型" prop="dict_type_id">
<el-input
:value="dictTypeDisplayName"
disabled
style="width: 100%"
/>
<div class="form-tip">当前字典类型{{ dictTypeDisplayName }}</div>
</el-form-item>
<el-form-item label="字典标签" prop="dict_label">
<el-input
v-model="dictItemForm.dict_label"
placeholder="请输入字典标签(显示值)"
/>
</el-form-item>
<el-form-item label="字典值" prop="dict_value">
<el-input
v-model="dictItemForm.dict_value"
placeholder="请输入字典值(存储值)"
/>
</el-form-item>
<el-form-item label="父级字典项" prop="parent_id">
<el-select
v-model="dictItemForm.parent_id"
placeholder="选择父级字典项(可选)"
clearable
filterable
style="width: 100%"
>
<el-option
v-for="item in parentDictItems"
:key="item.id"
:label="item.dict_label"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="dictItemForm.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number
v-model="dictItemForm.sort"
:min="0"
style="width: 100%"
placeholder="数字越小越靠前"
/>
</el-form-item>
<el-form-item label="颜色" prop="color">
<el-input
v-model="dictItemForm.color"
placeholder="请输入颜色值(如:#FF0000 或 red"
>
<template #append>
<el-color-picker
v-model="dictItemForm.color"
:predefine="predefineColors"
/>
</template>
</el-input>
<div class="form-tip">支持十六进制颜色值#FF0000或颜色名称red</div>
</el-form-item>
<el-form-item label="图标" prop="icon">
<el-input
v-model="dictItemForm.icon"
placeholder="请输入图标(如:✓、✗、⭐)"
/>
<div class="form-tip">支持emoji或图标字符</div>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="dictItemForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
保存
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch, computed } from 'vue';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { addDictItem, updateDictItem, getDictItems } from '@/api/dict';
interface Props {
modelValue: boolean;
dictItem?: any;
dictTypeId?: number;
dictTypeName?: string;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dictItem: null,
dictTypeId: undefined,
dictTypeName: '',
});
const emit = defineEmits<{
'update:modelValue': [value: boolean];
success: [];
}>();
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
const submitting = ref(false);
const dictItemFormRef = ref<FormInstance>();
const parentDictItems = ref<any[]>([]);
// 预定义颜色
const predefineColors = [
'#ff4500',
'#ff8c00',
'#ffd700',
'#90ee90',
'#00ced1',
'#1e90ff',
'#c71585',
'#ff0000',
'#00ff00',
'#0000ff',
];
// 判断是否为编辑模式
const isEditing = computed(() => {
return !!(props.dictItem && props.dictItem.id);
});
// 表单数据
const dictItemForm = reactive({
id: null as number | null,
dict_type_id: 0,
dict_label: '',
dict_value: '',
parent_id: 0,
status: 1,
sort: 0,
color: '',
icon: '',
remark: '',
});
// 表单验证规则
const formRules: FormRules = {
dict_label: [
{ required: true, message: '请输入字典标签', trigger: 'blur' },
{ min: 1, max: 100, message: '长度在 1 到 100 个字符', trigger: 'blur' },
],
dict_value: [
{ required: true, message: '请输入字典值', trigger: 'blur' },
{ min: 1, max: 100, message: '长度在 1 到 100 个字符', trigger: 'blur' },
],
sort: [
{ required: true, message: '请输入排序', trigger: 'blur' },
{ type: 'number', min: 0, message: '排序必须大于等于 0', trigger: 'blur' },
],
color: [
{ max: 20, message: '颜色值长度不能超过 20 个字符', trigger: 'blur' },
],
icon: [
{ max: 50, message: '图标长度不能超过 50 个字符', trigger: 'blur' },
],
remark: [
{ max: 500, message: '备注长度不能超过 500 个字符', trigger: 'blur' },
],
};
// 获取父级字典项列表(排除自己)
async function fetchParentDictItems() {
if (!props.dictTypeId) return;
try {
const res = await getDictItems({ dict_type_id: props.dictTypeId });
if (res.success && res.data) {
// 如果是编辑模式,排除当前字典项
if (isEditing.value && props.dictItem) {
parentDictItems.value = res.data.filter(
(item: any) => item.id !== props.dictItem.id
);
} else {
parentDictItems.value = res.data;
}
}
} catch (error) {
console.error('获取父级字典项失败:', error);
}
}
// 计算字典类型显示名称
const dictTypeDisplayName = computed(() => {
if (props.dictTypeName) {
return props.dictTypeName;
}
if (props.dictTypeId) {
return `字典类型 ID: ${props.dictTypeId}`;
}
return '未知字典类型';
});
// 监听 dictItem 变化,填充表单数据
watch(
() => props.dictItem,
(newDictItem) => {
if (newDictItem) {
dictItemForm.id = newDictItem.id || null;
dictItemForm.dict_type_id = newDictItem.dict_type_id || props.dictTypeId || 0;
dictItemForm.dict_label = newDictItem.dict_label || '';
dictItemForm.dict_value = newDictItem.dict_value || '';
dictItemForm.parent_id = newDictItem.parent_id || 0;
dictItemForm.status = newDictItem.status !== undefined ? newDictItem.status : 1;
dictItemForm.sort = newDictItem.sort || 0;
dictItemForm.color = newDictItem.color || '';
dictItemForm.icon = newDictItem.icon || '';
dictItemForm.remark = newDictItem.remark || '';
} else {
resetForm();
}
},
{ immediate: true }
);
// 监听弹窗打开,获取父级字典项
watch(visible, (val) => {
if (val) {
if (props.dictTypeId) {
dictItemForm.dict_type_id = props.dictTypeId;
}
fetchParentDictItems();
}
});
// 重置表单
function resetForm() {
dictItemForm.id = null;
dictItemForm.dict_type_id = props.dictTypeId || 0;
dictItemForm.dict_label = '';
dictItemForm.dict_value = '';
dictItemForm.parent_id = 0;
dictItemForm.status = 1;
dictItemForm.sort = 0;
dictItemForm.color = '';
dictItemForm.icon = '';
dictItemForm.remark = '';
dictItemFormRef.value?.clearValidate();
}
// 关闭对话框
function handleClose() {
visible.value = false;
resetForm();
}
// 提交表单
async function handleSubmit() {
if (!dictItemFormRef.value) return;
try {
await dictItemFormRef.value.validate();
} catch (error) {
return;
}
if (!dictItemForm.dict_type_id) {
ElMessage.error('字典类型ID不能为空');
return;
}
submitting.value = true;
try {
const submitData: any = {
dict_type_id: dictItemForm.dict_type_id,
dict_label: dictItemForm.dict_label,
dict_value: dictItemForm.dict_value,
parent_id: dictItemForm.parent_id || 0,
status: dictItemForm.status,
sort: dictItemForm.sort,
color: dictItemForm.color || '',
icon: dictItemForm.icon || '',
remark: dictItemForm.remark || '',
};
if (isEditing.value) {
await updateDictItem(dictItemForm.id!, submitData);
ElMessage.success('字典项更新成功');
} else {
await addDictItem(submitData);
ElMessage.success('字典项添加成功');
}
emit('success');
handleClose();
} catch (error: any) {
ElMessage.error(error.message || '操作失败,请重试');
} finally {
submitting.value = false;
}
}
</script>
<style scoped lang="less">
.form-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
line-height: 1.4;
}
</style>
@@ -0,0 +1,316 @@
<template>
<el-dialog
:title="isEditing ? '编辑字典项' : '添加字典项'"
v-model="visible"
width="600px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
:model="dictItemForm"
:rules="formRules"
ref="dictItemFormRef"
label-width="100px"
>
<el-form-item label="字典类型" prop="dict_type_id">
<el-select
v-model="dictItemForm.dict_type_id"
placeholder="请选择字典类型"
filterable
style="width: 100%"
:disabled="isEditing || !!dictTypeId"
>
<el-option
v-for="type in dictTypes"
:key="type.id"
:label="`${type.dict_name} (${type.dict_code})`"
:value="type.id"
/>
</el-select>
</el-form-item>
<el-form-item label="字典标签" prop="dict_label">
<el-input
v-model="dictItemForm.dict_label"
placeholder="请输入字典标签(显示值)"
/>
</el-form-item>
<el-form-item label="字典值" prop="dict_value">
<el-input
v-model="dictItemForm.dict_value"
placeholder="请输入字典值(存储值)"
/>
</el-form-item>
<!-- <el-form-item label="父级ID" prop="parent_id">
<el-input-number
v-model="dictItemForm.parent_id"
:min="0"
style="width: 100%"
placeholder="0表示顶级"
/>
<div class="form-tip">0表示顶级字典项</div>
</el-form-item> -->
<el-form-item label="状态" prop="status">
<el-radio-group v-model="dictItemForm.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="排序序号" prop="sort">
<el-input-number
v-model="dictItemForm.sort"
:min="0"
style="width: 100%"
placeholder="数字越小越靠前"
/>
</el-form-item>
<el-form-item label="颜色标记" prop="color">
<el-input
v-model="dictItemForm.color"
placeholder="请输入颜色值(如:#FF0000 或 red"
maxlength="20"
/>
<div class="form-tip">用于前端显示时的颜色标记</div>
</el-form-item>
<el-form-item label="图标" prop="icon">
<el-input
v-model="dictItemForm.icon"
placeholder="请输入图标名称或类名"
maxlength="50"
/>
<div class="form-tip">用于前端显示时的图标</div>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="dictItemForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注信息"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
保存
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch, computed, onMounted } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { getDictTypes, addDictItem, updateDictItem } from '@/api/dict'
interface Props {
modelValue: boolean
dictItem?: any
dictTypeId?: number | null
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dictItem: null,
dictTypeId: null,
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
success: []
}>()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
const submitting = ref(false)
const dictItemFormRef = ref<FormInstance>()
const dictTypes = ref<any[]>([])
// 判断是否为编辑模式
const isEditing = computed(() => {
return !!(props.dictItem && props.dictItem.id)
})
// 表单数据
const dictItemForm = reactive({
id: null as number | null,
dict_type_id: null as number | null,
dict_label: '',
dict_value: '',
parent_id: 0,
status: 1,
sort: 0,
color: '',
icon: '',
remark: '',
})
// 表单验证规则
const formRules: FormRules = {
dict_type_id: [
{ required: true, message: '请选择字典类型', trigger: 'change' },
],
dict_label: [
{ required: true, message: '请输入字典标签', trigger: 'blur' },
{ min: 1, max: 100, message: '长度在 1 到 100 个字符', trigger: 'blur' },
],
dict_value: [
{ required: true, message: '请输入字典值', trigger: 'blur' },
{ min: 1, max: 100, message: '长度在 1 到 100 个字符', trigger: 'blur' },
],
parent_id: [
{ type: 'number', min: 0, message: '父级ID必须大于等于 0', trigger: 'blur' },
],
sort: [
{ type: 'number', min: 0, message: '排序序号必须大于等于 0', trigger: 'blur' },
],
color: [
{ max: 20, message: '颜色值长度不能超过 20 个字符', trigger: 'blur' },
],
icon: [
{ max: 50, message: '图标长度不能超过 50 个字符', trigger: 'blur' },
],
remark: [
{ max: 500, message: '备注长度不能超过 500 个字符', trigger: 'blur' },
],
}
// 获取字典类型列表
async function fetchDictTypes() {
try {
const res = await getDictTypes()
if (res.success === true && res.data) {
dictTypes.value = Array.isArray(res.data) ? res.data : []
} else if (Array.isArray(res)) {
dictTypes.value = res
} else if (res && res.data) {
dictTypes.value = Array.isArray(res.data) ? res.data : []
}
} catch (err: any) {
console.error('获取字典类型列表失败:', err)
}
}
// 监听 dictItem 变化,填充表单数据
watch(
() => props.dictItem,
(newDictItem) => {
if (newDictItem) {
dictItemForm.id = newDictItem.id || null
dictItemForm.dict_type_id = newDictItem.dict_type_id || null
dictItemForm.dict_label = newDictItem.dict_label || ''
dictItemForm.dict_value = newDictItem.dict_value || ''
dictItemForm.parent_id = newDictItem.parent_id || 0
dictItemForm.status = newDictItem.status !== undefined ? newDictItem.status : 1
dictItemForm.sort = newDictItem.sort || 0
dictItemForm.color = newDictItem.color || ''
dictItemForm.icon = newDictItem.icon || ''
dictItemForm.remark = newDictItem.remark || ''
} else {
resetForm()
}
},
{ immediate: true }
)
// 监听 dictTypeId 变化
watch(
() => props.dictTypeId,
(newTypeId) => {
if (newTypeId && !isEditing.value) {
dictItemForm.dict_type_id = newTypeId
}
},
{ immediate: true }
)
// 重置表单
function resetForm() {
dictItemForm.id = null
dictItemForm.dict_type_id = props.dictTypeId || null
dictItemForm.dict_label = ''
dictItemForm.dict_value = ''
dictItemForm.parent_id = 0
dictItemForm.status = 1
dictItemForm.sort = 0
dictItemForm.color = ''
dictItemForm.icon = ''
dictItemForm.remark = ''
dictItemFormRef.value?.clearValidate()
}
// 关闭对话框
function handleClose() {
visible.value = false
resetForm()
}
// 提交表单
async function handleSubmit() {
if (!dictItemFormRef.value) return
try {
await dictItemFormRef.value.validate()
} catch (error) {
return
}
submitting.value = true
try {
const submitData: any = {
dict_type_id: dictItemForm.dict_type_id,
dict_label: dictItemForm.dict_label,
dict_value: dictItemForm.dict_value,
parent_id: dictItemForm.parent_id,
status: dictItemForm.status,
sort: dictItemForm.sort,
color: dictItemForm.color || '',
icon: dictItemForm.icon || '',
remark: dictItemForm.remark || '',
}
if (isEditing.value) {
const res = await updateDictItem(dictItemForm.id!, submitData)
if (res.success === true || res.code === 0) {
ElMessage.success('字典项更新成功')
emit('success')
handleClose()
} else {
ElMessage.error(res.message || '更新失败,请重试')
}
} else {
const res = await addDictItem(submitData)
if (res.success === true || res.code === 0) {
ElMessage.success('字典项添加成功')
emit('success')
handleClose()
} else {
ElMessage.error(res.message || '添加失败,请重试')
}
}
} catch (error: any) {
ElMessage.error(error.message || '操作失败,请重试')
} finally {
submitting.value = false
}
}
onMounted(() => {
fetchDictTypes()
})
</script>
<style scoped lang="less">
.form-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
line-height: 1.4;
}
</style>
@@ -0,0 +1,422 @@
<template>
<div class="dict-item-list">
<div class="toolbar">
<div class="toolbar-left">
<el-select
v-model="selectedDictTypeId"
placeholder="选择字典类型"
clearable
filterable
style="width: 250px"
@change="handleTypeChange"
>
<el-option
v-for="type in dictTypes"
:key="type.id"
:label="`${type.dict_name} (${type.dict_code})`"
:value="type.id"
/>
</el-select>
<el-input
v-model="searchKeyword"
placeholder="搜索字典项标签或值"
clearable
style="width: 300px"
@input="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select
v-model="statusFilter"
placeholder="状态筛选"
clearable
style="width: 120px"
@change="handleFilter"
>
<el-option label="全部" value="" />
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</div>
<div class="toolbar-right">
<el-button
type="primary"
:disabled="!selectedDictTypeId"
@click="handleAdd"
>
<el-icon><Plus /></el-icon>
添加字典项
</el-button>
</div>
</div>
<!-- 提示信息 -->
<el-alert
v-if="!selectedDictTypeId"
title="请先选择字典类型"
type="info"
:closable="false"
style="margin-bottom: 16px"
/>
<!-- 错误状态 -->
<div v-if="error" class="error-state">
<el-alert title="加载失败" :message="error" type="error" show-icon />
<el-button type="primary" @click="fetchDictItems">重试</el-button>
</div>
<!-- 字典项列表 -->
<div v-else-if="selectedDictTypeId">
<el-table
:data="filteredDictItems"
stripe
style="width: 100%"
v-loading="loading"
>
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="dict_label" label="字典标签" min-width="150" align="center" />
<el-table-column prop="dict_value" label="字典值" min-width="150" align="center">
<template #default="{ row }">
<el-tag size="small">{{ row.dict_value }}</el-tag>
</template>
</el-table-column>
<!-- <el-table-column prop="parent_id" label="父级ID" width="100" align="center">
<template #default="{ row }">
<span>{{ row.parent_id || '-' }}</span>
</template>
</el-table-column> -->
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column prop="color" label="颜色" width="100" align="center">
<template #default="{ row }">
<div v-if="row.color" class="color-display">
<span
class="color-dot"
:style="{ backgroundColor: row.color }"
></span>
<span>{{ row.color }}</span>
</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="icon" label="图标" width="100" align="center">
<template #default="{ row }">
<span v-if="row.icon">{{ row.icon }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="200" align="center" show-overflow-tooltip />
<el-table-column label="创建时间" width="180" align="center">
<template #default="{ row }">
{{ formatDate(row.create_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="handleEdit(row)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button size="small" type="danger" link @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 编辑对话框 -->
<DictItemEditDialog
v-model="editDialogVisible"
:dict-item="currentDictItem"
:dict-type-id="selectedDictTypeId"
@success="handleEditSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import {
ElMessage,
ElMessageBox,
} from 'element-plus'
import { Plus, Search, Edit, Delete } from '@element-plus/icons-vue'
import {
getDictTypes,
getDictItems,
deleteDictItem,
} from '@/api/dict'
import DictItemEditDialog from './DictItemEditDialog.vue'
interface Props {
selectedTypeId?: number | null
}
const props = withDefaults(defineProps<Props>(), {
selectedTypeId: null,
})
const emit = defineEmits<{
'refresh': []
}>()
const dictTypes = ref<any[]>([])
const dictItems = ref<any[]>([])
const loading = ref(false)
const error = ref('')
const searchKeyword = ref('')
const statusFilter = ref<number | ''>('')
const selectedDictTypeId = ref<number | null>(null)
const editDialogVisible = ref(false)
const currentDictItem = ref<any>(null)
// 过滤后的字典项
const filteredDictItems = computed(() => {
let result = dictItems.value
// 搜索过滤
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
result = result.filter(
(item) =>
(item.dict_label && item.dict_label.toLowerCase().includes(keyword)) ||
(item.dict_value && item.dict_value.toLowerCase().includes(keyword)) ||
(item.remark && item.remark.toLowerCase().includes(keyword))
)
}
// 状态过滤
if (statusFilter.value !== '') {
result = result.filter((item) => item.status === statusFilter.value)
}
return result
})
// 监听外部传入的selectedTypeId
watch(
() => props.selectedTypeId,
(newId) => {
if (newId) {
selectedDictTypeId.value = newId
fetchDictItems()
}
},
{ immediate: true }
)
// 格式化日期
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return ''
try {
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return dateStr as string
}
}
// 获取字典类型列表(用于下拉选择)
async function fetchDictTypes() {
try {
const res = await getDictTypes()
if (res.success === true && res.data) {
dictTypes.value = Array.isArray(res.data) ? res.data : []
} else if (Array.isArray(res)) {
dictTypes.value = res
} else if (res && res.data) {
dictTypes.value = Array.isArray(res.data) ? res.data : []
}
} catch (err: any) {
console.error('获取字典类型列表失败:', err)
}
}
// 获取字典项列表
async function fetchDictItems() {
if (!selectedDictTypeId.value) {
dictItems.value = []
return
}
loading.value = true
error.value = ''
try {
const res = await getDictItems({ dict_type_id: selectedDictTypeId.value })
if (res.success === true && res.data) {
dictItems.value = Array.isArray(res.data) ? res.data : []
} else if (Array.isArray(res)) {
dictItems.value = res
} else if (res && res.data) {
dictItems.value = Array.isArray(res.data) ? res.data : []
} else {
error.value = res.message || '获取字典项列表失败'
dictItems.value = []
}
} catch (err: any) {
error.value = err.message || '获取字典项列表失败'
dictItems.value = []
} finally {
loading.value = false
}
}
// 字典类型改变
const handleTypeChange = () => {
fetchDictItems()
}
// 搜索
const handleSearch = () => {
// 搜索逻辑在computed中处理
}
// 筛选
const handleFilter = () => {
// 筛选逻辑在computed中处理
}
// 设置字典类型ID(供外部调用)
function setTypeId(typeId: number) {
selectedDictTypeId.value = typeId
}
// 添加字典项
function handleAdd() {
if (!selectedDictTypeId.value) {
ElMessage.warning('请先选择字典类型')
return
}
currentDictItem.value = null
editDialogVisible.value = true
}
// 编辑字典项
function handleEdit(row: any) {
currentDictItem.value = { ...row }
editDialogVisible.value = true
}
// 删除字典项
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(
`确定要删除字典项「${row.dict_label}」吗?删除后不可恢复。`,
'警告',
{ type: 'warning' }
)
loading.value = true
try {
const res = await deleteDictItem(row.id)
if (res.success === true || res.code === 0) {
ElMessage.success('删除成功')
fetchDictItems()
emit('refresh')
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (err: any) {
ElMessage.error(err.message || '删除失败')
} finally {
loading.value = false
}
} catch {
// 取消删除
}
}
// 编辑成功回调
function handleEditSuccess() {
fetchDictItems()
emit('refresh')
}
// 刷新
function refresh() {
fetchDictTypes()
if (selectedDictTypeId.value) {
fetchDictItems()
}
}
defineExpose({
refresh,
setTypeId,
})
onMounted(() => {
fetchDictTypes()
if (selectedDictTypeId.value) {
fetchDictItems()
}
})
</script>
<style scoped lang="less">
.dict-item-list {
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
gap: 12px;
.toolbar-left {
display: flex;
gap: 12px;
align-items: center;
}
.toolbar-right {
display: flex;
gap: 12px;
}
}
.color-display {
display: flex;
align-items: center;
gap: 6px;
justify-content: center;
.color-dot {
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid #ddd;
}
}
}
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 220px;
padding: 32px 0 16px 0;
background: var(--el-bg-color-page);
border-radius: 5px;
gap: 16px;
}
</style>
@@ -0,0 +1,241 @@
<template>
<el-dialog
:title="isEditing ? '编辑字典类型' : '添加字典类型'"
v-model="visible"
width="600px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
:model="dictTypeForm"
:rules="formRules"
ref="dictTypeFormRef"
label-width="100px"
>
<el-form-item label="字典编码" prop="dict_code">
<el-input
v-model="dictTypeForm.dict_code"
placeholder="请输入字典编码(如:user_status"
:disabled="isEditing"
/>
<div class="form-tip">只能包含字母数字或下划线且必须唯一</div>
</el-form-item>
<el-form-item label="字典名称" prop="dict_name">
<el-input
v-model="dictTypeForm.dict_name"
placeholder="请输入字典名称"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="dictTypeForm.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="shouldShowGlobalOption" label="全局展示" prop="is_global">
<el-radio-group v-model="dictTypeForm.is_global">
<el-radio :value="1"></el-radio>
<el-radio :value="0"></el-radio>
</el-radio-group>
<div class="form-tip">所有租户可见仅租户内部可见</div>
</el-form-item>
<el-form-item label="排序序号" prop="sort">
<el-input-number
v-model="dictTypeForm.sort"
:min="0"
style="width: 100%"
placeholder="数字越小越靠前"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="dictTypeForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注信息"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
保存
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch, computed, onMounted } from 'vue';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { addDictType, updateDictType, getDictTypes } from '@/api/dict';
import { useAuthStore } from '@/stores/auth';
interface Props {
modelValue: boolean;
dictType?: any;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dictType: null,
});
const emit = defineEmits<{
'update:modelValue': [value: boolean];
success: [];
}>();
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
const submitting = ref(false);
const dictTypeFormRef = ref<FormInstance>();
const authStore = useAuthStore();
// 已移除父级选择
// 判断是否为编辑模式
const isEditing = computed(() => {
return !!(props.dictType && props.dictType.id);
});
// 判断是否应该显示全局展示选项(只有 system_admin 和 admin 角色才能看到)
const shouldShowGlobalOption = computed(() => {
const roleCode = authStore.user.role_code;
return roleCode === 'system_admin' || roleCode === 'admin';
});
// 表单数据
const dictTypeForm = reactive({
id: null as number | null,
dict_code: '',
dict_name: '',
status: 1,
is_global: 0,
sort: 0,
remark: '',
});
// 表单验证规则
const formRules: FormRules = {
dict_code: [
{ required: true, message: '请输入字典编码', trigger: 'blur' },
{
pattern: /^[a-zA-Z0-9_]+$/,
message: '只能包含字母、数字或下划线',
trigger: 'blur',
},
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' },
],
dict_name: [
{ required: true, message: '请输入字典名称', trigger: 'blur' },
{ min: 2, max: 100, message: '长度在 2 到 100 个字符', trigger: 'blur' },
],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
sort: [
{ required: true, message: '请输入排序序号', trigger: 'blur' },
{ type: 'number', min: 0, message: '排序序号必须大于等于 0', trigger: 'blur' },
],
remark: [
{ max: 500, message: '备注长度不能超过 500 个字符', trigger: 'blur' },
],
};
// 已移除父级选择相关数据获取
// 监听 dictType 变化,填充表单数据
watch(
() => props.dictType,
(newDictType) => {
if (newDictType) {
dictTypeForm.id = newDictType.id || null;
dictTypeForm.dict_code = newDictType.dict_code || '';
dictTypeForm.dict_name = newDictType.dict_name || '';
dictTypeForm.status = newDictType.status !== undefined ? newDictType.status : 1;
dictTypeForm.is_global = newDictType.is_global !== undefined ? newDictType.is_global : 0;
dictTypeForm.sort = newDictType.sort || 0;
dictTypeForm.remark = newDictType.remark || '';
} else {
resetForm();
}
},
{ immediate: true }
);
// 已移除父级选择监听
// 重置表单
function resetForm() {
dictTypeForm.id = null;
dictTypeForm.dict_code = '';
dictTypeForm.dict_name = '';
dictTypeForm.status = 1;
dictTypeForm.is_global = 0;
dictTypeForm.sort = 0;
dictTypeForm.remark = '';
dictTypeFormRef.value?.clearValidate();
}
// 关闭对话框
function handleClose() {
visible.value = false;
resetForm();
}
// 提交表单
async function handleSubmit() {
if (!dictTypeFormRef.value) return;
try {
await dictTypeFormRef.value.validate();
} catch (error) {
return;
}
submitting.value = true;
try {
const submitData: any = {
dict_code: dictTypeForm.dict_code,
dict_name: dictTypeForm.dict_name,
status: dictTypeForm.status,
is_global: dictTypeForm.is_global,
sort: dictTypeForm.sort,
remark: dictTypeForm.remark || '',
};
if (isEditing.value) {
await updateDictType(dictTypeForm.id!, submitData);
ElMessage.success('字典类型更新成功');
} else {
await addDictType(submitData);
ElMessage.success('字典类型添加成功');
}
emit('success');
handleClose();
} catch (error: any) {
ElMessage.error(error.message || '操作失败,请重试');
} finally {
submitting.value = false;
}
}
onMounted(() => {
// 无需加载父级数据
});
</script>
<style scoped lang="less">
.form-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
line-height: 1.4;
}
</style>
@@ -0,0 +1,316 @@
<template>
<div class="dict-type-list">
<div class="toolbar">
<div class="toolbar-left">
<el-input
v-model="searchKeyword"
placeholder="搜索字典类型名称或编码"
clearable
style="width: 300px"
@input="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select
v-model="statusFilter"
placeholder="状态筛选"
clearable
style="width: 120px"
@change="handleFilter"
>
<el-option label="全部" value="" />
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</div>
<div class="toolbar-right">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
添加字典类型
</el-button>
</div>
</div>
<!-- 错误状态 -->
<div v-if="error" class="error-state">
<el-alert title="加载失败" :message="error" type="error" show-icon />
<el-button type="primary" @click="fetchDictTypes">重试</el-button>
</div>
<!-- 字典类型列表 -->
<div v-else>
<el-table
:data="filteredDictTypes"
stripe
style="width: 100%"
v-loading="loading"
@row-click="handleRowClick"
>
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="dict_name" label="字典名称" min-width="150" align="center" />
<el-table-column prop="dict_code" label="字典编码" min-width="150" align="center">
<template #default="{ row }">
<el-tag size="small">{{ row.dict_code }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="tenant_id" label="租户ID" width="100" align="center">
<template #default="{ row }">
<span>{{ row.tenant_id || 0 }}</span>
</template>
</el-table-column>
<!-- <el-table-column prop="parent_id" label="父级ID" width="100" align="center">
<template #default="{ row }">
<span>{{ row.parent_id || '-' }}</span>
</template>
</el-table-column> -->
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column prop="remark" label="备注" min-width="200" align="center" show-overflow-tooltip />
<el-table-column label="创建时间" width="180" align="center">
<template #default="{ row }">
{{ formatDate(row.create_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" link @click.stop="handleViewItems(row)">
<el-icon><Menu /></el-icon>
查看字典项
</el-button>
<el-button size="small" type="primary" link @click.stop="handleEdit(row)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button size="small" type="danger" link @click.stop="handleDelete(row)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 编辑对话框 -->
<DictTypeEdit
v-model="editDialogVisible"
:dict-type="currentDictType"
@success="handleEditSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import {
ElMessage,
ElMessageBox,
} from 'element-plus'
import { Plus, Search, Edit, Delete, Menu } from '@element-plus/icons-vue'
import {
getDictTypes,
deleteDictType,
} from '@/api/dict'
import DictTypeEdit from './DictTypeEdit.vue'
const emit = defineEmits<{
'select-type': [typeId: number]
'refresh': []
}>()
const dictTypes = ref<any[]>([])
const loading = ref(false)
const error = ref('')
const searchKeyword = ref('')
const statusFilter = ref<number | ''>('')
const editDialogVisible = ref(false)
const currentDictType = ref<any>(null)
// 过滤后的字典类型
const filteredDictTypes = computed(() => {
let result = dictTypes.value
// 搜索过滤
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
result = result.filter(
(type) =>
(type.dict_name && type.dict_name.toLowerCase().includes(keyword)) ||
(type.dict_code && type.dict_code.toLowerCase().includes(keyword))
)
}
// 状态过滤
if (statusFilter.value !== '') {
result = result.filter((type) => type.status === statusFilter.value)
}
return result
})
// 格式化日期
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return ''
try {
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return dateStr as string
}
}
// 获取字典类型列表
async function fetchDictTypes() {
loading.value = true
error.value = ''
try {
const res = await getDictTypes()
if (res.success === true && res.data) {
dictTypes.value = Array.isArray(res.data) ? res.data : []
} else if (Array.isArray(res)) {
dictTypes.value = res
} else if (res && res.data) {
dictTypes.value = Array.isArray(res.data) ? res.data : []
} else {
error.value = res.message || '获取字典类型列表失败'
dictTypes.value = []
}
} catch (err: any) {
error.value = err.message || '获取字典类型列表失败'
dictTypes.value = []
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
// 搜索逻辑在computed中处理
}
// 筛选
const handleFilter = () => {
// 筛选逻辑在computed中处理
}
// 添加字典类型
function handleAdd() {
currentDictType.value = null
editDialogVisible.value = true
}
// 编辑字典类型
function handleEdit(row: any) {
currentDictType.value = { ...row }
editDialogVisible.value = true
}
// 查看字典项
function handleViewItems(row: any) {
emit('select-type', row.id)
}
// 行点击
function handleRowClick(row: any) {
// 可以在这里处理行点击事件
}
// 删除字典类型
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(
`确定要删除字典类型「${row.dict_name}」吗?删除后不可恢复。`,
'警告',
{ type: 'warning' }
)
loading.value = true
try {
const res = await deleteDictType(row.id)
if (res.success === true || res.code === 0) {
ElMessage.success('删除成功')
fetchDictTypes()
emit('refresh')
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (err: any) {
ElMessage.error(err.message || '删除失败')
} finally {
loading.value = false
}
} catch {
// 取消删除
}
}
// 编辑成功回调
function handleEditSuccess() {
fetchDictTypes()
emit('refresh')
}
// 刷新
function refresh() {
fetchDictTypes()
}
defineExpose({
refresh,
})
onMounted(() => {
fetchDictTypes()
})
</script>
<style scoped lang="less">
.dict-type-list {
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
gap: 12px;
.toolbar-left {
display: flex;
gap: 12px;
align-items: center;
}
.toolbar-right {
display: flex;
gap: 12px;
}
}
}
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 220px;
padding: 32px 0 16px 0;
background: var(--el-bg-color-page);
border-radius: 5px;
gap: 16px;
}
</style>
+368
View File
@@ -0,0 +1,368 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>字典管理</h2>
<div class="header-actions">
<el-button @click="refresh">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 内容区域根据当前视图切换 -->
<div v-if="currentView === 'types'">
<div class="tab-content">
<div class="toolbar">
<el-button type="primary" @click="handleAddType">
<el-icon><Plus /></el-icon>
添加字典类型
</el-button>
<div class="search-box">
<el-input
v-model="typeSearchKeyword"
placeholder="搜索字典类型名称或编码"
clearable
style="width: 300px"
@input="handleTypeSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
</div>
<el-table
:data="dictTypes"
stripe
style="width: 100%"
v-loading="typeLoading"
>
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="dict_name" label="字典名称" min-width="150" align="center" />
<el-table-column prop="dict_code" label="字典编码" min-width="150" align="center">
<template #default="{ row }">
<el-tag size="small">{{ row.dict_code }}</el-tag>
</template>
</el-table-column>
<!-- <el-table-column prop="parent_id" label="父级ID" width="100" align="center">
<template #default="{ row }">
<span v-if="row.parent_id > 0">{{ row.parent_id }}</span>
<span v-else style="color: #999">-</span>
</template>
</el-table-column> -->
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="is_global" label="全局展示" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.is_global === 1 ? 'success' : 'warning'" size="small">
{{ row.is_global === 1 ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<!-- <el-table-column prop="sort" label="排序" width="80" align="center" /> -->
<!-- <el-table-column prop="remark" label="备注" min-width="200" show-overflow-tooltip /> -->
<el-table-column prop="create_time" label="创建时间" width="180" align="center">
<template #default="{ row }">
{{ formatDate(row.create_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="handleEditType(row)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button size="small" type="primary" link @click="handleViewItems(row)">
<el-icon><List /></el-icon>
查看字典项
</el-button>
<el-button size="small" type="danger" link @click="handleDeleteType(row)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
:current-page="currentPage"
:page-size="pageSize"
:total="totalCount"
@current-change="handlePageChange"
layout="total, prev, pager, next"
/>
</div>
</div>
</div>
<div v-else-if="currentView === 'items'">
<div class="tab-content">
<div class="toolbar">
<div>
<el-button @click="goBackToTypes">
返回
</el-button>
</div>
<div class="search-box">
<span v-if="selectedDictType">
当前字典类型{{ selectedDictType.dict_name }}{{ selectedDictType.dict_code }}
</span>
</div>
</div>
<DictItemList :selected-type-id="selectedDictTypeId || undefined" @refresh="refreshTypesSilently" />
</div>
</div>
<!-- 字典类型编辑对话框 -->
<DictTypeEdit
v-model="typeDialogVisible"
:dict-type="currentDictType"
@success="handleTypeSuccess"
/>
<!-- 字典项独立页面内操作通过 DictItemList 完成 -->
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
Edit,
Delete,
Refresh,
Search,
List,
} from '@element-plus/icons-vue'
import {
getDictTypes,
deleteDictType,
} from '@/api/dict'
import DictTypeEdit from './components/DictTypeEdit.vue'
import DictItemList from './components/DictItemList.vue'
const currentView = ref<'types' | 'items'>('types')
const typeLoading = ref(false)
// 字典类型相关
const dictTypes = ref<any[]>([])
const typeSearchKeyword = ref('')
const typeDialogVisible = ref(false)
const currentDictType = ref<any>(null)
// 分页相关
const currentPage = ref(1)
const pageSize = ref(10)
const totalCount = ref(0)
const selectedDictTypeId = ref<number | null>(null)
const selectedDictType = computed(() => {
if (!selectedDictTypeId.value) {
return null
}
return dictTypes.value.find((type) => type.id === selectedDictTypeId.value) || null
})
// 格式化日期
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return ''
try {
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return dateStr as string
}
}
// 获取字典类型列表
async function fetchDictTypes() {
typeLoading.value = true
try {
const res = await getDictTypes({
page: currentPage.value,
pageSize: pageSize.value,
keyword: typeSearchKeyword.value
})
if (res.success === true && res.data) {
dictTypes.value = Array.isArray(res.data.list) ? res.data.list : (Array.isArray(res.data) ? res.data : [])
totalCount.value = res.data.total || res.data.length || 0
} else if (Array.isArray(res)) {
dictTypes.value = res
totalCount.value = res.length
} else {
dictTypes.value = []
totalCount.value = 0
ElMessage.error(res.message || '获取字典类型失败')
}
} catch (error: any) {
ElMessage.error(error.message || '获取字典类型失败')
dictTypes.value = []
totalCount.value = 0
} finally {
typeLoading.value = false
}
}
// 字典类型搜索
function handleTypeSearch() {
currentPage.value = 1
fetchDictTypes()
}
// 分页切换
function handlePageChange(page: number) {
currentPage.value = page
fetchDictTypes()
}
// 添加字典类型
function handleAddType() {
currentDictType.value = null
typeDialogVisible.value = true
}
// 编辑字典类型
function handleEditType(row: any) {
currentDictType.value = { ...row }
typeDialogVisible.value = true
}
// 查看字典项
function handleViewItems(row: any) {
selectedDictTypeId.value = row.id
currentView.value = 'items'
}
// 删除字典类型
async function handleDeleteType(row: any) {
try {
await ElMessageBox.confirm(
`确定要删除字典类型「${row.dict_name}」吗?删除后不可恢复。`,
'警告',
{ type: 'warning' }
)
typeLoading.value = true
try {
const res = await deleteDictType(row.id)
if (res.success === true) {
ElMessage.success('删除成功')
fetchDictTypes()
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (error: any) {
ElMessage.error(error.message || '删除失败')
} finally {
typeLoading.value = false
}
} catch {
// 取消删除
}
}
// 字典类型操作成功回调
function handleTypeSuccess() {
fetchDictTypes()
}
// 从字典项页返回
function goBackToTypes() {
currentView.value = 'types'
}
// 子页面刷新后,静默刷新类型列表
function refreshTypesSilently() {
fetchDictTypes()
}
// 刷新
async function refresh() {
await fetchDictTypes()
ElMessage.success('刷新成功')
}
onMounted(() => {
fetchDictTypes()
})
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 10px;
}
}
.tab-content {
padding: 20px 0;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.search-box {
display: flex;
align-items: center;
}
.filter-box {
display: flex;
align-items: center;
}
}
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.color-display {
display: flex;
align-items: center;
gap: 8px;
.color-dot {
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid #e5e5e5;
display: inline-block;
}
}
</style>
@@ -0,0 +1,155 @@
<template>
<el-dialog
v-model="visible"
title="创建文件分组"
width="400px"
@close="handleCancel"
>
<el-form
:model="form"
:rules="rules"
ref="formRef"
label-width="100px"
>
<el-form-item label="分组名称" prop="name">
<el-input
v-model="form.name"
placeholder="请输入文件分组名称"
maxlength="50"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="loading">
确定
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { createFileCate } from "@/api/file";
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:modelValue", "success", "close"]);
const visible = ref(false);
const formRef = ref<any>(null);
const loading = ref(false);
const form = ref({
name: "",
});
// 表单验证规则
const rules = {
name: [
{ required: true, message: "请输入文件分组名称", trigger: "blur" },
{ min: 1, max: 50, message: "名称长度在 1 到 50 个字符", trigger: "blur" },
],
};
// 监听 modelValue 变化
watch(
() => props.modelValue,
(newVal) => {
visible.value = newVal;
if (newVal) {
// 打开对话框时,清空表单数据
form.value.name = "";
// 清除表单验证
if (formRef.value) {
formRef.value.clearValidate();
}
}
}
);
// 监听 visible 变化
watch(visible, (newVal) => {
if (!newVal) {
emit("update:modelValue", false);
}
});
// 取消
const handleCancel = () => {
visible.value = false;
form.value.name = "";
if (formRef.value) {
formRef.value.clearValidate();
}
emit("close");
};
// 提交
const handleSubmit = async () => {
if (!formRef.value) {
return;
}
// 表单验证
try {
await formRef.value.validate();
} catch (error) {
ElMessage.warning("请检查表单填写是否正确");
return;
}
// 验证名称是否为空
if (!form.value.name || form.value.name.trim() === "") {
ElMessage.error("文件分组名称不能为空");
return;
}
loading.value = true;
try {
const res = await createFileCate({
name: form.value.name.trim(),
});
if (res.code === 200) {
ElMessage.success("创建成功");
visible.value = false;
form.value.name = "";
emit("success");
emit("close");
} else {
ElMessage.error(res.msg || "创建失败");
}
} catch (error: any) {
const errorMsg =
error?.response?.data?.message || error?.message || "创建失败";
ElMessage.error(errorMsg);
} finally {
loading.value = false;
}
};
// 暴露方法给父组件
defineExpose({
open: () => {
form.value.name = "";
visible.value = true;
if (formRef.value) {
formRef.value.clearValidate();
}
},
});
</script>
<style scoped>
.create-category-dialog {
width: 400px;
}
</style>
@@ -0,0 +1,124 @@
<template>
<el-dialog
:model-value="visible"
title="移动文件"
width="400px"
@close="handleClose"
@update:model-value="handleClose"
>
<el-form>
<el-form-item label="目标分组">
<el-select
v-model="targetCate"
placeholder="请选择分组"
style="width: 100%"
@change="handleCateChange"
>
<el-option
v-for="cate in filteredCateList"
:key="cate.id"
:label="cate.name"
:value="cate.id"
:disabled="cate.id === props.currentCateId"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleMove">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { moveFile } from '@/api/file'
interface Cate {
id: number
name: string
}
// Props
const props = defineProps<{
visible: boolean
fileId: number | null
currentCateId?: number | null
cateList: Cate[]
}>()
// Emits
const emit = defineEmits(['update:visible', 'moved'])
// Data
const targetCate = ref<number | null>(null)
const loading = ref(false)
// 未分类分组
const uncategorizedOption: Cate = {
id: 0,
name: '未分类'
}
// 计算属性:添加未分类选项,并标记当前分组为禁用
const filteredCateList = computed(() => {
const list = [uncategorizedOption, ...(props.cateList || [])]
return list
})
// 重置
watch(
() => props.visible,
(val) => {
if (val) {
targetCate.value = null
}
}
)
// 关闭对话框
function handleClose() {
emit('update:visible', false)
targetCate.value = null
}
// 选择分组
function handleCateChange(value: number) {
targetCate.value = value
}
// 移动文件
async function handleMove() {
// 校验文件ID和目标分组ID的有效性
if (!props.fileId) {
ElMessage.error('请选择要移动的文件')
return
}
// 修复:0 是有效的分组ID(未分类),需要检查 null 或 undefined
if (targetCate.value === null || targetCate.value === undefined) {
ElMessage.error('请选择目标分组')
return
}
loading.value = true
try {
const res = await moveFile(props.fileId, targetCate.value)
if (res && res.code === 200) {
ElMessage.success('文件移动成功')
handleClose() // 先关闭对话框
emit('moved') // 然后触发移动成功事件,刷新列表
} else {
ElMessage.error(res?.msg || '文件移动失败')
}
} catch (error: any) {
console.error('移动文件失败:', error)
ElMessage.error(error?.message || '文件移动失败,请稍后重试')
} finally {
loading.value = false
}
}
</script>
@@ -0,0 +1,175 @@
<template>
<el-dialog
v-model="visible"
title="重命名文件分组"
width="400px"
@close="handleCancel"
>
<el-form
:model="form"
:rules="rules"
ref="formRef"
label-width="100px"
>
<el-form-item label="分组名称" prop="name">
<el-input
v-model="form.name"
placeholder="请输入文件分组名称"
maxlength="50"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="loading">
确定
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { renameFileCate } from "@/api/file";
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
categoryId: {
type: Number,
default: null,
},
categoryName: {
type: String,
default: "",
},
});
const emit = defineEmits(["update:modelValue", "success", "close"]);
const visible = ref(false);
const formRef = ref<any>(null);
const loading = ref(false);
const form = ref({
name: "",
});
// 表单验证规则
const rules = {
name: [
{ required: true, message: "请输入文件分组名称", trigger: "blur" },
{ min: 1, max: 50, message: "名称长度在 1 到 50 个字符", trigger: "blur" },
],
};
// 监听 modelValue 变化
watch(
() => props.modelValue,
(newVal) => {
visible.value = newVal;
if (newVal) {
// 打开对话框时,初始化表单数据
form.value.name = props.categoryName || "";
// 清除表单验证
if (formRef.value) {
formRef.value.clearValidate();
}
}
}
);
// 监听 visible 变化
watch(visible, (newVal) => {
if (!newVal) {
emit("update:modelValue", false);
}
});
// 取消
const handleCancel = () => {
visible.value = false;
form.value.name = "";
if (formRef.value) {
formRef.value.clearValidate();
}
emit("close");
};
// 提交
const handleSubmit = async () => {
if (!formRef.value) {
return;
}
// 表单验证
try {
await formRef.value.validate();
} catch (error) {
ElMessage.warning("请检查表单填写是否正确");
return;
}
// 验证名称是否为空
if (!form.value.name || form.value.name.trim() === "") {
ElMessage.error("文件分组名称不能为空");
return;
}
// 验证名称是否与原来相同
if (form.value.name.trim() === props.categoryName) {
ElMessage.warning("名称未发生变化");
return;
}
// 验证 categoryId
if (!props.categoryId) {
ElMessage.error("分组ID不能为空");
return;
}
loading.value = true;
try {
const res = await renameFileCate(props.categoryId, {
name: form.value.name.trim(),
});
if (res.code === 200) {
ElMessage.success("重命名成功");
visible.value = false;
form.value.name = "";
emit("success");
emit("close");
} else {
ElMessage.error(res.msg || "重命名失败");
}
} catch (error: any) {
const errorMsg =
error?.response?.data?.message || error?.message || "重命名失败";
ElMessage.error(errorMsg);
} finally {
loading.value = false;
}
};
// 暴露方法给父组件
defineExpose({
open: (categoryId: number, categoryName: string) => {
form.value.name = categoryName || "";
visible.value = true;
if (formRef.value) {
formRef.value.clearValidate();
}
},
});
</script>
<style scoped>
.rename-category-dialog {
width: 400px;
}
</style>
@@ -0,0 +1,313 @@
<template>
<el-dialog
v-model="visible"
title="上传文件"
width="600px"
@close="handleClose"
>
<el-upload
ref="uploadRef"
:action="uploadAction"
:headers="uploadHeaders"
:data="uploadData"
:file-list="fileList"
:on-change="handleChange"
:on-success="handleSuccess"
:on-error="handleError"
:on-progress="handleProgress"
:on-remove="handleRemove"
:before-upload="beforeUpload"
:auto-upload="false"
drag
multiple
class="upload-container"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">
将文件拖到此处<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
支持多文件上传单个文件大小不超过50MB
</div>
</template>
</el-upload>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="submitUpload" :loading="uploading">
{{ uploading ? "上传中..." : "开始上传" }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref, computed } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { UploadFilled } from "@element-plus/icons-vue";
import { uploadFile } from "@/api/file";
import type { UploadInstance, UploadFile, UploadFiles } from "element-plus";
interface Props {
modelValue: boolean;
categoryId?: number;
}
interface Emits {
(e: "update:modelValue", value: boolean): void;
(e: "success"): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
categoryId: undefined,
});
const emit = defineEmits<Emits>();
const visible = computed({
get: () => props.modelValue,
set: (val) => emit("update:modelValue", val),
});
const uploadRef = ref<UploadInstance>();
const fileList = ref<UploadFile[]>([]);
const uploading = ref(false);
const uploadAction = ref(""); // 不使用action,手动上传
// 上传请求头
const uploadHeaders = computed(() => {
const token = localStorage.getItem("token");
return {
Authorization: token ? `Bearer ${token}` : "",
};
});
// 上传额外数据
const uploadData = computed(() => ({
cate: props.categoryId || 0,
}));
// 文件变化监听
const handleChange = (file: UploadFile, files: UploadFiles) => {
// 更新文件列表
fileList.value = files;
};
// 文件上传前的验证
const beforeUpload = (file: File) => {
const maxSize = 50 * 1024 * 1024; // 50MB
const isValidType = true; // 可以根据需要限制文件类型
if (file.size > maxSize) {
ElMessage.error(`文件 ${file.name} 大小超过50MB限制`);
return false;
}
return true;
};
// 移除文件
const handleRemove = (file: UploadFile) => {
const index = fileList.value.findIndex((item) => item.uid === file.uid);
if (index > -1) {
fileList.value.splice(index, 1);
}
};
// 上传进度
const handleProgress = (event: any, file: UploadFile) => {
file.status = "uploading";
file.percentage = Math.round(event.percent);
};
// 上传成功
const handleSuccess = (response: any, file: UploadFile) => {
// 只要 code === 200 或 201 都视作已成功(存在或新上传)
if (response.code === 200) {
file.status = "success";
ElMessage.success(`文件 ${file.name} 上传成功`);
} else if (response.code === 201) {
file.status = "success";
ElMessage.info(response.msg || `文件 ${file.name} 已存在`);
} else {
file.status = "fail";
ElMessage.error(response.msg || response.message || `文件 ${file.name} 上传失败`);
}
};
// 上传失败
const handleError = (error: any, file: UploadFile) => {
file.status = "fail";
let msg = error?.message || error?.msg || '';
if (!msg && error?.response && error.response.data) {
msg = error.response.data.msg || error.response.data.message;
}
console.error("上传失败:", error);
ElMessage.error(msg ? `文件 ${file.name} 上传失败:${msg}` : `文件 ${file.name} 上传失败`);
};
// 手动提交上传
const submitUpload = async () => {
if (!uploadRef.value) return;
// 从 upload 组件实例获取文件列表
const uploadFiles = uploadRef.value.uploadFiles || fileList.value;
const filesToUpload = uploadFiles.filter(
(file: UploadFile) => file.status !== "success" && file.raw
);
if (filesToUpload.length === 0) {
ElMessage.warning("请先选择要上传的文件");
return;
}
uploading.value = true;
try {
// 逐个上传文件,显示进度
const uploadPromises = filesToUpload.map(async (file) => {
file.status = "uploading";
file.percentage = 0;
try {
const formData = new FormData();
formData.append("file", file.raw as File);
if (props.categoryId) {
formData.append("cate", props.categoryId.toString());
}
// 模拟上传进度
const progressInterval = setInterval(() => {
if (file.percentage < 90) {
file.percentage = Math.min(file.percentage + 10, 90);
}
}, 200);
const res = await uploadFile(formData, { cate: props.categoryId });
clearInterval(progressInterval);
file.percentage = 100;
// code 200 表示新上传成功,code 201 表示文件已存在(也视为成功)
if (res.code === 200 || res.code === 201) {
file.status = "success";
return { success: true, file, res };
} else {
file.status = "fail";
throw new Error(res.msg || "上传失败");
}
} catch (error: any) {
file.status = "fail";
file.percentage = 0;
throw { file, error };
}
});
const results = await Promise.allSettled(uploadPromises);
const successCount = results.filter((r) => r.status === "fulfilled").length;
const failCount = results.filter((r) => r.status === "rejected").length;
// 统计新上传和已存在的文件数量
let newUploadCount = 0;
let existCount = 0;
results.forEach((result) => {
if (result.status === "fulfilled" && result.value?.res) {
if (result.value.res.code === 200) {
newUploadCount++;
} else if (result.value.res.code === 201) {
existCount++;
}
}
});
if (successCount > 0) {
let message = "";
if (newUploadCount > 0 && existCount > 0) {
message = `成功处理 ${successCount} 个文件(${newUploadCount} 个新上传,${existCount} 个已存在)`;
} else if (newUploadCount > 0) {
message = `成功上传 ${newUploadCount} 个文件`;
} else if (existCount > 0) {
message = `${existCount} 个文件已存在`;
} else {
message = `成功处理 ${successCount} 个文件`;
}
ElMessage.success(message);
}
if (failCount > 0) {
ElMessage.warning(`${failCount} 个文件上传失败`);
}
// 如果所有文件都上传成功,关闭对话框并刷新列表
if (failCount === 0) {
emit("success");
handleClose();
}
} catch (error: any) {
console.error("上传失败:", error);
ElMessage.error("上传过程中发生错误");
} finally {
uploading.value = false;
}
};
// 关闭对话框
const handleClose = () => {
fileList.value = [];
uploading.value = false;
visible.value = false;
};
</script>
<style lang="scss" scoped>
.upload-container {
:deep(.el-upload) {
width: 100%;
}
:deep(.el-upload-dragger) {
width: 100%;
height: 180px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.el-icon--upload {
font-size: 67px;
color: #c0c4cc;
margin-bottom: 16px;
}
.el-upload__text {
color: #606266;
font-size: 14px;
text-align: center;
em {
color: #409eff;
font-style: normal;
}
}
.el-upload__tip {
color: #909399;
font-size: 12px;
margin-top: 8px;
text-align: center;
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -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支持 jpgpnggif 格式大小不超过 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>
+563
View File
@@ -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.message);
}
} 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.message);
}
} 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>
+11
View File
@@ -0,0 +1,11 @@
<script setup>
</script>
<template>
<router-view />
</template>
<style scoped>
</style>
+429
View File
@@ -0,0 +1,429 @@
<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-cascader
:model-value="pidValue"
@update:model-value="handlePidUpdate"
:options="parentMenuOptions"
:props="cascaderProps"
placeholder="请选择父级菜单"
clearable
style="width: 100%"
/>
</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-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>
</el-form-item>
<el-form-item
label="路由地址"
prop="path"
v-if="currentMenu.type !== 3"
>
<el-input v-model="currentMenu.path" placeholder="例如:/system" />
</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="icon">
<el-input
v-model="currentMenu.icon"
placeholder="例如:fas fa-tachometer-alt"
/>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number
v-model="currentMenu.sort"
:min="0"
placeholder="数字越小越靠前"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch
v-model="currentMenu.status"
:active-value="1"
:inactive-value="0"
/>
</el-form-item>
<el-form-item label="权限标识" prop="permission">
<el-input
v-model="currentMenu.permission"
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";
// 定义菜单数据类型
interface Menu {
id: number;
pid: number;
title: string;
path: string;
component_path: string;
icon: string;
sort: number;
status: 0 | 1;
type: 1 | 2 | 3;
permission: 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: '',
path: '',
component_path: '',
icon: '',
sort: 0,
status: 1,
type: 1,
permission: '',
});
// 使用计算属性处理 pid,确保始终是单个整数
const pidValue = computed({
get: () => {
const pid = currentMenu.value.pid;
// 如果已经是数组,取最后一个元素(el-cascader 返回的是路径数组)
if (Array.isArray(pid)) {
const normalized = pid.length > 0 ? (parseInt(String(pid[pid.length - 1]), 10) || 0) : 0;
// 立即修正 currentMenu.value.pid
currentMenu.value.pid = normalized;
return normalized;
}
// 确保是整数
const normalized = typeof pid === 'number' ? pid : (pid ? parseInt(String(pid), 10) || 0 : 0);
// 如果值被修改了,更新回去
if (normalized !== pid && pid !== null && pid !== undefined) {
currentMenu.value.pid = normalized;
}
return normalized;
},
set: (value: number | number[] | null | undefined) => {
// 处理数组情况:el-cascader 可能返回路径数组,取最后一个元素
let pid: number = 0;
if (Array.isArray(value)) {
pid = value.length > 0 ? (parseInt(String(value[value.length - 1]), 10) || 0) : 0;
} else if (value !== null && value !== undefined) {
pid = parseInt(String(value), 10) || 0;
}
currentMenu.value.pid = pid;
}
});
// 处理父级菜单更新
const handlePidUpdate = (value: number | number[] | null | undefined) => {
pidValue.value = value;
};
// 监听props变化,更新当前菜单
watch(() => props.menu, (newMenu) => {
if (newMenu) {
// 确保 pid 是整数,处理数组情况
let normalizedPid = 0;
if (newMenu.pid !== null && newMenu.pid !== undefined) {
if (Array.isArray(newMenu.pid)) {
normalizedPid = newMenu.pid.length > 0 ? (parseInt(String(newMenu.pid[newMenu.pid.length - 1]), 10) || 0) : 0;
} else {
normalizedPid = parseInt(String(newMenu.pid), 10) || 0;
}
}
currentMenu.value = {
...newMenu,
pid: normalizedPid,
};
} else {
// 重置表单
currentMenu.value = {
id: 0,
pid: props.dialogType === 'addSub' ? props.parentMenuOptions[0]?.id || 0 : 0,
title: '',
path: '',
component_path: '',
icon: '',
sort: 0,
status: 1,
type: 1,
permission: '',
};
}
}, { immediate: true });
// 监听 currentMenu.value.pid 的变化,确保始终是整数(防止被直接修改为数组)
watch(
() => currentMenu.value.pid,
(newPid) => {
if (Array.isArray(newPid)) {
// 如果变成了数组,立即转换为整数(取最后一个元素,因为 el-cascader 返回的是路径数组)
const normalizedPid = newPid.length > 0 ? (parseInt(String(newPid[newPid.length - 1]), 10) || 0) : 0;
currentMenu.value.pid = normalizedPid;
} else if (typeof newPid !== 'number' && newPid !== null && newPid !== undefined) {
// 如果不是数字,转换为整数
const normalizedPid = parseInt(String(newPid), 10) || 0;
currentMenu.value.pid = normalizedPid;
}
},
{ deep: true }
);
// 监听props.visible变化
watch(() => props.visible, (newVisible) => {
if (newVisible && props.dialogType === 'add') {
// 新增时重置表单
currentMenu.value = {
id: 0,
pid: 0,
title: '',
path: '',
component_path: '',
icon: '',
sort: 0,
status: 1,
type: 1,
permission: '',
};
}
});
// 对话框标题
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" }],
});
// 级联选择器配置
const cascaderProps = ref({
value: "id",
label: "title",
children: "children",
checkStrictly: true,
emitpath: false,
});
// 监听菜单类型变化,自动清空不相关的字段
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 = "";
}
});
// 取消操作
const handleCancel = () => {
emit('update:visible', false);
emit('cancel');
};
// 处理对话框关闭
const handleDialogClose = (value: boolean) => {
if (!value) {
emit('update:visible', false);
emit('cancel');
}
};
// 保存菜单
const handleSave = async () => {
// 表单验证
if (!menuFormRef.value) return;
const valid = await menuFormRef.value.validate();
if (!valid) return;
// 先确保 currentMenu.value.pid 是整数(防止 el-cascader 直接修改为数组)
let rawPid = currentMenu.value.pid;
if (Array.isArray(rawPid)) {
// el-cascader 返回的是路径数组,取最后一个元素
rawPid = rawPid.length > 0 ? rawPid[rawPid.length - 1] : 0;
}
const normalizedPid = typeof rawPid === 'number' ? rawPid : (rawPid ? parseInt(String(rawPid), 10) || 0 : 0);
currentMenu.value.pid = normalizedPid;
// 解决后端时间字段问题:过滤掉不需要的字段
const payload = { ...currentMenu.value };
// 再次确保 pid 是整数类型(双重保险)
// 处理数组情况:如果 pid 是数组,取最后一个元素(el-cascader 返回的是路径数组)
let pidValue: any = payload.pid;
// 如果是数组,取最后一个元素
if (Array.isArray(pidValue)) {
pidValue = pidValue.length > 0 ? pidValue[pidValue.length - 1] : null;
}
// 强制转换为整数
if (pidValue === null || pidValue === undefined || pidValue === '') {
payload.pid = 0;
} else {
const parsedPid = parseInt(String(pidValue), 10);
// 如果转换失败(NaN),设置为 0
if (isNaN(parsedPid)) {
payload.pid = 0;
} else {
payload.pid = parsedPid;
}
}
// 最终验证:确保 payload.pid 是数字类型,不是数组
if (Array.isArray(payload.pid)) {
payload.pid = Array.isArray(payload.pid) && payload.pid.length > 0
? parseInt(String(payload.pid[payload.pid.length - 1]), 10) || 0
: 0;
}
// 确保是数字类型
if (typeof payload.pid !== 'number') {
payload.pid = parseInt(String(payload.pid), 10) || 0;
}
// 触发保存事件
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;
}
</style>
+508
View File
@@ -0,0 +1,508 @@
<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 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 prop="status" label="状态" width="100" align="center">
<template #default="scope">
<el-switch
v-model="scope.row.status"
:active-value="1"
:inactive-value="0"
@change="handleStatusChange(scope.row)"
@click.stop
:disabled="!scope.row.id"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right" align="center">
<template #default="scope">
<el-button size="small" text @click.stop="handleAddSubMenu(scope.row)" :disabled="scope.row.type === 3">
<el-icon>
<CirclePlus />
</el-icon>
<span>子菜单</span>
</el-button>
<el-button size="small" text @click.stop="handleEditMenu(scope.row)">
<el-icon>
<Edit />
</el-icon>
<span>编辑</span>
</el-button>
<el-button size="small" text type="danger" @click.stop="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 { getAllMenus, updateMenuStatus, createMenu, updateMenu, deleteMenu } from "@/api/menu";
import MenuEdit from "./components/edit.vue";
// 定义菜单数据类型
interface Menu {
id: number;
pid: number;
title: string;
path: string;
component_path: string;
icon: string;
sort: number;
status: 0 | 1;
type: 1 | 2 | 3; // 1:目录 2:页面 3:接口
permission: 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 getAllMenus();
if (result.code === 200) {
menuTree.value = result.data;
parentMenuOptions.value = [
{
id: 0,
pid: 0,
title: "顶级菜单",
children: [],
} as Menu,
...result.data,
];
} else {
ElMessage.error("获取菜单失败: " + result.message);
}
} 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((menu) => {
rows.push(menu);
if (menu.children && menu.children.length > 0) {
rows.push(...getAllMenuRows(menu.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);
}
});
}
// 处理行点击事件 - 展开/折叠树形结构
const handleRowClick = (row: Menu) => {
if (!tableRef.value) return;
// 检查该行是否有子节点
const hasChildren = row.children && row.children.length > 0;
if (hasChildren) {
// 切换展开/折叠状态(toggleRowExpansion 会自动切换当前状态)
tableRef.value.toggleRowExpansion(row);
}
};
// 构建菜单树(处理父子关系)
const buildMenuTree = (menuList: Menu[]): Menu[] => {
return menuList;
};
// 获取菜单类型名称
const getMenuTypeTitle = (type: number) => {
const typeMap = { 1: "目录", 2: "页面", 3: "接口" };
return typeMap[type as keyof typeof typeMap] || "未知类型";
};
// 获取菜单类型标签样式
const getMenuTypeTagType = (type: number) => {
const typeMap = { 1: "primary", 2: "success", 3: "info" };
return typeMap[type as keyof typeof typeMap] || "default";
};
// 处理状态变更
const handleStatusChange = async (menu: Menu) => {
try {
const result = await updateMenuStatus(menu.id, menu.status);
if (!result.success) {
ElMessage.error(result.message);
// 状态更新失败时回滚
menu.status = menu.status === 1 ? 0 : 1;
}
} catch (error) {
ElMessage.error("更新状态失败: " + (error as Error).message);
menu.status = menu.status === 1 ? 0 : 1;
}
};
// 添加子菜单
const handleAddSubMenu = (parentMenu: Menu) => {
dialogType.value = 'addSub';
dialogParentTitle.value = parentMenu.title;
dialogMenu.value = {
id: 0,
pid: parentMenu.id,
title: '',
path: '',
component_path: '',
icon: '',
sort: 0,
status: 1,
type: parentMenu.type === 2 ? 1 : parentMenu.type,
permission: '',
};
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 deleteMenu(menu.id);
if (result.success) {
ElMessage.success("删除成功");
fetchMenus();
} else {
ElMessage.error("删除失败: " + result.message);
}
} catch (error) {
ElMessage.error("删除失败: " + (error as Error).message);
}
});
};
// 添加菜单
const handleAddMenu = () => {
dialogType.value = 'add';
dialogMenu.value = null;
dialogVisible.value = true;
};
// 处理菜单保存
const handleMenuSave = async (menu: Partial<Menu>) => {
try {
// 解决后端时间字段问题:过滤掉不需要的字段
const payload = { ...menu };
// 确保 pid 是整数类型(后端要求必须是整数)
// 处理数组情况:如果 pid 是数组(el-cascader 返回的是路径数组),取最后一个元素
let pidValue: any = payload.pid;
if (Array.isArray(pidValue)) {
pidValue = pidValue.length > 0 ? pidValue[pidValue.length - 1] : 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;
}
}
// 最终验证:确保 payload.pid 是数字类型,不是数组
if (Array.isArray(payload.pid)) {
payload.pid = Array.isArray(payload.pid) && payload.pid.length > 0
? parseInt(String(payload.pid[payload.pid.length - 1]), 10) || 0
: 0;
}
// 确保是数字类型
if (typeof payload.pid !== 'number') {
payload.pid = parseInt(String(payload.pid), 10) || 0;
}
if (menu.id === 0) {
// 新增菜单
const result = await createMenu(payload as Menu);
if (result.code === 200) { // 修改这里,检查 code 而不是 success
ElMessage.success(result.msg || "菜单添加成功");
dialogVisible.value = false;
await fetchMenus();
} else {
ElMessage.error(result.msg || "添加失败");
}
} else {
// 编辑菜单
const result = await updateMenu(menu.id!, payload as Menu);
if (result.code === 200) { // 修改这里,检查 code 而不是 success
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;
};
// 组件挂载时加载菜单
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__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,272 @@
<template>
<el-drawer v-model="visible" title="操作日志详情" size="60%">
<div class="log-detail" v-if="log">
<el-descriptions :column="2" border>
<el-descriptions-item label="日志ID" label-width="150px">
{{ log.id }}
</el-descriptions-item>
<el-descriptions-item label="请求方法" label-width="150px">
<el-tag :type="getMethodTagType(log.method)" size="small">
{{ log.method }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="用户ID" label-width="150px">
{{ log.user_id }}
</el-descriptions-item>
<el-descriptions-item label="用户姓名" label-width="150px">
{{ log.user_name }}
</el-descriptions-item>
<el-descriptions-item label="操作模块" label-width="150px">
{{ log.module }}
</el-descriptions-item>
<el-descriptions-item label="操作动作" label-width="150px">
{{ log.action }}
</el-descriptions-item>
<el-descriptions-item label="操作状态" label-width="150px">
<el-tag :type="log.status === 1 ? 'success' : 'danger'" size="small">
{{ log.status === 1 ? "成功" : "失败" }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="请求URL" :span="2" label-width="150px">
<div class="url-text">{{ log.url }}</div>
</el-descriptions-item>
<el-descriptions-item label="IP地址" label-width="150px">
{{ log.ip }}
</el-descriptions-item>
<el-descriptions-item label="操作时间" label-width="150px" :span="2">
{{ log.create_time }}
</el-descriptions-item>
<el-descriptions-item label="用户代理" label-width="150px" :span="2">
<div class="user-agent-text">{{ log.user_agent || "无" }}</div>
</el-descriptions-item>
<el-descriptions-item label="请求参数" :span="2" label-width="150px">
<div class="json-content">
<div class="json-header">
<el-icon><Document /></el-icon>
<span>请求数据</span>
</div>
<pre v-if="log.request_data">{{ formatJson(log.request_data) }}</pre>
<span v-else class="empty-text"></span>
</div>
</el-descriptions-item>
<el-descriptions-item label="响应数据" :span="4" label-width="150px">
<div class="json-content">
<div class="json-header">
<el-icon><Document /></el-icon>
<span>响应数据</span>
</div>
<pre v-if="log.response_data">{{ formatJson(log.response_data) }}</pre>
<span v-else class="empty-text"></span>
</div>
</el-descriptions-item>
<el-descriptions-item v-if="log.error_message" label="错误信息" :span="2">
<div class="error-message">{{ log.error_message }}</div>
</el-descriptions-item>
</el-descriptions>
</div>
<div v-else class="loading-container">
<el-empty description="加载中..." />
</div>
</el-drawer>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { Document } from "@element-plus/icons-vue";
import { getOperationLogDetail } from "@/api/operationLog";
interface OperationLog {
id: number;
user_id: number;
user_account: string;
user_name: string;
module: string;
action: string;
method: string;
url: string;
ip: string;
user_agent: string;
request_data: string;
response_data: string;
status: number;
error_message: string;
execution_time: number;
create_time: string;
}
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
logId: {
type: Number,
default: undefined,
},
});
const emit = defineEmits(["update:modelValue"]);
const visible = ref(false);
const log = ref<OperationLog | null>(null);
const loading = ref(false);
// 获取请求方法标签类型
const getMethodTagType = (method: string): string => {
const typeMap: Record<string, string> = {
GET: "primary",
POST: "success",
PUT: "warning",
DELETE: "danger",
PATCH: "",
};
return typeMap[method] || "";
};
// 格式化 JSON
const formatJson = (jsonString: string): string => {
try {
const obj = typeof jsonString === "string" ? JSON.parse(jsonString) : jsonString;
return JSON.stringify(obj, null, 2);
} catch (e) {
return jsonString;
}
};
// 获取日志详情
const fetchLogDetail = async (logId?: number) => {
const targetId = logId || props.logId;
if (!targetId) {
return;
}
loading.value = true;
log.value = null; // 清空之前的数据
try {
const res = await getOperationLogDetail(targetId);
if (res.code === 200) {
log.value = res.data;
console.log(log.value);
} else {
console.error("获取日志详情失败:", res?.msg || "未知错误");
log.value = null;
}
} catch (error: any) {
console.error("获取日志详情失败", error);
log.value = null;
} finally {
loading.value = false;
}
};
// 监听 modelValue 变化
watch(
() => props.modelValue,
(newVal) => {
visible.value = newVal;
if (newVal && props.logId) {
fetchLogDetail();
}
}
);
// 监听 visible 变化
watch(visible, (newVal) => {
if (!newVal) {
emit("update:modelValue", false);
log.value = null;
}
});
// 暴露方法给父组件
defineExpose({
open: (logId: number) => {
if (logId) {
fetchLogDetail(logId);
}
},
});
</script>
<style lang="scss" scoped>
.log-detail {
padding: 20px;
}
.url-text {
word-break: break-all;
color: #606266;
}
.user-agent-text {
word-break: break-all;
color: #606266;
font-size: 12px;
}
.json-content {
width: 950px;
max-height: 200px;
min-height: 60px;
overflow-y: auto;
overflow-x: auto;
background: #f5f7fa;
padding: 10px;
border-radius: 4px;
border: 1px solid #e4e7ed;
resize: vertical;
transition: all 0.3s ease;
&:hover {
border-color: #c0c4cc;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
pre {
margin: 0;
font-family: "Monaco", "Consolas", monospace;
font-size: 11px;
line-height: 1.4;
color: #303133;
white-space: pre-wrap;
word-wrap: break-word;
max-width: 100%;
overflow-x: auto;
}
.empty-text {
color: #909399;
font-style: italic;
}
}
.error-message {
color: #f56c6c;
background: #fef0f0;
padding: 10px;
border-radius: 4px;
word-break: break-all;
}
.json-header {
display: flex;
align-items: center;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid #e4e7ed;
font-size: 12px;
color: #606266;
font-weight: 500;
.el-icon {
margin-right: 6px;
font-size: 14px;
}
}
.loading-container {
padding: 40px;
text-align: center;
}
</style>
+401
View File
@@ -0,0 +1,401 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>操作日志</h2>
<div class="header-actions">
<el-button @click="refresh">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 搜索筛选 -->
<div class="filter-bar">
<el-form :inline="true" :model="filterForm" class="filter-form">
<!-- <el-form-item label="关键词">
<el-input
v-model="filterForm.keyword"
placeholder="搜索用户账号、姓名、URL"
clearable
style="width: 200px"
@clear="handleSearch"
@keyup.enter="handleSearch"
/>
</el-form-item> -->
<el-form-item label="状态">
<el-select
v-model="filterForm.status"
placeholder="请选择状态"
clearable
style="width: 120px"
@change="handleSearch"
>
<el-option label="成功" value="1" />
<el-option label="失败" value="0" />
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 350px"
@change="handleDateRangeChange"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 操作日志列表 -->
<el-table
:data="logs"
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="60" align="center" />
<el-table-column
prop="user_name"
label="用户姓名"
width="120"
align="center"
/>
<el-table-column
prop="action"
label="操作"
min-width="100"
align="center"
/>
<el-table-column prop="module" label="模块" width="120" align="center">
<template #default="scope">
<el-tag type="primary" size="small">
{{ scope.row.module }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="method"
label="请求方法"
width="100"
align="center"
>
<template #default="scope">
<el-tag :type="getMethodTagType(scope.row.method)" size="small">
{{ scope.row.method }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="url"
label="请求URL"
min-width="200"
show-overflow-tooltip
/>
<el-table-column prop="ip" label="IP地址" width="130" align="center" />
<el-table-column prop="status" label="状态" width="80" align="center">
<template #default="scope">
<el-tag
:type="scope.row.status === 1 ? 'success' : 'danger'"
size="small"
>
{{ scope.row.status === 1 ? "成功" : "失败" }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="create_time"
label="操作时间"
width="180"
align="center"
/>
<el-table-column label="操作" width="120" align="center" fixed="right">
<template #default="scope">
<el-button
size="small"
type="primary"
link
@click="handleViewDetail(scope.row)"
>
详情
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-bar">
<el-pagination
v-model:current-page="page"
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="handlePageChange"
/>
</div>
<!-- 详情对话框 -->
<DetailDialog
ref="detailDialogRef"
v-model="detailDialogVisible"
:log-id="currentLogId"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Refresh, Delete, Search } from "@element-plus/icons-vue";
import {
getOperationLogs,
deleteOperationLog,
batchDeleteOperationLogs,
getOperationStatistics,
} from "@/api/operationLog";
import DetailDialog from "./components/detail.vue";
interface OperationLog {
id: number;
user_id: number;
user_account: string;
user_name: string;
module: string;
action: string;
method: string;
url: string;
ip: string;
user_agent: string;
request_data: string;
response_data: string;
status: number;
error_message: string;
execution_time: number;
create_time: string;
}
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);
const loading = ref(false);
const logs = ref<OperationLog[]>([]);
const selectedLogs = ref<OperationLog[]>([]);
// 筛选表单
const filterForm = ref({
keyword: "",
module: "",
action: "",
status: "",
startTime: "",
endTime: "",
});
const dateRange = ref<[string, string] | null>(null);
// 模块和操作列表
const modules = ref<string[]>([]);
const actions = ref<string[]>([]);
// 详情对话框
const detailDialogVisible = ref(false);
const detailDialogRef = ref<any>(null);
const currentLogId = ref<number | undefined>(undefined);
// 获取请求方法标签类型
const getMethodTagType = (method: string): string => {
const typeMap: Record<string, string> = {
GET: "info",
POST: "success",
PUT: "warning",
DELETE: "danger",
PATCH: "",
};
return typeMap[method] || "";
};
// 获取操作日志列表
const fetchLogs = async () => {
loading.value = true;
try {
const params: any = {
page: page.value,
pageSize: pageSize.value,
};
if (filterForm.value.keyword) {
params.keyword = filterForm.value.keyword;
}
if (filterForm.value.module) {
params.module = filterForm.value.module;
}
if (filterForm.value.action) {
params.action = filterForm.value.action;
}
if (filterForm.value.status !== "") {
params.status = filterForm.value.status;
}
if (filterForm.value.startTime) {
params.startTime = filterForm.value.startTime;
}
if (filterForm.value.endTime) {
params.endTime = filterForm.value.endTime;
}
const res = await getOperationLogs(params);
if (res.code === 200) {
logs.value = res.data.list || [];
total.value = res.data.total || 0;
} else {
ElMessage.error(res.msg || "获取操作日志失败");
}
} catch (error: any) {
ElMessage.error(error?.message || "获取操作日志失败");
} finally {
loading.value = false;
}
};
// 获取统计信息(模块和操作列表)
const fetchStatistics = async () => {
try {
const res = await getOperationStatistics();
if (res.code === 200) {
modules.value = res.data.modules || [];
actions.value = res.data.actions || [];
}
} catch (error) {
console.error("获取统计信息失败", error);
}
};
// 刷新
const refresh = () => {
fetchLogs();
};
// 搜索
const handleSearch = () => {
page.value = 1;
fetchLogs();
};
// 重置
const handleReset = () => {
filterForm.value = {
keyword: "",
module: "",
action: "",
status: "",
startTime: "",
endTime: "",
};
dateRange.value = null;
page.value = 1;
fetchLogs();
};
// 时间范围变化
const handleDateRangeChange = (val: [string, string] | null) => {
if (val && val.length === 2) {
filterForm.value.startTime = val[0];
filterForm.value.endTime = val[1];
} else {
filterForm.value.startTime = "";
filterForm.value.endTime = "";
}
handleSearch();
};
// 分页改变
const handlePageChange = (val: number) => {
page.value = val;
fetchLogs();
};
// 每页数量改变
const handleSizeChange = (val: number) => {
pageSize.value = val;
page.value = 1;
fetchLogs();
};
// 选择改变
const handleSelectionChange = (selection: OperationLog[]) => {
selectedLogs.value = selection;
};
// 查看详情
const handleViewDetail = (log: OperationLog) => {
currentLogId.value = log.id;
detailDialogVisible.value = true;
// 使用 nextTick 确保组件已渲染后再调用 open 方法
nextTick(() => {
if (detailDialogRef.value && detailDialogRef.value.open) {
detailDialogRef.value.open(log.id);
}
});
};
onMounted(() => {
fetchLogs();
fetchStatistics();
});
</script>
<style lang="scss" scoped>
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 10px;
}
}
.filter-bar {
margin-bottom: 20px;
padding: 20px;
// background: #f5f7fa;
border-radius: 4px;
.filter-form {
margin: 0;
}
}
.pagination-bar {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
+589
View File
@@ -0,0 +1,589 @@
<template>
<div class="permissions-container">
<el-card>
<template #header>
<div class="card-header">
<span class="title">
<el-icon><Key /></el-icon>
权限管理
</span>
<span class="subtitle">为角色分配菜单和API访问权限</span>
</div>
</template>
<el-row :gutter="20">
<!-- 左侧角色列表 -->
<el-col :span="8">
<el-card shadow="hover" class="role-card">
<template #header>
<div class="card-title">
<el-icon><UserFilled /></el-icon>
<span>角色列表</span>
</div>
</template>
<el-input
v-model="roleSearchQuery"
placeholder="搜索角色..."
:prefix-icon="Search"
clearable
class="search-input"
/>
<el-scrollbar height="600px" class="role-list">
<div
v-for="role in filteredRoles"
:key="role.roleId"
:class="['role-item', { active: selectedRole?.roleId === role.roleId }]"
@click="selectRole(role)"
>
<div class="role-info">
<div class="role-name">{{ role.roleName }}</div>
<div class="role-code">{{ role.roleCode }}</div>
</div>
<el-icon v-if="selectedRole?.roleId === role.roleId" class="check-icon">
<Check />
</el-icon>
</div>
<el-empty v-if="filteredRoles.length === 0" description="暂无角色数据" />
</el-scrollbar>
</el-card>
</el-col>
<!-- 右侧权限分配 -->
<el-col :span="16">
<el-card shadow="hover" class="permission-card">
<template #header>
<div class="card-title">
<el-icon><Menu /></el-icon>
<span>权限分配</span>
<span v-if="selectedRole" class="selected-role-name">
{{ selectedRole.roleName }}
</span>
</div>
</template>
<div v-if="!selectedRole" class="empty-state">
<el-empty description="请先选择一个角色" />
</div>
<div v-else class="permission-content">
<!-- 搜索和操作按钮 -->
<div class="toolbar">
<el-input
v-model="permissionSearchQuery"
placeholder="搜索菜单..."
:prefix-icon="Search"
clearable
style="width: 300px;"
/>
<div class="actions">
<el-button @click="expandAll">全部展开</el-button>
<el-button @click="collapseAll">全部折叠</el-button>
<el-button @click="checkAll">全选</el-button>
<el-button @click="uncheckAll">取消全选</el-button>
</div>
</div>
<!-- 权限树 -->
<el-scrollbar height="550px" class="tree-container">
<el-tree
ref="permissionTree"
:data="menuTreeData"
:props="treeProps"
:filter-node-method="filterNode"
node-key="menu_id"
show-checkbox
:default-expand-all="false"
:check-strictly="false"
class="permission-tree"
>
<template #default="{ node, data }">
<div class="custom-tree-node">
<div class="node-content">
<el-icon v-if="data.menu_type === 1" class="menu-icon">
<Folder />
</el-icon>
<el-icon v-else-if="data.menu_type === 2" class="page-icon">
<Document />
</el-icon>
<el-icon v-else class="api-icon">
<Link />
</el-icon>
<span class="node-label">{{ node.label }}</span>
</div>
<div class="node-info">
<el-tag v-if="data.menu_type === 1" type="primary" size="small">
目录
</el-tag>
<el-tag v-else-if="data.menu_type === 2" type="success" size="small">
页面
</el-tag>
<el-tag v-else type="warning" size="small">
按钮
</el-tag>
<el-tag v-if="data.permission" type="info" size="small" class="permission-tag">
{{ data.permission }}
</el-tag>
</div>
</div>
</template>
</el-tree>
</el-scrollbar>
<!-- 保存按钮 -->
<div class="footer">
<el-button type="primary" @click="savePermissions" :loading="saving">
<el-icon><Select /></el-icon>
保存权限设置
</el-button>
<el-button @click="resetPermissions">
<el-icon><RefreshLeft /></el-icon>
重置
</el-button>
</div>
</div>
</el-card>
</el-col>
</el-row>
</el-card>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
Key,
UserFilled,
Menu,
Search,
Check,
Folder,
Link,
Document,
Select,
RefreshLeft,
} from '@element-plus/icons-vue';
import { getRoleByTenantId, getAllRoles } from '@/api/role';
import {
getAllMenuPermissions,
getRolePermissions,
assignRolePermissions,
} from '@/api/permission.js';
import { useAuthStore } from '@/stores/auth';
// 角色相关
const roleList = ref([]);
const roleSearchQuery = ref('');
const selectedRole = ref(null);
const authStore = useAuthStore();
// 权限相关
const allMenus = ref([]);
const menuTreeData = ref([]);
const permissionSearchQuery = ref('');
const permissionTree = ref(null);
const saving = ref(false);
// 树配置
const treeProps = {
children: 'children',
label: 'menu_name',
};
// 过滤后的角色列表
const filteredRoles = computed(() => {
if (!roleSearchQuery.value) {
return roleList.value;
}
const query = roleSearchQuery.value.toLowerCase();
return roleList.value.filter(
(role) =>
role.roleName.toLowerCase().includes(query) ||
role.roleCode.toLowerCase().includes(query)
);
});
// 加载所有菜单权限(根据选中的角色过滤)
const loadAllMenus = async (roleId = null) => {
try {
// 如果提供了roleId,传递给接口用于根据角色的default值过滤菜单
const params = roleId ? { roleId } : {};
const res = await getAllMenuPermissions(params);
if (res.success && res.data) {
allMenus.value = res.data;
buildMenuTree();
}
} catch (error) {
console.error('加载菜单列表失败:', error);
ElMessage.error('加载菜单列表失败');
}
};
// 构建菜单树
const buildMenuTree = () => {
const tree = [];
const map = new Map();
// 先创建所有节点的映射
allMenus.value.forEach((menu) => {
map.set(menu.menu_id, { ...menu, children: [] });
});
// 构建树形结构
allMenus.value.forEach((menu) => {
const node = map.get(menu.menu_id);
if (menu.parent_id === 0) {
tree.push(node);
} else {
const parent = map.get(menu.parent_id);
if (parent) {
parent.children.push(node);
}
}
});
menuTreeData.value = tree;
};
// 选择角色
const selectRole = async (role) => {
selectedRole.value = role;
// 重新加载菜单列表(根据角色的default值过滤)
await loadAllMenus(role.roleId);
await loadRolePermissions(role.roleId);
};
// 加载角色权限
const loadRolePermissions = async (roleId) => {
try {
const res = await getRolePermissions(roleId);
if (res.success && res.data) {
// 等待树渲染完成
await nextTick();
// 设置选中的节点
if (permissionTree.value) {
permissionTree.value.setCheckedKeys(res.data.menu_ids || []);
}
}
} catch (error) {
console.error('加载角色权限失败:', error);
ElMessage.error('加载角色权限失败');
}
};
// 保存权限
const savePermissions = async () => {
if (!selectedRole.value) {
ElMessage.warning('请先选择角色');
return;
}
try {
// 只获取完全选中的节点(不包括半选中的父节点)
// 因为半选中的父节点表示部分子节点被选中,父节点本身不需要权限记录
const checkedKeys = permissionTree.value.getCheckedKeys();
const menuIds = checkedKeys;
saving.value = true;
const res = await assignRolePermissions(selectedRole.value.roleId, menuIds);
if (res.success) {
ElMessage.success('权限保存成功');
await loadRolePermissions(selectedRole.value.roleId);
} else {
ElMessage.error(res.message || '权限保存失败');
}
} catch (error) {
console.error('保存权限失败:', error);
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
ElMessage.error('请求超时,请重试。如果数据量较大,可能需要更长时间');
} else {
ElMessage.error(error.message || '保存权限失败,请稍后重试');
}
} finally {
saving.value = false;
}
};
// 重置权限
const resetPermissions = async () => {
if (!selectedRole.value) {
return;
}
try {
await ElMessageBox.confirm('确定要重置权限设置吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await loadRolePermissions(selectedRole.value.roleId);
ElMessage.success('已重置');
} catch (error) {
// 用户取消操作
}
};
// 全部展开
const expandAll = () => {
if (permissionTree.value) {
const allKeys = allMenus.value.map((m) => m.menu_id);
allKeys.forEach((key) => {
const node = permissionTree.value.getNode(key);
if (node) {
node.expanded = true;
}
});
}
};
// 全部折叠
const collapseAll = () => {
if (permissionTree.value) {
const allKeys = allMenus.value.map((m) => m.menu_id);
allKeys.forEach((key) => {
const node = permissionTree.value.getNode(key);
if (node) {
node.expanded = false;
}
});
}
};
// 全选
const checkAll = () => {
if (permissionTree.value) {
const allKeys = allMenus.value.map((m) => m.menu_id);
permissionTree.value.setCheckedKeys(allKeys);
}
};
// 取消全选
const uncheckAll = () => {
if (permissionTree.value) {
permissionTree.value.setCheckedKeys([]);
}
};
// 过滤节点
const filterNode = (value, data) => {
if (!value) return true;
return (
data.menu_name.toLowerCase().includes(value.toLowerCase()) ||
(data.path && data.path.toLowerCase().includes(value.toLowerCase())) ||
(data.permission && data.permission.toLowerCase().includes(value.toLowerCase()))
);
};
// 监听搜索框变化
watch(permissionSearchQuery, (val) => {
if (permissionTree.value) {
permissionTree.value.filter(val);
}
});
// 初始化
const init = async () => {
await loadRoles();
await loadAllMenus();
};
init();
</script>
<style lang="less" scoped>
.permissions-container {
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
.title {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 600;
color: #303133;
}
.subtitle {
font-size: 13px;
color: #909399;
}
}
.role-card,
.permission-card {
height: 100%;
.card-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
.selected-role-name {
font-size: 14px;
color: var(--el-color-primary);
font-weight: normal;
}
}
}
.search-input {
margin-bottom: 16px;
}
.role-list {
margin-top: 16px;
.role-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
margin-bottom: 8px;
background: #f5f7fa;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
&:hover {
background: #e6f0ff;
transform: translateX(4px);
}
&.active {
background: var(--el-color-primary-light-9);
border-left: 3px solid var(--el-color-primary);
.role-name {
color: var(--el-color-primary);
font-weight: 600;
}
}
.role-info {
flex: 1;
.role-name {
font-size: 15px;
font-weight: 500;
color: #303133;
margin-bottom: 4px;
}
.role-code {
font-size: 12px;
color: #909399;
}
}
.check-icon {
color: var(--el-color-primary);
font-size: 20px;
}
}
}
.permission-content {
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #ebeef5;
.actions {
display: flex;
gap: 8px;
}
}
.tree-container {
margin-bottom: 16px;
padding: 16px;
background: #fafafa;
border-radius: 8px;
}
.permission-tree {
background: transparent;
:deep(.el-tree-node__content) {
height: auto;
padding: 8px 0;
margin-bottom: 4px;
&:hover {
background: #e6f0ff;
}
}
.custom-tree-node {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding-right: 16px;
.node-content {
display: flex;
align-items: center;
gap: 8px;
.menu-icon {
color: var(--el-color-primary);
}
.api-icon {
color: var(--el-color-success);
}
.node-label {
font-size: 14px;
color: #303133;
}
}
.node-info {
display: flex;
align-items: center;
gap: 8px;
.permission-tag {
font-family: 'Courier New', monospace;
}
}
}
}
.footer {
display: flex;
justify-content: center;
gap: 16px;
padding-top: 16px;
border-top: 1px solid #ebeef5;
}
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
}
}
</style>
+244
View File
@@ -0,0 +1,244 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>程序管理</h2>
<el-button type="primary" @click="showProgramDialog = true">
<el-icon><Plus /></el-icon>
添加程序
</el-button>
</div>
<el-divider></el-divider>
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<div class="loading-spinner"></div>
<p>正在加载程序数据...</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-state">
<el-alert title="加载失败" :message="error" type="error" show-icon />
<el-button type="primary" @click="fetchPrograms">重试</el-button>
</div>
<!-- 程序列表 -->
<div v-else>
<el-table :data="programs" stripe style="width: 100%" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="name" label="程序名称" min-width="160" align="center" />
<el-table-column prop="type" label="类型" width="100" align="center" />
<el-table-column prop="owner" label="负责人" width="120" align="center" />
<el-table-column prop="createdAt" label="创建时间" width="170" align="center" />
<el-table-column prop="remark" label="备注" min-width="140" align="center" />
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(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>
</div>
<!-- 新增/编辑程序弹窗 -->
<el-dialog
:title="isEditing ? '编辑程序' : '添加程序'"
v-model="showProgramDialog"
width="420px"
:close-on-click-modal="false"
>
<el-form
:model="programForm"
:rules="formRules"
ref="programFormRef"
label-width="90px"
>
<el-form-item label="程序名称" prop="name">
<el-input v-model="programForm.name" />
</el-form-item>
<el-form-item label="类型" prop="type">
<el-select v-model="programForm.type" placeholder="选择类型">
<el-option label="Web" value="Web" />
<el-option label="服务" value="Service" />
<el-option label="工具" value="Tool" />
<el-option label="其他" value="Other" />
</el-select>
</el-form-item>
<el-form-item label="负责人" prop="owner">
<el-input v-model="programForm.owner" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="programForm.remark" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showProgramDialog = false">取消</el-button>
<el-button type="primary" @click="submitProgramForm"> 保存 </el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue";
import {
ElMessage,
ElMessageBox,
type FormInstance,
type FormRules,
} from "element-plus";
import { Plus } from "@element-plus/icons-vue";
// 数据与状态
const programs = ref<any[]>([]);
const loading = ref(false);
const error = ref("");
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);
const showProgramDialog = ref(false);
const isEditing = ref(false);
const programForm = reactive({
id: null,
name: "",
type: "",
owner: "",
remark: "",
});
const programFormRef = ref<FormInstance>();
const formRules: FormRules = {
name: [
{ required: true, message: "请输入程序名称", trigger: "blur" },
{ min: 2, max: 32, message: "名称长度2-32字符", trigger: "blur" },
],
type: [{ required: true, message: "请选择类型", trigger: "change" }],
owner: [{ required: true, message: "请输入负责人", trigger: "blur" }],
};
async function fetchPrograms() {
loading.value = true;
error.value = "";
try {
// TODO: 替换为真实API
await new Promise((r) => setTimeout(r, 250));
// 假数据
const all = [
{
id: 1,
name: "门户网站",
type: "Web",
owner: "张三",
createdAt: "2023-11-08 09:22:53",
remark: "官网",
},
{
id: 2,
name: "自动备份服务",
type: "Service",
owner: "李四",
createdAt: "2024-01-16 15:40:01",
remark: "每日凌晨自动执行",
},
{
id: 3,
name: "运维工具",
type: "Tool",
owner: "王五",
createdAt: "2023-12-02 12:51:29",
remark: "",
},
// ...更多
];
total.value = all.length;
programs.value = all.slice(
(page.value - 1) * pageSize.value,
page.value * pageSize.value
);
} catch (err: any) {
error.value = err.message || "获取程序列表失败";
} finally {
loading.value = false;
}
}
const handlePageChange = (val: number) => {
page.value = val;
fetchPrograms();
};
function resetProgramForm() {
programForm.id = null;
programForm.name = "";
programForm.type = "";
programForm.owner = "";
programForm.remark = "";
}
function handleEdit(row: any) {
isEditing.value = true;
programForm.id = row.id;
programForm.name = row.name;
programForm.type = row.type;
programForm.owner = row.owner;
programForm.remark = row.remark;
showProgramDialog.value = true;
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(
`确定要删除程序「${row.name}」? 删除后不可恢复。`,
"警告",
{ type: "warning" }
);
// TODO: 替换为真实API
await new Promise((r) => setTimeout(r, 250));
ElMessage.success("删除成功");
fetchPrograms();
} catch {
// 取消删除
}
}
async function submitProgramForm() {
await programFormRef.value?.validate();
loading.value = true;
try {
if (isEditing.value) {
// TODO: 替换为真实API更新
await new Promise((r) => setTimeout(r, 250));
ElMessage.success("程序更新成功");
} else {
// TODO: 替换为真实API新增
await new Promise((r) => setTimeout(r, 250));
ElMessage.success("程序添加成功");
}
showProgramDialog.value = false;
fetchPrograms();
} catch (e: any) {
ElMessage.error(e.message || "操作失败,请重试");
} finally {
loading.value = false;
}
}
onMounted(() => {
fetchPrograms();
});
</script>
<style scoped>
</style>
@@ -0,0 +1,167 @@
<template>
<el-dialog v-model="visible" title="角色详情" width="600px" @close="handleClose">
<el-descriptions :column="1" border v-loading="loading">
<el-descriptions-item label="角色ID">
{{ roleDetail.id }}
</el-descriptions-item>
<el-descriptions-item label="角色名称">
{{ roleDetail.name }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="roleDetail.status === 1 ? 'success' : 'info'" size="small">
{{ roleDetail.status === 1 ? "启用" : "禁用" }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="权限列表">
<div v-if="menuNames.length > 0" style="max-height: 300px; overflow-y: auto;">
<el-tag
v-for="(name, index) in menuNames"
:key="index"
style="margin: 4px;"
size="small"
>
{{ name }}
</el-tag>
</div>
<span v-else style="color: var(--el-text-color-secondary);">
暂无权限
</span>
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ roleDetail.create_time }}
</el-descriptions-item>
<el-descriptions-item label="更新时间">
{{ roleDetail.update_time }}
</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="handleClose">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch, computed } from "vue";
import { ElMessage } from "element-plus";
import { getRoleById } from "@/api/role";
import { getAllMenus } from "@/api/menu";
interface Props {
modelValue: boolean;
roleId?: number | null;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
roleId: null,
});
const emit = defineEmits(["update:modelValue"]);
const visible = ref(false);
const loading = ref(false);
const roleDetail = ref<any>({});
const allMenus = ref<any[]>([]);
// 监听 modelValue
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (val && props.roleId) {
loadRoleDetail();
loadMenus();
}
}
);
// 监听 visible
watch(visible, (val) => {
emit("update:modelValue", val);
});
// 解析权限ID
const parseRights = (rights: any): number[] => {
if (!rights) return [];
if (Array.isArray(rights)) return rights;
if (typeof rights === "string") {
try {
const parsed = JSON.parse(rights);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
return [];
};
// 递归获取所有菜单ID和名称的映射
const getMenuMap = (menus: any[]): Map<number, string> => {
const map = new Map<number, string>();
const traverse = (items: any[]) => {
for (const item of items) {
map.set(item.id, item.title || item.name || `菜单${item.id}`);
if (item.children && item.children.length > 0) {
traverse(item.children);
}
}
};
traverse(menus);
return map;
};
// 计算菜单名称列表
const menuNames = computed(() => {
if (!roleDetail.value.rights) return [];
const rightIds = parseRights(roleDetail.value.rights);
const menuMap = getMenuMap(allMenus.value);
return rightIds
.map((id) => menuMap.get(id))
.filter((name) => name !== undefined) as string[];
});
// 加载角色详情
const loadRoleDetail = async () => {
if (!props.roleId) return;
loading.value = true;
try {
const res = await getRoleById(props.roleId);
if (res.code === 200) {
roleDetail.value = res.data || {};
} else {
ElMessage.error(res.msg || "获取角色详情失败");
}
} catch (error) {
console.error("加载角色详情失败:", error);
ElMessage.error("获取角色详情失败");
} finally {
loading.value = false;
}
};
// 加载菜单树
const loadMenus = async () => {
try {
const res = await getAllMenus();
if (res.code === 200) {
allMenus.value = res.data || [];
}
} catch (error) {
console.error("加载菜单失败:", error);
}
};
// 关闭对话框
const handleClose = () => {
visible.value = false;
roleDetail.value = {};
};
</script>
<style scoped>
:deep(.el-descriptions__label) {
width: 120px;
}
</style>
+217
View File
@@ -0,0 +1,217 @@
<template>
<el-dialog
v-model="visible"
:title="isEdit ? '编辑角色' : '添加角色'"
width="600px"
@close="handleClose"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="角色名称" prop="name">
<el-input
v-model="form.name"
placeholder="请输入角色名称"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="权限设置" prop="rights">
<el-tree
ref="treeRef"
:data="menuTree"
show-checkbox
node-key="id"
:props="{ children: 'children', label: 'title' }"
:default-checked-keys="form.rights"
style="width: 100%; border: 1px solid var(--el-border-color); border-radius: 4px; padding: 10px; max-height: 400px; overflow-y: auto;"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
确定
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from "vue";
import { ElMessage } from "element-plus";
import { createRole, updateRole } from "@/api/role";
import { getAllMenus } from "@/api/menu";
interface Props {
modelValue: boolean;
role?: any;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
role: null,
});
const emit = defineEmits(["update:modelValue", "success"]);
const visible = ref(false);
const isEdit = ref(false);
const submitting = ref(false);
const formRef = ref();
const treeRef = ref();
const menuTree = ref<any[]>([]);
const form = ref({
name: "",
status: 1,
rights: [] as number[],
});
const rules = {
name: [
{ required: true, message: "请输入角色名称", trigger: "blur" },
{ min: 2, max: 50, message: "角色名称长度在 2 到 50 个字符", trigger: "blur" },
],
status: [{ required: true, message: "请选择状态", trigger: "change" }],
};
// 监听 modelValue
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (val) {
loadMenus();
if (props.role) {
isEdit.value = true;
form.value = {
name: props.role.name,
status: props.role.status,
rights: parseRights(props.role.rights),
};
// 等待树加载完成后设置选中状态
nextTick(() => {
if (treeRef.value) {
treeRef.value.setCheckedKeys(form.value.rights);
}
});
} else {
isEdit.value = false;
resetForm();
}
}
}
);
// 监听 visible
watch(visible, (val) => {
emit("update:modelValue", val);
});
// 解析权限字符串
const parseRights = (rights: any): number[] => {
if (!rights) return [];
if (Array.isArray(rights)) return rights;
if (typeof rights === "string") {
try {
const parsed = JSON.parse(rights);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
return [];
};
// 加载菜单树
const loadMenus = async () => {
try {
const res = await getAllMenus();
if (res.code === 200) {
menuTree.value = res.data || [];
}
} catch (error) {
console.error("加载菜单失败:", error);
ElMessage.error("加载菜单失败");
}
};
// 重置表单
const resetForm = () => {
form.value = {
name: "",
status: 1,
rights: [],
};
if (formRef.value) {
formRef.value.clearValidate();
}
if (treeRef.value) {
treeRef.value.setCheckedKeys([]);
}
};
// 关闭对话框
const handleClose = () => {
visible.value = false;
resetForm();
};
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
// 获取选中的菜单ID
const checkedKeys = treeRef.value.getCheckedKeys();
const halfCheckedKeys = treeRef.value.getHalfCheckedKeys();
const allCheckedKeys = [...checkedKeys, ...halfCheckedKeys];
const submitData = {
name: form.value.name,
status: form.value.status,
rights: allCheckedKeys,
};
submitting.value = true;
let res;
if (isEdit.value && props.role) {
res = await updateRole(props.role.id, submitData);
} else {
res = await createRole(submitData);
}
if (res.code === 200) {
ElMessage.success(isEdit.value ? "更新成功" : "创建成功");
emit("success");
handleClose();
} else {
ElMessage.error(res.msg || "操作失败");
}
} catch (error: any) {
if (error !== false) {
// 不是表单验证错误
ElMessage.error(error.message || "操作失败");
}
} finally {
submitting.value = false;
}
};
</script>
<style scoped>
:deep(.el-tree) {
background-color: var(--el-bg-color);
}
</style>
+226
View File
@@ -0,0 +1,226 @@
<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">
<el-icon><Refresh /></el-icon>
刷新</el-button
>
</div>
</div>
<el-divider></el-divider>
<!-- 错误状态 -->
<div v-if="error" class="error-state">
<el-alert title="加载失败" :message="error" type="error" show-icon />
<el-button type="primary" @click="refresh">重试</el-button>
</div>
<!-- 角色列表 -->
<div v-else>
<el-table :data="roles" stripe style="width: 100%" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column
prop="name"
label="角色名称"
min-width="150"
align="center"
/>
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? "启用" : "禁用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="rights"
label="权限"
min-width="150"
align="center"
/>
<el-table-column label="操作" width="220" fixed="right" align="center">
<template #default="{ row }">
<el-button
size="small"
type="primary"
link
@click="handleView(row)"
>
<el-icon><View /></el-icon>
详情
</el-button>
<el-button
v-if="row.default !== 1 && row.default !== 2"
size="small"
type="primary"
link
@click="handleEdit(row)"
>
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button
v-if="row.id !== 1"
size="small"
type="danger"
link
@click="handleDelete(row)"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 编辑对话框 -->
<RoleEditDialog
v-model="editDialogVisible"
:role="currentRole"
@success="handleEditSuccess"
/>
<!-- 详情对话框 -->
<RoleDetailDialog
v-model="detailDialogVisible"
:roleId="currentRoleId"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, View, Edit, Delete, Refresh } from "@element-plus/icons-vue";
import { getAllRoles, getRoleById, deleteRole } from "@/api/role";
import RoleEditDialog from "./components/edit.vue";
import RoleDetailDialog from "./components/detail.vue";
import { useAuthStore } from "@/stores/auth";
// 使用 auth store 获取用户信息
const authStore = useAuthStore();
const roles = ref<any[]>([]);
const loading = ref(false);
const error = ref("");
const editDialogVisible = ref(false);
const detailDialogVisible = ref(false);
const currentRole = ref<any>(null);
const currentRoleId = ref<number | null>(null);
// 获取用户信息
const userInfo = authStore.user;
if (userInfo && userInfo.id) {
// console.log('用户名:', userInfo.username || userInfo.nickname);
// console.log('用户ID:', userInfo.id);
// console.log('角色:', userInfo.role);
} else {
// console.log('未找到用户信息或用户未登录');
}
// 获取角色列表
const fetchRoles = async () => {
loading.value = true;
error.value = "";
try {
const res = await getAllRoles();
if (res.code === 200) {
roles.value = res.data || [];
console.log('角色列表:', roles.value);
} else {
error.value = res.msg || "获取角色列表失败";
ElMessage.error(error.value);
}
} catch (err: any) {
error.value = err.message || "获取角色列表失败";
ElMessage.error(error.value);
} finally {
loading.value = false;
}
};
// 刷新界面
async function refresh() {
await fetchRoles();
if (!error.value) {
ElMessage.success("刷新成功");
}
}
// 添加角色
function handleAdd() {
currentRole.value = null;
editDialogVisible.value = true;
}
// 查看详情
function handleView(row: any) {
currentRoleId.value = row.id;
detailDialogVisible.value = true;
}
// 编辑角色
function handleEdit(row: any) {
currentRole.value = { ...row };
editDialogVisible.value = true;
}
// 删除角色
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(
`确定要删除角色「${row.name}」吗?删除后不可恢复。`,
"警告",
{ type: "warning" }
);
loading.value = true;
try {
const res = await deleteRole(row.id);
if (res.code === 200) {
ElMessage.success("删除成功");
fetchRoles();
} else {
ElMessage.error(res.msg || "删除失败");
}
} catch (err: any) {
ElMessage.error(err.message || "删除失败");
} finally {
loading.value = false;
}
} catch {
// 取消删除
}
}
// 编辑成功回调
function handleEditSuccess() {
fetchRoles();
}
onMounted(() => {
fetchRoles();
});
</script>
<style scoped>
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 220px;
padding: 32px 0 16px 0;
background: var(--el-bg-color-page);
border-radius: 5px;
gap: 16px;
}
</style>
@@ -0,0 +1,78 @@
<template>
<el-form
ref="contactFormRef"
:model="contactForm"
:rules="contactRules"
label-width="120px"
style="max-width: 600px"
>
<el-form-item label="联系电话" prop="phone">
<el-input
v-model="contactForm.phone"
placeholder="请输入联系电话"
/>
</el-form-item>
<el-form-item label="联系邮箱" prop="email">
<el-input
v-model="contactForm.email"
placeholder="请输入联系邮箱"
/>
</el-form-item>
<el-form-item label="公司地址" prop="address">
<el-input
v-model="contactForm.address"
placeholder="请输入公司地址"
/>
</el-form-item>
<el-form-item label="工作时间" prop="workTime">
<el-input
v-model="contactForm.workTime"
placeholder="如:周一至周五 9:00-18:00"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveContactSettings">保存设置</el-button>
<el-button @click="resetContactForm">重置</el-button>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { ref, reactive } from "vue";
import { ElMessage } from "element-plus";
import type { FormInstance, FormRules } from "element-plus";
const contactFormRef = ref<FormInstance>();
const contactForm = reactive({
phone: "",
email: "",
address: "",
workTime: ""
});
const contactRules: FormRules = {
email: [{ type: "email", message: "请输入正确的邮箱地址", trigger: "blur" }]
};
const saveContactSettings = async () => {
if (!contactFormRef.value) return;
await contactFormRef.value.validate((valid) => {
if (valid) {
// TODO: 保存联系方式
ElMessage.success("联系方式保存成功");
}
});
};
const resetContactForm = () => {
contactForm.phone = "";
contactForm.email = "";
contactForm.address = "";
contactForm.workTime = "";
};
defineExpose({
contactForm
});
</script>
@@ -0,0 +1,91 @@
<template>
<el-form
ref="legalNoticeFormRef"
:model="formData"
label-width="120px"
style="max-width: 600px"
>
<el-form-item label="法律声明" prop="legalNotice">
<el-input
v-model="legalNotice"
type="textarea"
:rows="8"
placeholder="请输入法律声明"
/>
</el-form-item>
<el-form-item label="隐私条款" prop="privacyTerms">
<el-input
v-model="privacyTerms"
type="textarea"
:rows="8"
placeholder="请输入隐私条款"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveLegalInfos">保存设置</el-button>
<el-button @click="resetLegalNoticeForm">重置</el-button>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue";
import { ElMessage } from "element-plus";
import type { FormInstance, FormRules } from "element-plus";
import { getLegalInfos, saveLegalInfos } from "@/api/sitesettings";
const legalNoticeFormRef = ref<FormInstance>();
const legalNotice = ref("");
const privacyTerms = ref("");
const seoForm = reactive({
legalNotice: "",
privacyTerms: "",
});
const formData = {
legalNotice,
privacyTerms,
};
//调用法律声明和隐私条款数据
const initLegalInfos = async () => {
const res = await getLegalInfos();
if (res.code === 200 && res.data) {
const data = res.data;
const dataMap: Record<string, string> = {};
data.forEach((item: any) => {
dataMap[item.label] = item.value;
});
legalNotice.value = dataMap["legalNotice"] || "";
privacyTerms.value = dataMap["privacyTerms"] || "";
}
};
const saveLegalInfos = async () => {
if (!legalNoticeFormRef.value) return;
await legalNoticeFormRef.value.validate((valid) => {
if (valid) {
// TODO: 保存法律声明和隐私条款
ElMessage.success("法律声明和隐私条款保存成功");
}
});
};
const resetLegalNoticeForm = () => {
legalNotice.value = "";
privacyTerms.value = "";
};
defineExpose({
legalNotice,
privacyTerms,
});
// 初始化法律声明和隐私条款数据
onMounted(() => {
initLegalInfos();
});
</script>
@@ -0,0 +1,195 @@
<template>
<el-form
ref="normalFormRef"
:model="formData"
:rules="normalRules"
label-width="120px"
style="max-width: 600px"
>
<el-form-item label="站点名称" prop="sitename">
<el-input v-model="sitename" placeholder="请输入站点名称" />
</el-form-item>
<el-form-item label="站点Logo" prop="logo">
<el-upload
class="logo-uploader"
:show-file-list="false"
:auto-upload="false"
:on-change="handleLogoChange"
>
<img
v-if="logo"
:src="API_BASE_URL + logo.replace(/^\//, '/')"
class="logo-image"
/>
<el-icon v-else class="logo-uploader-icon"><Plus /></el-icon>
</el-upload>
</el-form-item>
<el-form-item label="企业名称" prop="companyname">
<el-input v-model="companyname" placeholder="请输入企业名称" />
</el-form-item>
<el-form-item label="站点SEO描述" prop="description">
<el-input v-model="description" placeholder="请输入站点SEO描述" />
</el-form-item>
<el-form-item label="版权信息" prop="copyright">
<el-input v-model="copyright" placeholder="如:© 2026 公司名称" />
</el-form-item>
<el-form-item label="备案号 " prop="icp">
<el-input v-model="icp" placeholder="如:苏ICP备20260000号" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSaveNormalInfos"
>保存设置</el-button
>
<el-button @click="resetnormalForm">重置</el-button>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { ElMessage } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import type { FormInstance, FormRules, UploadFile } from "element-plus";
import { getNormalInfos, saveNormalInfos } from "@/api/sitesettings";
import { uploadFile } from "@/api/file";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const normalFormRef = ref<FormInstance>();
const sitename = ref("");
const companyname = ref("");
const logo = ref("");
const description = ref("");
const copyright = ref("");
const icp = ref("");
const formData = {
sitename,
companyname,
logo,
description,
copyright,
icp,
};
const normalRules: FormRules = {
sitename: [{ required: true, message: "请输入站点名称", trigger: "blur" }],
};
//调用基础数据
const initNormalInfos = async () => {
const res = await getNormalInfos();
if (res.code === 200 && res.data) {
const data = res.data;
const dataMap: Record<string, string> = {};
data.forEach((item: any) => {
dataMap[item.label] = item.value;
});
sitename.value = dataMap["sitename"] || "";
logo.value = dataMap["logo"] || "";
description.value = dataMap["description"] || "";
copyright.value = dataMap["copyright"] || "";
icp.value = dataMap["icp"] || "";
companyname.value = dataMap["companyname"] || "";
}
};
const handleLogoChange = (file: UploadFile) => {
const uploadFormData = new FormData();
uploadFormData.append("file", file.raw);
uploadFormData.append("cate", "site");
uploadFile(uploadFormData).then((uploadRes) => {
if (
(uploadRes.code === 200 || uploadRes.code === 201) &&
uploadRes.data &&
uploadRes.data.url
) {
logo.value = uploadRes.data.url.replace(/\\/g, "/");
} else {
ElMessage.error(uploadRes.msg || "上传失败");
}
});
};
const handleSaveNormalInfos = async () => {
if (!normalFormRef.value) return;
await normalFormRef.value.validate(async (valid) => {
if (valid) {
const data = [
{ label: "sitename", value: sitename.value },
{ label: "logo", value: logo.value },
{ label: "description", value: description.value },
{ label: "companyname", value: companyname.value },
{ label: "copyright", value: copyright.value },
{ label: "icp", value: icp.value },
];
const res = await saveNormalInfos(data);
if (res.code === 200) {
ElMessage.success("保存成功");
} else {
ElMessage.error(res.msg || "保存失败");
}
}
});
};
const resetnormalForm = () => {
sitename.value = "";
companyname.value = "";
logo.value = "";
description.value = "";
copyright.value = "";
icp.value = "";
};
defineExpose({
sitename,
companyname,
logo,
description,
copyright,
icp,
});
// 初始化基础数据
onMounted(() => {
initNormalInfos();
});
</script>
<style lang="scss" scoped>
.logo-uploader {
:deep(.el-upload) {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: all 0.3s;
&:hover {
border-color: #409eff;
}
}
.logo-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 100px;
height: 100px;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.logo-image {
width: 100px;
height: 100px;
object-fit: contain;
display: block;
}
}
</style>
@@ -0,0 +1,62 @@
<template>
<el-form
ref="otherFormRef"
:model="otherForm"
label-width="120px"
style="max-width: 600px"
>
<el-form-item label="开启维护模式">
<el-switch v-model="otherForm.maintenanceMode" />
<span class="form-tip">开启后前台将显示维护中页面</span>
</el-form-item>
<el-form-item label="开启注册">
<el-switch v-model="otherForm.allowRegister" />
<span class="form-tip">允许用户在前台注册账号</span>
</el-form-item>
<el-form-item label="验证码">
<el-switch v-model="otherForm.captchaEnabled" />
<span class="form-tip">登录注册等操作需要验证码</span>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveOtherSettings">保存设置</el-button>
<el-button @click="resetOtherForm">重置</el-button>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { ref, reactive } from "vue";
import { ElMessage } from "element-plus";
import type { FormInstance } from "element-plus";
const otherFormRef = ref<FormInstance>();
const otherForm = reactive({
maintenanceMode: false,
allowRegister: false,
captchaEnabled: true
});
const saveOtherSettings = () => {
// TODO: 保存其他设置
ElMessage.success("其他设置保存成功");
};
const resetOtherForm = () => {
otherForm.maintenanceMode = false;
otherForm.allowRegister = false;
otherForm.captchaEnabled = true;
};
defineExpose({
otherForm
});
</script>
<style lang="scss" scoped>
.form-tip {
margin-left: 10px;
font-size: 12px;
color: #909399;
}
</style>
@@ -0,0 +1,72 @@
<template>
<el-form
ref="seoFormRef"
:model="seoForm"
:rules="seoRules"
label-width="120px"
style="max-width: 600px"
>
<el-form-item label="SEO标题" prop="seoTitle">
<el-input
v-model="seoForm.seoTitle"
placeholder="请输入SEO标题"
/>
</el-form-item>
<el-form-item label="SEO关键词" prop="seoKeywords">
<el-input
v-model="seoForm.seoKeywords"
placeholder="多个关键词用逗号分隔"
/>
</el-form-item>
<el-form-item label="SEO描述" prop="seoDescription">
<el-input
v-model="seoForm.seoDescription"
type="textarea"
:rows="3"
placeholder="请输入SEO描述"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveSeoSettings">保存设置</el-button>
<el-button @click="resetSeoForm">重置</el-button>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { ref, reactive } from "vue";
import { ElMessage } from "element-plus";
import type { FormInstance, FormRules } from "element-plus";
const seoFormRef = ref<FormInstance>();
const seoForm = reactive({
seoTitle: "",
seoKeywords: "",
seoDescription: ""
});
const seoRules: FormRules = {
seoTitle: [{ required: true, message: "请输入SEO标题", trigger: "blur" }]
};
const saveSeoSettings = async () => {
if (!seoFormRef.value) return;
await seoFormRef.value.validate((valid) => {
if (valid) {
// TODO: 保存SEO设置
ElMessage.success("SEO设置保存成功");
}
});
};
const resetSeoForm = () => {
seoForm.seoTitle = "";
seoForm.seoKeywords = "";
seoForm.seoDescription = "";
};
defineExpose({
seoForm
});
</script>
+109
View File
@@ -0,0 +1,109 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>站点设置</h2>
</div>
<el-divider></el-divider>
<div class="settings-container">
<el-tabs v-model="activeTab" class="settings-tabs">
<el-tab-pane label="基本信息" name="basic">
<normalSettings ref="normalSettingsRef" />
</el-tab-pane>
<el-tab-pane label="SEO设置" name="seo">
<seoSettings ref="seoSettingsRef" />
</el-tab-pane>
<el-tab-pane label="联系方式" name="contact">
<contactSettings ref="contactSettingsRef" />
</el-tab-pane>
<el-tab-pane label="法律声明&隐私条款" name="legalNotice">
<legalNoticeSettings ref="legalNoticeSettingsRef" />
</el-tab-pane>
<el-tab-pane label="其他设置" name="other">
<otherSettings ref="otherSettingsRef" />
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { ElMessage } from "element-plus";
import normalSettings from "./components/normalSettings.vue";
import seoSettings from "./components/seoSettings.vue";
import contactSettings from "./components/contactSettings.vue";
import otherSettings from "./components/otherSettings.vue";
import legalNoticeSettings from "./components/legalNotice.vue";
const activeTab = ref("basic");
const normalSettingsRef = ref();
const seoSettingsRef = ref();
const contactSettingsRef = ref();
const otherSettingsRef = ref();
const legalNoticeSettingsRef = ref();
// 初始化各标签页数据
const initSettings = async () => {
// TODO: 从后端获取各设置数据并赋值给对应组件
if (normalSettingsRef.value) {
// normalSettingsRef.value.normalinfos = ...
}
if (seoSettingsRef.value) {
// seoSettingsRef.value.seoForm = ...
}
if (contactSettingsRef.value) {
// contactSettingsRef.value.contactForm = ...
}
if (otherSettingsRef.value) {
// otherSettingsRef.value.otherForm = ...
}
if (legalNoticeSettingsRef.value) {
// legalNoticeSettingsRef.value.legalNoticeForm = ...
}
};
onMounted(() => {
initSettings();
});
</script>
<style lang="scss" scoped>
.container-box {
padding: 20px;
border-radius: 4px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--el-text-color-primary);
h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
}
.settings-container {
margin-top: 20px;
}
.settings-tabs {
:deep(.el-tabs__header) {
margin-bottom: 20px;
}
:deep(.el-tabs__nav-wrap::after) {
height: 1px;
}
}
</style>
@@ -0,0 +1,198 @@
<template>
<el-dialog v-model="visible" title="修改密码" width="400px" @close="handleClose">
<el-form :model="form">
<!-- 用户账号只读 -->
<el-form-item label="账号">
<el-input v-model="form.username" disabled />
</el-form-item>
<!-- 新密码 -->
<el-form-item label="新密码">
<el-input
v-model="passwordForm.newPassword"
type="password"
autocomplete="new-password"
show-password
placeholder="请输入新密码(6-16位)"
/>
</el-form-item>
<!-- 确认密码 -->
<el-form-item label="确认密码">
<el-input
v-model="passwordForm.confirmPassword"
type="password"
autocomplete="new-password"
show-password
placeholder="请再次输入新密码"
/>
</el-form-item>
<!-- 错误提示 -->
<el-form-item v-if="passwordError">
<el-alert :title="passwordError" type="error" :closable="false" style="color: #f56c6c;" />
</el-form-item>
</el-form>
<!-- 对话框脚部 -->
<template #footer>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定修改</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { changePassword } from "@/api/user";
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
userId: {
type: Number,
default: null,
},
});
const emit = defineEmits(['update:modelValue', 'submit', 'close']);
const visible = ref(false);
const passwordError = ref("");
const form = ref<any>({
id: null,
username: "",
});
const passwordForm = ref<any>({
newPassword: "",
confirmPassword: "",
});
// 监听 modelValue
watch(() => props.modelValue, (newVal) => {
visible.value = newVal;
if (newVal && props.userId) {
form.value.id = props.userId;
}
});
// 监听 userId 变化
watch(() => props.userId, (newVal) => {
if (newVal) {
form.value.id = newVal;
}
});
// 监听 visible 变化
watch(visible, (newVal) => {
if (!newVal) {
emit('update:modelValue', false);
}
});
// 校验密码
const validatePassword = (password: string) => {
if (!password) {
return "请输入密码";
}
if (password.length < 6) {
return "密码长度不能小于6位";
}
if (password.length > 16) {
return "密码长度不能大于16位";
}
return true;
};
// 校验确认密码
const validateConfirmPassword = (password: string, confirmPassword: string) => {
if (!confirmPassword) {
return "请再次输入密码";
}
if (confirmPassword !== password) {
return "两次输入的密码不一致";
}
return true;
};
const handleCancel = () => {
visible.value = false;
};
const handleClose = () => {
passwordForm.value = {
newPassword: "",
confirmPassword: "",
};
passwordError.value = "";
emit('close');
};
const handleSubmit = async () => {
// 清除之前的错误
passwordError.value = "";
try {
if (!form.value.id) {
passwordError.value = "用户ID不能为空";
return;
}
// 校验新密码格式
const passwordCheck = validatePassword(passwordForm.value.newPassword);
if (passwordCheck !== true) {
passwordError.value = passwordCheck;
return;
}
// 校验确认密码
const confirmCheck = validateConfirmPassword(
passwordForm.value.newPassword,
passwordForm.value.confirmPassword
);
if (confirmCheck !== true) {
passwordError.value = confirmCheck;
return;
}
// 调用接口修改密码
const res = await changePassword(form.value.id, passwordForm.value);
if (res.code === 200 || res.msg === '修改成功') {
ElMessage.success("密码修改成功");
visible.value = false;
emit('update:modelValue', false);
emit('submit');
} else {
passwordError.value = res.msg || "密码修改失败";
}
} catch (e: any) {
const errorMsg = e?.response?.data?.msg || e?.response?.data?.message || e?.message || "操作失败";
passwordError.value = errorMsg;
}
};
// 暴露方法给父组件
defineExpose({
open: (userId: number, username: string) => {
form.value = {
id: userId,
username: username,
};
passwordForm.value = {
newPassword: "",
confirmPassword: "",
};
passwordError.value = "";
visible.value = true;
},
});
</script>
<style lang="less" scoped>
</style>
@@ -0,0 +1,190 @@
<template>
<el-drawer v-model="visible" title="用户信息预览" size="50%">
<div class="user-preview" v-if="user">
<div class="user-header">
<div class="user-avatar">
<el-avatar :size="80" :icon="UserFilled" />
</div>
<h2 class="user-name">{{ user.name || "未知用户" }}</h2>
<el-tag :type="user.status === 1 ? 'success' : 'danger'" size="large">
{{ user.status === 1 ? "启用" : "禁用" }}
</el-tag>
</div>
<el-divider />
<div class="user-info">
<el-descriptions :column="2" border>
<el-descriptions-item label="ID">
{{ user.id }}
</el-descriptions-item>
<el-descriptions-item label="账号">
{{ user.account || "未设置" }}
</el-descriptions-item>
<el-descriptions-item label="姓名">
{{ user.name || "未设置" }}
</el-descriptions-item>
<el-descriptions-item label="性别">
{{ user.sex === 1 ? "男" : "女" }}
</el-descriptions-item>
<el-descriptions-item label="手机号">
{{ user.phone || "未设置" }}
</el-descriptions-item>
<el-descriptions-item label="QQ">
{{ user.qq || "未设置" }}
</el-descriptions-item>
<el-descriptions-item label="邮箱">
{{ user.email || "未设置" }}
</el-descriptions-item>
<el-descriptions-item label="角色">
<el-tag :type="getRoleTagType(user.group_id)" size="small">
{{ getRoleName(user.group_id) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最后登录IP">
{{ user.last_login_ip || "未登录" }}
</el-descriptions-item>
<el-descriptions-item label="登录次数">
{{ user.login_count || 0 }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ user.create_time || "未知" }}
</el-descriptions-item>
<el-descriptions-item label="更新时间" :span="2">
{{ user.update_time || "未知" }}
</el-descriptions-item>
</el-descriptions>
</div>
</div>
<div v-else class="no-user">
<el-empty description="暂无用户信息" />
</div>
</el-drawer>
</template>
<script lang="ts" setup>
import { ref, watch } from "vue";
import { UserFilled } from "@element-plus/icons-vue";
import { getAllRoles } from "@/api/role";
interface User {
id: number;
account: string;
name: string;
phone: string;
qq: string;
email: string;
sex: number;
group_id: number;
status: number;
last_login_ip: string;
login_count: number;
create_time: string;
update_time: string;
}
interface Role {
id: number;
name: string;
}
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
user: {
type: Object as () => User | undefined,
default: undefined,
},
});
const emit = defineEmits(["update:modelValue"]);
const visible = ref(false);
const roles = ref<Role[]>([]);
// 监听对话框显示状态
watch(
() => props.modelValue,
(newVal) => {
visible.value = newVal;
}
);
// 监听visible变化,同步给父组件
watch(visible, (newVal) => {
emit("update:modelValue", newVal);
});
// 获取角色列表
const fetchRoles = async () => {
try {
const res = await getAllRoles();
roles.value = res.data || [];
} catch (e) {
roles.value = [];
}
};
// 获取角色tag状态
function getRoleTagType(group_id: number): string {
const typeMap: Record<number, string> = {
1: "primary",
2: "success",
3: "warning",
4: "danger",
};
return typeMap[group_id] || "primary";
}
// 获取角色名称
function getRoleName(group_id: number): string {
const role = roles.value.find((r) => r.id === group_id);
return role?.name || "未知";
}
// 暴露open方法供父组件调用
const open = (userData?: User) => {
visible.value = true;
fetchRoles();
};
defineExpose({
open,
});
// 初始化时获取角色列表
fetchRoles();
</script>
<style lang="scss" scoped>
.user-preview {
padding: 20px;
.user-header {
text-align: center;
padding: 20px 0;
.user-avatar {
margin-bottom: 15px;
}
.user-name {
margin: 15px 0;
font-size: 24px;
font-weight: 600;
color: #303133;
}
}
.user-info {
margin-top: 20px;
}
}
.no-user {
padding: 40px 0;
text-align: center;
}
</style>
@@ -0,0 +1,408 @@
<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="账号">
<el-input v-model="form.account" :disabled="!isAdd" placeholder="请输入账号" />
</el-form-item>
<!-- 密码 -->
<el-form-item label="密码" prop="password" v-if="isAdd">
<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">
<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="姓名">
<el-input v-model="form.name" placeholder="请输入姓名" />
</el-form-item>
<!-- 电话 -->
<el-form-item label="电话">
<el-input v-model="form.phone" placeholder="请输入电话" />
</el-form-item>
<!-- 邮箱 -->
<el-form-item label="邮箱">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
<!-- QQ -->
<el-form-item label="QQ">
<el-input v-model="form.qq" placeholder="请输入QQ" />
</el-form-item>
<!-- 性别 -->
<el-form-item label="性别">
<el-radio-group v-model="form.sex" placeholder="请选择性别">
<el-radio-button label="男" :value="1" />
<el-radio-button label="女" :value="2" />
</el-radio-group>
</el-form-item>
<!-- 状态 -->
<el-form-item label="状态">
<el-select
v-model="form.status"
placeholder="请选择状态"
style="width: 100%"
>
<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 } from "vue";
import { ElMessage } from "element-plus";
import { addUser, editUser, getUserInfo } from "@/api/user";
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 form = ref<any>({
id: null,
account: "",
name: "",
phone: "",
qq: "",
sex: 1,
password: "",
confirmPassword: "",
email: "",
status: 1,
});
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 getUserInfo(userId);
const data = res.data || res;
// 确保 sex 和 status 都是数字类型
const sexValue =
data.sex !== undefined && data.sex !== null
? Number(data.sex)
: 1;
const statusValue =
data.status !== undefined && data.status !== null
? Number(data.status)
: 1;
form.value = {
id: data.id,
account: data.account,
name: data.name,
phone: data.phone,
qq: data.qq,
sex: sexValue,
password: "",
confirmPassword: "",
email: data.email,
status: statusValue,
};
} 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 = {
account: form.value.account,
name: form.value.name,
phone: form.value.phone,
qq: form.value.qq,
sex: form.value.sex,
email: form.value.email,
status: form.value.status,
password: form.value.password,
};
await addUser(submitData);
ElMessage.success("添加成功");
} else {
// 编辑用户
if (!form.value.id || form.value.id === 0) {
ElMessage.error("用户ID不能为空");
return;
}
const submitData: any = {
id: form.value.id,
account: form.value.account,
name: form.value.name,
phone: form.value.phone,
qq: form.value.qq,
sex: form.value.sex,
email: form.value.email,
status: form.value.status,
};
// 只有在填写了密码时才添加到提交数据中
if (form.value.password) {
submitData.password = form.value.password;
}
await editUser(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,
account: "",
name: "",
phone: "",
qq: "",
sex: 1,
password: "",
confirmPassword: "",
email: "",
status: 1,
};
visible.value = true;
// 清除表单验证
if (formRef.value) {
formRef.value.clearValidate();
}
},
openEdit: (user: any) => {
isAdd.value = false;
visible.value = true;
// 清除表单验证
if (formRef.value) {
formRef.value.clearValidate();
}
// 异步加载用户详细信息
loadUserData(user);
},
open: (user?: any) => {
if (user) {
isAdd.value = false;
loadUserData(user);
} else {
isAdd.value = true;
form.value = {
id: 0,
account: "",
name: "",
phone: "",
qq: "",
sex: 1,
password: "",
confirmPassword: "",
email: "",
status: 1,
};
}
visible.value = true;
// 清除表单验证
if (formRef.value) {
formRef.value.clearValidate();
}
},
});
</script>
<style lang="scss" scoped>
.form-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 20px;
}
</style>
+342
View File
@@ -0,0 +1,342 @@
<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"
/>
<el-table-column prop="account" label="账号" align="center" />
<el-table-column
prop="name"
label="姓名"
align="center"
>
<template #default="scope">
<span class="name-link" @click="handlePreview(scope.row)">{{ scope.row.name }}</span>
</template>
</el-table-column>
<el-table-column prop="group_id" label="角色" align="center">
<template #default="scope">
<el-tag
:type="getRoleTagType(scope.row.group_id)"
size="small"
>
{{ getRoleTagText(roles, scope.row.group_id) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="phone"
label="手机号"
align="center"
/>
<el-table-column prop="qq" label="QQ" align="center" />
<el-table-column
prop="last_login_ip"
label="最后登录IP"
width="120"
align="center"
/>
<el-table-column
prop="login_count"
label="登陆次数"
width="120"
align="center"
/>
<el-table-column prop="status" label="状态" width="80" 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 label="操作" width="240" align="center" fixed="right">
<template #default="scope">
<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>
<!-- 编辑用户对话框组件 -->
<UserEditDialog
ref="userEditRef"
:modelValue="editDialogVisible"
@update:modelValue="editDialogVisible = $event"
:is-edit="isEdit"
@submit="handleEditSuccess"
@close="editDialogVisible = false"
/>
<!-- 修改密码对话框组件 -->
<ChangePasswordDialog
ref="changePasswordRef"
:modelValue="passwordDialogVisible"
@update:modelValue="passwordDialogVisible = $event"
:user-id="currentUserId"
@submit="handlePasswordChangeSuccess"
@close="passwordDialogVisible = false"
/>
<!-- 预览用户对话框组件 -->
<PreviewDialog
ref="previewDialogRef"
:modelValue="previewDialogVisible"
@update:modelValue="previewDialogVisible = $event"
:user="currentUser"
/>
</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 { getAllUsers, deleteUser } from "@/api/user";
import { getAllRoles } from "@/api/role";
import UserEditDialog from "./components/userEdit.vue";
import ChangePasswordDialog from "./components/changePassword.vue";
import PreviewDialog from "./components/preview.vue";
interface User {
id: number;
account: string;
name: string;
phone: string;
qq: string;
sex: number;
group_id: number;
status: number;
last_login_ip: string;
login_count: number;
create_time: string;
update_time: string;
}
interface Role {
id: number;
name: string;
status?: number;
rights?: string;
create_time?: string;
update_time?: string;
}
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);
const users = ref<User[]>([]);
const roles = ref<Role[]>([]);
const loading = ref(false);
// 组件引用
const userEditRef = ref();
const changePasswordRef = ref();
const previewDialogRef = ref();
// 编辑/密码对话框状态
const editDialogVisible = ref(false);
const passwordDialogVisible = ref(false);
const previewDialogVisible = ref(false);
const editDialogTitle = ref("添加用户");
const isEdit = ref(false);
const currentUserId = ref<number | undefined>(undefined);
const currentUser = ref<User | undefined>(undefined);
//刷新
const refresh = async () => {
await fetchUsers();
};
// 获取角色列表
const fetchRoles = async () => {
try {
const res = await getAllRoles();
roles.value = res.data || [];
} catch (e) {
roles.value = [];
}
};
// 获取角色tag状态
function getRoleTagType(group_id: number): string {
const typeMap: Record<number, string> = {
1: "primary",
2: "success",
3: "warning",
4: "danger",
};
return typeMap[group_id] || "primary";
}
// 获取角色tag文本
function getRoleTagText(roles: Role[] | undefined, group_id: number): string {
if (!roles || !Array.isArray(roles)) {
return "未知";
}
return roles.find((role) => role.id === group_id)?.name || "未知";
}
// 获取用户列表
const fetchUsers = async () => {
loading.value = true;
try {
const res = await getAllUsers();
users.value = res.data.list;
} catch (e) {
users.value = [];
} finally {
loading.value = false;
}
};
// 添加用户
const handleAddUser = () => {
isEdit.value = false;
editDialogVisible.value = true;
if (userEditRef.value) {
userEditRef.value.open();
}
};
// 编辑用户
const handleEdit = (user: User) => {
isEdit.value = true;
editDialogVisible.value = true;
if (userEditRef.value) {
userEditRef.value.open(user);
}
};
// 预览用户
const handlePreview = (user: User) => {
currentUser.value = user;
previewDialogVisible.value = true;
if (previewDialogRef.value) {
previewDialogRef.value.open(user);
}
};
//修改密码
const handleChangePassword = async (user: User) => {
changePasswordRef.value.open(user.id, user.account);
passwordDialogVisible.value = true;
currentUserId.value = user.id;
};
// 编辑成功回调
const handleEditSuccess = () => {
editDialogVisible.value = false;
ElMessage.success(isEdit.value ? "编辑成功" : "添加成功");
fetchUsers();
};
// 密码修改成功回调
const handlePasswordChangeSuccess = () => {
passwordDialogVisible.value = false;
ElMessage.success("密码修改成功");
};
// 分页改变
const handlePageChange = (val: number) => {
page.value = val;
fetchUsers();
};
// 删除用户
const handleDelete = async (user: User) => {
ElMessageBox.confirm("确认删除该用户?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}).then(async () => {
try {
await deleteUser(user.id);
ElMessage.success("删除成功");
fetchUsers();
} catch (e) {
ElMessage.error("删除失败");
}
});
};
onMounted(async () => {
fetchUsers();
fetchRoles();
});
</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: #409eff;
cursor: pointer;
text-decoration: none;
transition: color 0.3s;
&:hover {
color: #66b1ff;
text-decoration: underline;
}
}
</style>