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

@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { Folder } from '@/store/useFoldersStore';
type FolderChild =
| { type: 'icon'; id: string; icon?: { name: string; img?: string; text?: string; bgColor?: string } }
| { type: 'widget'; id: string; widget?: { component: string } };
const props = defineProps<{
folder: Folder;
children: FolderChild[];
}>();
const emit = defineEmits<{
(e: 'open'): void;
}>();
const previewChildren = computed(() => props.children.slice(0, 4));
const initialText = (child: FolderChild) => {
if (child.type === 'icon') {
const label = child.icon?.text?.trim() || child.icon?.name || '';
return label ? label.charAt(0) : 'I';
}
return 'W';
};
</script>
<template>
<div class="folder-card" @click.stop="emit('open')">
<div class="folder-badge">{{ props.folder.name || '组' }}</div>
<div class="folder-preview">
<div
v-for="child in previewChildren"
:key="`${child.type}-${child.id}`"
class="folder-chip"
:data-type="child.type"
:style="child.type === 'icon' && child.icon?.bgColor ? { backgroundColor: child.icon.bgColor } : {}"
>
<img v-if="child.type === 'icon' && child.icon?.img" :src="child.icon.img" :alt="child.icon?.name" />
<span v-else>{{ initialText(child) }}</span>
</div>
<div v-if="!previewChildren.length" class="folder-empty"></div>
</div>
</div>
</template>
<style scoped lang="scss">
@import '@/styles/tokens.scss';
.folder-card {
width: 100%;
height: 100%;
border-radius: var(--icon-radius, #{$border-radius-small});
background: linear-gradient(145deg, rgba(0, 122, 255, 0.12), rgba(0, 122, 255, 0.04));
position: relative;
padding: 10px;
box-sizing: border-box;
cursor: pointer;
transition: transform $motion-duration-sm $motion-easing-standard, box-shadow $motion-duration-sm $motion-easing-standard;
box-shadow: $shadow-md;
&:hover {
transform: translateY(-2px);
box-shadow: $shadow-lg;
}
}
.folder-badge {
position: absolute;
top: 8px;
right: 10px;
background: rgba(0, 122, 255, 0.9);
color: #fff;
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
font-weight: 600;
pointer-events: none;
}
.folder-preview {
width: 100%;
height: 100%;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 6px;
padding-top: 6px;
box-sizing: border-box;
}
.folder-chip {
border-radius: 8px;
background: rgba(255, 255, 255, 0.85);
color: #222;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
overflow: hidden;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.05);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
&[data-type='widget'] {
background: rgba(0, 0, 0, 0.06);
}
}
.folder-empty {
grid-column: 1 / -1;
border-radius: 8px;
border: 1px dashed rgba(0, 0, 0, 0.15);
color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
}
</style>

View File

