1
This commit is contained in:
126
app/src/components/FolderCard/index.vue
Normal file
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
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 { 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>
|
||||
|
||||
111
app/src/store/useFoldersStore.ts
Normal file
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';
|
||||
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));
|
||||
}
|
||||
},
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user