This commit is contained in:
yinsx
2026-01-29 11:28:01 +08:00
parent a68e380c42
commit d025691180
15 changed files with 286 additions and 119 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 669 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

View File

@ -4,12 +4,13 @@
:groups="sidebarGroups" :groups="sidebarGroups"
:base-overrides="sidebarOverrides" :base-overrides="sidebarOverrides"
:hidden-base-ids="hiddenBaseIds" :hidden-base-ids="hiddenBaseIds"
@select="handleSidebarSelect"
@add="openAddGroup" @add="openAddGroup"
@edit-group="openEditGroup" @edit-group="openEditGroup"
@remove-group="handleRemoveGroup" @remove-group="handleRemoveGroup"
@settings="toggleSettings" @settings="toggleSettings"
/> />
<MainContent /> <MainContent :active-group-id="activeGroupId" />
<TheContextMenu <TheContextMenu
@add-icon="openAddIcon" @add-icon="openAddIcon"
@edit-icon="openEditIcon" @edit-icon="openEditIcon"
@ -99,6 +100,8 @@ const isSettingsOpen = ref(false);
const addIconMode = ref<'add' | 'replace-widget'>('add'); const addIconMode = ref<'add' | 'replace-widget'>('add');
const addIconInitialTab = ref<'widgets' | 'sites' | 'custom'>('widgets'); const addIconInitialTab = ref<'widgets' | 'sites' | 'custom'>('widgets');
const replaceWidgetId = ref<string | null>(null); const replaceWidgetId = ref<string | null>(null);
const defaultGroupId = 'home';
const activeGroupId = ref<string | null>(defaultGroupId);
const editTarget = ref<{ id: string; source: 'base' | 'group' } | null>(null); const editTarget = ref<{ id: string; source: 'base' | 'group' } | null>(null);
const layoutStore = useLayoutStore(); const layoutStore = useLayoutStore();
const widgetsStore = useWidgetsStore(); const widgetsStore = useWidgetsStore();
@ -154,6 +157,10 @@ const toggleSettings = () => {
isSettingsOpen.value = !isSettingsOpen.value; isSettingsOpen.value = !isSettingsOpen.value;
}; };
const handleSidebarSelect = (id: string) => {
activeGroupId.value = id;
};
const openAddIcon = () => { const openAddIcon = () => {
addIconMode.value = 'add'; addIconMode.value = 'add';
addIconInitialTab.value = 'widgets'; addIconInitialTab.value = 'widgets';
@ -252,6 +259,7 @@ const handleAddIcon = (payload: { name: string; url: string; bgColor: string; im
bgColor: payload.bgColor, bgColor: payload.bgColor,
img: payload.img, img: payload.img,
text: payload.text, text: payload.text,
groupId: activeGroupId.value ?? defaultGroupId,
}); });
}; };
@ -270,6 +278,7 @@ const handleAddWidget = (payload: { component: string; size?: '1x1' | '1x2' | '2
component: payload.component, component: payload.component,
size: payload.size, size: payload.size,
data: payload.data, data: payload.data,
groupId: activeGroupId.value ?? defaultGroupId,
}); });
}; };

View File

