初始化
This commit is contained in:
531
src/views/system/dict/index.vue
Normal file
531
src/views/system/dict/index.vue
Normal file
@@ -0,0 +1,531 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { ElButton, ElPopconfirm, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import type { FlatResponseData } from '@sa/axios';
|
||||
import { dictStatusRecord } from '@/constants/business';
|
||||
import {
|
||||
fetchBatchDeleteDictData,
|
||||
fetchDeleteDictData,
|
||||
fetchDeleteDictType,
|
||||
fetchGetDictDataPage,
|
||||
fetchGetDictTypePage
|
||||
} from '@/service/api';
|
||||
import { useTableOperate, useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import { $t } from '@/locales';
|
||||
import DictDataOperateModal from './modules/dict-data-operate-modal.vue';
|
||||
import DictDataSearch from './modules/dict-data-search.vue';
|
||||
import DictTypeOperateModal from './modules/dict-type-operate-modal.vue';
|
||||
import DictTypeSearch from './modules/dict-type-search.vue';
|
||||
|
||||
defineOptions({ name: 'DictManage' });
|
||||
|
||||
function getInitDictTypeSearchParams(): Api.Dict.DictTypeSearchParams {
|
||||
return {
|
||||
pageNo: 1,
|
||||
pageSize: 200,
|
||||
name: undefined,
|
||||
type: undefined,
|
||||
status: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function getInitDictDataSearchParams(): Api.Dict.DictDataSearchParams {
|
||||
return {
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
label: undefined,
|
||||
dictType: undefined,
|
||||
status: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyDictPageResult<ApiData>(): Promise<FlatResponseData<any, Api.Dict.PageResult<ApiData>>> {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
list: [],
|
||||
total: 0
|
||||
},
|
||||
error: null,
|
||||
response: undefined
|
||||
} as unknown as FlatResponseData<any, Api.Dict.PageResult<ApiData>>);
|
||||
}
|
||||
|
||||
function transformDictPage<ApiData>(
|
||||
response: FlatResponseData<any, Api.Dict.PageResult<ApiData>>,
|
||||
pageNo: number,
|
||||
pageSize: number
|
||||
) {
|
||||
// 项目通用表格 hook 默认消费 data/pageNum/pageSize/total,
|
||||
// 这里把后端字典接口的 list/total 结构适配过去。
|
||||
const { data, error } = response;
|
||||
|
||||
if (!error) {
|
||||
return {
|
||||
data: data.list,
|
||||
pageNum: pageNo,
|
||||
pageSize,
|
||||
total: data.total
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: [],
|
||||
pageNum: pageNo,
|
||||
pageSize,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
function formatTime(value?: number | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
function getDictStatusLabel(status: Api.Dict.DictStatus) {
|
||||
return $t(dictStatusRecord[String(status) as '0' | '1']);
|
||||
}
|
||||
|
||||
function getDictStatusTagType(status: Api.Dict.DictStatus): UI.ThemeColor {
|
||||
return status === 0 ? 'success' : 'info';
|
||||
}
|
||||
|
||||
const dictTypeSearchParams = reactive(getInitDictTypeSearchParams());
|
||||
const dictDataSearchParams = reactive(getInitDictDataSearchParams());
|
||||
|
||||
const typeLoading = ref(false);
|
||||
const typeList = ref<Api.Dict.DictType[]>([]);
|
||||
const typeTotal = ref(0);
|
||||
const currentTypeId = ref<number | null>(null);
|
||||
|
||||
const currentType = computed(() => typeList.value.find(item => item.id === currentTypeId.value) ?? null);
|
||||
const currentTypeCode = computed(() => currentType.value?.type ?? '');
|
||||
|
||||
async function getDictTypeList() {
|
||||
typeLoading.value = true;
|
||||
|
||||
const { error, data } = await fetchGetDictTypePage(dictTypeSearchParams);
|
||||
|
||||
typeLoading.value = false;
|
||||
|
||||
if (error) {
|
||||
typeList.value = [];
|
||||
typeTotal.value = 0;
|
||||
currentTypeId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
typeList.value = data.list;
|
||||
typeTotal.value = data.total;
|
||||
|
||||
if (!data.list.length) {
|
||||
currentTypeId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 搜索后尽量保留原选中项;如果原项已不在结果中,则自动落到第一项。
|
||||
const matched = data.list.find(item => item.id === currentTypeId.value);
|
||||
|
||||
if (!matched) {
|
||||
currentTypeId.value = data.list[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
drawerVisible: dictTypeModalVisible,
|
||||
operateType: dictTypeOperateType,
|
||||
editingData: editingDictTypeData,
|
||||
handleAdd: handleAddDictType,
|
||||
handleEdit: handleEditDictType,
|
||||
onDeleted: onDeletedDictType
|
||||
} = useTableOperate<Api.Dict.DictType>(typeList, 'id', getDictTypeList);
|
||||
|
||||
const {
|
||||
columns: dictDataColumns,
|
||||
columnChecks: dictDataColumnChecks,
|
||||
data: dictData,
|
||||
loading: dictDataLoading,
|
||||
getData: getDictData,
|
||||
getDataByPage: getDictDataByPage,
|
||||
mobilePagination: dictDataPagination
|
||||
} = useUIPaginatedTable<FlatResponseData<any, Api.Dict.PageResult<Api.Dict.DictData>>, Api.Dict.DictData>({
|
||||
paginationProps: {
|
||||
currentPage: dictDataSearchParams.pageNo,
|
||||
pageSize: dictDataSearchParams.pageSize
|
||||
},
|
||||
api: () => {
|
||||
if (!currentTypeCode.value) {
|
||||
// 左侧还没选中字典类型时,右侧直接返回空结果,避免发送无效请求。
|
||||
return createEmptyDictPageResult<Api.Dict.DictData>();
|
||||
}
|
||||
|
||||
return fetchGetDictDataPage({
|
||||
...dictDataSearchParams,
|
||||
dictType: currentTypeCode.value
|
||||
});
|
||||
},
|
||||
transform: response => transformDictPage(response, dictDataSearchParams.pageNo, dictDataSearchParams.pageSize),
|
||||
onPaginationParamsChange: params => {
|
||||
dictDataSearchParams.pageNo = params.currentPage ?? 1;
|
||||
dictDataSearchParams.pageSize = params.pageSize ?? 10;
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'selection', type: 'selection', width: 48 },
|
||||
{ prop: 'index', type: 'index', label: $t('common.index'), width: 64 },
|
||||
{ prop: 'label', label: $t('page.system.dict.dictLabel'), minWidth: 160 },
|
||||
{ prop: 'value', label: $t('page.system.dict.dictValue'), minWidth: 180 },
|
||||
{ prop: 'sort', label: $t('page.system.dict.sort'), width: 90, align: 'center' },
|
||||
{
|
||||
prop: 'status',
|
||||
label: $t('page.system.dict.dictStatus'),
|
||||
width: 110,
|
||||
formatter: row => <ElTag type={getDictStatusTagType(row.status)}>{getDictStatusLabel(row.status)}</ElTag>
|
||||
},
|
||||
{ prop: 'remark', label: $t('page.system.dict.remark'), minWidth: 180, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'createTime',
|
||||
label: $t('common.update'),
|
||||
minWidth: 170,
|
||||
formatter: row => formatTime(row.createTime)
|
||||
},
|
||||
{
|
||||
prop: 'operate',
|
||||
label: $t('common.operate'),
|
||||
width: 180,
|
||||
align: 'center',
|
||||
formatter: row => (
|
||||
<div class="flex-center">
|
||||
<ElButton type="primary" plain size="small" onClick={() => editDictData(row.id)}>
|
||||
{$t('common.edit')}
|
||||
</ElButton>
|
||||
<ElPopconfirm title={$t('common.confirmDelete')} onConfirm={() => handleDeleteSingleDictData(row.id)}>
|
||||
{{
|
||||
reference: () => (
|
||||
<ElButton type="danger" plain size="small">
|
||||
{$t('common.delete')}
|
||||
</ElButton>
|
||||
)
|
||||
}}
|
||||
</ElPopconfirm>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const {
|
||||
drawerVisible: dictDataModalVisible,
|
||||
operateType: dictDataOperateType,
|
||||
editingData: editingDictData,
|
||||
handleAdd: handleAddDictDataBase,
|
||||
handleEdit: handleEditDictData,
|
||||
checkedRowKeys: checkedDictDataRowKeys,
|
||||
onBatchDeleted: onBatchDeletedDictData,
|
||||
onDeleted: onDeletedDictData
|
||||
} = useTableOperate<Api.Dict.DictData>(dictData, 'id', getDictData);
|
||||
|
||||
function selectType(item: Api.Dict.DictType) {
|
||||
currentTypeId.value = item.id;
|
||||
}
|
||||
|
||||
function openEditType(id: number) {
|
||||
currentTypeId.value = id;
|
||||
handleEditDictType(id);
|
||||
}
|
||||
|
||||
async function handleDeleteType(id: number) {
|
||||
const { error } = await fetchDeleteDictType(id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTypeId.value === id) {
|
||||
currentTypeId.value = null;
|
||||
}
|
||||
|
||||
await onDeletedDictType();
|
||||
}
|
||||
|
||||
function handleDictDataSelectionChange(rows: Api.Dict.DictData[]) {
|
||||
checkedDictDataRowKeys.value = rows.map(item => String(item.id));
|
||||
}
|
||||
|
||||
function handleAddDictData() {
|
||||
if (!currentType.value) {
|
||||
window.$message?.warning($t('page.system.dict.emptyType'));
|
||||
return;
|
||||
}
|
||||
|
||||
handleAddDictDataBase();
|
||||
}
|
||||
|
||||
function editDictData(id: number) {
|
||||
handleEditDictData(id);
|
||||
}
|
||||
|
||||
async function handleDeleteSingleDictData(id: number) {
|
||||
const { error } = await fetchDeleteDictData(id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
await onDeletedDictData();
|
||||
}
|
||||
|
||||
async function handleBatchDeleteDictDataRows() {
|
||||
const ids = checkedDictDataRowKeys.value.map(item => Number(item));
|
||||
|
||||
if (!ids.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await fetchBatchDeleteDictData(ids);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
await onBatchDeletedDictData();
|
||||
}
|
||||
|
||||
async function handleSearchDictType() {
|
||||
dictTypeSearchParams.pageNo = 1;
|
||||
await getDictTypeList();
|
||||
}
|
||||
|
||||
async function handleSearchDictData() {
|
||||
dictDataSearchParams.pageNo = 1;
|
||||
await getDictDataByPage(1);
|
||||
}
|
||||
|
||||
async function resetDictDataSearchParams() {
|
||||
const pageSize = dictDataSearchParams.pageSize;
|
||||
|
||||
Object.assign(dictDataSearchParams, getInitDictDataSearchParams(), { pageSize });
|
||||
|
||||
await getDictDataByPage(1);
|
||||
}
|
||||
|
||||
async function handleTypeSubmitted() {
|
||||
await getDictTypeList();
|
||||
}
|
||||
|
||||
async function handleDataSubmitted() {
|
||||
await getDictData();
|
||||
}
|
||||
|
||||
watch(
|
||||
currentTypeCode,
|
||||
async (value, oldValue) => {
|
||||
if (value === oldValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 左侧切换类型时,右侧查询条件只保留分页大小,其余条件全部重置。
|
||||
const pageSize = dictDataSearchParams.pageSize;
|
||||
|
||||
Object.assign(dictDataSearchParams, getInitDictDataSearchParams(), {
|
||||
pageSize,
|
||||
dictType: value || undefined
|
||||
});
|
||||
|
||||
await getDictDataByPage(1);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
getDictTypeList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-h-560px gap-16px overflow-hidden xl:grid xl:grid-cols-[360px_minmax(0,1fr)] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
|
||||
>
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<DictTypeSearch v-model:model="dictTypeSearchParams" @search="handleSearchDictType" />
|
||||
<ElCard v-loading="typeLoading" class="card-wrapper xl:flex-1-hidden" body-class="dict-type-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px py-2px">
|
||||
<div class="flex items-center gap-10px">
|
||||
<p>{{ $t('page.system.dict.typeTitle') }}</p>
|
||||
<ElTag effect="plain">{{ typeTotal }}</ElTag>
|
||||
</div>
|
||||
<ElButton type="primary" plain size="small" @click="handleAddDictType">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElScrollbar class="flex-1 px-10px py-10px">
|
||||
<div v-if="typeList.length" class="flex flex-col gap-8px">
|
||||
<div
|
||||
v-for="item in typeList"
|
||||
:key="item.id"
|
||||
class="group w-full cursor-pointer border rounded-12px px-12px py-10px text-left transition-all duration-200"
|
||||
:class="
|
||||
item.id === currentTypeId
|
||||
? 'border-primary bg-primary/6 shadow-sm shadow-primary/10'
|
||||
: 'border-[#ebeef5] bg-white hover:border-primary/45 hover:bg-[#fafcff]'
|
||||
"
|
||||
@click="selectType(item)"
|
||||
>
|
||||
<div class="flex items-center gap-10px">
|
||||
<icon-ep-success-filled v-if="item.status === 0" class="shrink-0 text-16px text-[#67c23a]" />
|
||||
<icon-ep-remove-filled v-else class="shrink-0 text-16px text-[#c0c4cc]" />
|
||||
<ElTooltip :content="`${item.name} / ${item.type}`" placement="top-start">
|
||||
<div class="min-w-0 flex-1 truncate text-14px text-[#1f2329] font-600">
|
||||
<span class="truncate align-middle">{{ item.name }}</span>
|
||||
<span class="mx-6px align-middle text-[#c0c4cc]">/</span>
|
||||
<span class="align-middle text-13px text-[#7d8592] font-500">{{ item.type }}</span>
|
||||
</div>
|
||||
</ElTooltip>
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-2 opacity-75 transition-opacity duration-200 group-hover:opacity-100"
|
||||
@click.stop
|
||||
>
|
||||
<ElTooltip :content="$t('common.edit')">
|
||||
<ElButton link type="primary" @click="openEditType(item.id)">
|
||||
<icon-mdi-pencil-outline class="text-16px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElPopconfirm :title="$t('common.confirmDelete')" @confirm="handleDeleteType(item.id)">
|
||||
<template #reference>
|
||||
<span class="inline-flex">
|
||||
<ElTooltip :content="$t('common.delete')">
|
||||
<ElButton link type="danger">
|
||||
<icon-mdi-delete-outline class="text-16px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
</span>
|
||||
</template>
|
||||
</ElPopconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="min-h-260px flex items-center justify-center">
|
||||
<ElEmpty :description="$t('common.noData')" />
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<DictDataSearch
|
||||
v-model:model="dictDataSearchParams"
|
||||
:disabled="!currentType"
|
||||
@reset="resetDictDataSearchParams"
|
||||
@search="handleSearchDictData"
|
||||
/>
|
||||
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="dict-data-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex flex-wrap items-center gap-8px">
|
||||
<p>{{ $t('page.system.dict.dataTitle') }}</p>
|
||||
<ElTag v-if="currentType" type="primary" effect="light">
|
||||
{{ currentType.name }}
|
||||
</ElTag>
|
||||
<span v-if="currentType" class="rounded-full bg-[#f4f6f8] px-10px py-4px text-12px text-[#606266]">
|
||||
{{ currentType.type }}
|
||||
</span>
|
||||
<span v-else class="text-14px text-[#909399]">{{ $t('page.system.dict.emptyType') }}</span>
|
||||
</div>
|
||||
<TableHeaderOperation
|
||||
v-model:columns="dictDataColumnChecks"
|
||||
:disabled-delete="checkedDictDataRowKeys.length === 0 || !currentType"
|
||||
:loading="dictDataLoading"
|
||||
@refresh="getDictData"
|
||||
>
|
||||
<template #default>
|
||||
<ElButton plain type="primary" :disabled="!currentType" @click="handleAddDictData">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</ElButton>
|
||||
<ElPopconfirm :title="$t('common.confirmDelete')" @confirm="handleBatchDeleteDictDataRows">
|
||||
<template #reference>
|
||||
<ElButton type="danger" plain :disabled="checkedDictDataRowKeys.length === 0 || !currentType">
|
||||
<template #icon>
|
||||
<icon-ic-round-delete class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.batchDelete') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElPopconfirm>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="currentType">
|
||||
<div class="flex-1">
|
||||
<ElTable
|
||||
v-loading="dictDataLoading"
|
||||
height="100%"
|
||||
border
|
||||
row-key="id"
|
||||
:data="dictData"
|
||||
@selection-change="handleDictDataSelectionChange"
|
||||
>
|
||||
<ElTableColumn v-for="col in dictDataColumns" :key="String(col.prop)" v-bind="col" />
|
||||
</ElTable>
|
||||
</div>
|
||||
<div class="mt-20px flex justify-end">
|
||||
<ElPagination
|
||||
v-if="dictDataPagination.total"
|
||||
layout="total,prev,pager,next,sizes"
|
||||
v-bind="dictDataPagination"
|
||||
@current-change="dictDataPagination['current-change']"
|
||||
@size-change="dictDataPagination['size-change']"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="h-full flex items-center justify-center">
|
||||
<ElEmpty :description="$t('page.system.dict.emptyType')" />
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<DictTypeOperateModal
|
||||
v-model:visible="dictTypeModalVisible"
|
||||
:operate-type="dictTypeOperateType"
|
||||
:row-data="editingDictTypeData"
|
||||
@submitted="handleTypeSubmitted"
|
||||
/>
|
||||
<DictDataOperateModal
|
||||
v-model:visible="dictDataModalVisible"
|
||||
:operate-type="dictDataOperateType"
|
||||
:row-data="editingDictData"
|
||||
:current-type="currentType"
|
||||
@submitted="handleDataSubmitted"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.dict-type-card-body) {
|
||||
height: calc(100% - 56px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.dict-data-card-body) {
|
||||
height: calc(100% - 56px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
203
src/views/system/dict/modules/dict-data-operate-modal.vue
Normal file
203
src/views/system/dict/modules/dict-data-operate-modal.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { dictStatusOptions } from '@/constants/business';
|
||||
import { fetchCreateDictData, fetchUpdateDictData } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'DictDataOperateModal' });
|
||||
|
||||
interface Props {
|
||||
operateType: UI.TableOperateType;
|
||||
rowData?: Api.Dict.DictData | null;
|
||||
currentType?: Api.Dict.DictType | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate, restoreValidation } = useForm();
|
||||
const { defaultRequiredRule } = useFormRules();
|
||||
|
||||
const isEdit = computed(() => props.operateType === 'edit');
|
||||
const submitting = ref(false);
|
||||
|
||||
const title = computed(() => {
|
||||
const titleMap: Record<UI.TableOperateType, string> = {
|
||||
add: $t('page.system.dict.addData'),
|
||||
edit: $t('page.system.dict.editData')
|
||||
};
|
||||
|
||||
return titleMap[props.operateType];
|
||||
});
|
||||
|
||||
type Model = Api.Dict.SaveDictDataParams;
|
||||
|
||||
const model = ref(createDefaultModel());
|
||||
|
||||
const currentTypeName = computed(() => props.currentType?.name ?? '-');
|
||||
const currentTypeCode = computed(() => props.currentType?.type ?? model.value.dictType ?? '-');
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
label: '',
|
||||
value: '',
|
||||
dictType: '',
|
||||
sort: 0,
|
||||
status: 0,
|
||||
remark: ''
|
||||
};
|
||||
}
|
||||
|
||||
type RuleKey = Extract<keyof Model, 'label' | 'value' | 'sort' | 'status'>;
|
||||
|
||||
const rules: Record<RuleKey, App.Global.FormRule> = {
|
||||
label: defaultRequiredRule,
|
||||
value: defaultRequiredRule,
|
||||
sort: defaultRequiredRule,
|
||||
status: defaultRequiredRule
|
||||
};
|
||||
|
||||
function handleInitModel() {
|
||||
model.value = createDefaultModel();
|
||||
|
||||
// 新增字典数据时默认继承左侧当前选中的字典类型。
|
||||
const currentDictType = props.currentType?.type ?? '';
|
||||
model.value.dictType = currentDictType;
|
||||
|
||||
if (isEdit.value && props.rowData) {
|
||||
// 编辑时直接使用表格行数据回填,保持弹框打开速度。
|
||||
Object.assign(model.value, {
|
||||
label: props.rowData.label,
|
||||
value: props.rowData.value,
|
||||
dictType: props.rowData.dictType,
|
||||
sort: props.rowData.sort,
|
||||
status: props.rowData.status,
|
||||
remark: props.rowData.remark ?? ''
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
|
||||
// 右侧数据必须依附左侧字典类型,没有选中类型时不允许提交。
|
||||
const dictType = model.value.dictType || props.currentType?.type;
|
||||
|
||||
if (!dictType) {
|
||||
window.$message?.warning($t('page.system.dict.emptyType'));
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const params: Api.Dict.SaveDictDataParams = {
|
||||
...model.value,
|
||||
dictType
|
||||
};
|
||||
|
||||
const request =
|
||||
isEdit.value && props.rowData
|
||||
? fetchUpdateDictData({ id: props.rowData.id, ...params })
|
||||
: fetchCreateDictData(params);
|
||||
|
||||
const { error } = await request;
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t(isEdit.value ? 'common.updateSuccess' : 'common.addSuccess'));
|
||||
|
||||
closeModal();
|
||||
emit('submitted');
|
||||
}
|
||||
|
||||
watch(visible, value => {
|
||||
if (value) {
|
||||
handleInitModel();
|
||||
restoreValidation();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="md"
|
||||
:confirm-loading="submitting"
|
||||
:scrollbar="false"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.dict.currentType')">
|
||||
<ElInput :model-value="currentTypeName" disabled />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.dict.dictCode')">
|
||||
<ElInput :model-value="currentTypeCode" disabled />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.dict.dictLabel')" prop="label">
|
||||
<ElInput v-model="model.label" :placeholder="$t('page.system.dict.form.dictLabel')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.dict.dictValue')" prop="value">
|
||||
<ElInput v-model="model.value" :placeholder="$t('page.system.dict.form.dictValue')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.dict.sort')" prop="sort">
|
||||
<ElInputNumber
|
||||
v-model="model.sort"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:placeholder="$t('page.system.dict.form.sort')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.dict.dictStatus')" prop="status">
|
||||
<ElRadioGroup v-model="model.status" class="business-form-radio-group">
|
||||
<ElRadio v-for="{ label, value } in dictStatusOptions" :key="value" :value="value" :label="$t(label)" />
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem :label="$t('page.system.dict.remark')" prop="remark">
|
||||
<ElInput
|
||||
v-model="model.remark"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="$t('page.system.dict.form.remark')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
68
src/views/system/dict/modules/dict-data-search.vue
Normal file
68
src/views/system/dict/modules/dict-data-search.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { dictStatusOptions } from '@/constants/business';
|
||||
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'DictDataSearch' });
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
disabled: false
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
(e: 'reset'): void;
|
||||
(e: 'search'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const model = defineModel<Api.Dict.DictDataSearchParams>('model', { required: true });
|
||||
|
||||
function handleReset() {
|
||||
emit('reset');
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
emit('search');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchPanel
|
||||
:model="model"
|
||||
:disabled="disabled"
|
||||
:action-col-lg="12"
|
||||
:action-col-md="8"
|
||||
@reset="handleReset"
|
||||
@search="handleSearch"
|
||||
>
|
||||
<ElCol :lg="6" :md="8" :sm="12">
|
||||
<ElFormItem :label="$t('page.system.dict.dictLabel')" prop="label">
|
||||
<ElInput
|
||||
v-model="model.label"
|
||||
clearable
|
||||
:disabled="disabled"
|
||||
:placeholder="$t('page.system.dict.form.dictLabel')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :lg="6" :md="8" :sm="12">
|
||||
<ElFormItem :label="$t('page.system.dict.dictStatus')" prop="status">
|
||||
<ElSelect
|
||||
v-model="model.status"
|
||||
clearable
|
||||
:disabled="disabled"
|
||||
:placeholder="$t('page.system.dict.form.dictStatus')"
|
||||
>
|
||||
<ElOption v-for="{ label, value } in dictStatusOptions" :key="value" :label="$t(label)" :value="value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</TableSearchPanel>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
143
src/views/system/dict/modules/dict-type-operate-modal.vue
Normal file
143
src/views/system/dict/modules/dict-type-operate-modal.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { dictStatusOptions } from '@/constants/business';
|
||||
import { fetchCreateDictType, fetchUpdateDictType } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'DictTypeOperateModal' });
|
||||
|
||||
interface Props {
|
||||
operateType: UI.TableOperateType;
|
||||
rowData?: Api.Dict.DictType | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate, restoreValidation } = useForm();
|
||||
const { defaultRequiredRule } = useFormRules();
|
||||
|
||||
const isEdit = computed(() => props.operateType === 'edit');
|
||||
const submitting = ref(false);
|
||||
|
||||
const title = computed(() => {
|
||||
const titleMap: Record<UI.TableOperateType, string> = {
|
||||
add: $t('page.system.dict.addType'),
|
||||
edit: $t('page.system.dict.editType')
|
||||
};
|
||||
|
||||
return titleMap[props.operateType];
|
||||
});
|
||||
|
||||
type Model = Api.Dict.SaveDictTypeParams;
|
||||
|
||||
const model = ref(createDefaultModel());
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
name: '',
|
||||
type: '',
|
||||
status: 0,
|
||||
remark: ''
|
||||
};
|
||||
}
|
||||
|
||||
type RuleKey = Extract<keyof Model, 'name' | 'type' | 'status'>;
|
||||
|
||||
const rules: Record<RuleKey, App.Global.FormRule> = {
|
||||
name: defaultRequiredRule,
|
||||
type: defaultRequiredRule,
|
||||
status: defaultRequiredRule
|
||||
};
|
||||
|
||||
function handleInitModel() {
|
||||
model.value = createDefaultModel();
|
||||
|
||||
if (isEdit.value && props.rowData) {
|
||||
// 当前页列表数据已经包含编辑所需字段,直接回填,避免额外详情请求。
|
||||
Object.assign(model.value, {
|
||||
name: props.rowData.name,
|
||||
type: props.rowData.type,
|
||||
status: props.rowData.status,
|
||||
remark: props.rowData.remark ?? ''
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
// 按接口文档先保守处理:编辑时仍带 type,但输入框禁用,避免误改编码引发子表联动问题。
|
||||
const request =
|
||||
isEdit.value && props.rowData
|
||||
? fetchUpdateDictType({ id: props.rowData.id, ...model.value })
|
||||
: fetchCreateDictType(model.value);
|
||||
|
||||
const { error } = await request;
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t(isEdit.value ? 'common.updateSuccess' : 'common.addSuccess'));
|
||||
|
||||
closeModal();
|
||||
emit('submitted');
|
||||
}
|
||||
|
||||
watch(visible, value => {
|
||||
if (value) {
|
||||
handleInitModel();
|
||||
restoreValidation();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="sm"
|
||||
:confirm-loading="submitting"
|
||||
:scrollbar="false"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElFormItem :label="$t('page.system.dict.dictName')" prop="name">
|
||||
<ElInput v-model="model.name" :placeholder="$t('page.system.dict.form.dictName')" />
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('page.system.dict.dictCode')" prop="type">
|
||||
<ElInput v-model="model.type" :disabled="isEdit" :placeholder="$t('page.system.dict.form.dictCode')" />
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('page.system.dict.dictStatus')" prop="status">
|
||||
<ElRadioGroup v-model="model.status" class="business-form-radio-group">
|
||||
<ElRadio v-for="{ label, value } in dictStatusOptions" :key="value" :value="value" :label="$t(label)" />
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('page.system.dict.remark')" prop="remark">
|
||||
<ElInput v-model="model.remark" type="textarea" :rows="4" :placeholder="$t('page.system.dict.form.remark')" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
126
src/views/system/dict/modules/dict-type-search.vue
Normal file
126
src/views/system/dict/modules/dict-type-search.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'DictTypeSearch' });
|
||||
|
||||
interface Emits {
|
||||
(e: 'search'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const model = defineModel<Api.Dict.DictTypeSearchParams>('model', { required: true });
|
||||
|
||||
type StatusFilter = 'all' | Api.Dict.DictStatus;
|
||||
|
||||
const filterVisible = ref(false);
|
||||
|
||||
const statusFilter = computed<StatusFilter>({
|
||||
get() {
|
||||
return model.value.status ?? 'all';
|
||||
},
|
||||
set(value) {
|
||||
model.value.status = value === 'all' ? undefined : value;
|
||||
}
|
||||
});
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ label: '全部', value: 'all' as const },
|
||||
{ label: $t('page.system.common.status.enable'), value: 0 as const },
|
||||
{ label: $t('page.system.common.status.disable'), value: 1 as const }
|
||||
]);
|
||||
|
||||
function handleSearch() {
|
||||
emit('search');
|
||||
}
|
||||
|
||||
function handleKeywordInput(value: string) {
|
||||
const keyword = value.trim() || undefined;
|
||||
|
||||
model.value.name = keyword;
|
||||
model.value.type = keyword;
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
model.value.name = undefined;
|
||||
model.value.type = undefined;
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function handleStatusChange(value: StatusFilter) {
|
||||
statusFilter.value = value;
|
||||
filterVisible.value = false;
|
||||
handleSearch();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="card-wrapper" body-class="px-12px py-10px">
|
||||
<ElForm :model="model" @submit.prevent>
|
||||
<div class="relative">
|
||||
<ElInput
|
||||
:model-value="model.name ?? model.type ?? ''"
|
||||
class="dict-type-search-input"
|
||||
clearable
|
||||
:placeholder="$t('page.system.dict.typeSearchPlaceholder')"
|
||||
@update:model-value="handleKeywordInput"
|
||||
@clear="handleClear"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
|
||||
<div class="absolute right-8px top-1/2 z-10 flex items-center gap-2px -translate-y-1/2">
|
||||
<ElPopover v-model:visible="filterVisible" placement="bottom-end" trigger="click" :width="132">
|
||||
<template #reference>
|
||||
<ElButton
|
||||
link
|
||||
class="relative h-24px min-w-24px w-24px"
|
||||
:type="statusFilter === 'all' ? 'default' : 'primary'"
|
||||
>
|
||||
<icon-mdi-filter-variant class="text-15px" />
|
||||
<span
|
||||
v-if="statusFilter !== 'all'"
|
||||
class="absolute right-2px top-2px h-6px w-6px rounded-full bg-primary"
|
||||
></span>
|
||||
</ElButton>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-6px py-4px">
|
||||
<button
|
||||
v-for="item in statusOptions"
|
||||
:key="String(item.value)"
|
||||
type="button"
|
||||
class="flex items-center justify-between rounded-8px px-10px py-7px text-left text-13px transition-colors duration-200"
|
||||
:class="
|
||||
statusFilter === item.value ? 'bg-primary/10 text-primary' : 'text-[#606266] hover:bg-[#f5f7fa]'
|
||||
"
|
||||
@click="handleStatusChange(item.value)"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
<icon-mdi-check v-if="statusFilter === item.value" class="text-14px" />
|
||||
</button>
|
||||
</div>
|
||||
</ElPopover>
|
||||
|
||||
<ElTooltip :content="$t('common.search')">
|
||||
<ElButton link class="h-24px min-w-24px w-24px" type="primary" @click="handleSearch">
|
||||
<icon-ic-round-search class="text-15px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</ElForm>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dict-type-search-input {
|
||||
:deep(.el-input__wrapper) {
|
||||
padding-right: 76px;
|
||||
}
|
||||
|
||||
:deep(.el-input__clear) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
252
src/views/system/menu/index.vue
Normal file
252
src/views/system/menu/index.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, nextTick, reactive, ref } from 'vue';
|
||||
import type { TableInstance } from 'element-plus';
|
||||
import { ElTag } from 'element-plus';
|
||||
import { useBoolean } from '@sa/hooks';
|
||||
import { menuRouteKindRecord, menuTypeRecord } from '@/constants/business';
|
||||
import { fetchBatchDeleteMenu, fetchDeleteMenu, fetchGetMenuList } from '@/service/api';
|
||||
import { useUITable } from '@/hooks/common/table';
|
||||
import { $t } from '@/locales';
|
||||
import { buildMenuTree } from '@/views/system/shared/menu-tree';
|
||||
import MenuIconCell from './modules/menu-icon-cell';
|
||||
import MenuOperateDialog, { type OperateType } from './modules/menu-operate-dialog.vue';
|
||||
import MenuOperateCell from './modules/menu-operate-cell';
|
||||
import MenuSearch from './modules/menu-search.vue';
|
||||
|
||||
defineOptions({ name: 'MenuManage' });
|
||||
|
||||
function getInitSearchParams(): Api.SystemManage.MenuSearchParams {
|
||||
return {
|
||||
name: undefined,
|
||||
status: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function getMenuTypeTagType(type: Api.SystemManage.MenuType): UI.ThemeColor {
|
||||
const tagMap: Record<Api.SystemManage.MenuType, UI.ThemeColor> = {
|
||||
1: 'info',
|
||||
2: 'primary',
|
||||
3: 'warning'
|
||||
};
|
||||
|
||||
return tagMap[type];
|
||||
}
|
||||
|
||||
function getRouteKindLabel(routeKind?: Api.SystemManage.MenuRouteKind | null) {
|
||||
if (!routeKind) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return $t(menuRouteKindRecord[routeKind]);
|
||||
}
|
||||
|
||||
const searchParams = reactive(getInitSearchParams());
|
||||
const flatMenuList = ref<Api.SystemManage.Menu[]>([]);
|
||||
|
||||
const { columns, columnChecks, data, loading, getData } = useUITable({
|
||||
api: () => fetchGetMenuList(searchParams),
|
||||
transform: response => {
|
||||
if (!response.error) {
|
||||
flatMenuList.value = response.data;
|
||||
return buildMenuTree(response.data);
|
||||
}
|
||||
|
||||
flatMenuList.value = [];
|
||||
return [];
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'selection', type: 'selection', width: 48 },
|
||||
{ prop: 'name', label: $t('page.system.menu.menuName'), minWidth: 220, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'type',
|
||||
label: $t('page.system.menu.menuType'),
|
||||
width: 96,
|
||||
align: 'center',
|
||||
formatter: row => <ElTag type={getMenuTypeTagType(row.type)}>{$t(menuTypeRecord[row.type])}</ElTag>
|
||||
},
|
||||
{
|
||||
prop: 'icon',
|
||||
label: $t('page.system.menu.icon'),
|
||||
width: 88,
|
||||
align: 'center',
|
||||
formatter: row => {
|
||||
return <MenuIconCell icon={row.icon ?? ''} />;
|
||||
}
|
||||
},
|
||||
{ prop: 'permission', label: $t('page.system.menu.permission'), minWidth: 180, showOverflowTooltip: true },
|
||||
{ prop: 'path', label: $t('page.system.menu.routePath'), minWidth: 160, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'routeKind',
|
||||
label: $t('page.system.menu.routeKind'),
|
||||
width: 116,
|
||||
formatter: row => getRouteKindLabel(row.routeKind)
|
||||
},
|
||||
{ prop: 'component', label: $t('page.system.menu.component'), minWidth: 180, showOverflowTooltip: true },
|
||||
{ prop: 'componentName', label: $t('page.system.menu.componentName'), minWidth: 160, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'operate',
|
||||
label: $t('common.operate'),
|
||||
width: 196,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => (
|
||||
<MenuOperateCell row={row} onEdit={openEdit} onAddChild={openAddChild} onDelete={handleDeleteAction} />
|
||||
)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const { bool: visible, setTrue: openModal, setFalse: closeModal } = useBoolean();
|
||||
const operateType = ref<OperateType>('add');
|
||||
const editingData = ref<Api.SystemManage.Menu | null>(null);
|
||||
const checkedRowKeys = ref<number[]>([]);
|
||||
const tableRef = ref<TableInstance>();
|
||||
const tableRenderKey = ref(0);
|
||||
|
||||
const allMenus = computed(() => flatMenuList.value);
|
||||
const expandedRowKeys = computed(() => {
|
||||
const firstRootMenu = data.value[0];
|
||||
|
||||
return firstRootMenu ? [String(firstRootMenu.id)] : [];
|
||||
});
|
||||
|
||||
function handleSelectionChange(rows: Api.SystemManage.Menu[]) {
|
||||
checkedRowKeys.value = rows.map(item => item.id);
|
||||
}
|
||||
|
||||
async function reloadTable() {
|
||||
checkedRowKeys.value = [];
|
||||
await getData();
|
||||
tableRenderKey.value += 1;
|
||||
await nextTick();
|
||||
tableRef.value?.clearSelection();
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
operateType.value = 'add';
|
||||
editingData.value = null;
|
||||
openModal();
|
||||
}
|
||||
|
||||
function openAddChild(item: Api.SystemManage.Menu) {
|
||||
operateType.value = 'addChild';
|
||||
editingData.value = item;
|
||||
openModal();
|
||||
}
|
||||
|
||||
function openEdit(item: Api.SystemManage.Menu) {
|
||||
operateType.value = 'edit';
|
||||
editingData.value = item;
|
||||
openModal();
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
const { error } = await fetchDeleteMenu(id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t('common.deleteSuccess'));
|
||||
await reloadTable();
|
||||
}
|
||||
|
||||
async function handleDeleteAction(row: Api.SystemManage.Menu) {
|
||||
try {
|
||||
await window.$messageBox?.confirm($t('common.confirmDelete'), $t('common.warning'), {
|
||||
confirmButtonText: $t('common.confirm'),
|
||||
cancelButtonText: $t('common.cancel'),
|
||||
type: 'warning'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleDelete(row.id);
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
if (!checkedRowKeys.value.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await fetchBatchDeleteMenu(checkedRowKeys.value);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t('common.deleteSuccess'));
|
||||
await reloadTable();
|
||||
}
|
||||
|
||||
async function resetSearchParams() {
|
||||
Object.assign(searchParams, getInitSearchParams());
|
||||
await reloadTable();
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
await reloadTable();
|
||||
}
|
||||
|
||||
async function handleSubmitted() {
|
||||
closeModal();
|
||||
await reloadTable();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-560px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
|
||||
<MenuSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
|
||||
|
||||
<ElCard class="card-wrapper sm:flex-1-hidden" body-class="menu-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-8px">
|
||||
<p>{{ $t('page.system.menu.title') }}</p>
|
||||
<ElTag effect="plain">{{ flatMenuList.length }}</ElTag>
|
||||
</div>
|
||||
<TableHeaderOperation
|
||||
v-model:columns="columnChecks"
|
||||
:disabled-delete="checkedRowKeys.length === 0"
|
||||
:loading="loading"
|
||||
@add="openAdd"
|
||||
@delete="handleBatchDelete"
|
||||
@refresh="reloadTable"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElTable
|
||||
:key="tableRenderKey"
|
||||
ref="tableRef"
|
||||
v-loading="loading"
|
||||
height="100%"
|
||||
border
|
||||
row-key="id"
|
||||
:expand-row-keys="expandedRowKeys"
|
||||
:data="data"
|
||||
class="sm:h-full"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||
</ElTable>
|
||||
</ElCard>
|
||||
|
||||
<MenuOperateDialog
|
||||
v-model:visible="visible"
|
||||
:operate-type="operateType"
|
||||
:row-data="editingData"
|
||||
:all-menus="allMenus"
|
||||
@submitted="handleSubmitted"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.menu-card-body) {
|
||||
height: calc(100% - 56px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
203
src/views/system/menu/modules/icon-options.ts
Normal file
203
src/views/system/menu/modules/icon-options.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
export interface IconOptionGroup {
|
||||
label: string;
|
||||
value: string;
|
||||
icons: string[];
|
||||
}
|
||||
|
||||
export const menuIconGroups: IconOptionGroup[] = [
|
||||
{
|
||||
label: '导航布局',
|
||||
value: 'navigation',
|
||||
icons: [
|
||||
'mdi:home-outline',
|
||||
'mdi:view-dashboard-outline',
|
||||
'mdi:menu-open',
|
||||
'mdi:map',
|
||||
'mdi:compass-outline',
|
||||
'mdi:application-outline',
|
||||
'mdi:monitor-dashboard',
|
||||
'material-symbols:dashboard-outline',
|
||||
'material-symbols:space-dashboard-outline',
|
||||
'material-symbols:route',
|
||||
'material-symbols:account-tree-outline',
|
||||
'icon-park-outline:all-application',
|
||||
'tabler:layout-dashboard',
|
||||
'tabler:sitemap',
|
||||
'tabler:apps',
|
||||
'tabler:browser',
|
||||
'carbon:network-overlay',
|
||||
'hugeicons:flow-square'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '系统管理',
|
||||
value: 'system',
|
||||
icons: [
|
||||
'ep:setting',
|
||||
'ep:tools',
|
||||
'ep:operation',
|
||||
'ep:management',
|
||||
'ep:monitor',
|
||||
'ep:platform',
|
||||
'ep:connection',
|
||||
'ep:office-building',
|
||||
'mdi:cog-outline',
|
||||
'mdi:tune-variant',
|
||||
'mdi:server-outline',
|
||||
'mdi:cloud-outline',
|
||||
'mdi:shield-crown-outline',
|
||||
'carbon:cloud-service-management',
|
||||
'carbon:network-overlay',
|
||||
'ic:round-manage-accounts',
|
||||
'ic:round-supervisor-account',
|
||||
'carbon:user-role',
|
||||
'material-symbols:admin-panel-settings-outline',
|
||||
'material-symbols:settings-outline-rounded'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '用户权限',
|
||||
value: 'user',
|
||||
icons: [
|
||||
'ep:user',
|
||||
'ep:user-filled',
|
||||
'ep:avatar',
|
||||
'mdi:account-outline',
|
||||
'mdi:account-group-outline',
|
||||
'mdi:account-key-outline',
|
||||
'mdi:badge-account-outline',
|
||||
'mdi:card-account-details-outline',
|
||||
'mdi:key-outline',
|
||||
'mdi:shield-account-outline',
|
||||
'mdi:lock-outline',
|
||||
'ri:admin-line',
|
||||
'ri:user-settings-line',
|
||||
'carbon:user-avatar',
|
||||
'carbon:user-role',
|
||||
'carbon:user-admin',
|
||||
'carbon:user-multiple',
|
||||
'ic:round-supervisor-account'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '文档表单',
|
||||
value: 'document',
|
||||
icons: [
|
||||
'mdi:file-document-outline',
|
||||
'mdi:file-document-edit-outline',
|
||||
'mdi:file-document-multiple-outline',
|
||||
'mdi:clipboard-outline',
|
||||
'mdi:book-open-page-variant-outline',
|
||||
'mdi:form-select',
|
||||
'mdi:notebook-outline',
|
||||
'ri:file-excel-2-line',
|
||||
'ri:markdown-line',
|
||||
'uiw:file-pdf',
|
||||
'gridicons:posts',
|
||||
'icon-park-outline:editor',
|
||||
'material-symbols:article-outline',
|
||||
'material-symbols:description-outline',
|
||||
'ph:archive-box-light',
|
||||
'ph:note-pencil',
|
||||
'tabler:files',
|
||||
'tabler:report'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '数据分析',
|
||||
value: 'data',
|
||||
icons: [
|
||||
'ant-design:bar-chart-outlined',
|
||||
'mdi:chart-areaspline',
|
||||
'mdi:chart-bar',
|
||||
'mdi:chart-box-outline',
|
||||
'mdi:database-outline',
|
||||
'mdi:table-large',
|
||||
'mdi:table-search',
|
||||
'icon-park-outline:table',
|
||||
'material-symbols:analytics-outline',
|
||||
'material-symbols:table-chart-outline',
|
||||
'material-symbols:dataset-outline',
|
||||
'simple-icons:apacheecharts',
|
||||
'simple-icons:swiper',
|
||||
'ri:pie-chart-2-line',
|
||||
'ri:line-chart-line',
|
||||
'tabler:chart-bar',
|
||||
'tabler:chart-donut',
|
||||
'tabler:database'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '业务工具',
|
||||
value: 'business',
|
||||
icons: [
|
||||
'clarity:plugin-line',
|
||||
'ic:round-barcode',
|
||||
'mdi:printer',
|
||||
'mdi:typewriter',
|
||||
'mdi:video',
|
||||
'mdi:map-marker-path',
|
||||
'mdi:tools',
|
||||
'mdi:wrench-outline',
|
||||
'mdi:qrcode-scan',
|
||||
'mdi:hammer-wrench',
|
||||
'material-symbols:construction-outline',
|
||||
'material-symbols:extension-outline',
|
||||
'material-symbols:inventory-2-outline',
|
||||
'carbon:tool-kit',
|
||||
'carbon:settings-adjust',
|
||||
'ph:toolbox',
|
||||
'ph:package',
|
||||
'hugeicons:flow-square'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '状态反馈',
|
||||
value: 'status',
|
||||
icons: [
|
||||
'ant-design:exception-outlined',
|
||||
'ic:baseline-block',
|
||||
'ic:baseline-web-asset-off',
|
||||
'ic:baseline-wifi-off',
|
||||
'material-symbols:filter-list-off',
|
||||
'mdi:alert-outline',
|
||||
'mdi:check-circle-outline',
|
||||
'mdi:close-circle-outline',
|
||||
'mdi:information-outline',
|
||||
'mdi:timer-sand',
|
||||
'mdi:progress-question',
|
||||
'tabler:alert-circle',
|
||||
'tabler:circle-check',
|
||||
'tabler:circle-x',
|
||||
'tabler:info-circle'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '通用备选',
|
||||
value: 'common',
|
||||
icons: [
|
||||
'mdi:apps-box',
|
||||
'mdi:emoticon',
|
||||
'mdi:ab-testing',
|
||||
'mdi:alert',
|
||||
'mdi:airballoon',
|
||||
'mdi:airplane-edit',
|
||||
'mdi:alpha-f-box-outline',
|
||||
'mdi:arm-flex-outline',
|
||||
'ph:alarm',
|
||||
'ph:android-logo',
|
||||
'ph:align-bottom',
|
||||
'uil:basketball',
|
||||
'uil:brightness-plus',
|
||||
'uil:capture',
|
||||
'ic:baseline-10mp',
|
||||
'ic:baseline-access-time',
|
||||
'ic:baseline-brightness-4',
|
||||
'ic:baseline-brightness-5',
|
||||
'ic:baseline-credit-card',
|
||||
'entypo-social:google-hangouts'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const menuIconOptions = Array.from(new Set(menuIconGroups.flatMap(group => group.icons)));
|
||||
62
src/views/system/menu/modules/menu-icon-cell.tsx
Normal file
62
src/views/system/menu/modules/menu-icon-cell.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { defineComponent, ref, watch } from 'vue';
|
||||
import { loadIcon } from '@iconify/vue';
|
||||
import SvgIcon from '@/components/custom/svg-icon.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MenuIconCell',
|
||||
props: {
|
||||
icon: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const failed = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.icon,
|
||||
(value, _, onCleanup) => {
|
||||
const icon = value.trim();
|
||||
|
||||
failed.value = !icon;
|
||||
|
||||
if (!icon) {
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
|
||||
onCleanup(() => {
|
||||
active = false;
|
||||
});
|
||||
|
||||
loadIcon(icon)
|
||||
.then(() => {
|
||||
if (active) {
|
||||
failed.value = false;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
failed.value = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return () => {
|
||||
const icon = props.icon.trim();
|
||||
|
||||
if (!icon || failed.value) {
|
||||
return <div class="flex-center text-[#909399]">--</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex-center">
|
||||
<SvgIcon icon={icon} class="text-18px text-[#303133]" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
});
|
||||
58
src/views/system/menu/modules/menu-operate-cell.tsx
Normal file
58
src/views/system/menu/modules/menu-operate-cell.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { computed, defineComponent } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MenuOperateCell',
|
||||
props: {
|
||||
row: {
|
||||
type: Object as PropType<Api.SystemManage.Menu>,
|
||||
required: true
|
||||
},
|
||||
onEdit: {
|
||||
type: Function as PropType<(row: Api.SystemManage.Menu) => void>,
|
||||
required: true
|
||||
},
|
||||
onAddChild: {
|
||||
type: Function as PropType<(row: Api.SystemManage.Menu) => void>,
|
||||
required: true
|
||||
},
|
||||
onDelete: {
|
||||
type: Function as PropType<(row: Api.SystemManage.Menu) => Promise<void>>,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const actions = computed<BusinessTableAction[]>(() => {
|
||||
const list: BusinessTableAction[] = [
|
||||
{
|
||||
key: 'edit',
|
||||
label: $t('common.edit'),
|
||||
buttonType: 'primary',
|
||||
onClick: () => props.onEdit(props.row)
|
||||
}
|
||||
];
|
||||
|
||||
if (props.row.type !== 3) {
|
||||
list.push({
|
||||
key: 'addChild',
|
||||
label: $t('page.system.menu.addChildMenu'),
|
||||
buttonType: 'primary',
|
||||
onClick: () => props.onAddChild(props.row)
|
||||
});
|
||||
}
|
||||
|
||||
list.push({
|
||||
key: 'delete',
|
||||
label: $t('common.delete'),
|
||||
buttonType: 'danger',
|
||||
onClick: () => props.onDelete(props.row)
|
||||
});
|
||||
|
||||
return list;
|
||||
});
|
||||
|
||||
return () => <BusinessTableActionCell actions={actions.value} />;
|
||||
}
|
||||
});
|
||||
1211
src/views/system/menu/modules/menu-operate-dialog.vue
Normal file
1211
src/views/system/menu/modules/menu-operate-dialog.vue
Normal file
File diff suppressed because it is too large
Load Diff
35
src/views/system/menu/modules/menu-search.vue
Normal file
35
src/views/system/menu/modules/menu-search.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'MenuSearch' });
|
||||
|
||||
interface Emits {
|
||||
(e: 'reset'): void;
|
||||
(e: 'search'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const model = defineModel<Api.SystemManage.MenuSearchParams>('model', { required: true });
|
||||
|
||||
function reset() {
|
||||
emit('reset');
|
||||
}
|
||||
|
||||
function search() {
|
||||
emit('search');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchPanel :model="model" :action-col-lg="6" :action-col-md="8" @reset="reset" @search="search">
|
||||
<ElCol :lg="6" :md="8" :sm="12">
|
||||
<ElFormItem :label="$t('page.system.menu.menuName')" prop="name">
|
||||
<ElInput v-model="model.name" clearable :placeholder="$t('page.system.menu.form.menuName')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</TableSearchPanel>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
79
src/views/system/menu/modules/shared.ts
Normal file
79
src/views/system/menu/modules/shared.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
const LAYOUT_PREFIX = 'layout.';
|
||||
const VIEW_PREFIX = 'view.';
|
||||
const FIRST_LEVEL_ROUTE_COMPONENT_SPLIT = '$';
|
||||
|
||||
export function getLayoutAndPage(component?: string | null) {
|
||||
let layout = '';
|
||||
let page = '';
|
||||
|
||||
const [layoutOrPage = '', pageItem = ''] = component?.split(FIRST_LEVEL_ROUTE_COMPONENT_SPLIT) || [];
|
||||
|
||||
layout = getLayout(layoutOrPage);
|
||||
page = getPage(pageItem || layoutOrPage);
|
||||
|
||||
return { layout, page };
|
||||
}
|
||||
|
||||
function getLayout(layout: string) {
|
||||
return layout.startsWith(LAYOUT_PREFIX) ? layout.replace(LAYOUT_PREFIX, '') : '';
|
||||
}
|
||||
|
||||
function getPage(page: string) {
|
||||
return page.startsWith(VIEW_PREFIX) ? page.replace(VIEW_PREFIX, '') : '';
|
||||
}
|
||||
|
||||
export function transformLayoutAndPageToComponent(layout: string, page: string) {
|
||||
const hasLayout = Boolean(layout);
|
||||
const hasPage = Boolean(page);
|
||||
|
||||
if (hasLayout && hasPage) {
|
||||
return `${LAYOUT_PREFIX}${layout}${FIRST_LEVEL_ROUTE_COMPONENT_SPLIT}${VIEW_PREFIX}${page}`;
|
||||
}
|
||||
|
||||
if (hasLayout) {
|
||||
return `${LAYOUT_PREFIX}${layout}`;
|
||||
}
|
||||
|
||||
if (hasPage) {
|
||||
return `${VIEW_PREFIX}${page}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get route name by route path
|
||||
*
|
||||
* @param routeName
|
||||
*/
|
||||
export function getRoutePathByRouteName(routeName: string) {
|
||||
return `/${routeName.replace(/_/g, '/')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path param from route path
|
||||
*
|
||||
* @param routePath route path
|
||||
*/
|
||||
export function getPathParamFromRoutePath(routePath: string) {
|
||||
const [path, param = ''] = routePath.split('/:');
|
||||
|
||||
return {
|
||||
path,
|
||||
param
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get route path with param
|
||||
*
|
||||
* @param routePath route path
|
||||
* @param param path param
|
||||
*/
|
||||
export function getRoutePathWithParam(routePath: string, param: string) {
|
||||
if (param.trim()) {
|
||||
return `${routePath}/:${param}`;
|
||||
}
|
||||
|
||||
return routePath;
|
||||
}
|
||||
3
src/views/system/post/index.vue
Normal file
3
src/views/system/post/index.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<h1>岗位管理</h1>
|
||||
</template>
|
||||
328
src/views/system/role/index.vue
Normal file
328
src/views/system/role/index.vue
Normal file
@@ -0,0 +1,328 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { ElButton, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { useBoolean } from '@sa/hooks';
|
||||
import { commonStatusRecord } from '@/constants/business';
|
||||
import { fetchDeleteRole, fetchGetMenuSimpleList, fetchGetRolePage } from '@/service/api';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
|
||||
import { $t } from '@/locales';
|
||||
import { buildMenuTree } from '@/views/system/shared/menu-tree';
|
||||
import RoleOperateDialog from './modules/role-operate-dialog.vue';
|
||||
import RoleResourcePanel from './modules/role-resource-panel.vue';
|
||||
import RoleSearch from './modules/role-search.vue';
|
||||
|
||||
defineOptions({ name: 'RoleManage' });
|
||||
|
||||
function getInitSearchParams(): Api.SystemManage.RoleSearchParams {
|
||||
return {
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
name: undefined,
|
||||
code: undefined,
|
||||
status: undefined,
|
||||
createTime: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function transformPageResult(response: Awaited<ReturnType<typeof fetchGetRolePage>>, pageNo: number, pageSize: number) {
|
||||
if (!response.error) {
|
||||
return {
|
||||
data: response.data.list,
|
||||
pageNum: pageNo,
|
||||
pageSize,
|
||||
total: response.data.total
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: [],
|
||||
pageNum: pageNo,
|
||||
pageSize,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
function formatTime(value?: number | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
function getStatusTagType(status: Api.SystemManage.CommonStatus): UI.ThemeColor {
|
||||
return status === 0 ? 'success' : 'warning';
|
||||
}
|
||||
|
||||
function getStatusLabel(status: Api.SystemManage.CommonStatus) {
|
||||
return $t(commonStatusRecord[status]);
|
||||
}
|
||||
|
||||
const searchParams = reactive(getInitSearchParams());
|
||||
const selectedRoleId = ref<number | null>(null);
|
||||
const pendingSelectedRoleId = ref<number | null>(null);
|
||||
|
||||
const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable({
|
||||
paginationProps: {
|
||||
currentPage: searchParams.pageNo,
|
||||
pageSize: searchParams.pageSize
|
||||
},
|
||||
api: () => fetchGetRolePage(searchParams),
|
||||
transform: response => transformPageResult(response, searchParams.pageNo, searchParams.pageSize),
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.pageNo = params.currentPage ?? 1;
|
||||
searchParams.pageSize = params.pageSize ?? 10;
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'index', type: 'index', label: $t('common.index'), width: 64 },
|
||||
{ prop: 'name', label: $t('page.system.role.roleName'), minWidth: 160, showOverflowTooltip: true },
|
||||
{ prop: 'code', label: $t('page.system.role.roleCode'), minWidth: 180, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'status',
|
||||
label: $t('page.system.role.roleStatus'),
|
||||
width: 110,
|
||||
align: 'center',
|
||||
formatter: row => <ElTag type={getStatusTagType(row.status)}>{getStatusLabel(row.status)}</ElTag>
|
||||
},
|
||||
{ prop: 'sort', label: $t('page.system.role.sort'), width: 90, align: 'center' },
|
||||
{
|
||||
prop: 'remark',
|
||||
label: $t('page.system.role.remark'),
|
||||
minWidth: 180,
|
||||
showOverflowTooltip: true,
|
||||
formatter: row => row.remark || '--'
|
||||
},
|
||||
{
|
||||
prop: 'createTime',
|
||||
label: $t('page.system.role.createTime'),
|
||||
minWidth: 170,
|
||||
formatter: row => formatTime(row.createTime)
|
||||
},
|
||||
{
|
||||
prop: 'operate',
|
||||
label: $t('common.operate'),
|
||||
width: 196,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => (
|
||||
<BusinessTableActionCell
|
||||
actions={[
|
||||
{
|
||||
key: 'edit',
|
||||
label: $t('common.edit'),
|
||||
buttonType: 'primary',
|
||||
onClick: () => openEdit(row)
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: $t('common.delete'),
|
||||
buttonType: 'danger',
|
||||
onClick: () => handleDeleteAction(row)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const menuTreeLoading = ref(false);
|
||||
const menuTree = ref<Api.SystemManage.MenuSimple[]>([]);
|
||||
|
||||
async function getMenuTreeData() {
|
||||
menuTreeLoading.value = true;
|
||||
|
||||
const { error, data: menuList } = await fetchGetMenuSimpleList();
|
||||
|
||||
menuTreeLoading.value = false;
|
||||
|
||||
if (error) {
|
||||
menuTree.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
menuTree.value = buildMenuTree(menuList);
|
||||
}
|
||||
|
||||
const currentRole = computed(() => data.value.find(item => item.id === selectedRoleId.value) ?? null);
|
||||
|
||||
const { bool: operateVisible, setTrue: openOperateModal, setFalse: closeOperateModal } = useBoolean();
|
||||
const operateType = ref<UI.TableOperateType>('add');
|
||||
const editingData = ref<Api.SystemManage.Role | null>(null);
|
||||
|
||||
function openAdd() {
|
||||
operateType.value = 'add';
|
||||
editingData.value = null;
|
||||
openOperateModal();
|
||||
}
|
||||
|
||||
function openEdit(item: Api.SystemManage.Role) {
|
||||
operateType.value = 'edit';
|
||||
editingData.value = item;
|
||||
openOperateModal();
|
||||
}
|
||||
|
||||
async function handleDelete(item: Api.SystemManage.Role) {
|
||||
const { error } = await fetchDeleteRole(item.id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t('common.deleteSuccess'));
|
||||
|
||||
if (selectedRoleId.value === item.id) {
|
||||
selectedRoleId.value = null;
|
||||
}
|
||||
|
||||
await getData();
|
||||
}
|
||||
|
||||
async function handleDeleteAction(row: Api.SystemManage.Role) {
|
||||
try {
|
||||
await window.$messageBox?.confirm($t('common.confirmDelete'), $t('common.warning'), {
|
||||
confirmButtonText: $t('common.confirm'),
|
||||
cancelButtonText: $t('common.cancel'),
|
||||
type: 'warning'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleDelete(row);
|
||||
}
|
||||
|
||||
function resetSearchParams() {
|
||||
Object.assign(searchParams, getInitSearchParams());
|
||||
getDataByPage(1);
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
getDataByPage(1);
|
||||
}
|
||||
|
||||
function selectRole(roleId: number) {
|
||||
selectedRoleId.value = roleId;
|
||||
}
|
||||
|
||||
function handleRowClick(row: Api.SystemManage.Role) {
|
||||
selectRole(row.id);
|
||||
}
|
||||
|
||||
function handleSubmitted(roleId: number) {
|
||||
pendingSelectedRoleId.value = roleId;
|
||||
closeOperateModal();
|
||||
getData();
|
||||
}
|
||||
|
||||
watch(
|
||||
data,
|
||||
value => {
|
||||
if (!value.length) {
|
||||
selectedRoleId.value = null;
|
||||
pendingSelectedRoleId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const targetId = pendingSelectedRoleId.value ?? selectedRoleId.value;
|
||||
const matched = targetId ? value.find(item => item.id === targetId) : null;
|
||||
|
||||
if (matched) {
|
||||
selectedRoleId.value = matched.id;
|
||||
} else {
|
||||
selectedRoleId.value = value[0].id;
|
||||
}
|
||||
|
||||
pendingSelectedRoleId.value = null;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
getMenuTreeData();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-h-560px gap-16px overflow-hidden xl:grid xl:grid-cols-[minmax(0,1fr)_360px] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
|
||||
>
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<RoleSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
|
||||
|
||||
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="role-table-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-10px">
|
||||
<p>{{ $t('page.system.role.title') }}</p>
|
||||
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
|
||||
</div>
|
||||
<TableHeaderOperation
|
||||
v-model:columns="columnChecks"
|
||||
:disabled-delete="true"
|
||||
:loading="loading"
|
||||
@refresh="getData"
|
||||
>
|
||||
<template #default>
|
||||
<ElButton plain type="primary" @click="openAdd">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-1">
|
||||
<ElTable
|
||||
v-loading="loading"
|
||||
height="100%"
|
||||
border
|
||||
row-key="id"
|
||||
highlight-current-row
|
||||
:data="data"
|
||||
:current-row-key="selectedRoleId ?? undefined"
|
||||
@row-click="handleRowClick"
|
||||
>
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<div class="mt-20px flex justify-end">
|
||||
<ElPagination
|
||||
v-if="mobilePagination.total"
|
||||
layout="total,prev,pager,next,sizes"
|
||||
v-bind="mobilePagination"
|
||||
@current-change="mobilePagination['current-change']"
|
||||
@size-change="mobilePagination['size-change']"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<div class="flex-col-stretch xl:min-h-0">
|
||||
<RoleResourcePanel :role="currentRole" :menu-tree="menuTree" :loading="menuTreeLoading" />
|
||||
</div>
|
||||
|
||||
<RoleOperateDialog
|
||||
v-model:visible="operateVisible"
|
||||
:operate-type="operateType"
|
||||
:row-data="editingData"
|
||||
@submitted="handleSubmitted"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.role-table-card-body) {
|
||||
height: calc(100% - 56px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.el-table__row.current-row > td.el-table__cell) {
|
||||
background-color: rgb(64 158 255 / 8%);
|
||||
}
|
||||
</style>
|
||||
194
src/views/system/role/modules/role-operate-dialog.vue
Normal file
194
src/views/system/role/modules/role-operate-dialog.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { commonStatusOptions } from '@/constants/business';
|
||||
import { fetchCreateRole, fetchGetRole, fetchUpdateRole } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'RoleOperateDialog' });
|
||||
|
||||
interface Props {
|
||||
operateType: UI.TableOperateType;
|
||||
rowData?: Api.SystemManage.Role | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted', roleId: number): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const detailLoading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const isEdit = computed(() => props.operateType === 'edit');
|
||||
|
||||
const title = computed(() => {
|
||||
const titleMap: Record<UI.TableOperateType, string> = {
|
||||
add: $t('page.system.role.addRole'),
|
||||
edit: $t('page.system.role.editRole')
|
||||
};
|
||||
|
||||
return titleMap[props.operateType];
|
||||
});
|
||||
|
||||
type Model = Api.SystemManage.SaveRoleParams;
|
||||
|
||||
const model = ref(createDefaultModel());
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
name: '',
|
||||
code: '',
|
||||
sort: 0,
|
||||
status: 0,
|
||||
remark: ''
|
||||
};
|
||||
}
|
||||
|
||||
const rules = {
|
||||
name: createRequiredRule($t('page.system.role.form.roleName')),
|
||||
code: createRequiredRule($t('page.system.role.form.roleCode')),
|
||||
sort: createRequiredRule($t('page.system.role.form.sort')),
|
||||
status: createRequiredRule($t('page.system.role.form.roleStatus'))
|
||||
} satisfies Record<string, App.Global.FormRule>;
|
||||
|
||||
function closeModal() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function initModel() {
|
||||
model.value = createDefaultModel();
|
||||
|
||||
if (!isEdit.value || !props.rowData) {
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
return;
|
||||
}
|
||||
|
||||
detailLoading.value = true;
|
||||
|
||||
const { error, data } = await fetchGetRole(props.rowData.id);
|
||||
|
||||
detailLoading.value = false;
|
||||
|
||||
if (!error) {
|
||||
model.value = {
|
||||
name: data.name,
|
||||
code: data.code,
|
||||
sort: data.sort,
|
||||
status: data.status,
|
||||
remark: data.remark ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const submitData: Api.SystemManage.SaveRoleParams = {
|
||||
...model.value,
|
||||
name: model.value.name.trim(),
|
||||
code: model.value.code.trim(),
|
||||
remark: model.value.remark?.trim() || null
|
||||
};
|
||||
|
||||
const request =
|
||||
isEdit.value && props.rowData
|
||||
? fetchUpdateRole({ id: props.rowData.id, ...submitData })
|
||||
: fetchCreateRole(submitData);
|
||||
|
||||
const { error, data } = await request;
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
const roleId = isEdit.value && props.rowData ? props.rowData.id : Number(data);
|
||||
|
||||
window.$message?.success($t(isEdit.value ? 'common.updateSuccess' : 'common.addSuccess'));
|
||||
|
||||
closeModal();
|
||||
emit('submitted', roleId);
|
||||
}
|
||||
|
||||
watch(visible, value => {
|
||||
if (value) {
|
||||
initModel();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="md"
|
||||
:loading="detailLoading"
|
||||
:confirm-loading="submitting"
|
||||
:scrollbar="false"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.role.roleName')" prop="name">
|
||||
<ElInput v-model="model.name" :placeholder="$t('page.system.role.form.roleName')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.role.roleCode')" prop="code">
|
||||
<ElInput v-model="model.code" :placeholder="$t('page.system.role.form.roleCode')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.role.sort')" prop="sort">
|
||||
<ElInputNumber
|
||||
v-model="model.sort"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:placeholder="$t('page.system.role.form.sort')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.role.roleStatus')" prop="status">
|
||||
<ElRadioGroup v-model="model.status" class="business-form-radio-group">
|
||||
<ElRadio v-for="{ label, value } in commonStatusOptions" :key="value" :value="value">
|
||||
{{ $t(label) }}
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem :label="$t('page.system.role.remark')" prop="remark">
|
||||
<ElInput
|
||||
v-model="model.remark"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="$t('page.system.role.form.remark')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
238
src/views/system/role/modules/role-resource-panel.vue
Normal file
238
src/views/system/role/modules/role-resource-panel.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import type { TreeInstance } from 'element-plus';
|
||||
import { menuTypeRecord } from '@/constants/business';
|
||||
import { fetchAssignRoleMenus, fetchGetRoleMenuIds } from '@/service/api';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'RoleResourcePanel' });
|
||||
|
||||
interface Props {
|
||||
role: Api.SystemManage.Role | null;
|
||||
menuTree: Api.SystemManage.MenuSimple[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'saved'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const treeRef = ref<TreeInstance | null>(null);
|
||||
const permissionLoading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const filterKeyword = ref('');
|
||||
const checkedKeys = ref<number[]>([]);
|
||||
|
||||
const disabled = computed(() => !props.role || props.role.status === 1);
|
||||
const checkedCount = computed(() => checkedKeys.value.length);
|
||||
const defaultExpandedKeys = computed(() => collectExpandableNodeIds(props.menuTree));
|
||||
const treeRenderKey = computed(() => `${props.role?.id ?? 'empty'}:${defaultExpandedKeys.value.join(',')}`);
|
||||
|
||||
const treeProps = {
|
||||
children: 'children',
|
||||
label: 'name'
|
||||
} as const;
|
||||
|
||||
function getTagType(type: Api.SystemManage.MenuType): UI.ThemeColor {
|
||||
const tagMap: Record<Api.SystemManage.MenuType, UI.ThemeColor> = {
|
||||
1: 'info',
|
||||
2: 'primary',
|
||||
3: 'warning'
|
||||
};
|
||||
|
||||
return tagMap[type];
|
||||
}
|
||||
|
||||
function getMenuTypeLabel(type: number) {
|
||||
return $t(menuTypeRecord[type as Api.SystemManage.MenuType]);
|
||||
}
|
||||
|
||||
function filterNode(value: string, data: any) {
|
||||
const node = data as Api.SystemManage.MenuSimple;
|
||||
|
||||
if (!value.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return node.name.toLowerCase().includes(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function collectExpandableNodeIds(nodes: Api.SystemManage.MenuSimple[]) {
|
||||
const ids: number[] = [];
|
||||
|
||||
const walk = (items: Api.SystemManage.MenuSimple[]) => {
|
||||
items.forEach(item => {
|
||||
const children = item.children ?? [];
|
||||
const hasNonButtonChild = children.some(child => child.type !== 3);
|
||||
|
||||
if (hasNonButtonChild) {
|
||||
ids.push(item.id);
|
||||
walk(children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
walk(nodes);
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function loadRoleMenus() {
|
||||
if (!props.role) {
|
||||
checkedKeys.value = [];
|
||||
treeRef.value?.setCheckedKeys([]);
|
||||
treeRef.value?.filter(filterKeyword.value);
|
||||
return;
|
||||
}
|
||||
|
||||
permissionLoading.value = true;
|
||||
|
||||
const { error, data } = await fetchGetRoleMenuIds(props.role.id);
|
||||
|
||||
permissionLoading.value = false;
|
||||
|
||||
if (error) {
|
||||
checkedKeys.value = [];
|
||||
treeRef.value?.setCheckedKeys([]);
|
||||
treeRef.value?.filter(filterKeyword.value);
|
||||
return;
|
||||
}
|
||||
|
||||
checkedKeys.value = data;
|
||||
treeRef.value?.setCheckedKeys(data);
|
||||
treeRef.value?.filter(filterKeyword.value);
|
||||
}
|
||||
|
||||
function handleCheck() {
|
||||
checkedKeys.value = (treeRef.value?.getCheckedKeys(false) as number[]) ?? [];
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!props.role) {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuIds = (treeRef.value?.getCheckedKeys(false) as number[]) ?? [];
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const { error } = await fetchAssignRoleMenus({
|
||||
roleId: props.role.id,
|
||||
menuIds
|
||||
});
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
checkedKeys.value = menuIds;
|
||||
|
||||
window.$message?.success($t('common.modifySuccess'));
|
||||
emit('saved');
|
||||
}
|
||||
|
||||
watch(filterKeyword, value => {
|
||||
treeRef.value?.filter(value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.role?.id,
|
||||
() => {
|
||||
loadRoleMenus();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.menuTree.length,
|
||||
value => {
|
||||
if (value && props.role) {
|
||||
treeRef.value?.setCheckedKeys(checkedKeys.value);
|
||||
treeRef.value?.filter(filterKeyword.value);
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="h-full card-wrapper" body-class="role-resource-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<p>{{ $t('page.system.role.resourceAuth') }}</p>
|
||||
<ElButton type="primary" size="small" :disabled="disabled" :loading="submitting" @click="handleSave">
|
||||
{{ $t('page.system.role.saveAuth') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="role">
|
||||
<div class="mb-12px flex flex-col gap-10px">
|
||||
<ElInput v-model="filterKeyword" clearable :placeholder="$t('page.system.role.form.resourceKeyword')">
|
||||
<template #prefix>
|
||||
<icon-ic-round-search class="text-icon" />
|
||||
</template>
|
||||
</ElInput>
|
||||
<div class="flex items-center gap-8px text-13px text-[#606266]">
|
||||
<span>{{ $t('page.system.role.selectedCount') }}</span>
|
||||
<ElTag type="primary" effect="plain">{{ checkedCount }}</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElAlert
|
||||
v-if="role.status === 1"
|
||||
:title="$t('page.system.role.disabledTip')"
|
||||
type="warning"
|
||||
class="mb-12px"
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-loading="permissionLoading || props.loading"
|
||||
:class="{ 'pointer-events-none opacity-70': disabled }"
|
||||
class="min-h-0 flex-1 overflow-hidden border border-[#ebeef5] rounded-12px bg-[#fcfdff]"
|
||||
>
|
||||
<ElScrollbar class="h-full px-12px py-12px">
|
||||
<ElTree
|
||||
:key="treeRenderKey"
|
||||
ref="treeRef"
|
||||
node-key="id"
|
||||
show-checkbox
|
||||
:default-expanded-keys="defaultExpandedKeys"
|
||||
:data="menuTree"
|
||||
:props="treeProps"
|
||||
:filter-node-method="filterNode"
|
||||
:check-on-click-node="true"
|
||||
@check="handleCheck"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div class="min-w-0 flex items-center gap-8px">
|
||||
<span class="truncate text-14px">{{ data.name }}</span>
|
||||
<ElTag size="small" effect="plain" :type="getTagType(data.type)">
|
||||
{{ getMenuTypeLabel(data.type) }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</template>
|
||||
</ElTree>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="h-full flex items-center justify-center">
|
||||
<ElEmpty :description="$t('page.system.role.emptyRole')" />
|
||||
</div>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.role-resource-card-body) {
|
||||
height: calc(100% - 56px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
55
src/views/system/role/modules/role-search.vue
Normal file
55
src/views/system/role/modules/role-search.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { commonStatusOptions } from '@/constants/business';
|
||||
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'RoleSearch' });
|
||||
|
||||
interface Emits {
|
||||
(e: 'reset'): void;
|
||||
(e: 'search'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const model = defineModel<Api.SystemManage.RoleSearchParams>('model', { required: true });
|
||||
|
||||
const keyword = computed({
|
||||
get() {
|
||||
return model.value.name ?? model.value.code ?? '';
|
||||
},
|
||||
set(value: string) {
|
||||
const text = value.trim() || undefined;
|
||||
model.value.name = text;
|
||||
model.value.code = text;
|
||||
}
|
||||
});
|
||||
|
||||
function reset() {
|
||||
emit('reset');
|
||||
}
|
||||
|
||||
function search() {
|
||||
emit('search');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchPanel :model="model" :action-col-lg="8" @reset="reset" @search="search">
|
||||
<ElCol :lg="8" :md="12" :sm="12">
|
||||
<ElFormItem :label="$t('page.system.role.searchKeyword')" prop="name">
|
||||
<ElInput v-model="keyword" clearable :placeholder="$t('page.system.role.searchPlaceholder')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :lg="8" :md="12" :sm="12">
|
||||
<ElFormItem :label="$t('page.system.role.roleStatus')" prop="status">
|
||||
<ElSelect v-model="model.status" clearable :placeholder="$t('page.system.role.form.roleStatus')">
|
||||
<ElOption v-for="{ label, value } in commonStatusOptions" :key="value" :label="$t(label)" :value="value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</TableSearchPanel>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
92
src/views/system/shared/menu-tree.ts
Normal file
92
src/views/system/shared/menu-tree.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
type TreeNode = {
|
||||
id: number;
|
||||
parentId: number;
|
||||
sort?: number | null;
|
||||
children?: TreeNode[] | null;
|
||||
};
|
||||
|
||||
export function buildMenuTree<T extends TreeNode>(list: T[]) {
|
||||
const nodeMap = new Map<number, T>();
|
||||
const roots: T[] = [];
|
||||
|
||||
list.forEach(item => {
|
||||
nodeMap.set(item.id, {
|
||||
...item,
|
||||
children: []
|
||||
});
|
||||
});
|
||||
|
||||
nodeMap.forEach(node => {
|
||||
if (node.parentId === 0) {
|
||||
roots.push(node);
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = nodeMap.get(node.parentId);
|
||||
|
||||
if (!parent) {
|
||||
roots.push(node);
|
||||
return;
|
||||
}
|
||||
|
||||
parent.children = [...(parent.children ?? []), node];
|
||||
});
|
||||
|
||||
return sortMenuTree(roots);
|
||||
}
|
||||
|
||||
export function collectDescendantIds<T extends Pick<TreeNode, 'id' | 'children'>>(nodes: T[], targetId: number) {
|
||||
const target = findTreeNode(nodes, targetId);
|
||||
|
||||
if (!target?.children?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ids: number[] = [];
|
||||
|
||||
walkTree(target.children, item => {
|
||||
ids.push(item.id);
|
||||
});
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
function sortMenuTree<T extends TreeNode>(nodes: T[]) {
|
||||
const sortedNodes = [...nodes].sort((prev, next) => Number(prev.sort ?? 0) - Number(next.sort ?? 0));
|
||||
|
||||
sortedNodes.forEach(node => {
|
||||
if (node.children?.length) {
|
||||
node.children = sortMenuTree(node.children as T[]);
|
||||
}
|
||||
});
|
||||
|
||||
return sortedNodes;
|
||||
}
|
||||
|
||||
function findTreeNode<T extends Pick<TreeNode, 'id' | 'children'>>(nodes: T[], targetId: number): T | null {
|
||||
for (const node of nodes) {
|
||||
if (node.id === targetId) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (node.children?.length) {
|
||||
const target = findTreeNode(node.children as unknown as T[], targetId);
|
||||
|
||||
if (target) {
|
||||
return target;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function walkTree<T extends Pick<TreeNode, 'id' | 'children'>>(nodes: T[], callback: (node: T) => void) {
|
||||
for (const node of nodes) {
|
||||
callback(node);
|
||||
|
||||
if (node.children?.length) {
|
||||
walkTree(node.children as unknown as T[], callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/views/system/user-detail/[id].vue
Normal file
14
src/views/system/user-detail/[id].vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
id: string;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LookForward />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
737
src/views/system/user/index.vue
Normal file
737
src/views/system/user/index.vue
Normal file
@@ -0,0 +1,737 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
import type { TableInstance } from 'element-plus';
|
||||
import { ElButton, ElPopconfirm, ElSwitch, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import type { FlatResponseData } from '@sa/axios';
|
||||
import { userGenderRecord } from '@/constants/business';
|
||||
import {
|
||||
fetchBatchDeleteUser,
|
||||
fetchDeleteDept,
|
||||
fetchDeleteUser,
|
||||
fetchGetDeptList,
|
||||
fetchGetPostSimpleList,
|
||||
fetchGetRoleSimpleList,
|
||||
fetchGetUser,
|
||||
fetchGetUserPage,
|
||||
fetchUpdateUser,
|
||||
fetchUpdateUserStatus
|
||||
} from '@/service/api';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
|
||||
import { $t } from '@/locales';
|
||||
import { buildMenuTree } from '@/views/system/shared/menu-tree';
|
||||
import UserOperateDialog from './modules/user-operate-dialog.vue';
|
||||
import UserOrgLeaderDialog from './modules/user-org-leader-dialog.vue';
|
||||
import UserOrgOperateDialog from './modules/user-org-operate-dialog.vue';
|
||||
import UserOrgPanel from './modules/user-org-panel.vue';
|
||||
import UserResignedDialog from './modules/user-resigned-dialog.vue';
|
||||
import UserResetPasswordDialog from './modules/user-reset-password-dialog.vue';
|
||||
import UserSearch from './modules/user-search.vue';
|
||||
|
||||
defineOptions({ name: 'UserManage' });
|
||||
|
||||
function getInitSearchParams(): Api.SystemManage.UserSearchParams {
|
||||
return {
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
username: undefined,
|
||||
mobile: undefined,
|
||||
status: undefined,
|
||||
deptId: undefined,
|
||||
roleId: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyUserPageResult(): Promise<FlatResponseData<any, Api.SystemManage.UserList>> {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
list: [],
|
||||
total: 0
|
||||
},
|
||||
error: null,
|
||||
response: undefined
|
||||
} as unknown as FlatResponseData<any, Api.SystemManage.UserList>);
|
||||
}
|
||||
|
||||
function transformUserPage(
|
||||
response: FlatResponseData<any, Api.SystemManage.UserList>,
|
||||
pageNo: number,
|
||||
pageSize: number
|
||||
) {
|
||||
if (!response.error) {
|
||||
return {
|
||||
data: response.data.list,
|
||||
pageNum: pageNo,
|
||||
pageSize,
|
||||
total: response.data.total
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: [],
|
||||
pageNum: pageNo,
|
||||
pageSize,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
function formatTime(value?: number | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
function getNullableLabel(value?: string | null) {
|
||||
return value?.trim() || '--';
|
||||
}
|
||||
|
||||
type UserResignedState = 'active' | 'pending' | 'resigned';
|
||||
|
||||
function getUserResignedState(row: Api.SystemManage.User): UserResignedState {
|
||||
if (!row.resignedAt) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
return row.resignedAt > Date.now() ? 'pending' : 'resigned';
|
||||
}
|
||||
|
||||
function getResignedActionConfig(row: Api.SystemManage.User) {
|
||||
const state = getUserResignedState(row);
|
||||
|
||||
if (state === 'active') {
|
||||
return {
|
||||
label: $t('page.system.user.resignUser'),
|
||||
buttonType: 'warning' as const
|
||||
};
|
||||
}
|
||||
|
||||
if (state === 'pending') {
|
||||
return {
|
||||
label: $t('page.system.user.adjustResignUser'),
|
||||
buttonType: 'warning' as const
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: $t('page.system.user.restoreUser'),
|
||||
buttonType: 'success' as const
|
||||
};
|
||||
}
|
||||
|
||||
const searchParams = reactive(getInitSearchParams());
|
||||
const deptLoading = ref(false);
|
||||
const userTableRef = ref<TableInstance>();
|
||||
const userCheckedRowKeys = ref<number[]>([]);
|
||||
const statusLoadingIds = ref<number[]>([]);
|
||||
const deptList = ref<Api.SystemManage.Dept[]>([]);
|
||||
const currentDeptId = ref<number | null>(null);
|
||||
const operateVisible = ref(false);
|
||||
const operateType = ref<UI.TableOperateType>('add');
|
||||
const editingUserId = ref<number | null>(null);
|
||||
const postOptions = ref<Api.SystemManage.PostSimple[]>([]);
|
||||
const roleOptions = ref<Api.SystemManage.RoleSimple[]>([]);
|
||||
const resetPasswordVisible = ref(false);
|
||||
const resetPasswordUserId = ref<number | null>(null);
|
||||
const resetPasswordUsername = ref<string | null>(null);
|
||||
const resignedVisible = ref(false);
|
||||
const resignedUserId = ref<number | null>(null);
|
||||
const resignedUsername = ref<string | null>(null);
|
||||
const resignedAt = ref<number | null>(null);
|
||||
const orgOperateVisible = ref(false);
|
||||
const orgOperateType = ref<UI.TableOperateType>('add');
|
||||
const editingDeptData = ref<Api.SystemManage.Dept | null>(null);
|
||||
const orgParentId = ref<number | null>(0);
|
||||
const orgLeaderVisible = ref(false);
|
||||
const leaderDeptData = ref<Api.SystemManage.Dept | null>(null);
|
||||
|
||||
const deptTree = computed(() => buildMenuTree(deptList.value));
|
||||
const currentDept = computed(() => deptList.value.find(item => item.id === currentDeptId.value) ?? null);
|
||||
const deptCount = computed(() => deptList.value.length);
|
||||
|
||||
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||
FlatResponseData<any, Api.SystemManage.UserList>,
|
||||
Api.SystemManage.User
|
||||
>({
|
||||
paginationProps: {
|
||||
currentPage: searchParams.pageNo,
|
||||
pageSize: searchParams.pageSize
|
||||
},
|
||||
api: () => {
|
||||
if (!currentDeptId.value) {
|
||||
return createEmptyUserPageResult();
|
||||
}
|
||||
|
||||
return fetchGetUserPage({
|
||||
...searchParams,
|
||||
deptId: currentDeptId.value
|
||||
});
|
||||
},
|
||||
transform: response => transformUserPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.pageNo = params.currentPage ?? 1;
|
||||
searchParams.pageSize = params.pageSize ?? 10;
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'selection', type: 'selection', width: 48 },
|
||||
{ prop: 'index', type: 'index', label: $t('common.index'), width: 64 },
|
||||
{ prop: 'username', label: $t('page.system.user.userName'), minWidth: 140, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'nickname',
|
||||
label: $t('page.system.user.nickName'),
|
||||
minWidth: 120,
|
||||
formatter: row => getNullableLabel(row.nickname)
|
||||
},
|
||||
{
|
||||
prop: 'deptName',
|
||||
label: $t('page.system.user.deptName'),
|
||||
minWidth: 180,
|
||||
showOverflowTooltip: true,
|
||||
formatter: row => getNullableLabel(row.deptName)
|
||||
},
|
||||
{
|
||||
prop: 'positionName',
|
||||
label: $t('page.system.user.positionName'),
|
||||
minWidth: 140,
|
||||
showOverflowTooltip: true,
|
||||
formatter: row => getNullableLabel(row.positionName)
|
||||
},
|
||||
{
|
||||
prop: 'mobile',
|
||||
label: $t('page.system.user.userPhone'),
|
||||
width: 140,
|
||||
formatter: row => getNullableLabel(row.mobile)
|
||||
},
|
||||
{
|
||||
prop: 'email',
|
||||
label: $t('page.system.user.userEmail'),
|
||||
minWidth: 180,
|
||||
showOverflowTooltip: true,
|
||||
formatter: row => getNullableLabel(row.email)
|
||||
},
|
||||
{
|
||||
prop: 'sex',
|
||||
label: $t('page.system.user.userGender'),
|
||||
width: 100,
|
||||
align: 'center',
|
||||
formatter: row => {
|
||||
const value = row.sex ?? 0;
|
||||
const tagMap: Record<Api.SystemManage.UserGender, UI.ThemeColor> = {
|
||||
0: 'info',
|
||||
1: 'primary',
|
||||
2: 'danger'
|
||||
};
|
||||
|
||||
return <ElTag type={tagMap[value]}>{$t(userGenderRecord[value])}</ElTag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: $t('page.system.user.userStatus'),
|
||||
width: 110,
|
||||
align: 'center',
|
||||
formatter: row => (
|
||||
<ElSwitch
|
||||
modelValue={row.status === 0}
|
||||
loading={statusLoadingIds.value.includes(row.id)}
|
||||
inlinePrompt
|
||||
activeText={$t('page.system.common.status.enable')}
|
||||
inactiveText={$t('page.system.common.status.disable')}
|
||||
onChange={value => handleToggleStatus(row, Boolean(value))}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'resignedAt',
|
||||
label: $t('page.system.user.resignedAt'),
|
||||
minWidth: 170,
|
||||
formatter: row => formatTime(row.resignedAt)
|
||||
},
|
||||
{
|
||||
prop: 'resignedState',
|
||||
label: $t('page.system.user.resignedState'),
|
||||
width: 110,
|
||||
align: 'center',
|
||||
formatter: row => {
|
||||
const state = getUserResignedState(row);
|
||||
const stateMap: Record<UserResignedState, { type: UI.ThemeColor; label: App.I18n.I18nKey }> = {
|
||||
active: { type: 'success', label: 'page.system.user.resignedStateEnum.active' },
|
||||
pending: { type: 'warning', label: 'page.system.user.resignedStateEnum.pending' },
|
||||
resigned: { type: 'info', label: 'page.system.user.resignedStateEnum.resigned' }
|
||||
};
|
||||
|
||||
return <ElTag type={stateMap[state].type}>{$t(stateMap[state].label)}</ElTag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'loginDate',
|
||||
label: $t('page.system.user.loginDate'),
|
||||
minWidth: 170,
|
||||
formatter: row => formatTime(row.loginDate)
|
||||
},
|
||||
{
|
||||
prop: 'createTime',
|
||||
label: $t('page.system.user.createTime'),
|
||||
minWidth: 170,
|
||||
formatter: row => formatTime(row.createTime)
|
||||
},
|
||||
{
|
||||
prop: 'operate',
|
||||
label: $t('common.operate'),
|
||||
width: 210,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => (
|
||||
<BusinessTableActionCell
|
||||
actions={[
|
||||
{
|
||||
key: 'edit',
|
||||
label: $t('common.edit'),
|
||||
buttonType: 'primary',
|
||||
onClick: () => openEdit(row.id)
|
||||
},
|
||||
{
|
||||
key: 'reset-password',
|
||||
label: $t('page.system.user.resetPassword'),
|
||||
buttonType: 'warning',
|
||||
onClick: () => openResetPassword(row)
|
||||
},
|
||||
{
|
||||
key: 'resigned',
|
||||
label: getResignedActionConfig(row).label,
|
||||
buttonType: getResignedActionConfig(row).buttonType,
|
||||
onClick: () => handleResignedAction(row)
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: $t('common.delete'),
|
||||
buttonType: 'danger',
|
||||
onClick: () => handleDeleteAction(row)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
async function loadDeptTree() {
|
||||
deptLoading.value = true;
|
||||
|
||||
const { error, data: deptItems } = await fetchGetDeptList({
|
||||
status: 0
|
||||
});
|
||||
|
||||
deptLoading.value = false;
|
||||
|
||||
if (error) {
|
||||
deptList.value = [];
|
||||
currentDeptId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
deptList.value = deptItems;
|
||||
|
||||
if (!deptItems.length) {
|
||||
currentDeptId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const matched = deptItems.find(item => item.id === currentDeptId.value);
|
||||
currentDeptId.value = matched?.id ?? deptItems[0].id;
|
||||
}
|
||||
|
||||
async function loadFormOptions() {
|
||||
const [postResult, roleResult] = await Promise.all([fetchGetPostSimpleList(), fetchGetRoleSimpleList()]);
|
||||
|
||||
if (!postResult.error) {
|
||||
postOptions.value = postResult.data;
|
||||
}
|
||||
|
||||
if (!roleResult.error) {
|
||||
roleOptions.value = roleResult.data.filter(item => item.status === 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadUserTable(page = searchParams.pageNo) {
|
||||
userCheckedRowKeys.value = [];
|
||||
await getDataByPage(page);
|
||||
await nextTick();
|
||||
userTableRef.value?.clearSelection();
|
||||
}
|
||||
|
||||
function handleDeptSelect(nodeData: Api.SystemManage.Dept) {
|
||||
currentDeptId.value = nodeData.id;
|
||||
}
|
||||
|
||||
function handleUserSelectionChange(rows: Api.SystemManage.User[]) {
|
||||
userCheckedRowKeys.value = rows.map(item => item.id);
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
operateType.value = 'add';
|
||||
editingUserId.value = null;
|
||||
operateVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(id: number) {
|
||||
operateType.value = 'edit';
|
||||
editingUserId.value = id;
|
||||
operateVisible.value = true;
|
||||
}
|
||||
|
||||
function openResetPassword(row: Api.SystemManage.User) {
|
||||
resetPasswordUserId.value = row.id;
|
||||
resetPasswordUsername.value = row.username;
|
||||
resetPasswordVisible.value = true;
|
||||
}
|
||||
|
||||
function openResignedDialog(row: Api.SystemManage.User) {
|
||||
resignedUserId.value = row.id;
|
||||
resignedUsername.value = row.username;
|
||||
resignedAt.value = row.resignedAt ?? null;
|
||||
resignedVisible.value = true;
|
||||
}
|
||||
|
||||
function openAddRootOrg() {
|
||||
orgOperateType.value = 'add';
|
||||
editingDeptData.value = null;
|
||||
orgParentId.value = 0;
|
||||
orgOperateVisible.value = true;
|
||||
}
|
||||
|
||||
function openAddChildOrg(row: Api.SystemManage.Dept) {
|
||||
orgOperateType.value = 'add';
|
||||
editingDeptData.value = null;
|
||||
orgParentId.value = row.id;
|
||||
orgOperateVisible.value = true;
|
||||
}
|
||||
|
||||
function openEditOrg(row: Api.SystemManage.Dept) {
|
||||
orgOperateType.value = 'edit';
|
||||
editingDeptData.value = row;
|
||||
orgParentId.value = row.parentId;
|
||||
orgOperateVisible.value = true;
|
||||
}
|
||||
|
||||
function openOrgLeader(row: Api.SystemManage.Dept) {
|
||||
leaderDeptData.value = row;
|
||||
orgLeaderVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleDeleteDeptAction(row: Api.SystemManage.Dept) {
|
||||
const { error } = await fetchDeleteDept(row.id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentDeptId.value === row.id) {
|
||||
currentDeptId.value = null;
|
||||
}
|
||||
|
||||
window.$message?.success($t('common.deleteSuccess'));
|
||||
await loadDeptTree();
|
||||
}
|
||||
|
||||
async function handleDeleteAction(row: Api.SystemManage.User) {
|
||||
try {
|
||||
await window.$messageBox?.confirm($t('common.confirmDelete'), $t('common.warning'), {
|
||||
confirmButtonText: $t('common.confirm'),
|
||||
cancelButtonText: $t('common.cancel'),
|
||||
type: 'warning'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await fetchDeleteUser(row.id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t('common.deleteSuccess'));
|
||||
await reloadUserTable();
|
||||
}
|
||||
|
||||
async function updateUserResignedAt(userId: number, value: number | null) {
|
||||
const detailResult = await fetchGetUser(userId);
|
||||
|
||||
if (detailResult.error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const user = detailResult.data;
|
||||
|
||||
const { error } = await fetchUpdateUser({
|
||||
id: userId,
|
||||
username: user.username,
|
||||
nickname: user.nickname ?? null,
|
||||
remark: user.remark ?? null,
|
||||
deptId: user.deptId,
|
||||
positionId: user.positionId ?? null,
|
||||
resignedAt: value,
|
||||
email: user.email ?? null,
|
||||
mobile: user.mobile ?? null,
|
||||
sex: user.sex ?? 0,
|
||||
avatar: user.avatar ?? null
|
||||
});
|
||||
|
||||
return !error;
|
||||
}
|
||||
|
||||
async function handleRestoreUser(row: Api.SystemManage.User) {
|
||||
try {
|
||||
await window.$messageBox?.confirm($t('common.confirmDelete'), $t('page.system.user.restoreUser'), {
|
||||
confirmButtonText: $t('common.confirm'),
|
||||
cancelButtonText: $t('common.cancel'),
|
||||
type: 'warning'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await updateUserResignedAt(row.id, null);
|
||||
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t('common.updateSuccess'));
|
||||
await reloadUserTable();
|
||||
}
|
||||
|
||||
async function handleResignedAction(row: Api.SystemManage.User) {
|
||||
if (getUserResignedState(row) === 'resigned') {
|
||||
await handleRestoreUser(row);
|
||||
return;
|
||||
}
|
||||
|
||||
openResignedDialog(row);
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
if (!userCheckedRowKeys.value.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await fetchBatchDeleteUser(userCheckedRowKeys.value);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t('common.deleteSuccess'));
|
||||
await reloadUserTable();
|
||||
}
|
||||
|
||||
async function handleToggleStatus(row: Api.SystemManage.User, enabled: boolean) {
|
||||
statusLoadingIds.value = [...statusLoadingIds.value, row.id];
|
||||
|
||||
const { error } = await fetchUpdateUserStatus({
|
||||
id: row.id,
|
||||
status: enabled ? 0 : 1
|
||||
});
|
||||
|
||||
statusLoadingIds.value = statusLoadingIds.value.filter(item => item !== row.id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
row.status = enabled ? 0 : 1;
|
||||
window.$message?.success($t('common.updateSuccess'));
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
await reloadUserTable(1);
|
||||
}
|
||||
|
||||
async function handleResetSearch() {
|
||||
const pageSize = searchParams.pageSize;
|
||||
|
||||
Object.assign(searchParams, getInitSearchParams(), {
|
||||
pageSize,
|
||||
deptId: currentDeptId.value ?? undefined
|
||||
});
|
||||
|
||||
await reloadUserTable(1);
|
||||
}
|
||||
|
||||
async function handleSubmitted() {
|
||||
operateVisible.value = false;
|
||||
await reloadUserTable();
|
||||
}
|
||||
|
||||
async function handleResignedSubmitted() {
|
||||
resignedVisible.value = false;
|
||||
await reloadUserTable();
|
||||
}
|
||||
|
||||
async function handleDeptSubmitted(deptId: number) {
|
||||
orgOperateVisible.value = false;
|
||||
await loadDeptTree();
|
||||
currentDeptId.value = deptId;
|
||||
}
|
||||
|
||||
watch(currentDeptId, async (value, oldValue) => {
|
||||
if (!value || value === oldValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageSize = searchParams.pageSize;
|
||||
|
||||
Object.assign(searchParams, getInitSearchParams(), {
|
||||
pageSize,
|
||||
deptId: value
|
||||
});
|
||||
|
||||
await reloadUserTable(1);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadDeptTree(), loadFormOptions()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-h-560px gap-16px overflow-hidden xl:grid xl:grid-cols-[320px_minmax(0,1fr)] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
|
||||
>
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<UserOrgPanel
|
||||
:loading="deptLoading"
|
||||
:tree-data="deptTree"
|
||||
:current-dept-id="currentDeptId"
|
||||
:total="deptCount"
|
||||
@add-root="openAddRootOrg"
|
||||
@add-child="openAddChildOrg"
|
||||
@leader="openOrgLeader"
|
||||
@edit="openEditOrg"
|
||||
@delete="handleDeleteDeptAction"
|
||||
@refresh="loadDeptTree"
|
||||
@select="handleDeptSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<UserSearch
|
||||
v-model:model="searchParams"
|
||||
:role-options="roleOptions"
|
||||
:disabled="!currentDept"
|
||||
@reset="handleResetSearch"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
|
||||
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="user-table-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex flex-wrap items-center gap-8px">
|
||||
<p>{{ $t('page.system.user.title') }}</p>
|
||||
<ElTag v-if="currentDept" type="primary" effect="light">
|
||||
{{ currentDept.name }}
|
||||
</ElTag>
|
||||
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
|
||||
</div>
|
||||
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadUserTable">
|
||||
<template #default>
|
||||
<ElButton plain type="primary" :disabled="!currentDept" @click="openAdd">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</ElButton>
|
||||
<ElPopconfirm :title="$t('common.confirmDelete')" @confirm="handleBatchDelete">
|
||||
<template #reference>
|
||||
<ElButton type="danger" plain :disabled="userCheckedRowKeys.length === 0">
|
||||
<template #icon>
|
||||
<icon-ic-round-delete class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.batchDelete') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElPopconfirm>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="currentDept">
|
||||
<div class="flex-1">
|
||||
<ElTable
|
||||
ref="userTableRef"
|
||||
v-loading="loading"
|
||||
height="100%"
|
||||
border
|
||||
row-key="id"
|
||||
:data="data"
|
||||
@selection-change="handleUserSelectionChange"
|
||||
>
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||
</ElTable>
|
||||
</div>
|
||||
<div class="mt-20px flex justify-end">
|
||||
<ElPagination
|
||||
v-if="mobilePagination.total"
|
||||
layout="total,prev,pager,next,sizes"
|
||||
v-bind="mobilePagination"
|
||||
@current-change="mobilePagination['current-change']"
|
||||
@size-change="mobilePagination['size-change']"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="h-full flex items-center justify-center">
|
||||
<ElEmpty :description="$t('page.system.user.emptyOrg')" />
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<UserOperateDialog
|
||||
v-model:visible="operateVisible"
|
||||
:operate-type="operateType"
|
||||
:user-id="editingUserId"
|
||||
:current-dept-id="currentDeptId"
|
||||
:dept-tree="deptTree"
|
||||
:post-options="postOptions"
|
||||
:role-options="roleOptions"
|
||||
@submitted="handleSubmitted"
|
||||
/>
|
||||
|
||||
<UserResetPasswordDialog
|
||||
v-model:visible="resetPasswordVisible"
|
||||
:user-id="resetPasswordUserId"
|
||||
:username="resetPasswordUsername"
|
||||
/>
|
||||
|
||||
<UserResignedDialog
|
||||
v-model:visible="resignedVisible"
|
||||
:user-id="resignedUserId"
|
||||
:username="resignedUsername"
|
||||
:resigned-at="resignedAt"
|
||||
@submitted="handleResignedSubmitted"
|
||||
/>
|
||||
|
||||
<UserOrgOperateDialog
|
||||
v-model:visible="orgOperateVisible"
|
||||
:operate-type="orgOperateType"
|
||||
:row-data="editingDeptData"
|
||||
:parent-id="orgParentId"
|
||||
:dept-tree="deptTree"
|
||||
@submitted="handleDeptSubmitted"
|
||||
/>
|
||||
|
||||
<UserOrgLeaderDialog v-model:visible="orgLeaderVisible" :dept="leaderDeptData" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.user-table-card-body) {
|
||||
height: calc(100% - 56px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
363
src/views/system/user/modules/user-operate-dialog.vue
Normal file
363
src/views/system/user/modules/user-operate-dialog.vue
Normal file
@@ -0,0 +1,363 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { userGenderOptions } from '@/constants/business';
|
||||
import {
|
||||
fetchAssignUserRoles,
|
||||
fetchCreateUser,
|
||||
fetchGetUser,
|
||||
fetchGetUserRoleIds,
|
||||
fetchUpdateUser
|
||||
} from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import { translateOptions } from '@/utils/common';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'UserOperateDialog' });
|
||||
|
||||
interface Props {
|
||||
operateType: UI.TableOperateType;
|
||||
userId?: number | null;
|
||||
currentDeptId?: number | null;
|
||||
deptTree: Api.SystemManage.Dept[];
|
||||
postOptions: Api.SystemManage.PostSimple[];
|
||||
roleOptions: Api.SystemManage.RoleSimple[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted', userId?: number): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule, patternRules } = useFormRules();
|
||||
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
|
||||
const title = computed(() => {
|
||||
const titles: Record<UI.TableOperateType, string> = {
|
||||
add: $t('page.system.user.addUser'),
|
||||
edit: $t('page.system.user.editUser')
|
||||
};
|
||||
|
||||
return titles[props.operateType];
|
||||
});
|
||||
|
||||
const isEdit = computed(() => props.operateType === 'edit');
|
||||
|
||||
type Model = Api.SystemManage.SaveUserParams & {
|
||||
roleIds: number[];
|
||||
};
|
||||
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
|
||||
const genderOptions = computed(() =>
|
||||
translateOptions(userGenderOptions).map(item => ({
|
||||
...item,
|
||||
value: Number(item.value) as Api.SystemManage.UserGender
|
||||
}))
|
||||
);
|
||||
|
||||
const deptTreeProps = {
|
||||
value: 'id',
|
||||
label: 'name',
|
||||
children: 'children'
|
||||
} as const;
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
username: '',
|
||||
nickname: '',
|
||||
remark: '',
|
||||
deptId: props.currentDeptId ?? 0,
|
||||
positionId: null,
|
||||
email: '',
|
||||
mobile: '',
|
||||
sex: 1,
|
||||
avatar: '',
|
||||
password: '',
|
||||
roleIds: []
|
||||
};
|
||||
}
|
||||
|
||||
function getNullableText(value?: string | null) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
const rules = computed(() => {
|
||||
const passwordRules = isEdit.value
|
||||
? []
|
||||
: [createRequiredRule($t('page.system.user.form.password')), patternRules.pwd];
|
||||
|
||||
return {
|
||||
username: [createRequiredRule($t('page.system.user.form.userName')), patternRules.userName],
|
||||
deptId: [createRequiredRule($t('page.system.user.form.deptName'))],
|
||||
positionId: [createRequiredRule($t('page.system.user.form.positionName'))],
|
||||
mobile: getNullableText(model.value.mobile) ? [patternRules.phone] : [],
|
||||
email: getNullableText(model.value.email) ? [patternRules.email] : [],
|
||||
password: passwordRules
|
||||
} satisfies Record<string, App.Global.FormRule[]>;
|
||||
});
|
||||
|
||||
async function handleInitModel() {
|
||||
model.value = createDefaultModel();
|
||||
|
||||
if (!isEdit.value || !props.userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
const [userResult, roleResult] = await Promise.all([fetchGetUser(props.userId), fetchGetUserRoleIds(props.userId)]);
|
||||
|
||||
loading.value = false;
|
||||
|
||||
if (userResult.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = userResult.data;
|
||||
|
||||
model.value = {
|
||||
username: user.username,
|
||||
nickname: user.nickname ?? '',
|
||||
remark: user.remark ?? '',
|
||||
deptId: user.deptId,
|
||||
positionId: user.positionId ?? null,
|
||||
email: user.email ?? '',
|
||||
mobile: user.mobile ?? '',
|
||||
sex: user.sex ?? 0,
|
||||
avatar: user.avatar ?? '',
|
||||
password: '',
|
||||
roleIds: roleResult.error ? [] : roleResult.data
|
||||
};
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
|
||||
const payload: Api.SystemManage.SaveUserParams = {
|
||||
username: model.value.username.trim(),
|
||||
nickname: getNullableText(model.value.nickname),
|
||||
remark: getNullableText(model.value.remark),
|
||||
deptId: model.value.deptId,
|
||||
positionId: model.value.positionId,
|
||||
email: getNullableText(model.value.email),
|
||||
mobile: getNullableText(model.value.mobile),
|
||||
sex: model.value.sex,
|
||||
avatar: getNullableText(model.value.avatar)
|
||||
};
|
||||
|
||||
if (!isEdit.value) {
|
||||
payload.password = String(model.value.password ?? '').trim();
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
let userId = props.userId ?? undefined;
|
||||
|
||||
if (isEdit.value && props.userId) {
|
||||
const result = await fetchUpdateUser({ id: props.userId, ...payload });
|
||||
|
||||
if (result.error) {
|
||||
submitting.value = false;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const result = await fetchCreateUser(payload);
|
||||
|
||||
if (result.error) {
|
||||
submitting.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
userId = result.data;
|
||||
}
|
||||
|
||||
if (userId !== undefined) {
|
||||
const roleResult = await fetchAssignUserRoles({
|
||||
userId,
|
||||
roleIds: model.value.roleIds
|
||||
});
|
||||
|
||||
if (roleResult.error) {
|
||||
submitting.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
window.$message?.success(isEdit.value ? $t('common.updateSuccess') : $t('common.addSuccess'));
|
||||
closeDialog();
|
||||
emit('submitted', userId);
|
||||
}
|
||||
|
||||
watch(visible, async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleInitModel();
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="lg"
|
||||
:loading="loading"
|
||||
:confirm-loading="submitting"
|
||||
max-body-height="70vh"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" autocomplete="off">
|
||||
<input class="business-form-autofill-guard" type="text" name="fake-username" autocomplete="username" />
|
||||
<input class="business-form-autofill-guard" type="password" name="fake-password" autocomplete="new-password" />
|
||||
|
||||
<BusinessFormSection :title="$t('page.system.user.sections.basicInfo')">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.userName')" prop="username">
|
||||
<ElInput
|
||||
v-model="model.username"
|
||||
name="system-user-username"
|
||||
autocomplete="off"
|
||||
:placeholder="$t('page.system.user.form.userName')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.nickName')" prop="nickname">
|
||||
<ElInput
|
||||
v-model="model.nickname"
|
||||
name="system-user-nickname"
|
||||
autocomplete="off"
|
||||
:placeholder="$t('page.system.user.form.nickName')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol v-if="!isEdit" :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.password')" prop="password">
|
||||
<ElInput
|
||||
v-model="model.password"
|
||||
name="system-user-password"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
:placeholder="$t('page.system.user.form.password')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.userGender')" prop="sex">
|
||||
<ElSelect v-model="model.sex" :placeholder="$t('page.system.user.form.userGender')">
|
||||
<ElOption v-for="{ label, value } in genderOptions" :key="value" :label="label" :value="value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.userPhone')" prop="mobile">
|
||||
<ElInput
|
||||
v-model="model.mobile"
|
||||
name="system-user-mobile"
|
||||
autocomplete="off"
|
||||
:placeholder="$t('page.system.user.form.userPhone')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.userEmail')" prop="email">
|
||||
<ElInput
|
||||
v-model="model.email"
|
||||
name="system-user-email"
|
||||
autocomplete="off"
|
||||
:placeholder="$t('page.system.user.form.userEmail')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem :label="$t('page.system.user.remark')" prop="remark">
|
||||
<ElInput
|
||||
v-model="model.remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="$t('page.system.user.form.remark')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</BusinessFormSection>
|
||||
|
||||
<BusinessFormSection :title="$t('page.system.user.sections.organizationInfo')">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.deptName')" prop="deptId">
|
||||
<ElTreeSelect
|
||||
v-model="model.deptId"
|
||||
check-strictly
|
||||
clearable
|
||||
default-expand-all
|
||||
:data="deptTree"
|
||||
:props="deptTreeProps"
|
||||
:render-after-expand="false"
|
||||
:placeholder="$t('page.system.user.form.deptName')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.positionName')" prop="positionId">
|
||||
<ElSelect v-model="model.positionId" clearable :placeholder="$t('page.system.user.form.positionName')">
|
||||
<ElOption v-for="item in postOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.userRole')" prop="roleIds">
|
||||
<ElSelect
|
||||
v-model="model.roleIds"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
:placeholder="$t('page.system.user.form.userRole')"
|
||||
>
|
||||
<ElOption v-for="item in roleOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</BusinessFormSection>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.business-form-autofill-guard {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
264
src/views/system/user/modules/user-org-leader-dialog.vue
Normal file
264
src/views/system/user/modules/user-org-leader-dialog.vue
Normal file
@@ -0,0 +1,264 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { ElButton, ElEmpty, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
fetchDeleteOrgLeaderRelation,
|
||||
fetchGetOrgLeaderCandidateUsers,
|
||||
fetchGetOrgLeaderListByDept,
|
||||
fetchGetUserPage
|
||||
} from '@/service/api';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
|
||||
import { $t } from '@/locales';
|
||||
import UserOrgLeaderOperateDialog from './user-org-leader-operate-dialog.vue';
|
||||
|
||||
defineOptions({ name: 'UserOrgLeaderDialog' });
|
||||
|
||||
interface Props {
|
||||
dept?: Api.SystemManage.Dept | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const relations = ref<Api.SystemManage.OrgLeaderRelation[]>([]);
|
||||
const candidateUsers = ref<Api.SystemManage.OrgLeaderCandidateUser[]>([]);
|
||||
const operateVisible = ref(false);
|
||||
const operateType = ref<UI.TableOperateType>('add');
|
||||
const editingData = ref<Api.SystemManage.OrgLeaderRelation | null>(null);
|
||||
|
||||
const title = computed(() => {
|
||||
if (props.dept?.name) {
|
||||
return `${$t('page.system.user.orgLeaderTitle')} / ${props.dept.name}`;
|
||||
}
|
||||
|
||||
return $t('page.system.user.orgLeaderTitle');
|
||||
});
|
||||
|
||||
const total = computed(() => relations.value.length);
|
||||
|
||||
function formatTime(value?: number | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
function isCandidateUser(item: unknown): item is Api.SystemManage.OrgLeaderCandidateUser {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const record = item as Record<string, unknown>;
|
||||
|
||||
return typeof record.id === 'number' && typeof record.nickname === 'string' && typeof record.deptId === 'number';
|
||||
}
|
||||
|
||||
function mapUsersToCandidateUsers(users: Api.SystemManage.User[]): Api.SystemManage.OrgLeaderCandidateUser[] {
|
||||
const now = Date.now();
|
||||
|
||||
return users
|
||||
.filter(item => !item.resignedAt || item.resignedAt > now)
|
||||
.map(item => ({
|
||||
id: item.id,
|
||||
nickname: item.nickname?.trim() || item.username,
|
||||
deptId: item.deptId,
|
||||
deptName: item.deptName ?? null
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadCandidateUsers(deptId: number) {
|
||||
const candidateResult = await fetchGetOrgLeaderCandidateUsers(deptId);
|
||||
|
||||
if (!candidateResult.error && Array.isArray(candidateResult.data) && candidateResult.data.every(isCandidateUser)) {
|
||||
return candidateResult.data;
|
||||
}
|
||||
|
||||
const userResult = await fetchGetUserPage({
|
||||
pageNo: 1,
|
||||
pageSize: 200,
|
||||
deptId,
|
||||
status: 0
|
||||
});
|
||||
|
||||
if (userResult.error) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return mapUsersToCandidateUsers(userResult.data.list);
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!props.dept?.id) {
|
||||
relations.value = [];
|
||||
candidateUsers.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
const [relationResult, candidates] = await Promise.all([
|
||||
fetchGetOrgLeaderListByDept(props.dept.id),
|
||||
loadCandidateUsers(props.dept.id)
|
||||
]);
|
||||
|
||||
loading.value = false;
|
||||
|
||||
relations.value = relationResult.error ? [] : relationResult.data;
|
||||
candidateUsers.value = candidates;
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
operateType.value = 'add';
|
||||
editingData.value = null;
|
||||
operateVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: Api.SystemManage.OrgLeaderRelation) {
|
||||
operateType.value = 'edit';
|
||||
editingData.value = row;
|
||||
operateVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleDelete(row: Api.SystemManage.OrgLeaderRelation) {
|
||||
const { error } = await fetchDeleteOrgLeaderRelation(row.id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t('common.deleteSuccess'));
|
||||
await loadData();
|
||||
}
|
||||
|
||||
async function handleDeleteAction(row: Api.SystemManage.OrgLeaderRelation) {
|
||||
try {
|
||||
await window.$messageBox?.confirm($t('common.confirmDelete'), $t('common.warning'), {
|
||||
confirmButtonText: $t('common.confirm'),
|
||||
cancelButtonText: $t('common.cancel'),
|
||||
type: 'warning'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleDelete(row);
|
||||
}
|
||||
|
||||
async function handleSubmitted() {
|
||||
operateVisible.value = false;
|
||||
await loadData();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [visible.value, props.dept?.id] as const,
|
||||
async ([dialogVisible, deptId]) => {
|
||||
if (!dialogVisible || !deptId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await loadData();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog v-model="visible" :title="title" preset="lg" :show-footer="false">
|
||||
<div class="flex-col-stretch gap-16px">
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-8px">
|
||||
<ElTag type="primary" effect="light">{{ dept?.name || $t('common.noData') }}</ElTag>
|
||||
<ElTag effect="plain">{{ total }}</ElTag>
|
||||
</div>
|
||||
<div class="flex items-center gap-8px">
|
||||
<ElButton type="primary" plain size="small" @click="openAdd">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</ElButton>
|
||||
<ElButton plain size="small" @click="loadData">
|
||||
<template #icon>
|
||||
<icon-mdi-refresh class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.refresh') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="min-h-260px">
|
||||
<template v-if="relations.length">
|
||||
<div class="min-h-260px">
|
||||
<ElTable border :data="relations" :max-height="420">
|
||||
<ElTableColumn type="index" :label="$t('common.index')" width="64" />
|
||||
<ElTableColumn prop="userNickname" :label="$t('page.system.user.orgLeader')" min-width="140" />
|
||||
<ElTableColumn
|
||||
prop="effectiveFrom"
|
||||
:label="$t('page.system.user.effectiveFrom')"
|
||||
min-width="170"
|
||||
:formatter="row => formatTime(row.effectiveFrom)"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="effectiveUntil"
|
||||
:label="$t('page.system.user.effectiveUntil')"
|
||||
min-width="170"
|
||||
:formatter="row => formatTime(row.effectiveUntil)"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="remark"
|
||||
:label="$t('page.system.user.relationRemark')"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="createTime"
|
||||
:label="$t('page.system.user.createTime')"
|
||||
min-width="170"
|
||||
:formatter="row => formatTime(row.createTime)"
|
||||
/>
|
||||
<ElTableColumn :label="$t('common.operate')" width="196" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<BusinessTableActionCell
|
||||
:actions="[
|
||||
{
|
||||
key: 'edit',
|
||||
label: $t('common.edit'),
|
||||
buttonType: 'primary',
|
||||
onClick: () => openEdit(row)
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: $t('common.delete'),
|
||||
buttonType: 'danger',
|
||||
onClick: () => handleDeleteAction(row)
|
||||
}
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="min-h-260px flex items-center justify-center">
|
||||
<ElEmpty :description="$t('page.system.user.emptyLeader')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UserOrgLeaderOperateDialog
|
||||
v-model:visible="operateVisible"
|
||||
:operate-type="operateType"
|
||||
:row-data="editingData"
|
||||
:dept="dept"
|
||||
:candidate-users="candidateUsers"
|
||||
@submitted="handleSubmitted"
|
||||
/>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
224
src/views/system/user/modules/user-org-leader-operate-dialog.vue
Normal file
224
src/views/system/user/modules/user-org-leader-operate-dialog.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { fetchCreateOrgLeaderRelation, fetchUpdateOrgLeaderRelation } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'UserOrgLeaderOperateDialog' });
|
||||
|
||||
interface Props {
|
||||
operateType: UI.TableOperateType;
|
||||
rowData?: Api.SystemManage.OrgLeaderRelation | null;
|
||||
dept?: Api.SystemManage.Dept | null;
|
||||
candidateUsers: Api.SystemManage.OrgLeaderCandidateUser[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const submitting = ref(false);
|
||||
const isEdit = computed(() => props.operateType === 'edit');
|
||||
|
||||
const title = computed(() => {
|
||||
const titleMap: Record<UI.TableOperateType, string> = {
|
||||
add: $t('page.system.user.addLeader'),
|
||||
edit: $t('page.system.user.editLeader')
|
||||
};
|
||||
|
||||
return titleMap[props.operateType];
|
||||
});
|
||||
|
||||
type Model = {
|
||||
userId: number | null;
|
||||
effectiveFrom: Date | null;
|
||||
effectiveUntil: Date | null;
|
||||
remark: string;
|
||||
};
|
||||
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
|
||||
const candidateOptions = computed(() => {
|
||||
const options = [...props.candidateUsers];
|
||||
const rowData = props.rowData;
|
||||
|
||||
if (isEdit.value && rowData && !options.some(item => item.id === rowData.userId)) {
|
||||
options.unshift({
|
||||
id: rowData.userId,
|
||||
nickname: rowData.userNickname,
|
||||
deptId: rowData.deptId,
|
||||
deptName: props.dept?.name ?? null
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
const rules = {
|
||||
userId: createRequiredRule($t('page.system.user.form.candidateUser'))
|
||||
} satisfies Partial<Record<keyof Model, App.Global.FormRule>>;
|
||||
|
||||
function createCurrentTime() {
|
||||
return dayjs().second(0).millisecond(0).toDate();
|
||||
}
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
userId: null,
|
||||
effectiveFrom: createCurrentTime(),
|
||||
effectiveUntil: null,
|
||||
remark: ''
|
||||
};
|
||||
}
|
||||
|
||||
function initModel() {
|
||||
model.value = createDefaultModel();
|
||||
|
||||
if (isEdit.value && props.rowData) {
|
||||
model.value = {
|
||||
userId: props.rowData.userId,
|
||||
effectiveFrom: props.rowData.effectiveFrom ? dayjs(props.rowData.effectiveFrom).toDate() : createCurrentTime(),
|
||||
effectiveUntil: props.rowData.effectiveUntil ? dayjs(props.rowData.effectiveUntil).toDate() : null,
|
||||
remark: props.rowData.remark ?? ''
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!props.dept?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
await validate();
|
||||
|
||||
const effectiveFrom = model.value.effectiveFrom ? dayjs(model.value.effectiveFrom).valueOf() : null;
|
||||
const effectiveUntil = model.value.effectiveUntil ? dayjs(model.value.effectiveUntil).valueOf() : null;
|
||||
|
||||
if (effectiveFrom && effectiveUntil && effectiveFrom > effectiveUntil) {
|
||||
window.$message?.warning($t('common.pleaseCheckValue'));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Api.SystemManage.SaveOrgLeaderRelationParams = {
|
||||
deptId: props.dept.id,
|
||||
userId: Number(model.value.userId),
|
||||
effectiveFrom,
|
||||
effectiveUntil,
|
||||
remark: model.value.remark.trim() || null
|
||||
};
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const request =
|
||||
isEdit.value && props.rowData
|
||||
? fetchUpdateOrgLeaderRelation({ id: props.rowData.id, ...payload })
|
||||
: fetchCreateOrgLeaderRelation(payload);
|
||||
|
||||
const { error } = await request;
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t(isEdit.value ? 'common.updateSuccess' : 'common.addSuccess'));
|
||||
closeDialog();
|
||||
emit('submitted');
|
||||
}
|
||||
|
||||
watch(visible, async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
initModel();
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="md"
|
||||
:confirm-loading="submitting"
|
||||
:scrollbar="false"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<BusinessFormSection :title="$t('page.system.user.sections.basicInfo')">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.deptName')">
|
||||
<ElInput :model-value="dept?.name || $t('common.noData')" disabled />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.candidateUser')" prop="userId">
|
||||
<ElSelect
|
||||
v-model="model.userId"
|
||||
class="w-full"
|
||||
filterable
|
||||
:placeholder="$t('page.system.user.form.candidateUser')"
|
||||
>
|
||||
<ElOption v-for="item in candidateOptions" :key="item.id" :label="item.nickname" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.effectiveFrom')" prop="effectiveFrom">
|
||||
<ElDatePicker
|
||||
v-model="model.effectiveFrom"
|
||||
class="w-full"
|
||||
type="datetime"
|
||||
clearable
|
||||
:placeholder="$t('page.system.user.form.effectiveFrom')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.effectiveUntil')" prop="effectiveUntil">
|
||||
<ElDatePicker
|
||||
v-model="model.effectiveUntil"
|
||||
class="w-full"
|
||||
type="datetime"
|
||||
clearable
|
||||
:placeholder="$t('page.system.user.form.effectiveUntil')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem :label="$t('page.system.user.relationRemark')" prop="remark">
|
||||
<ElInput
|
||||
v-model="model.remark"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="$t('page.system.user.form.relationRemark')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</BusinessFormSection>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
250
src/views/system/user/modules/user-org-operate-dialog.vue
Normal file
250
src/views/system/user/modules/user-org-operate-dialog.vue
Normal file
@@ -0,0 +1,250 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { commonStatusOptions } from '@/constants/business';
|
||||
import { fetchCreateDept, fetchUpdateDept } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'UserOrgOperateDialog' });
|
||||
|
||||
interface Props {
|
||||
operateType: UI.TableOperateType;
|
||||
rowData?: Api.SystemManage.Dept | null;
|
||||
parentId?: number | null;
|
||||
deptTree: Api.SystemManage.Dept[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted', deptId: number): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const submitting = ref(false);
|
||||
|
||||
const isEdit = computed(() => props.operateType === 'edit');
|
||||
|
||||
const title = computed(() => {
|
||||
if (isEdit.value) {
|
||||
return $t('page.system.user.editOrg');
|
||||
}
|
||||
|
||||
return props.parentId && props.parentId > 0 ? $t('page.system.user.addChildOrg') : $t('page.system.user.addOrg');
|
||||
});
|
||||
|
||||
const orgTypeOptions: CommonType.Option<Api.SystemManage.DeptOrgType, App.I18n.I18nKey>[] = [
|
||||
{ value: 'company', label: 'page.system.user.orgType.company' },
|
||||
{ value: 'dept', label: 'page.system.user.orgType.dept' },
|
||||
{ value: 'direction', label: 'page.system.user.orgType.direction' },
|
||||
{ value: 'team', label: 'page.system.user.orgType.team' }
|
||||
];
|
||||
|
||||
type Model = Api.SystemManage.SaveDeptParams;
|
||||
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
|
||||
const treeProps = {
|
||||
value: 'id',
|
||||
label: 'name',
|
||||
children: 'children'
|
||||
} as const;
|
||||
|
||||
const parentTree = computed(() => {
|
||||
const filteredTree = filterDeptTree(props.deptTree, props.rowData?.id ?? null);
|
||||
|
||||
return [
|
||||
{
|
||||
id: 0,
|
||||
name: $t('page.system.user.topLevelOrg'),
|
||||
parentId: -1,
|
||||
orgType: 'company' as const,
|
||||
status: 0,
|
||||
children: filteredTree
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
const expandParentTree = computed(() => !isEdit.value && (props.parentId ?? 0) === 0);
|
||||
|
||||
const rules = {
|
||||
name: createRequiredRule($t('page.system.user.form.orgName')),
|
||||
parentId: createRequiredRule($t('page.system.user.form.parentOrg')),
|
||||
orgType: createRequiredRule($t('page.system.user.form.orgTypeLabel')),
|
||||
sort: createRequiredRule($t('page.system.user.form.orgSort')),
|
||||
status: createRequiredRule($t('page.system.user.form.userStatus'))
|
||||
} satisfies Partial<Record<keyof Model, App.Global.FormRule>>;
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
name: '',
|
||||
parentId: props.parentId ?? 0,
|
||||
orgType: 'dept',
|
||||
code: '',
|
||||
sort: 0,
|
||||
status: 0
|
||||
};
|
||||
}
|
||||
|
||||
function filterDeptTree(tree: Api.SystemManage.Dept[], excludedId: number | null): Api.SystemManage.Dept[] {
|
||||
if (!excludedId) {
|
||||
return tree;
|
||||
}
|
||||
|
||||
return tree
|
||||
.filter(item => item.id !== excludedId)
|
||||
.map(item => ({
|
||||
...item,
|
||||
children: item.children ? filterDeptTree(item.children, excludedId) : item.children
|
||||
}));
|
||||
}
|
||||
|
||||
function initModel() {
|
||||
if (isEdit.value && props.rowData) {
|
||||
model.value = {
|
||||
name: props.rowData.name,
|
||||
parentId: props.rowData.parentId,
|
||||
orgType: props.rowData.orgType,
|
||||
code: props.rowData.code ?? '',
|
||||
sort: props.rowData.sort ?? 0,
|
||||
status: props.rowData.status
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
model.value = createDefaultModel();
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const payload: Api.SystemManage.SaveDeptParams = {
|
||||
name: model.value.name.trim(),
|
||||
parentId: model.value.parentId,
|
||||
orgType: model.value.orgType,
|
||||
code: String(model.value.code ?? '').trim() || null,
|
||||
sort: model.value.sort,
|
||||
status: model.value.status
|
||||
} as Api.SystemManage.SaveDeptParams;
|
||||
|
||||
if (isEdit.value && props.rowData) {
|
||||
const { error } = await fetchUpdateDept({
|
||||
id: props.rowData.id,
|
||||
...payload
|
||||
});
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t('common.updateSuccess'));
|
||||
closeDialog();
|
||||
emit('submitted', props.rowData.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const { error, data } = await fetchCreateDept(payload);
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t('common.addSuccess'));
|
||||
closeDialog();
|
||||
emit('submitted', Number(data));
|
||||
}
|
||||
|
||||
watch(visible, async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
initModel();
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="md"
|
||||
:confirm-loading="submitting"
|
||||
:scrollbar="false"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="24">
|
||||
<ElFormItem :label="$t('page.system.user.orgName')" prop="name">
|
||||
<ElInput v-model="model.name" :placeholder="$t('page.system.user.form.orgName')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem :label="$t('page.system.user.deptName')" prop="parentId">
|
||||
<ElTreeSelect
|
||||
v-model="model.parentId"
|
||||
check-strictly
|
||||
:data="parentTree"
|
||||
:default-expand-all="expandParentTree"
|
||||
:props="treeProps"
|
||||
:render-after-expand="false"
|
||||
:placeholder="$t('page.system.user.form.parentOrg')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.orgTypeLabel')" prop="orgType">
|
||||
<ElSelect v-model="model.orgType" :placeholder="$t('page.system.user.form.orgTypeLabel')">
|
||||
<ElOption v-for="item in orgTypeOptions" :key="item.value" :label="$t(item.label)" :value="item.value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.orgCode')" prop="code">
|
||||
<ElInput v-model="model.code" :placeholder="$t('page.system.user.form.orgCode')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.orgSort')" prop="sort">
|
||||
<ElInputNumber
|
||||
v-model="model.sort"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:placeholder="$t('page.system.user.form.orgSort')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.userStatus')" prop="status">
|
||||
<ElRadioGroup v-model="model.status" class="business-form-radio-group">
|
||||
<ElRadio v-for="item in commonStatusOptions" :key="item.value" :value="item.value">
|
||||
{{ $t(item.label) }}
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
163
src/views/system/user/modules/user-org-panel.vue
Normal file
163
src/views/system/user/modules/user-org-panel.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import { markRaw, ref, watch } from 'vue';
|
||||
import type { TreeInstance } from 'element-plus';
|
||||
import { $t } from '@/locales';
|
||||
import IconMdiAccountGroup from '~icons/mdi/account-group';
|
||||
import IconMdiDomain from '~icons/mdi/domain';
|
||||
import IconMdiOfficeBuilding from '~icons/mdi/office-building';
|
||||
import IconMdiSourceBranch from '~icons/mdi/source-branch';
|
||||
|
||||
defineOptions({ name: 'UserOrgPanel' });
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
treeData: Api.SystemManage.Dept[];
|
||||
currentDeptId?: number | null;
|
||||
total: number;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'refresh'): void;
|
||||
(e: 'select', dept: Api.SystemManage.Dept): void;
|
||||
(e: 'addRoot'): void;
|
||||
(e: 'addChild', dept: Api.SystemManage.Dept): void;
|
||||
(e: 'leader', dept: Api.SystemManage.Dept): void;
|
||||
(e: 'edit', dept: Api.SystemManage.Dept): void;
|
||||
(e: 'delete', dept: Api.SystemManage.Dept): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const keyword = ref('');
|
||||
const treeRef = ref<TreeInstance | null>(null);
|
||||
|
||||
function filterNode(value: string, nodeData: Api.SystemManage.Dept) {
|
||||
if (!value.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return nodeData.name.toLowerCase().includes(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function getOrgIcon(orgType: Api.SystemManage.DeptOrgType) {
|
||||
const iconMap: Record<Api.SystemManage.DeptOrgType, object> = {
|
||||
company: markRaw(IconMdiDomain),
|
||||
dept: markRaw(IconMdiOfficeBuilding),
|
||||
direction: markRaw(IconMdiSourceBranch),
|
||||
team: markRaw(IconMdiAccountGroup)
|
||||
};
|
||||
|
||||
return iconMap[orgType];
|
||||
}
|
||||
|
||||
watch(keyword, value => {
|
||||
treeRef.value?.filter(value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="user-org-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-8px">
|
||||
<p>{{ $t('page.system.user.orgTitle') }}</p>
|
||||
<ElTag effect="plain">{{ total }}</ElTag>
|
||||
</div>
|
||||
<div class="flex items-center gap-8px">
|
||||
<ElButton type="primary" plain size="small" @click="emit('addRoot')">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</ElButton>
|
||||
<ElButton plain size="small" @click="emit('refresh')">
|
||||
<template #icon>
|
||||
<icon-mdi-refresh class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.refresh') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="mb-12px">
|
||||
<ElInput v-model="keyword" clearable :placeholder="$t('page.system.user.orgFilterPlaceholder')">
|
||||
<template #prefix>
|
||||
<icon-ic-round-search class="text-icon" />
|
||||
</template>
|
||||
</ElInput>
|
||||
</div>
|
||||
|
||||
<ElScrollbar v-loading="loading" class="flex-1">
|
||||
<ElTree
|
||||
ref="treeRef"
|
||||
node-key="id"
|
||||
highlight-current
|
||||
:current-node-key="currentDeptId ?? undefined"
|
||||
:data="treeData"
|
||||
default-expand-all
|
||||
:expand-on-click-node="false"
|
||||
:props="{ label: 'name', children: 'children' }"
|
||||
:filter-node-method="filterNode as any"
|
||||
@node-click="emit('select', $event)"
|
||||
>
|
||||
<template #default="{ data: nodeData }">
|
||||
<div class="group min-w-0 flex flex-1 items-center gap-8px pr-8px">
|
||||
<component :is="getOrgIcon(nodeData.orgType)" class="shrink-0 text-16px text-primary" />
|
||||
<span class="min-w-0 flex-1 truncate text-14px">{{ nodeData.name }}</span>
|
||||
<div class="flex items-center opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<ElTooltip :content="$t('page.system.user.orgLeader')">
|
||||
<ElButton link type="primary" class="user-org-action-btn" @click.stop="emit('leader', nodeData)">
|
||||
<icon-mdi-account-tie-outline class="text-14px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElTooltip :content="$t('common.add')">
|
||||
<ElButton link type="primary" class="user-org-action-btn" @click.stop="emit('addChild', nodeData)">
|
||||
<icon-mdi-plus class="text-14px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElTooltip :content="$t('common.edit')">
|
||||
<ElButton link type="primary" class="user-org-action-btn" @click.stop="emit('edit', nodeData)">
|
||||
<icon-mdi-pencil-outline class="text-14px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElPopconfirm :title="$t('common.confirmDelete')" @confirm="emit('delete', nodeData)">
|
||||
<template #reference>
|
||||
<span class="inline-flex" @click.stop>
|
||||
<ElTooltip :content="$t('common.delete')">
|
||||
<ElButton link type="danger" class="user-org-action-btn">
|
||||
<icon-mdi-delete-outline class="text-14px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
</span>
|
||||
</template>
|
||||
</ElPopconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElTree>
|
||||
</ElScrollbar>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.user-org-card-body) {
|
||||
height: calc(100% - 56px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.user-org-action-btn) {
|
||||
padding: 2px;
|
||||
min-width: auto;
|
||||
height: auto;
|
||||
margin-left: 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
:deep(.user-org-action-btn:first-child) {
|
||||
margin-left: 0;
|
||||
}
|
||||
</style>
|
||||
127
src/views/system/user/modules/user-reset-password-dialog.vue
Normal file
127
src/views/system/user/modules/user-reset-password-dialog.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { REG_PWD } from '@/constants/reg';
|
||||
import { fetchUpdateUserPassword } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'UserResetPasswordDialog' });
|
||||
|
||||
interface Props {
|
||||
userId?: number | null;
|
||||
username?: string | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule, createConfirmPwdRule } = useFormRules();
|
||||
|
||||
const submitting = ref(false);
|
||||
|
||||
const title = computed(() => $t('page.system.user.resetPassword'));
|
||||
const displayUsername = computed(() => props.username?.trim() || $t('common.noData'));
|
||||
|
||||
const model = ref({
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
|
||||
const rules = computed<Record<'password' | 'confirmPassword', App.Global.FormRule[]>>(() => ({
|
||||
password: [
|
||||
createRequiredRule($t('page.system.user.form.newPassword')),
|
||||
{ pattern: REG_PWD, message: $t('form.pwd.invalid') }
|
||||
],
|
||||
confirmPassword: createConfirmPwdRule(model.value.password)
|
||||
}));
|
||||
|
||||
function initModel() {
|
||||
model.value.password = '';
|
||||
model.value.confirmPassword = '';
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!props.userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await validate();
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const { error } = await fetchUpdateUserPassword({
|
||||
id: props.userId,
|
||||
password: model.value.password.trim()
|
||||
});
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t('common.updateSuccess'));
|
||||
closeDialog();
|
||||
emit('submitted');
|
||||
}
|
||||
|
||||
watch(visible, async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
initModel();
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="sm"
|
||||
:confirm-loading="submitting"
|
||||
:scrollbar="false"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="24">
|
||||
<ElFormItem :label="$t('page.system.user.userName')">
|
||||
<ElInput :model-value="displayUsername" disabled />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem :label="$t('page.system.user.newPassword')" prop="password">
|
||||
<ElInput v-model="model.password" show-password :placeholder="$t('page.system.user.form.newPassword')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem :label="$t('page.system.user.confirmPassword')" prop="confirmPassword">
|
||||
<ElInput
|
||||
v-model="model.confirmPassword"
|
||||
show-password
|
||||
:placeholder="$t('page.system.user.form.confirmPassword')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
140
src/views/system/user/modules/user-resigned-dialog.vue
Normal file
140
src/views/system/user/modules/user-resigned-dialog.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { fetchGetUser, fetchUpdateUser } from '@/service/api';
|
||||
import { useForm } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'UserResignedDialog' });
|
||||
|
||||
interface Props {
|
||||
userId?: number | null;
|
||||
username?: string | null;
|
||||
resignedAt?: number | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef } = useForm();
|
||||
|
||||
const submitting = ref(false);
|
||||
|
||||
const title = computed(() => {
|
||||
if (props.resignedAt && props.resignedAt > Date.now()) {
|
||||
return $t('page.system.user.adjustResignUser');
|
||||
}
|
||||
|
||||
return $t('page.system.user.resignUser');
|
||||
});
|
||||
const displayUsername = computed(() => props.username?.trim() || $t('common.noData'));
|
||||
|
||||
const model = ref({
|
||||
resignedAt: null as Date | null
|
||||
});
|
||||
|
||||
function createCurrentTime() {
|
||||
return dayjs().second(0).millisecond(0).toDate();
|
||||
}
|
||||
|
||||
function initModel() {
|
||||
model.value.resignedAt = props.resignedAt ? dayjs(props.resignedAt).toDate() : createCurrentTime();
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!props.userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const detailResult = await fetchGetUser(props.userId);
|
||||
|
||||
if (detailResult.error) {
|
||||
submitting.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const user = detailResult.data;
|
||||
|
||||
const { error } = await fetchUpdateUser({
|
||||
id: props.userId,
|
||||
username: user.username,
|
||||
nickname: user.nickname ?? null,
|
||||
remark: user.remark ?? null,
|
||||
deptId: user.deptId,
|
||||
positionId: user.positionId ?? null,
|
||||
resignedAt: model.value.resignedAt ? dayjs(model.value.resignedAt).valueOf() : null,
|
||||
email: user.email ?? null,
|
||||
mobile: user.mobile ?? null,
|
||||
sex: user.sex ?? 0,
|
||||
avatar: user.avatar ?? null
|
||||
});
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t('common.updateSuccess'));
|
||||
closeDialog();
|
||||
emit('submitted');
|
||||
}
|
||||
|
||||
watch(visible, async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
initModel();
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="sm"
|
||||
:confirm-loading="submitting"
|
||||
:scrollbar="false"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" label-position="top">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.userName')">
|
||||
<ElInput :model-value="displayUsername" disabled />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.resignedAt')">
|
||||
<ElDatePicker
|
||||
v-model="model.resignedAt"
|
||||
class="w-full"
|
||||
type="datetime"
|
||||
clearable
|
||||
:placeholder="$t('page.system.user.form.resignedAt')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
91
src/views/system/user/modules/user-search.vue
Normal file
91
src/views/system/user/modules/user-search.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { commonStatusOptions } from '@/constants/business';
|
||||
import { translateOptions } from '@/utils/common';
|
||||
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'UserSearch' });
|
||||
|
||||
interface Props {
|
||||
roleOptions: Api.SystemManage.RoleSimple[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
disabled: false
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
(e: 'reset'): void;
|
||||
(e: 'search'): void;
|
||||
}
|
||||
|
||||
defineEmits<Emits>();
|
||||
|
||||
const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchPanel
|
||||
:model="model"
|
||||
:disabled="disabled"
|
||||
:action-col-lg="8"
|
||||
:action-col-md="8"
|
||||
@reset="$emit('reset')"
|
||||
@search="$emit('search')"
|
||||
>
|
||||
<ElCol :lg="8" :md="8" :sm="12">
|
||||
<ElFormItem :label="$t('page.system.user.userName')" prop="username">
|
||||
<ElInput
|
||||
v-model="model.username"
|
||||
clearable
|
||||
:disabled="disabled"
|
||||
:placeholder="$t('page.system.user.form.userName')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :lg="8" :md="8" :sm="12">
|
||||
<ElFormItem :label="$t('page.system.user.userPhone')" prop="mobile">
|
||||
<ElInput
|
||||
v-model="model.mobile"
|
||||
clearable
|
||||
:disabled="disabled"
|
||||
:placeholder="$t('page.system.user.form.userPhone')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :lg="8" :md="8" :sm="12">
|
||||
<ElFormItem :label="$t('page.system.user.userStatus')" prop="status">
|
||||
<ElSelect
|
||||
v-model="model.status"
|
||||
clearable
|
||||
:disabled="disabled"
|
||||
:placeholder="$t('page.system.user.form.userStatus')"
|
||||
>
|
||||
<ElOption
|
||||
v-for="{ label, value } in translateOptions(commonStatusOptions)"
|
||||
:key="value"
|
||||
:label="label"
|
||||
:value="value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
|
||||
<template #extra>
|
||||
<ElCol :lg="8" :md="8" :sm="12">
|
||||
<ElFormItem :label="$t('page.system.user.userRole')" prop="roleId">
|
||||
<ElSelect
|
||||
v-model="model.roleId"
|
||||
clearable
|
||||
filterable
|
||||
:disabled="disabled"
|
||||
:placeholder="$t('page.system.user.form.userRole')"
|
||||
>
|
||||
<ElOption v-for="item in roleOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</template>
|
||||
</TableSearchPanel>
|
||||
</template>
|
||||
Reference in New Issue
Block a user