@ -0,0 +1,268 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { Folder } from '@/store/useFoldersStore';
type FolderChild =
| { type: 'icon'; id: string; icon?: { name: string; img?: string; text?: string; bgColor?: string; url?: string } }
| { type: 'widget'; id: string; widget?: { component: string } };
const props = defineProps<{
open: boolean;
folder: Folder | null;
children: FolderChild[];
}>();
const emit = defineEmits<{
(e: 'close'): void;
(e: 'rename', payload: { id: string; name: string }): void;
(e: 'remove', payload: { folderId: string; child: FolderChild }): void;
(e: 'open-item', payload: FolderChild): void;
}>();
const localName = ref('组');
watch(
() => props.folder?.name,
name => {
localName.value = name ?? '组';
},
{ immediate: true }
);
const handleRename = () => {
if (!props.folder) return;
const name = localName.value.trim() || '组';
emit('rename', { id: props.folder.id, name });
};
const badgeLabel = computed(() => props.folder?.name ?? '组');
const initialText = (child: FolderChild) => {
if (child.type === 'icon') {
const label = child.icon?.text?.trim() || child.icon?.name || '';
return label ? label.charAt(0) : 'I';
}
return 'W';
};
</script>
<template>
<teleport to="body">
<div v-if="open && folder" class="folder-dialog-overlay" @click.self="emit('close')">
<div class="folder-dialog">
<header class="folder-dialog__header">
<input
v-model="localName"
class="folder-name-input"
@keyup.enter="handleRename"
@blur="handleRename"
/>
<span class="folder-badge">{{ badgeLabel }}</span>
<button class="close-btn" type="button" @click="emit('close')">×</button>
</header>
<div class="folder-dialog__body">
<div
v-for="child in children"
:key="`${child.type}-${child.id}`"
class="folder-row"
>
<div
class="row-thumb"
:data-type="child.type"
:style="child.type === 'icon' && child.icon?.bgColor ? { backgroundColor: child.icon.bgColor } : {}"
>
<img v-if="child.type === 'icon' && child.icon?.img" :src="child.icon.img" :alt="child.icon?.name" />
<span v-else>{{ initialText(child) }}</span>
</div>
<div class="row-title">
<div class="row-name">
{{ child.type === 'icon' ? child.icon?.name : child.widget?.component || 'Widget' }}
</div>
<div class="row-sub" v-if="child.type === 'icon' && child.icon?.url">{{ child.icon.url }}</div>
</div>
<div class="row-actions">
<button type="button" class="link-btn" @click="emit('open-item', child)">打开</button>
<button
type="button"
class="link-btn danger"
@click="emit('remove', { folderId: folder.id, child })"
>
移出
</button>
</div>
</div>
<div v-if="!children.length" class="folder-empty">还没有内容</div>
</div>
</div>
</div>
</teleport>
</template>
<style scoped lang="scss">
@import '@/styles/tokens.scss';
.folder-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: $z-index-menu;
}
.folder-dialog {
width: min(520px, 90vw);
max-height: 80vh;
background: #fff;
border-radius: 16px;
box-shadow: 0 20px 80px rgba(0, 0, 0, 0.18);
overflow: hidden;
display: flex;
flex-direction: column;
}
.folder-dialog__header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 18px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.folder-name-input {
flex: 1;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 10px;
padding: 8px 10px;
font-size: 15px;
outline: none;
transition: border-color $motion-duration-sm $motion-easing-standard, box-shadow $motion-duration-sm $motion-easing-standard;
&:focus {
border-color: #007aff;
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.15);
}
}
.folder-badge {
background: rgba(0, 122, 255, 0.12);
color: #0a63d1;
border-radius: 999px;
padding: 6px 12px;
font-weight: 600;
font-size: 13px;
}
.close-btn {
background: transparent;
border: none;
font-size: 20px;
color: #666;
cursor: pointer;
line-height: 1;
}
.folder-dialog__body {
padding: 12px 18px 18px;
overflow: auto;
max-height: 60vh;
display: flex;
flex-direction: column;
gap: 10px;
}
.folder-row {
display: grid;
grid-template-columns: 54px 1fr auto;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 12px;
background: rgba(0, 0, 0, 0.02);
}
.row-thumb {
width: 54px;
height: 54px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.06);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 18px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
&[data-type='widget'] {
background: rgba(0, 0, 0, 0.08);
}
}
.row-title {
min-width: 0;
}
.row-name {
font-weight: 600;
color: #1f1f1f;
font-size: 15px;
line-height: 1.3;
}
.row-sub {
font-size: 12px;
color: #666;
margin-top: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-actions {
display: flex;
align-items: center;
gap: 8px;
}
.link-btn {
background: rgba(0, 0, 0, 0.05);
border: none;
border-radius: 8px;
padding: 6px 10px;
cursor: pointer;
font-size: 13px;
color: #222;
transition: background $motion-duration-sm $motion-easing-standard, transform $motion-duration-sm $motion-easing-standard;
&:hover {
background: rgba(0, 122, 255, 0.12);
transform: translateY(-1px);
}
&.danger {
color: #c0392b;
background: rgba(192, 57, 43, 0.08);
&:hover {
background: rgba(192, 57, 43, 0.15);
}
}
}
.folder-empty {
text-align: center;
padding: 26px 12px;
color: #666;
border: 1px dashed rgba(0, 0, 0, 0.08);
border-radius: 12px;
}
</style>

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>

View File

@ -0,0 +1,111 @@
import { defineStore, acceptHMRUpdate } from 'pinia';
type FolderItemType = 'icon' | 'widget';
export interface FolderItem {
id: string;
type: FolderItemType;
}
export interface Folder {
id: string;
name: string;
items: FolderItem[];
groupId?: string;
}
interface FolderState {
folders: Folder[];
}
const DEFAULT_GROUP_ID = 'home';
const STORAGE_KEY = 'itab_folders';
const normalizeFolder = (folder: Folder): Folder => ({
id: folder.id,
name: folder.name || '组',
groupId: folder.groupId ?? DEFAULT_GROUP_ID,
items: Array.isArray(folder.items)
? folder.items
.filter(item => item && typeof item.id === 'string' && (item.type === 'icon' || item.type === 'widget'))
.map(item => ({ id: item.id, type: item.type }))
: []
});
const loadFolders = (): Folder[] => {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as Folder[];
if (!Array.isArray(parsed)) return [];
return parsed.map(normalizeFolder);
} catch {
return [];
}
};
export const useFoldersStore = defineStore('folders', {
state: (): FolderState => ({
folders: loadFolders(),
}),
actions: {
persist() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.folders));
},
addFolder(payload: { name?: string; items?: FolderItem[]; groupId?: string }) {
const nextId = `folder-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const folder: Folder = normalizeFolder({
id: nextId,
name: payload.name || '组',
items: payload.items ?? [],
groupId: payload.groupId ?? DEFAULT_GROUP_ID,
});
this.folders.push(folder);
this.persist();
return folder;
},
renameFolder(folderId: string, name: string) {
const folder = this.folders.find(item => item.id === folderId);
if (!folder) return;
folder.name = name || '组';
this.persist();
},
addItem(folderId: string, item: FolderItem) {
const folder = this.folders.find(f => f.id === folderId);
if (!folder) return;
const exists = folder.items.some(it => it.id === item.id && it.type === item.type);
if (!exists) {
folder.items.push({ id: item.id, type: item.type });
this.persist();
}
},
removeItem(folderId: string, itemId: string, itemType: FolderItemType) {
const folder = this.folders.find(f => f.id === folderId);
if (!folder) return;
folder.items = folder.items.filter(item => !(item.id === itemId && item.type === itemType));
if (!folder.items.length) {
this.deleteFolder(folderId);
} else {
this.persist();
}
},
deleteFolder(folderId: string) {
const idx = this.folders.findIndex(f => f.id === folderId);
if (idx !== -1) {
this.folders.splice(idx, 1);
this.persist();
}
},
clearEmptyFolders() {
const before = this.folders.length;
this.folders = this.folders.filter(folder => folder.items.length > 0);
if (this.folders.length !== before) {
this.persist();
}
},
}
});
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useFoldersStore, import.meta.hot));
}

View File

@ -1,18 +1,18 @@
import { defineStore, acceptHMRUpdate } from 'pinia';
import { defineStore, acceptHMRUpdate } from 'pinia';
type IconSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
const DEFAULT_GROUP_ID = 'home';
// 模拟数据(来自截图参考)
interface Icon {
id: string;
name: string;
url: string;
size?: IconSize;
img?: string; // 可选:用于图片图标(如徽标)
text?: string; // 可选:用于文字图标
bgColor?: string; // 可选:纯色背景
img?: string;
text?: string;
bgColor?: string;
groupId?: string;
folderId?: string;
}
interface DragState {
@ -32,21 +32,21 @@ interface LayoutState {
const defaultIcons: Icon[] = [
{ id: '1', name: '淘宝', url: 'https://taobao.com', bgColor: '#ff4f00' },
{ id: '2', name: '京东商城', url: 'https://jd.com', bgColor: '#e4393c' },
{ id: '2', name: '京东', url: 'https://jd.com', bgColor: '#e4393c' },
{ id: '3', name: '百度', url: 'https://baidu.com', bgColor: '#3388ff' },
{ id: '4', name: '备忘录', url: '#', bgColor: '#f9ca24' },
{ id: '4', name: '电影', url: '#', bgColor: '#f9ca24' },
{ id: '5', name: '爱奇艺', url: 'https://iqiyi.com', bgColor: '#00be06' },
{ id: '6', name: '文件夹', url: '#', bgColor: '#4285f4' },
{ id: '7', name: '抖音', url: 'https://douyin.com', bgColor: '#222' },
{ id: '8', name: '小浣熊', url: '#', bgColor: '#f0932b' },
{ id: '8', name: '微博', url: '#', bgColor: '#f0932b' },
{ id: '9', name: 'AiPPT', url: '#', bgColor: '#d63031' },
{ id: '10', name: '电影日历', url: '#', bgColor: 'transparent', img: 'https://example.com/movie_poster.png' }, // 图片示例
{ id: '11', name: '稿定设计', url: '#', bgColor: '#00aaff' },
{ id: '12', name: '壁纸', url: '#', bgColor: '#1dd1a1' },
{ id: '13', 'name': '即梦AI', url: '#', bgColor: '#6c5ce7' },
{ id: '14', name: '码上掘金', url: '#', bgColor: '#1e80ff' },
{ id: '15', name: '展管理', url: '#', bgColor: '#7f8c8d' },
{ id: '16', name: '签管理', url: '#', bgColor: '#f1c40f' },
{ id: '10', name: '电影日历', url: '#', bgColor: 'transparent', img: 'https://example.com/movie_poster.png' },
{ id: '11', name: '产品设计', url: '#', bgColor: '#00aaff' },
{ id: '12', name: '插画', url: '#', bgColor: '#1dd1a1' },
{ id: '13', name: '科普AI', url: '#', bgColor: '#6c5ce7' },
{ id: '14', name: '代码助手', url: '#', bgColor: '#1e80ff' },
{ id: '15', name: '展管理', url: '#', bgColor: '#7f8c8d' },
{ id: '16', name: '签管理', url: '#', bgColor: '#f1c40f' },
];
const defaultGroupById: Record<string, string> = {
@ -75,6 +75,7 @@ const normalizeIcons = (icons: Icon[]): Icon[] =>
...icon,
size: icon.size ?? '1x1',
groupId: icon.groupId ?? defaultGroupById[icon.id] ?? DEFAULT_GROUP_ID,
folderId: icon.folderId,
}));
const loadIcons = (): Icon[] => {
@ -116,12 +117,9 @@ export const useLayoutStore = defineStore('layout', {
const targetIndex = this.icons.findIndex(p => p.id === targetId);
if (draggedIndex !== -1 && targetIndex !== -1) {
// 直接交换
const draggedItem = this.icons[draggedIndex];
this.icons[draggedIndex] = this.icons[targetIndex];
this.icons[targetIndex] = draggedItem;
// 持久化顺序
localStorage.setItem('itab_icons', JSON.stringify(this.icons));
}
},
@ -154,7 +152,6 @@ export const useLayoutStore = defineStore('layout', {
const index = this.icons.findIndex(p => p.id === itemId);
if (index !== -1) {
this.icons.splice(index, 1);
// 持久化顺序
localStorage.setItem('itab_icons', JSON.stringify(this.icons));
}
},

View File

@ -1,15 +1,16 @@
import { defineStore, acceptHMRUpdate } from 'pinia';
import { defineStore, acceptHMRUpdate } from 'pinia';
// 组件数据结构
// 缁勪欢鏁版嵁缁撴瀯
type WidgetSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
const DEFAULT_GROUP_ID = 'home';
interface Widget {
id: string;
component: string; // 渲染的组件名
size: WidgetSize; // 组件尺寸
gridPosition: { x: number; y: number }; // 网格位置
data?: any; // 组件数据
component: string; // 娓叉煋鐨勭粍浠跺悕
size: WidgetSize; // 缁勪欢灏哄
gridPosition: { x: number; y: number }; // 缃戞牸浣嶇疆
data?: any; // 缁勪欢鏁版嵁
groupId?: string;
folderId?: string;
}
interface WidgetsState {
@ -29,12 +30,12 @@ const defaultWidgets: Widget[] = [
size: '2x1',
gridPosition: { x: 1, y: 0 },
data: {
tabs: ['百度', '微博', '抖音'],
tabs: ['热点', '新闻', '视频'],
items: [
{ title: '台确认“马茅”包装少写一撇', value: '780.9万' },
{ title: '双休不应成为奢侈品', value: '771.2万' },
{ title: '突破8100亿元这场双向奔赴很燃', value: '761.8万' },
{ title: '第一个2万亿经济大区要来了', value: '752.2万' }
{ title: '台确认新品发售', value: '780.9万' },
{ title: '网友热议某话题', value: '771.2万' },
{ title: '新机性能实测数据', value: '761.8万' },
{ title: '首个万亿经济区新闻', value: '752.2万' }
]
}
},
@ -69,7 +70,7 @@ const loadWidgets = (): Widget[] => {
}
};
// 模拟数据(来自截图参考)
// 妯℃嫙鏁版嵁锛堟潵鑷埅鍥惧弬鑰冿級
export const useWidgetsStore = defineStore('widgets', {
state: (): WidgetsState => ({
widgets: loadWidgets(),
@ -119,6 +120,15 @@ export const useWidgetsStore = defineStore('widgets', {
localStorage.setItem('itab_widgets', JSON.stringify(this.widgets));
}
},
updateWidgetFolder(widgetId: string, folderId?: string, groupId?: string) {
const widget = this.widgets.find(w => w.id === widgetId);
if (!widget) return;
widget.folderId = folderId;
if (groupId) {
widget.groupId = groupId;
}
localStorage.setItem('itab_widgets', JSON.stringify(this.widgets));
},
setWidgetOrder(orderIds: string[]) {
const ordered: Widget[] = [];
const seen = new Set<string>();
@ -143,3 +153,4 @@ export const useWidgetsStore = defineStore('widgets', {
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useWidgetsStore, import.meta.hot));
}