This commit is contained in:
yinsx
2026-01-29 13:02:51 +08:00
parent 2bd53e3744
commit e04353a5fa
31 changed files with 852 additions and 51 deletions

View File

@ -3,12 +3,15 @@ import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
import Muuri from 'muuri';
import { useLayoutStore } from '@/store/useLayoutStore';
import { useWidgetsStore } from '@/store/useWidgetsStore';
import { useFoldersStore, type Folder, type FolderItem } from '@/store/useFoldersStore';
import { useUIStore } from '@/store/useUIStore';
import { useSettingsStore } from '@/store/useSettingsStore';
import IconCard from '@/components/IconCard/index.vue';
import WidgetCard from '@/components/WidgetCard/index.vue';
import FolderCard from '@/components/FolderCard/index.vue';
import FolderDialog from '@/components/FolderDialog/index.vue';
type GridItemType = 'icon' | 'widget';
type GridItemType = 'icon' | 'widget' | 'folder';
type GridItemSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
interface Icon {
@ -20,6 +23,7 @@ interface Icon {
bgColor?: string;
size?: GridItemSize;
groupId?: string;
folderId?: string;
}
type WidgetSize = GridItemSize;
@ -29,8 +33,13 @@ interface Widget {
size: WidgetSize;
data?: any;
groupId?: string;
folderId?: string;
}
type FolderChild =
| { type: 'icon'; id: string; icon: Icon }
| { type: 'widget'; id: string; widget: Widget };
interface GridOrderEntry {
id: string;
type: GridItemType;
@ -38,10 +47,12 @@ interface GridOrderEntry {
type OrderedGridItem =
| { type: 'icon'; id: string; icon: Icon }
| { type: 'widget'; id: string; widget: Widget };
| { type: 'widget'; id: string; widget: Widget }
| { type: 'folder'; id: string; folder: Folder; children: FolderChild[] };
const GRID_ORDER_STORAGE_KEY = 'itab_grid_order';
const DEFAULT_GROUP_ID = 'home';
const FOLDER_SIZE: GridItemSize = '2x2';
const props = defineProps<{
activeGroupId?: string | null;
@ -49,6 +60,7 @@ const props = defineProps<{
const layoutStore = useLayoutStore();
const widgetsStore = useWidgetsStore();
const foldersStore = useFoldersStore();
const uiStore = useUIStore();
const settingsStore = useSettingsStore();
const gridRef = ref<HTMLElement | null>(null);
@ -64,6 +76,12 @@ const iconSizeMap = new Map<string, GridItemSize>();
const enteringKeys = ref(new Set<string>());
const previousIconIds = ref(new Set<string>());
const previousWidgetIds = ref(new Set<string>());
const openFolderId = ref<string | null>(null);
const hoverTargetEl = ref<HTMLElement | null>(null);
const draggingMeta = ref<{ id: string | null; type: GridItemType | null }>({
id: null,
type: null,
});
const layoutAnimationMs = 240;
const layoutEasing = 'cubic-bezier(0.4, 0, 0.2, 1)';
const normalizeGroupId = (groupId?: string | null) => groupId ?? DEFAULT_GROUP_ID;
@ -74,7 +92,7 @@ const normalizeGridOrder = (order: GridOrderEntry[]) => {
const normalized: GridOrderEntry[] = [];
for (const entry of order) {
if (!entry || typeof entry.id !== 'string') continue;
if (entry.type !== 'icon' && entry.type !== 'widget') continue;
if (entry.type !== 'icon' && entry.type !== 'widget' && entry.type !== 'folder') continue;
const key = `${entry.type}:${entry.id}`;
if (seen.has(key)) continue;
seen.add(key);
@ -84,8 +102,9 @@ const normalizeGridOrder = (order: 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 })),
...widgetsStore.widgets.filter(widget => !widget.folderId).map(widget => ({ id: widget.id, type: 'widget' as const })),
...layoutStore.icons.filter(icon => !icon.folderId).map(icon => ({ id: icon.id, type: 'icon' as const })),
...foldersStore.folders.map(folder => ({ id: folder.id, type: 'folder' as const })),
];
const applyGridOrder = (order: GridOrderEntry[], syncStoreOrder = true) => {
@ -121,8 +140,9 @@ const loadGridOrder = () => {
};
const ensureOrderConsistency = () => {
const iconIds = new Set(layoutStore.icons.map(icon => icon.id));
const widgetIds = new Set(widgetsStore.widgets.map(widget => widget.id));
const iconIds = new Set(layoutStore.icons.filter(icon => !icon.folderId).map(icon => icon.id));
const widgetIds = new Set(widgetsStore.widgets.filter(widget => !widget.folderId).map(widget => widget.id));
const folderIds = new Set(foldersStore.folders.map(folder => folder.id));
const seen = new Set<string>();
const nextOrder: GridOrderEntry[] = [];
@ -140,22 +160,32 @@ const ensureOrderConsistency = () => {
if (entry.type === 'widget' && widgetIds.has(entry.id)) {
pushEntry(entry);
}
if (entry.type === 'folder' && folderIds.has(entry.id)) {
pushEntry(entry);
}
}
for (const widget of widgetsStore.widgets) {
const key = `widget:${widget.id}`;
if (!seen.has(key)) {
if (!widget.folderId && !seen.has(key)) {
pushEntry({ id: widget.id, type: 'widget' });
}
}
for (const icon of layoutStore.icons) {
const key = `icon:${icon.id}`;
if (!seen.has(key)) {
if (!icon.folderId && !seen.has(key)) {
pushEntry({ id: icon.id, type: 'icon' });
}
}
for (const folder of foldersStore.folders) {
const key = `folder:${folder.id}`;
if (!seen.has(key)) {
pushEntry({ id: folder.id, type: 'folder' });
}
}
const orderChanged =
nextOrder.length !== gridOrder.value.length ||
nextOrder.some((entry, index) => {
@ -176,42 +206,103 @@ const isInActiveGroup = (groupId?: string | null) =>
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 foldersById = new Map(foldersStore.folders.map(folder => [folder.id, folder]));
const used = new Set<string>();
const items: OrderedGridItem[] = [];
const buildChildren = (folder: Folder): FolderChild[] => {
const children: FolderChild[] = [];
for (const child of folder.items) {
if (child.type === 'icon') {
const icon = iconsById.get(child.id);
if (icon) children.push({ type: 'icon', id: icon.id, icon });
} else if (child.type === 'widget') {
const widget = widgetsById.get(child.id);
if (widget) children.push({ type: 'widget', id: widget.id, widget });
}
}
return children;
};
for (const entry of gridOrder.value) {
if (entry.type === 'icon') {
const icon = iconsById.get(entry.id);
if (icon && isInActiveGroup(icon.groupId)) {
if (icon && !icon.folderId && isInActiveGroup(icon.groupId)) {
items.push({ type: 'icon', id: icon.id, icon });
used.add(`icon:${icon.id}`);
}
} else {
} else if (entry.type === 'widget') {
const widget = widgetsById.get(entry.id);
if (widget && isInActiveGroup(widget.groupId)) {
if (widget && !widget.folderId && isInActiveGroup(widget.groupId)) {
items.push({ type: 'widget', id: widget.id, widget });
used.add(`widget:${widget.id}`);
}
} else if (entry.type === 'folder') {
const folder = foldersById.get(entry.id);
if (folder && isInActiveGroup(folder.groupId)) {
items.push({ type: 'folder', id: folder.id, folder, children: buildChildren(folder) });
used.add(`folder:${folder.id}`);
}
}
}
for (const widget of widgetsStore.widgets) {
const key = `widget:${widget.id}`;
if (!used.has(key) && isInActiveGroup(widget.groupId)) {
if (!widget.folderId && !used.has(key) && isInActiveGroup(widget.groupId)) {
items.push({ type: 'widget', id: widget.id, widget });
}
}
for (const icon of layoutStore.icons) {
const key = `icon:${icon.id}`;
if (!used.has(key) && isInActiveGroup(icon.groupId)) {
if (!icon.folderId && !used.has(key) && isInActiveGroup(icon.groupId)) {
items.push({ type: 'icon', id: icon.id, icon });
}
}
for (const folder of foldersStore.folders) {
const key = `folder:${folder.id}`;
if (!used.has(key) && isInActiveGroup(folder.groupId)) {
items.push({ type: 'folder', id: folder.id, folder, children: buildChildren(folder) });
}
}
return items;
});
const resolveFolderChildren = (folderId: string): FolderChild[] => {
const folder = foldersStore.folders.find(f => f.id === folderId);
if (!folder) return [];
const iconsById = new Map(layoutStore.icons.map(icon => [icon.id, icon]));
const widgetsById = new Map(widgetsStore.widgets.map(widget => [widget.id, widget]));
const children: FolderChild[] = [];
for (const child of folder.items) {
if (child.type === 'icon') {
const icon = iconsById.get(child.id);
if (icon) children.push({ type: 'icon', id: icon.id, icon });
} else if (child.type === 'widget') {
const widget = widgetsById.get(child.id);
if (widget) children.push({ type: 'widget', id: widget.id, widget });
}
}
return children;
};
const sizeClassFor = (item: OrderedGridItem) => {
if (item.type === 'widget') return `size-${item.widget.size}`;
if (item.type === 'folder') return `size-${FOLDER_SIZE}`;
return `size-${item.icon.size ?? '1x1'}`;
};
const currentFolder = computed(() => {
if (!openFolderId.value) return null;
return foldersStore.folders.find(folder => folder.id === openFolderId.value) ?? null;
});
const currentFolderChildren = computed(() =>
currentFolder.value ? resolveFolderChildren(currentFolder.value.id) : []
);
const handleClick = (event: MouseEvent) => {
if (suppressClick.value || isDragging.value) return;
const target = event.target as HTMLElement;
@ -240,11 +331,163 @@ const handleContextMenu = (event: MouseEvent) => {
}
const id = itemEl.dataset.id;
const type = itemEl.dataset.type as GridItemType | undefined;
if (id && type) {
if (id && (type === 'icon' || type === 'widget')) {
uiStore.openContextMenu(event.clientX, event.clientY, id, type);
} else {
uiStore.openContextMenu(event.clientX, event.clientY, null, 'desktop');
}
};
const setHoverTarget = (el: HTMLElement | null) => {
if (hoverTargetEl.value === el) return;
if (hoverTargetEl.value) {
hoverTargetEl.value.classList.remove('is-drop-target');
}
hoverTargetEl.value = el;
if (el) {
el.classList.add('is-drop-target');
}
};
const clearHoverTarget = () => setHoverTarget(null);
const findDropTarget = (x: number, y: number, ignoreEl?: HTMLElement | null) => {
if (!gridRef.value) return null;
const elements = Array.from(gridRef.value.querySelectorAll('.grid-item')) as HTMLElement[];
for (const el of elements) {
if (ignoreEl && el === ignoreEl) continue;
const rect = el.getBoundingClientRect();
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
return el;
}
}
return null;
};
const resolveGroupIdByItem = (item: { id: string; type: GridItemType }) => {
if (item.type === 'icon') {
return layoutStore.icons.find(icon => icon.id === item.id)?.groupId ?? activeGroupId.value;
}
if (item.type === 'widget') {
return widgetsStore.widgets.find(widget => widget.id === item.id)?.groupId ?? activeGroupId.value;
}
if (item.type === 'folder') {
return foldersStore.folders.find(folder => folder.id === item.id)?.groupId ?? activeGroupId.value;
}
return activeGroupId.value;
};
const addItemToFolderState = (folderId: string, item: { id: string; type: 'icon' | 'widget' }) => {
const folderGroup = foldersStore.folders.find(f => f.id === folderId)?.groupId ?? activeGroupId.value;
if (item.type === 'icon') {
layoutStore.updateIcon(item.id, { folderId, groupId: folderGroup });
} else {
widgetsStore.updateWidgetFolder(item.id, folderId, folderGroup);
}
foldersStore.addItem(folderId, { id: item.id, type: item.type });
};
const removeItemFromFolderState = (folderId: string, item: { id: string; type: 'icon' | 'widget' }) => {
if (item.type === 'icon') {
layoutStore.updateIcon(item.id, { folderId: undefined });
} else {
widgetsStore.updateWidgetFolder(item.id, undefined);
}
foldersStore.removeItem(folderId, item.id, item.type);
};
const removeEntryFromOrder = (order: GridOrderEntry[], item: { id: string; type: GridItemType }) =>
order.filter(entry => !(entry.id === item.id && entry.type === item.type));
const createFolderFromItems = (
source: { id: string; type: 'icon' | 'widget' },
target: { id: string; type: 'icon' | 'widget' }
) => {
const groupId = resolveGroupIdByItem(target);
const folder = foldersStore.addFolder({
name: '组',
groupId,
items: [
{ id: target.id, type: target.type },
{ id: source.id, type: source.type },
],
});
addItemToFolderState(folder.id, target);
addItemToFolderState(folder.id, source);
const cleaned = removeEntryFromOrder(removeEntryFromOrder(gridOrder.value, source), target);
const targetIndex = gridOrder.value.findIndex(
entry => entry.id === target.id && entry.type === target.type
);
const insertIndex = targetIndex >= 0 ? targetIndex : cleaned.length;
cleaned.splice(insertIndex, 0, { id: folder.id, type: 'folder' });
applyGridOrder(cleaned, true);
refreshLayout(true);
};
const moveItemIntoFolder = (folderId: string, item: { id: string; type: 'icon' | 'widget' }) => {
const folder = foldersStore.folders.find(f => f.id === folderId);
if (!folder) return;
addItemToFolderState(folderId, item);
const nextOrder = removeEntryFromOrder(gridOrder.value, item);
applyGridOrder(nextOrder, true);
refreshLayout(true);
};
const handleMergeDrop = () => {
const dragId = draggingMeta.value.id;
const dragType = draggingMeta.value.type;
if (!dragId || !dragType) return;
const targetEl = hoverTargetEl.value;
clearHoverTarget();
if (!targetEl) return;
const targetId = targetEl.dataset.id;
const targetType = targetEl.dataset.type as GridItemType | undefined;
if (!targetId || !targetType) return;
if (targetId === dragId && targetType === dragType) return;
if (dragType === 'folder') return; // no nesting folders for now
if (targetType === 'folder') {
moveItemIntoFolder(targetId, { id: dragId, type: dragType });
return;
}
if (targetType === 'icon' || targetType === 'widget') {
if (dragType === 'icon' || dragType === 'widget') {
createFolderFromItems({ id: dragId, type: dragType }, { id: targetId, type: targetType });
}
}
};
const openFolder = (folderId: string) => {
openFolderId.value = folderId;
};
const handleRenameFolder = (payload: { id: string; name: string }) => {
foldersStore.renameFolder(payload.id, payload.name);
};
const handleOpenChild = (child: FolderChild) => {
if (child.type === 'icon') {
const targetMode = settingsStore.openInNewTab ? '_blank' : '_self';
window.open(child.icon.url, targetMode);
}
};
const handleRemoveFromFolder = (payload: { folderId: string; child: FolderChild }) => {
const { folderId, child } = payload;
removeItemFromFolderState(folderId, { id: child.id, type: child.type });
let nextOrder = removeEntryFromOrder(gridOrder.value, { id: child.id, type: child.type });
const folderExists = foldersStore.folders.some(f => f.id === folderId);
if (!folderExists) {
nextOrder = removeEntryFromOrder(nextOrder, { id: folderId, type: 'folder' });
}
const folderIndex = nextOrder.findIndex(entry => entry.id === folderId && entry.type === 'folder');
const insertIndex = folderIndex >= 0 ? folderIndex + 1 : nextOrder.length;
nextOrder.splice(insertIndex, 0, { id: child.id, type: child.type });
applyGridOrder(nextOrder, true);
refreshLayout(true);
};
const persistOrderFromGrid = async () => {
if (!grid.value) return;
const items = grid.value.getItems();
@ -253,7 +496,7 @@ const persistOrderFromGrid = async () => {
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')) {
if (id && (type === 'icon' || type === 'widget' || type === 'folder')) {
nextOrder.push({ id, type });
}
}
@ -373,21 +616,36 @@ onMounted(async () => {
layoutEasing,
});
grid.value.on('dragStart', () => {
grid.value.on('dragStart', (item: any) => {
isDragging.value = true;
suppressClick.value = true;
const el = item.getElement() as HTMLElement;
draggingMeta.value = {
id: el?.dataset?.id ?? null,
type: (el?.dataset?.type as GridItemType) ?? null,
};
});
grid.value.on('dragMove', (item: any) => {
const el = item.getElement() as HTMLElement;
const rect = el.getBoundingClientRect();
const target = findDropTarget(rect.left + rect.width / 2, rect.top + rect.height / 2, el);
setHoverTarget(target);
});
grid.value.on('dragEnd', () => {
isDragging.value = false;
handleMergeDrop();
clickBlockUntil.value = Date.now() + 180;
window.setTimeout(() => {
suppressClick.value = false;
}, 0);
draggingMeta.value = { id: null, type: null };
});
grid.value.on('dragReleaseEnd', () => {
persistOrderFromGrid();
clearHoverTarget();
});
grid.value.on('layoutStart', () => {
@ -457,6 +715,15 @@ watch(
}
);
watch(
() => foldersStore.folders.map(folder => ({ id: folder.id, items: folder.items.length, groupId: folder.groupId })),
() => {
ensureOrderConsistency();
refreshLayout(true);
},
{ deep: true }
);
watch(
() => props.activeGroupId,
() => {
@ -498,10 +765,11 @@ onUnmounted(() => {
:key="`${item.type}-${item.id}`"
class="grid-item"
:class="[
`size-${item.type === 'widget' ? item.widget.size : (item.icon.size ?? '1x1')}`,
sizeClassFor(item),
{
'is-resized': resizedKeys.has(`${item.type}:${item.id}`),
'is-entering': enteringKeys.has(`${item.type}:${item.id}`)
'is-entering': enteringKeys.has(`${item.type}:${item.id}`),
'is-drop-target': hoverTargetEl && hoverTargetEl.dataset?.id === item.id
}
]"
:data-id="item.id"
@ -509,10 +777,25 @@ onUnmounted(() => {
>
<div class="grid-item-content">
<WidgetCard v-if="item.type === 'widget'" :widget="item.widget" />
<IconCard v-else :icon="item.icon" />
<IconCard v-else-if="item.type === 'icon'" :icon="item.icon" />
<FolderCard
v-else
:folder="item.folder"
:children="item.children"
@open="openFolder(item.folder.id)"
/>
</div>
</div>
</div>
<FolderDialog
:open="!!currentFolder"
:folder="currentFolder"
:children="currentFolderChildren"
@close="openFolderId = null"
@rename="handleRenameFolder"
@remove="handleRemoveFromFolder"
@open-item="handleOpenChild"
/>
</template>
<style lang="scss" scoped>
@ -592,4 +875,9 @@ onUnmounted(() => {
cursor: grabbing;
transition: none;
}
.grid-item.is-drop-target .grid-item-content {
outline: 2px dashed rgba(0, 122, 255, 0.7);
outline-offset: 4px;
}
</style>