Merge remote-tracking branch 'origin/6.X-Vue' into dev

This commit is contained in:
yangwj 2026-07-02 10:38:04 +08:00
commit 6bc5583109
21 changed files with 1621 additions and 903 deletions

View File

@ -1,5 +1,7 @@
import type { ${BusinessName}Form, ${BusinessName}Query, ${BusinessName}VO } from '@/api/${moduleName}/${businessName}/types'; import type { ${BusinessName}Form, ${BusinessName}Query, ${BusinessName}VO } from '@/api/${moduleName}/${businessName}/types';
<#if !table.tree>
import type { PageResult } from '@/api/types'; import type { PageResult } from '@/api/types';
</#if>
import type { AxiosPromise } from '@/utils/api-types'; import type { AxiosPromise } from '@/utils/api-types';
import request from '@/utils/request'; import request from '@/utils/request';
@ -9,7 +11,7 @@ import request from '@/utils/request';
* @returns {*} * @returns {*}
*/ */
export const list${BusinessName} = (query?: ${BusinessName}Query): AxiosPromise<PageResult<${BusinessName}VO>> => { export const list${BusinessName} = (query?: ${BusinessName}Query): AxiosPromise<<#if table.tree>${BusinessName}VO[]<#else>PageResult<${BusinessName}VO></#if>> => {
return request({ return request({
url: '/${moduleName}/${businessName}/list', url: '/${moduleName}/${businessName}/list',
method: 'get', method: 'get',
@ -100,4 +102,3 @@ export const del${BusinessName} = (${pkColumn.javaField}: string | number | Arra
}; };

View File

@ -45,7 +45,7 @@
</#if> </#if>
</el-select> </el-select>
</el-form-item> </el-form-item>
<#elseif (column.htmlType == "select" || column.htmlType == "radio") && column.dictType?has_content> <#elseif (column.htmlType == "select" || column.htmlType == "radio") && !(column.dictType?has_content)>
<el-form-item label="${column.columnLabel}" prop="${column.javaField}"> <el-form-item label="${column.columnLabel}" prop="${column.javaField}">
<el-select v-model="queryParams.${column.javaField}" placeholder="请选择${column.columnLabel}" clearable> <el-select v-model="queryParams.${column.javaField}" placeholder="请选择${column.columnLabel}" clearable>
<el-option label="请选择字典生成" value="" /> <el-option label="请选择字典生成" value="" />
@ -312,7 +312,7 @@
></el-option> ></el-option>
</el-select> </el-select>
</el-form-item> </el-form-item>
<#elseif column.htmlType == "select" && column.dictType?has_content> <#elseif column.htmlType == "select" && !(column.dictType?has_content)>
<el-form-item label="${column.columnLabel}" prop="${column.javaField}"> <el-form-item label="${column.columnLabel}" prop="${column.javaField}">
<el-select v-model="form.${column.javaField}" placeholder="请选择${column.columnLabel}"> <el-select v-model="form.${column.javaField}" placeholder="请选择${column.columnLabel}">
<el-option label="请选择字典生成" value="" /> <el-option label="请选择字典生成" value="" />
@ -329,7 +329,7 @@
</el-checkbox> </el-checkbox>
</el-checkbox-group> </el-checkbox-group>
</el-form-item> </el-form-item>
<#elseif column.htmlType == "checkbox" && column.dictType?has_content> <#elseif column.htmlType == "checkbox" && !(column.dictType?has_content)>
<el-form-item label="${column.columnLabel}" prop="${column.javaField}"> <el-form-item label="${column.columnLabel}" prop="${column.javaField}">
<el-checkbox-group v-model="form.${column.javaField}"> <el-checkbox-group v-model="form.${column.javaField}">
<el-checkbox>请选择字典生成</el-checkbox> <el-checkbox>请选择字典生成</el-checkbox>
@ -349,7 +349,7 @@
>{{dict.label}}</el-radio> >{{dict.label}}</el-radio>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<#elseif column.htmlType == "radio" && column.dictType?has_content> <#elseif column.htmlType == "radio" && !(column.dictType?has_content)>
<el-form-item label="${column.columnLabel}" prop="${column.javaField}"> <el-form-item label="${column.columnLabel}" prop="${column.javaField}">
<el-radio-group v-model="form.${column.javaField}"> <el-radio-group v-model="form.${column.javaField}">
<el-radio value="1">请选择字典生成</el-radio> <el-radio value="1">请选择字典生成</el-radio>
@ -689,4 +689,3 @@ onMounted(() => {
getList(); getList();
}); });
</script> </script>

View File

@ -45,7 +45,7 @@
</#if> </#if>
</el-select> </el-select>
</el-form-item> </el-form-item>
<#elseif (column.htmlType == "select" || column.htmlType == "radio") && column.dictType?has_content> <#elseif (column.htmlType == "select" || column.htmlType == "radio") && !(column.dictType?has_content)>
<el-form-item label="${column.columnLabel}" prop="${column.javaField}"> <el-form-item label="${column.columnLabel}" prop="${column.javaField}">
<el-select v-model="queryParams.${column.javaField}" placeholder="请选择${column.columnLabel}" clearable > <el-select v-model="queryParams.${column.javaField}" placeholder="请选择${column.columnLabel}" clearable >
<el-option label="请选择字典生成" value="" /> <el-option label="请选择字典生成" value="" />
@ -60,7 +60,7 @@
placeholder="请选择${column.columnLabel}" placeholder="请选择${column.columnLabel}"
/> />
</el-form-item> </el-form-item>
<#elseif column.htmlType == "datetime" && column.queryType == "BETWEEN"> <#elseif column.htmlType == "datetime" && column.queryType == "BETWEEN" && column.query>
<el-form-item label="${column.columnLabel}" style="width: 308px"> <el-form-item label="${column.columnLabel}" style="width: 308px">
<el-date-picker <el-date-picker
v-model="dateRange${column.capJavaField}" v-model="dateRange${column.capJavaField}"
@ -233,7 +233,8 @@
<el-dialog :title="dialog.title" v-model="dialog.visible" width="500px" append-to-body> <el-dialog :title="dialog.title" v-model="dialog.visible" width="500px" append-to-body>
<el-form ref="${businessName}FormRef" :model="form" :rules="rules" label-width="80px"> <el-form ref="${businessName}FormRef" :model="form" :rules="rules" label-width="80px">
<#list columns as column> <#list columns as column>
<#if (column.insert || column.edit) && !column.pk>`n<#if column.htmlType == "input"> <#if (column.insert || column.edit) && !column.pk>
<#if column.htmlType == "input">
<el-form-item label="${column.columnLabel}" prop="${column.javaField}"> <el-form-item label="${column.columnLabel}" prop="${column.javaField}">
<el-input v-model="form.${column.javaField}" placeholder="请输入${column.columnLabel}" /> <el-input v-model="form.${column.javaField}" placeholder="请输入${column.columnLabel}" />
</el-form-item> </el-form-item>
@ -268,7 +269,7 @@
></el-option> ></el-option>
</el-select> </el-select>
</el-form-item> </el-form-item>
<#elseif column.htmlType == "select" && column.dictType?has_content> <#elseif column.htmlType == "select" && !(column.dictType?has_content)>
<el-form-item label="${column.columnLabel}" prop="${column.javaField}"> <el-form-item label="${column.columnLabel}" prop="${column.javaField}">
<el-select v-model="form.${column.javaField}" placeholder="请选择${column.columnLabel}"> <el-select v-model="form.${column.javaField}" placeholder="请选择${column.columnLabel}">
<el-option label="请选择字典生成" value="" /> <el-option label="请选择字典生成" value="" />
@ -285,7 +286,7 @@
</el-checkbox> </el-checkbox>
</el-checkbox-group> </el-checkbox-group>
</el-form-item> </el-form-item>
<#elseif column.htmlType == "checkbox" && column.dictType?has_content> <#elseif column.htmlType == "checkbox" && !(column.dictType?has_content)>
<el-form-item label="${column.columnLabel}" prop="${column.javaField}"> <el-form-item label="${column.columnLabel}" prop="${column.javaField}">
<el-checkbox-group v-model="form.${column.javaField}"> <el-checkbox-group v-model="form.${column.javaField}">
<el-checkbox>请选择字典生成</el-checkbox> <el-checkbox>请选择字典生成</el-checkbox>
@ -305,7 +306,7 @@
>{{dict.label}}</el-radio> >{{dict.label}}</el-radio>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<#elseif column.htmlType == "radio" && column.dictType?has_content> <#elseif column.htmlType == "radio" && !(column.dictType?has_content)>
<el-form-item label="${column.columnLabel}" prop="${column.javaField}"> <el-form-item label="${column.columnLabel}" prop="${column.javaField}">
<el-radio-group v-model="form.${column.javaField}"> <el-radio-group v-model="form.${column.javaField}">
<el-radio value="1">请选择字典生成</el-radio> <el-radio value="1">请选择字典生成</el-radio>
@ -380,6 +381,9 @@ import { useTableSelection } from '@/hooks/table/useTableSelection';
<#if needDict> <#if needDict>
import { useDict } from '@/utils/dict'; import { useDict } from '@/utils/dict';
</#if> </#if>
<#if needParseTime>
import { parseTime } from '@/utils/ruoyi';
</#if>
import modal from '@/plugins/modal'; import modal from '@/plugins/modal';
<#if enableExport> <#if enableExport>
import { download as requestDownload } from '@/utils/request'; import { download as requestDownload } from '@/utils/request';
@ -400,7 +404,7 @@ const { loading, withLoading } = useLoading(true);
const { showSearch } = useSearchToggle(); const { showSearch } = useSearchToggle();
const total = ref(0); const total = ref(0);
<#list columns as column> <#list columns as column>
<#if column.htmlType == "datetime" && column.queryType == "BETWEEN"> <#if column.htmlType == "datetime" && column.queryType == "BETWEEN" && column.query>
const { const {
dateRange: dateRange${column.capJavaField}, dateRange: dateRange${column.capJavaField},
applyDateRange: apply${column.capJavaField}DateRange, applyDateRange: apply${column.capJavaField}DateRange,
@ -472,7 +476,7 @@ const getList = async () => {
<#if needAddDateRange> <#if needAddDateRange>
let params = queryParams.value; let params = queryParams.value;
<#list columns as column> <#list columns as column>
<#if column.htmlType == "datetime" && column.queryType == "BETWEEN"> <#if column.htmlType == "datetime" && column.queryType == "BETWEEN" && column.query>
params = apply${column.capJavaField}DateRange(params); params = apply${column.capJavaField}DateRange(params);
</#if> </#if>
</#list> </#list>
@ -505,7 +509,7 @@ const { resetQuery } = useSearchReset({
initialPageSize: 10, initialPageSize: 10,
resetExtras: () => { resetExtras: () => {
<#list columns as column> <#list columns as column>
<#if column.htmlType == "datetime" && column.queryType == "BETWEEN"> <#if column.htmlType == "datetime" && column.queryType == "BETWEEN" && column.query>
reset${column.capJavaField}DateRange(); reset${column.capJavaField}DateRange();
</#if> </#if>
</#list> </#list>
@ -565,8 +569,8 @@ const handleDelete = async (row?: Partial<${BusinessName}VO>) => {
await getList(); await getList();
}; };
/** 导出按钮操作 */
<#if enableExport> <#if enableExport>
/** 导出按钮操作 */
const handleExport = () => { const handleExport = () => {
requestDownload( requestDownload(
'${moduleName}/${businessName}/export', '${moduleName}/${businessName}/export',

View File

@ -9,7 +9,7 @@
<title>%VITE_APP_TITLE%</title> <title>%VITE_APP_TITLE%</title>
<!--[if lt IE 11]> <!--[if lt IE 11]>
<script> <script>
window.location.href = '/html/ie.html'; window.location.href = '/ie.html';
</script> </script>
<![endif]--> <![endif]-->
<style> <style>

View File

@ -11,6 +11,7 @@
}, },
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "vite build --mode production",
"build:dev": "vite build --mode development", "build:dev": "vite build --mode development",
"build:prod": "vite build --mode production", "build:prod": "vite build --mode production",
"dev": "vite serve --mode development", "dev": "vite serve --mode development",
@ -24,48 +25,49 @@
"@highlightjs/vue-plugin": "2.1.2", "@highlightjs/vue-plugin": "2.1.2",
"@iconify/vue": "^5.0.1", "@iconify/vue": "^5.0.1",
"@vueuse/core": "14.3.0", "@vueuse/core": "14.3.0",
"@wangeditor-next/editor": "5.7.10", "@wangeditor-next/editor": "5.7.13",
"@wangeditor-next/editor-for-vue": "5.1.14", "@wangeditor-next/editor-for-vue": "5.1.14",
"animate.css": "4.1.1", "animate.css": "4.1.1",
"await-to-js": "3.0.0", "await-to-js": "3.0.0",
"axios": "1.17.0", "axios": "1.18.1",
"crypto-js": "4.2.0", "crypto-js": "4.2.0",
"echarts": "6.1.0", "echarts": "6.1.0",
"element-plus": "2.14.1", "element-plus": "2.14.2",
"highlight.js": "11.11.1", "highlight.js": "11.11.1",
"image-conversion": "2.1.1", "image-conversion": "2.1.1",
"jsencrypt": "3.5.4", "jsencrypt": "3.5.4",
"nprogress": "0.2.0", "nprogress": "0.2.0",
"pinia": "3.0.4", "pinia": "3.0.4",
"vue": "3.5.35", "vue": "3.5.39",
"vue-cropper": "1.1.4", "vue-cropper": "1.1.4",
"vue-i18n": "11.4.5", "vue-i18n": "11.4.6",
"vue-json-pretty": "2.6.0", "vue-json-pretty": "2.6.0",
"vue-router": "5.1.0", "vue-router": "5.1.0",
"vue-types": "7.0.0", "vue-types": "7.0.0",
"vxe-table": "4.19.10" "vxe-table": "4.19.21"
}, },
"devDependencies": { "devDependencies": {
"@types/crypto-js": "4.2.2", "@types/crypto-js": "4.2.2",
"@types/node": "^25.9.2", "@types/node": "^26.0.1",
"@types/nprogress": "0.2.3", "@types/nprogress": "0.2.3",
"@unocss/preset-attributify": "66.7.0", "@unocss/preset-attributify": "66.7.3",
"@unocss/preset-wind3": "66.7.0", "@unocss/preset-wind3": "66.7.3",
"@vitejs/plugin-vue": "^6.0.7", "@vitejs/plugin-vue": "^6.0.7",
"@vue/compiler-sfc": "3.5.35", "@vue/compiler-sfc": "3.5.39",
"autoprefixer": "10.5.0", "autoprefixer": "10.5.2",
"oxfmt": "^0.53.0", "oxfmt": "^0.56.0",
"oxlint": "^1.68.0", "oxlint": "^1.71.0",
"sass": "1.100.0", "oxlint-tsgolint": "^0.23.0",
"sass": "1.101.0",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"unocss": "66.7.0", "unocss": "66.7.3",
"unplugin-auto-import": "21.0.0", "unplugin-auto-import": "21.0.0",
"unplugin-vue-components": "32.1.0", "unplugin-vue-components": "32.1.0",
"unplugin-vue-setup-extend-plus": "1.0.1", "unplugin-vue-setup-extend-plus": "1.0.1",
"vite": "^8.0.16", "vite": "^8.1.0",
"vite-plugin-svg-icons-ng": "^1.9.1", "vite-plugin-svg-icons-ng": "^1.9.1",
"vitest": "4.1.8", "vitest": "4.1.9",
"vue-tsc": "^3.3.4" "vue-tsc": "^3.3.5"
}, },
"browserslist": [ "browserslist": [
"Chrome >= 87", "Chrome >= 87",

1804
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -19,33 +19,38 @@
</template> </template>
<template v-if="!modelCollapsed"> <template v-if="!modelCollapsed">
<el-input v-model="filterText" :placeholder="placeholder" prefix-icon="Search" clearable /> <el-input v-model="filterText" :placeholder="placeholder" prefix-icon="Search" clearable />
<el-tree <div ref="treeWrapRef" class="dept-tree-wrap">
ref="treeRef" <el-tree-v2
class="mt-2 dept-tree" ref="treeRef"
:node-key="nodeKey" class="dept-tree"
:data="data" :data="treeData"
:props="mergedTreeProps as any" :props="mergedTreeProps as any"
:expand-on-click-node="false" :height="treeHeight"
:filter-node-method="internalFilterNode" :item-size="28"
highlight-current :expand-on-click-node="false"
default-expand-all :default-expanded-keys="defaultExpandedKeys"
@node-click="onNodeClick" :filter-method="internalFilterNode"
> highlight-current
<template #default="{ data: nodeData }"> @node-click="onNodeClick"
<span class="tree-node-label" :class="{ 'is-disabled': isNodeDisabled(nodeData) }"> >
<span>{{ getNodeLabel(nodeData) }}</span> <template #default="{ node }">
<el-tooltip v-if="isNodeDisabled(nodeData)" content="停用" placement="top"> <span class="tree-node-label" :class="{ 'is-disabled': isNodeDisabled(node.data) }">
<el-icon class="tree-node-disabled-icon"><CircleCloseFilled /></el-icon> <span>{{ node.label }}</span>
</el-tooltip> <el-tooltip v-if="isNodeDisabled(node.data)" content="停用" placement="top">
</span> <el-icon class="tree-node-disabled-icon"><CircleCloseFilled /></el-icon>
</template> </el-tooltip>
</el-tree> </span>
</template>
</el-tree-v2>
</div>
</template> </template>
</el-card> </el-card>
</el-col> </el-col>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TreeKey, TreeV2Instance } from 'element-plus';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
/** 面板标题 */ /** 面板标题 */
@ -91,23 +96,34 @@ const modelCollapsed = computed({
}); });
const filterText = ref(''); const filterText = ref('');
const treeRef = ref<ElTreeInstance>(); const treeRef = ref<TreeV2Instance>();
const treeWrapRef = ref<HTMLElement>();
const treeData = shallowRef<any[]>([]);
const defaultExpandedKeys = shallowRef<TreeKey[]>([]);
const { height: treeWrapHeight } = useElementSize(treeWrapRef);
const treeHeight = computed(() => Math.max(Math.floor(treeWrapHeight.value), 180));
const mergedTreeProps = computed(() => { const mergedTreeProps = computed(() => {
const label = props.treeProps?.label ?? 'label';
const children = props.treeProps?.children ?? 'children';
const value = props.treeProps?.value ?? props.nodeKey;
if (!props.disabledField) { if (!props.disabledField) {
return props.treeProps; return {
...props.treeProps,
value,
label,
children
};
} }
return { return {
...props.treeProps, ...props.treeProps,
value,
label,
children,
disabled: props.treeProps?.disabled ?? props.disabledField disabled: props.treeProps?.disabled ?? props.disabledField
}; };
}); });
const getNodeLabel = (data: any) => {
const labelField = props.treeProps?.label ?? 'label';
return data?.[labelField] ?? '';
};
const isNodeDisabled = (data: any) => { const isNodeDisabled = (data: any) => {
if (!props.disabledField) { if (!props.disabledField) {
return false; return false;
@ -119,6 +135,23 @@ const toggleCollapsed = () => {
modelCollapsed.value = !modelCollapsed.value; modelCollapsed.value = !modelCollapsed.value;
}; };
const getNodeKey = (data: any) => data?.[props.nodeKey] as TreeKey | undefined;
const collectExpandedKeys = (nodes: any[], keys: TreeKey[] = []) => {
const childrenField = props.treeProps?.children ?? 'children';
for (const node of nodes) {
const children = node?.[childrenField];
if (Array.isArray(children) && children.length > 0) {
const key = getNodeKey(node);
if (key !== undefined) {
keys.push(key);
}
collectExpandedKeys(children, keys);
}
}
return keys;
};
const internalFilterNode = (value: string, data: any) => { const internalFilterNode = (value: string, data: any) => {
if (props.filterNodeMethod) { if (props.filterNodeMethod) {
return props.filterNodeMethod(value, data); return props.filterNodeMethod(value, data);
@ -127,16 +160,32 @@ const internalFilterNode = (value: string, data: any) => {
return data[props.filterField]?.indexOf(value) !== -1; return data[props.filterField]?.indexOf(value) !== -1;
}; };
watchEffect( const filterTree = useDebounceFn(() => {
() => { treeRef.value?.filter(filterText.value);
treeRef.value?.filter(filterText.value); }, 200);
watch(filterText, () => filterTree());
watch(
() => props.data,
(data) => {
const rawData = toRaw(data);
treeData.value = rawData;
defaultExpandedKeys.value = collectExpandedKeys(rawData);
nextTick(() => {
treeRef.value?.setData(rawData);
treeRef.value?.setExpandedKeys(defaultExpandedKeys.value);
if (filterText.value) {
filterTree();
}
});
}, },
{ flush: 'post' } { immediate: true }
); );
const onNodeClick = (data: any, node: any, component: any) => { const onNodeClick = (data: any, node: any, component: any) => {
if (isNodeDisabled(data)) { if (isNodeDisabled(data)) {
treeRef.value?.setCurrentKey(undefined); treeRef.value?.setCurrentKey(undefined as any);
return; return;
} }
emit('node-click', data, node, component); emit('node-click', data, node, component);
@ -224,14 +273,17 @@ $mobile-breakpoint: 900px;
transform: rotate(-45deg); transform: rotate(-45deg);
} }
.dept-tree { .dept-tree-wrap {
padding-top: 6px; margin-top: 8px;
flex: 1 1 auto; flex: 1 1 auto;
min-height: 180px; min-height: 180px;
max-height: 100%; max-height: 100%;
min-width: 0;
}
.dept-tree {
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
padding-right: 4px;
scrollbar-width: thin; scrollbar-width: thin;
} }

View File

@ -0,0 +1,298 @@
import { useRoute } from 'vue-router';
import { useSettingsStore } from '@/store/modules/settings';
interface VueBackedHTMLElement extends HTMLElement {
[key: string]: unknown;
}
interface TableComponentHost {
proxy?: {
doLayout?: () => void;
};
}
interface TableState {
height: string;
maxHeight: string;
bodyHeight: string;
bodyMaxHeight: string;
bodyOverflowY: string;
bodyOverflowX: string;
}
const tableStates = new WeakMap<HTMLElement, TableState>();
const vueParentComponentKey = '__vueParentComponent';
const getTableComponentHost = (table: HTMLElement): TableComponentHost | undefined => {
const host = (table as VueBackedHTMLElement)[vueParentComponentKey];
if (!host || typeof host !== 'object') return undefined;
return host as TableComponentHost;
};
const getOuterHeight = (element: HTMLElement) => {
const style = window.getComputedStyle(element);
const marginTop = Number.parseFloat(style.marginTop) || 0;
const marginBottom = Number.parseFloat(style.marginBottom) || 0;
return element.getBoundingClientRect().height + marginTop + marginBottom;
};
const isVisible = (element: HTMLElement) => {
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const getFollowingHeight = (table: HTMLElement) => {
const parent = table.parentElement;
if (!parent) return 0;
const children = Array.from(parent.children) as HTMLElement[];
const tableIndex = children.indexOf(table);
if (tableIndex === -1) return 0;
return children.slice(tableIndex + 1).reduce((height, child) => {
if (!isVisible(child)) return height;
return height + getOuterHeight(child);
}, 0);
};
const getBottomPadding = (table: HTMLElement) => {
const parent = table.parentElement;
if (!parent) return 0;
const parentStyle = window.getComputedStyle(parent);
return Number.parseFloat(parentStyle.paddingBottom) || 0;
};
const getBodyWrapper = (table: HTMLElement) => {
return table.querySelector<HTMLElement>('.el-table__body-wrapper');
};
const getTableComponent = (table: HTMLElement) => {
return getTableComponentHost(table)?.proxy;
};
const getPageOverflowHeight = () => {
const documentElement = document.documentElement;
return Math.max(0, documentElement.scrollHeight - documentElement.clientHeight);
};
const rememberTableState = (table: HTMLElement) => {
if (tableStates.has(table)) return;
const bodyWrapper = getBodyWrapper(table);
tableStates.set(table, {
height: table.style.height,
maxHeight: table.style.maxHeight,
bodyHeight: bodyWrapper?.style.height ?? '',
bodyMaxHeight: bodyWrapper?.style.maxHeight ?? '',
bodyOverflowY: bodyWrapper?.style.overflowY ?? '',
bodyOverflowX: bodyWrapper?.style.overflowX ?? ''
});
};
const resetTable = (table: HTMLElement) => {
const originalState = tableStates.get(table);
if (!originalState) return;
const bodyWrapper = getBodyWrapper(table);
table.classList.remove('is-full-height-table');
table.style.height = originalState.height;
table.style.maxHeight = originalState.maxHeight;
if (bodyWrapper) {
bodyWrapper.style.height = originalState.bodyHeight;
bodyWrapper.style.maxHeight = originalState.bodyMaxHeight;
bodyWrapper.style.overflowY = originalState.bodyOverflowY;
bodyWrapper.style.overflowX = originalState.bodyOverflowX;
}
tableStates.delete(table);
getTableComponent(table)?.doLayout?.();
};
const resetAllTables = () => {
document.querySelectorAll<HTMLElement>('.is-full-height-table').forEach(resetTable);
};
const calculateTableHeight = (table: HTMLElement) => {
const rect = table.getBoundingClientRect();
const viewportBottom = window.innerHeight;
const followingHeight = getFollowingHeight(table);
const bottomPadding = getBottomPadding(table);
const bottomOffset = window.innerWidth < 768 ? 28 : 32;
const minHeight = window.innerWidth < 768 ? 220 : 260;
return Math.max(minHeight, Math.floor(viewportBottom - rect.top - followingHeight - bottomPadding - bottomOffset));
};
const applyTableHeightValue = (table: HTMLElement, tableHeight: number) => {
const nextHeight = `${tableHeight}px`;
if (table.style.height !== nextHeight) {
table.style.height = nextHeight;
}
if (table.style.maxHeight !== nextHeight) {
table.style.maxHeight = nextHeight;
}
table.classList.add('is-full-height-table');
const bodyWrapper = getBodyWrapper(table);
if (bodyWrapper) {
const headerHeight = table.querySelector<HTMLElement>('.el-table__header-wrapper')?.offsetHeight ?? 0;
const footerHeight = table.querySelector<HTMLElement>('.el-table__footer-wrapper')?.offsetHeight ?? 0;
const bodyHeight = `${Math.max(120, tableHeight - headerHeight - footerHeight)}px`;
if (bodyWrapper.style.height !== bodyHeight) {
bodyWrapper.style.height = bodyHeight;
}
if (bodyWrapper.style.maxHeight !== bodyHeight) {
bodyWrapper.style.maxHeight = bodyHeight;
}
bodyWrapper.style.overflowY = 'auto';
bodyWrapper.style.overflowX = 'auto';
}
getTableComponent(table)?.doLayout?.();
};
const applyTableHeight = (table: HTMLElement) => {
rememberTableState(table);
const minHeight = window.innerWidth < 768 ? 220 : 260;
const tableHeight = calculateTableHeight(table);
applyTableHeightValue(table, tableHeight);
const overflowHeight = getPageOverflowHeight();
if (overflowHeight > 0 && tableHeight > minHeight) {
applyTableHeightValue(table, Math.max(minHeight, tableHeight - overflowHeight - 4));
}
};
const canManageTable = (table: HTMLElement) => {
if (!isVisible(table)) return false;
if (!table.closest('.app-main')) return false;
if (!table.closest('.table-panel')) return false;
if (table.closest('.el-dialog')) return false;
if (table.dataset.fullHeightTable === 'false') return false;
const hasManualHeight = Boolean(table.style.height || table.style.maxHeight);
return !hasManualHeight || tableStates.has(table);
};
const getManagedTables = () => {
return Array.from(document.querySelectorAll<HTMLElement>('.app-main .table-panel .data-table.el-table')).filter(
canManageTable
);
};
export function useFullHeightTable() {
const route = useRoute();
const settingsStore = useSettingsStore();
const observedElements = new Set<Element>();
let resizeObserver: ResizeObserver | undefined;
let mutationObserver: MutationObserver | undefined;
let animationFrame = 0;
const timerIds = new Set<number>();
const refreshObservedElements = () => {
if (!resizeObserver) return;
const nextElements = new Set<Element>([
...Array.from(document.querySelectorAll('.app-main, .app-main .p-2, .app-main .table-panel')),
...Array.from(document.querySelectorAll('.app-main .search-panel, .app-main .tree-panel-shell')),
...getManagedTables()
]);
observedElements.forEach(element => {
if (!nextElements.has(element)) {
resizeObserver?.unobserve(element);
observedElements.delete(element);
}
});
nextElements.forEach(element => {
if (!observedElements.has(element)) {
resizeObserver?.observe(element);
observedElements.add(element);
}
});
};
const recalculate = () => {
animationFrame = 0;
if (!settingsStore.fullHeightTable) {
resetAllTables();
return;
}
const tables = getManagedTables();
document.querySelectorAll<HTMLElement>('.is-full-height-table').forEach(table => {
if (!tables.includes(table)) {
resetTable(table);
}
});
tables.forEach(applyTableHeight);
refreshObservedElements();
};
const scheduleRecalculate = (delay = 0) => {
if (delay > 0) {
const timerId = window.setTimeout(() => {
timerIds.delete(timerId);
scheduleRecalculate();
}, delay);
timerIds.add(timerId);
return;
}
if (animationFrame) return;
animationFrame = window.requestAnimationFrame(recalculate);
};
const scheduleLayoutRecheck = () => {
scheduleRecalculate();
[80, 180, 360].forEach(scheduleRecalculate);
};
onMounted(() => {
resizeObserver = new ResizeObserver(() => scheduleLayoutRecheck());
mutationObserver = new MutationObserver(() => {
refreshObservedElements();
scheduleLayoutRecheck();
});
const appMain = document.querySelector('.app-main');
if (appMain) {
mutationObserver.observe(appMain, {
attributes: true,
attributeFilter: ['class', 'style'],
childList: true,
subtree: true
});
appMain.addEventListener('transitionend', scheduleLayoutRecheck);
}
window.addEventListener('resize', scheduleLayoutRecheck);
refreshObservedElements();
scheduleLayoutRecheck();
});
onBeforeUnmount(() => {
if (animationFrame) {
window.cancelAnimationFrame(animationFrame);
}
timerIds.forEach(timerId => window.clearTimeout(timerId));
timerIds.clear();
resizeObserver?.disconnect();
mutationObserver?.disconnect();
document.querySelector('.app-main')?.removeEventListener('transitionend', scheduleLayoutRecheck);
window.removeEventListener('resize', scheduleLayoutRecheck);
resetAllTables();
});
watch(
() => settingsStore.fullHeightTable,
() => scheduleLayoutRecheck()
);
watch(
() => route.fullPath,
() => {
nextTick(() => scheduleLayoutRecheck());
}
);
}

View File

@ -13,12 +13,14 @@
<script setup name="AppMain" lang="ts"> <script setup name="AppMain" lang="ts">
import animateConfig from '@/animate'; import animateConfig from '@/animate';
import { useFullHeightTable } from '@/hooks/table/useFullHeightTable';
import { useSettingsStore } from '@/store/modules/settings'; import { useSettingsStore } from '@/store/modules/settings';
import { useTagsViewStore } from '@/store/modules/tagsView'; import { useTagsViewStore } from '@/store/modules/tagsView';
import IframeToggle from './IframeToggle/index.vue'; import IframeToggle from './IframeToggle/index.vue';
const route = useRoute(); const route = useRoute();
const tagsViewStore = useTagsViewStore(); const tagsViewStore = useTagsViewStore();
useFullHeightTable();
// //
const animate = ref<string>(''); const animate = ref<string>('');
@ -62,6 +64,14 @@ function addIframe() {
} }
} }
.app-main:not(.with-fixed-header) {
min-height: calc(100vh - 64px);
}
.app-main.with-tags-view:not(.with-fixed-header) {
min-height: calc(100vh - 105px);
}
.app-main.with-fixed-header { .app-main.with-fixed-header {
padding-top: 76px; padding-top: 76px;
min-height: calc(100vh - 76px); min-height: calc(100vh - 76px);

View File

@ -154,6 +154,13 @@
</span> </span>
</div> </div>
<div class="drawer-item">
<span>全高表格</span>
<span class="comp-style">
<el-switch v-model="settingsStore.fullHeightTable" class="drawer-switch" />
</span>
</div>
<el-divider /> <el-divider />
<el-button type="primary" plain icon="DocumentAdd" @click="saveSetting">保存配置</el-button> <el-button type="primary" plain icon="DocumentAdd" @click="saveSetting">保存配置</el-button>
@ -260,6 +267,7 @@ const saveSetting = () => {
settings.value.theme = storeSettings.value.theme; settings.value.theme = storeSettings.value.theme;
settings.value.navType = storeSettings.value.navType; settings.value.navType = storeSettings.value.navType;
settings.value.radiusBase = storeSettings.value.radiusBase; settings.value.radiusBase = storeSettings.value.radiusBase;
settings.value.fullHeightTable = storeSettings.value.fullHeightTable;
setTimeout(() => { setTimeout(() => {
modal.closeLoading(); modal.closeLoading();
}, 1000); }, 1000);

View File

@ -1,7 +1,7 @@
import HighLight from '@highlightjs/vue-plugin'; import HighLight from '@highlightjs/vue-plugin';
import { ElDialog } from 'element-plus'; import { ElDialog } from 'element-plus';
import { createApp } from 'vue'; import { createApp } from 'vue';
import VXETable from 'vxe-table'; import { install as VxeTablePlugin, VxeUI } from 'vxe-table';
import 'virtual:uno.css'; import 'virtual:uno.css';
import 'element-plus/theme-chalk/dark/css-vars.css'; import 'element-plus/theme-chalk/dark/css-vars.css';
import '@/assets/styles/index.scss'; import '@/assets/styles/index.scss';
@ -18,7 +18,7 @@ import './permission';
import router from './router'; import router from './router';
import store from './store'; import store from './store';
VXETable.setConfig({ VxeUI.setConfig({
zIndex: 999999 zIndex: 999999
}); });
@ -28,10 +28,10 @@ const app = createApp(App);
app.use(HighLight); app.use(HighLight);
app.use(ElementIcons); app.use(ElementIcons);
app.use(router);
app.use(store); app.use(store);
app.use(router);
app.use(i18n); app.use(i18n);
app.use(VXETable); app.use(VxeTablePlugin);
app.use(plugins); app.use(plugins);
directive(app); directive(app);

View File

@ -81,6 +81,11 @@ const setting: DefaultSettings = {
/** /**
* *
*/ */
radiusBase: 14 radiusBase: 14,
/**
*
*/
fullHeightTable: true
}; };
export default setting; export default setting;

View File

@ -16,7 +16,8 @@ export const useSettingsStore = defineStore('setting', () => {
sideTheme: defaultSettings.sideTheme, sideTheme: defaultSettings.sideTheme,
theme: defaultSettings.theme, theme: defaultSettings.theme,
navType: defaultSettings.navType, navType: defaultSettings.navType,
radiusBase: defaultSettings.radiusBase radiusBase: defaultSettings.radiusBase,
fullHeightTable: defaultSettings.fullHeightTable
}); });
const title = ref<string>(defaultSettings.title); const title = ref<string>(defaultSettings.title);
const theme = ref<string>(storageSetting.value.theme); const theme = ref<string>(storageSetting.value.theme);
@ -32,6 +33,7 @@ export const useSettingsStore = defineStore('setting', () => {
const dark = ref<boolean>(defaultSettings.dark); const dark = ref<boolean>(defaultSettings.dark);
const navType = ref<NavTypeEnum>(storageSetting.value.navType || NavTypeEnum.LEFT); const navType = ref<NavTypeEnum>(storageSetting.value.navType || NavTypeEnum.LEFT);
const radiusBase = ref<number>(storageSetting.value.radiusBase ?? defaultSettings.radiusBase); const radiusBase = ref<number>(storageSetting.value.radiusBase ?? defaultSettings.radiusBase);
const fullHeightTable = ref<boolean>(storageSetting.value.fullHeightTable ?? defaultSettings.fullHeightTable);
const setTitle = (value: string) => { const setTitle = (value: string) => {
title.value = value; title.value = value;
@ -52,6 +54,7 @@ export const useSettingsStore = defineStore('setting', () => {
dark, dark,
navType, navType,
radiusBase, radiusBase,
fullHeightTable,
setTitle setTitle
}; };
}); });

View File

@ -131,6 +131,11 @@ declare global {
* *
*/ */
radiusBase: number; radiusBase: number;
/**
*
*/
fullHeightTable: boolean;
} }
interface DefaultSettings extends LayoutSetting { interface DefaultSettings extends LayoutSetting {

View File

@ -9,6 +9,9 @@ import { parsePushMessage, resolveNoticeGroup, resolveNoticeTitle, shouldAppendN
let closePushConnection: (() => void) | undefined; let closePushConnection: (() => void) | undefined;
let stopPushWatchers: Array<() => void> = []; let stopPushWatchers: Array<() => void> = [];
const KICKED_MESSAGE = 'kicked';
let pushKicked = false;
let resumePushTimer: ReturnType<typeof setTimeout> | undefined;
const formatNoticeTime = (timestamp?: number | string) => { const formatNoticeTime = (timestamp?: number | string) => {
const time = timestamp ? new Date(timestamp) : new Date(); const time = timestamp ? new Date(timestamp) : new Date();
@ -44,6 +47,15 @@ const appendNotice = (raw: string) => {
}); });
}; };
const handlePushMessage = (raw: string) => {
if (raw === KICKED_MESSAGE) {
pushKicked = true;
closePush();
return;
}
appendNotice(raw);
};
const toNoticeItem = (item: MessageVO) => { const toNoticeItem = (item: MessageVO) => {
const userId = useUserStore().userId; const userId = useUserStore().userId;
const timestamp = item.createTime ? new Date(item.createTime).getTime() : Date.now(); const timestamp = item.createTime ? new Date(item.createTime).getTime() : Date.now();
@ -91,7 +103,7 @@ const initSsePush = (url: string) => {
const stopDataWatch = watch(data, () => { const stopDataWatch = watch(data, () => {
if (!data.value) return; if (!data.value) return;
appendNotice(data.value); handlePushMessage(data.value);
data.value = null; data.value = null;
}); });
stopPushWatchers.push(stopErrorWatch, stopDataWatch); stopPushWatchers.push(stopErrorWatch, stopDataWatch);
@ -115,7 +127,7 @@ const initWsPush = (url: string) => {
if (String(e.data) === 'pong') { if (String(e.data) === 'pong') {
return; return;
} }
appendNotice(String(e.data)); handlePushMessage(String(e.data));
} }
}); });
closePushConnection = close; closePushConnection = close;
@ -126,6 +138,7 @@ export const initPush = () => {
if (import.meta.env.VITE_APP_MESSAGE_ENABLED === 'false') { if (import.meta.env.VITE_APP_MESSAGE_ENABLED === 'false') {
return; return;
} }
pushKicked = false;
const path = import.meta.env.VITE_APP_MESSAGE_PATH || '/resource/message'; const path = import.meta.env.VITE_APP_MESSAGE_PATH || '/resource/message';
const transport = import.meta.env.VITE_APP_MESSAGE_TRANSPORT || 'sse'; const transport = import.meta.env.VITE_APP_MESSAGE_TRANSPORT || 'sse';
if (transport.toLowerCase() === 'websocket') { if (transport.toLowerCase() === 'websocket') {
@ -153,3 +166,27 @@ export const closePush = () => {
stopPushWatchers.forEach(stop => stop()); stopPushWatchers.forEach(stop => stop());
stopPushWatchers = []; stopPushWatchers = [];
}; };
const resumePushIfNeeded = () => {
if (!pushKicked || !getToken() || document.visibilityState !== 'visible') {
return;
}
if (resumePushTimer) {
clearTimeout(resumePushTimer);
}
resumePushTimer = setTimeout(async () => {
resumePushTimer = undefined;
if (!pushKicked || !getToken() || document.visibilityState !== 'visible') {
return;
}
try {
await initMessageBox();
} finally {
initPush();
}
}, 300);
};
window.addEventListener('focus', resumePushIfNeeded);
document.addEventListener('visibilitychange', resumePushIfNeeded);
window.addEventListener('online', resumePushIfNeeded);

View File

@ -75,7 +75,7 @@
<el-table-column prop="menuName" label="菜单名称" :show-overflow-tooltip="true" width="220"> <el-table-column prop="menuName" label="菜单名称" :show-overflow-tooltip="true" width="220">
<template #default="scope"> <template #default="scope">
<div class="menu-name-cell"> <div class="menu-name-cell">
<svg-icon v-if="scope.row.icon" :icon-class="scope.row.icon" /> <svg-icon v-if="isMenuIconVisible(scope.row.icon)" :icon-class="scope.row.icon" />
<span class="menu-name-text">{{ scope.row.menuName }}</span> <span class="menu-name-text">{{ scope.row.menuName }}</span>
</div> </div>
</template> </template>
@ -431,6 +431,11 @@ const { dialog, openDialog, closeDialog, setTitle } = useDialogState();
type MenuTagType = 'warning' | 'primary' | 'success' | 'danger'; type MenuTagType = 'warning' | 'primary' | 'success' | 'danger';
const isMenuIconVisible = (icon?: string) => {
const normalizedIcon = icon?.trim();
return !!normalizedIcon && normalizedIcon !== '#';
};
const getMenuTypeMeta = (menu: Partial<MenuVO>): { label: string; type: MenuTagType } => { const getMenuTypeMeta = (menu: Partial<MenuVO>): { label: string; type: MenuTagType } => {
if (menu.menuType === MenuTypeEnum.F) { if (menu.menuType === MenuTypeEnum.F) {
return { label: '按钮', type: 'warning' }; return { label: '按钮', type: 'warning' };

View File

@ -1,5 +1,4 @@
<template> <template>
<!-- 导入表 -->
<el-dialog v-model="visible" title="导入表" width="1100px" top="5vh" append-to-body> <el-dialog v-model="visible" title="导入表" width="1100px" top="5vh" append-to-body>
<el-form ref="queryFormRef" :model="queryParams" :inline="true"> <el-form ref="queryFormRef" :model="queryParams" :inline="true">
<el-form-item label="数据源" prop="dataName"> <el-form-item label="数据源" prop="dataName">

View File

@ -15,6 +15,15 @@ export default defineConfig(({ mode, command }) => {
}, },
// https://cn.vitejs.dev/config/#resolve-extensions // https://cn.vitejs.dev/config/#resolve-extensions
plugins: createPlugins(env, command === 'build'), plugins: createPlugins(env, command === 'build'),
build: {
chunkSizeWarningLimit: 1500,
rolldownOptions: {
checks: {
invalidAnnotation: false,
pluginTimings: false
}
}
},
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: Number(env.VITE_APP_PORT), port: Number(env.VITE_APP_PORT),

View File

@ -0,0 +1,117 @@
import type { PluginOption } from 'vite';
import { normalizePath } from 'vite';
import { parse } from 'vue/compiler-sfc';
/** 模板 AST 元素节点类型 */
const NODE_TYPE_ELEMENT = 1;
/** 模板 AST 注释节点类型 */
const NODE_TYPE_COMMENT = 3;
/** 模板 AST 指令属性类型 */
const NODE_TYPE_DIRECTIVE = 7;
/**
*
*/
function isElement(node: any): boolean {
return node?.type === NODE_TYPE_ELEMENT;
}
/**
*
* @description transition
*/
function isComment(node: any): boolean {
return node?.type === NODE_TYPE_COMMENT;
}
/**
*
*/
function nodeLabel(node: any): string {
return isComment(node) ? '<!--注释-->' : `<${node.tag}>`;
}
/**
* v-if / v-else-if / v-else
*/
function hasDirective(node: any, name: string): boolean {
return (node.props || []).some(
(prop: any) => prop.type === NODE_TYPE_DIRECTIVE && prop.name === name,
);
}
/**
* v-if / v-else-if / v-else
* @description transition
*/
function isConditionalChain(elements: any[]): boolean {
// 首个节点必须是 v-if后续节点必须都是 v-else-if 或 v-else
if (!hasDirective(elements[0], 'if')) {
return false;
}
return elements
.slice(1)
.every((el) => hasDirective(el, 'else-if') || hasDirective(el, 'else'));
}
/**
* vite
* @description / <transition> transition
* Vue <transition>
* src/views
*/
export const viteCheckTransitionPlugin = (): PluginOption => {
// 仅检测 views 目录下的页面
const targetDir = normalizePath('/src/views/');
return {
enforce: 'pre',
name: 'vite:check-transition',
transform(code, id) {
const normalizedId = normalizePath(id);
// 跳过非 views 下的文件、非 .vue 文件以及带 query 的子请求
if (
normalizedId.includes('?') ||
!normalizedId.endsWith('.vue') ||
!normalizedId.includes(targetDir)
) {
return null;
}
const { descriptor, errors: parseErrors } = parse(code, {
filename: id,
});
// 解析失败交由 vue 插件处理,这里不重复报错
if (parseErrors.length > 0 || !descriptor.template?.ast) {
return null;
}
// 统计根级元素节点与注释节点,二者都是 transition 的子节点,忽略空白文本
const rootNodes = (descriptor.template.ast.children || []).filter(
(child: any) => isElement(child) || isComment(child),
);
// 仅元素节点参与 v-if 分支链判断(注释不属于分支链)
const rootElements = rootNodes.filter((node: any) => isElement(node));
const isChain =
rootNodes.length === rootElements.length &&
isConditionalChain(rootElements);
if (rootNodes.length > 1 && !isChain) {
const relativePath = normalizedId.slice(
normalizedId.indexOf('/src/') + 1,
);
const tags = rootNodes.map((node: any) => nodeLabel(node)).join(' ');
throw new Error(
`[check-transition] ${relativePath} 存在多个根节点: ${tags}\n 页面外层已包裹 <transition>,模板必须只有单个根节点,请用一个容器元素(如 div)包裹,并删除多余的注释/空标签。`,
);
}
return null;
},
};
};

View File

@ -6,6 +6,7 @@ import createComponents from './components';
import createSvgIconsPlugin from './svg-icon'; import createSvgIconsPlugin from './svg-icon';
import createCompression from './compression'; import createCompression from './compression';
import createSetupExtend from './setup-extend'; import createSetupExtend from './setup-extend';
import { viteCheckTransitionPlugin } from "./check-transition";
export default (viteEnv: any, isBuild = false): [] => { export default (viteEnv: any, isBuild = false): [] => {
const vitePlugins: any = []; const vitePlugins: any = [];
@ -16,5 +17,6 @@ export default (viteEnv: any, isBuild = false): [] => {
vitePlugins.push(createCompression(viteEnv)); vitePlugins.push(createCompression(viteEnv));
vitePlugins.push(createSvgIconsPlugin()); vitePlugins.push(createSvgIconsPlugin());
vitePlugins.push(createSetupExtend()); vitePlugins.push(createSetupExtend());
vitePlugins.push(viteCheckTransitionPlugin())
return vitePlugins; return vitePlugins;
}; };