first commit
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>字典管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button @click="refresh">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 内容区域:根据当前视图切换 -->
|
||||
<div v-if="currentView === 'types'">
|
||||
<div class="tab-content">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="handleAddType">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加字典类型
|
||||
</el-button>
|
||||
<div class="search-box">
|
||||
<el-input
|
||||
v-model="typeSearchKeyword"
|
||||
placeholder="搜索字典类型名称或编码"
|
||||
clearable
|
||||
style="width: 300px"
|
||||
@input="handleTypeSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="dictTypes"
|
||||
stripe
|
||||
style="width: 100%"
|
||||
v-loading="typeLoading"
|
||||
>
|
||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||
<el-table-column prop="dict_name" label="字典名称" min-width="150" align="center" />
|
||||
<el-table-column prop="dict_code" label="字典编码" min-width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ row.dict_code }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- <el-table-column prop="parent_id" label="父级ID" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.parent_id > 0">{{ row.parent_id }}</span>
|
||||
<span v-else style="color: #999">-</span>
|
||||
</template>
|
||||
</el-table-column> -->
|
||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="is_global" label="全局展示" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_global === 1 ? 'success' : 'warning'" size="small">
|
||||
{{ row.is_global === 1 ? '是' : '否' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- <el-table-column prop="sort" label="排序" width="80" align="center" /> -->
|
||||
<!-- <el-table-column prop="remark" label="备注" min-width="200" show-overflow-tooltip /> -->
|
||||
<el-table-column prop="create_time" label="创建时间" width="180" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.create_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click="handleEditType(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button size="small" type="primary" link @click="handleViewItems(row)">
|
||||
<el-icon><List /></el-icon>
|
||||
查看字典项
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" link @click="handleDeleteType(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-bar">
|
||||
<el-pagination
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="totalCount"
|
||||
@current-change="handlePageChange"
|
||||
layout="total, prev, pager, next"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="currentView === 'items'">
|
||||
<div class="tab-content">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<el-button @click="goBackToTypes">
|
||||
返回
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="search-box">
|
||||
<span v-if="selectedDictType">
|
||||
当前字典类型:{{ selectedDictType.dict_name }}({{ selectedDictType.dict_code }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<DictItemList :selected-type-id="selectedDictTypeId || undefined" @refresh="refreshTypesSilently" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 字典类型编辑对话框 -->
|
||||
<DictTypeEdit
|
||||
v-model="typeDialogVisible"
|
||||
:dict-type="currentDictType"
|
||||
@success="handleTypeSuccess"
|
||||
/>
|
||||
|
||||
<!-- 字典项独立页面内操作通过 DictItemList 完成 -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Delete,
|
||||
Refresh,
|
||||
Search,
|
||||
List,
|
||||
} from '@element-plus/icons-vue'
|
||||
import {
|
||||
getDictTypes,
|
||||
deleteDictType,
|
||||
} from '@/api/dict'
|
||||
import DictTypeEdit from './components/DictTypeEdit.vue'
|
||||
import DictItemList from './components/DictItemList.vue'
|
||||
|
||||
const currentView = ref<'types' | 'items'>('types')
|
||||
const typeLoading = ref(false)
|
||||
|
||||
// 字典类型相关
|
||||
const dictTypes = ref<any[]>([])
|
||||
const typeSearchKeyword = ref('')
|
||||
const typeDialogVisible = ref(false)
|
||||
const currentDictType = ref<any>(null)
|
||||
|
||||
// 分页相关
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const totalCount = ref(0)
|
||||
|
||||
const selectedDictTypeId = ref<number | null>(null)
|
||||
const selectedDictType = computed(() => {
|
||||
if (!selectedDictTypeId.value) {
|
||||
return null
|
||||
}
|
||||
return dictTypes.value.find((type) => type.id === selectedDictTypeId.value) || null
|
||||
})
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return dateStr as string
|
||||
}
|
||||
}
|
||||
|
||||
// 获取字典类型列表
|
||||
async function fetchDictTypes() {
|
||||
typeLoading.value = true
|
||||
try {
|
||||
const res = await getDictTypes({
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
keyword: typeSearchKeyword.value
|
||||
})
|
||||
if (res.success === true && res.data) {
|
||||
dictTypes.value = Array.isArray(res.data.list) ? res.data.list : (Array.isArray(res.data) ? res.data : [])
|
||||
totalCount.value = res.data.total || res.data.length || 0
|
||||
} else if (Array.isArray(res)) {
|
||||
dictTypes.value = res
|
||||
totalCount.value = res.length
|
||||
} else {
|
||||
dictTypes.value = []
|
||||
totalCount.value = 0
|
||||
ElMessage.error(res.message || '获取字典类型失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '获取字典类型失败')
|
||||
dictTypes.value = []
|
||||
totalCount.value = 0
|
||||
} finally {
|
||||
typeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 字典类型搜索
|
||||
function handleTypeSearch() {
|
||||
currentPage.value = 1
|
||||
fetchDictTypes()
|
||||
}
|
||||
|
||||
// 分页切换
|
||||
function handlePageChange(page: number) {
|
||||
currentPage.value = page
|
||||
fetchDictTypes()
|
||||
}
|
||||
|
||||
// 添加字典类型
|
||||
function handleAddType() {
|
||||
currentDictType.value = null
|
||||
typeDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑字典类型
|
||||
function handleEditType(row: any) {
|
||||
currentDictType.value = { ...row }
|
||||
typeDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 查看字典项
|
||||
function handleViewItems(row: any) {
|
||||
selectedDictTypeId.value = row.id
|
||||
currentView.value = 'items'
|
||||
}
|
||||
|
||||
// 删除字典类型
|
||||
async function handleDeleteType(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除字典类型「${row.dict_name}」吗?删除后不可恢复。`,
|
||||
'警告',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
|
||||
typeLoading.value = true
|
||||
try {
|
||||
const res = await deleteDictType(row.id)
|
||||
if (res.success === true) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchDictTypes()
|
||||
} else {
|
||||
ElMessage.error(res.message || '删除失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
} finally {
|
||||
typeLoading.value = false
|
||||
}
|
||||
} catch {
|
||||
// 取消删除
|
||||
}
|
||||
}
|
||||
|
||||
// 字典类型操作成功回调
|
||||
function handleTypeSuccess() {
|
||||
fetchDictTypes()
|
||||
}
|
||||
|
||||
// 从字典项页返回
|
||||
function goBackToTypes() {
|
||||
currentView.value = 'types'
|
||||
}
|
||||
|
||||
// 子页面刷新后,静默刷新类型列表
|
||||
function refreshTypesSilently() {
|
||||
fetchDictTypes()
|
||||
}
|
||||
|
||||
// 刷新
|
||||
async function refresh() {
|
||||
await fetchDictTypes()
|
||||
ElMessage.success('刷新成功')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDictTypes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.container-box {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.color-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.color-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #e5e5e5;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user