first commit

This commit is contained in:
2026-06-03 10:09:03 +08:00
commit 81f5039458
6266 changed files with 188236 additions and 0 deletions
@@ -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="tid" label="租户ID" width="100" align="center">
<template #default="{ row }">
<span>{{ row.tid || 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>
+248
View File
@@ -0,0 +1,248 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>邮箱管理</h2>
</div>
<el-divider />
<div class="content-box">
<el-form
ref="emailFormRef"
:model="emailForm"
:rules="emailRules"
label-width="120px"
class="email-form"
>
<el-form-item label="发件人邮箱" prop="fromAddress">
<el-input
v-model="emailForm.fromAddress"
placeholder="如:noreply@example.com"
clearable
/>
</el-form-item>
<el-form-item label="发件人名称" prop="fromName">
<el-input
v-model="emailForm.fromName"
placeholder="如:官方网站"
clearable
/>
</el-form-item>
<el-form-item label="SMTP 主机" prop="host">
<el-input
v-model="emailForm.host"
placeholder="如:smtp.qq.com"
clearable
/>
</el-form-item>
<el-form-item label="SMTP 端口" prop="port">
<el-input
v-model="emailForm.port"
placeholder="如:465 或 587"
clearable
/>
</el-form-item>
<!-- 用户名去掉,直接使用发件人邮箱作为登录账号 -->
<el-form-item label="授权码/密码" prop="password">
<el-input
v-model="emailForm.password"
type="password"
show-password
placeholder="邮箱授权码或登录密码"
clearable
/>
</el-form-item>
<el-form-item label="加密方式" prop="encryption">
<el-radio-group v-model="emailForm.encryption">
<el-radio label="ssl">SSL</el-radio>
<el-radio label="tls">TLS</el-radio>
<el-radio label="none">不加密</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="超时时间(秒)" prop="timeout">
<el-input
v-model="emailForm.timeout"
placeholder="默认 30 秒"
clearable
/>
</el-form-item>
<el-form-item label="测试收件邮箱">
<el-input
v-model="testEmail"
placeholder="输入一个邮箱地址用于测试发送"
clearable
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSave">
保存配置
</el-button>
<el-button @click="handleTest">
发送测试邮件
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from "vue";
import { ElMessage } from "element-plus";
import type { FormInstance, FormRules } from "element-plus";
import { getEmailInfo, editEmailInfo, sendTestEmail } from "@/api/email";
const emailFormRef = ref<FormInstance>();
/** 是否已有数据库中的配置(允许保存时不重复填写密码) */
const hasSavedConfig = ref(false);
const emailForm = reactive({
fromAddress: "",
fromName: "",
host: "",
port: "",
password: "",
encryption: "ssl",
timeout: 30
});
const validatePassword = (
_rule: unknown,
value: string,
callback: (e?: Error) => void
) => {
if (!hasSavedConfig.value && !String(value || "").trim()) {
callback(new Error("请输入授权码/密码"));
return;
}
callback();
};
const emailRules: FormRules = {
fromAddress: [
{ required: true, message: "请输入发件人邮箱", trigger: "blur" },
{ type: "email", message: "请输入正确的邮箱地址", trigger: "blur" }
],
host: [{ required: true, message: "请输入 SMTP 主机", trigger: "blur" }],
port: [{ required: true, message: "请输入 SMTP 端口", trigger: "blur" }],
password: [{ validator: validatePassword, trigger: "blur" }]
};
const testEmail = ref("");
const loadEmailConfig = async () => {
const res = await getEmailInfo();
if (res.code === 200 && Array.isArray(res.data) && res.data.length > 0) {
hasSavedConfig.value = true;
const item = res.data[0];
emailForm.fromAddress = item.from_address || "";
emailForm.fromName = item.from_name || "";
emailForm.host = item.host || "";
emailForm.port = item.port != null ? String(item.port) : "";
emailForm.password = item.password || "";
emailForm.encryption = item.encryption || "ssl";
emailForm.timeout = item.timeout != null ? item.timeout : 30;
} else {
hasSavedConfig.value = false;
}
};
const handleSave = async () => {
if (!emailFormRef.value) return;
try {
await emailFormRef.value.validate();
} catch {
return;
}
try {
const res = await editEmailInfo(emailForm);
if (res.code === 200) {
ElMessage.success("邮箱配置保存成功");
} else {
ElMessage.error(res.msg || "保存失败,请稍后重试");
}
} catch (error: any) {
ElMessage.error(error?.message || "保存失败,请稍后重试");
}
};
const handleTest = async () => {
if (!testEmail.value) {
ElMessage.warning("请先输入测试收件邮箱");
return;
}
if (!emailFormRef.value) return;
try {
await emailFormRef.value.validate();
} catch {
return;
}
try {
const res = await sendTestEmail({ ...emailForm, testEmail: testEmail.value });
if (res.code === 200) {
ElMessage.success("测试邮件发送成功");
} else {
ElMessage.error(res.msg || "测试邮件发送失败,请稍后重试");
}
} catch (error: any) {
ElMessage.error(error?.message || "测试邮件发送失败,请稍后重试");
}
};
const handleReset = () => {
emailForm.fromAddress = "";
emailForm.fromName = "";
emailForm.host = "";
emailForm.port = "";
emailForm.password = "";
emailForm.encryption = "ssl";
emailForm.timeout = 30;
testEmail.value = "";
emailFormRef.value?.clearValidate();
};
onMounted(async () => {
await loadEmailConfig();
});
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
}
.content-box {
margin-top: 20px;
}
.email-form {
max-width: 700px;
}
</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 !== undefined && props.categoryId !== null) {
formData.append("cate", String(props.categoryId));
}
// 模拟上传进度
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="less" 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: #3973ff;
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
+11
View File
@@ -0,0 +1,11 @@
<script setup>
</script>
<template>
<router-view />
</template>
<style scoped>
</style>
@@ -0,0 +1,491 @@
<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%"
>
<template #default="{ node, data }">
<span>{{ data.title }}</span>
<el-tag
v-if="node?.level === 1"
size="small"
:type="getMenuSideTagType(data)"
style="margin-left: 8px"
>
{{ getMenuSideLabel(data) }}
</el-tag>
</template>
</el-cascader>
</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="is_visible">
<el-switch
v-model="currentMenu.is_visible"
:active-value="1"
:inactive-value="0"
/>
</el-form-item>
<el-form-item label="菜单显示" prop="views">
<el-checkbox-group v-model="currentMenu.views" class="views-inline">
<el-checkbox :value="1" label="平台端" />
<el-checkbox :value="2" label="租户端" />
</el-checkbox-group>
</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;
is_visible: 0 | 1;
views: number[];
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,
is_visible: 1,
views: [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,
is_visible: 1,
views: [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,
is_visible: 1,
views: [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" }],
views: [{ required: true, message: "请选择菜单显示端", trigger: "change" }],
});
// 级联选择器配置
const cascaderProps = ref({
value: "id",
label: "title",
children: "children",
checkStrictly: true,
emitpath: false,
});
const getMenuSideLabel = (menu: any) => {
const views = Array.isArray(menu?.views) ? menu.views : [];
const hasP = views.includes(1);
const hasT = views.includes(2);
if (hasP && hasT) return "双端";
if (hasP) return "平台端";
if (hasT) return "租户端";
return "未设置";
};
const getMenuSideTagType = (menu: any) => {
const views = Array.isArray(menu?.views) ? menu.views : [];
const hasP = views.includes(1);
const hasT = views.includes(2);
if (hasP && hasT) return "success";
if (hasP) return "primary";
if (hasT) return "warning";
return "info";
};
// 监听菜单类型变化,自动清空不相关的字段
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;
}
.views-inline{
display: flex;
align-items: center;
gap: 14px;
}
</style>
+601
View File
@@ -0,0 +1,601 @@
<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="id"
width="60"
label="ID"
align="center"
></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="is_visible" label="是否显示" width="100" align="center">
<template #default="scope">
<el-tag :type="scope.row.is_visible === 1 ? 'success' : 'danger'">{{ scope.row.is_visible === 1 ? '显示' : '隐藏' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="views" label="菜单显示" width="180" align="center">
<template #default="scope">
<el-tag v-if="Array.isArray(scope.row.views) && scope.row.views.includes(1)" type="primary" style="margin-right:6px;">
平台端
</el-tag>
<el-tag v-if="Array.isArray(scope.row.views) && scope.row.views.includes(2)" type="warning">
租户端
</el-tag>
<el-tag v-if="!Array.isArray(scope.row.views) || scope.row.views.length === 0" type="info">
未设置
</el-tag>
</template>
</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;
is_visible?: 0 | 1;
views?: number[];
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;
// 菜单树递归排序:按 sort 升序,sort 相同按 id 升序
function sortMenuTree(menus: Menu[]): Menu[] {
return menus
.map((item) => ({
...item,
children: item.children ? sortMenuTree(item.children) : [],
}))
.sort((a, b) => {
const sortA = Number(a.sort ?? 999999);
const sortB = Number(b.sort ?? 999999);
if (sortA === sortB) {
return Number(a.id ?? 0) - Number(b.id ?? 0);
}
return sortA - sortB;
});
}
const fetchMenus = async () => {
if (fetchMenusPromise) {
return fetchMenusPromise;
}
loading.value = true;
fetchMenusPromise = (async () => {
try {
const result = await getAllMenus();
if (result.code === 200) {
const sortedMenus = sortMenuTree(result.data || []);
menuTree.value = sortedMenus;
parentMenuOptions.value = [
{
id: 0,
pid: 0,
title: "顶级菜单",
children: [],
} as Menu,
...sortedMenus,
];
} 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,
is_visible: 1,
is_platform: 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>
+489
View File
@@ -0,0 +1,489 @@
<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>
<el-table
:data="modules"
style="width: 100%"
v-loading="loading"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="mid" label="MID" width="80" align="center" />
<el-table-column
prop="name"
label="模块名称"
min-width="150"
align="center"
>
<template #default="{ row }">
<div class="module-name">
<span class="module-icon" v-html="row.icon"></span>
<span>{{ row.name }}</span>
</div>
</template>
</el-table-column>
<el-table-column
prop="code"
label="模块编码"
min-width="120"
align="center"
/>
<el-table-column
prop="path"
label="路由路径"
min-width="150"
align="center"
>
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.path || "-" }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="type" label="分类" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.type === 1 ? 'primary' : row.type === 2 ? 'warning' : 'info'">
{{ row.type === 1 ? '功能模块' : row.type === 2 ? '系统配置' : '未分类' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="description"
label="描述"
min-width="200"
align="center"
show-overflow-tooltip
/>
<el-table-column prop="sort" label="排序" width="100" align="center" />
<el-table-column prop="is_show" label="显示" width="80" align="center">
<template #default="{ row }">
<el-switch
v-model="row.is_show"
:active-value="1"
:inactive-value="0"
@change="handleShowChange(row)"
/>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? "启用" : "禁用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" 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="table-footer" v-if="selectedModules.length > 0">
<span>已选择 {{ selectedModules.length }} 项</span>
<el-button type="danger" size="small" @click="handleBatchDelete"
>批量删除</el-button
>
</div>
<el-divider></el-divider>
<div class="tips-section">
<el-alert title="模块管理说明" type="info" :closable="false" show-icon>
<template #default>
<p>1. 模块用于管理系统功能单元,每个模块包含独立的路由、图标和描述</p>
<p>2. 模块编码用于程序识别,请确保唯一性</p>
<p>3. 禁用状态的模块将不会在菜单中显示</p>
</template>
</el-alert>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '添加模块' : '编辑模块'"
width="600px"
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="100px"
>
<el-form-item label="模块名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入模块名称" />
</el-form-item>
<el-form-item label="模块编码" prop="code">
<el-input
v-model="formData.code"
placeholder="请输入模块编码(英文)"
:disabled="dialogType === 'edit'"
/>
</el-form-item>
<el-form-item label="路由路径" prop="path">
<el-input
v-model="formData.path"
placeholder="请输入路由路径,如 /system/modules"
/>
</el-form-item>
<el-form-item label="模块分类" prop="type">
<el-select v-model="formData.type" placeholder="请选择模块分类" style="width: 100%">
<el-option label="未分类" :value="0" />
<el-option label="功能模块" :value="1" />
<el-option label="系统配置" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="图标" prop="icon">
<el-input
v-model="formData.icon"
placeholder="请输入图标类名,<i class='fa-solid fa-warehouse'></i>"
>
<!-- <template #append>
<el-popover placement="bottom-end" :width="400" trigger="click">
<template #reference>
<el-button link>选择图标</el-button>
</template>
<div class="icon-grid">
<div
v-for="icon in iconList"
:key="icon"
class="icon-item"
:class="{ active: formData.icon === icon }"
@click="formData.icon = icon"
>
<el-icon :size="20"><component :is="icon" /></el-icon>
</div>
</div>
</el-popover>
</template> -->
</el-input>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="请输入模块描述"
/>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number
v-model="formData.sort"
:min="0"
:max="999"
controls-position="right"
/>
</el-form-item>
<el-form-item label="是否显示" prop="is_show">
<el-switch
v-model="formData.is_show"
:active-value="1"
:inactive-value="0"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading"
>确定</el-button
>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, shallowRef } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import {
getModulesList,
getModuleDetail,
addModule,
editModule,
deleteModule,
batchDeleteModules,
changeModuleStatus,
} from "@/api/modules";
const loading = ref(false);
const modules = ref([]);
const selectedModules = ref([]);
const dialogVisible = ref(false);
const dialogType = ref("add");
const formRef = ref(null);
const submitLoading = ref(false);
const formData = ref({
name: "",
code: "",
path: "",
type: 0,
icon: "",
description: "",
sort: 0,
is_show: 1,
status: 1,
});
const rules = {
name: [{ required: true, message: "请输入模块名称", trigger: "blur" }],
code: [{ required: true, message: "请输入模块编码", trigger: "blur" }],
};
function getIconComponent(iconName) {
return iconComponents.value[iconName] || Grid;
}
async function fetchModules() {
loading.value = true;
try {
const res = await getModulesList();
if (res.code === 200 && res.data) {
modules.value = res.data.list || [];
}
} catch (error) {
console.error("获取模块列表失败:", error);
ElMessage.error("获取模块列表失败");
} finally {
loading.value = false;
}
}
function refresh() {
fetchModules();
}
function handleSelectionChange(selection) {
selectedModules.value = selection;
}
function handleAdd() {
dialogType.value = "add";
formData.value = {
name: "",
code: "",
path: "",
type: 0,
icon: "",
description: "",
sort: 0,
is_show: 1,
status: 1,
};
dialogVisible.value = true;
}
async function handleEdit(row) {
dialogType.value = "edit";
try {
const res = await getModuleDetail(row.id);
if (res.code === 200 && res.data) {
formData.value = { ...res.data };
dialogVisible.value = true;
} else {
ElMessage.error(res.msg || "获取模块详情失败");
}
} catch (error) {
console.error("获取模块详情失败:", error);
ElMessage.error("获取模块详情失败");
}
}
async function handleSubmit() {
try {
await formRef.value.validate();
submitLoading.value = true;
if (dialogType.value === "add") {
const res = await addModule(formData.value);
if (res.code === 200) {
ElMessage.success("添加成功");
dialogVisible.value = false;
fetchModules();
} else {
ElMessage.error(res.msg || "添加失败");
}
} else {
const res = await editModule(formData.value.id, formData.value);
if (res.code === 200) {
ElMessage.success("编辑成功");
dialogVisible.value = false;
fetchModules();
} else {
ElMessage.error(res.msg || "编辑失败");
}
}
} catch (error) {
console.error("提交失败:", error);
} finally {
submitLoading.value = false;
}
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm("确定要删除该模块吗?", "提示", {
type: "warning",
});
const res = await deleteModule(row.id);
if (res.code === 200) {
ElMessage.success("删除成功");
fetchModules();
} else {
ElMessage.error(res.msg || "删除失败");
}
} catch (error) {
if (error !== "cancel") {
console.error("删除失败:", error);
ElMessage.error("删除失败");
}
}
}
async function handleBatchDelete() {
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${selectedModules.value.length} 个模块吗?`,
"提示",
{
type: "warning",
},
);
const ids = selectedModules.value.map((item) => item.id);
const res = await batchDeleteModules(ids);
if (res.code === 200) {
ElMessage.success("批量删除成功");
fetchModules();
} else {
ElMessage.error(res.msg || "批量删除失败");
}
} catch (error) {
if (error !== "cancel") {
console.error("批量删除失败:", error);
ElMessage.error("批量删除失败");
}
}
}
async function handleShowChange(row) {
try {
const res = await changeModuleStatus(row.id, row.is_show);
if (res.code !== 200) {
ElMessage.error(res.msg || "状态修改失败");
row.is_show = row.is_show ? 0 : 1;
}
} catch (error) {
console.error("状态修改失败:", error);
row.is_show = row.is_show ? 0 : 1;
}
}
onMounted(() => {
fetchModules();
});
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
background: var(--el-bg-color);
border-radius: 8px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.header-actions {
display: flex;
gap: 12px;
}
.module-name {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
.module-icon {
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: #667eea;
}
}
.table-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
}
.tips-section {
margin-top: 20px;
p {
margin: 4px 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.icon-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 8px;
max-height: 200px;
overflow-y: auto;
.icon-item {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
&:hover,
&.active {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
}
}
</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="less" 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>
@@ -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="less" 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>
@@ -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>
@@ -0,0 +1,182 @@
<template>
<div class="platform-settings">
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="120px"
class="settings-form"
>
<el-card shadow="never" class="settings-card">
<el-form-item label="开启验证">
<el-switch v-model="formData.openVerify_enabled" />
</el-form-item>
<el-form-item label="登录验证方式" prop="use_geetest">
<el-radio-group v-model="formData.use_geetest">
<el-radio label="captcha">验证码</el-radio>
<el-radio label="sms">短信</el-radio>
<el-radio label="geetest">极验</el-radio>
<el-radio label="email">邮箱</el-radio>
</el-radio-group>
</el-form-item>
<template v-if="formData.openVerify_enabled && formData.use_geetest === 'geetest'">
<el-form-item label="geetest3ID" prop="geetest3_id">
<el-input
v-model="formData.geetest3_id"
placeholder="请输入 geetest3ID"
/>
</el-form-item>
<el-form-item label="geetest3KEY" prop="geetest3_key">
<el-input
v-model="formData.geetest3_key"
placeholder="请输入 geetest3KEY"
/>
</el-form-item>
<el-form-item label="geetest4ID" prop="geetest4_id">
<el-input
v-model="formData.geetest4_id"
placeholder="请输入 geetest4ID"
/>
</el-form-item>
<el-form-item label="geetest4KEY" prop="geetest4_key">
<el-input
v-model="formData.geetest4_key"
placeholder="请输入 geetest4KEY"
/>
</el-form-item>
</template>
</el-card>
</el-form>
<div class="footer-actions">
<el-button @click="handleReset">重置</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit"
>保存设置</el-button
>
</div>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import { getVerifyInfos, saveVerifyInfos } from "@/api/sitesettings";
const STORAGE_KEY = "platform_settings_draft";
const formRef = ref();
const submitting = ref(false);
const formData = reactive({
openVerify_enabled: true,
use_geetest: "captcha",
geetest3_id: "",
geetest3_key: "",
geetest4_id: "",
geetest4_key: "",
});
const validateGeetestField = (_rule, value, callback) => {
if (formData.openVerify_enabled && formData.use_geetest === "geetest" && !String(value || "").trim()) {
callback(new Error("已启用极验时该参数不能为空"));
return;
}
callback();
};
const rules = {
geetest3_id: [{ validator: validateGeetestField, trigger: "blur" }],
geetest3_key: [{ validator: validateGeetestField, trigger: "blur" }],
geetest4_id: [{ validator: validateGeetestField, trigger: "blur" }],
geetest4_key: [{ validator: validateGeetestField, trigger: "blur" }],
};
const loadDraft = () => {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
try {
const data = JSON.parse(raw);
Object.assign(formData, data);
} catch {
// ignore invalid cache
}
};
const loadRemoteVerifyInfos = async () => {
try {
const res = await getVerifyInfos();
if (res?.code === 200 && res?.data) {
Object.assign(formData, {
openVerify_enabled: Number(res.data.openVerify_enabled ?? 1) === 1,
use_geetest: res.data.use_geetest || "captcha",
geetest3_id: res.data.geetest3_id || "",
geetest3_key: res.data.geetest3_key || "",
geetest4_id: res.data.geetest4_id || "",
geetest4_key: res.data.geetest4_key || "",
});
}
} catch {
// ignore remote errors
}
};
const saveDraft = () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(formData));
};
const handleReset = () => {
Object.assign(formData, {
openVerify_enabled: true,
use_geetest: "captcha",
geetest3_id: "",
geetest3_key: "",
geetest4_id: "",
geetest4_key: "",
});
saveDraft();
ElMessage.success("已重置");
};
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate();
submitting.value = true;
try {
await saveVerifyInfos({
openVerify_enabled: formData.openVerify_enabled ? 1 : 0,
use_geetest: formData.use_geetest,
geetest3_id: formData.geetest3_id,
geetest3_key: formData.geetest3_key,
geetest4_id: formData.geetest4_id,
geetest4_key: formData.geetest4_key,
});
saveDraft();
ElMessage.success("保存成功");
} finally {
submitting.value = false;
}
};
onMounted(() => {
loadDraft();
loadRemoteVerifyInfos();
});
</script>
<style scoped>
.platform-settings {
display: flex;
flex-direction: column;
gap: 16px;
}
.settings-card {
margin-bottom: 12px;
}
.footer-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>
@@ -0,0 +1,288 @@
<template>
<div class="storage-settings">
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="140px"
class="settings-form"
>
<el-card shadow="never" class="settings-card">
<template #header>
<div class="card-header">
<span>存储位置配置</span>
</div>
</template>
<el-form-item label="存储类型" prop="storage_type">
<el-radio-group v-model="formData.storage_type">
<el-radio label="local">本地存储</el-radio>
<el-radio label="qiniu">七牛云存储</el-radio>
</el-radio-group>
</el-form-item>
<template v-if="formData.storage_type === 'qiniu'">
<el-divider content-position="left">七牛云配置</el-divider>
<el-form-item label="AccessKey" prop="qiniu_access_key">
<el-input
v-model="formData.qiniu_access_key"
placeholder="请输入七牛云 AccessKey"
clearable
/>
</el-form-item>
<el-form-item label="SecretKey" prop="qiniu_secret_key">
<el-input
v-model="formData.qiniu_secret_key"
type="password"
placeholder="请输入七牛云 SecretKey"
show-password
clearable
/>
</el-form-item>
<el-form-item label="Bucket" prop="qiniu_bucket">
<el-input
v-model="formData.qiniu_bucket"
placeholder="请输入存储空间名称"
clearable
/>
</el-form-item>
<el-form-item label="CDN域名" prop="qiniu_domain">
<el-input
v-model="formData.qiniu_domain"
placeholder="请输入CDN加速域名,如:https://cdn.example.com"
clearable
>
<template #prepend>
<el-icon><Link /></el-icon>
</template>
</el-input>
<div class="form-tip">用于访问上传的文件,需要在七牛云控制台配置</div>
</el-form-item>
<el-form-item label="存储区域" prop="qiniu_region">
<el-select
v-model="formData.qiniu_region"
placeholder="请选择存储区域"
clearable
>
<el-option label="华东-浙江" value="z0" />
<el-option label="华北-河北" value="z1" />
<el-option label="华南-广东" value="z2" />
<el-option label="北美-洛杉矶" value="na0" />
<el-option label="亚太-新加坡" value="as0" />
<el-option label="华东-浙江2" value="cn-east-2" />
</el-select>
<div class="form-tip">请根据您的Bucket所在区域选择</div>
</el-form-item>
</template>
<el-alert
v-if="formData.storage_type === 'local'"
title="本地存储说明"
type="info"
:closable="false"
show-icon
>
文件将存储在服务器本地磁盘,适合小规模应用或开发测试环境
</el-alert>
<el-alert
v-if="formData.storage_type === 'qiniu'"
title="七牛云存储说明"
type="warning"
:closable="false"
show-icon
>
<p>使用七牛云存储需要:</p>
<ul style="margin: 8px 0; padding-left: 20px;">
<li>在七牛云控制台创建存储空间(Bucket)</li>
<li>获取 AccessKey 和 SecretKey(在个人中心-密钥管理)</li>
<li>配置 CDN 加速域名(在存储空间-域名管理)</li>
<li>确保域名已备案并完成 CNAME 解析</li>
</ul>
</el-alert>
</el-card>
</el-form>
<div class="footer-actions">
<el-button @click="handleReset">重置</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
保存设置
</el-button>
</div>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import { Link } from "@element-plus/icons-vue";
import { getStorageConfig, saveStorageConfig } from "@/api/sitesettings";
const STORAGE_KEY = "storage_settings_draft";
const formRef = ref();
const submitting = ref(false);
const formData = reactive({
storage_type: "local",
qiniu_access_key: "",
qiniu_secret_key: "",
qiniu_bucket: "",
qiniu_domain: "",
qiniu_region: "",
});
const validateQiniuField = (field, fieldName) => {
return (_rule, value, callback) => {
if (
formData.storage_type === "qiniu" &&
!String(value || "").trim()
) {
callback(new Error(`${fieldName}不能为空`));
return;
}
callback();
};
};
const rules = {
qiniu_access_key: [
{ validator: validateQiniuField("qiniu_access_key", "AccessKey"), trigger: "blur" },
],
qiniu_secret_key: [
{ validator: validateQiniuField("qiniu_secret_key", "SecretKey"), trigger: "blur" },
],
qiniu_bucket: [
{ validator: validateQiniuField("qiniu_bucket", "Bucket"), trigger: "blur" },
],
qiniu_domain: [
{ validator: validateQiniuField("qiniu_domain", "CDN域名"), trigger: "blur" },
],
};
const loadDraft = () => {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
try {
const data = JSON.parse(raw);
Object.assign(formData, data);
} catch {
// ignore invalid cache
}
};
const loadRemoteConfig = async () => {
try {
const res = await getStorageConfig();
if (res?.code === 200 && res?.data) {
Object.assign(formData, {
storage_type: res.data.storage_type || "local",
qiniu_access_key: res.data.qiniu_access_key || "",
qiniu_secret_key: res.data.qiniu_secret_key || "",
qiniu_bucket: res.data.qiniu_bucket || "",
qiniu_domain: res.data.qiniu_domain || "",
qiniu_region: res.data.qiniu_region || "",
});
}
} catch (error) {
console.error("加载存储配置失败:", error);
}
};
const saveDraft = () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(formData));
};
const handleReset = () => {
Object.assign(formData, {
storage_type: "local",
qiniu_access_key: "",
qiniu_secret_key: "",
qiniu_bucket: "",
qiniu_domain: "",
qiniu_region: "",
});
saveDraft();
ElMessage.success("已重置");
};
const handleSubmit = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
} catch {
return;
}
submitting.value = true;
try {
await saveStorageConfig({
storage_type: formData.storage_type,
qiniu_access_key: formData.qiniu_access_key || null,
qiniu_secret_key: formData.qiniu_secret_key || null,
qiniu_bucket: formData.qiniu_bucket || null,
qiniu_domain: formData.qiniu_domain || null,
qiniu_region: formData.qiniu_region || null,
});
saveDraft();
ElMessage.success("保存成功");
} catch (error) {
ElMessage.error(error?.response?.data?.msg || "保存失败");
} finally {
submitting.value = false;
}
};
onMounted(() => {
loadDraft();
loadRemoteConfig();
});
</script>
<style scoped>
.storage-settings {
display: flex;
flex-direction: column;
gap: 16px;
}
.settings-card {
margin-bottom: 12px;
}
.card-header {
font-weight: 600;
font-size: 16px;
}
.form-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
line-height: 1.5;
}
.footer-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
:deep(.el-alert) {
margin-top: 16px;
}
:deep(.el-alert ul) {
list-style-type: disc;
}
:deep(.el-alert li) {
line-height: 1.8;
}
</style>
@@ -0,0 +1,71 @@
<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="platform">
<platformSettings
ref="platformSettingsRef"
v-if="activeTab === 'platform'"
/>
</el-tab-pane>
<!-- 存储配置 -->
<el-tab-pane label="存储配置" name="storage">
<storageSettings
ref="storageSettingsRef"
v-if="activeTab === 'storage'"
/>
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import platformSettings from "./components/platformSettings.vue";
import storageSettings from "./components/storageSettings.vue";
const activeTab = ref("platform");
</script>
<style lang="less" 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,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,149 @@
<template>
<div class="sms-edit">
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
class="sms-form"
>
<el-form-item label="短信网关地址" prop="backendUrl">
<el-input
v-model="form.backendUrl"
placeholder="例如:https://yzsms.yunzer.cn"
clearable
/>
</el-form-item>
<el-form-item label="短信API KEY" prop="apiKey">
<el-input v-model="form.apiKey" placeholder="请输入 API_KEY" clearable />
</el-form-item>
<el-form-item label="测试收件手机号">
<el-input
v-model="testPhone"
placeholder="国际格式号码,例如 +8613712345678"
clearable
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSave" :loading="saveLoading">
保存配置
</el-button>
<el-button @click="handleTest" :loading="testLoading">发送测试短信</el-button>
<el-button @click="handleReset" :disabled="saveLoading || testLoading">重置</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import type { FormInstance, FormRules } from "element-plus";
import { editSmsInfo, sendTestSms } from "../../../../api/sms";
const props = defineProps<{
config: {
backendUrl?: string;
apiKey?: string;
};
}>();
const emit = defineEmits<{
(e: "saved"): void;
(e: "tested"): void;
}>();
const formRef = ref<FormInstance>();
const form = reactive({
backendUrl: "",
apiKey: "",
});
const rules: FormRules = {
backendUrl: [{ required: true, message: "请输入短信网关地址", trigger: "blur" }],
apiKey: [{ required: true, message: "请输入API KEY", trigger: "blur" }],
};
const testPhone = ref("");
const saveLoading = ref(false);
const testLoading = ref(false);
watch(
() => props.config,
(v) => {
form.backendUrl = v?.backendUrl ?? "";
form.apiKey = v?.apiKey ?? "";
},
{ deep: true, immediate: true }
);
const handleSave = async () => {
if (!formRef.value) return;
try {
saveLoading.value = true;
await formRef.value.validate();
const res = await editSmsInfo(form);
if (res.code === 200) {
ElMessage.success(res.msg || "短信配置保存成功");
emit("saved");
} else {
ElMessage.error(res.msg || "保存失败,请稍后重试");
}
} catch (e: any) {
ElMessage.error(e?.message || "保存失败,请稍后重试");
} finally {
saveLoading.value = false;
}
};
const handleTest = async () => {
const phone = (testPhone.value || "").trim();
if (!phone) {
ElMessage.warning("请先输入测试收件手机号");
return;
}
// Android 网关要求国际格式:以 + 开头,后面为数字
if (!/^\+\d{6,15}$/.test(phone)) {
ElMessage.warning("请使用国际格式号码(以 + 开头,后面为数字)");
return;
}
if (!formRef.value) return;
try {
testLoading.value = true;
await formRef.value.validate();
const res = await sendTestSms({ ...form, phone });
if (res.code === 200) {
ElMessage.success(res.msg || "短信测试任务入队成功");
emit("tested");
} else {
ElMessage.error(res.msg || "短信测试失败,请稍后重试");
}
} catch (e: any) {
ElMessage.error(e?.message || "短信测试失败,请稍后重试");
} finally {
testLoading.value = false;
}
};
const handleReset = () => {
form.backendUrl = "";
form.apiKey = "";
testPhone.value = "";
formRef.value?.clearValidate();
};
</script>
<style scoped lang="less">
.sms-edit {
max-width: 700px;
}
.sms-form {
max-width: 700px;
}
</style>
@@ -0,0 +1,60 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>短信配置</h2>
</div>
<el-divider />
<div class="content-box">
<Edit :config="smsForm" @saved="loadSmsConfig" />
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted } from "vue";
import Edit from "./components/edit.vue";
import { getSmsInfo } from "../../../api/sms";
const smsForm = reactive({
backendUrl: "",
apiKey: "",
});
const loadSmsConfig = async () => {
const res = await getSmsInfo();
if (res.code === 200 && Array.isArray(res.data) && res.data.length > 0) {
const item = res.data[0];
smsForm.backendUrl = item.backend_url || item.backendUrl || "";
smsForm.apiKey = item.api_key || item.apiKey || "";
}
};
onMounted(() => {
loadSmsConfig();
});
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
}
.content-box {
margin-top: 20px;
}
</style>
@@ -0,0 +1,188 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>短信任务列表</h2>
<div>
<el-button type="primary" @click="handleSearch">
搜索
</el-button>
<el-button @click="fetchTasks" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<el-divider />
<div class="search-bar">
<el-form :inline="true" :model="searchForm">
<el-form-item label="手机号">
<el-input
v-model="searchForm.phone"
placeholder="请输入手机号关键词"
style="width: 220px"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="全部" style="width: 160px">
<el-option label="全部" value="" />
<el-option label="待发送" :value="0" />
<el-option label="处理中" :value="1" />
<el-option label="失败" :value="2" />
<el-option label="已上报" :value="3" />
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
</div>
<el-table v-loading="loading" :data="pagedList" style="width: 100%" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="phone" label="手机号" min-width="150" />
<el-table-column
prop="content"
label="短信内容"
min-width="260"
show-overflow-tooltip
/>
<el-table-column prop="code" label="验证码" width="120" />
<el-table-column prop="status" label="状态" width="130">
<template #default="{ row }">
<el-tag :type="getStatusTagType(row.status)">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="180" />
</el-table>
<div class="pagination" v-if="total > 0">
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.size"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
:page-sizes="[10, 20, 50, 100]"
@size-change="applyPagination"
@current-change="applyPagination"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from "vue";
import { Refresh } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { getSmsTaskList } from "../../../api/sms";
const loading = ref(false);
const searchForm = reactive({
phone: "",
status: "",
});
const tasks = ref<any[]>([]);
const pagedList = ref<any[]>([]);
const pagination = reactive({
current: 1,
size: 10,
});
const total = ref(0);
const getStatusTagType = (status: any) => {
const map: Record<string | number, string> = {
0: "info",
1: "warning",
2: "danger",
3: "success",
};
return map[status] || "info";
};
const getStatusText = (status: any) => {
const map: Record<string | number, string> = {
0: "待发送",
1: "发送中",
2: "发送失败",
3: "发送成功",
};
return map[status] || String(status ?? "");
};
const applyPagination = () => {
total.value = tasks.value.length;
const start = (pagination.current - 1) * pagination.size;
const end = start + pagination.size;
pagedList.value = tasks.value.slice(start, end);
};
const fetchTasks = async () => {
loading.value = true;
try {
const res = await getSmsTaskList({
status: searchForm.status,
phone: searchForm.phone,
});
if (res.code === 200) {
tasks.value = res.list || [];
pagination.current = 1;
applyPagination();
} else {
ElMessage.error(res.msg || "获取短信任务列表失败");
}
} catch (error: any) {
ElMessage.error(error?.message || "获取短信任务列表失败,请稍后重试");
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.current = 1;
fetchTasks();
};
const resetSearch = () => {
searchForm.phone = "";
searchForm.status = "";
pagination.current = 1;
fetchTasks();
};
onMounted(() => {
fetchTasks();
});
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
}
.search-bar {
margin-bottom: 16px;
}
.pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>