初始化
This commit is contained in:
201
easyflow-ui-admin/app/src/components/page/CardList.vue
Normal file
201
easyflow-ui-admin/app/src/components/page/CardList.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useAccess } from '@easyflow/access';
|
||||
|
||||
import { MoreFilled } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElAvatar,
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElDivider,
|
||||
ElDropdown,
|
||||
ElDropdownItem,
|
||||
ElDropdownMenu,
|
||||
ElIcon,
|
||||
ElText,
|
||||
} from 'element-plus';
|
||||
|
||||
export interface ActionButton {
|
||||
icon: any;
|
||||
text: string;
|
||||
className: string;
|
||||
permission: string;
|
||||
onClick: (row: any) => void;
|
||||
}
|
||||
|
||||
export interface CardListProps {
|
||||
iconField?: string;
|
||||
titleField?: string;
|
||||
descField?: string;
|
||||
actions?: ActionButton[];
|
||||
defaultIcon: any;
|
||||
data: any[];
|
||||
}
|
||||
const props = withDefaults(defineProps<CardListProps>(), {
|
||||
iconField: 'icon',
|
||||
titleField: 'title',
|
||||
descField: 'description',
|
||||
actions: () => [],
|
||||
});
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const filterActions = computed(() => {
|
||||
return props.actions.filter((action) => {
|
||||
return hasAccessByCodes([action.permission]);
|
||||
});
|
||||
});
|
||||
const visibleActions = computed(() => {
|
||||
return filterActions.value.length <= 3
|
||||
? filterActions.value
|
||||
: filterActions.value.slice(0, 3);
|
||||
});
|
||||
const hiddenActions = computed(() => {
|
||||
return filterActions.value.length > 3 ? filterActions.value.slice(3) : [];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card-grid">
|
||||
<ElCard
|
||||
v-for="(item, index) in props.data"
|
||||
:key="index"
|
||||
shadow="hover"
|
||||
footer-class="foot-c"
|
||||
:style="{
|
||||
'--el-box-shadow-light': '0px 2px 12px 0px rgb(100 121 153 10%)',
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<ElAvatar
|
||||
class="shrink-0"
|
||||
:src="item[iconField] || defaultIcon"
|
||||
:size="36"
|
||||
/>
|
||||
<ElText truncated size="large" class="font-medium">
|
||||
{{ item[titleField] }}
|
||||
</ElText>
|
||||
</div>
|
||||
<ElText line-clamp="2" class="item-desc w-full">
|
||||
{{ item[titleField] }}
|
||||
</ElText>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div :class="visibleActions.length > 2 ? 'footer-div' : ''">
|
||||
<template v-for="(action, idx) in visibleActions" :key="idx">
|
||||
<ElButton
|
||||
:icon="typeof action.icon === 'string' ? undefined : action.icon"
|
||||
size="small"
|
||||
:style="{
|
||||
'--el-button-text-color': 'hsl(220deg 9.68% 63.53%)',
|
||||
'--el-button-font-weight': 400,
|
||||
}"
|
||||
link
|
||||
@click="action.onClick(item)"
|
||||
>
|
||||
<template v-if="typeof action.icon === 'string'" #icon>
|
||||
<IconifyIcon :icon="action.icon" />
|
||||
</template>
|
||||
{{ action.text }}
|
||||
</ElButton>
|
||||
<ElDivider
|
||||
v-if="
|
||||
filterActions.length <= 3
|
||||
? idx < filterActions.length - 1
|
||||
: true
|
||||
"
|
||||
direction="vertical"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<ElDropdown v-if="hiddenActions.length > 0" trigger="click">
|
||||
<ElButton
|
||||
:style="{
|
||||
'--el-button-text-color': 'hsl(220deg 9.68% 63.53%)',
|
||||
'--el-button-font-weight': 400,
|
||||
}"
|
||||
:icon="MoreFilled"
|
||||
link
|
||||
/>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
v-for="(action, idx) in hiddenActions"
|
||||
:key="idx"
|
||||
@click="action.onClick(item)"
|
||||
>
|
||||
<template #default>
|
||||
<div :class="`${action.className} handle-div`">
|
||||
<ElIcon v-if="action.icon">
|
||||
<component :is="action.icon" />
|
||||
</ElIcon>
|
||||
{{ action.text }}
|
||||
</div>
|
||||
</template>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</template>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 1024px) {
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-card__footer) {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.footer-div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 20px;
|
||||
background-color: hsl(var(--background-deep));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.handle-div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
min-width: max(100%, 600px); /* 确保至少显示2个卡片 */
|
||||
}
|
||||
|
||||
.item-desc {
|
||||
height: 40px;
|
||||
font-size: clamp(8px, 1vw, 14px);
|
||||
line-height: 20px;
|
||||
color: #75808d;
|
||||
}
|
||||
|
||||
.item-danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
</style>
|
||||
129
easyflow-ui-admin/app/src/components/page/PageData.vue
Normal file
129
easyflow-ui-admin/app/src/components/page/PageData.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { preferences } from '@easyflow/preferences';
|
||||
|
||||
import { ElEmpty, ElPagination } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
|
||||
interface PageDataProps {
|
||||
pageUrl: string;
|
||||
pageSize?: number;
|
||||
pageSizes?: number[];
|
||||
extraQueryParams?: Record<string, any>;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<PageDataProps>(), {
|
||||
pageSize: 10,
|
||||
pageSizes: () => [10, 20, 50, 100],
|
||||
extraQueryParams: () => ({}),
|
||||
});
|
||||
|
||||
// 响应式数据
|
||||
const pageList = ref([]);
|
||||
const loading = ref(false);
|
||||
const queryParams = ref({});
|
||||
|
||||
const pageInfo = reactive({
|
||||
pageNumber: 1,
|
||||
pageSize: props.pageSize,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
// 模拟 API 调用 - 这里需要根据你的实际 API 调用方式调整
|
||||
const doGet = async (params: any) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// 这里替换为你的实际 API 调用
|
||||
// 例如:return await api.get(props.pageUrl, { params })
|
||||
const response = await api.get(`${props.pageUrl}`, {
|
||||
params,
|
||||
});
|
||||
const data = await response.data;
|
||||
return { data };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取页面数据
|
||||
const getPageList = async () => {
|
||||
try {
|
||||
const res = await doGet({
|
||||
pageNumber: pageInfo.pageNumber,
|
||||
pageSize: pageInfo.pageSize,
|
||||
...props.extraQueryParams,
|
||||
...queryParams.value,
|
||||
});
|
||||
pageList.value = res.data?.records || [];
|
||||
pageInfo.total = res.data?.totalRow || 0;
|
||||
} catch (error) {
|
||||
console.error('get data error:', error);
|
||||
pageList.value = [];
|
||||
pageInfo.total = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// 分页事件处理
|
||||
const handleSizeChange = (newSize: number) => {
|
||||
pageInfo.pageSize = newSize;
|
||||
pageInfo.pageNumber = 1; // 重置到第一页
|
||||
};
|
||||
|
||||
const handleCurrentChange = (newPage: number) => {
|
||||
pageInfo.pageNumber = newPage;
|
||||
};
|
||||
|
||||
// 暴露给父组件的方法 (替代 useImperativeHandle)
|
||||
const setQuery = (newQueryParams: string) => {
|
||||
pageInfo.pageNumber = 1;
|
||||
pageInfo.pageSize = props.pageSize;
|
||||
queryParams.value = newQueryParams;
|
||||
getPageList();
|
||||
};
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
setQuery,
|
||||
});
|
||||
|
||||
// 监听器
|
||||
watch(
|
||||
[() => pageInfo.pageNumber, () => pageInfo.pageSize],
|
||||
() => {
|
||||
getPageList();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
getPageList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-data-container" v-loading="loading">
|
||||
<template v-if="pageList.length > 0">
|
||||
<div>
|
||||
<slot :page-list="pageList"></slot>
|
||||
</div>
|
||||
<div v-if="pageInfo.total > pageInfo.pageSize" class="mx-auto mt-8 w-fit">
|
||||
<ElPagination
|
||||
v-model:current-page="pageInfo.pageNumber"
|
||||
v-model:page-size="pageInfo.pageSize"
|
||||
:total="pageInfo.total"
|
||||
:page-sizes="pageSizes"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<ElEmpty
|
||||
:image="`/empty${preferences.theme.mode === 'dark' ? '-dark' : ''}.png`"
|
||||
v-else
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
236
easyflow-ui-admin/app/src/components/page/PageSide.vue
Normal file
236
easyflow-ui-admin/app/src/components/page/PageSide.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<script setup lang="ts" generic="T extends { icon?: any; [key: string]: any }">
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { preferences } from '@easyflow/preferences';
|
||||
import { cn } from '@easyflow/utils';
|
||||
|
||||
import { MoreFilled } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElDropdown,
|
||||
ElDropdownItem,
|
||||
ElDropdownMenu,
|
||||
ElEmpty,
|
||||
ElIcon,
|
||||
} from 'element-plus';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
menus: T[];
|
||||
labelKey: string;
|
||||
valueKey: string;
|
||||
iconSize?: number;
|
||||
controlBtns?: {
|
||||
icon?: any;
|
||||
label: string;
|
||||
onClick: (_: T) => void;
|
||||
type?: any;
|
||||
}[];
|
||||
footerButton?: {
|
||||
icon?: any;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
defaultSelected?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '',
|
||||
iconSize: 16,
|
||||
controlBtns: () => [],
|
||||
footerButton: undefined,
|
||||
defaultSelected: '',
|
||||
});
|
||||
const emits = defineEmits<{
|
||||
(e: 'change', item: T): void;
|
||||
}>();
|
||||
const panelWidth = ref(225);
|
||||
const selected = ref<string>(props.defaultSelected ?? '');
|
||||
const hoverId = ref<string>();
|
||||
|
||||
const handleChange = (item: T) => {
|
||||
selected.value = item[props.valueKey];
|
||||
emits('change', item);
|
||||
};
|
||||
// 监听 defaultSelected 的变化
|
||||
watch(
|
||||
() => props.defaultSelected,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
selected.value = newVal;
|
||||
const item = props.menus.find((menu) => menu[props.valueKey] === newVal);
|
||||
if (item) {
|
||||
emits('change', item);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const handleMouseEvent = (id?: string) => {
|
||||
if (id === undefined) {
|
||||
setTimeout(() => {
|
||||
hoverId.value = id;
|
||||
}, 200);
|
||||
} else {
|
||||
hoverId.value = id;
|
||||
}
|
||||
};
|
||||
const isComponent = (icon: any) => {
|
||||
return typeof icon !== 'string';
|
||||
};
|
||||
const isSvgString = (icon: any) => {
|
||||
if (typeof icon !== 'string') return false;
|
||||
// 简单判断:是否包含 SVG 根标签
|
||||
return icon.trim().startsWith('<svg') && icon.trim().endsWith('</svg>');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-full w-[225px] flex-col rounded-lg border border-[var(--el-border-color)] bg-[var(--el-bg-color)] p-2"
|
||||
:style="{ width: `${panelWidth}px` }"
|
||||
>
|
||||
<div class="flex flex-1 flex-col gap-5 overflow-hidden">
|
||||
<h3 v-if="title && title.length > 0" class="text-base font-medium">
|
||||
{{ title }}
|
||||
</h3>
|
||||
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div
|
||||
v-for="item in menus"
|
||||
:key="item[valueKey]"
|
||||
class="group list-item"
|
||||
:class="{
|
||||
selected: selected === item[valueKey],
|
||||
}"
|
||||
@click="handleChange(item)"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<div
|
||||
v-if="item.icon"
|
||||
class="ml-[-3px] flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
v-if="isSvgString(item.icon)"
|
||||
v-html="item.icon"
|
||||
:style="{
|
||||
width: `${iconSize}px`,
|
||||
height: `${iconSize}px`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}"
|
||||
class="svg-container"
|
||||
></div>
|
||||
<img
|
||||
v-else-if="
|
||||
typeof item.icon === 'string' && !isComponent(item.icon)
|
||||
"
|
||||
:src="item.icon"
|
||||
:style="{
|
||||
width: `${iconSize}px`,
|
||||
height: `${iconSize}px`,
|
||||
objectFit: 'contain',
|
||||
}"
|
||||
/>
|
||||
<ElIcon v-else>
|
||||
<component :is="item.icon as Component" v-bind="$attrs" />
|
||||
</ElIcon>
|
||||
</div>
|
||||
<div>
|
||||
{{ item[labelKey] }}
|
||||
</div>
|
||||
</div>
|
||||
<ElDropdown
|
||||
v-if="controlBtns.length > 0 && !['', '0'].includes(item[valueKey])"
|
||||
@click.stop
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'group-hover:!inline-flex',
|
||||
(!hoverId || item.id !== hoverId) && '!hidden',
|
||||
)
|
||||
"
|
||||
>
|
||||
<ElIcon>
|
||||
<MoreFilled />
|
||||
</ElIcon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<div
|
||||
@mouseenter="handleMouseEvent(item.id)"
|
||||
@mouseleave="handleMouseEvent()"
|
||||
>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
v-for="btn in controlBtns"
|
||||
:key="btn.label"
|
||||
@click="btn.onClick(item)"
|
||||
>
|
||||
<ElButton :type="btn.type" :icon="btn.icon" link>
|
||||
{{ btn.label }}
|
||||
</ElButton>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
<ElEmpty
|
||||
v-if="menus.length <= 0"
|
||||
:image="`/empty${preferences.theme.mode === 'dark' ? '-dark' : ''}.png`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElButton
|
||||
v-if="footerButton"
|
||||
@click="footerButton.onClick"
|
||||
:icon="footerButton.icon"
|
||||
plain
|
||||
>
|
||||
{{ footerButton.label }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.list-item:hover {
|
||||
background-color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
.list-item.selected {
|
||||
color: hsl(var(--primary));
|
||||
background-color: hsl(var(--primary) / 15%);
|
||||
}
|
||||
|
||||
.list-item.selected:where(.dark, .dark *) {
|
||||
color: hsl(var(--accent-foreground));
|
||||
background-color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
.svg-container :deep(svg) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user