1
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 329 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 782 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 788 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 550 KiB |
|
Before Width: | Height: | Size: 669 KiB |
|
Before Width: | Height: | Size: 289 KiB |
126
app/src/components/FolderCard/index.vue
Normal 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>
|
||||||
268
app/src/components/FolderDialog/index.vue
Normal 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>
|
||||||
@ -3,12 +3,15 @@ import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
|
|||||||
import Muuri from 'muuri';
|
import Muuri from 'muuri';
|
||||||
import { useLayoutStore } from '@/store/useLayoutStore';
|
import { useLayoutStore } from '@/store/useLayoutStore';
|
||||||
import { useWidgetsStore } from '@/store/useWidgetsStore';
|
import { useWidgetsStore } from '@/store/useWidgetsStore';
|
||||||
|
import { useFoldersStore, type Folder, type FolderItem } from '@/store/useFoldersStore';
|
||||||
import { useUIStore } from '@/store/useUIStore';
|
import { useUIStore } from '@/store/useUIStore';
|
||||||
import { useSettingsStore } from '@/store/useSettingsStore';
|
import { useSettingsStore } from '@/store/useSettingsStore';
|
||||||
import IconCard from '@/components/IconCard/index.vue';
|
import IconCard from '@/components/IconCard/index.vue';
|
||||||
import WidgetCard from '@/components/WidgetCard/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';
|
type GridItemSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
|
||||||
|
|
||||||
interface Icon {
|
interface Icon {
|
||||||
@ -20,6 +23,7 @@ interface Icon {
|
|||||||
bgColor?: string;
|
bgColor?: string;
|
||||||
size?: GridItemSize;
|
size?: GridItemSize;
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
|
folderId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type WidgetSize = GridItemSize;
|
type WidgetSize = GridItemSize;
|
||||||
@ -29,8 +33,13 @@ interface Widget {
|
|||||||
size: WidgetSize;
|
size: WidgetSize;
|
||||||
data?: any;
|
data?: any;
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
|
folderId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FolderChild =
|
||||||
|
| { type: 'icon'; id: string; icon: Icon }
|
||||||
|
| { type: 'widget'; id: string; widget: Widget };
|
||||||
|
|
||||||
interface GridOrderEntry {
|
interface GridOrderEntry {
|
||||||
id: string;
|
id: string;
|
||||||
type: GridItemType;
|
type: GridItemType;
|
||||||
@ -38,10 +47,12 @@ interface GridOrderEntry {
|
|||||||
|
|
||||||
type OrderedGridItem =
|
type OrderedGridItem =
|
||||||
| { type: 'icon'; id: string; icon: Icon }
|
| { 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 GRID_ORDER_STORAGE_KEY = 'itab_grid_order';
|
||||||
const DEFAULT_GROUP_ID = 'home';
|
const DEFAULT_GROUP_ID = 'home';
|
||||||
|
const FOLDER_SIZE: GridItemSize = '2x2';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
activeGroupId?: string | null;
|
activeGroupId?: string | null;
|
||||||
@ -49,6 +60,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const layoutStore = useLayoutStore();
|
const layoutStore = useLayoutStore();
|
||||||
const widgetsStore = useWidgetsStore();
|
const widgetsStore = useWidgetsStore();
|
||||||
|
const foldersStore = useFoldersStore();
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
const gridRef = ref<HTMLElement | null>(null);
|
const gridRef = ref<HTMLElement | null>(null);
|
||||||
@ -64,6 +76,12 @@ const iconSizeMap = new Map<string, GridItemSize>();
|
|||||||
const enteringKeys = ref(new Set<string>());
|
const enteringKeys = ref(new Set<string>());
|
||||||
const previousIconIds = ref(new Set<string>());
|
const previousIconIds = ref(new Set<string>());
|
||||||
const previousWidgetIds = 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 layoutAnimationMs = 240;
|
||||||
const layoutEasing = 'cubic-bezier(0.4, 0, 0.2, 1)';
|
const layoutEasing = 'cubic-bezier(0.4, 0, 0.2, 1)';
|
||||||
const normalizeGroupId = (groupId?: string | null) => groupId ?? DEFAULT_GROUP_ID;
|
const normalizeGroupId = (groupId?: string | null) => groupId ?? DEFAULT_GROUP_ID;
|
||||||
@ -74,7 +92,7 @@ const normalizeGridOrder = (order: GridOrderEntry[]) => {
|
|||||||
const normalized: GridOrderEntry[] = [];
|
const normalized: GridOrderEntry[] = [];
|
||||||
for (const entry of order) {
|
for (const entry of order) {
|
||||||
if (!entry || typeof entry.id !== 'string') continue;
|
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}`;
|
const key = `${entry.type}:${entry.id}`;
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
@ -84,8 +102,9 @@ const normalizeGridOrder = (order: GridOrderEntry[]) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buildDefaultOrder = (): GridOrderEntry[] => [
|
const buildDefaultOrder = (): GridOrderEntry[] => [
|
||||||
...widgetsStore.widgets.map(widget => ({ id: widget.id, type: 'widget' as const })),
|
...widgetsStore.widgets.filter(widget => !widget.folderId).map(widget => ({ id: widget.id, type: 'widget' as const })),
|
||||||
...layoutStore.icons.map(icon => ({ id: icon.id, type: 'icon' 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) => {
|
const applyGridOrder = (order: GridOrderEntry[], syncStoreOrder = true) => {
|
||||||
@ -121,8 +140,9 @@ const loadGridOrder = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ensureOrderConsistency = () => {
|
const ensureOrderConsistency = () => {
|
||||||
const iconIds = new Set(layoutStore.icons.map(icon => icon.id));
|
const iconIds = new Set(layoutStore.icons.filter(icon => !icon.folderId).map(icon => icon.id));
|
||||||
const widgetIds = new Set(widgetsStore.widgets.map(widget => widget.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 seen = new Set<string>();
|
||||||
const nextOrder: GridOrderEntry[] = [];
|
const nextOrder: GridOrderEntry[] = [];
|
||||||
|
|
||||||
@ -140,22 +160,32 @@ const ensureOrderConsistency = () => {
|
|||||||
if (entry.type === 'widget' && widgetIds.has(entry.id)) {
|
if (entry.type === 'widget' && widgetIds.has(entry.id)) {
|
||||||
pushEntry(entry);
|
pushEntry(entry);
|
||||||
}
|
}
|
||||||
|
if (entry.type === 'folder' && folderIds.has(entry.id)) {
|
||||||
|
pushEntry(entry);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const widget of widgetsStore.widgets) {
|
for (const widget of widgetsStore.widgets) {
|
||||||
const key = `widget:${widget.id}`;
|
const key = `widget:${widget.id}`;
|
||||||
if (!seen.has(key)) {
|
if (!widget.folderId && !seen.has(key)) {
|
||||||
pushEntry({ id: widget.id, type: 'widget' });
|
pushEntry({ id: widget.id, type: 'widget' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const icon of layoutStore.icons) {
|
for (const icon of layoutStore.icons) {
|
||||||
const key = `icon:${icon.id}`;
|
const key = `icon:${icon.id}`;
|
||||||
if (!seen.has(key)) {
|
if (!icon.folderId && !seen.has(key)) {
|
||||||
pushEntry({ id: icon.id, type: 'icon' });
|
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 =
|
const orderChanged =
|
||||||
nextOrder.length !== gridOrder.value.length ||
|
nextOrder.length !== gridOrder.value.length ||
|
||||||
nextOrder.some((entry, index) => {
|
nextOrder.some((entry, index) => {
|
||||||
@ -176,42 +206,103 @@ const isInActiveGroup = (groupId?: string | null) =>
|
|||||||
const orderedItems = computed<OrderedGridItem[]>(() => {
|
const orderedItems = computed<OrderedGridItem[]>(() => {
|
||||||
const iconsById = new Map(layoutStore.icons.map(icon => [icon.id, icon]));
|
const iconsById = new Map(layoutStore.icons.map(icon => [icon.id, icon]));
|
||||||
const widgetsById = new Map(widgetsStore.widgets.map(widget => [widget.id, widget]));
|
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 used = new Set<string>();
|
||||||
const items: OrderedGridItem[] = [];
|
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) {
|
for (const entry of gridOrder.value) {
|
||||||
if (entry.type === 'icon') {
|
if (entry.type === 'icon') {
|
||||||
const icon = iconsById.get(entry.id);
|
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 });
|
items.push({ type: 'icon', id: icon.id, icon });
|
||||||
used.add(`icon:${icon.id}`);
|
used.add(`icon:${icon.id}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else if (entry.type === 'widget') {
|
||||||
const widget = widgetsById.get(entry.id);
|
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 });
|
items.push({ type: 'widget', id: widget.id, widget });
|
||||||
used.add(`widget:${widget.id}`);
|
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) {
|
for (const widget of widgetsStore.widgets) {
|
||||||
const key = `widget:${widget.id}`;
|
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 });
|
items.push({ type: 'widget', id: widget.id, widget });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const icon of layoutStore.icons) {
|
for (const icon of layoutStore.icons) {
|
||||||
const key = `icon:${icon.id}`;
|
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 });
|
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;
|
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) => {
|
const handleClick = (event: MouseEvent) => {
|
||||||
if (suppressClick.value || isDragging.value) return;
|
if (suppressClick.value || isDragging.value) return;
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
@ -240,11 +331,163 @@ const handleContextMenu = (event: MouseEvent) => {
|
|||||||
}
|
}
|
||||||
const id = itemEl.dataset.id;
|
const id = itemEl.dataset.id;
|
||||||
const type = itemEl.dataset.type as GridItemType | undefined;
|
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);
|
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 () => {
|
const persistOrderFromGrid = async () => {
|
||||||
if (!grid.value) return;
|
if (!grid.value) return;
|
||||||
const items = grid.value.getItems();
|
const items = grid.value.getItems();
|
||||||
@ -253,7 +496,7 @@ const persistOrderFromGrid = async () => {
|
|||||||
const element = item.getElement() as HTMLElement;
|
const element = item.getElement() as HTMLElement;
|
||||||
const id = element.dataset.id;
|
const id = element.dataset.id;
|
||||||
const type = element.dataset.type as GridItemType | undefined;
|
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 });
|
nextOrder.push({ id, type });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -373,21 +616,36 @@ onMounted(async () => {
|
|||||||
layoutEasing,
|
layoutEasing,
|
||||||
});
|
});
|
||||||
|
|
||||||
grid.value.on('dragStart', () => {
|
grid.value.on('dragStart', (item: any) => {
|
||||||
isDragging.value = true;
|
isDragging.value = true;
|
||||||
suppressClick.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', () => {
|
grid.value.on('dragEnd', () => {
|
||||||
isDragging.value = false;
|
isDragging.value = false;
|
||||||
|
handleMergeDrop();
|
||||||
clickBlockUntil.value = Date.now() + 180;
|
clickBlockUntil.value = Date.now() + 180;
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
suppressClick.value = false;
|
suppressClick.value = false;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
draggingMeta.value = { id: null, type: null };
|
||||||
});
|
});
|
||||||
|
|
||||||
grid.value.on('dragReleaseEnd', () => {
|
grid.value.on('dragReleaseEnd', () => {
|
||||||
persistOrderFromGrid();
|
persistOrderFromGrid();
|
||||||
|
clearHoverTarget();
|
||||||
});
|
});
|
||||||
|
|
||||||
grid.value.on('layoutStart', () => {
|
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(
|
watch(
|
||||||
() => props.activeGroupId,
|
() => props.activeGroupId,
|
||||||
() => {
|
() => {
|
||||||
@ -498,10 +765,11 @@ onUnmounted(() => {
|
|||||||
:key="`${item.type}-${item.id}`"
|
:key="`${item.type}-${item.id}`"
|
||||||
class="grid-item"
|
class="grid-item"
|
||||||
:class="[
|
:class="[
|
||||||
`size-${item.type === 'widget' ? item.widget.size : (item.icon.size ?? '1x1')}`,
|
sizeClassFor(item),
|
||||||
{
|
{
|
||||||
'is-resized': resizedKeys.has(`${item.type}:${item.id}`),
|
'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"
|
:data-id="item.id"
|
||||||
@ -509,10 +777,25 @@ onUnmounted(() => {
|
|||||||
>
|
>
|
||||||
<div class="grid-item-content">
|
<div class="grid-item-content">
|
||||||
<WidgetCard v-if="item.type === 'widget'" :widget="item.widget" />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<FolderDialog
|
||||||
|
:open="!!currentFolder"
|
||||||
|
:folder="currentFolder"
|
||||||
|
:children="currentFolderChildren"
|
||||||
|
@close="openFolderId = null"
|
||||||
|
@rename="handleRenameFolder"
|
||||||
|
@remove="handleRemoveFromFolder"
|
||||||
|
@open-item="handleOpenChild"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@ -592,4 +875,9 @@ onUnmounted(() => {
|
|||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.grid-item.is-drop-target .grid-item-content {
|
||||||
|
outline: 2px dashed rgba(0, 122, 255, 0.7);
|
||||||
|
outline-offset: 4px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
111
app/src/store/useFoldersStore.ts
Normal 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));
|
||||||
|
}
|
||||||
@ -1,18 +1,18 @@
|
|||||||
import { defineStore, acceptHMRUpdate } from 'pinia';
|
import { defineStore, acceptHMRUpdate } from 'pinia';
|
||||||
|
|
||||||
type IconSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
|
type IconSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
|
||||||
const DEFAULT_GROUP_ID = 'home';
|
const DEFAULT_GROUP_ID = 'home';
|
||||||
|
|
||||||
// 模拟数据(来自截图参考)
|
|
||||||
interface Icon {
|
interface Icon {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
size?: IconSize;
|
size?: IconSize;
|
||||||
img?: string; // 可选:用于图片图标(如徽标)
|
img?: string;
|
||||||
text?: string; // 可选:用于文字图标
|
text?: string;
|
||||||
bgColor?: string; // 可选:纯色背景
|
bgColor?: string;
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
|
folderId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DragState {
|
interface DragState {
|
||||||
@ -32,21 +32,21 @@ interface LayoutState {
|
|||||||
|
|
||||||
const defaultIcons: Icon[] = [
|
const defaultIcons: Icon[] = [
|
||||||
{ id: '1', name: '淘宝', url: 'https://taobao.com', bgColor: '#ff4f00' },
|
{ 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: '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: '5', name: '爱奇艺', url: 'https://iqiyi.com', bgColor: '#00be06' },
|
||||||
{ id: '6', name: '文件夹', url: '#', bgColor: '#4285f4' },
|
{ id: '6', name: '文件夹', url: '#', bgColor: '#4285f4' },
|
||||||
{ id: '7', name: '抖音', url: 'https://douyin.com', bgColor: '#222' },
|
{ 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: '9', name: 'AiPPT', url: '#', bgColor: '#d63031' },
|
||||||
{ id: '10', name: '电影日历', url: '#', bgColor: 'transparent', img: 'https://example.com/movie_poster.png' }, // 图片示例
|
{ id: '10', name: '电影日历', url: '#', bgColor: 'transparent', img: 'https://example.com/movie_poster.png' },
|
||||||
{ id: '11', name: '稿定设计', url: '#', bgColor: '#00aaff' },
|
{ id: '11', name: '产品设计', url: '#', bgColor: '#00aaff' },
|
||||||
{ id: '12', name: '壁纸', url: '#', bgColor: '#1dd1a1' },
|
{ id: '12', name: '插画', url: '#', bgColor: '#1dd1a1' },
|
||||||
{ id: '13', 'name': '即梦AI', url: '#', bgColor: '#6c5ce7' },
|
{ id: '13', name: '科普AI', url: '#', bgColor: '#6c5ce7' },
|
||||||
{ id: '14', name: '码上掘金', url: '#', bgColor: '#1e80ff' },
|
{ id: '14', name: '代码助手', url: '#', bgColor: '#1e80ff' },
|
||||||
{ id: '15', name: '扩展管理', url: '#', bgColor: '#7f8c8d' },
|
{ id: '15', name: '拓展管理', url: '#', bgColor: '#7f8c8d' },
|
||||||
{ id: '16', name: '书签管理', url: '#', bgColor: '#f1c40f' },
|
{ id: '16', name: '签名管理', url: '#', bgColor: '#f1c40f' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const defaultGroupById: Record<string, string> = {
|
const defaultGroupById: Record<string, string> = {
|
||||||
@ -75,6 +75,7 @@ const normalizeIcons = (icons: Icon[]): Icon[] =>
|
|||||||
...icon,
|
...icon,
|
||||||
size: icon.size ?? '1x1',
|
size: icon.size ?? '1x1',
|
||||||
groupId: icon.groupId ?? defaultGroupById[icon.id] ?? DEFAULT_GROUP_ID,
|
groupId: icon.groupId ?? defaultGroupById[icon.id] ?? DEFAULT_GROUP_ID,
|
||||||
|
folderId: icon.folderId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const loadIcons = (): Icon[] => {
|
const loadIcons = (): Icon[] => {
|
||||||
@ -116,12 +117,9 @@ export const useLayoutStore = defineStore('layout', {
|
|||||||
const targetIndex = this.icons.findIndex(p => p.id === targetId);
|
const targetIndex = this.icons.findIndex(p => p.id === targetId);
|
||||||
|
|
||||||
if (draggedIndex !== -1 && targetIndex !== -1) {
|
if (draggedIndex !== -1 && targetIndex !== -1) {
|
||||||
// 直接交换
|
|
||||||
const draggedItem = this.icons[draggedIndex];
|
const draggedItem = this.icons[draggedIndex];
|
||||||
this.icons[draggedIndex] = this.icons[targetIndex];
|
this.icons[draggedIndex] = this.icons[targetIndex];
|
||||||
this.icons[targetIndex] = draggedItem;
|
this.icons[targetIndex] = draggedItem;
|
||||||
|
|
||||||
// 持久化顺序
|
|
||||||
localStorage.setItem('itab_icons', JSON.stringify(this.icons));
|
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);
|
const index = this.icons.findIndex(p => p.id === itemId);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
this.icons.splice(index, 1);
|
this.icons.splice(index, 1);
|
||||||
// 持久化顺序
|
|
||||||
localStorage.setItem('itab_icons', JSON.stringify(this.icons));
|
localStorage.setItem('itab_icons', JSON.stringify(this.icons));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,15 +1,16 @@
|
|||||||
import { defineStore, acceptHMRUpdate } from 'pinia';
|
import { defineStore, acceptHMRUpdate } from 'pinia';
|
||||||
|
|
||||||
// 组件数据结构
|
// 缁勪欢鏁版嵁缁撴瀯
|
||||||
type WidgetSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
|
type WidgetSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
|
||||||
const DEFAULT_GROUP_ID = 'home';
|
const DEFAULT_GROUP_ID = 'home';
|
||||||
interface Widget {
|
interface Widget {
|
||||||
id: string;
|
id: string;
|
||||||
component: string; // 渲染的组件名
|
component: string; // 娓叉煋鐨勭粍浠跺悕
|
||||||
size: WidgetSize; // 组件尺寸
|
size: WidgetSize; // 缁勪欢灏哄
|
||||||
gridPosition: { x: number; y: number }; // 网格位置
|
gridPosition: { x: number; y: number }; // 缃戞牸浣嶇疆
|
||||||
data?: any; // 组件数据
|
data?: any; // 缁勪欢鏁版嵁
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
|
folderId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WidgetsState {
|
interface WidgetsState {
|
||||||
@ -29,12 +30,12 @@ const defaultWidgets: Widget[] = [
|
|||||||
size: '2x1',
|
size: '2x1',
|
||||||
gridPosition: { x: 1, y: 0 },
|
gridPosition: { x: 1, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
tabs: ['百度', '微博', '抖音'],
|
tabs: ['热点', '新闻', '视频'],
|
||||||
items: [
|
items: [
|
||||||
{ title: '茅台确认“马茅”包装少写一撇', value: '780.9万' },
|
{ title: '平台确认新品发售', value: '780.9万' },
|
||||||
{ title: '双休不应成为奢侈品', value: '771.2万' },
|
{ title: '网友热议某话题', value: '771.2万' },
|
||||||
{ title: '突破8100亿元这场双向奔赴很燃', value: '761.8万' },
|
{ title: '新机性能实测数据', value: '761.8万' },
|
||||||
{ title: '第一个2万亿经济大区要来了', value: '752.2万' }
|
{ title: '首个万亿级经济区新闻', value: '752.2万' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -69,7 +70,7 @@ const loadWidgets = (): Widget[] => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 模拟数据(来自截图参考)
|
// 妯℃嫙鏁版嵁锛堟潵鑷埅鍥惧弬鑰冿級
|
||||||
export const useWidgetsStore = defineStore('widgets', {
|
export const useWidgetsStore = defineStore('widgets', {
|
||||||
state: (): WidgetsState => ({
|
state: (): WidgetsState => ({
|
||||||
widgets: loadWidgets(),
|
widgets: loadWidgets(),
|
||||||
@ -119,6 +120,15 @@ export const useWidgetsStore = defineStore('widgets', {
|
|||||||
localStorage.setItem('itab_widgets', JSON.stringify(this.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[]) {
|
setWidgetOrder(orderIds: string[]) {
|
||||||
const ordered: Widget[] = [];
|
const ordered: Widget[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@ -143,3 +153,4 @@ export const useWidgetsStore = defineStore('widgets', {
|
|||||||
if (import.meta.hot) {
|
if (import.meta.hot) {
|
||||||
import.meta.hot.accept(acceptHMRUpdate(useWidgetsStore, import.meta.hot));
|
import.meta.hot.accept(acceptHMRUpdate(useWidgetsStore, import.meta.hot));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 932 KiB |
|
Before Width: | Height: | Size: 362 KiB |
|
Before Width: | Height: | Size: 32 KiB |