初始化

This commit is contained in:
2026-02-22 18:56:10 +08:00
commit 26677972a6
3112 changed files with 255972 additions and 0 deletions

View 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>

View 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>

View 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>