This commit is contained in:
yinsx
2026-01-23 15:23:04 +08:00
commit 5674ce116e
34 changed files with 3901 additions and 0 deletions

View File

@ -0,0 +1,343 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
import Muuri from 'muuri';
import { useLayoutStore } from '@/store/useLayoutStore';
import { useWidgetsStore } from '@/store/useWidgetsStore';
import { useUIStore } from '@/store/useUIStore';
import IconCard from '@/components/IconCard/index.vue';
import WidgetCard from '@/components/WidgetCard/index.vue';
type GridItemType = 'icon' | 'widget';
interface Icon {
id: string;
name: string;
url: string;
img?: string;
bgColor?: string;
}
type WidgetSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
interface Widget {
id: string;
component: string;
size: WidgetSize;
data?: any;
}
interface GridOrderEntry {
id: string;
type: GridItemType;
}
type OrderedGridItem =
| { type: 'icon'; id: string; icon: Icon }
| { type: 'widget'; id: string; widget: Widget };
const GRID_ORDER_STORAGE_KEY = 'itab_grid_order';
const layoutStore = useLayoutStore();
const widgetsStore = useWidgetsStore();
const uiStore = useUIStore();
const gridRef = ref<HTMLElement | null>(null);
const grid = ref<any | null>(null);
const isDragging = ref(false);
const suppressClick = ref(false);
const gridOrder = ref<GridOrderEntry[]>([]);
const buildDefaultOrder = (): GridOrderEntry[] => [
...widgetsStore.widgets.map(widget => ({ id: widget.id, type: 'widget' as const })),
...layoutStore.icons.map(icon => ({ id: icon.id, type: 'icon' as const })),
];
const persistGridOrder = (order: GridOrderEntry[]) => {
gridOrder.value = order;
localStorage.setItem(GRID_ORDER_STORAGE_KEY, JSON.stringify(order));
const iconOrder = order.filter(item => item.type === 'icon').map(item => item.id);
const widgetOrder = order.filter(item => item.type === 'widget').map(item => item.id);
if (iconOrder.length) {
layoutStore.setIconOrder(iconOrder);
}
if (widgetOrder.length) {
widgetsStore.setWidgetOrder(widgetOrder);
}
};
const loadGridOrder = () => {
const saved = localStorage.getItem(GRID_ORDER_STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved) as GridOrderEntry[];
if (Array.isArray(parsed)) {
gridOrder.value = parsed.filter(item => item?.id && (item.type === 'icon' || item.type === 'widget'));
}
} catch {
gridOrder.value = [];
}
}
if (!gridOrder.value.length) {
gridOrder.value = buildDefaultOrder();
}
persistGridOrder(gridOrder.value);
};
const ensureOrderConsistency = () => {
const iconIds = new Set(layoutStore.icons.map(icon => icon.id));
const widgetIds = new Set(widgetsStore.widgets.map(widget => widget.id));
const nextOrder: GridOrderEntry[] = [];
for (const entry of gridOrder.value) {
if (entry.type === 'icon' && iconIds.has(entry.id)) {
nextOrder.push(entry);
}
if (entry.type === 'widget' && widgetIds.has(entry.id)) {
nextOrder.push(entry);
}
}
for (const widget of widgetsStore.widgets) {
if (!nextOrder.some(item => item.type === 'widget' && item.id === widget.id)) {
nextOrder.push({ id: widget.id, type: 'widget' });
}
}
for (const icon of layoutStore.icons) {
if (!nextOrder.some(item => item.type === 'icon' && item.id === icon.id)) {
nextOrder.push({ id: icon.id, type: 'icon' });
}
}
const orderChanged =
nextOrder.length !== gridOrder.value.length ||
nextOrder.some((entry, index) => {
const current = gridOrder.value[index];
return current?.id !== entry.id || current?.type !== entry.type;
});
if (orderChanged) {
persistGridOrder(nextOrder);
} else {
gridOrder.value = nextOrder;
}
};
const orderedItems = computed<OrderedGridItem[]>(() => {
const iconsById = new Map(layoutStore.icons.map(icon => [icon.id, icon]));
const widgetsById = new Map(widgetsStore.widgets.map(widget => [widget.id, widget]));
const used = new Set<string>();
const items: OrderedGridItem[] = [];
for (const entry of gridOrder.value) {
if (entry.type === 'icon') {
const icon = iconsById.get(entry.id);
if (icon) {
items.push({ type: 'icon', id: icon.id, icon });
used.add(`icon:${icon.id}`);
}
} else {
const widget = widgetsById.get(entry.id);
if (widget) {
items.push({ type: 'widget', id: widget.id, widget });
used.add(`widget:${widget.id}`);
}
}
}
for (const widget of widgetsStore.widgets) {
const key = `widget:${widget.id}`;
if (!used.has(key)) {
items.push({ type: 'widget', id: widget.id, widget });
}
}
for (const icon of layoutStore.icons) {
const key = `icon:${icon.id}`;
if (!used.has(key)) {
items.push({ type: 'icon', id: icon.id, icon });
}
}
return items;
});
const handleClick = (event: MouseEvent) => {
if (suppressClick.value || isDragging.value) return;
const target = event.target as HTMLElement;
const itemEl = target.closest('.grid-item') as HTMLElement | null;
if (!itemEl || itemEl.dataset.type !== 'icon') return;
const link = target.closest('a') as HTMLAnchorElement | null;
if (link && link.href) {
window.open(link.href, '_blank');
}
};
const handleContextMenu = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const itemEl = target.closest('.grid-item') as HTMLElement | null;
if (!itemEl) return;
const id = itemEl.dataset.id;
const type = itemEl.dataset.type as GridItemType | undefined;
if (id && type) {
uiStore.openContextMenu(event.clientX, event.clientY, id, type);
}
};
const persistOrderFromGrid = async () => {
if (!grid.value) return;
const items = grid.value.getItems();
const nextOrder: GridOrderEntry[] = [];
for (const item of items) {
const element = item.getElement() as HTMLElement;
const id = element.dataset.id;
const type = element.dataset.type as GridItemType | undefined;
if (id && (type === 'icon' || type === 'widget')) {
nextOrder.push({ id, type });
}
}
if (nextOrder.length) {
persistGridOrder(nextOrder);
}
await nextTick();
grid.value?.synchronize();
grid.value?.layout();
};
const refreshLayout = async () => {
await nextTick();
grid.value?.refreshItems();
grid.value?.layout();
};
onMounted(async () => {
loadGridOrder();
await nextTick();
if (!gridRef.value) return;
grid.value = new Muuri(gridRef.value, {
items: '.grid-item',
dragEnabled: true,
dragStartPredicate: {
distance: 5,
delay: 150,
},
dragSort: true,
layout: {
fillGaps: true,
rounding: true,
},
});
grid.value.on('dragStart', () => {
isDragging.value = true;
suppressClick.value = true;
});
grid.value.on('dragEnd', () => {
isDragging.value = false;
window.setTimeout(() => {
suppressClick.value = false;
}, 0);
});
grid.value.on('dragReleaseEnd', () => {
persistOrderFromGrid();
});
grid.value.layout(true);
});
watch(
() => [layoutStore.icons.length, widgetsStore.widgets.length],
() => {
ensureOrderConsistency();
refreshLayout();
}
);
watch(
() => widgetsStore.widgets.map(widget => widget.size).join('|'),
() => {
refreshLayout();
}
);
onUnmounted(() => {
grid.value?.destroy();
grid.value = null;
});
</script>
<template>
<div
ref="gridRef"
class="grid-canvas"
@click="handleClick"
@contextmenu.prevent="handleContextMenu"
>
<div
v-for="item in orderedItems"
:key="`${item.type}-${item.id}`"
class="grid-item"
:class="item.type === 'widget' ? `size-${item.widget.size}` : 'size-1x1'"
:data-id="item.id"
:data-type="item.type"
>
<div class="grid-item-content">
<WidgetCard v-if="item.type === 'widget'" :widget="item.widget" />
<IconCard v-else :icon="item.icon" />
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
@import '@/styles/tokens.scss';
.grid-canvas {
width: 100%;
position: relative;
user-select: none; /* 标准 */
-webkit-user-select: none; /* Safari 浏览器 */
-moz-user-select: none; /* Firefox 浏览器 */
-ms-user-select: none; /* IE 10+/Edge 浏览器 */
--cell-size: var(--grid-cell-size);
--cell-gap: var(--grid-gap);
padding: calc(var(--cell-gap) / 2);
box-sizing: border-box;
}
.grid-item {
position: absolute;
width: var(--cell-size);
height: var(--cell-size);
margin: calc(var(--cell-gap) / 2);
cursor: grab;
}
.grid-item.size-1x2 {
height: calc(var(--cell-size) * 2 + var(--cell-gap));
}
.grid-item.size-2x1 {
width: calc(var(--cell-size) * 2 + var(--cell-gap));
}
.grid-item.size-2x2 {
width: calc(var(--cell-size) * 2 + var(--cell-gap));
height: calc(var(--cell-size) * 2 + var(--cell-gap));
}
.grid-item.size-2x4 {
width: calc(var(--cell-size) * 2 + var(--cell-gap));
height: calc(var(--cell-size) * 4 + var(--cell-gap) * 3);
}
.grid-item-content {
width: 100%;
height: 100%;
}
.grid-item.muuri-item-dragging {
z-index: $z-index-menu;
cursor: grabbing;
}
</style>