初始化
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<el-dialog :title="dialogTitle" :model-value="dialogVisible" @close="close" v-bind="dialogMiddle" align-center>
|
||||
<el-form :model="formContent" ref="dialogFormRef" :rules="rules" class="form-two">
|
||||
<el-form-item label="上级菜单" prop="pid" :label-width="100">
|
||||
<el-tree-select
|
||||
v-model="displayPid"
|
||||
:data="functionList"
|
||||
check-strictly
|
||||
:render-after-expand="false"
|
||||
show-checkbox
|
||||
check-on-click-node
|
||||
node-key="id"
|
||||
:props="defaultProps"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="名称" prop="name" :label-width="100">
|
||||
<el-input v-model="formContent.name" maxlength="32" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="编码" prop="code" :label-width="100">
|
||||
<el-input v-model="formContent.code" maxlength="32" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!formContent.type" label="图标" prop="icon" :label-width="100">
|
||||
<IconSelect
|
||||
v-model="formContent.icon"
|
||||
:iconValue="formContent.icon"
|
||||
@update:icon-value="iconValue => (formContent.icon = iconValue)"
|
||||
placeholder="选择一个图标"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!formContent.type" label="路由地址" prop="path" :label-width="100">
|
||||
<el-input v-model="formContent.path" maxlength="32" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!formContent.type" label="组件地址" prop="component" :label-width="100">
|
||||
<el-input v-model="formContent.component" maxlength="32" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" prop="sort" :label-width="100">
|
||||
<el-input-number v-model="formContent.sort" :min="1" :max="999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型" prop="type" :label-width="100">
|
||||
<el-select v-model="formContent.type" clearable placeholder="请选择资源类型">
|
||||
<el-option label="菜单" :value="0"></el-option>
|
||||
<el-option label="按钮" :value="1"></el-option>
|
||||
<!-- <el-option label="公共资源" :value="2"></el-option>
|
||||
<el-option label="服务间调用资源" :value="3"></el-option> -->
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="remark" :label-width="100">
|
||||
<el-input v-model="formContent.remark" :rows="2" type="textarea" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="close()">取 消</el-button>
|
||||
<el-button type="primary" @click="save()">确 定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup name="ResourceDialog">
|
||||
import { computed, type Ref, ref, watch } from 'vue'
|
||||
import { dialogMiddle } from '@/utils/elementBind'
|
||||
import { ElMessage, type FormInstance, type FormItemRule } from 'element-plus'
|
||||
import { useDictStore } from '@/stores/modules/dict'
|
||||
import type { Function } from '@/api/user/interface/function'
|
||||
import { addFunction, getFunctionListNoButton, updateFunction } from '@/api/user/function/index'
|
||||
import IconSelect from '@/components/SelectIcon/index.vue'
|
||||
|
||||
const value = ref()
|
||||
// 树形节点配置
|
||||
const defaultProps = {
|
||||
children: 'children',
|
||||
label: 'name',
|
||||
value: 'id'
|
||||
}
|
||||
const functionList = ref<Function.ResFunction[]>([])
|
||||
const dictStore = useDictStore()
|
||||
// 定义弹出组件元信息
|
||||
const dialogFormRef = ref()
|
||||
function useMetaInfo() {
|
||||
const dialogVisible = ref(false)
|
||||
const titleType = ref('add')
|
||||
const formContent = ref<Function.ResFunction>({
|
||||
id: '', //资源表Id
|
||||
pid: '', //节点(0为根节点)
|
||||
pids: '', //节点上层所有节点
|
||||
name: '', //名称
|
||||
code: '', //资源标识
|
||||
path: '', //路径
|
||||
component: '',
|
||||
icon: undefined as string | undefined, // 图标
|
||||
sort: 100, //排序
|
||||
type: 0, //资源类型0-菜单、1-按钮、2-公共资源、3-服务间调用资源
|
||||
remark: '', //权限资源描述
|
||||
state: 1 //权限资源状态
|
||||
})
|
||||
return { dialogVisible, titleType, formContent }
|
||||
}
|
||||
|
||||
const { dialogVisible, titleType, formContent } = useMetaInfo()
|
||||
// 清空formContent
|
||||
const resetFormContent = () => {
|
||||
formContent.value = {
|
||||
id: '', //资源表Id
|
||||
pid: '', //节点(0为根节点)
|
||||
pids: '', //节点上层所有节点
|
||||
name: '', //名称
|
||||
code: '', //资源标识
|
||||
path: '', //路径
|
||||
component: '',
|
||||
icon: undefined, //图标
|
||||
sort: 100, //排序
|
||||
type: 0, //资源类型0-菜单、1-按钮、2-公共资源、3-服务间调用资源
|
||||
remark: '', //权限资源描述
|
||||
state: 1 //权限资源状态
|
||||
}
|
||||
}
|
||||
|
||||
let dialogTitle = computed(() => {
|
||||
return titleType.value === 'add' ? '新增菜单' : '编辑菜单'
|
||||
})
|
||||
|
||||
// 定义规则
|
||||
const formRuleRef = ref<FormInstance>()
|
||||
const rules: Ref<Record<string, Array<FormItemRule>>> = ref({
|
||||
name: [{ required: true, trigger: 'blur', message: '菜单名称必填!' }],
|
||||
code: [{ required: true, trigger: 'blur', message: '编码必填!' }],
|
||||
path : [{ required: true, trigger: 'blur', message: '路由地址必填!' }],
|
||||
component :[{ required: true, trigger: 'blur', message: '组件地址必填!' }]
|
||||
})
|
||||
|
||||
// watch(
|
||||
// () => formContent.value.type,
|
||||
// newVal => {
|
||||
// if (newVal === 1) {
|
||||
// // 选择按钮时,路由地址和组件地址无需校验
|
||||
// rules.value.path = []
|
||||
// rules.value.component = []
|
||||
// } else {
|
||||
// // 其他情况下,路由地址和组件地址需要校验
|
||||
// rules.value.path = [{ required: true, trigger: 'blur', message: '路由地址必填!' }]
|
||||
// rules.value.component = [{ required: true, trigger: 'blur', message: '组件地址必填!' }]
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
|
||||
// 关闭弹窗
|
||||
const close = () => {
|
||||
dialogVisible.value = false
|
||||
// 清空dialogForm中的值
|
||||
resetFormContent()
|
||||
// 重置表单
|
||||
dialogFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 计算属性,用于控制显示的 pid
|
||||
const displayPid = computed({
|
||||
get: () => {
|
||||
return formContent.value.pid === '0' ? '' : formContent.value.pid
|
||||
},
|
||||
set: value => {
|
||||
formContent.value.pid = value
|
||||
}
|
||||
})
|
||||
|
||||
// 保存数据
|
||||
const save = () => {
|
||||
try {
|
||||
dialogFormRef.value?.validate(async (valid: boolean) => {
|
||||
if (formContent.value.pid === undefined || formContent.value.pid === null || formContent.value.pid === '') {
|
||||
formContent.value.pid = '0'
|
||||
}
|
||||
if (
|
||||
formContent.value.pids === undefined ||
|
||||
formContent.value.pids === null ||
|
||||
formContent.value.pids === ''
|
||||
) {
|
||||
formContent.value.pids = '0'
|
||||
}
|
||||
if (valid) {
|
||||
if (formContent.value.id) {
|
||||
await updateFunction(formContent.value)
|
||||
} else {
|
||||
await addFunction(formContent.value)
|
||||
}
|
||||
ElMessage.success({ message: `${dialogTitle.value}成功!` })
|
||||
close()
|
||||
// 刷新表格
|
||||
await props.refreshTable!()
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('验证过程中出现错误', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开弹窗,可能是新增,也可能是编辑
|
||||
const open = async (sign: string, data: Function.ResFunction) => {
|
||||
// 重置表单
|
||||
dialogFormRef.value?.resetFields()
|
||||
// 清空表单校验
|
||||
dialogFormRef.value?.clearValidate()
|
||||
const response = await getFunctionListNoButton()
|
||||
functionList.value = response.data as unknown as Function.ResFunction[]
|
||||
titleType.value = sign
|
||||
dialogVisible.value = true
|
||||
|
||||
if (formContent.value.pid === '0') {
|
||||
formContent.value.pid = ''
|
||||
}
|
||||
|
||||
if (data.id) {
|
||||
formContent.value = { ...data }
|
||||
} else {
|
||||
resetFormContent()
|
||||
}
|
||||
}
|
||||
|
||||
// 对外映射
|
||||
defineExpose({ open })
|
||||
const props = defineProps<{
|
||||
refreshTable: (() => Promise<void>) | undefined
|
||||
}>()
|
||||
</script>
|
||||
149
frontend/src/views/authority/resource/index.vue
Normal file
149
frontend/src/views/authority/resource/index.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div class='table-box'>
|
||||
<ProTable
|
||||
ref='proTable'
|
||||
:columns='columns'
|
||||
:request-api='getFunctionList'
|
||||
:pagination="false"
|
||||
>
|
||||
<!-- :data='userData' -->
|
||||
<!-- 表格 header 按钮 -->
|
||||
<template #tableHeader>
|
||||
<el-button v-auth.resource="'add'" type='primary' :icon='CirclePlus' @click="openDialog('add')">新增</el-button>
|
||||
</template>
|
||||
<!-- 表格操作 -->
|
||||
<template #operation='scope'>
|
||||
<el-button v-auth.resource="'edit'" type='primary' link :icon='EditPen' @click="openDialog('edit', scope.row)">编辑</el-button>
|
||||
<el-button v-auth.resource="'delete'" type='primary' link :icon='Delete' @click='handleDelete(scope.row)'>删除</el-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
|
||||
|
||||
</div>
|
||||
<ResourcePopup :refresh-table='proTable?.getTableList' ref='resourcePopup' />
|
||||
|
||||
</template>
|
||||
<script setup lang='tsx' name='useProTable'>
|
||||
import { ref ,reactive} from 'vue'
|
||||
import { useHandleData } from '@/hooks/useHandleData'
|
||||
import type { Function } from "@/api/user/interface/function"
|
||||
import ProTable from '@/components/ProTable/index.vue'
|
||||
import { CirclePlus, Delete, EditPen } from '@element-plus/icons-vue'
|
||||
import type { ColumnProps, ProTableInstance } from '@/components/ProTable/interface'
|
||||
import { useDictStore } from '@/stores/modules/dict'
|
||||
import ResourcePopup from './components/resourcePopup.vue'
|
||||
import {deleteFunction,getFunctionList} from '@/api/user/function/index'
|
||||
import * as Icons from '@element-plus/icons-vue'
|
||||
defineOptions({
|
||||
name: 'resource'
|
||||
})
|
||||
const dictStore = useDictStore()
|
||||
const resourcePopup = ref()
|
||||
// ProTable 实例
|
||||
const proTable = ref<ProTableInstance>()
|
||||
|
||||
// 表格配置项
|
||||
const columns = reactive<ColumnProps<Function.ResFunction>[]>([
|
||||
{ type: 'index', fixed: 'left', width: 70, label: '序号' },
|
||||
{
|
||||
prop: 'name',
|
||||
label: '名称',
|
||||
minWidth: 150,
|
||||
align:'left',
|
||||
headerAlign: 'center',
|
||||
search: { el: 'input' },
|
||||
},
|
||||
{
|
||||
prop: 'code',
|
||||
label: '编码',
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
prop: 'type',
|
||||
label: '类型',
|
||||
width: 100,
|
||||
render: (scope) => {
|
||||
const typeMap: { [key: number]: { label: string; type: string } } = {
|
||||
0: { label: '菜单', type: 'primary' },
|
||||
1: { label: '按钮', type: 'success' },
|
||||
2: { label: '公共资源', type: 'info' },
|
||||
3: { label: '服务间调用资源', type: 'warning' },
|
||||
};
|
||||
const typeInfo = typeMap[scope.row.type] || { label: '未知', type: 'danger' };
|
||||
return (
|
||||
<el-tag type={typeInfo.type}>{typeInfo.label}</el-tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
prop: 'icon',
|
||||
label: '图标',
|
||||
minWidth: 80,
|
||||
render: scope => {
|
||||
const customIcons: { [key: string]: any } = Icons
|
||||
const iconKey = scope.row.icon; //
|
||||
if (!iconKey || !customIcons[iconKey]) {
|
||||
// 如果 iconKey 为空或未定义,或者 customIcons 中找不到对应的图标,返回一个空的 <span> 标签
|
||||
return <span></span>;
|
||||
}
|
||||
const icon = customIcons[iconKey]; // 如果找不到图标,使用默认图标
|
||||
return (
|
||||
<el-button icon={icon} />
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
prop: 'path',
|
||||
label: '路由地址',
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
prop: 'component',
|
||||
label: '组件地址',
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
prop: 'sort',
|
||||
label: '排序',
|
||||
width: 70,
|
||||
},
|
||||
|
||||
{
|
||||
prop: 'state',
|
||||
label: '状态',
|
||||
minWidth: 70,
|
||||
render: scope => {
|
||||
return (
|
||||
|
||||
<el-tag type={scope.row.state ? 'success' : 'danger'} > {scope.row.state ? '正常' : '禁用'} </el-tag>
|
||||
)
|
||||
},
|
||||
},
|
||||
{ prop: 'operation', label: '操作', fixed: 'right',width: 200 },
|
||||
])
|
||||
|
||||
|
||||
|
||||
// 打开 drawer(新增、编辑)
|
||||
const openDialog = (titleType: string, row: Partial<Function.ResFunction> = {}) => {
|
||||
resourcePopup.value?.open(titleType, row)
|
||||
|
||||
}
|
||||
|
||||
// 删除菜单信息
|
||||
const handleDelete = async (params: Function.ResFunction) => {
|
||||
await useHandleData(deleteFunction, params , `删除【${params.name}】菜单`)
|
||||
proTable.value?.getTableList()
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 批量删除菜单信息
|
||||
const batchDelete = async (id: string[]) => {
|
||||
await useHandleData(deleteFunction, { id }, '删除所选菜单信息')
|
||||
proTable.value?.clearSelection()
|
||||
proTable.value?.getTableList()
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
162
frontend/src/views/authority/role/components/rolePopup.vue
Normal file
162
frontend/src/views/authority/role/components/rolePopup.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<!-- 基础信息弹出框 -->
|
||||
<el-dialog :model-value="dialogVisible" :title="dialogTitle" v-bind="dialogSmall" @close="close" align-center>
|
||||
<div>
|
||||
|
||||
<el-form :model="formContent"
|
||||
ref='dialogFormRef'
|
||||
:rules='rules'
|
||||
>
|
||||
<el-form-item label="名称" prop='name' :label-width="100" >
|
||||
<el-input v-model="formContent.name" placeholder="请输入名称" autocomplete="off" :disabled="rootIsEdit" maxlength="32" show-word-limit/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="编码" prop='code' :label-width="100">
|
||||
<el-input v-model="formContent.code" placeholder="请输入编码" autocomplete="off" :disabled="rootIsEdit" maxlength="32" show-word-limit/>
|
||||
</el-form-item>
|
||||
<el-form-item label='类型' prop='type' :label-width="100">
|
||||
<el-select v-model="formContent.type" clearable placeholder="请选择类型" :disabled="rootIsEdit">
|
||||
<el-option label="普通角色" :value="2"></el-option>
|
||||
<el-option label="管理员角色" :value="1"></el-option>
|
||||
<el-option label="超级管理员" :value="0" v-if="rootIsShow"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop='remark' :label-width="100">
|
||||
<el-input v-model="formContent.remark" :rows="2" type="textarea" placeholder="请输入备注" autocomplete="off" :disabled="rootIsEdit"/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="close()">取消</el-button>
|
||||
<el-button type="primary" @click="save()" :disabled="rootIsEdit">
|
||||
保存
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import{ ElMessage, type FormInstance,type FormItemRule } from 'element-plus'
|
||||
import type { ProTableInstance } from '@/components/ProTable/interface'
|
||||
import { ref,computed, Ref } from 'vue'
|
||||
import { type Role } from '@/api/user/interface/role'
|
||||
import {dialogSmall} from '@/utils/elementBind'
|
||||
import { useDictStore } from '@/stores/modules/dict'
|
||||
import {addRole,editRole} from '@/api/user/role'
|
||||
|
||||
const dictStore = useDictStore()
|
||||
// 定义弹出组件元信息
|
||||
const dialogFormRef = ref()
|
||||
//超级管理员有3个下拉框,其他两个
|
||||
const rootIsShow = ref(false)
|
||||
//超级管理员下拉框不可编辑
|
||||
const rootIsEdit = ref(false)
|
||||
function useMetaInfo() {
|
||||
const dialogVisible = ref(false)
|
||||
const titleType = ref('add')
|
||||
const formContent = ref<Role.RoleBO>({
|
||||
id: '', //角色类型ID
|
||||
name: '', //角色类型名称
|
||||
code: '', //角色代码
|
||||
type: 2, //角色类型
|
||||
remark:'', //角色描述
|
||||
state:1,
|
||||
})
|
||||
return { dialogVisible, titleType, formContent }
|
||||
}
|
||||
|
||||
const { dialogVisible, titleType, formContent } = useMetaInfo()
|
||||
|
||||
|
||||
// 清空formContent
|
||||
const resetFormContent = () => {
|
||||
formContent.value = {
|
||||
id: '', //角色类型ID
|
||||
name: '', //角色类型名称
|
||||
code: '', //角色代码
|
||||
type: 2, //角色类型
|
||||
remark:'', //角色描述
|
||||
state:1,
|
||||
}
|
||||
}
|
||||
|
||||
let dialogTitle = computed(() => {
|
||||
return titleType.value === 'add' ? '新增角色' : '编辑角色'
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
//定义规则
|
||||
const formRuleRef = ref<FormInstance>()
|
||||
//定义校验规则
|
||||
const rules: Ref<Record<string, Array<FormItemRule>>> = ref({
|
||||
name: [{ required: true, message: '名称必填!', trigger: 'blur' },
|
||||
{ pattern: /^[\u4e00-\u9fa5]{1,20}$/, message: '名称为长度1-20的中文', trigger: 'blur' }],
|
||||
code: [{ required: true, message: '编码必填!', trigger: 'blur' }],
|
||||
type: { required: true, message: '类型必选!', trigger: 'change' },
|
||||
})
|
||||
|
||||
|
||||
// 关闭弹窗
|
||||
const close = () => {
|
||||
dialogVisible.value = false
|
||||
// 清空dialogForm中的值
|
||||
resetFormContent()
|
||||
// 重置表单
|
||||
dialogFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 保存数据
|
||||
const save = () => {
|
||||
try {
|
||||
dialogFormRef.value?.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
if (formContent.value.id) {
|
||||
await editRole(formContent.value);
|
||||
} else {
|
||||
await addRole(formContent.value);
|
||||
}
|
||||
ElMessage.success({ message: `${dialogTitle.value}成功!` })
|
||||
close()
|
||||
// 刷新表格
|
||||
await props.refreshTable!()
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
//error('验证过程中出现错误', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开弹窗,可能是新增,也可能是编辑
|
||||
const open = async (sign: string, data: Role.RoleBO) => {
|
||||
// 重置表单
|
||||
dialogFormRef.value?.resetFields()
|
||||
titleType.value = sign
|
||||
dialogVisible.value = true
|
||||
|
||||
if(data.type == 0){
|
||||
rootIsShow.value = true
|
||||
rootIsEdit.value = true
|
||||
}else{
|
||||
rootIsShow.value = false
|
||||
rootIsEdit.value = false
|
||||
}
|
||||
|
||||
if (data.id) {
|
||||
formContent.value = { ...data }
|
||||
} else {
|
||||
resetFormContent()
|
||||
}
|
||||
}
|
||||
|
||||
// 对外映射
|
||||
defineExpose({ open })
|
||||
const props = defineProps<{
|
||||
refreshTable: (() => Promise<void>) | undefined;
|
||||
}>()
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<!-- 基础信息弹出框 -->
|
||||
<el-dialog :model-value="dialogVisible" :title="dialogTitle" v-bind="dialogMiddle" @close="close" align-center>
|
||||
<div>
|
||||
<el-form :model="formContent" ref='dialogFormRef'>
|
||||
<el-tree
|
||||
:data="functionList"
|
||||
:props="defaultProps"
|
||||
node-key="id"
|
||||
:expand-on-click-node="false"
|
||||
show-checkbox
|
||||
:default-checked-keys="checkedKeysRef"
|
||||
ref="treeRef"
|
||||
>
|
||||
|
||||
</el-tree>
|
||||
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="close()">取消</el-button>
|
||||
<el-button type="primary" @click="save()">
|
||||
保存
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import{ ElMessage, ElTree, type FormInstance,type FormItemRule } from 'element-plus'
|
||||
import type { ProTableInstance } from '@/components/ProTable/interface'
|
||||
import { ref,computed, type Ref, nextTick } from 'vue'
|
||||
import { Role } from '@/api/user/interface/role'
|
||||
import {dialogMiddle,dialogSmall} from '@/utils/elementBind'
|
||||
import { useDictStore } from '@/stores/modules/dict'
|
||||
import { type Function } from '@/api/user/function/interface'
|
||||
import {getRoleFunction,assignFunction} from '@/api/user/role/index'
|
||||
|
||||
// 保存数据
|
||||
const treeRef = ref<InstanceType<typeof ElTree>>()
|
||||
const functionList = ref<Function.ResFunction[]>([])
|
||||
// 定义一个 ref 来存储已选中的 keys
|
||||
const checkedKeysRef = ref<string[]>([]);
|
||||
// 树形节点配置
|
||||
const defaultProps = {
|
||||
children: 'children',
|
||||
label: 'name',
|
||||
};
|
||||
|
||||
// 定义弹出组件元信息
|
||||
const dialogFormRef = ref()
|
||||
const dialogTitle = ref('授权资源')
|
||||
const { dialogVisible, formContent } = useMetaInfo()
|
||||
|
||||
function useMetaInfo() {
|
||||
const dialogVisible = ref(false)
|
||||
|
||||
const formContent = ref<Role.RoleBO>({
|
||||
id: '', //角色类型ID
|
||||
name: '', //角色类型名称
|
||||
code: '', //角色代码
|
||||
type: 2, //角色类型
|
||||
remark:'', //角色描述
|
||||
state:1,
|
||||
})
|
||||
return { dialogVisible, formContent }
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 清空formContent
|
||||
const resetFormContent = () => {
|
||||
formContent.value = {
|
||||
id: '', //角色类型ID
|
||||
name: '', //角色类型名称
|
||||
code: '', //角色代码
|
||||
type: 2, //角色类型
|
||||
remark:'', //角色描述
|
||||
state:1,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 关闭弹窗
|
||||
const close = () => {
|
||||
dialogVisible.value = false
|
||||
// 清空dialogForm中的值
|
||||
resetFormContent()
|
||||
// 重置表单
|
||||
dialogFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 保存数据
|
||||
const save = () => {
|
||||
try {
|
||||
dialogFormRef.value?.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
if (formContent.value.id) {
|
||||
// 获取半选中的节点 ID
|
||||
const halfCheckedKeys = treeRef.value?.getHalfCheckedKeys() || [];
|
||||
// 获取全选中的节点 ID
|
||||
const checkedKeys = treeRef.value?.getCheckedKeys() || [];
|
||||
// 将两个数组合并
|
||||
const allCheckedKeys = [...halfCheckedKeys, ...checkedKeys];
|
||||
|
||||
// 将 checkedKeys 转换为字符串数组
|
||||
const checkedKeysAsString: string[] = allCheckedKeys.map(key => String(key));
|
||||
// 假设 RoleFunctionId 是一个对象,且需要 id 属性
|
||||
const roleFunctionIdObject: Role.RoleFunctionId = {
|
||||
id: checkedKeysAsString
|
||||
};
|
||||
|
||||
const result = await assignFunction(formContent.value,roleFunctionIdObject);
|
||||
if(result.code != 'A0000'){
|
||||
ElMessage.error({ message: result.message})
|
||||
}else{
|
||||
ElMessage.success({ message: `${dialogTitle.value}成功!` })
|
||||
}
|
||||
}
|
||||
close()
|
||||
// 刷新表格
|
||||
await props.refreshTable!()
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('验证过程中出现错误', err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 打开弹窗,可能是新增,也可能是编辑
|
||||
const open = async (sign: string, data: Role.RoleBO, AllFunction: Function.ResFunction[]) => {
|
||||
// 重置表单
|
||||
dialogFormRef.value?.resetFields()
|
||||
// 重置树状结构
|
||||
functionList.value = []
|
||||
checkedKeysRef.value = []
|
||||
const result = await getRoleFunction(data);
|
||||
if (result.code === 'A0000') {
|
||||
// 将 result.data 转换为 Function.ResFunction[] 类型
|
||||
const roleFunctions = result.data as Function.ResFunction[];
|
||||
// 获取 AllFunction 中所有层级的 id
|
||||
const allIds = getAllIds(AllFunction);
|
||||
// 匹配 roleFunctions 中的 id 并设置 checkedKeys
|
||||
const checkedKeys = allIds.filter(id => roleFunctions.some(roleFunc => roleFunc.id === id));
|
||||
// 过滤出叶子节点
|
||||
const leafCheckedKeys = filterLeafNodes(AllFunction, checkedKeys);
|
||||
|
||||
// 设置 functionList 和 checkedKeys
|
||||
functionList.value = AllFunction;
|
||||
checkedKeysRef.value = leafCheckedKeys;
|
||||
|
||||
// nextTick(() => {
|
||||
// // 触发一次更新,确保表单中的数据被更新
|
||||
// dialogFormRef.value?.validate();
|
||||
// });
|
||||
} else {
|
||||
ElMessage.error({ message: result.message });
|
||||
}
|
||||
|
||||
dialogVisible.value = true;
|
||||
if (data.id) {
|
||||
formContent.value = { ...data };
|
||||
} else {
|
||||
resetFormContent();
|
||||
}
|
||||
}
|
||||
|
||||
const filterLeafNodes = (functions: Function.ResFunction[], checkedKeys: string[]): string[] => {
|
||||
const leafNodes: string[] = [];
|
||||
const isLeafNode = (func: Function.ResFunction) => !func.children || func.children.length === 0;
|
||||
|
||||
const traverse = (funcs: Function.ResFunction[]) => {
|
||||
for (const func of funcs) {
|
||||
if (isLeafNode(func)) {
|
||||
if (checkedKeys.includes(func.id)) {
|
||||
leafNodes.push(func.id);
|
||||
}
|
||||
} else {
|
||||
traverse(func.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
traverse(functions);
|
||||
return leafNodes;
|
||||
};
|
||||
const getAllIds = (functions: Function.ResFunction[]): string[] => {
|
||||
const ids: string[] = [];
|
||||
|
||||
const traverse = (func: Function.ResFunction) => {
|
||||
ids.push(func.id);
|
||||
if (func.children && func.children.length > 0) {
|
||||
func.children.forEach((child: any) => traverse(child));
|
||||
}
|
||||
};
|
||||
|
||||
functions.forEach(func => traverse(func));
|
||||
|
||||
return ids;
|
||||
};
|
||||
|
||||
// 对外映射
|
||||
defineExpose({ open })
|
||||
const props = defineProps<{
|
||||
refreshTable: (() => Promise<void>) | undefined;
|
||||
}>()
|
||||
|
||||
</script>
|
||||
162
frontend/src/views/authority/role/index.vue
Normal file
162
frontend/src/views/authority/role/index.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div class='table-box'>
|
||||
<ProTable
|
||||
ref='proTable'
|
||||
:columns='columns'
|
||||
:request-api="getTableList"
|
||||
>
|
||||
<!-- :requestApi="getRoleList" -->
|
||||
<!-- 表格 header 按钮 -->
|
||||
<template #tableHeader='scope'>
|
||||
<el-button v-auth.role="'add'" type='primary' :icon='CirclePlus' @click="openDrawer('add')">新增</el-button>
|
||||
<el-button v-auth.role="'delete'" type='danger' :icon='Delete' plain :disabled='!scope.isSelected'
|
||||
@click='batchDelete(scope.selectedListIds)'>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
<!-- 表格操作 -->
|
||||
<template #operation='scope'>
|
||||
<el-button v-auth.role="'edit'" type='primary' link :icon='EditPen' @click="openDrawer('edit', scope.row)" :disabled="scope.row.code == 'root'">编辑</el-button>
|
||||
<el-button v-auth.role="'delete'" type='primary' link :icon='Delete' @click='deleteAccount(scope.row)' :disabled="scope.row.code == 'root'">删除</el-button>
|
||||
<el-button v-auth.role="'SetPermissions'" type='primary' link :icon='Share' @click="openDrawer('设置权限', scope.row)" :disabled="scope.row.code == 'root'">设置权限</el-button>
|
||||
</template>
|
||||
|
||||
</ProTable>
|
||||
</div>
|
||||
<RolePopup :refresh-table='proTable?.getTableList' ref='rolePopup' />
|
||||
<RoleResourcePopup :refresh-table='proTable?.getTableList' ref='roleResourcePopup' />
|
||||
</template>
|
||||
|
||||
<script setup lang='tsx' name='useRole'>
|
||||
import { type Role } from '@/api/user/interface/role'
|
||||
import { type Function } from '@/api/user/interface/function'
|
||||
import { useHandleData } from '@/hooks/useHandleData'
|
||||
import ProTable from '@/components/ProTable/index.vue'
|
||||
import type{ ProTableInstance, ColumnProps } from '@/components/ProTable/interface'
|
||||
import { CirclePlus, Delete, EditPen, Share, Download, Upload, View, Refresh } from '@element-plus/icons-vue'
|
||||
import {getRoleList,deleteRole,getFunctionList} from '@/api/user/role/index'
|
||||
import RolePopup from './components/rolePopup.vue'
|
||||
import RoleResourcePopup from './components/roleResourcePopup.vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import {useDictStore} from '@/stores/modules/dict'
|
||||
defineOptions({
|
||||
name: 'role'
|
||||
})
|
||||
const rolePopup = ref()
|
||||
const roleResourcePopup = ref()
|
||||
const dictStore = useDictStore()
|
||||
// ProTable 实例
|
||||
const proTable = ref<ProTableInstance>()
|
||||
const functionList = ref<Function.ResFunction[]>([])
|
||||
// 如果你想在请求之前对当前请求参数做一些操作,可以自定义如下函数:params 为当前所有的请求参数(包括分页),最后返回请求列表接口
|
||||
// 默认不做操作就直接在 ProTable 组件上绑定 :requestApi="getUserList"
|
||||
const getTableList = (params: any) => {
|
||||
let newParams = JSON.parse(JSON.stringify(params))
|
||||
newParams.createTime && (newParams.startTime = newParams.createTime[0])
|
||||
newParams.createTime && (newParams.endTime = newParams.createTime[1])
|
||||
delete newParams.createTime
|
||||
return getRoleList(newParams)
|
||||
}
|
||||
|
||||
|
||||
// 初始化时获取角色列表
|
||||
onMounted(async () => {
|
||||
const response = await getFunctionList()
|
||||
functionList.value = response.data as unknown as Function.ResFunction[]
|
||||
//console.log(functionList.value)
|
||||
})
|
||||
|
||||
// 表格配置项
|
||||
const columns = reactive<ColumnProps<Role.RoleBO>[]>([
|
||||
{
|
||||
type: 'selection',
|
||||
fixed: 'left',
|
||||
width: 70,
|
||||
selectable(row, index) {
|
||||
return ![0, 1].includes(row.type);//类型是0-超级管理员,1-管理员,不可选择
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'index',
|
||||
fixed: 'left',
|
||||
width: 70,
|
||||
label: '序号'
|
||||
},
|
||||
{
|
||||
prop: 'name',
|
||||
label: '名称',
|
||||
search: { el: 'input' },
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
prop: 'code',
|
||||
label: '编码',
|
||||
search: { el: 'input' },
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
prop: 'type',
|
||||
label: '类型',
|
||||
minWidth: 200,
|
||||
render: (scope) => {
|
||||
const typeMap: { [key: number]: { label: string; type: string } } = {
|
||||
0: { label: '超级管理员', type: 'primary' },
|
||||
1: { label: '管理员角色', type: 'success' },
|
||||
2: { label: '普通角色', type: 'info' },
|
||||
};
|
||||
const typeInfo = typeMap[scope.row.type] || { label: '未知', type: 'danger' };
|
||||
return (
|
||||
<el-tag type={typeInfo.type}>{typeInfo.label}</el-tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
prop: 'remark',
|
||||
label: '描述',
|
||||
minWidth: 300,
|
||||
},
|
||||
{
|
||||
prop: 'state',
|
||||
label: '状态',
|
||||
minWidth: 100,
|
||||
render: scope => {
|
||||
return (
|
||||
<el-tag type={scope.row.state ? 'success' : 'danger'} > {scope.row.state ? '正常' : '删除'} </el-tag>
|
||||
)
|
||||
},
|
||||
},
|
||||
{ prop: 'operation',
|
||||
label: '操作',
|
||||
fixed: 'right',
|
||||
width: 250,
|
||||
|
||||
}
|
||||
])
|
||||
|
||||
// 删除角色信息
|
||||
const deleteAccount = async (params: Role.RoleBO) => {
|
||||
if (params.id) {
|
||||
await useHandleData(deleteRole, [params.id], '删除所选角色信息')
|
||||
proTable.value?.clearSelection()
|
||||
proTable.value?.getTableList()
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除角色信息
|
||||
const batchDelete = async (id: string[]) => {
|
||||
await useHandleData(deleteRole, id , '删除所选角色信息')
|
||||
proTable.value?.clearSelection()
|
||||
proTable.value?.getTableList()
|
||||
}
|
||||
|
||||
|
||||
// 打开 drawer(新增、查看、编辑)
|
||||
const openDrawer = (titleType: string, row: Partial<Role.RoleBO> = {}) => {
|
||||
if(titleType==='设置权限'){
|
||||
roleResourcePopup.value?.open(titleType, row,functionList.value)
|
||||
}else{
|
||||
rolePopup.value?.open(titleType, row)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
139
frontend/src/views/authority/user/components/passWordPopup.vue
Normal file
139
frontend/src/views/authority/user/components/passWordPopup.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<!-- 基础信息弹出框 -->
|
||||
<el-dialog v-model='dialogVisible' :title="dialogTitle" v-bind="dialogSmall" @close="close" align-center>
|
||||
<div>
|
||||
<el-form :model="formContent"
|
||||
ref='dialogFormRef'
|
||||
:rules='rules'
|
||||
>
|
||||
<el-form-item label="原密码" prop='oldPassword' :label-width="100">
|
||||
<el-input type="oldPassword" v-model="formContent.oldPassword" show-password placeholder="请输入原密码" autocomplete="off" maxlength="32" show-word-limit/>
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" prop='newPassword' :label-width="100">
|
||||
<el-input type="newPassword" v-model="formContent.newPassword" show-password placeholder="请输入新密码" autocomplete="off" maxlength="32" show-word-limit/>
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop='surePassword' :label-width="100">
|
||||
<el-input type="surePassword" v-model="formContent.surePassword" show-password placeholder="请再次输入确认密码" autocomplete="off" maxlength="32" show-word-limit/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="close()">取消</el-button>
|
||||
<el-button type="primary" @click="save()">
|
||||
保存
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, type Ref } from 'vue'
|
||||
import {dialogSmall} from '@/utils/elementBind'
|
||||
import { ElMessage, type FormInstance,type FormItemRule } from 'element-plus'
|
||||
import {updatePassWord} from '@/api/user/user'
|
||||
// 使用 dayjs 库格式化
|
||||
import dayjs from 'dayjs';
|
||||
import { type User } from '@/api/user/interface/user';
|
||||
|
||||
// 定义弹出组件元信息
|
||||
const dialogFormRef = ref()
|
||||
const dialogTitle = ref('修改密码')
|
||||
const surePassword = ref('')
|
||||
function useMetaInfo() {
|
||||
const dialogVisible = ref(false)
|
||||
const formContent = ref<User.ResPassWordUser>({
|
||||
id: '', //用户ID,作为唯一标识
|
||||
oldPassword: '',//密码
|
||||
newPassword:'',//
|
||||
surePassword:'',
|
||||
})
|
||||
return { dialogVisible, formContent }
|
||||
}
|
||||
|
||||
const { dialogVisible, formContent } = useMetaInfo()
|
||||
// 清空formContent
|
||||
const resetFormContent = () => {
|
||||
formContent.value = {
|
||||
id: '', //用户ID,作为唯一标识
|
||||
oldPassword: '',//密码
|
||||
newPassword:'',//
|
||||
surePassword:'',
|
||||
}
|
||||
}
|
||||
|
||||
//定义规则
|
||||
const formRuleRef = ref<FormInstance>()
|
||||
// 定义规则
|
||||
const rules: Ref<Record<string, Array<FormItemRule>>> = ref({
|
||||
oldPassword: [
|
||||
{ required: true, message: '原密码必填!', trigger: 'blur' }
|
||||
],
|
||||
newPassword: [
|
||||
{ required: true, message: '新密码必填!', trigger: 'blur' },
|
||||
{ pattern: /^(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]).{8,16}$/, message: '密码长度为8-16,需包含特殊字符', trigger: 'blur' }
|
||||
],
|
||||
surePassword: [
|
||||
{ required: true, message: '确认密码必填!', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule: FormItemRule, value: string, callback: Function) => {
|
||||
if (value !== formContent.value.newPassword) {
|
||||
callback(new Error('两次输入的密码不一致!'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
// 关闭弹窗
|
||||
const close = () => {
|
||||
dialogVisible.value = false
|
||||
// 清空dialogForm中的值
|
||||
resetFormContent()
|
||||
// 重置表单
|
||||
dialogFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 保存数据
|
||||
const save = () => {
|
||||
try {
|
||||
dialogFormRef.value?.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
if (formContent.value.id) {
|
||||
await updatePassWord(formContent.value);
|
||||
ElMessage.success({ message: `${dialogTitle.value}成功!` })
|
||||
}
|
||||
close()
|
||||
// 刷新表格
|
||||
await props.refreshTable!()
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('验证过程中出现错误', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开弹窗是编辑
|
||||
const open = async ( data: User.ResPassWordUser) => {
|
||||
// 重置表单
|
||||
dialogFormRef.value?.resetFields()
|
||||
dialogVisible.value = true
|
||||
if (data.id) {
|
||||
formContent.value = { ...data }
|
||||
}
|
||||
}
|
||||
|
||||
// 对外映射
|
||||
defineExpose({ open })
|
||||
const props = defineProps<{
|
||||
refreshTable: (() => Promise<void>) | undefined;
|
||||
}>()
|
||||
|
||||
|
||||
</script>
|
||||
180
frontend/src/views/authority/user/components/userPopup.vue
Normal file
180
frontend/src/views/authority/user/components/userPopup.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<!-- 基础信息弹出框 -->
|
||||
<el-dialog v-model='dialogVisible' :title="dialogTitle" v-bind="dialogSmall" @close="close" align-center>
|
||||
<div>
|
||||
<el-form :model="formContent"
|
||||
ref='dialogFormRef'
|
||||
:rules='rules'
|
||||
>
|
||||
<el-form-item label="用户名" prop='name' :label-width="100">
|
||||
<el-input v-model="formContent.name" placeholder="请输入用户名" autocomplete="off" maxlength="32" show-word-limit/>
|
||||
</el-form-item>
|
||||
<el-form-item label="登录名" prop='loginName' :label-width="100" >
|
||||
<el-input v-model="formContent.loginName" placeholder="请输入登录名" autocomplete="off" :disabled="LoginNameIsShow" maxlength="32" show-word-limit/>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop='password' :label-width="100" v-if="IsPasswordShow">
|
||||
<el-input type="password" v-model="formContent.password" show-password placeholder="请输入密码" autocomplete="off" maxlength="32" show-word-limit/>
|
||||
</el-form-item>
|
||||
<el-form-item label='角色' :label-width='100' prop='roles'>
|
||||
<el-select v-model="formContent.roleIds" multiple placeholder="请选择角色">
|
||||
<el-option
|
||||
v-for="item in roleList"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号码" prop='phone' :label-width="100">
|
||||
<el-input v-model="formContent.phone" placeholder="请输入手机号码" autocomplete="off" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱地址" prop='email' :label-width="100">
|
||||
<el-input v-model="formContent.email" placeholder="请输入邮箱地址" autocomplete="off" />
|
||||
</el-form-item>
|
||||
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="close()">取消</el-button>
|
||||
<el-button type="primary" @click="save()">
|
||||
保存
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref,computed, type Ref } from 'vue'
|
||||
import {dialogSmall} from '@/utils/elementBind'
|
||||
import { ElMessage, type FormInstance,type FormItemRule } from 'element-plus'
|
||||
import {
|
||||
addUser,
|
||||
updateUser,
|
||||
} from '@/api/user/user'
|
||||
// 使用 dayjs 库格式化
|
||||
import dayjs from 'dayjs';
|
||||
import { type User } from '@/api/user/interface/user';
|
||||
import { useDictStore } from '@/stores/modules/dict'
|
||||
import { type Role } from '@/api/user/interface/role';
|
||||
const dictStore = useDictStore()
|
||||
// 定义弹出组件元信息
|
||||
const dialogFormRef = ref()
|
||||
const IsPasswordShow = ref(false)
|
||||
const roleList = ref<Role.RoleBO[]>([])
|
||||
const LoginNameIsShow = ref(false)
|
||||
function useMetaInfo() {
|
||||
const dialogVisible = ref(false)
|
||||
const titleType = ref('add')
|
||||
const formContent = ref<User.ResUser>({
|
||||
id: '', //用户ID,作为唯一标识
|
||||
name: '', //用户名(别名)
|
||||
loginName: '',//登录名
|
||||
password: '',//密码
|
||||
phone: '', //手机号
|
||||
email: '', //邮箱
|
||||
loginTime: '',//最后一次登录时间
|
||||
loginErrorTimes: 0,//登录错误次数
|
||||
lockTime: '', //用户密码错误锁定时间
|
||||
state: 1, //
|
||||
})
|
||||
return { dialogVisible, titleType, formContent }
|
||||
}
|
||||
|
||||
const { dialogVisible, titleType, formContent } = useMetaInfo()
|
||||
// 清空formContent
|
||||
const resetFormContent = () => {
|
||||
formContent.value = {
|
||||
id: '', //用户ID,作为唯一标识
|
||||
name: '', //用户名(别名)
|
||||
loginName: '',//登录名
|
||||
password: '',//密码
|
||||
phone: '', //手机号
|
||||
email: '', //邮箱
|
||||
loginTime: '',//最后一次登录时间
|
||||
loginErrorTimes: 0,//登录错误次数
|
||||
lockTime: '', //用户密码错误锁定时间
|
||||
state: 1, //
|
||||
}
|
||||
}
|
||||
|
||||
let dialogTitle = computed(() => {
|
||||
return titleType.value === 'add' ? '新增用户' : '编辑用户'
|
||||
})
|
||||
|
||||
|
||||
//定义规则
|
||||
const formRuleRef = ref<FormInstance>()
|
||||
//定义校验规则
|
||||
const rules: Ref<Record<string, Array<FormItemRule>>> = ref({
|
||||
name: [{ required: true, message: '名称必填!', trigger: 'blur' },
|
||||
// 指定正则,此处是数字正则
|
||||
{ pattern: /^[A-Za-z\u4e00-\u9fa5]{1,16}$/, message: '名称需1~16位的英文或汉字', trigger: 'blur' }],
|
||||
loginName: [{ required: true, message: '登录名必填!', trigger: 'blur' },
|
||||
{ pattern: /^[a-zA-Z]{1}[a-zA-Z0-9]{2,15}$/, message: '格式错误,需以字母开头,长度为3-16位的字母或数字', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '密码必填!', trigger: 'blur' },
|
||||
{ pattern: /^(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]).{8,16}$/, message: '密码长度为8-16,需包含特殊字符', trigger: 'blur' }],
|
||||
})
|
||||
|
||||
|
||||
// 关闭弹窗
|
||||
const close = () => {
|
||||
dialogVisible.value = false
|
||||
// 清空dialogForm中的值
|
||||
resetFormContent()
|
||||
// 重置表单
|
||||
dialogFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 保存数据
|
||||
const save = () => {
|
||||
try {
|
||||
dialogFormRef.value?.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
if (formContent.value.id) {
|
||||
await updateUser(formContent.value);
|
||||
} else {
|
||||
await addUser(formContent.value);
|
||||
}
|
||||
ElMessage.success({ message: `${dialogTitle.value}成功!` })
|
||||
close()
|
||||
// 刷新表格
|
||||
await props.refreshTable!()
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('验证过程中出现错误', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开弹窗,可能是新增,也可能是编辑
|
||||
const open = async (sign: string, data: User.ResUser,roleParams: Role.RoleBO[]) => {
|
||||
// 重置表单
|
||||
dialogFormRef.value?.resetFields()
|
||||
// 获取角色列表
|
||||
roleList.value = roleParams
|
||||
titleType.value = sign
|
||||
dialogVisible.value = true
|
||||
if (data.id) {
|
||||
IsPasswordShow.value = false
|
||||
LoginNameIsShow.value = true
|
||||
formContent.value = { ...data }
|
||||
|
||||
|
||||
} else {
|
||||
IsPasswordShow.value = true
|
||||
LoginNameIsShow.value = false
|
||||
resetFormContent()
|
||||
}
|
||||
}
|
||||
|
||||
// 对外映射
|
||||
defineExpose({ open })
|
||||
const props = defineProps<{
|
||||
refreshTable: (() => Promise<void>) | undefined;
|
||||
}>()
|
||||
|
||||
|
||||
</script>
|
||||
214
frontend/src/views/authority/user/index.vue
Normal file
214
frontend/src/views/authority/user/index.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div class='table-box' ref='popupBaseView'>
|
||||
<ProTable
|
||||
ref='proTable'
|
||||
:columns='columns'
|
||||
:request-api="getTableList"
|
||||
>
|
||||
<!-- :data='userData' -->
|
||||
<!-- 表格 header 按钮 -->
|
||||
<template #tableHeader='scope'>
|
||||
<el-button v-auth.user="'add'" type='primary' :icon='CirclePlus' @click="openDialog('add')">新增</el-button>
|
||||
<el-button v-auth.user="'delete'" type='danger' :icon='Delete' plain :disabled='!scope.isSelected'
|
||||
@click='batchDelete(scope.selectedListIds)'>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
<!-- 表格操作 -->
|
||||
<template #operation='scope'>
|
||||
<el-button v-auth.user="'edit'" type='primary' link :icon='EditPen' @click="openDialog('edit', scope.row)" :disabled="scope.row.loginName == 'root'">编辑</el-button>
|
||||
<el-button v-auth.user="'delete'" type='primary' link :icon='Delete' @click='handleDelete(scope.row)' :disabled="scope.row.loginName == 'root'">删除</el-button>
|
||||
<el-button v-auth.user="'editPassWord'" type='primary' link :icon='Delete' @click='EditPassWord(scope.row)' :disabled="scope.row.loginName == 'root'">修改密码</el-button>
|
||||
</template>
|
||||
|
||||
</ProTable>
|
||||
</div>
|
||||
<UserPopup :refresh-table='proTable?.getTableList' ref='userPopup' />
|
||||
|
||||
<PassWordPopup :refresh-table='proTable?.getTableList' ref='passWordPopup' />
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang='tsx' name='useRole'>
|
||||
import { type User } from '@/api/user/interface/user'
|
||||
import { useHandleData } from '@/hooks/useHandleData'
|
||||
import ProTable from '@/components/ProTable/index.vue'
|
||||
import UserPopup from './components/userPopup.vue'
|
||||
import PassWordPopup from './components/passWordPopup.vue'
|
||||
import { type ProTableInstance, type ColumnProps } from '@/components/ProTable/interface'
|
||||
import { CirclePlus, Delete, EditPen} from '@element-plus/icons-vue'
|
||||
import { useDictStore } from '@/stores/modules/dict'
|
||||
import {getUserList, deleteUser,getRoleList} from '@/api/user/user'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { type Role } from '@/api/user/interface/role'
|
||||
defineOptions({
|
||||
name: 'user'
|
||||
})
|
||||
const roleList = ref<Role.RoleBO[]>([])
|
||||
const dictStore = useDictStore()
|
||||
const userPopup = ref()
|
||||
const passWordPopup = ref()
|
||||
// ProTable 实例
|
||||
const proTable = ref<ProTableInstance>()
|
||||
|
||||
|
||||
// 初始化时获取角色列表
|
||||
onMounted(async () => {
|
||||
const response = await getRoleList()
|
||||
roleList.value = response.data as unknown as Role.RoleBO[]
|
||||
})
|
||||
|
||||
// 如果你想在请求之前对当前请求参数做一些操作,可以自定义如下函数:params 为当前所有的请求参数(包括分页),最后返回请求列表接口
|
||||
// 默认不做操作就直接在 ProTable 组件上绑定 :requestApi="getUserList"
|
||||
const getTableList = (params: any) => {
|
||||
let newParams = JSON.parse(JSON.stringify(params))
|
||||
// newParams.searchEndTime = endDate.value
|
||||
// newParams.searchBeginTime = startDate.value
|
||||
return getUserList(newParams)
|
||||
}
|
||||
|
||||
// 表格配置项
|
||||
const columns = reactive<ColumnProps<User.ResUser>[]>([
|
||||
{ type: 'selection', fixed: 'left', width: 70 },
|
||||
{ type: 'index', fixed: 'left', width: 70, label: '序号' },
|
||||
{
|
||||
prop: 'name',
|
||||
label: '用户名',
|
||||
search: { el: 'input' },
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
prop: 'loginName',
|
||||
label: '登录名',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
prop: 'roleNames',
|
||||
label: '角色',
|
||||
minWidth: 250,
|
||||
render: (scope) => {
|
||||
const roleNames = scope.row.roleNames;
|
||||
const roleArray = Array.isArray(roleNames) ? roleNames : [roleNames];
|
||||
|
||||
if (roleArray.length > 1) {
|
||||
return roleArray.join(', ');
|
||||
}
|
||||
return roleArray[0] || ''; // 添加默认值
|
||||
},
|
||||
},
|
||||
{
|
||||
prop: 'phone',
|
||||
label: '手机号',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
prop: 'email',
|
||||
label: '邮箱',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
prop: 'loginTime',
|
||||
label: '最后一次登录时间',
|
||||
minWidth: 180,
|
||||
// search: {
|
||||
// render: () => {
|
||||
// return (
|
||||
// <div class='flx-flex-start'>
|
||||
// <TimeControl
|
||||
// include={['日', '周', '月', '自定义']}
|
||||
// default={'月'}
|
||||
// onUpdate-dates={handleDateChange}
|
||||
// />
|
||||
// </div>
|
||||
// )
|
||||
// },
|
||||
// },
|
||||
},
|
||||
{
|
||||
prop: 'state',
|
||||
label: '状态',
|
||||
minWidth: 100,
|
||||
enum: dictStore.getDictData('state'),
|
||||
fieldNames: { label: 'label', value: 'code' },
|
||||
render: (scope: { row: { state: any } }) => {
|
||||
const { tagType, tagText } = getTagTypeAndText(scope.row.state);
|
||||
return (<el-tag type={tagType}>{tagText}</el-tag>);
|
||||
}
|
||||
},
|
||||
{ prop: 'operation', label: '操作', fixed: 'right', width: 250 },
|
||||
])
|
||||
|
||||
|
||||
// 提取出生成 tag 的逻辑
|
||||
const getTagTypeAndText = (state: number) => {
|
||||
let tagType = 'danger'; // 默认标签类型为 'danger'
|
||||
let tagText = '';
|
||||
|
||||
switch(state) {
|
||||
case 1:
|
||||
tagType = 'success'; // 正常
|
||||
tagText = '正常';
|
||||
break;
|
||||
case 2:
|
||||
tagType = 'warning'; // 锁定
|
||||
tagText = '锁定';
|
||||
break;
|
||||
case 3:
|
||||
tagType = 'info'; // 待审核
|
||||
tagText = '待审核';
|
||||
break;
|
||||
case 4:
|
||||
tagType = 'default'; // 休眠
|
||||
tagText = '休眠';
|
||||
break;
|
||||
case 5:
|
||||
tagType = 'warning'; // 密码过期
|
||||
tagText = '密码过期';
|
||||
break;
|
||||
case 0:
|
||||
default:
|
||||
tagType = 'danger'; // 删除
|
||||
tagText = '删除';
|
||||
break;
|
||||
}
|
||||
|
||||
return { tagType, tagText };
|
||||
}
|
||||
|
||||
// 处理日期变化的回调函数
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
const handleDateChange = (startDateTemp: string, endDateTemp: string) => {
|
||||
startDate.value = startDateTemp
|
||||
endDate.value = endDateTemp
|
||||
}
|
||||
|
||||
// 打开 drawer(新增、编辑)
|
||||
const openDialog = (titleType: string, row: Partial<User.ResUser> = {}) => {
|
||||
userPopup.value?.open(titleType, row,roleList.value)
|
||||
|
||||
}
|
||||
|
||||
// 删除用户信息
|
||||
const handleDelete = async (params: User.ResUser) => {
|
||||
await useHandleData(deleteUser, [params.id] , `删除【${params.name}】用户`)
|
||||
proTable.value?.getTableList()
|
||||
}
|
||||
|
||||
// 批量删除用户信息
|
||||
const batchDelete = async (id: string[]) => {
|
||||
await useHandleData(deleteUser, id , '删除所选用户信息')
|
||||
proTable.value?.clearSelection()
|
||||
proTable.value?.getTableList()
|
||||
}
|
||||
|
||||
|
||||
// 修改密码
|
||||
const EditPassWord = async (row: User.ResPassWordUser) => {
|
||||
passWordPopup.value?.open(row)
|
||||
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
282
frontend/src/views/home/components/FavoriteMenuCard.vue
Normal file
282
frontend/src/views/home/components/FavoriteMenuCard.vue
Normal file
@@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<el-card shadow="hover" class="panel-card">
|
||||
<template #header>
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h3 class="panel-title">收藏菜单</h3>
|
||||
<p class="panel-subtitle">手动固定常用入口,登录后可以直接从这里进入业务页面。</p>
|
||||
</div>
|
||||
<el-tag round type="primary">{{ favoriteCount }}/{{ favoriteLimit }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="panel-body">
|
||||
<div class="panel-toolbar">
|
||||
<el-input
|
||||
v-model.trim="searchKeyword"
|
||||
clearable
|
||||
class="search-input"
|
||||
placeholder="搜索收藏菜单"
|
||||
:disabled="!favorites.length"
|
||||
/>
|
||||
|
||||
<div class="favorite-adder">
|
||||
<el-select
|
||||
v-model="selectedPath"
|
||||
class="menu-select"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="添加快捷入口"
|
||||
:disabled="favoriteCount >= favoriteLimit || !availableMenus.length"
|
||||
>
|
||||
<el-option v-for="item in availableMenus" :key="item.path" :label="item.title" :value="item.path" />
|
||||
</el-select>
|
||||
<el-button type="primary" :disabled="!selectedPath || favoriteCount >= favoriteLimit" @click="handleAdd">
|
||||
添加
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredFavorites.length" class="list-scroll">
|
||||
<div class="menu-list">
|
||||
<div v-for="item in filteredFavorites" :key="item.path" class="menu-item" @click="$emit('navigate', item.path)">
|
||||
<div class="menu-main">
|
||||
<span class="menu-icon">
|
||||
<el-icon>
|
||||
<component :is="item.icon"></component>
|
||||
</el-icon>
|
||||
</span>
|
||||
<strong class="menu-title">{{ item.title }}</strong>
|
||||
</div>
|
||||
<el-button text type="danger" class="menu-action" @click.stop="$emit('remove', item.path)">移除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-box">
|
||||
<el-empty :description="emptyDescription" :image-size="88" />
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, toRefs } from 'vue'
|
||||
import type { HomeMenuItem } from '@/utils/home'
|
||||
|
||||
const props = defineProps<{
|
||||
favorites: HomeMenuItem[]
|
||||
availableMenus: HomeMenuItem[]
|
||||
favoriteCount: number
|
||||
favoriteLimit: number
|
||||
}>()
|
||||
const { favorites, availableMenus, favoriteCount, favoriteLimit } = toRefs(props)
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [path: string]
|
||||
add: [path: string]
|
||||
remove: [path: string]
|
||||
}>()
|
||||
|
||||
const selectedPath = ref('')
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const filteredFavorites = computed(() => {
|
||||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||||
if (!keyword) return props.favorites
|
||||
|
||||
return props.favorites.filter(item => {
|
||||
const searchFields = [item.title, item.name]
|
||||
return searchFields.some(field => field?.toLowerCase().includes(keyword))
|
||||
})
|
||||
})
|
||||
|
||||
const emptyDescription = computed(() => {
|
||||
return searchKeyword.value.trim() ? '未找到匹配的收藏菜单' : '暂无收藏菜单'
|
||||
})
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!selectedPath.value) return
|
||||
|
||||
const path = selectedPath.value
|
||||
emit('add', path)
|
||||
selectedPath.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.panel-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.panel-card :deep(.el-card__header) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-card :deep(.el-card__body) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.favorite-adder {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.menu-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.list-scroll {
|
||||
--list-item-min-height: 74px;
|
||||
flex: 1;
|
||||
min-height: calc(var(--list-item-min-height) * 3 + 24px);
|
||||
overflow-y: auto;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: var(--list-item-min-height);
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #e7edf5;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(82, 106, 222, 0.35);
|
||||
box-shadow: 0 14px 28px rgba(82, 106, 222, 0.12);
|
||||
}
|
||||
|
||||
.menu-main {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 18px;
|
||||
color: var(--el-color-primary);
|
||||
border-radius: 14px;
|
||||
background: rgba(82, 106, 222, 0.12);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: #172033;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.menu-action {
|
||||
flex-shrink: 0;
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.empty-box {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(74px * 3 + 24px);
|
||||
border: 1px dashed #d8e0eb;
|
||||
border-radius: 16px;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
@media (max-width: 1600px) {
|
||||
.panel-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.favorite-adder,
|
||||
.menu-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.list-scroll,
|
||||
.empty-box {
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
236
frontend/src/views/home/components/RecentVisitedCard.vue
Normal file
236
frontend/src/views/home/components/RecentVisitedCard.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<el-card shadow="hover" class="panel-card">
|
||||
<template #header>
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h3 class="panel-title">最近访问</h3>
|
||||
<p class="panel-subtitle">自动记录最近打开过的业务页面,方便快速回到刚才的上下文。</p>
|
||||
</div>
|
||||
<el-button text type="primary" :disabled="!items.length" @click="$emit('clear')">清空记录</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="panel-body">
|
||||
<el-input
|
||||
v-if="items.length"
|
||||
v-model.trim="searchKeyword"
|
||||
clearable
|
||||
class="search-input"
|
||||
placeholder="搜索最近访问,支持菜单名称"
|
||||
/>
|
||||
|
||||
<div v-if="filteredItems.length" class="list-scroll">
|
||||
<div class="visit-list">
|
||||
<div v-for="item in filteredItems" :key="item.path" class="visit-item" @click="$emit('navigate', item.path)">
|
||||
<div class="visit-main">
|
||||
<span class="visit-icon">
|
||||
<el-icon>
|
||||
<component :is="item.icon"></component>
|
||||
</el-icon>
|
||||
</span>
|
||||
<div class="visit-text">
|
||||
<strong>{{ item.title }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="visit-side">
|
||||
<span class="visit-time">{{ formatRecentVisitedTime(item.visitedAt) }}</span>
|
||||
<el-button text type="primary" @click.stop="$emit('toggle-favorite', item.path)">
|
||||
{{ favoritePaths.includes(item.path) ? '取消收藏' : '收藏' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-box">
|
||||
<el-empty :description="emptyDescription" :image-size="88" />
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, toRefs } from 'vue'
|
||||
import type { HomeResolvedRecentItem } from '@/utils/home'
|
||||
import { formatRecentVisitedTime } from '@/utils/home'
|
||||
|
||||
const props = defineProps<{
|
||||
items: HomeResolvedRecentItem[]
|
||||
favoritePaths: string[]
|
||||
}>()
|
||||
const { items, favoritePaths } = toRefs(props)
|
||||
|
||||
defineEmits<{
|
||||
navigate: [path: string]
|
||||
toggleFavorite: [path: string]
|
||||
clear: []
|
||||
}>()
|
||||
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||||
if (!keyword) return props.items
|
||||
|
||||
return props.items.filter(item => {
|
||||
const searchFields = [item.title, item.name]
|
||||
return searchFields.some(field => field?.toLowerCase().includes(keyword))
|
||||
})
|
||||
})
|
||||
|
||||
const emptyDescription = computed(() => {
|
||||
return searchKeyword.value.trim() ? '未找到匹配的访问记录' : '暂无最近访问记录'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.panel-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.panel-card :deep(.el-card__header) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-card :deep(.el-card__body) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.list-scroll {
|
||||
--list-item-min-height: 86px;
|
||||
flex: 1;
|
||||
min-height: calc(var(--list-item-min-height) * 3 + 24px);
|
||||
overflow-y: auto;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.visit-list {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.visit-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
min-height: var(--list-item-min-height);
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e7edf5;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.visit-item:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(34, 197, 94, 0.28);
|
||||
box-shadow: 0 14px 28px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.visit-main {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.visit-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 18px;
|
||||
color: #159957;
|
||||
border-radius: 14px;
|
||||
background: rgba(21, 153, 87, 0.12);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.visit-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.visit-text strong {
|
||||
overflow: hidden;
|
||||
color: #172033;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.visit-side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.visit-time {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.empty-box {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 206px;
|
||||
border: 1px dashed #d8e0eb;
|
||||
border-radius: 16px;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
</style>
|
||||
88
frontend/src/views/home/components/ToolGrid.vue
Normal file
88
frontend/src/views/home/components/ToolGrid.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<section class="tool-section">
|
||||
<div class="tool-header">
|
||||
<div>
|
||||
<p class="tool-eyebrow">Toolkit</p>
|
||||
<h2 class="tool-title">本地工具中心</h2>
|
||||
</div>
|
||||
<p class="tool-desc">所有工具都在浏览器本地处理,不上传文本、图片或文件内容。</p>
|
||||
</div>
|
||||
|
||||
<div class="tool-grid">
|
||||
<JsonTool />
|
||||
<TimestampTool />
|
||||
<CodecTool />
|
||||
<UuidTool />
|
||||
<TextTool />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import CodecTool from './tools/CodecTool.vue'
|
||||
import JsonTool from './tools/JsonTool.vue'
|
||||
import TextTool from './tools/TextTool.vue'
|
||||
import TimestampTool from './tools/TimestampTool.vue'
|
||||
import UuidTool from './tools/UuidTool.vue'
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tool-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.tool-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tool-eyebrow {
|
||||
margin: 0 0 6px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.16em;
|
||||
color: #526ade;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tool-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.tool-desc {
|
||||
max-width: 440px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #64748b;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.tool-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.tool-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tool-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tool-desc {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
109
frontend/src/views/home/components/tools/CodecTool.vue
Normal file
109
frontend/src/views/home/components/tools/CodecTool.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<HomeToolCard title="编码转换" description="提供 Base64 与 URL 的本地编码和解码能力。" icon="Connection">
|
||||
<div class="tool-body">
|
||||
<el-radio-group v-model="mode" class="mode-group">
|
||||
<el-radio-button label="base64Encode">Base64 编码</el-radio-button>
|
||||
<el-radio-button label="base64Decode">Base64 解码</el-radio-button>
|
||||
<el-radio-button label="urlEncode">URL 编码</el-radio-button>
|
||||
<el-radio-button label="urlDecode">URL 解码</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<div class="editor-grid">
|
||||
<el-input v-model="sourceText" type="textarea" :rows="9" resize="none" placeholder="请输入原始文本" />
|
||||
<el-input
|
||||
v-model="resultText"
|
||||
type="textarea"
|
||||
:rows="9"
|
||||
resize="none"
|
||||
readonly
|
||||
placeholder="转换结果会显示在这里"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="errorMessage" :title="errorMessage" type="error" :closable="false" show-icon />
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="transformText">执行转换</el-button>
|
||||
<el-button @click="copyResult">复制结果</el-button>
|
||||
<el-button @click="clearAll">清空</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</HomeToolCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import HomeToolCard from './HomeToolCard.vue'
|
||||
import { copyText, decodeBase64, encodeBase64 } from '@/utils/home'
|
||||
|
||||
const mode = ref('base64Encode')
|
||||
const sourceText = ref('')
|
||||
const resultText = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const transformText = () => {
|
||||
try {
|
||||
errorMessage.value = ''
|
||||
|
||||
switch (mode.value) {
|
||||
case 'base64Encode':
|
||||
resultText.value = encodeBase64(sourceText.value)
|
||||
break
|
||||
case 'base64Decode':
|
||||
resultText.value = decodeBase64(sourceText.value)
|
||||
break
|
||||
case 'urlEncode':
|
||||
resultText.value = encodeURIComponent(sourceText.value)
|
||||
break
|
||||
case 'urlDecode':
|
||||
resultText.value = decodeURIComponent(sourceText.value)
|
||||
break
|
||||
default:
|
||||
resultText.value = sourceText.value
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = (error as Error).message || '转换失败,请检查输入内容。'
|
||||
}
|
||||
}
|
||||
|
||||
const copyResult = async () => {
|
||||
await copyText(resultText.value)
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
sourceText.value = ''
|
||||
resultText.value = ''
|
||||
errorMessage.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tool-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.mode-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.editor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
82
frontend/src/views/home/components/tools/HomeToolCard.vue
Normal file
82
frontend/src/views/home/components/tools/HomeToolCard.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<el-card shadow="hover" class="tool-card">
|
||||
<template #header>
|
||||
<div class="tool-head">
|
||||
<div class="tool-title-wrap">
|
||||
<span class="tool-icon">
|
||||
<el-icon>
|
||||
<component :is="icon"></component>
|
||||
</el-icon>
|
||||
</span>
|
||||
<div>
|
||||
<h3 class="tool-title">{{ title }}</h3>
|
||||
<p class="tool-description">{{ description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-extra">
|
||||
<slot name="extra"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<slot></slot>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tool-card {
|
||||
height: 100%;
|
||||
border: 0;
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.tool-head {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tool-title-wrap {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
font-size: 18px;
|
||||
color: var(--el-color-primary);
|
||||
border-radius: 14px;
|
||||
background: rgba(82, 106, 222, 0.12);
|
||||
}
|
||||
|
||||
.tool-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.tool-description {
|
||||
margin: 5px 0 0;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.tool-extra {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
109
frontend/src/views/home/components/tools/JsonTool.vue
Normal file
109
frontend/src/views/home/components/tools/JsonTool.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<HomeToolCard title="JSON 格式化" description="校验、格式化和压缩 JSON 文本。" icon="DocumentCopy">
|
||||
<div class="tool-body">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="formatJson">格式化</el-button>
|
||||
<el-button @click="compressJson">压缩</el-button>
|
||||
<el-button @click="validateJson">校验</el-button>
|
||||
<el-button @click="copyResult">复制结果</el-button>
|
||||
<el-button @click="clearAll">清空</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="statusMessage" :title="statusMessage" :type="statusType" :closable="false" show-icon />
|
||||
|
||||
<div class="editor-grid">
|
||||
<el-input v-model="sourceText" type="textarea" :rows="10" resize="none" placeholder="请输入 JSON 文本" />
|
||||
<el-input
|
||||
v-model="resultText"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
resize="none"
|
||||
readonly
|
||||
placeholder="处理结果会显示在这里"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</HomeToolCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import HomeToolCard from './HomeToolCard.vue'
|
||||
import { copyText } from '@/utils/home'
|
||||
|
||||
const sourceText = ref('')
|
||||
const resultText = ref('')
|
||||
const statusMessage = ref('')
|
||||
const statusType = ref<'success' | 'error' | 'info'>('info')
|
||||
|
||||
const setStatus = (message: string, type: 'success' | 'error' | 'info') => {
|
||||
statusMessage.value = message
|
||||
statusType.value = type
|
||||
}
|
||||
|
||||
const parseJson = () => JSON.parse(sourceText.value)
|
||||
|
||||
const formatJson = () => {
|
||||
try {
|
||||
resultText.value = JSON.stringify(parseJson(), null, 2)
|
||||
setStatus('JSON 校验通过,已完成格式化。', 'success')
|
||||
} catch (error) {
|
||||
setStatus((error as Error).message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const compressJson = () => {
|
||||
try {
|
||||
resultText.value = JSON.stringify(parseJson())
|
||||
setStatus('JSON 校验通过,已压缩为单行。', 'success')
|
||||
} catch (error) {
|
||||
setStatus((error as Error).message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const validateJson = () => {
|
||||
try {
|
||||
parseJson()
|
||||
setStatus('JSON 有效。', 'success')
|
||||
} catch (error) {
|
||||
setStatus((error as Error).message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const copyResult = async () => {
|
||||
await copyText(resultText.value || sourceText.value)
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
sourceText.value = ''
|
||||
resultText.value = ''
|
||||
statusMessage.value = ''
|
||||
statusType.value = 'info'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tool-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.editor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.editor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
174
frontend/src/views/home/components/tools/TextTool.vue
Normal file
174
frontend/src/views/home/components/tools/TextTool.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<HomeToolCard title="文本处理" description="常见文本清洗能力:去空行、去重、排序与统计。" icon="Files">
|
||||
<div class="tool-body">
|
||||
<div class="option-grid">
|
||||
<el-checkbox v-model="options.trimEachLine">去除每行首尾空格</el-checkbox>
|
||||
<el-checkbox v-model="options.removeEmpty">去空行</el-checkbox>
|
||||
<el-checkbox v-model="options.unique">去重</el-checkbox>
|
||||
<el-checkbox v-model="options.sort">排序</el-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="processText">处理文本</el-button>
|
||||
<el-button @click="copyResult">复制结果</el-button>
|
||||
<el-button @click="clearAll">清空</el-button>
|
||||
</div>
|
||||
|
||||
<div class="editor-grid">
|
||||
<el-input v-model="sourceText" type="textarea" :rows="10" resize="none" placeholder="请输入多行文本" />
|
||||
<el-input
|
||||
v-model="resultText"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
resize="none"
|
||||
readonly
|
||||
placeholder="处理结果会显示在这里"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stats-item">
|
||||
<span>输入总行数</span>
|
||||
<strong>{{ stats.sourceLines }}</strong>
|
||||
</div>
|
||||
<div class="stats-item">
|
||||
<span>输入非空行</span>
|
||||
<strong>{{ stats.sourceNonEmptyLines }}</strong>
|
||||
</div>
|
||||
<div class="stats-item">
|
||||
<span>输出总行数</span>
|
||||
<strong>{{ stats.resultLines }}</strong>
|
||||
</div>
|
||||
<div class="stats-item">
|
||||
<span>输出非空行</span>
|
||||
<strong>{{ stats.resultNonEmptyLines }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HomeToolCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import HomeToolCard from './HomeToolCard.vue'
|
||||
import { copyText } from '@/utils/home'
|
||||
|
||||
const sourceText = ref('')
|
||||
const resultText = ref('')
|
||||
const options = reactive({
|
||||
trimEachLine: true,
|
||||
removeEmpty: true,
|
||||
unique: false,
|
||||
sort: false
|
||||
})
|
||||
const stats = reactive({
|
||||
sourceLines: 0,
|
||||
sourceNonEmptyLines: 0,
|
||||
resultLines: 0,
|
||||
resultNonEmptyLines: 0
|
||||
})
|
||||
|
||||
const updateStats = (sourceLines: string[], resultLines: string[]) => {
|
||||
stats.sourceLines = sourceLines.length
|
||||
stats.sourceNonEmptyLines = sourceLines.filter(line => line.trim()).length
|
||||
stats.resultLines = resultLines.length
|
||||
stats.resultNonEmptyLines = resultLines.filter(line => line.trim()).length
|
||||
}
|
||||
|
||||
const processText = () => {
|
||||
const originalLines = sourceText.value.split(/\r?\n/)
|
||||
let nextLines = [...originalLines]
|
||||
|
||||
if (options.trimEachLine) {
|
||||
nextLines = nextLines.map(line => line.trim())
|
||||
}
|
||||
|
||||
if (options.removeEmpty) {
|
||||
nextLines = nextLines.filter(Boolean)
|
||||
}
|
||||
|
||||
if (options.unique) {
|
||||
nextLines = Array.from(new Set(nextLines))
|
||||
}
|
||||
|
||||
if (options.sort) {
|
||||
nextLines = [...nextLines].sort((left, right) => left.localeCompare(right, 'zh-Hans-CN', { numeric: true }))
|
||||
}
|
||||
|
||||
resultText.value = nextLines.join('\n')
|
||||
updateStats(originalLines, nextLines)
|
||||
}
|
||||
|
||||
const copyResult = async () => {
|
||||
await copyText(resultText.value)
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
sourceText.value = ''
|
||||
resultText.value = ''
|
||||
updateStats([], [])
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tool-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.option-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e5edf5;
|
||||
border-radius: 16px;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.editor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stats-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e5edf5;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.stats-item span {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.stats-item strong {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.option-grid,
|
||||
.editor-grid,
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
200
frontend/src/views/home/components/tools/TimestampTool.vue
Normal file
200
frontend/src/views/home/components/tools/TimestampTool.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<HomeToolCard title="时间戳转换" description="支持秒级、毫秒级时间戳和日期文本互转。" icon="Clock">
|
||||
<div class="tool-body">
|
||||
<div class="field-group">
|
||||
<label class="field-label">时间戳</label>
|
||||
<div class="field-row">
|
||||
<el-input v-model="timestampInput" placeholder="请输入 10 位或 13 位时间戳" />
|
||||
<el-button type="primary" @click="convertTimestamp">转日期</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label">日期文本</label>
|
||||
<div class="field-row field-row-date">
|
||||
<el-input v-model="dateInput" placeholder="例如 2026-04-09 21:30:00" />
|
||||
<el-button @click="convertDate">转时间戳</el-button>
|
||||
<el-button @click="fillNow">当前时间</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="errorMessage" :title="errorMessage" type="error" :closable="false" show-icon />
|
||||
|
||||
<div class="result-grid">
|
||||
<div class="result-item">
|
||||
<span>格式化时间</span>
|
||||
<strong>{{ result.formatted || '--' }}</strong>
|
||||
</div>
|
||||
<div class="result-item">
|
||||
<span>秒级时间戳</span>
|
||||
<strong>{{ result.seconds || '--' }}</strong>
|
||||
</div>
|
||||
<div class="result-item">
|
||||
<span>毫秒级时间戳</span>
|
||||
<strong>{{ result.milliseconds || '--' }}</strong>
|
||||
</div>
|
||||
<div class="result-item">
|
||||
<span>ISO 时间</span>
|
||||
<strong>{{ result.iso || '--' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button @click="copyMilliseconds">复制毫秒值</el-button>
|
||||
<el-button @click="copyFormatted">复制日期</el-button>
|
||||
<el-button @click="clearAll">清空</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</HomeToolCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import HomeToolCard from './HomeToolCard.vue'
|
||||
import { copyText, formatDateTime } from '@/utils/home'
|
||||
|
||||
const timestampInput = ref('')
|
||||
const dateInput = ref('')
|
||||
const errorMessage = ref('')
|
||||
const result = reactive({
|
||||
formatted: '',
|
||||
seconds: '',
|
||||
milliseconds: '',
|
||||
iso: ''
|
||||
})
|
||||
|
||||
const updateResult = (milliseconds: number) => {
|
||||
const date = new Date(milliseconds)
|
||||
|
||||
result.formatted = formatDateTime(date)
|
||||
result.seconds = String(Math.floor(milliseconds / 1000))
|
||||
result.milliseconds = String(milliseconds)
|
||||
result.iso = date.toISOString()
|
||||
timestampInput.value = result.milliseconds
|
||||
dateInput.value = result.formatted
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
const parseTimestampInput = () => {
|
||||
const value = timestampInput.value.trim()
|
||||
|
||||
if (/^\d{10}$/.test(value)) return Number(value) * 1000
|
||||
if (/^\d{13}$/.test(value)) return Number(value)
|
||||
return null
|
||||
}
|
||||
|
||||
const convertTimestamp = () => {
|
||||
const milliseconds = parseTimestampInput()
|
||||
if (milliseconds === null) {
|
||||
errorMessage.value = '请输入 10 位秒级或 13 位毫秒级时间戳。'
|
||||
return
|
||||
}
|
||||
|
||||
updateResult(milliseconds)
|
||||
}
|
||||
|
||||
const convertDate = () => {
|
||||
const parsedDate = dayjs(dateInput.value.trim())
|
||||
if (!parsedDate.isValid()) {
|
||||
errorMessage.value = '日期格式无效,请输入可识别的时间文本。'
|
||||
return
|
||||
}
|
||||
|
||||
updateResult(parsedDate.valueOf())
|
||||
}
|
||||
|
||||
const fillNow = () => {
|
||||
updateResult(Date.now())
|
||||
}
|
||||
|
||||
const copyMilliseconds = async () => {
|
||||
await copyText(result.milliseconds, '毫秒时间戳已复制')
|
||||
}
|
||||
|
||||
const copyFormatted = async () => {
|
||||
await copyText(result.formatted, '格式化日期已复制')
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
timestampInput.value = ''
|
||||
dateInput.value = ''
|
||||
errorMessage.value = ''
|
||||
result.formatted = ''
|
||||
result.seconds = ''
|
||||
result.milliseconds = ''
|
||||
result.iso = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tool-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field-row-date {
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
}
|
||||
|
||||
.result-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e5edf5;
|
||||
border-radius: 16px;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.result-item span {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.result-item strong {
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.field-row,
|
||||
.field-row-date,
|
||||
.result-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
101
frontend/src/views/home/components/tools/UuidTool.vue
Normal file
101
frontend/src/views/home/components/tools/UuidTool.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<HomeToolCard title="UUID 生成" description="生成单个或批量 UUID,支持去除中划线。" icon="Tickets">
|
||||
<div class="tool-body">
|
||||
<div class="control-row">
|
||||
<div class="control-item">
|
||||
<label class="field-label">生成数量</label>
|
||||
<el-input-number v-model="count" :min="1" :max="100" />
|
||||
</div>
|
||||
<div class="control-item switch-item">
|
||||
<label class="field-label">去除中划线</label>
|
||||
<el-switch v-model="removeHyphen" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="generateUuid">生成 UUID</el-button>
|
||||
<el-button @click="copyResult">复制结果</el-button>
|
||||
<el-button @click="clearAll">清空</el-button>
|
||||
</div>
|
||||
|
||||
<el-input
|
||||
v-model="resultText"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
resize="none"
|
||||
readonly
|
||||
placeholder="生成结果会显示在这里"
|
||||
/>
|
||||
</div>
|
||||
</HomeToolCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import HomeToolCard from './HomeToolCard.vue'
|
||||
import { copyText, createUuidList } from '@/utils/home'
|
||||
|
||||
const count = ref(1)
|
||||
const removeHyphen = ref(false)
|
||||
const resultText = ref('')
|
||||
|
||||
const generateUuid = () => {
|
||||
resultText.value = createUuidList(count.value, removeHyphen.value).join('\n')
|
||||
}
|
||||
|
||||
const copyResult = async () => {
|
||||
await copyText(resultText.value)
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
count.value = 1
|
||||
removeHyphen.value = false
|
||||
resultText.value = ''
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
generateUuid()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tool-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.control-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.switch-item {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.control-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
125
frontend/src/views/home/index.vue
Normal file
125
frontend/src/views/home/index.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div class="home-view">
|
||||
<div class="home-shell">
|
||||
<section class="quick-section">
|
||||
<FavoriteMenuCard
|
||||
:favorites="favoriteMenus"
|
||||
:available-menus="availableMenus"
|
||||
:favorite-count="homeStore.favoritePaths.length"
|
||||
:favorite-limit="HOME_FAVORITE_LIMIT"
|
||||
@navigate="handleNavigate"
|
||||
@add="handleAddFavorite"
|
||||
@remove="handleRemoveFavorite"
|
||||
/>
|
||||
|
||||
<RecentVisitedCard
|
||||
:items="recentMenus"
|
||||
:favorite-paths="homeStore.favoritePaths"
|
||||
@navigate="handleNavigate"
|
||||
@toggle-favorite="handleToggleFavorite"
|
||||
@clear="handleClearRecent"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<ToolGrid />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/modules/auth'
|
||||
import { useHomeStore } from '@/stores/modules/home'
|
||||
import FavoriteMenuCard from './components/FavoriteMenuCard.vue'
|
||||
import RecentVisitedCard from './components/RecentVisitedCard.vue'
|
||||
import ToolGrid from './components/ToolGrid.vue'
|
||||
import {
|
||||
HOME_FAVORITE_LIMIT,
|
||||
createHomeMenuMap,
|
||||
getHomeValidPaths,
|
||||
resolveHomeMenus,
|
||||
resolveRecentVisited
|
||||
} from '@/utils/home'
|
||||
|
||||
defineOptions({
|
||||
name: 'HomeWorkbench'
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const homeStore = useHomeStore()
|
||||
|
||||
const menuMap = computed(() => createHomeMenuMap(authStore.flatMenuListGet))
|
||||
const validPaths = computed(() => getHomeValidPaths(authStore.flatMenuListGet))
|
||||
|
||||
watch(
|
||||
validPaths,
|
||||
paths => {
|
||||
homeStore.syncWithMenus(paths)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const favoriteMenus = computed(() => resolveHomeMenus(homeStore.favoritePaths, menuMap.value))
|
||||
const recentMenus = computed(() => resolveRecentVisited(homeStore.recentVisited, menuMap.value))
|
||||
const availableMenus = computed(() =>
|
||||
Object.values(menuMap.value).filter(item => !homeStore.favoritePaths.includes(item.path))
|
||||
)
|
||||
|
||||
const handleNavigate = async (path: string) => {
|
||||
if (!path) return
|
||||
await router.push(path)
|
||||
}
|
||||
|
||||
const handleAddFavorite = (path: string) => {
|
||||
homeStore.addFavorite(path)
|
||||
}
|
||||
|
||||
const handleRemoveFavorite = (path: string) => {
|
||||
homeStore.removeFavorite(path)
|
||||
}
|
||||
|
||||
const handleToggleFavorite = (path: string) => {
|
||||
homeStore.toggleFavorite(path)
|
||||
}
|
||||
|
||||
const handleClearRecent = () => {
|
||||
homeStore.clearRecentVisited()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.home-view {
|
||||
min-height: 100%;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(82, 106, 222, 0.18), transparent 30%),
|
||||
radial-gradient(circle at top right, rgba(22, 163, 74, 0.12), transparent 28%),
|
||||
linear-gradient(180deg, #f6f8fc 0%, #eef2f7 100%);
|
||||
}
|
||||
|
||||
.home-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.quick-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.quick-section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.home-view {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
119
frontend/src/views/log/index.vue
Normal file
119
frontend/src/views/log/index.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="table-box">
|
||||
<ProTable ref="proTable" :columns="columns" :request-api="getTableList">
|
||||
<!-- 表格 header 按钮 -->
|
||||
<template #tableHeader>
|
||||
<!-- <el-button type="primary" :icon="DataAnalysis">分析</el-button>-->
|
||||
<el-button type="primary" icon="Download" @click="handleExport">导出csv</el-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="tsx" name="useProTable">
|
||||
// 根据实际路径调整
|
||||
import TimeControl from '@/components/TimeControl/index.vue'
|
||||
import ProTable from '@/components/ProTable/index.vue'
|
||||
import type { ColumnProps, ProTableInstance } from '@/components/ProTable/interface'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { exportCsv, getAuditLog } from '@/api/system/log'
|
||||
import { useDownload } from '@/hooks/useDownload'
|
||||
|
||||
// defineOptions({
|
||||
// name: 'log'
|
||||
// })
|
||||
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
// ProTable 实例
|
||||
const proTable = ref<ProTableInstance>()
|
||||
|
||||
const getTableList = async (params: any) => {
|
||||
let newParams = JSON.parse(JSON.stringify(params))
|
||||
newParams.searchEndTime = endDate.value
|
||||
newParams.searchBeginTime = startDate.value
|
||||
return getAuditLog(newParams)
|
||||
}
|
||||
|
||||
// 表格配置项
|
||||
const columns = reactive<ColumnProps[]>([
|
||||
{ type: 'selection', fixed: 'left', width: 70 },
|
||||
{ type: 'index', fixed: 'left', width: 70, label: '序号' },
|
||||
{
|
||||
prop: 'userName',
|
||||
label: '操作用户',
|
||||
search: { el: 'input' },
|
||||
minWidth: 100
|
||||
},
|
||||
{
|
||||
prop: 'ip',
|
||||
label: 'IP',
|
||||
minWidth: 120
|
||||
},
|
||||
{
|
||||
prop: 'logTime',
|
||||
label: '记录时间',
|
||||
minWidth: 180,
|
||||
search: {
|
||||
render: () => {
|
||||
return (
|
||||
<div class="flx-flex-start">
|
||||
<TimeControl
|
||||
include={['日', '周', '月', '自定义']}
|
||||
default={'月'}
|
||||
onUpdate-dates={handleDateChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '事件描述',
|
||||
minWidth: 450,
|
||||
render(scope) {
|
||||
return `${scope.row.userName}在${scope.row.logTime}执行了【${scope.row.operateType}】${scope.row.operate}操作,结果为${scope.row.result}。`
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'result',
|
||||
label: '事件结果',
|
||||
minWidth: 120
|
||||
},
|
||||
{
|
||||
prop: 'warn',
|
||||
label: '告警标志',
|
||||
minWidth: 100,
|
||||
render: scope => {
|
||||
return (
|
||||
<el-tag type={scope.row.warn == 1 ? 'danger' : 'success'}>
|
||||
{scope.row.warn == 1 ? '已告警' : '未告警'}
|
||||
</el-tag>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'operateType',
|
||||
label: '日志类型',
|
||||
width: 100
|
||||
}
|
||||
])
|
||||
|
||||
// 处理日期变化的回调函数
|
||||
const handleDateChange = (startDateTemp: string, endDateTemp: string) => {
|
||||
startDate.value = startDateTemp
|
||||
endDate.value = endDateTemp
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
// 获取当前的搜索参数
|
||||
let searchParam = proTable.value?.searchParam || {}
|
||||
|
||||
// 将开始时间和结束时间添加到搜索参数中
|
||||
searchParam.searchBeginTime = startDate.value
|
||||
searchParam.searchEndTime = endDate.value
|
||||
|
||||
useDownload(exportCsv, '日志列表', searchParam, false, '.csv')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
158
frontend/src/views/login/components/LoginForm.vue
Normal file
158
frontend/src/views/login/components/LoginForm.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" size="large">
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="loginForm.username" placeholder="用户名">
|
||||
<template #prefix>
|
||||
<el-icon class="el-input__icon">
|
||||
<user />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon class="el-input__icon">
|
||||
<lock />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="checked" v-show="false">
|
||||
<el-checkbox v-model="loginForm.checked">记住我</el-checkbox>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="login-btn">
|
||||
<el-button :icon="UserFilled" round size="large" type="primary" :loading="loading" @click="login(loginFormRef)">
|
||||
登录
|
||||
</el-button>
|
||||
<el-button :icon="CircleClose" round size="large" @click="resetForm(loginFormRef)">重置</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { HOME_URL } from '@/config'
|
||||
import { getTimeState } from '@/utils'
|
||||
import { type Dict } from '@/api/interface'
|
||||
import { type Login } from '@/api/user/interface/user'
|
||||
import type { ElForm } from 'element-plus'
|
||||
import { ElNotification } from 'element-plus'
|
||||
import { getDictList, getPublicKey, loginApi } from '@/api/user/login'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import { useTabsStore } from '@/stores/modules/tabs'
|
||||
import { useKeepAliveStore } from '@/stores/modules/keepAlive'
|
||||
import { initDynamicRouter } from '@/routers/modules/dynamicRouter'
|
||||
import { CircleClose, UserFilled } from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/modules/auth'
|
||||
import { useDictStore } from '@/stores/modules/dict'
|
||||
import forge from 'node-forge'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const tabsStore = useTabsStore()
|
||||
const keepAliveStore = useKeepAliveStore()
|
||||
|
||||
const dictStore = useDictStore()
|
||||
let publicKey: any = null
|
||||
|
||||
type FormInstance = InstanceType<typeof ElForm>
|
||||
const loginFormRef = ref<FormInstance>()
|
||||
const loginRules = reactive({
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const loginForm = reactive<Login.ReqLoginForm>({
|
||||
username: '',
|
||||
password: '',
|
||||
checked: false
|
||||
})
|
||||
|
||||
// login
|
||||
const login = (formEl: FormInstance | undefined) => {
|
||||
if (!formEl) return
|
||||
formEl.validate(async valid => {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
let { data: publicKeyBase64 }: { data: string } = await getPublicKey(loginForm.username)
|
||||
//将base64格式的公钥转换为Forge可以使用的格式
|
||||
const publicKeyDer = forge.util.decode64(publicKeyBase64)
|
||||
publicKey = forge.pki.publicKeyFromPem(
|
||||
forge.pki.publicKeyToPem(forge.pki.publicKeyFromAsn1(forge.asn1.fromDer(publicKeyDer)))
|
||||
)
|
||||
|
||||
// 1.执行登录接口
|
||||
const { data } = await loginApi({
|
||||
username: forge.util.encode64(loginForm.username),
|
||||
password: encryptPassword(loginForm.password)
|
||||
})
|
||||
ElNotification({
|
||||
title: getTimeState(),
|
||||
message: '登录成功',
|
||||
type: 'success',
|
||||
duration: 3000
|
||||
})
|
||||
userStore.setAccessToken(data.accessToken)
|
||||
userStore.setRefreshToken(data.refreshToken)
|
||||
userStore.setUserInfo(data.userInfo)
|
||||
if (loginForm.checked) {
|
||||
userStore.setExp(Date.now() + 1000 * 60 * 60 * 24 * 30)
|
||||
}
|
||||
// 设置激活信息
|
||||
await authStore.setActivateInfo()
|
||||
const response = await getDictList()
|
||||
const dictData = response.data as unknown as Dict[]
|
||||
await dictStore.initDictData(dictData)
|
||||
// 2.添加动态路由
|
||||
await initDynamicRouter()
|
||||
|
||||
// 3.清空 tabs、keepAlive 数据
|
||||
await tabsStore.setTabs([])
|
||||
await keepAliveStore.setKeepAliveName([])
|
||||
// 登录默认不显示菜单和导航栏
|
||||
await authStore.setShowMenu()
|
||||
// 跳转到首页
|
||||
await router.push(HOME_URL)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// resetForm
|
||||
const resetForm = (formEl: FormInstance | undefined) => {
|
||||
if (!formEl) return
|
||||
formEl.resetFields()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 监听 enter 事件(调用登录)
|
||||
document.onkeydown = (e: KeyboardEvent) => {
|
||||
e = (window.event as KeyboardEvent) || e
|
||||
if (e.code === 'Enter' || e.code === 'enter' || e.code === 'NumpadEnter') {
|
||||
if (loading.value) return
|
||||
login(loginFormRef.value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const encryptPassword = (password: string) => {
|
||||
const encrypted = publicKey.encrypt(password, 'RSAES-PKCS1-V1_5')
|
||||
// 将加密后的数据转换为base64格式以便传输
|
||||
return forge.util.encode64(encrypted)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '../index.scss';
|
||||
</style>
|
||||
85
frontend/src/views/login/index.scss
Normal file
85
frontend/src/views/login/index.scss
Normal file
@@ -0,0 +1,85 @@
|
||||
.login-container {
|
||||
height: 100%;
|
||||
min-height: 550px;
|
||||
// background-color: #eeeeee;
|
||||
background-color: var(--el-color-primary);
|
||||
background-image: url("@/assets/images/login_bg.svg");
|
||||
background-size: 100% 100%;
|
||||
background-size: cover;
|
||||
.login-box {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
width: 96.5%;
|
||||
height: 94%;
|
||||
padding: 0 50px;
|
||||
// background-color: rgb(255 255 255 / 80%);
|
||||
border-radius: 10px;
|
||||
.dark {
|
||||
position: absolute;
|
||||
top: 13px;
|
||||
right: 18px;
|
||||
}
|
||||
.login-left {
|
||||
width: 800px;
|
||||
margin-right: 10px;
|
||||
.login-left-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
.login-form {
|
||||
width: 420px;
|
||||
padding: 50px 40px 45px;
|
||||
background-color: var(--el-bg-color);
|
||||
border-radius: 10px;
|
||||
box-shadow: rgb(0 0 0 / 10%) 0 2px 10px 2px;
|
||||
.login-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 45px;
|
||||
padding: 0 20px;
|
||||
.login-icon {
|
||||
width: 60px;
|
||||
height: auto;
|
||||
}
|
||||
.logo-text {
|
||||
padding: 0 0 0 25px;
|
||||
margin: 0;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #34495e;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
.el-form-item {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.login-btn {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 40px;
|
||||
white-space: nowrap;
|
||||
.el-button {
|
||||
width: 185px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 1250px) {
|
||||
.login-left {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 600px) {
|
||||
.login-form {
|
||||
width: 97% !important;
|
||||
}
|
||||
}
|
||||
26
frontend/src/views/login/index.vue
Normal file
26
frontend/src/views/login/index.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div class="login-container flx-center">
|
||||
<div class="login-box">
|
||||
<div class="login-left">
|
||||
<img class="login-left-img" src="@/assets/images/login_left.png" alt="login" />
|
||||
</div>
|
||||
<div class="login-form">
|
||||
<div class="login-logo">
|
||||
<img class="login-icon" src="@/assets/images/cn_tool_logo.png" alt="" />
|
||||
<h2 class="logo-text">{{ title }}</h2>
|
||||
</div>
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="login">
|
||||
import LoginForm from './components/LoginForm.vue'
|
||||
|
||||
const title = import.meta.env.VITE_GLOB_APP_TITLE
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use './index.scss';
|
||||
</style>
|
||||
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle"
|
||||
v-bind="dialogSmall" @close="close" align-center>
|
||||
<div>
|
||||
<el-form :model="formContent" ref="dialogFormRef" :rules="rules">
|
||||
<el-form-item label="字典类型" :label-width="100">
|
||||
<el-input :value="dictTypeName" disabled/>
|
||||
</el-form-item>
|
||||
<el-form-item label="名称" :label-width="100" prop="name">
|
||||
<el-input v-model="formContent.name" placeholder="请输入" autocomplete="off" maxlength="32" show-word-limit/>
|
||||
</el-form-item>
|
||||
<el-form-item label="编码" :label-width="100" prop="code">
|
||||
<el-input v-model="formContent.code" placeholder="请输入" autocomplete="off" maxlength="32" show-word-limit/>
|
||||
</el-form-item>
|
||||
<el-form-item label="开启值描述" :label-width="100" >
|
||||
<el-radio-group v-model="formContent.openValue" @change="handleOpenValueChange">
|
||||
<el-radio-button label="开启" :value="1"></el-radio-button>
|
||||
<el-radio-button label="关闭" :value="0"></el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="值" :label-width="100" prop="value" v-if="formContent.openValue==1">
|
||||
<el-input v-model="formContent.value" placeholder="请输入" autocomplete="off"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="事件等级" :label-width="100" prop="level" v-if="false">
|
||||
<el-select v-model.number="formContent.level">
|
||||
<el-option label="普通" :value="0"/>
|
||||
<el-option label="中等" :value="1"/>
|
||||
<el-option label="严重" :value="2"/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" :label-width="100">
|
||||
<el-input-number v-model="formContent.sort" :min='1' :max='999'/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="close()">取消</el-button>
|
||||
<el-button type="primary" @click="save()">
|
||||
保存
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<script lang="tsx" setup>
|
||||
import {dialogSmall} from "@/utils/elementBind";
|
||||
import {addDictData, updateDictData} from "@/api/system/dictionary/dictData/index.ts";
|
||||
import {Dict} from "@/api/system/dictionary/interface";
|
||||
import {ElMessage, FormItemRule} from "element-plus";
|
||||
import {computed, Ref} from "vue";
|
||||
|
||||
const rules: Ref<Record<string, Array<FormItemRule>>> = ref({
|
||||
name: [{required: true, message: '类型名称必填!', trigger: 'blur'}],
|
||||
code: [{required: true, message: '类型编码必填!', trigger: 'blur'}],
|
||||
})
|
||||
|
||||
const dialogFormRef = ref()
|
||||
const {dialogVisible, titleType, formContent, dictTypeName} = useMetaInfo();
|
||||
|
||||
function useMetaInfo() {
|
||||
const dialogVisible = ref(false)
|
||||
const titleType = ref('add')
|
||||
const dictTypeName = ref('')
|
||||
|
||||
const formContent = ref<Dict.ResDictData>({
|
||||
id: "",
|
||||
typeId: "",
|
||||
name: "",
|
||||
code: "",
|
||||
value: "",
|
||||
//dictValue: "",
|
||||
level: 0,
|
||||
sort: 100,
|
||||
state: 1,
|
||||
openValue:0
|
||||
})
|
||||
|
||||
return {dialogVisible, titleType, formContent, dictTypeName};
|
||||
}
|
||||
|
||||
let dialogTitle = computed(() => {
|
||||
return titleType.value === 'add' ? '新增字典数据' : '编辑字典数据'
|
||||
})
|
||||
|
||||
const resetFormContent = () => {
|
||||
formContent.value = {
|
||||
id: "",
|
||||
typeId: "",
|
||||
name: "",
|
||||
code: "",
|
||||
value: "",
|
||||
//dictValue: "",
|
||||
level: 0,
|
||||
sort: 100,
|
||||
state: 1,
|
||||
openValue:0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const handleOpenValueChange = ()=> {
|
||||
if(formContent.value.openValue == 0){
|
||||
formContent.value.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
dialogVisible.value = false
|
||||
resetFormContent()
|
||||
dialogFormRef.value?.resetFields()
|
||||
}
|
||||
const open = (sign: string, typeId: string, name: string, data: Dict.ResDictData) => {
|
||||
// 重置表单
|
||||
dialogFormRef.value?.resetFields()
|
||||
resetFormContent()
|
||||
|
||||
titleType.value = sign
|
||||
formContent.value.typeId = typeId
|
||||
dictTypeName.value = name
|
||||
dialogVisible.value = true
|
||||
if (data.id) {
|
||||
formContent.value = {...data}
|
||||
//formContent.value.dictValue = data.value
|
||||
}
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
try {
|
||||
dialogFormRef.value?.validate(async (valid: boolean) => {
|
||||
if(formContent.value.openValue === 0){
|
||||
formContent.value.value = null
|
||||
}
|
||||
|
||||
if (valid) {
|
||||
if (formContent.value.id) {
|
||||
await updateDictData(formContent.value)
|
||||
} else {
|
||||
await addDictData(formContent.value)
|
||||
}
|
||||
ElMessage.success({message: `${dialogTitle.value}成功!`})
|
||||
close()
|
||||
await props.refreshTable!()
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('验证过程中出现错误', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 对外映射
|
||||
defineExpose({open})
|
||||
const props = defineProps<{
|
||||
refreshTable: (() => Promise<void>) | undefined;
|
||||
}>()
|
||||
</script>
|
||||
194
frontend/src/views/system/dictionary/dictData/index.vue
Normal file
194
frontend/src/views/system/dictionary/dictData/index.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
class="table-box"
|
||||
v-model="dialogVisible"
|
||||
top="114px"
|
||||
:style="{ height: height + 'px', maxHeight: height + 'px', overflow: 'hidden' }"
|
||||
title="字典数据"
|
||||
:width="width"
|
||||
:modal="false"
|
||||
>
|
||||
<div
|
||||
class="table-box"
|
||||
:style="{ height: height - 64 + 'px', maxHeight: height - 64 + 'px', overflow: 'hidden' }"
|
||||
>
|
||||
<ProTable ref="proTable" :columns="columns" :request-api="getDictDataListByTypeId" :initParam="initParam">
|
||||
<template #tableHeader="scope">
|
||||
<el-button v-auth.dict="'show_add'" type="primary" :icon="CirclePlus" @click="openDialog('add')">
|
||||
新增
|
||||
</el-button>
|
||||
<el-button v-auth.dict="'show_export'" type="primary" :icon="Download" plain @click="downloadFile">
|
||||
导出
|
||||
</el-button>
|
||||
<el-button
|
||||
v-auth.dict="'show_delete'"
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
plain
|
||||
:disabled="!scope.isSelected"
|
||||
@click="batchDelete(scope.selectedListIds)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<template #operation="scope">
|
||||
<el-button
|
||||
v-auth.dict="'show_edit'"
|
||||
type="primary"
|
||||
link
|
||||
:icon="EditPen"
|
||||
@click="openDialog('edit', scope.row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-auth.dict="'show_delete'"
|
||||
type="primary"
|
||||
link
|
||||
:icon="Delete"
|
||||
@click="handleDelete(scope.row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
</div>
|
||||
</el-dialog>
|
||||
<DataPopup :refresh-table="proTable?.getTableList" ref="dataPopup" />
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx" name="dictData">
|
||||
import { CirclePlus, Delete, Download, EditPen } from '@element-plus/icons-vue'
|
||||
import { type Dict } from '@/api/system/dictionary/interface'
|
||||
import { ColumnProps, ProTableInstance } from '@/components/ProTable/interface'
|
||||
import { useHandleData } from '@/hooks/useHandleData'
|
||||
import { deleteDictData, exportDictData, getDictDataListByTypeId } from '@/api/system/dictionary/dictData/index'
|
||||
import { useDownload } from '@/hooks/useDownload'
|
||||
|
||||
defineOptions({
|
||||
name: 'dict'
|
||||
})
|
||||
const proTable = ref<ProTableInstance>()
|
||||
const dialogVisible = ref(false)
|
||||
//字典数据所属的字典类型Id
|
||||
const dictTypeId = ref('')
|
||||
const dictTypeName = ref('')
|
||||
|
||||
const initParam = reactive({ typeId: '' })
|
||||
|
||||
const dataPopup = ref()
|
||||
|
||||
const props = defineProps({
|
||||
width: {
|
||||
type: Number,
|
||||
default: 800
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 744
|
||||
}
|
||||
})
|
||||
|
||||
const columns = reactive<ColumnProps<Dict.ResDictData>[]>([
|
||||
{ type: 'selection', fixed: 'left', width: 70 },
|
||||
{ type: 'index', fixed: 'left', width: 70, label: '序号' },
|
||||
{
|
||||
prop: 'name',
|
||||
label: '名称',
|
||||
minWidth: 180,
|
||||
search: {
|
||||
el: 'input'
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'code',
|
||||
label: '编码',
|
||||
minWidth: 180,
|
||||
search: {
|
||||
el: 'input'
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'value',
|
||||
label: '值',
|
||||
minWidth: 180,
|
||||
render: scope => {
|
||||
if (scope.row.openValue === 0 || scope.row.value === null || scope.row.value === '') {
|
||||
return <span>/</span> // 使用 JSX 返回 VNode
|
||||
}
|
||||
return <span>{scope.row.value}</span> // 使用 JSX 返回 VNode
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'level',
|
||||
label: '事件等级',
|
||||
minWidth: 180,
|
||||
isShow: false,
|
||||
render: scope => {
|
||||
return (
|
||||
<>
|
||||
{
|
||||
<el-tag type={scope.row.level === 0 ? 'info' : scope.row.level === 1 ? 'warning' : 'danger'}>
|
||||
{scope.row.level === 0 ? '普通' : scope.row.level === 1 ? '中等' : '严重'}
|
||||
</el-tag>
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'createTime',
|
||||
label: '创建时间',
|
||||
minWidth: 180
|
||||
},
|
||||
{
|
||||
prop: 'operation',
|
||||
label: '操作',
|
||||
fixed: 'right',
|
||||
width: 200
|
||||
}
|
||||
])
|
||||
|
||||
const open = (row: Dict.ResDictType) => {
|
||||
dialogVisible.value = true
|
||||
dictTypeId.value = row.id
|
||||
dictTypeName.value = row.name
|
||||
initParam.typeId = row.id
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
|
||||
// 打开 dialog(新增、查看、编辑)
|
||||
const openDialog = (titleType: string, row: Partial<Dict.ResDictData> = {}) => {
|
||||
dataPopup.value?.open(titleType, dictTypeId.value, dictTypeName.value, row)
|
||||
}
|
||||
|
||||
// 批量删除字典数据
|
||||
const batchDelete = async (id: string[]) => {
|
||||
await useHandleData(deleteDictData, id, '删除所选字典数据')
|
||||
proTable.value?.clearSelection()
|
||||
proTable.value?.getTableList()
|
||||
}
|
||||
|
||||
// 删除字典数据
|
||||
const handleDelete = async (params: Dict.ResDictData) => {
|
||||
await useHandleData(deleteDictData, [params.id], `删除【${params.name}】字典数据`)
|
||||
proTable.value?.getTableList()
|
||||
}
|
||||
|
||||
const downloadFile = async () => {
|
||||
ElMessageBox.confirm('确认导出字典数据?', '温馨提示', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消'
|
||||
}).then(() =>
|
||||
useDownload(
|
||||
exportDictData,
|
||||
'字典数据导出数据',
|
||||
{ typeId: dictTypeId.value, ...proTable.value?.searchParam },
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<el-dialog v-model='dialogVisible' :title='dialogTitle' v-bind='dialogSmall' @close="close" align-center>
|
||||
<el-form :model='formContent' ref='dialogFormRef' :rules='rules' >
|
||||
<el-form-item label='字典名称' :label-width='100' prop='name'>
|
||||
<el-input v-model='formContent.name' placeholder='请输入字典名称' autocomplete='off' maxlength="32" show-word-limit/>
|
||||
</el-form-item>
|
||||
<el-form-item label='排序' :label-width='100'>
|
||||
<el-input-number v-model='formContent.sort' :min='1' :max='999' />
|
||||
</el-form-item>
|
||||
<el-form-item label='编码' :label-width='100' prop='code'>
|
||||
<el-input v-model='formContent.code' placeholder='请输入字典编码' autocomplete='off' maxlength="32" show-word-limit/>
|
||||
</el-form-item>
|
||||
<el-form-item label='描述' :label-width='100' prop='remark'>
|
||||
<el-input v-model='formContent.remark' placeholder='请输入字典描述' autocomplete='off' :rows="2"
|
||||
type="textarea"/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div class='dialog-footer'>
|
||||
<el-button @click='close()'>取消</el-button>
|
||||
<el-button type='primary' @click='save()'>
|
||||
保存
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<script setup lang='ts'>
|
||||
import { dialogSmall } from '@/utils/elementBind'
|
||||
import { type Dict } from '@/api/system/dictionary/interface'
|
||||
import { ElMessage, type FormItemRule } from 'element-plus'
|
||||
import { addDictTree, updateDictTree } from '@/api/system/dictionary/dictTree'
|
||||
import { computed, type Ref, ref } from 'vue';
|
||||
import { type ResultData } from '@/api/interface';
|
||||
// 定义弹出组件元信息
|
||||
const dialogFormRef = ref()
|
||||
function useMetaInfo() {
|
||||
const dialogVisible = ref(false)
|
||||
const titleType = ref('add')
|
||||
const formContent = ref<Dict.ResDictTree>({
|
||||
id: '',
|
||||
pid: '',
|
||||
pids: '',
|
||||
name: '',
|
||||
code: '',
|
||||
sort: 100,
|
||||
remark: '',
|
||||
state: 0,
|
||||
children: [],
|
||||
})
|
||||
return { dialogVisible, titleType, formContent }
|
||||
}
|
||||
|
||||
const { dialogVisible, titleType, formContent } = useMetaInfo()
|
||||
// 清空formContent
|
||||
const resetFormContent = () => {
|
||||
formContent.value = {
|
||||
id: '',
|
||||
pid: '0',
|
||||
pids: '0',
|
||||
name: '',
|
||||
code: '',
|
||||
sort: 100,
|
||||
remark: '',
|
||||
state: 0,
|
||||
children: [],
|
||||
}
|
||||
}
|
||||
|
||||
let dialogTitle = computed(() => {
|
||||
return titleType.value === 'add' ? '新增字典类型' : '编辑字典类型'
|
||||
})
|
||||
|
||||
// 定义表单校验规则
|
||||
const rules: Ref<Record<string, Array<FormItemRule>>> = ref({
|
||||
name: [{ required: true, message: '字典名称必填!', trigger: 'blur' }],
|
||||
code: [{ required: true, message: '编码必填!', trigger: 'blur' }],
|
||||
})
|
||||
|
||||
// 关闭弹窗
|
||||
const close = () => {
|
||||
dialogVisible.value = false
|
||||
// 清空dialogForm中的值
|
||||
resetFormContent()
|
||||
// 重置表单
|
||||
dialogFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 保存数据
|
||||
const save = () => {
|
||||
try {
|
||||
dialogFormRef.value?.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
if (formContent.value.id) {
|
||||
let result: ResultData<unknown>;
|
||||
if( titleType.value == 'add'){
|
||||
await addDictTree(formContent.value);
|
||||
}else{
|
||||
await updateDictTree(formContent.value);
|
||||
}
|
||||
ElMessage.success({ message: `${dialogTitle.value}成功!` })
|
||||
} else {
|
||||
await addDictTree(formContent.value);
|
||||
ElMessage.success({ message: `${dialogTitle.value}成功!` })
|
||||
|
||||
}
|
||||
close()
|
||||
// 刷新表格
|
||||
await props.refreshTable!()
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('验证过程中出现错误', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开弹窗,可能是新增,也可能是编辑
|
||||
const open = (sign: string, data: Dict.ResDictTree) => {
|
||||
// 重置表单
|
||||
dialogFormRef.value?.resetFields()
|
||||
titleType.value = sign
|
||||
dialogVisible.value = true
|
||||
if (data.id) {
|
||||
if(sign == 'add'){
|
||||
resetFormContent()
|
||||
formContent.value.pid = data.id
|
||||
}else{
|
||||
formContent.value = { ...data }
|
||||
}
|
||||
} else {
|
||||
resetFormContent()
|
||||
}
|
||||
}
|
||||
|
||||
// 对外映射
|
||||
defineExpose({ open })
|
||||
const props = defineProps<{
|
||||
refreshTable: (() => Promise<void>) | undefined;
|
||||
}>()
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
105
frontend/src/views/system/dictionary/dictTree/index.vue
Normal file
105
frontend/src/views/system/dictionary/dictTree/index.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class='table-box' ref='popupBaseView'>
|
||||
<ProTable
|
||||
ref='proTable'
|
||||
:columns='columns'
|
||||
:request-api='getDictTreeByName'
|
||||
:pagination="false"
|
||||
|
||||
>
|
||||
<template #tableHeader>
|
||||
<el-button v-auth.dictTree="'add'" type='primary' :icon='CirclePlus' @click="openDialog('add')">新增</el-button>
|
||||
</template>
|
||||
|
||||
<template #operation='scope'>
|
||||
<el-button v-auth.dictTree="'add'" type='primary' link :icon='CirclePlus' @click="openDialog('add',scope.row)">新增</el-button>
|
||||
<el-button v-auth.dictTree="'edit'" type='primary' link :icon='EditPen' @click="openDialog('edit', scope.row)">编辑</el-button>
|
||||
<el-button v-auth.dictTree="'delete'" type='primary' link :icon='Delete' @click='handleDelete(scope.row)'>删除</el-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
</div>
|
||||
<TreePopup :refresh-table='proTable?.getTableList' ref='treePopup'/>
|
||||
</template>
|
||||
|
||||
<script setup lang='tsx' name='dict'>
|
||||
import {CirclePlus, Delete, EditPen} from '@element-plus/icons-vue'
|
||||
import {type Dict} from '@/api/system/dictionary/interface'
|
||||
import type { ProTableInstance, ColumnProps } from '@/components/ProTable/interface'
|
||||
import TreePopup from '@/views/system/dictionary/dictTree/components/treePopup.vue'
|
||||
import {useDictStore} from '@/stores/modules/dict'
|
||||
import {useHandleData} from '@/hooks/useHandleData'
|
||||
import {
|
||||
getDictTreeByName,
|
||||
deleteDictTree,
|
||||
} from '@/api/system/dictionary/dictTree'
|
||||
import { reactive, ref } from 'vue'
|
||||
defineOptions({
|
||||
name: 'dictTree'
|
||||
})
|
||||
const dictStore = useDictStore()
|
||||
|
||||
const proTable = ref<ProTableInstance>()
|
||||
const treePopup = ref()
|
||||
|
||||
const columns = reactive<ColumnProps<Dict.ResDictTree>[]>([
|
||||
{ type: 'index', fixed: 'left', width: 70, label: '序号' },
|
||||
{
|
||||
prop: 'name',
|
||||
label: '字典名称',
|
||||
align:'left',
|
||||
headerAlign: 'center',
|
||||
search: { el: 'input' },
|
||||
},
|
||||
{
|
||||
prop: 'code',
|
||||
label: '编码',
|
||||
},
|
||||
{
|
||||
prop: 'remark',
|
||||
label: '描述',
|
||||
},
|
||||
{
|
||||
prop: 'sort',
|
||||
label: '排序',
|
||||
width:70,
|
||||
|
||||
},
|
||||
{
|
||||
prop: 'state',
|
||||
label: '状态',
|
||||
minWidth:30,
|
||||
isShow:false,
|
||||
render: scope => {
|
||||
return (
|
||||
<el-tag type={scope.row.state === 0 ? 'success' : (scope.row.state === 1 ? 'warning' : 'danger')}>
|
||||
{scope.row.state === 0 ? '正常' : (scope.row.state === 1 ? '停用' : '删除')}
|
||||
</el-tag>
|
||||
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
prop: 'operation',
|
||||
label: '操作',
|
||||
fixed: 'right',
|
||||
width:250,
|
||||
|
||||
},
|
||||
])
|
||||
|
||||
// 打开 drawer(新增、编辑)
|
||||
const openDialog = (titleType: string, row: Partial<Dict.ResDictTree> = {}) => {
|
||||
treePopup.value?.open(titleType, row)
|
||||
}
|
||||
|
||||
|
||||
// 删除字典类型
|
||||
const handleDelete = async (params: Dict.ResDictTree) => {
|
||||
await useHandleData(deleteDictTree, params, `删除【${params.name}】树形字典类型`)
|
||||
proTable.value?.getTableList()
|
||||
}
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<el-dialog v-model='dialogVisible' :title='dialogTitle' v-bind='dialogSmall' @close="close" align-center>
|
||||
<div>
|
||||
<el-form :model='formContent' ref='dialogFormRef' :rules='rules'>
|
||||
<el-form-item label='类型名称' :label-width='100' prop='name'>
|
||||
<el-input v-model='formContent.name' placeholder='请输入' autocomplete='off' maxlength="32" show-word-limit/>
|
||||
</el-form-item>
|
||||
<el-form-item label='类型编码' :label-width='100' prop='code'>
|
||||
<el-input v-model='formContent.code' placeholder='请输入' autocomplete='off' maxlength="32" show-word-limit/>
|
||||
</el-form-item>
|
||||
<el-form-item label="开启等级" :label-width="100" v-if="false">
|
||||
<el-radio-group v-model="formContent.openLevel" >
|
||||
<el-radio-button label="开启" :value="1"></el-radio-button>
|
||||
<el-radio-button label="关闭" :value="0"></el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="开启描述" :label-width="100" v-if="false">
|
||||
<el-radio-group v-model="formContent.openDescribe" >
|
||||
<el-radio-button label="开启" :value="1"></el-radio-button>
|
||||
<el-radio-button label="关闭" :value="0"></el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label='排序' :label-width='100'>
|
||||
<el-input-number v-model='formContent.sort' :min='1' :max='999'/>
|
||||
</el-form-item>
|
||||
<el-form-item label='备注' :label-width='100'>
|
||||
<el-input v-model='formContent.remark' placeholder='请输入备注' autocomplete='off' :rows='2'
|
||||
type='textarea' />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class='dialog-footer'>
|
||||
<el-button @click='close()'>取消</el-button>
|
||||
<el-button type='primary' @click='save()'>
|
||||
保存
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<script setup lang='ts'>
|
||||
import { dialogSmall } from '@/utils/elementBind'
|
||||
import { Dict } from '@/api/system/dictionary/interface'
|
||||
import { FormItemRule } from 'element-plus'
|
||||
import { addDictType, updateDictType } from '@/api/system/dictionary/dictType'
|
||||
|
||||
// 定义弹出组件元信息
|
||||
const dialogFormRef = ref()
|
||||
|
||||
|
||||
function useMetaInfo() {
|
||||
const dialogVisible = ref(false)
|
||||
const titleType = ref('add')
|
||||
const formContent = ref<Dict.ResDictType>({
|
||||
id: '',
|
||||
name: '',
|
||||
code: '',
|
||||
sort: 100,
|
||||
openLevel: 0,
|
||||
openDescribe: 0,
|
||||
remark: '',
|
||||
state: 1,
|
||||
})
|
||||
return { dialogVisible, titleType, formContent }
|
||||
}
|
||||
|
||||
const { dialogVisible, titleType, formContent } = useMetaInfo()
|
||||
|
||||
// 清空formContent
|
||||
const resetFormContent = () => {
|
||||
formContent.value = {
|
||||
id: '',
|
||||
name: '',
|
||||
code: '',
|
||||
sort: 100,
|
||||
openLevel: 0,
|
||||
openDescribe: 0,
|
||||
remark: '',
|
||||
state: 1,
|
||||
}
|
||||
}
|
||||
|
||||
let dialogTitle = computed(() => {
|
||||
return titleType.value === 'add' ? '新增字典类型' : '编辑字典类型'
|
||||
})
|
||||
|
||||
// 定义表单校验规则
|
||||
const rules: Ref<Record<string, Array<FormItemRule>>> = ref({
|
||||
name: [{ required: true, message: '类型名称必填!', trigger: 'blur' }],
|
||||
code: [{ required: true, message: '类型编码必填!', trigger: 'blur' }],
|
||||
})
|
||||
|
||||
|
||||
// 关闭弹窗
|
||||
const close = () => {
|
||||
dialogVisible.value = false
|
||||
// 清空dialogForm中的值
|
||||
resetFormContent()
|
||||
// 重置表单
|
||||
dialogFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 保存数据
|
||||
const save = () => {
|
||||
try {
|
||||
dialogFormRef.value?.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
if (formContent.value.id) {
|
||||
await updateDictType(formContent.value)
|
||||
} else {
|
||||
await addDictType(formContent.value)
|
||||
}
|
||||
ElMessage.success({ message: `${dialogTitle.value}成功!` })
|
||||
close()
|
||||
// 刷新表格
|
||||
await props.refreshTable!()
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('验证过程中出现错误', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开弹窗,可能是新增,也可能是编辑
|
||||
const open = (sign: string, data: Dict.ResDictType) => {
|
||||
// 重置表单
|
||||
dialogFormRef.value?.resetFields()
|
||||
titleType.value = sign
|
||||
dialogVisible.value = true
|
||||
if (data.id) {
|
||||
formContent.value = { ...data }
|
||||
} else {
|
||||
resetFormContent()
|
||||
}
|
||||
}
|
||||
|
||||
// 对外映射
|
||||
defineExpose({ open })
|
||||
const props = defineProps<{
|
||||
refreshTable: (() => Promise<void>) | undefined;
|
||||
}>()
|
||||
|
||||
</script>
|
||||
139
frontend/src/views/system/dictionary/dictType/index.vue
Normal file
139
frontend/src/views/system/dictionary/dictType/index.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div class="table-box" ref="popupBaseView">
|
||||
<ProTable ref="proTable" :columns="columns" :request-api="getDictTypeList">
|
||||
<template #tableHeader="scope">
|
||||
<el-button v-auth.dict="'add'" type="primary" :icon="CirclePlus" @click="openDialog('add')">
|
||||
新增
|
||||
</el-button>
|
||||
<el-button v-auth.dict="'export'" type="primary" :icon="Download" plain @click="downloadFile()">
|
||||
导出
|
||||
</el-button>
|
||||
<el-button
|
||||
v-auth.dict="'delete'"
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
plain
|
||||
:disabled="!scope.isSelected"
|
||||
@click="batchDelete(scope.selectedListIds)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<template #operation="scope">
|
||||
<el-button v-auth.dict="'show'" type="primary" link :icon="View" @click="toDictData(scope.row)">
|
||||
查看
|
||||
</el-button>
|
||||
<el-button
|
||||
v-auth.dict="'edit'"
|
||||
type="primary"
|
||||
link
|
||||
:icon="EditPen"
|
||||
@click="openDialog('edit', scope.row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button v-auth.dict="'delete'" type="primary" link :icon="Delete" @click="handleDelete(scope.row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
</div>
|
||||
<DictData :width="viewWidth" :height="viewHeight" ref="openView" />
|
||||
<TypePopup :refresh-table="proTable?.getTableList" ref="typePopup" />
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx" name="dict">
|
||||
import { CirclePlus, Delete, Download, EditPen, View } from '@element-plus/icons-vue'
|
||||
import { type Dict } from '@/api/system/dictionary/interface'
|
||||
import type { ColumnProps, ProTableInstance } from '@/components/ProTable/interface'
|
||||
import DictData from '@/views/system/dictionary/dictData/index.vue'
|
||||
import { useHandleData } from '@/hooks/useHandleData'
|
||||
import { useViewSize } from '@/hooks/useViewSize'
|
||||
import { useDownload } from '@/hooks/useDownload'
|
||||
import { deleteDictType, exportDictType, getDictTypeList } from '@/api/system/dictionary/dictType'
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'dict'
|
||||
})
|
||||
const { popupBaseView, viewWidth, viewHeight } = useViewSize()
|
||||
|
||||
const proTable = ref<ProTableInstance>()
|
||||
const typePopup = ref()
|
||||
const openView = ref()
|
||||
|
||||
const columns = reactive<ColumnProps<Dict.ResDictType>[]>([
|
||||
{ type: 'selection', fixed: 'left', width: 70 },
|
||||
{ type: 'index', fixed: 'left', width: 70, label: '序号' },
|
||||
{
|
||||
prop: 'name',
|
||||
label: '类型名称',
|
||||
minWidth: 180,
|
||||
search: {
|
||||
el: 'input'
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'code',
|
||||
label: '类型编码',
|
||||
minWidth: 220,
|
||||
search: {
|
||||
el: 'input'
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'remark',
|
||||
label: '描述',
|
||||
minWidth: 250
|
||||
},
|
||||
{
|
||||
prop: 'sort',
|
||||
label: '排序',
|
||||
minWidth: 70
|
||||
},
|
||||
{
|
||||
prop: 'createTime',
|
||||
label: '创建时间',
|
||||
minWidth: 180
|
||||
},
|
||||
{
|
||||
prop: 'operation',
|
||||
label: '操作',
|
||||
fixed: 'right',
|
||||
width: 250
|
||||
}
|
||||
])
|
||||
|
||||
// 打开 drawer(新增、编辑)
|
||||
const openDialog = (titleType: string, row: Partial<Dict.ResDictType> = {}) => {
|
||||
typePopup.value?.open(titleType, row)
|
||||
}
|
||||
|
||||
// 批量删除字典类型
|
||||
const batchDelete = async (id: string[]) => {
|
||||
await useHandleData(deleteDictType, id, '删除所选字典类型')
|
||||
proTable.value?.clearSelection()
|
||||
proTable.value?.getTableList()
|
||||
}
|
||||
|
||||
// 删除字典类型
|
||||
const handleDelete = async (params: Dict.ResDictType) => {
|
||||
await useHandleData(deleteDictType, [params.id], `删除【${params.name}】字典类型`)
|
||||
proTable.value?.getTableList()
|
||||
}
|
||||
|
||||
//查看字典类型包含的字典数据
|
||||
const toDictData = (row: Dict.ResDictType) => {
|
||||
openView.value.open(row)
|
||||
}
|
||||
|
||||
// 导出字典类型列表
|
||||
const downloadFile = async () => {
|
||||
ElMessageBox.confirm('确认导出字典类型数据?', '温馨提示', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消'
|
||||
}).then(() => useDownload(exportDictType, '字典类型导出数据', proTable.value?.searchParam, false))
|
||||
}
|
||||
</script>
|
||||
185
frontend/src/views/system/versionRegister/index.vue
Normal file
185
frontend/src/views/system/versionRegister/index.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
title="程序激活"
|
||||
width="450px"
|
||||
draggable
|
||||
:close-on-click-modal="false"
|
||||
:before-close="beforeClose"
|
||||
:destroy-on-close="true"
|
||||
>
|
||||
<el-form :label-width="100" label-position="left">
|
||||
<el-form-item label="程序版本号">
|
||||
<el-text style="margin-left: 82%">{{ 'v' + versionNumber }}</el-text>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-descriptions class="mode-descriptions" title="授权模块状态" size="small"></el-descriptions>
|
||||
<el-form v-if="activationModules.length" label-position="left" :label-width="100">
|
||||
<el-form-item v-for="module in activationModules" :key="module.key" class="mode-item" :label="module.label">
|
||||
<el-tag class="activated-state" disable-transitions :type="module.permanently === 1 ? 'success' : 'danger'">
|
||||
{{ module.permanently === 1 ? '已激活' : '未激活' }}
|
||||
</el-tag>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-text v-else type="info">暂无可展示的授权模块</el-text>
|
||||
<el-row v-if="applicationCode">
|
||||
<el-descriptions class="mode-descriptions" title="设备申请码" size="small"></el-descriptions>
|
||||
<el-tooltip placement="top-end" effect="light">
|
||||
<el-input
|
||||
v-model="applicationCode"
|
||||
:rows="5"
|
||||
type="textarea"
|
||||
readonly
|
||||
resize="none"
|
||||
class="code-display"
|
||||
/>
|
||||
<template #content>
|
||||
<el-button size="small" @click="copyCode" icon="DocumentCopy">复制</el-button>
|
||||
</template>
|
||||
</el-tooltip>
|
||||
</el-row>
|
||||
<el-row v-if="applicationCode || hadActivationCode">
|
||||
<el-descriptions class="mode-descriptions" title="设备激活码" size="small"></el-descriptions>
|
||||
<el-input
|
||||
placeholder="请输入设备激活码"
|
||||
v-model="activationCode"
|
||||
:rows="5"
|
||||
resize="none"
|
||||
type="textarea"
|
||||
class="code-input"
|
||||
/>
|
||||
</el-row>
|
||||
<template #footer v-if="!activatedAll">
|
||||
<div v-if="!applicationCode && !hadActivationCode">
|
||||
<el-button @click="cancel">取消</el-button>
|
||||
<el-button type="primary" @click="getApplicationCode">获取申请码</el-button>
|
||||
<el-button type="primary" @click="hadActivationCode = true">已有激活码</el-button>
|
||||
</div>
|
||||
<div v-if="applicationCode || hadActivationCode">
|
||||
<el-button @click="cancel">取消</el-button>
|
||||
<el-button :disabled="!activationCode" type="primary" @click="submitActivation">激活</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { version } from '../../../../package.json'
|
||||
import { generateApplicationCode, verifyActivationCode } from '@/api/activate'
|
||||
import type { Activate } from '@/api/activate/interface'
|
||||
import { useAuthStore } from '@/stores/modules/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const activateInfo = computed(() => authStore.activateInfo)
|
||||
const versionNumber = ref(version)
|
||||
const activationModules = computed<Activate.ActivationModuleStatus[]>(() => {
|
||||
return Object.keys(activateInfo.value)
|
||||
.sort()
|
||||
.map((key, index) => ({
|
||||
key,
|
||||
label: `授权模块 ${index + 1}`,
|
||||
permanently: activateInfo.value[key]?.permanently === 1 ? 1 : 0
|
||||
}))
|
||||
})
|
||||
const activatedAll = computed(() => {
|
||||
return activationModules.value.length > 0 && activationModules.value.every(module => module.permanently === 1)
|
||||
})
|
||||
const hadActivationCode = ref(false)
|
||||
const applicationCode = ref('')
|
||||
const activationCode = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
// 获取申请码
|
||||
const getApplicationCode = async () => {
|
||||
const res = await generateApplicationCode()
|
||||
if (res.code == 'A0000') {
|
||||
applicationCode.value = res.data as string
|
||||
}
|
||||
}
|
||||
// 复制申请码
|
||||
const copyCode = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(applicationCode.value)
|
||||
ElMessage.success('已复制到剪贴板')
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动选择内容')
|
||||
}
|
||||
}
|
||||
const resetDialogState = () => {
|
||||
hadActivationCode.value = false
|
||||
activationCode.value = ''
|
||||
applicationCode.value = ''
|
||||
}
|
||||
const openDialog = () => {
|
||||
resetDialogState()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
const beforeClose = (done: Function) => {
|
||||
resetDialogState()
|
||||
done()
|
||||
}
|
||||
const cancel = () => {
|
||||
resetDialogState()
|
||||
dialogVisible.value = false
|
||||
}
|
||||
const submitActivation = async () => {
|
||||
const res = await verifyActivationCode(activationCode.value)
|
||||
if (res.code == 'A0000') {
|
||||
ElMessage.success('激活成功')
|
||||
await authStore.setActivateInfo()
|
||||
window.location.reload()
|
||||
} else {
|
||||
ElMessage.error(res.message)
|
||||
}
|
||||
}
|
||||
defineExpose({ openDialog })
|
||||
</script>
|
||||
<style scoped>
|
||||
.activated-state {
|
||||
margin-left: 80%;
|
||||
}
|
||||
.code-display,
|
||||
.code-input {
|
||||
font-family: consolas, sans-serif;
|
||||
}
|
||||
:deep(.el-tag) {
|
||||
user-select: none;
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
:deep(.el-divider__text) {
|
||||
user-select: none;
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
.mode-descriptions {
|
||||
user-select: none;
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
margin-top: 15px;
|
||||
}
|
||||
:deep(.el-form-item) {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
padding: 0 10px !important;
|
||||
margin-bottom: 10px !important;
|
||||
margin-right: 0 !important;
|
||||
user-select: none;
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
:deep(.el-divider--horizontal) {
|
||||
margin: 10px 0 !important;
|
||||
margin-top: 20px !important;
|
||||
}
|
||||
.mode-item {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.mode-item:hover {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user