@ -19,6 +19,7 @@ interface Icon {
text?: string; text?: string;
bgColor?: string; bgColor?: string;
size?: GridItemSize; size?: GridItemSize;
groupId?: string;
} }
type WidgetSize = GridItemSize; type WidgetSize = GridItemSize;
@ -27,6 +28,7 @@ interface Widget {
component: string; component: string;
size: WidgetSize; size: WidgetSize;
data?: any; data?: any;
groupId?: string;
} }
interface GridOrderEntry { interface GridOrderEntry {
@ -39,6 +41,11 @@ type OrderedGridItem =
| { type: 'widget'; id: string; widget: Widget }; | { type: 'widget'; id: string; widget: Widget };
const GRID_ORDER_STORAGE_KEY = 'itab_grid_order'; const GRID_ORDER_STORAGE_KEY = 'itab_grid_order';
const DEFAULT_GROUP_ID = 'home';
const props = defineProps<{
activeGroupId?: string | null;
}>();
const layoutStore = useLayoutStore(); const layoutStore = useLayoutStore();
const widgetsStore = useWidgetsStore(); const widgetsStore = useWidgetsStore();
@ -59,6 +66,22 @@ const previousIconIds = ref(new Set<string>());
const previousWidgetIds = ref(new Set<string>()); const previousWidgetIds = ref(new Set<string>());
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 activeGroupId = computed(() => normalizeGroupId(props.activeGroupId));
const normalizeGridOrder = (order: GridOrderEntry[]) => {
const seen = new Set<string>();
const normalized: GridOrderEntry[] = [];
for (const entry of order) {
if (!entry || typeof entry.id !== 'string') continue;
if (entry.type !== 'icon' && entry.type !== 'widget') continue;
const key = `${entry.type}:${entry.id}`;
if (seen.has(key)) continue;
seen.add(key);
normalized.push(entry);
}
return normalized;
};
const buildDefaultOrder = (): GridOrderEntry[] => [ const buildDefaultOrder = (): GridOrderEntry[] => [
...widgetsStore.widgets.map(widget => ({ id: widget.id, type: 'widget' as const })), ...widgetsStore.widgets.map(widget => ({ id: widget.id, type: 'widget' as const })),
@ -85,7 +108,7 @@ const loadGridOrder = () => {
try { try {
const parsed = JSON.parse(saved) as GridOrderEntry[]; const parsed = JSON.parse(saved) as GridOrderEntry[];
if (Array.isArray(parsed)) { if (Array.isArray(parsed)) {
gridOrder.value = parsed.filter(item => item?.id && (item.type === 'icon' || item.type === 'widget')); gridOrder.value = normalizeGridOrder(parsed);
} }
} catch { } catch {
gridOrder.value = []; gridOrder.value = [];
@ -100,26 +123,36 @@ const loadGridOrder = () => {
const ensureOrderConsistency = () => { const ensureOrderConsistency = () => {
const iconIds = new Set(layoutStore.icons.map(icon => icon.id)); const iconIds = new Set(layoutStore.icons.map(icon => icon.id));
const widgetIds = new Set(widgetsStore.widgets.map(widget => widget.id)); const widgetIds = new Set(widgetsStore.widgets.map(widget => widget.id));
const seen = new Set<string>();
const nextOrder: GridOrderEntry[] = []; const nextOrder: GridOrderEntry[] = [];
const pushEntry = (entry: GridOrderEntry) => {
const key = `${entry.type}:${entry.id}`;
if (seen.has(key)) return;
seen.add(key);
nextOrder.push(entry);
};
for (const entry of gridOrder.value) { for (const entry of gridOrder.value) {
if (entry.type === 'icon' && iconIds.has(entry.id)) { if (entry.type === 'icon' && iconIds.has(entry.id)) {
nextOrder.push(entry); pushEntry(entry);
} }
if (entry.type === 'widget' && widgetIds.has(entry.id)) { if (entry.type === 'widget' && widgetIds.has(entry.id)) {
nextOrder.push(entry); pushEntry(entry);
} }
} }
for (const widget of widgetsStore.widgets) { for (const widget of widgetsStore.widgets) {
if (!nextOrder.some(item => item.type === 'widget' && item.id === widget.id)) { const key = `widget:${widget.id}`;
nextOrder.push({ id: widget.id, type: 'widget' }); if (!seen.has(key)) {
pushEntry({ id: widget.id, type: 'widget' });
} }
} }
for (const icon of layoutStore.icons) { for (const icon of layoutStore.icons) {
if (!nextOrder.some(item => item.type === 'icon' && item.id === icon.id)) { const key = `icon:${icon.id}`;
nextOrder.push({ id: icon.id, type: 'icon' }); if (!seen.has(key)) {
pushEntry({ id: icon.id, type: 'icon' });
} }
} }
@ -137,6 +170,9 @@ const ensureOrderConsistency = () => {
} }
}; };
const isInActiveGroup = (groupId?: string | null) =>
normalizeGroupId(groupId) === activeGroupId.value;
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]));
@ -146,13 +182,13 @@ const orderedItems = computed<OrderedGridItem[]>(() => {
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) { if (icon && 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 {
const widget = widgetsById.get(entry.id); const widget = widgetsById.get(entry.id);
if (widget) { if (widget && 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}`);
} }
@ -161,14 +197,14 @@ const orderedItems = computed<OrderedGridItem[]>(() => {
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)) { if (!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)) { if (!used.has(key) && isInActiveGroup(icon.groupId)) {
items.push({ type: 'icon', id: icon.id, icon }); items.push({ type: 'icon', id: icon.id, icon });
} }
} }
@ -222,7 +258,36 @@ const persistOrderFromGrid = async () => {
} }
} }
if (nextOrder.length) { if (nextOrder.length) {
applyGridOrder(nextOrder, true); if (!gridOrder.value.length) {
applyGridOrder(nextOrder, true);
} else {
const entryKey = (entry: GridOrderEntry) => `${entry.type}:${entry.id}`;
const visibleKeys = new Set(nextOrder.map(entryKey));
const visibleQueue = [...nextOrder];
const merged: GridOrderEntry[] = [];
for (const entry of gridOrder.value) {
if (visibleKeys.has(entryKey(entry))) {
const nextEntry = visibleQueue.shift();
if (nextEntry) {
merged.push(nextEntry);
}
} else {
merged.push(entry);
}
}
const mergedKeys = new Set(merged.map(entryKey));
for (const entry of visibleQueue) {
const key = entryKey(entry);
if (!mergedKeys.has(key)) {
merged.push(entry);
mergedKeys.add(key);
}
}
applyGridOrder(merged, true);
}
} }
await nextTick(); await nextTick();
grid.value?.synchronize(); grid.value?.synchronize();
@ -247,7 +312,11 @@ const syncGridItems = () => {
const refreshLayout = async (instant = false) => { const refreshLayout = async (instant = false) => {
await nextTick(); await nextTick();
syncGridItems(); syncGridItems();
grid.value?.synchronize();
grid.value?.refreshItems(); grid.value?.refreshItems();
if (grid.value?._settings?.layout) {
grid.value._settings.layout.fillGaps = true;
}
if (instant) { if (instant) {
grid.value?.layout(true); grid.value?.layout(true);
} else { } else {
@ -283,6 +352,7 @@ const syncSizeMap = (
onMounted(async () => { onMounted(async () => {
loadGridOrder(); loadGridOrder();
ensureOrderConsistency();
previousIconIds.value = new Set(layoutStore.icons.map(icon => icon.id)); previousIconIds.value = new Set(layoutStore.icons.map(icon => icon.id));
previousWidgetIds.value = new Set(widgetsStore.widgets.map(widget => widget.id)); previousWidgetIds.value = new Set(widgetsStore.widgets.map(widget => widget.id));
await nextTick(); await nextTick();
@ -296,7 +366,7 @@ onMounted(async () => {
}, },
dragSort: true, dragSort: true,
layout: { layout: {
fillGaps: settingsStore.autoAlign, fillGaps: true,
rounding: true, rounding: true,
}, },
layoutDuration: layoutAnimationMs, layoutDuration: layoutAnimationMs,
@ -388,12 +458,10 @@ watch(
); );
watch( watch(
() => settingsStore.autoAlign, () => props.activeGroupId,
value => { () => {
if (!grid.value) return; if (!grid.value) return;
// Muuri has no public setter for fillGaps; update internal setting and relayout. refreshLayout(true);
grid.value._settings.layout.fillGaps = value;
grid.value.layout(true);
} }
); );
@ -475,8 +543,7 @@ onUnmounted(() => {
} }
.grid-item.is-resized { .grid-item.is-resized {
overflow: hidden; overflow: visible;
border-radius: $border-radius-small;
} }
.grid-item.is-entering { .grid-item.is-entering {

View File

@ -10,7 +10,6 @@
<img v-if="props.icon.img" :src="props.icon.img" :alt="props.icon.name" /> <img v-if="props.icon.img" :src="props.icon.img" :alt="props.icon.name" />
<span v-else>{{ props.icon.text?.trim() || props.icon.name.charAt(0) }}</span> <span v-else>{{ props.icon.text?.trim() || props.icon.name.charAt(0) }}</span>
</div> </div>
<div class="card-spacer"></div>
<div class="label">{{ props.icon.name }}</div> <div class="label">{{ props.icon.name }}</div>
</a> </a>
</template> </template>
@ -38,9 +37,8 @@ const props = defineProps<{
@import '@/styles/tokens.scss'; @import '@/styles/tokens.scss';
.icon-card-wrapper { .icon-card-wrapper {
display: grid; position: relative;
grid-template-rows: minmax(0, 1fr) var(--icon-label-margin-top) auto; display: block;
align-items: stretch;
width: 100%; width: 100%;
height: 100%; height: 100%;
text-decoration: none; text-decoration: none;
@ -83,27 +81,21 @@ const props = defineProps<{
} }
} }
.card-spacer {
height: 100%;
pointer-events: none;
}
.icon-card-wrapper.size-1x1 .icon-card {
width: auto;
height: 100%;
aspect-ratio: 1 / 1;
justify-self: center;
}
.label { .label {
position: absolute;
top: 100%;
left: 50%;
margin-top: var(--icon-label-margin-top);
transform: translateX(-50%);
font-size: var(--icon-label-font-size); font-size: var(--icon-label-font-size);
color: $color-text-primary; color: $color-text-primary;
text-align: center; text-align: center;
width: 100%; width: 100%;
display: var(--icon-label-display, block); max-width: 100%;
white-space: normal; visibility: var(--icon-label-visibility, visible);
word-break: break-word; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2; line-height: 1.2;
pointer-events: none;
} }
</style> </style>

View File

@ -1,14 +1,16 @@
<template> <template>
<main class="main-content" @contextmenu="handleDesktopContextMenu"> <main class="main-content" @contextmenu="handleDesktopContextMenu">
<div class="main-content-container"> <div class="main-content-container">
<TheClock <div v-if="showClock || showSearch" class="content-header">
v-if="showClock" <TheClock
:show-date="showDate" v-if="showClock"
:show-seconds="showSeconds" :show-date="showDate"
:use-24-hour="use24Hour" :show-seconds="showSeconds"
/> :use-24-hour="use24Hour"
<TheSearchBar v-if="showSearch" /> />
<GridCanvas /> <TheSearchBar v-if="showSearch" />
</div>
<GridCanvas :active-group-id="activeGroupId" />
</div> </div>
</main> </main>
</template> </template>
@ -24,6 +26,10 @@ import GridCanvas from '@/components/GridCanvas/index.vue';
import { useSettingsStore } from '@/store/useSettingsStore'; import { useSettingsStore } from '@/store/useSettingsStore';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
defineProps<{
activeGroupId?: string | null;
}>();
const uiStore = useUIStore(); const uiStore = useUIStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const { showSearch, showClock, showDate, showSeconds, use24Hour } = storeToRefs(settingsStore); const { showSearch, showClock, showDate, showSeconds, use24Hour } = storeToRefs(settingsStore);
@ -49,6 +55,13 @@ const handleDesktopContextMenu = (event: MouseEvent) => {
justify-content: center; justify-content: center;
align-items: flex-start; // 内容顶部对齐 align-items: flex-start; // 内容顶部对齐
overflow-y: auto; overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.main-content::-webkit-scrollbar {
width: 0;
height: 0;
} }
.main-content-container { .main-content-container {
@ -59,4 +72,15 @@ const handleDesktopContextMenu = (event: MouseEvent) => {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
} }
.content-header {
position: sticky;
top: var(--content-padding);
z-index: 2;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
align-self: stretch;
}
</style> </style>

View File

@ -179,12 +179,12 @@
<div class="row-desc">用网格密度统一控制图标与组件大小</div> <div class="row-desc">用网格密度统一控制图标与组件大小</div>
</div> </div>
<input <input
v-model.number="layoutDensity" v-model.number="iconSizeDensity"
class="settings-range" class="settings-range"
type="range" type="range"
min="1" :min="layoutDensityRange.min"
max="4" :max="layoutDensityRange.max"
step="1" :step="layoutDensityRange.step"
/> />
</div> </div>
<div class="settings-row"> <div class="settings-row">
@ -362,6 +362,7 @@ import { useSettingsStore } from '@/store/useSettingsStore';
import { import {
iconDensityRange, iconDensityRange,
iconRadiusRange, iconRadiusRange,
layoutDensityRange,
searchProviders, searchProviders,
themePresets, themePresets,
} from '@/config/settingsPresets'; } from '@/config/settingsPresets';
@ -441,9 +442,13 @@ const themeId = computed({
set: value => settingsStore.setSetting('themeId', value as typeof settingsStore.themeId), set: value => settingsStore.setSetting('themeId', value as typeof settingsStore.themeId),
}); });
const layoutDensity = computed({ const invertLayoutDensity = (value: number) =>
get: () => settingsStore.layoutDensity, layoutDensityRange.max + layoutDensityRange.min -
set: value => settingsStore.setSetting('layoutDensity', value), Math.min(Math.max(value, layoutDensityRange.min), layoutDensityRange.max);
const iconSizeDensity = computed({
get: () => invertLayoutDensity(settingsStore.layoutDensity),
set: value => settingsStore.setSetting('layoutDensity', invertLayoutDensity(value)),
}); });
const compactSidebar = computed({ const compactSidebar = computed({
@ -656,7 +661,6 @@ const clampRange = (value: number, min: number, max: number) =>
const applyImportedSettings = (settings: Partial<typeof settingsStore.$state>) => { const applyImportedSettings = (settings: Partial<typeof settingsStore.$state>) => {
const providerIds = new Set(searchProviders.map(item => item.id)); const providerIds = new Set(searchProviders.map(item => item.id));
const themeIds = new Set(themePresets.map(item => item.id)); const themeIds = new Set(themePresets.map(item => item.id));
const layoutDensityValues = new Set([1, 2, 3, 4]);
if (typeof settings.showSearch === 'boolean') { if (typeof settings.showSearch === 'boolean') {
settingsStore.setSetting('showSearch', settings.showSearch); settingsStore.setSetting('showSearch', settings.showSearch);
@ -709,8 +713,11 @@ const applyImportedSettings = (settings: Partial<typeof settingsStore.$state>) =
clampRange(settings.iconDensity, iconDensityRange.min, iconDensityRange.max) clampRange(settings.iconDensity, iconDensityRange.min, iconDensityRange.max)
); );
} }
if (typeof settings.layoutDensity === 'number' && layoutDensityValues.has(settings.layoutDensity)) { if (typeof settings.layoutDensity === 'number') {
settingsStore.setSetting('layoutDensity', settings.layoutDensity as typeof settingsStore.layoutDensity); settingsStore.setSetting(
'layoutDensity',
clampRange(settings.layoutDensity, layoutDensityRange.min, layoutDensityRange.max)
);
} }
}; };

View File

@ -169,6 +169,16 @@ const setActive = (id: string) => {
localStorage.setItem(STORAGE_KEY, id); localStorage.setItem(STORAGE_KEY, id);
}; };
watch(
() => activeId.value,
id => {
if (selectableItems.value.some(item => item.id === id)) {
emit('select', id);
}
},
{ immediate: true }
);
const handleItemClick = (item: NavItem) => { const handleItemClick = (item: NavItem) => {
if (item.action === 'add') { if (item.action === 'add') {
emit('add'); emit('add');

View File

@ -5,7 +5,6 @@
<component :is="widgetComponent" :data="props.widget.data" :size="props.widget.size" /> <component :is="widgetComponent" :data="props.widget.data" :size="props.widget.size" />
</div> </div>
</div> </div>
<div class="card-spacer"></div>
<div class="label label-below">{{ label }}</div> <div class="label label-below">{{ label }}</div>
</div> </div>
</template> </template>
@ -41,29 +40,22 @@ const label = computed(() => {
@import '@/styles/tokens.scss'; @import '@/styles/tokens.scss';
.widget-card-wrapper { .widget-card-wrapper {
position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
display: grid;
grid-template-rows: minmax(0, 1fr) var(--icon-label-margin-top) auto;
padding: var(--widget-card-padding, var(--icon-card-padding)); padding: var(--widget-card-padding, var(--icon-card-padding));
box-sizing: border-box;
// 鏍规嵁灏哄绫诲悕鎺у埗灏哄
&.size-1x1 { grid-column: span 1; grid-row: span 1; }
&.size-1x2 { grid-column: span 1; grid-row: span 2; }
&.size-2x1 { grid-column: span 2; grid-row: span 1; }
&.size-2x2 { grid-column: span 2; grid-row: span 2; }
&.size-2x4 { grid-column: span 2; grid-row: span 4; }
} }
.widget-card { .widget-card {
box-sizing: border-box;
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
background-color: $color-surface-1; background-color: $color-surface-1;
backdrop-filter: blur($backdrop-filter-blur) saturate($backdrop-filter-saturation); backdrop-filter: blur($backdrop-filter-blur) saturate($backdrop-filter-saturation);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: $border-radius-small; border-radius: var(--icon-radius, #{$border-radius-small});
box-shadow: $shadow-md; box-shadow: $shadow-md;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -72,31 +64,26 @@ const label = computed(() => {
transform-origin: center; transform-origin: center;
} }
.card-spacer {
height: 100%;
pointer-events: none;
}
.widget-card-wrapper.size-1x1 .widget-card {
width: auto;
height: 100%;
aspect-ratio: 1 / 1;
justify-self: center;
}
.widget-content { .widget-content {
flex-grow: 1; flex-grow: 1;
padding: var(--widget-content-padding); padding: var(--widget-content-padding);
} }
.label { .label {
position: absolute;
top: 100%;
left: 50%;
margin-top: var(--icon-label-margin-top);
transform: translateX(-50%);
font-size: var(--widget-label-font-size); font-size: var(--widget-label-font-size);
color: $color-text-primary; color: $color-text-primary;
text-align: center; text-align: center;
width: 100%; width: 100%;
display: var(--widget-label-display, block); max-width: 100%;
white-space: normal; visibility: var(--widget-label-visibility, visible);
word-break: break-word; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2; line-height: 1.2;
flex-shrink: 0; flex-shrink: 0;
} }

View File

@ -53,8 +53,9 @@ export const useSettingsSync = () => {
const baseGrid = layoutConfig.grid; const baseGrid = layoutConfig.grid;
const sizeScale = layoutPreset.cellSize / baseGrid.cellSize; const sizeScale = layoutPreset.cellSize / baseGrid.cellSize;
const labelScale = clampRange(sizeScale, 0.7, 1); const labelScale = clampRange(sizeScale, 0.7, 1);
const densityScale = getIconDensityScale(settingsStore.iconDensity);
const iconPadding = Math.max(0, baseIcon.padding * sizeScale); const iconPadding = Math.max(0, baseIcon.padding * sizeScale);
const widgetScale = sizeScale; const widgetScale = 1;
const widgetPadding = Math.max(0, baseIcon.padding * sizeScale); const widgetPadding = Math.max(0, baseIcon.padding * sizeScale);
const iconFontSize = Math.max(14, Math.round(baseIcon.fontSize * sizeScale)); const iconFontSize = Math.max(14, Math.round(baseIcon.fontSize * sizeScale));
const iconLabelFontSize = Math.max(10, Math.round(baseIcon.labelFontSize * labelScale)); const iconLabelFontSize = Math.max(10, Math.round(baseIcon.labelFontSize * labelScale));
@ -67,11 +68,7 @@ export const useSettingsSync = () => {
4, 4,
Math.round(layoutConfig.widget.labelPaddingY * labelScale) Math.round(layoutConfig.widget.labelPaddingY * labelScale)
); );
const densityScale = getIconDensityScale(settingsStore.iconDensity); const gridGap = Math.max(0, Math.round(baseGrid.gap * densityScale));
const baseVisualGap = (layoutPreset.gap + iconPadding * 2) * densityScale;
// Keep density purely controlling spacing; allow deeper negative gap for extra-tight layouts.
const minGap = -layoutPreset.cellSize * 0.75;
const gridGap = Math.max(minGap, baseVisualGap - iconPadding * 2);
setPx(root, '--grid-cell-size', layoutPreset.cellSize); setPx(root, '--grid-cell-size', layoutPreset.cellSize);
setPx(root, '--grid-gap', gridGap); setPx(root, '--grid-gap', gridGap);
@ -82,16 +79,13 @@ export const useSettingsSync = () => {
setPx(root, '--icon-font-size', iconFontSize); setPx(root, '--icon-font-size', iconFontSize);
setPx(root, '--icon-label-font-size', iconLabelFontSize); setPx(root, '--icon-label-font-size', iconLabelFontSize);
setPx(root, '--icon-radius', iconRadius); setPx(root, '--icon-radius', iconRadius);
setPx( setPx(root, '--icon-label-margin-top', iconLabelMarginTop);
root,
'--icon-label-margin-top',
settingsStore.showIconLabels ? iconLabelMarginTop : 0
);
setPx(root, '--widget-label-font-size', widgetLabelFontSize); setPx(root, '--widget-label-font-size', widgetLabelFontSize);
setPx(root, '--widget-label-padding-y', widgetLabelPaddingY); setPx(root, '--widget-label-padding-y', widgetLabelPaddingY);
setVar(root, '--icon-label-display', settingsStore.showIconLabels ? 'block' : 'none'); const labelVisibility = settingsStore.showIconLabels ? 'visible' : 'hidden';
setVar(root, '--widget-label-display', settingsStore.showIconLabels ? 'block' : 'none'); setVar(root, '--icon-label-visibility', labelVisibility);
setVar(root, '--widget-label-visibility', labelVisibility);
const sidebarVars = getSidebarVars(settingsStore.compactSidebar); const sidebarVars = getSidebarVars(settingsStore.compactSidebar);
setPx(root, '--sidebar-width', sidebarVars.width); setPx(root, '--sidebar-width', sidebarVars.width);

View File

@ -54,7 +54,7 @@ export const layoutConfig = {
// 图标首字母字体大小(像素)。 // 图标首字母字体大小(像素)。
fontSize: 28, fontSize: 28,
// 卡片内边距(像素)。 // 卡片内边距(像素)。
padding: 8, padding: 0,
// 图标卡片圆角(像素)。 // 图标卡片圆角(像素)。
radius: 12, radius: 12,
// 标签字体大小(像素)。 // 标签字体大小(像素)。

View File

@ -83,7 +83,7 @@ const clampRange = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max); Math.min(Math.max(value, min), max);
export type LayoutDensityPreset = { export type LayoutDensityPreset = {
value: 1 | 2 | 3 | 4; value: number;
label: string; label: string;
cellSize: number; cellSize: number;
gap: number; gap: number;
@ -119,19 +119,46 @@ export const layoutDensityPresets: LayoutDensityPreset[] = [
}, },
]; ];
export const getLayoutDensityPreset = (value?: number) => export const layoutDensityRange = {
layoutDensityPresets.find(item => item.value === value) ?? layoutDensityPresets[1]; min: 1,
max: 4,
step: 0.1,
default: 2,
};
export const getLayoutDensityPreset = (value?: number) => {
const normalized = clampRange(
typeof value === 'number' ? value : layoutDensityRange.default,
layoutDensityRange.min,
layoutDensityRange.max
);
const lower = Math.floor(normalized);
const upper = Math.ceil(normalized);
if (lower === upper) {
return layoutDensityPresets[lower - 1] ?? layoutDensityPresets[1];
}
const lowerPreset = layoutDensityPresets[lower - 1] ?? layoutDensityPresets[0];
const upperPreset = layoutDensityPresets[upper - 1] ??
layoutDensityPresets[layoutDensityPresets.length - 1];
const t = (normalized - lower) / (upper - lower);
return {
value: normalized,
label: t < 0.5 ? lowerPreset.label : upperPreset.label,
cellSize: Math.round(lowerPreset.cellSize + (upperPreset.cellSize - lowerPreset.cellSize) * t),
gap: Math.round(lowerPreset.gap + (upperPreset.gap - lowerPreset.gap) * t),
};
};
export const iconDensityRange = { export const iconDensityRange = {
min: 0, min: 0,
max: 10, max: 50,
step: 1, step: 1,
default: 5, default: 25,
}; };
const iconDensityScaleRange = { const iconDensityScaleRange = {
loose: 1.3, loose: 2,
dense: 0.05, dense: 0,
}; };
export const getIconDensityScale = (value?: number) => { export const getIconDensityScale = (value?: number) => {
@ -140,6 +167,7 @@ export const getIconDensityScale = (value?: number) => {
iconDensityRange.min, iconDensityRange.min,
iconDensityRange.max iconDensityRange.max
); );
const ease = (t: number) => Math.pow(t, 0.5);
if (normalized === iconDensityRange.default) { if (normalized === iconDensityRange.default) {
return 1; return 1;
} }
@ -147,11 +175,12 @@ export const getIconDensityScale = (value?: number) => {
// Smaller slider value = denser布局 // Smaller slider value = denser布局
const t = (iconDensityRange.default - normalized) / const t = (iconDensityRange.default - normalized) /
(iconDensityRange.default - iconDensityRange.min); (iconDensityRange.default - iconDensityRange.min);
return 1 - t * (1 - iconDensityScaleRange.dense); return 1 - ease(t) * (1 - iconDensityScaleRange.dense);
} }
const t = (normalized - iconDensityRange.default) / const t = (normalized - iconDensityRange.default) /
(iconDensityRange.max - iconDensityRange.default); (iconDensityRange.max - iconDensityRange.default);
return 1 + t * (iconDensityScaleRange.loose - 1); return 1 + ease(t) * (iconDensityScaleRange.loose - 1);
}; };
export const iconSizeRange = { export const iconSizeRange = {

View File

@ -1,6 +1,7 @@
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';
// 模拟数据(来自截图参考) // 模拟数据(来自截图参考)
interface Icon { interface Icon {
@ -11,6 +12,7 @@ interface Icon {
img?: string; // 可选:用于图片图标(如徽标) img?: string; // 可选:用于图片图标(如徽标)
text?: string; // 可选:用于文字图标 text?: string; // 可选:用于文字图标
bgColor?: string; // 可选:纯色背景 bgColor?: string; // 可选:纯色背景
groupId?: string;
} }
interface DragState { interface DragState {
@ -47,10 +49,33 @@ const defaultIcons: Icon[] = [
{ id: '16', name: '书签管理', url: '#', bgColor: '#f1c40f' }, { id: '16', name: '书签管理', url: '#', bgColor: '#f1c40f' },
]; ];
const defaultGroupById: Record<string, string> = {
'1': 'product',
'2': 'product',
'3': 'home',
'4': 'product',
'5': 'fun',
'6': 'home',
'7': 'fun',
'8': 'fun',
'9': 'ai',
'10': 'fun',
'11': 'design',
'12': 'design',
'13': 'ai',
'14': 'code',
'15': 'home',
'16': 'home',
};
const savedIcons = localStorage.getItem('itab_icons'); const savedIcons = localStorage.getItem('itab_icons');
const normalizeIcons = (icons: Icon[]): Icon[] => const normalizeIcons = (icons: Icon[]): Icon[] =>
icons.map(icon => ({ ...icon, size: icon.size ?? '1x1' })); icons.map(icon => ({
...icon,
size: icon.size ?? '1x1',
groupId: icon.groupId ?? defaultGroupById[icon.id] ?? DEFAULT_GROUP_ID,
}));
const loadIcons = (): Icon[] => { const loadIcons = (): Icon[] => {
if (!savedIcons) return normalizeIcons(defaultIcons); if (!savedIcons) return normalizeIcons(defaultIcons);
@ -77,7 +102,7 @@ export const useLayoutStore = defineStore('layout', {
actions: { actions: {
addIcon(icon: Omit<Icon, 'id'>) { addIcon(icon: Omit<Icon, 'id'>) {
const nextId = `custom-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; const nextId = `custom-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
this.icons.push({ ...icon, id: nextId }); this.icons.push({ ...icon, id: nextId, groupId: icon.groupId ?? DEFAULT_GROUP_ID });
localStorage.setItem('itab_icons', JSON.stringify(this.icons)); localStorage.setItem('itab_icons', JSON.stringify(this.icons));
}, },
updateIcon(iconId: string, updates: Partial<Omit<Icon, 'id'>>) { updateIcon(iconId: string, updates: Partial<Omit<Icon, 'id'>>) {

View File

@ -4,7 +4,7 @@ import {
getThemePreset, getThemePreset,
iconDensityRange, iconDensityRange,
iconRadiusRange, iconRadiusRange,
layoutDensityPresets, layoutDensityRange,
searchProviders, searchProviders,
themePresets, themePresets,
type SearchProviderId, type SearchProviderId,
@ -12,7 +12,7 @@ import {
} from '@/config/settingsPresets'; } from '@/config/settingsPresets';
type IconDensity = number; type IconDensity = number;
type LayoutDensity = 1 | 2 | 3 | 4; type LayoutDensity = number;
type SettingsState = { type SettingsState = {
showSearch: boolean; showSearch: boolean;
@ -54,7 +54,6 @@ const defaultSettings: SettingsState = {
const searchProviderIds = new Set(searchProviders.map(item => item.id)); const searchProviderIds = new Set(searchProviders.map(item => item.id));
const themePresetIds = new Set(themePresets.map(item => item.id)); const themePresetIds = new Set(themePresets.map(item => item.id));
const layoutDensityValues = new Set(layoutDensityPresets.map(item => item.value));
const clampRange = (value: number, min: number, max: number) => const clampRange = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max); Math.min(Math.max(value, min), max);
@ -92,10 +91,10 @@ const loadSettings = (): SettingsState => {
? parsed.themeId ? parsed.themeId
: defaultSettings.themeId, : defaultSettings.themeId,
layoutDensity: layoutDensity:
typeof parsed.layoutDensity === 'number' && layoutDensityValues.has(parsed.layoutDensity) typeof parsed.layoutDensity === 'number'
? parsed.layoutDensity ? clampRange(parsed.layoutDensity, layoutDensityRange.min, layoutDensityRange.max)
: defaultSettings.layoutDensity, : defaultSettings.layoutDensity,
autoAlign: typeof parsed.autoAlign === 'boolean' ? parsed.autoAlign : defaultSettings.autoAlign, autoAlign: defaultSettings.autoAlign,
compactSidebar: compactSidebar:
typeof parsed.compactSidebar === 'boolean' ? parsed.compactSidebar : defaultSettings.compactSidebar, typeof parsed.compactSidebar === 'boolean' ? parsed.compactSidebar : defaultSettings.compactSidebar,
showGroupLabels: showGroupLabels:

View File

@ -2,12 +2,14 @@ import { defineStore, acceptHMRUpdate } from 'pinia';
// 组件数据结构 // 组件数据结构
type WidgetSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4'; type WidgetSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
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;
} }
interface WidgetsState { interface WidgetsState {
@ -46,13 +48,34 @@ const defaultWidgets: Widget[] = [
const savedWidgets = localStorage.getItem('itab_widgets'); const savedWidgets = localStorage.getItem('itab_widgets');
const defaultGroupById: Record<string, string> = {
'widget-1': 'home',
'widget-2': 'fun',
'widget-3': 'product',
};
const normalizeWidgets = (widgets: Widget[]): Widget[] =>
widgets.map(widget => ({
...widget,
groupId: widget.groupId ?? defaultGroupById[widget.id] ?? DEFAULT_GROUP_ID,
}));
const loadWidgets = (): Widget[] => {
if (!savedWidgets) return normalizeWidgets(defaultWidgets);
try {
return normalizeWidgets(JSON.parse(savedWidgets) as Widget[]);
} catch {
return normalizeWidgets(defaultWidgets);
}
};
// 模拟数据(来自截图参考) // 模拟数据(来自截图参考)
export const useWidgetsStore = defineStore('widgets', { export const useWidgetsStore = defineStore('widgets', {
state: (): WidgetsState => ({ state: (): WidgetsState => ({
widgets: savedWidgets ? JSON.parse(savedWidgets) : defaultWidgets, widgets: loadWidgets(),
}), }),
actions: { actions: {
addWidget(payload: { component: string; size?: WidgetSize; data?: any }) { addWidget(payload: { component: string; size?: WidgetSize; data?: any; groupId?: string }) {
const nextId = `widget-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; const nextId = `widget-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const nextWidget: Widget = { const nextWidget: Widget = {
id: nextId, id: nextId,
@ -60,6 +83,7 @@ export const useWidgetsStore = defineStore('widgets', {
size: payload.size ?? '1x1', size: payload.size ?? '1x1',
gridPosition: { x: 0, y: 0 }, gridPosition: { x: 0, y: 0 },
data: payload.data, data: payload.data,
groupId: payload.groupId ?? DEFAULT_GROUP_ID,
}; };
this.widgets.push(nextWidget); this.widgets.push(nextWidget);
localStorage.setItem('itab_widgets', JSON.stringify(this.widgets)); localStorage.setItem('itab_widgets', JSON.stringify(this.widgets));