This commit is contained in:
yinsx
2026-01-28 17:51:06 +08:00
parent d97ea9c791
commit a68e380c42
45 changed files with 4980 additions and 151 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 KiB

22
app/package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "itab-clone",
"version": "0.0.0",
"dependencies": {
"@iconify/vue": "^5.0.0",
"muuri": "^0.9.5",
"pinia": "^2.1.7",
"vue": "^3.3.11"
@ -457,6 +458,27 @@
"node": ">=12"
}
},
"node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
"license": "MIT"
},
"node_modules/@iconify/vue": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@iconify/vue/-/vue-5.0.0.tgz",
"integrity": "sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==",
"license": "MIT",
"dependencies": {
"@iconify/types": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/cyberalien"
},
"peerDependencies": {
"vue": ">=3"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",

View File

@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@iconify/vue": "^5.0.0",
"muuri": "^0.9.5",
"pinia": "^2.1.7",
"vue": "^3.3.11"

View File

@ -1,15 +1,305 @@
<template>
<div id="desktop-app">
<TheSidebar />
<TheSidebar
:groups="sidebarGroups"
:base-overrides="sidebarOverrides"
:hidden-base-ids="hiddenBaseIds"
@add="openAddGroup"
@edit-group="openEditGroup"
@remove-group="handleRemoveGroup"
@settings="toggleSettings"
/>
<MainContent />
<TheContextMenu />
<TheContextMenu
@add-icon="openAddIcon"
@edit-icon="openEditIcon"
@edit-widget="openEditWidget"
@settings="openSettings"
/>
<SettingsPanel v-model:open="isSettingsOpen" />
<EditIconDialog
v-model:open="isEditIconOpen"
:icon="editingIcon"
@submit="handleEditIcon"
/>
<AddGroupDialog
v-model:open="isGroupDialogOpen"
:title="dialogTitle"
:submit-text="dialogSubmitText"
:initial-name="dialogInitialName"
:initial-icon-id="dialogInitialIconId"
@submit="handleGroupSubmit"
/>
<AddIconDialog
v-model:open="isAddIconOpen"
:mode="addIconMode"
:initial-tab="addIconInitialTab"
:target-widget-id="replaceWidgetId"
@submit="handleAddIcon"
@add-widget="handleAddWidget"
@replace-widget="handleReplaceWidget"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import TheSidebar from './components/TheSidebar/index.vue';
import MainContent from './components/MainContent/index.vue';
import TheContextMenu from './components/TheContextMenu/index.vue';
import AddGroupDialog from './components/AddGroupDialog/index.vue';
import AddIconDialog from './components/AddIconDialog/index.vue';
import EditIconDialog from './components/EditIconDialog/index.vue';
import SettingsPanel from './components/SettingsPanel/index.vue';
import { sidebarBaseItems } from './config/sidebarItems';
import { useLayoutStore } from './store/useLayoutStore';
import { useWidgetsStore } from './store/useWidgetsStore';
import { useSettingsSync } from './composables/useSettingsSync';
type SidebarGroup = {
id: string;
label: string;
iconId: string;
};
type SidebarOverride = {
label: string;
iconId: string;
};
const GROUPS_STORAGE_KEY = 'itab_sidebar_groups';
const OVERRIDES_STORAGE_KEY = 'itab_sidebar_overrides';
const HIDDEN_STORAGE_KEY = 'itab_sidebar_hidden';
const loadGroups = (): SidebarGroup[] => {
const raw = localStorage.getItem(GROUPS_STORAGE_KEY);
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(item): item is SidebarGroup =>
item &&
typeof item.id === 'string' &&
typeof item.label === 'string' &&
typeof item.iconId === 'string'
);
} catch {
return [];
}
};
const sidebarGroups = ref<SidebarGroup[]>(loadGroups());
const sidebarOverrides = ref<Record<string, SidebarOverride>>({});
const hiddenBaseIds = ref<string[]>([]);
const isGroupDialogOpen = ref(false);
const isAddIconOpen = ref(false);
const isEditIconOpen = ref(false);
const isSettingsOpen = ref(false);
const addIconMode = ref<'add' | 'replace-widget'>('add');
const addIconInitialTab = ref<'widgets' | 'sites' | 'custom'>('widgets');
const replaceWidgetId = ref<string | null>(null);
const editTarget = ref<{ id: string; source: 'base' | 'group' } | null>(null);
const layoutStore = useLayoutStore();
const widgetsStore = useWidgetsStore();
const editingIcon = ref<{ id: string; name: string; url: string; img?: string; text?: string; bgColor?: string } | null>(null);
useSettingsSync();
const loadOverrides = (): Record<string, SidebarOverride> => {
const raw = localStorage.getItem(OVERRIDES_STORAGE_KEY);
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return {};
return Object.fromEntries(
Object.entries(parsed).filter(
([, value]) =>
value &&
typeof value === 'object' &&
typeof (value as SidebarOverride).label === 'string' &&
typeof (value as SidebarOverride).iconId === 'string'
)
);
} catch {
return {};
}
};
const loadHidden = (): string[] => {
const raw = localStorage.getItem(HIDDEN_STORAGE_KEY);
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((value): value is string => typeof value === 'string');
} catch {
return [];
}
};
sidebarOverrides.value = loadOverrides();
hiddenBaseIds.value = loadHidden();
const openAddGroup = () => {
editTarget.value = null;
isGroupDialogOpen.value = true;
};
const openSettings = () => {
isSettingsOpen.value = true;
};
const toggleSettings = () => {
isSettingsOpen.value = !isSettingsOpen.value;
};
const openAddIcon = () => {
addIconMode.value = 'add';
addIconInitialTab.value = 'widgets';
replaceWidgetId.value = null;
isAddIconOpen.value = true;
};
const openEditWidget = (widgetId: string) => {
addIconMode.value = 'replace-widget';
addIconInitialTab.value = 'widgets';
replaceWidgetId.value = widgetId;
isAddIconOpen.value = true;
};
const openEditIcon = (iconId: string) => {
const icon = layoutStore.icons.find(item => item.id === iconId);
if (!icon) return;
editingIcon.value = { ...icon };
isEditIconOpen.value = true;
};
const openEditGroup = (payload: { id: string; source: 'base' | 'group' }) => {
editTarget.value = payload;
isGroupDialogOpen.value = true;
};
const handleRemoveGroup = (payload: { id: string; source: 'base' | 'group' }) => {
if (payload.source === 'group') {
sidebarGroups.value = sidebarGroups.value.filter(group => group.id !== payload.id);
localStorage.setItem(GROUPS_STORAGE_KEY, JSON.stringify(sidebarGroups.value));
return;
}
if (!hiddenBaseIds.value.includes(payload.id)) {
hiddenBaseIds.value = [...hiddenBaseIds.value, payload.id];
localStorage.setItem(HIDDEN_STORAGE_KEY, JSON.stringify(hiddenBaseIds.value));
}
};
const dialogTitle = computed(() => (editTarget.value ? '编辑分组' : '添加分组'));
const dialogSubmitText = computed(() => (editTarget.value ? '保存' : '添加'));
const dialogInitialData = computed(() => {
if (!editTarget.value) return null;
if (editTarget.value.source === 'group') {
return sidebarGroups.value.find(group => group.id === editTarget.value?.id) ?? null;
}
const base = sidebarBaseItems.find(item => item.id === editTarget.value?.id);
if (!base) return null;
const override = sidebarOverrides.value[base.id];
return {
id: base.id,
label: override?.label ?? base.label,
iconId: override?.iconId ?? base.iconId ?? '',
};
});
const dialogInitialName = computed(() => dialogInitialData.value?.label ?? '');
const dialogInitialIconId = computed(() => dialogInitialData.value?.iconId ?? '');
const handleGroupSubmit = (payload: { name: string; iconId: string }) => {
const name = payload.name.trim();
if (!name) return;
if (editTarget.value) {
if (editTarget.value.source === 'group') {
sidebarGroups.value = sidebarGroups.value.map(group =>
group.id === editTarget.value?.id
? { ...group, label: name, iconId: payload.iconId }
: group
);
localStorage.setItem(GROUPS_STORAGE_KEY, JSON.stringify(sidebarGroups.value));
} else {
sidebarOverrides.value = {
...sidebarOverrides.value,
[editTarget.value.id]: { label: name, iconId: payload.iconId },
};
localStorage.setItem(OVERRIDES_STORAGE_KEY, JSON.stringify(sidebarOverrides.value));
hiddenBaseIds.value = hiddenBaseIds.value.filter(id => id !== editTarget.value?.id);
localStorage.setItem(HIDDEN_STORAGE_KEY, JSON.stringify(hiddenBaseIds.value));
}
} else {
const group: SidebarGroup = {
id: `group-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
label: name,
iconId: payload.iconId,
};
sidebarGroups.value = [...sidebarGroups.value, group];
localStorage.setItem(GROUPS_STORAGE_KEY, JSON.stringify(sidebarGroups.value));
}
isGroupDialogOpen.value = false;
};
const handleAddIcon = (payload: { name: string; url: string; bgColor: string; img?: string; text?: string }) => {
layoutStore.addIcon({
name: payload.name,
url: payload.url,
bgColor: payload.bgColor,
img: payload.img,
text: payload.text,
});
};
const handleEditIcon = (payload: { id: string; name: string; url: string; bgColor: string; img?: string; text?: string }) => {
layoutStore.updateIcon(payload.id, {
name: payload.name,
url: payload.url,
bgColor: payload.bgColor,
img: payload.img,
text: payload.text,
});
};
const handleAddWidget = (payload: { component: string; size?: '1x1' | '1x2' | '2x1' | '2x2' | '2x4'; data?: any }) => {
widgetsStore.addWidget({
component: payload.component,
size: payload.size,
data: payload.data,
});
};
const handleReplaceWidget = (payload: { id: string; component: string; size?: '1x1' | '1x2' | '2x1' | '2x2' | '2x4'; data?: any }) => {
widgetsStore.replaceWidget(payload.id, {
component: payload.component,
size: payload.size,
data: payload.data,
});
};
watch(isGroupDialogOpen, open => {
if (!open) {
editTarget.value = null;
}
});
watch(isEditIconOpen, open => {
if (!open) {
editingIcon.value = null;
}
});
watch(isAddIconOpen, open => {
if (!open) {
addIconMode.value = 'add';
addIconInitialTab.value = 'widgets';
replaceWidgetId.value = null;
}
});
</script>
<style lang="scss">

View File

@ -0,0 +1,324 @@
<template>
<teleport to="body">
<transition name="add-group-fade" appear>
<div
v-if="open"
class="add-group-backdrop"
@click.self="closeDialog"
>
<div class="add-group-dialog" role="dialog" aria-modal="true" :aria-label="titleText">
<div class="add-group-title">{{ titleText }}</div>
<div class="add-group-icons" role="listbox" aria-label="选择分组图标">
<button
v-for="icon in iconOptions"
:key="icon.id"
class="add-group-icon"
:class="{ selected: selectedIcon === icon.id }"
type="button"
:title="icon.label"
:aria-pressed="selectedIcon === icon.id"
@click="selectIcon(icon)"
>
<Icon class="add-group-icon-svg" :icon="icon.icon" aria-hidden="true" />
</button>
</div>
<div class="add-group-field">
<label for="add-group-name">名称</label>
<input
id="add-group-name"
ref="inputRef"
v-model="name"
type="text"
maxlength="12"
placeholder="常用"
@keydown.enter.prevent="submit"
/>
</div>
<div class="add-group-actions">
<button class="btn btn-primary" type="button" :disabled="!canSubmit" @click="submit">
{{ submitText }}
</button>
<button class="btn btn-secondary" type="button" @click="closeDialog">
取消
</button>
</div>
</div>
</div>
</transition>
</teleport>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { Icon } from '@iconify/vue';
import { groupIcons } from '@/config/groupIcons';
const props = defineProps<{
open: boolean;
title?: string;
submitText?: string;
initialName?: string;
initialIconId?: string;
}>();
const emit = defineEmits<{
(event: 'update:open', value: boolean): void;
(event: 'submit', payload: { name: string; iconId: string }): void;
}>();
const iconOptions = groupIcons;
const inputRef = ref<HTMLInputElement | null>(null);
const name = ref('');
const selectedIcon = ref(iconOptions[0]?.id ?? '');
const canSubmit = computed(() => name.value.trim().length > 0);
const titleText = computed(() => props.title ?? '添加分组');
const submitText = computed(() => props.submitText ?? '保存');
const resetForm = () => {
const fallbackIcon = iconOptions[0];
const providedIcon = props.initialIconId?.trim();
const initialIcon = providedIcon || fallbackIcon?.id || '';
const providedName = props.initialName?.trim();
const initialLabel =
providedName ||
iconOptions.find(icon => icon.id === initialIcon)?.label ||
fallbackIcon?.label ||
'';
selectedIcon.value = initialIcon;
name.value = initialLabel;
};
const selectIcon = (icon: (typeof iconOptions)[number]) => {
selectedIcon.value = icon.id;
name.value = icon.label;
};
const closeDialog = () => {
emit('update:open', false);
};
const submit = () => {
if (!canSubmit.value) return;
emit('submit', { name: name.value.trim(), iconId: selectedIcon.value });
};
const handleKeydown = (event: KeyboardEvent) => {
if (!props.open) return;
if (event.key === 'Escape') {
event.preventDefault();
closeDialog();
}
};
onMounted(() => {
window.addEventListener('keydown', handleKeydown);
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeydown);
});
watch(
() => props.open,
value => {
if (value) {
resetForm();
nextTick(() => inputRef.value?.focus());
}
}
);
</script>
<style lang="scss" scoped>
@import '@/styles/tokens.scss';
.add-group-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: $z-index-modal;
}
.add-group-dialog {
width: min(500px, 92vw);
padding: 18px 20px 20px;
border-radius: $border-radius-large;
background: rgba(18, 18, 18, 0.92);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: $shadow-lg;
position: relative;
overflow: hidden;
backdrop-filter: blur($backdrop-filter-blur) saturate($backdrop-filter-saturation);
}
.add-group-dialog::before {
content: '';
position: absolute;
inset: auto auto -120px -120px;
width: 280px;
height: 280px;
background: radial-gradient(circle, rgba(255, 149, 0, 0.4), transparent 70%);
pointer-events: none;
}
.add-group-title {
font-size: 16px;
font-weight: 600;
color: $color-text-primary;
margin-bottom: 12px;
}
.add-group-icons {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 10px;
padding: 12px;
border-radius: $border-radius-medium;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
margin-bottom: 14px;
}
.add-group-icon {
width: 32px;
height: 32px;
border-radius: 9px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.02);
color: $color-text-secondary;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color $motion-duration-sm $motion-easing-standard,
color $motion-duration-sm $motion-easing-standard,
border-color $motion-duration-sm $motion-easing-standard,
box-shadow $motion-duration-sm $motion-easing-standard,
transform $motion-duration-sm $motion-easing-standard;
}
.add-group-icon-svg {
width: 18px;
height: 18px;
}
.add-group-icon:hover {
background: rgba(255, 255, 255, 0.1);
color: $color-text-primary;
transform: translateY(-1px);
}
.add-group-icon.selected {
border-color: rgba(255, 149, 0, 0.5);
background: rgba(255, 149, 0, 0.18);
color: $color-text-primary;
box-shadow: 0 0 0 1px rgba(255, 149, 0, 0.35);
}
.add-group-field {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.add-group-field label {
font-size: 13px;
color: $color-text-secondary;
min-width: 36px;
}
.add-group-field input {
flex: 1;
height: 34px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.08);
color: $color-text-primary;
padding: 0 14px;
font-size: 13px;
outline: none;
transition: border-color $motion-duration-sm $motion-easing-standard,
box-shadow $motion-duration-sm $motion-easing-standard;
}
.add-group-field input:focus {
border-color: rgba(255, 149, 0, 0.5);
box-shadow: 0 0 0 2px rgba(255, 149, 0, 0.2);
}
.add-group-actions {
display: flex;
gap: 10px;
}
.btn {
padding: 6px 16px;
border-radius: 999px;
border: none;
font-size: 13px;
cursor: pointer;
transition: transform $motion-duration-sm $motion-easing-standard,
box-shadow $motion-duration-sm $motion-easing-standard,
background-color $motion-duration-sm $motion-easing-standard,
color $motion-duration-sm $motion-easing-standard;
}
.btn-primary {
background: $color-accent;
color: #1a1206;
box-shadow: 0 6px 16px rgba(255, 149, 0, 0.25);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
box-shadow: none;
}
.btn-primary:not(:disabled):hover {
transform: translateY(-1px);
box-shadow: 0 8px 18px rgba(255, 149, 0, 0.35);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: $color-text-primary;
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.16);
}
.add-group-fade-enter-active,
.add-group-fade-leave-active {
transition: opacity 220ms ease;
}
.add-group-fade-enter-from,
.add-group-fade-leave-to {
opacity: 0;
}
.add-group-fade-enter-active .add-group-dialog,
.add-group-fade-leave-active .add-group-dialog {
transition: transform 240ms ease, opacity 240ms ease;
}
.add-group-fade-enter-from .add-group-dialog,
.add-group-fade-leave-to .add-group-dialog {
transform: translateY(6px) scale(0.98);
opacity: 0;
}
@media (max-width: 520px) {
.add-group-icons {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,541 @@
<template>
<teleport to="body">
<transition name="edit-icon-fade" appear>
<div v-if="open" class="edit-icon-backdrop" @click.self="closeDialog">
<div class="edit-icon-dialog" role="dialog" aria-modal="true" aria-label="编辑图标">
<button class="dialog-close" type="button" @click="closeDialog">
<Icon icon="lucide:x" aria-hidden="true" />
</button>
<div class="form-row">
<label>地址</label>
<div class="input-group">
<input ref="urlInputRef" v-model="url" type="text" placeholder="https://"/>
<button
class="ghost-button"
type="button"
:disabled="!url.trim()"
@click="fetchFavicon"
>
获取图标
</button>
</div>
</div>
<div class="form-row">
<label>名称</label>
<input v-model="name" type="text" placeholder="网站名称" maxlength="16" />
</div>
<div class="form-row">
<label>图标颜色</label>
<div class="color-row">
<button
v-for="item in colorOptions"
:key="item.id"
class="color-dot"
:class="{ selected: selectedColorId === item.id }"
type="button"
:style="item.previewStyle"
@click="selectColor(item)"
></button>
<input
ref="colorInputRef"
v-model="customColor"
type="color"
class="color-input"
@input="updateCustomColor"
/>
</div>
</div>
<div class="form-row">
<label>图标文字</label>
<input v-model="iconText" type="text" placeholder="A" maxlength="2" />
</div>
<div class="icon-type-row">
<button
class="icon-type-card"
:class="{ active: iconType === 'current' }"
type="button"
@click="selectType('current')"
>
<div class="icon-preview" :style="currentPreviewStyle">
<span v-if="!currentImg">{{ currentPreviewText }}</span>
</div>
<span class="icon-type-label">当前图标</span>
</button>
<button
class="icon-type-card"
:class="{ active: iconType === 'text' }"
type="button"
@click="selectType('text')"
>
<div class="icon-preview" :style="{ backgroundColor: previewColor }">
<span>{{ previewText }}</span>
</div>
<span class="icon-type-label">文字图标</span>
</button>
<button
class="icon-type-card"
:class="{ active: iconType === 'image' }"
type="button"
@click="triggerUpload"
>
<div class="icon-preview image" :style="imagePreviewStyle">
<Icon v-if="!previewImage" icon="lucide:plus" aria-hidden="true" />
</div>
<span class="icon-type-label">上传</span>
</button>
<input
ref="fileInputRef"
class="file-input"
type="file"
accept="image/*"
@change="handleFileChange"
/>
</div>
<div class="form-actions">
<button class="btn btn-primary" type="button" :disabled="!canSubmit" @click="submit">
保存
</button>
</div>
<div v-if="errorMessage" class="form-error">{{ errorMessage }}</div>
</div>
</div>
</transition>
</teleport>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { Icon } from '@iconify/vue';
type IconItem = {
id: string;
name: string;
url: string;
img?: string;
text?: string;
bgColor?: string;
};
type ColorOption = {
id: string;
value: string;
previewStyle: Record<string, string>;
isCustom?: boolean;
};
const props = defineProps<{
open: boolean;
icon: IconItem | null;
}>();
const emit = defineEmits<{
(event: 'update:open', value: boolean): void;
(event: 'submit', payload: { id: string; name: string; url: string; bgColor: string; img?: string; text?: string }): void;
}>();
const colorOptions: ColorOption[] = [
{ id: 'blue', value: '#2f80ed', previewStyle: { backgroundColor: '#2f80ed' } },
{ id: 'orange', value: '#f2c94c', previewStyle: { backgroundColor: '#f2c94c' } },
{ id: 'red', value: '#eb5757', previewStyle: { backgroundColor: '#eb5757' } },
{ id: 'brown', value: '#4f4f4f', previewStyle: { backgroundColor: '#4f4f4f' } },
{ id: 'green', value: '#6fcf97', previewStyle: { backgroundColor: '#6fcf97' } },
{ id: 'navy', value: '#2d9cdb', previewStyle: { backgroundColor: '#2d9cdb' } },
{ id: 'gold', value: '#bb8f3e', previewStyle: { backgroundColor: '#bb8f3e' } },
{ id: 'plum', value: '#9b51e0', previewStyle: { backgroundColor: '#9b51e0' } },
{ id: 'rose', value: '#f15b5b', previewStyle: { backgroundColor: '#f15b5b' } },
{ id: 'blue-dark', value: '#2f5fd1', previewStyle: { backgroundColor: '#2f5fd1' } },
{ id: 'mint', value: '#8bd8bd', previewStyle: { backgroundColor: '#8bd8bd' } },
{ id: 'custom', value: '#7a7a7a', previewStyle: { backgroundImage: 'conic-gradient(#f66, #fa0, #6f6, #6cf, #66f, #c6f, #f66)' }, isCustom: true },
];
const url = ref('');
const name = ref('');
const iconText = ref('');
const selectedColorId = ref(colorOptions[0].id);
const customColor = ref('#8a8a8a');
const iconType = ref<'current' | 'text' | 'image'>('current');
const currentImg = ref('');
const currentText = ref('');
const fetchedImage = ref('');
const uploadedImage = ref('');
const errorMessage = ref('');
const fileInputRef = ref<HTMLInputElement | null>(null);
const colorInputRef = ref<HTMLInputElement | null>(null);
const urlInputRef = ref<HTMLInputElement | null>(null);
const canSubmit = computed(() => {
if (!name.value.trim() || !props.icon) return false;
if (iconType.value === 'image' && !previewImage.value) return false;
return true;
});
const previewText = computed(() => {
const manual = iconText.value.trim();
if (manual) return manual.slice(0, 2);
const fallback = name.value.trim();
return fallback ? fallback.charAt(0) : 'A';
});
const currentPreviewText = computed(() => {
if (currentText.value.trim()) return currentText.value.trim().slice(0, 2);
const fallback = name.value.trim();
return fallback ? fallback.charAt(0) : 'A';
});
const previewImage = computed(() => uploadedImage.value || fetchedImage.value);
const previewColor = computed(() => {
const option = colorOptions.find(item => item.id === selectedColorId.value);
if (option?.isCustom) return customColor.value;
return option?.value ?? colorOptions[0].value;
});
const currentPreviewStyle = computed(() => ({
backgroundColor: previewColor.value,
backgroundImage: currentImg.value ? `url('${currentImg.value}')` : 'none',
}));
const imagePreviewStyle = computed(() => ({
backgroundImage: previewImage.value ? `url('${previewImage.value}')` : 'none',
}));
const normalizeUrl = (value: string) => {
if (!value.trim()) return '#';
try {
return new URL(value).toString();
} catch {
try {
return new URL(`https://${value}`).toString();
} catch {
return '#';
}
}
};
const closeDialog = () => {
emit('update:open', false);
};
const selectColor = (item: ColorOption) => {
selectedColorId.value = item.id;
if (item.isCustom && colorInputRef.value) {
colorInputRef.value.click();
}
};
const updateCustomColor = () => {
selectedColorId.value = 'custom';
};
const selectType = (type: 'current' | 'text' | 'image') => {
iconType.value = type;
};
const triggerUpload = () => {
iconType.value = 'image';
fileInputRef.value?.click();
};
const handleFileChange = (event: Event) => {
const input = event.target as HTMLInputElement | null;
const file = input?.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
uploadedImage.value = String(reader.result);
iconType.value = 'image';
};
reader.readAsDataURL(file);
};
const fetchFavicon = () => {
errorMessage.value = '';
const raw = url.value.trim();
if (!raw) return;
const resolved = normalizeUrl(raw);
if (resolved === '#') {
errorMessage.value = '请输入有效的网址';
return;
}
const parsed = new URL(resolved);
const favicon = `https://www.google.com/s2/favicons?domain=${parsed.hostname}&sz=128`;
fetchedImage.value = favicon;
iconType.value = 'image';
};
const submit = () => {
if (!props.icon || !canSubmit.value) return;
const payload = {
id: props.icon.id,
name: name.value.trim(),
url: normalizeUrl(url.value.trim()),
bgColor: previewColor.value,
img: undefined as string | undefined,
text: undefined as string | undefined,
};
if (iconType.value === 'current') {
payload.img = currentImg.value || undefined;
payload.text = currentImg.value ? undefined : currentText.value.trim() || currentPreviewText.value;
} else if (iconType.value === 'text') {
payload.text = iconText.value.trim() || previewText.value;
} else if (iconType.value === 'image') {
payload.img = previewImage.value || undefined;
}
emit('submit', payload);
closeDialog();
};
const handleKeydown = (event: KeyboardEvent) => {
if (!props.open) return;
if (event.key === 'Escape') {
event.preventDefault();
closeDialog();
}
};
const initFromIcon = () => {
if (!props.icon) return;
url.value = props.icon.url ?? '';
name.value = props.icon.name ?? '';
iconText.value = props.icon.text ?? '';
currentImg.value = props.icon.img ?? '';
currentText.value = props.icon.text ?? '';
fetchedImage.value = '';
uploadedImage.value = '';
errorMessage.value = '';
iconType.value = props.icon.img ? 'current' : 'text';
const match = colorOptions.find(item => item.value === props.icon?.bgColor);
if (match) {
selectedColorId.value = match.id;
} else if (props.icon.bgColor) {
selectedColorId.value = 'custom';
customColor.value = props.icon.bgColor;
} else {
selectedColorId.value = colorOptions[0].id;
}
};
onMounted(() => {
window.addEventListener('keydown', handleKeydown);
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeydown);
});
watch(
() => [props.open, props.icon?.id],
([open]) => {
if (open) {
initFromIcon();
nextTick(() => urlInputRef.value?.focus());
}
}
);
</script>
<style lang="scss" scoped>
@import '@/styles/tokens.scss';
.edit-icon-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: $z-index-modal;
}
.edit-icon-dialog {
width: min(860px, 90vw);
padding: 28px 36px 32px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.96);
color: #2a2a2a;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.18);
position: relative;
}
.dialog-close {
position: absolute;
top: 16px;
right: 18px;
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.06);
color: #333;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.form-row {
display: grid;
grid-template-columns: 64px 1fr;
gap: 12px;
align-items: center;
font-size: 13px;
color: #6b6b6b;
margin-bottom: 12px;
}
.form-row input {
height: 34px;
border-radius: 18px;
border: 1px solid rgba(0, 0, 0, 0.08);
background: rgba(0, 0, 0, 0.04);
color: #2a2a2a;
padding: 0 12px;
outline: none;
}
.input-group {
display: flex;
gap: 12px;
}
.input-group input {
flex: 1;
}
.ghost-button {
padding: 6px 14px;
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.12);
background: transparent;
color: #2a2a2a;
cursor: pointer;
}
.ghost-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.color-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.color-dot {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
}
.color-dot.selected {
border-color: #111;
}
.color-input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.icon-type-row {
display: flex;
gap: 14px;
align-items: flex-end;
margin: 10px 0 18px;
}
.icon-type-card {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
background: transparent;
border: none;
color: #7a7a7a;
cursor: pointer;
}
.icon-type-card.active {
color: #2a2a2a;
}
.icon-preview {
width: 56px;
height: 56px;
border-radius: 16px;
background: rgba(0, 0, 0, 0.06);
border: 1px solid rgba(0, 0, 0, 0.08);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 20px;
font-weight: 600;
background-size: cover;
background-position: center;
}
.icon-preview.image {
background-color: rgba(0, 0, 0, 0.04);
}
.icon-type-label {
font-size: 12px;
}
.file-input {
display: none;
}
.form-actions {
margin-top: 4px;
}
.btn {
padding: 8px 28px;
border-radius: 16px;
border: none;
font-size: 13px;
cursor: pointer;
}
.btn-primary {
background: #1e88ff;
color: #fff;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.form-error {
color: #e24b4b;
font-size: 12px;
margin-top: 8px;
}
.edit-icon-fade-enter-active,
.edit-icon-fade-leave-active {
transition: opacity 220ms ease;
}
.edit-icon-fade-enter-from,
.edit-icon-fade-leave-to {
opacity: 0;
}
.edit-icon-fade-enter-active .edit-icon-dialog,
.edit-icon-fade-leave-active .edit-icon-dialog {
transition: transform 240ms ease, opacity 240ms ease;
}
.edit-icon-fade-enter-from .edit-icon-dialog,
.edit-icon-fade-leave-to .edit-icon-dialog {
transform: translateY(6px) scale(0.98);
opacity: 0;
}
</style>

View File

@ -4,6 +4,7 @@ import Muuri from 'muuri';
import { useLayoutStore } from '@/store/useLayoutStore';
import { useWidgetsStore } from '@/store/useWidgetsStore';
import { useUIStore } from '@/store/useUIStore';
import { useSettingsStore } from '@/store/useSettingsStore';
import IconCard from '@/components/IconCard/index.vue';
import WidgetCard from '@/components/WidgetCard/index.vue';
@ -15,6 +16,7 @@ interface Icon {
name: string;
url: string;
img?: string;
text?: string;
bgColor?: string;
size?: GridItemSize;
}
@ -41,6 +43,7 @@ const GRID_ORDER_STORAGE_KEY = 'itab_grid_order';
const layoutStore = useLayoutStore();
const widgetsStore = useWidgetsStore();
const uiStore = useUIStore();
const settingsStore = useSettingsStore();
const gridRef = ref<HTMLElement | null>(null);
const grid = ref<any | null>(null);
const isDragging = ref(false);
@ -51,6 +54,9 @@ const resizedKeys = ref(new Set<string>());
const pendingResizedKeys = new Set<string>();
const widgetSizeMap = new Map<string, GridItemSize>();
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 layoutAnimationMs = 240;
const layoutEasing = 'cubic-bezier(0.4, 0, 0.2, 1)';
@ -59,9 +65,10 @@ const buildDefaultOrder = (): GridOrderEntry[] => [
...layoutStore.icons.map(icon => ({ id: icon.id, type: 'icon' as const })),
];
const persistGridOrder = (order: GridOrderEntry[]) => {
const applyGridOrder = (order: GridOrderEntry[], syncStoreOrder = true) => {
gridOrder.value = order;
localStorage.setItem(GRID_ORDER_STORAGE_KEY, JSON.stringify(order));
if (!syncStoreOrder) return;
const iconOrder = order.filter(item => item.type === 'icon').map(item => item.id);
const widgetOrder = order.filter(item => item.type === 'widget').map(item => item.id);
if (iconOrder.length) {
@ -87,7 +94,7 @@ const loadGridOrder = () => {
if (!gridOrder.value.length) {
gridOrder.value = buildDefaultOrder();
}
persistGridOrder(gridOrder.value);
applyGridOrder(gridOrder.value, true);
};
const ensureOrderConsistency = () => {
@ -124,7 +131,7 @@ const ensureOrderConsistency = () => {
});
if (orderChanged) {
persistGridOrder(nextOrder);
applyGridOrder(nextOrder, false);
} else {
gridOrder.value = nextOrder;
}
@ -176,7 +183,8 @@ const handleClick = (event: MouseEvent) => {
if (!itemEl || itemEl.dataset.type !== 'icon') return;
const link = target.closest('a') as HTMLAnchorElement | null;
if (link && link.href) {
window.open(link.href, '_blank');
const targetMode = settingsStore.openInNewTab ? '_blank' : '_self';
window.open(link.href, targetMode);
}
};
@ -190,7 +198,10 @@ const handleClickCapture = (event: MouseEvent) => {
const handleContextMenu = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const itemEl = target.closest('.grid-item') as HTMLElement | null;
if (!itemEl) return;
if (!itemEl) {
uiStore.openContextMenu(event.clientX, event.clientY, null, 'desktop');
return;
}
const id = itemEl.dataset.id;
const type = itemEl.dataset.type as GridItemType | undefined;
if (id && type) {
@ -211,15 +222,31 @@ const persistOrderFromGrid = async () => {
}
}
if (nextOrder.length) {
persistGridOrder(nextOrder);
applyGridOrder(nextOrder, true);
}
await nextTick();
grid.value?.synchronize();
grid.value?.layout();
};
const syncGridItems = () => {
if (!grid.value || !gridRef.value) return;
const existingItems = grid.value.getItems() ?? [];
const existingElements = new Set(existingItems.map((item: any) => item.getElement()));
const domElements = Array.from(gridRef.value.querySelectorAll('.grid-item'));
const toAdd = domElements.filter(element => !existingElements.has(element));
if (toAdd.length) {
grid.value.add(toAdd, { layout: false });
}
const toRemove = existingItems.filter((item: any) => !gridRef.value?.contains(item.getElement()));
if (toRemove.length) {
grid.value.remove(toRemove, { removeElements: false });
}
};
const refreshLayout = async (instant = false) => {
await nextTick();
syncGridItems();
grid.value?.refreshItems();
if (instant) {
grid.value?.layout(true);
@ -256,6 +283,8 @@ const syncSizeMap = (
onMounted(async () => {
loadGridOrder();
previousIconIds.value = new Set(layoutStore.icons.map(icon => icon.id));
previousWidgetIds.value = new Set(widgetsStore.widgets.map(widget => widget.id));
await nextTick();
if (!gridRef.value) return;
grid.value = new Muuri(gridRef.value, {
@ -267,7 +296,7 @@ onMounted(async () => {
},
dragSort: true,
layout: {
fillGaps: true,
fillGaps: settingsStore.autoAlign,
rounding: true,
},
layoutDuration: layoutAnimationMs,
@ -297,19 +326,36 @@ onMounted(async () => {
});
grid.value.on('layoutEnd', () => {
if (!pendingResizedKeys.size) return;
pendingResizedKeys.clear();
resizedKeys.value = new Set<string>();
if (pendingResizedKeys.size) {
pendingResizedKeys.clear();
resizedKeys.value = new Set<string>();
}
if (enteringKeys.value.size) {
enteringKeys.value = new Set<string>();
}
});
grid.value.layout(true);
});
watch(
() => [layoutStore.icons.length, widgetsStore.widgets.length],
() => {
() => [layoutStore.icons.map(icon => icon.id), widgetsStore.widgets.map(widget => widget.id)],
([iconIds, widgetIds]) => {
ensureOrderConsistency();
refreshLayout();
const nextIconIds = new Set(iconIds);
const nextWidgetIds = new Set(widgetIds);
const addedIcons = iconIds.filter(id => !previousIconIds.value.has(id));
const addedWidgets = widgetIds.filter(id => !previousWidgetIds.value.has(id));
if (addedIcons.length || addedWidgets.length) {
const nextEntering = new Set(enteringKeys.value);
for (const id of addedIcons) nextEntering.add(`icon:${id}`);
for (const id of addedWidgets) nextEntering.add(`widget:${id}`);
enteringKeys.value = nextEntering;
}
previousIconIds.value = nextIconIds;
previousWidgetIds.value = nextWidgetIds;
const instant = addedIcons.length > 0 || addedWidgets.length > 0;
refreshLayout(instant);
}
);
@ -341,6 +387,28 @@ watch(
}
);
watch(
() => settingsStore.autoAlign,
value => {
if (!grid.value) return;
// Muuri has no public setter for fillGaps; update internal setting and relayout.
grid.value._settings.layout.fillGaps = value;
grid.value.layout(true);
}
);
watch(
() => [
settingsStore.layoutDensity,
settingsStore.iconDensity,
settingsStore.compactSidebar,
],
() => {
if (!grid.value) return;
refreshLayout(true);
}
);
onUnmounted(() => {
pendingResizedKeys.clear();
grid.value?.destroy();
@ -363,7 +431,10 @@ onUnmounted(() => {
class="grid-item"
:class="[
`size-${item.type === 'widget' ? item.widget.size : (item.icon.size ?? '1x1')}`,
{ 'is-resized': resizedKeys.has(`${item.type}:${item.id}`) }
{
'is-resized': resizedKeys.has(`${item.type}:${item.id}`),
'is-entering': enteringKeys.has(`${item.type}:${item.id}`)
}
]"
:data-id="item.id"
:data-type="item.type"
@ -388,7 +459,8 @@ onUnmounted(() => {
-ms-user-select: none; /* IE 10+/Edge 浏览器 */
--cell-size: var(--grid-cell-size);
--cell-gap: var(--grid-gap);
padding: calc(var(--cell-gap) / 2);
--cell-gap-padding: max(var(--cell-gap), 0px);
padding: calc(var(--cell-gap-padding) / 2);
box-sizing: border-box;
}
@ -399,6 +471,7 @@ onUnmounted(() => {
margin: calc(var(--cell-gap) / 2);
cursor: grab;
will-change: transform;
transition: opacity 160ms ease;
}
.grid-item.is-resized {
@ -406,6 +479,10 @@ onUnmounted(() => {
border-radius: $border-radius-small;
}
.grid-item.is-entering {
opacity: 0;
}
.grid-item.size-1x2 {
height: calc(var(--cell-size) * 2 + var(--cell-gap));
}

View File

@ -8,8 +8,9 @@
>
<div class="icon-card" :style="{ backgroundColor: props.icon.bgColor }">
<img v-if="props.icon.img" :src="props.icon.img" :alt="props.icon.name" />
<span v-else>{{ props.icon.name.charAt(0) }}</span>
<span v-else>{{ props.icon.text?.trim() || props.icon.name.charAt(0) }}</span>
</div>
<div class="card-spacer"></div>
<div class="label">{{ props.icon.name }}</div>
</a>
</template>
@ -23,6 +24,7 @@ interface Icon {
url: string;
size?: IconSize;
img?: string;
text?: string;
bgColor?: string;
}
@ -36,14 +38,13 @@ const props = defineProps<{
@import '@/styles/tokens.scss';
.icon-card-wrapper {
display: flex;
flex-direction: column;
display: grid;
grid-template-rows: minmax(0, 1fr) var(--icon-label-margin-top) auto;
align-items: stretch;
justify-content: flex-start;
width: 100%;
height: 100%;
text-decoration: none;
border-radius: $border-radius-small;
border-radius: var(--icon-radius, #{$border-radius-small});
padding: var(--icon-card-padding);
transition: transform $motion-duration-sm $motion-easing-standard;
user-select: none; // 拖拽时避免选中文本
@ -59,9 +60,9 @@ const props = defineProps<{
.icon-card {
width: 100%;
flex: 1 1 auto;
height: 100%;
min-height: 0;
border-radius: $border-radius-small;
border-radius: var(--icon-radius, #{$border-radius-small});
display: flex;
align-items: center;
justify-content: center;
@ -70,7 +71,6 @@ const props = defineProps<{
color: white;
box-shadow: $shadow-md;
transition: box-shadow $motion-duration-sm $motion-easing-standard;
margin-bottom: var(--icon-label-margin-top);
background-size: cover;
background-position: center;
pointer-events: none; // 指针事件交给外层容器处理
@ -78,20 +78,21 @@ const props = defineProps<{
img {
width: 100%;
height: 100%;
border-radius: $border-radius-small;
border-radius: var(--icon-radius, #{$border-radius-small});
object-fit: cover;
}
}
.icon-card-wrapper.size-1x1 {
align-items: center;
justify-content: center;
.card-spacer {
height: 100%;
pointer-events: none;
}
.icon-card-wrapper.size-1x1 .icon-card {
width: var(--icon-size);
height: var(--icon-size);
flex: 0 0 auto;
width: auto;
height: 100%;
aspect-ratio: 1 / 1;
justify-self: center;
}
.label {
@ -99,6 +100,7 @@ const props = defineProps<{
color: $color-text-primary;
text-align: center;
width: 100%;
display: var(--icon-label-display, block);
white-space: normal;
word-break: break-word;
line-height: 1.2;

View File

@ -1,8 +1,13 @@
<template>
<main class="main-content">
<main class="main-content" @contextmenu="handleDesktopContextMenu">
<div class="main-content-container">
<TheClock />
<TheSearchBar />
<TheClock
v-if="showClock"
:show-date="showDate"
:show-seconds="showSeconds"
:use-24-hour="use24Hour"
/>
<TheSearchBar v-if="showSearch" />
<GridCanvas />
</div>
</main>
@ -10,11 +15,28 @@
<script setup lang="ts">
import { useUIStore } from '@/store/useUIStore';
import TheClock from '@/components/TheClock/index.vue';
import TheSearchBar from '@/components/TheSearchBar/index.vue';
import GridCanvas from '@/components/GridCanvas/index.vue';
import { useSettingsStore } from '@/store/useSettingsStore';
import { storeToRefs } from 'pinia';
const uiStore = useUIStore();
const settingsStore = useSettingsStore();
const { showSearch, showClock, showDate, showSeconds, use24Hour } = storeToRefs(settingsStore);
const handleDesktopContextMenu = (event: MouseEvent) => {
const target = event.target as HTMLElement | null;
if (!target) return;
if (target.closest('.grid-item')) return;
if (target.closest('.grid-canvas')) return;
if (target.closest('input, textarea')) return;
event.preventDefault();
uiStore.openContextMenu(event.clientX, event.clientY, null, 'desktop');
};
</script>

File diff suppressed because it is too large Load Diff

View File

@ -1,26 +1,52 @@
<template>
<div class="clock-container">
<div class="time">{{ currentTime }}</div>
<div class="date">{{ currentDate }}</div>
<div v-if="showDate" class="date">{{ currentDate }}</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
const props = defineProps<{
showDate?: boolean;
showSeconds?: boolean;
use24Hour?: boolean;
}>();
const currentTime = ref('');
const currentDate = ref('');
let timerId: number;
const showDate = computed(() => props.showDate !== false);
const showSeconds = computed(() => props.showSeconds === true);
const use24Hour = computed(() => props.use24Hour !== false);
const buildTimeOptions = (): Intl.DateTimeFormatOptions => {
const options: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
hour12: !use24Hour.value,
};
if (showSeconds.value) {
options.second = '2-digit';
}
return options;
};
const updateTime = () => {
const now = new Date();
currentTime.value = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
currentDate.value = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
}).format(now);
currentTime.value = now.toLocaleTimeString('zh-CN', buildTimeOptions());
if (showDate.value) {
currentDate.value = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
}).format(now);
} else {
currentDate.value = '';
}
};
onMounted(() => {
@ -31,6 +57,10 @@ onMounted(() => {
onUnmounted(() => {
clearInterval(timerId);
});
watch([showDate, showSeconds, use24Hour], () => {
updateTime();
});
</script>
<style lang="scss" scoped>

View File

@ -1,6 +1,11 @@
<template>
<div v-if="uiStore.contextMenu.isOpen" class="context-menu-overlay" @click="close" @contextmenu.prevent="close">
<div class="context-menu" :style="menuStyle">
<template>
<div
v-if="uiStore.contextMenu.isOpen"
class="context-menu-overlay"
@click="close"
@contextmenu.prevent="close"
>
<div ref="menuRef" class="context-menu" :style="menuStyle">
<ul v-if="uiStore.contextMenu.itemType === 'icon'">
<li class="layout-section">
<div class="layout-title">
@ -12,9 +17,9 @@
</div>
</li>
<li><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="menu-icon"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>在新标签页打开</li>
<li><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="menu-icon"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"></path><path d="m15 5 4 4"></path></svg>编辑图标</li>
<li @click="handleEditIcon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="menu-icon"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"></path><path d="m15 5 4 4"></path></svg>编辑图标</li>
<li><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="menu-icon"><path d="m12 19-7-7 7-7 7 7-7 7Z"></path><path d="M19 5v14"></path><path d="M5 5v14"></path></svg>编辑主页</li>
<li @click="deleteItem"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="menu-icon"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>删除</li>
<li class="menu-danger" @click="deleteItem"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="menu-icon"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>删除</li>
</ul>
<ul v-else-if="uiStore.contextMenu.itemType === 'widget'">
<li class="layout-section">
@ -26,33 +31,115 @@
<span v-for="size in widgetSizes" :key="size" @click="changeWidgetSize(size)" class="size-option">{{ size }}</span>
</div>
</li>
<li><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="menu-icon"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"></path></svg>编辑组件</li>
<li @click="deleteItem"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="menu-icon"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>删除</li>
<li @click="handleEditWidget"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="menu-icon"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"></path></svg>编辑组件</li>
<li class="menu-danger" @click="deleteItem"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="menu-icon"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>删除</li>
</ul>
<ul v-else-if="uiStore.contextMenu.itemType === 'desktop'" class="desktop-menu">
<li class="desktop-item" @click="handleDesktopAction('add-icon')">
<span class="desktop-label">添加图标</span>
<span class="desktop-trailing">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="menu-trailing-icon"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
</span>
</li>
<li class="desktop-item" @click="handleDesktopAction('wallpaper')">
<span class="desktop-label">换壁纸</span>
<span class="desktop-trailing">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="menu-trailing-icon"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="menu-trailing-icon"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.6l-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8z"></path></svg>
</span>
</li>
<li class="desktop-item" @click="handleDesktopAction('search')">
<span class="desktop-label">本地搜索</span>
<span class="desktop-shortcut">Ctrl+F</span>
</li>
<li class="desktop-item" @click="handleDesktopAction('backup')">
<span class="desktop-label">立即备份</span>
<span class="desktop-trailing">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="menu-trailing-icon"><path d="M20 16.5a4.5 4.5 0 0 0-1.4-8.8A6 6 0 0 0 6 9.8a4 4 0 0 0 0 8h13.5z"></path><polyline points="12 12 12 19"></polyline><polyline points="9 16 12 19 15 16"></polyline></svg>
</span>
</li>
<li class="desktop-item" @click="handleDesktopAction('settings')">
<span class="desktop-label">设置</span>
<span class="desktop-trailing">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="menu-trailing-icon"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.65 1.65 0 0 0-1.8-.3 1.65 1.65 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.65 1.65 0 0 0-1-1.5 1.65 1.65 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.65 1.65 0 0 0 .3-1.8 1.65 1.65 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.65 1.65 0 0 0 1.5-1 1.65 1.65 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.65 1.65 0 0 0 1.8.3H9a1.65 1.65 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.65 1.65 0 0 0 1 1.5 1.65 1.65 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.65 1.65 0 0 0-.3 1.8V9a1.65 1.65 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.65 1.65 0 0 0-1.5 1z"></path></svg>
</span>
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useUIStore } from '@/store/useUIStore';
import { useLayoutStore } from '@/store/useLayoutStore';
import { useWidgetsStore } from '@/store/useWidgetsStore';
const emit = defineEmits<{
(event: 'add-icon'): void;
(event: 'edit-icon', id: string): void;
(event: 'edit-widget', id: string): void;
(event: 'settings'): void;
}>();
const uiStore = useUIStore();
const layoutStore = useLayoutStore();
const widgetsStore = useWidgetsStore();
type ItemSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
type DesktopAction = 'add-icon' | 'wallpaper' | 'search' | 'backup' | 'settings';
const widgetSizes: ItemSize[] = ['1x1', '1x2', '2x1', '2x2', '2x4'];
const iconSizes: ItemSize[] = ['1x1', '1x2', '2x1', '2x2', '2x4'];
const menuRef = ref<HTMLElement | null>(null);
const menuPosition = ref({ x: 0, y: 0 });
const viewportPadding = 8;
const clampMenuPosition = () => {
const menuEl = menuRef.value;
const baseX = uiStore.contextMenu.x;
const baseY = uiStore.contextMenu.y;
if (!menuEl) {
menuPosition.value = { x: baseX, y: baseY };
return;
}
const rect = menuEl.getBoundingClientRect();
const maxX = Math.max(viewportPadding, window.innerWidth - rect.width - viewportPadding);
const maxY = Math.max(viewportPadding, window.innerHeight - rect.height - viewportPadding);
const x = Math.min(Math.max(baseX, viewportPadding), maxX);
const y = Math.min(Math.max(baseY, viewportPadding), maxY);
menuPosition.value = { x, y };
};
const menuStyle = computed(() => ({
left: `${uiStore.contextMenu.x}px`,
top: `${uiStore.contextMenu.y}px`,
left: `${menuPosition.value.x}px`,
top: `${menuPosition.value.y}px`,
}));
watch(
() => [
uiStore.contextMenu.isOpen,
uiStore.contextMenu.x,
uiStore.contextMenu.y,
uiStore.contextMenu.itemType,
],
async () => {
if (!uiStore.contextMenu.isOpen) return;
await nextTick();
clampMenuPosition();
}
);
onMounted(() => {
window.addEventListener('resize', clampMenuPosition);
});
onUnmounted(() => {
window.removeEventListener('resize', clampMenuPosition);
});
const close = () => {
uiStore.closeContextMenu();
};
@ -62,12 +149,28 @@ const deleteItem = () => {
if (uiStore.contextMenu.itemType === 'icon') {
layoutStore.deleteIcon(uiStore.contextMenu.itemId);
} else if (uiStore.contextMenu.itemType === 'widget') {
// 组件删除逻辑
widgetsStore.deleteWidget(uiStore.contextMenu.itemId);
}
}
close();
};
const handleEditIcon = () => {
if (uiStore.contextMenu.itemType !== 'icon') return;
if (uiStore.contextMenu.itemId) {
emit('edit-icon', uiStore.contextMenu.itemId);
}
close();
};
const handleEditWidget = () => {
if (uiStore.contextMenu.itemType !== 'widget') return;
if (uiStore.contextMenu.itemId) {
emit('edit-widget', uiStore.contextMenu.itemId);
}
close();
};
const changeWidgetSize = (newSize: ItemSize) => {
if (uiStore.contextMenu.itemId) {
widgetsStore.updateWidgetSize(uiStore.contextMenu.itemId, newSize);
@ -81,6 +184,16 @@ const changeIconSize = (newSize: ItemSize) => {
}
close();
};
const handleDesktopAction = (action: DesktopAction) => {
if (action === 'add-icon') {
emit('add-icon');
}
if (action === 'settings') {
emit('settings');
}
close();
};
</script>
<style lang="scss" scoped>
@ -105,6 +218,8 @@ const changeIconSize = (newSize: ItemSize) => {
box-shadow: $shadow-lg;
z-index: $z-index-menu;
padding: var(--context-menu-padding);
transform-origin: top left;
animation: context-menu-pop 110ms cubic-bezier(0.16, 1, 0.3, 1);
ul {
list-style: none;
@ -136,8 +251,8 @@ const changeIconSize = (newSize: ItemSize) => {
}
}
li:last-child {
color: #ff5c5c; // 删除项浅红色
li.menu-danger {
color: #ff5c5c;
.menu-icon {
stroke: #ff5c5c;
@ -151,6 +266,16 @@ const changeIconSize = (newSize: ItemSize) => {
}
}
@keyframes context-menu-pop {
0% {
opacity: 0;
transform: scale(0.92);
}
100% {
transform: scale(1);
}
}
.layout-title {
display: flex;
align-items: center;
@ -167,9 +292,9 @@ const changeIconSize = (newSize: ItemSize) => {
display: flex;
justify-content: center;
align-items: center;
width: var(--context-menu-layout-option-width); // 固定宽度便于对齐
padding: var(--context-menu-layout-option-padding-y) 0; // 固定宽度下调整内边距
border-radius: var(--radius-pill); // 胶囊形状
width: var(--context-menu-layout-option-width);
padding: var(--context-menu-layout-option-padding-y) 0;
border-radius: var(--radius-pill);
background-color: rgba(255, 255, 255, 0.08);
cursor: pointer;
font-size: 12px;
@ -179,4 +304,33 @@ const changeIconSize = (newSize: ItemSize) => {
}
}
}
.desktop-menu {
.desktop-item {
justify-content: space-between;
gap: 12px;
}
}
.desktop-label {
font-size: 14px;
}
.desktop-trailing {
display: flex;
align-items: center;
gap: 8px;
color: $color-text-secondary;
}
.menu-trailing-icon {
width: 16px;
height: 16px;
stroke: currentColor;
}
.desktop-shortcut {
font-size: 12px;
color: $color-text-secondary;
}
</style>

View File

@ -1,17 +1,39 @@
<template>
<template>
<div class="search-bar-wrapper">
<div class="search-bar">
<div class="search-icon">
<button class="search-icon" type="button" @click="handleSubmit">
<!-- 搜索图标矢量 -->
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
</div>
<input type="text" placeholder="输入搜索内容" />
</button>
<input
v-model="query"
type="text"
:placeholder="placeholderText"
@keydown.enter.prevent="handleSubmit"
/>
</div>
</div>
</template>
<script setup lang="ts">
// 搜索逻辑后续补充
import { computed, ref } from 'vue';
import { useSettingsStore } from '@/store/useSettingsStore';
import { getSearchProvider } from '@/config/settingsPresets';
const settingsStore = useSettingsStore();
const query = ref('');
const provider = computed(() => getSearchProvider(settingsStore.searchProvider));
const placeholderText = computed(
() => provider.value.placeholder || '输入搜索内容'
);
const handleSubmit = () => {
const text = query.value.trim();
if (!text) return;
const target = settingsStore.openInNewTab ? '_blank' : '_self';
const url = `${provider.value.url}${encodeURIComponent(text)}`;
window.open(url, target);
};
</script>
<style lang="scss" scoped>
@ -45,6 +67,13 @@
.search-icon {
color: $color-text-secondary;
margin-right: 12px;
border: none;
background: transparent;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
input {

View File

@ -1,53 +1,274 @@
<template>
<template>
<aside class="sidebar">
<div class="sidebar-top">
<div class="profile-icon">
<!-- 用户头像占位 -->
</div>
<button
class="profile-icon"
type="button"
title="Profile"
aria-label="Profile"
@click="handleProfile"
>
<span class="profile-initial">U</span>
</button>
</div>
<nav class="sidebar-nav">
<a href="#" class="nav-item active">
<!-- 图标主页 -->
<span>主页</span>
</a>
<a href="#" class="nav-item">
<!-- 图标编程 -->
<span>编程</span>
</a>
<a href="#" class="nav-item">
<!-- 图标设计 -->
<span>设计</span>
</a>
<a href="#" class="nav-item">
<!-- 图标产品 -->
<span>产品</span>
</a>
<a href="#" class="nav-item">
<!-- 图标AI -->
<span>AI</span>
</a>
<a href="#" class="nav-item">
<!-- 图标摸鱼 -->
<span>摸鱼</span>
</a>
<a href="#" class="nav-item add-item">
<!-- 图标新增 -->
</a>
<button
v-for="item in topItems"
:key="item.id"
class="nav-item"
:class="[
item.variant ? `nav-item--${item.variant}` : '',
{ active: activeId === item.id, 'has-icon': !!getGroupIcon(item.iconId) }
]"
type="button"
:title="item.label"
:aria-current="activeId === item.id ? 'page' : undefined"
@click="handleItemClick(item)"
@contextmenu.prevent="handleItemContextMenu($event, item)"
>
<Icon
v-if="getGroupIcon(item.iconId)"
class="nav-icon"
:icon="getGroupIcon(item.iconId)"
aria-hidden="true"
/>
<span class="nav-text">{{ item.label }}</span>
</button>
</nav>
<div class="sidebar-bottom">
<a href="#" class="nav-item">
<!-- 图标设置 -->
</a>
<button
v-for="item in bottomItems"
:key="item.id"
class="nav-item"
:class="{ active: activeId === item.id, 'has-icon': !!getGroupIcon(item.iconId) }"
type="button"
:title="item.label"
:aria-current="activeId === item.id ? 'page' : undefined"
@click="handleItemClick(item)"
@contextmenu.prevent="handleItemContextMenu($event, item)"
>
<Icon
v-if="getGroupIcon(item.iconId)"
class="nav-icon"
:icon="getGroupIcon(item.iconId)"
aria-hidden="true"
/>
<span class="nav-text">{{ item.label }}</span>
</button>
</div>
</aside>
<teleport to="body">
<div
v-if="menuState.open"
class="sidebar-menu-overlay"
@click.self="closeMenu"
@contextmenu.prevent="closeMenu"
>
<div ref="menuRef" class="sidebar-menu" :style="menuStyle">
<button class="sidebar-menu-item" type="button" @click="handleEdit">
<Icon class="menu-icon" icon="lucide:pencil" aria-hidden="true" />
编辑
</button>
<button
class="sidebar-menu-item menu-danger"
type="button"
@click="handleRemove"
>
<Icon class="menu-icon" icon="lucide:trash-2" aria-hidden="true" />
移除
</button>
</div>
</div>
</teleport>
</template>
<script setup lang="ts">
// 该组件暂时不需要脚本逻辑。
import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue';
import { Icon } from '@iconify/vue';
import { groupIcons } from '@/config/groupIcons';
import {
sidebarBaseItems,
sidebarAddItem,
sidebarBottomItems,
type SidebarItem
} from '@/config/sidebarItems';
type NavItem = SidebarItem;
type SidebarOverride = {
label?: string;
iconId?: string;
};
const STORAGE_KEY = 'itab_sidebar_active';
const props = defineProps<{
groups?: NavItem[];
baseOverrides?: Record<string, SidebarOverride>;
hiddenBaseIds?: string[];
}>();
const baseIdSet = new Set(sidebarBaseItems.map(item => item.id));
const resolvedBaseItems = computed(() => {
const overrides = props.baseOverrides ?? {};
const hidden = new Set(props.hiddenBaseIds ?? []);
return sidebarBaseItems
.filter(item => !hidden.has(item.id))
.map(item => {
const override = overrides[item.id];
return {
...item,
label: override?.label ?? item.label,
iconId: override?.iconId ?? item.iconId,
};
});
});
const topItems = computed(() => [...resolvedBaseItems.value, ...(props.groups ?? []), sidebarAddItem]);
const bottomItems = computed(() => sidebarBottomItems);
const allItems = computed(() => [...topItems.value, ...bottomItems.value]);
const selectableItems = computed(() => allItems.value.filter(item => !item.action));
const groupIconMap = new Map(groupIcons.map(icon => [icon.id, icon.icon]));
const getGroupIcon = (iconId?: string) => (iconId ? groupIconMap.get(iconId) : undefined);
const emit = defineEmits<{
(event: 'select', id: string): void;
(event: 'add'): void;
(event: 'edit-group', payload: { id: string; source: 'base' | 'group' }): void;
(event: 'remove-group', payload: { id: string; source: 'base' | 'group' }): void;
(event: 'settings'): void;
(event: 'profile'): void;
}>();
const activeId = ref(
resolvedBaseItems.value[0]?.id ??
props.groups?.[0]?.id ??
selectableItems.value[0]?.id ??
sidebarAddItem.id
);
onMounted(() => {
const saved = localStorage.getItem(STORAGE_KEY);
const allowed = selectableItems.value.some(item => item.id === saved);
if (saved && allowed) {
activeId.value = saved;
}
});
watch(selectableItems, items => {
if (!items.length) return;
if (!items.some(item => item.id === activeId.value)) {
setActive(items[0].id);
}
});
const setActive = (id: string) => {
if (!selectableItems.value.some(item => item.id === id)) return;
activeId.value = id;
localStorage.setItem(STORAGE_KEY, id);
};
const handleItemClick = (item: NavItem) => {
if (item.action === 'add') {
emit('add');
return;
}
if (item.action === 'settings') {
emit('settings');
return;
}
setActive(item.id);
emit('select', item.id);
};
const handleProfile = () => {
emit('profile');
};
const menuRef = ref<HTMLElement | null>(null);
const menuPosition = ref({ x: 0, y: 0 });
const menuState = ref<{ open: boolean; itemId: string | null; source: 'base' | 'group' | null }>({
open: false,
itemId: null,
source: null,
});
const viewportPadding = 8;
const menuStyle = computed(() => ({
left: `${menuPosition.value.x}px`,
top: `${menuPosition.value.y}px`,
}));
const clampMenuPosition = () => {
const menuEl = menuRef.value;
if (!menuEl) return;
const rect = menuEl.getBoundingClientRect();
const maxX = Math.max(viewportPadding, window.innerWidth - rect.width - viewportPadding);
const maxY = Math.max(viewportPadding, window.innerHeight - rect.height - viewportPadding);
const x = Math.min(Math.max(menuPosition.value.x, viewportPadding), maxX);
const y = Math.min(Math.max(menuPosition.value.y, viewportPadding), maxY);
menuPosition.value = { x, y };
};
const closeMenu = () => {
menuState.value = { open: false, itemId: null, source: null };
};
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && menuState.value.open) {
event.preventDefault();
closeMenu();
}
};
const handleItemContextMenu = async (event: MouseEvent, item: NavItem) => {
if (item.action) return;
menuState.value = {
open: true,
itemId: item.id,
source: baseIdSet.has(item.id) ? 'base' : 'group',
};
menuPosition.value = { x: event.clientX, y: event.clientY };
await nextTick();
clampMenuPosition();
};
const handleEdit = () => {
if (!menuState.value.itemId || !menuState.value.source) return;
emit('edit-group', { id: menuState.value.itemId, source: menuState.value.source });
closeMenu();
};
const handleRemove = () => {
if (!menuState.value.itemId || !menuState.value.source) return;
emit('remove-group', { id: menuState.value.itemId, source: menuState.value.source });
closeMenu();
};
watch(
() => menuState.value.open,
open => {
if (!open) return;
clampMenuPosition();
}
);
onMounted(() => {
window.addEventListener('resize', clampMenuPosition);
window.addEventListener('keydown', handleKeydown);
});
onUnmounted(() => {
window.removeEventListener('resize', clampMenuPosition);
window.removeEventListener('keydown', handleKeydown);
});
</script>
<style lang="scss" scoped>
@import '@/styles/tokens.scss';
.sidebar {
@ -57,7 +278,7 @@
flex-direction: column;
align-items: center;
padding: var(--sidebar-padding-y) 0;
background-color: rgba(10, 10, 10, 0.5); // 半透明黑色
background-color: rgba(10, 10, 10, 0.5);
backdrop-filter: blur($backdrop-filter-blur) saturate($backdrop-filter-saturation);
border-right: 1px solid rgba(255, 255, 255, 0.08);
position: fixed;
@ -72,11 +293,28 @@
border-radius: 50%;
background-color: $color-accent;
margin-bottom: var(--sidebar-profile-margin-bottom);
// 可放置“登录”文本或真实头像
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: $color-text-primary;
border: none;
cursor: pointer;
transition: transform $motion-duration-sm $motion-easing-standard,
box-shadow $motion-duration-sm $motion-easing-standard;
}
.profile-icon:hover {
box-shadow: $shadow-sm;
}
.profile-icon:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.35);
outline-offset: 2px;
}
.profile-initial {
font-weight: 600;
}
.sidebar-nav {
@ -97,43 +335,156 @@
justify-content: center;
text-decoration: none;
color: $color-text-secondary;
transition: background-color $motion-duration-sm $motion-easing-standard;
background: transparent;
border: 1px solid transparent;
transition: background-color $motion-duration-sm $motion-easing-standard,
color $motion-duration-sm $motion-easing-standard,
transform $motion-duration-sm $motion-easing-standard,
box-shadow $motion-duration-sm $motion-easing-standard,
border-color $motion-duration-sm $motion-easing-standard;
position: relative;
span {
font-size: 12px;
margin-top: 4px; // 预留图标间距
}
&.active, &:hover {
background-color: rgba(255, 255, 255, 0.1);
color: $color-text-primary;
}
&.active::before {
content: '';
position: absolute;
left: var(--sidebar-active-indicator-offset);
top: 50%;
transform: translateY(-50%);
width: var(--sidebar-active-indicator-width);
height: var(--sidebar-active-indicator-height);
background-color: $color-accent;
border-radius: 2px;
}
cursor: pointer;
}
.add-item {
.nav-item::before {
content: '';
position: absolute;
left: var(--sidebar-active-indicator-offset);
top: 50%;
transform: translateY(-50%) scaleY(0.6);
width: var(--sidebar-active-indicator-width);
height: var(--sidebar-active-indicator-height);
background-color: $color-accent;
border-radius: 2px;
opacity: 0;
transition: opacity $motion-duration-sm $motion-easing-standard,
transform $motion-duration-sm $motion-easing-standard;
}
.nav-item:hover {
background-color: rgba(255, 255, 255, 0.1);
color: $color-text-primary;
box-shadow: $shadow-sm;
}
.nav-item:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.35);
outline-offset: 2px;
}
.nav-item.active {
background-color: rgba(255, 255, 255, 0.14);
color: $color-text-primary;
}
.nav-item.active::before {
opacity: 1;
transform: translateY(-50%) scaleY(1.5);
}
.nav-icon {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
margin-bottom: var(--sidebar-icon-margin-bottom, 4px);
}
.nav-item.has-icon .nav-text {
font-size: 10px;
line-height: 1;
}
.nav-text {
font-size: 12px;
line-height: 1.1;
display: var(--sidebar-label-display, block);
}
.nav-item--add {
margin-top: var(--sidebar-add-margin-top);
border: 1px dashed rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.4);
&:hover {
border-color: rgba(255, 255, 255, 0.5);
color: white;
}
color: rgba(255, 255, 255, 0.6);
}
.nav-item--add::before {
opacity: 0;
}
.nav-item--add:hover {
border-color: rgba(255, 255, 255, 0.6);
color: $color-text-primary;
background-color: rgba(255, 255, 255, 0.05);
box-shadow: none;
}
.sidebar-bottom {
margin-top: auto;
}
.sidebar-menu-overlay {
position: fixed;
inset: 0;
z-index: $z-index-menu - 1;
}
.sidebar-menu {
position: absolute;
min-width: 140px;
background-color: $color-surface-2;
backdrop-filter: blur($backdrop-filter-blur) saturate($backdrop-filter-saturation);
border-radius: $border-radius-medium;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: $shadow-lg;
z-index: $z-index-menu;
padding: 6px;
display: flex;
flex-direction: column;
gap: 4px;
animation: sidebar-menu-pop 110ms cubic-bezier(0.16, 1, 0.3, 1);
}
.sidebar-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: $border-radius-small;
background: transparent;
border: none;
color: $color-text-primary;
font-size: 13px;
cursor: pointer;
transition: background-color $motion-duration-sm $motion-easing-standard;
}
.sidebar-menu-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.sidebar-menu-item.menu-danger {
color: #ff5c5c;
}
.sidebar-menu-item.menu-danger .menu-icon {
color: #ff5c5c;
}
.menu-icon {
width: 16px;
height: 16px;
}
@keyframes sidebar-menu-pop {
0% {
opacity: 0;
transform: scale(0.92);
}
100% {
transform: scale(1);
}
}
</style>

View File

@ -5,6 +5,7 @@
<component :is="widgetComponent" :data="props.widget.data" :size="props.widget.size" />
</div>
</div>
<div class="card-spacer"></div>
<div class="label label-below">{{ label }}</div>
</div>
</template>
@ -42,9 +43,9 @@ const label = computed(() => {
.widget-card-wrapper {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
padding: var(--icon-card-padding);
display: grid;
grid-template-rows: minmax(0, 1fr) var(--icon-label-margin-top) auto;
padding: var(--widget-card-padding, var(--icon-card-padding));
// 鏍规嵁灏哄绫诲悕鎺у埗灏哄
&.size-1x1 { grid-column: span 1; grid-row: span 1; }
@ -53,15 +54,11 @@ const label = computed(() => {
&.size-2x2 { grid-column: span 2; grid-row: span 2; }
&.size-2x4 { grid-column: span 2; grid-row: span 4; }
&.size-1x1 {
align-items: center;
justify-content: center;
}
}
.widget-card {
width: 100%;
flex: 1 1 auto;
height: 100%;
min-height: 0;
background-color: $color-surface-1;
backdrop-filter: blur($backdrop-filter-blur) saturate($backdrop-filter-saturation);
@ -71,13 +68,20 @@ const label = computed(() => {
display: flex;
flex-direction: column;
overflow: hidden;
margin-bottom: var(--icon-label-margin-top);
transform: scale(var(--widget-scale, 1));
transform-origin: center;
}
.card-spacer {
height: 100%;
pointer-events: none;
}
.widget-card-wrapper.size-1x1 .widget-card {
width: var(--icon-size);
height: var(--icon-size);
flex: 0 0 auto;
width: auto;
height: 100%;
aspect-ratio: 1 / 1;
justify-self: center;
}
.widget-content {
@ -90,6 +94,7 @@ const label = computed(() => {
color: $color-text-primary;
text-align: center;
width: 100%;
display: var(--widget-label-display, block);
white-space: normal;
word-break: break-word;
line-height: 1.2;

View File

@ -0,0 +1,110 @@
import { watchEffect } from 'vue';
import { useSettingsStore } from '@/store/useSettingsStore';
import {
getIconDensityScale,
getLayoutDensityPreset,
getThemePreset,
iconRadiusRange,
} from '@/config/settingsPresets';
import { layoutConfig } from '@/config/layout';
const setPx = (root: HTMLElement, name: string, value: number) => {
root.style.setProperty(name, `${value}px`);
};
const setVar = (root: HTMLElement, name: string, value: string) => {
root.style.setProperty(name, value);
};
const clampRange = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
const getSidebarVars = (compact: boolean) => {
const base = layoutConfig.sidebar;
if (!compact) {
return {
width: base.width,
paddingY: base.paddingY,
profileSize: base.profileSize,
profileMarginBottom: base.profileMarginBottom,
navItemSize: base.navItemSize,
navGap: base.navGap,
};
}
return {
width: Math.max(56, base.width - 8),
paddingY: Math.max(12, base.paddingY - 6),
profileSize: Math.max(30, base.profileSize - 8),
profileMarginBottom: Math.max(16, base.profileMarginBottom - 12),
navItemSize: Math.max(34, base.navItemSize - 8),
navGap: Math.max(10, base.navGap - 6),
};
};
export const useSettingsSync = () => {
const settingsStore = useSettingsStore();
const root = document.documentElement;
watchEffect(() => {
const layoutPreset = getLayoutDensityPreset(settingsStore.layoutDensity);
const themePreset = getThemePreset(settingsStore.themeId);
const baseIcon = layoutConfig.icon;
const iconRadius = clampRange(settingsStore.iconRadius, iconRadiusRange.min, iconRadiusRange.max);
const baseGrid = layoutConfig.grid;
const sizeScale = layoutPreset.cellSize / baseGrid.cellSize;
const labelScale = clampRange(sizeScale, 0.7, 1);
const iconPadding = Math.max(0, baseIcon.padding * sizeScale);
const widgetScale = sizeScale;
const widgetPadding = Math.max(0, baseIcon.padding * sizeScale);
const iconFontSize = Math.max(14, Math.round(baseIcon.fontSize * sizeScale));
const iconLabelFontSize = Math.max(10, Math.round(baseIcon.labelFontSize * labelScale));
const iconLabelMarginTop = Math.max(4, Math.round(baseIcon.labelMarginTop * labelScale));
const widgetLabelFontSize = Math.max(
10,
Math.round(layoutConfig.widget.labelFontSize * labelScale)
);
const widgetLabelPaddingY = Math.max(
4,
Math.round(layoutConfig.widget.labelPaddingY * labelScale)
);
const densityScale = getIconDensityScale(settingsStore.iconDensity);
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-gap', gridGap);
setPx(root, '--icon-card-padding', iconPadding);
setPx(root, '--widget-card-padding', widgetPadding);
setVar(root, '--widget-scale', widgetScale.toFixed(3));
setPx(root, '--icon-font-size', iconFontSize);
setPx(root, '--icon-label-font-size', iconLabelFontSize);
setPx(root, '--icon-radius', iconRadius);
setPx(
root,
'--icon-label-margin-top',
settingsStore.showIconLabels ? iconLabelMarginTop : 0
);
setPx(root, '--widget-label-font-size', widgetLabelFontSize);
setPx(root, '--widget-label-padding-y', widgetLabelPaddingY);
setVar(root, '--icon-label-display', settingsStore.showIconLabels ? 'block' : 'none');
setVar(root, '--widget-label-display', settingsStore.showIconLabels ? 'block' : 'none');
const sidebarVars = getSidebarVars(settingsStore.compactSidebar);
setPx(root, '--sidebar-width', sidebarVars.width);
setPx(root, '--sidebar-padding-y', sidebarVars.paddingY);
setPx(root, '--sidebar-profile-size', sidebarVars.profileSize);
setPx(root, '--sidebar-profile-margin-bottom', sidebarVars.profileMarginBottom);
setPx(root, '--sidebar-nav-item-size', sidebarVars.navItemSize);
setPx(root, '--sidebar-nav-gap', sidebarVars.navGap);
setVar(root, '--sidebar-label-display', settingsStore.showGroupLabels ? 'block' : 'none');
setVar(root, '--sidebar-icon-margin-bottom', settingsStore.showGroupLabels ? '4px' : '0px');
setVar(root, '--desktop-bg-image', themePreset.image);
setVar(root, '--desktop-bg-overlay', themePreset.overlay);
});
};

View File

@ -0,0 +1,133 @@
export type GroupIcon = {
id: string;
label: string;
icon: string;
};
export const groupIcons: GroupIcon[] = [
{
id: 'home',
label: '主页',
icon: 'lucide:home'
},
{
id: 'heart',
label: '收藏',
icon: 'lucide:heart'
},
{
id: 'music',
label: '音乐',
icon: 'lucide:music'
},
{
id: 'message',
label: '消息',
icon: 'lucide:message-circle'
},
{
id: 'briefcase',
label: '工作',
icon: 'lucide:briefcase'
},
{
id: 'gamepad',
label: '游戏',
icon: 'lucide:gamepad-2'
},
{
id: 'code',
label: '代码',
icon: 'lucide:code-2'
},
{
id: 'design',
label: '设计',
icon: 'lucide:palette'
},
{
id: 'product',
label: '产品',
icon: 'lucide:package'
},
{
id: 'ai',
label: 'AI',
icon: 'lucide:sparkles'
},
{
id: 'fun',
label: '摸鱼',
icon: 'lucide:party-popper'
},
{
id: 'book',
label: '读书',
icon: 'lucide:book'
},
{
id: 'tool',
label: '工具',
icon: 'lucide:wrench'
},
{
id: 'star',
label: '收藏夹',
icon: 'lucide:star'
},
{
id: 'cube',
label: '应用',
icon: 'lucide:app-window'
},
{
id: 'leaf',
label: '生活',
icon: 'lucide:leaf'
},
{
id: 'image',
label: '图片',
icon: 'lucide:image'
},
{
id: 'flag',
label: '目标',
icon: 'lucide:flag'
},
{
id: 'plane',
label: '旅行',
icon: 'lucide:plane'
},
{
id: 'map',
label: '地图',
icon: 'lucide:map-pin'
},
{
id: 'bookmark',
label: '阅读',
icon: 'lucide:bookmark'
},
{
id: 'tag',
label: '标签',
icon: 'lucide:tag'
},
{
id: 'folder',
label: '文件',
icon: 'lucide:folder'
},
{
id: 'settings',
label: '设置',
icon: 'lucide:settings'
},
{
id: 'add',
label: '新增',
icon: 'lucide:plus'
}
];

View File

@ -55,6 +55,8 @@ export const layoutConfig = {
fontSize: 28,
// 卡片内边距(像素)。
padding: 8,
// 图标卡片圆角(像素)。
radius: 12,
// 标签字体大小(像素)。
labelFontSize: 13,
// 图标与标签间距(像素)。
@ -143,6 +145,8 @@ export const applyLayoutConfig = (root: HTMLElement = document.documentElement)
setPx(root, '--icon-size', layoutConfig.icon.size);
setPx(root, '--icon-font-size', layoutConfig.icon.fontSize);
setPx(root, '--icon-card-padding', layoutConfig.icon.padding);
setPx(root, '--widget-card-padding', layoutConfig.icon.padding);
setPx(root, '--icon-radius', layoutConfig.icon.radius);
setPx(root, '--icon-label-font-size', layoutConfig.icon.labelFontSize);
setPx(root, '--icon-label-margin-top', layoutConfig.icon.labelMarginTop);

View File

@ -0,0 +1,171 @@
import { layoutConfig } from './layout';
export type SearchProvider = {
id: 'baidu' | 'bing' | 'google' | 'zhihu';
label: string;
url: string;
placeholder: string;
};
export const searchProviders: SearchProvider[] = [
{
id: 'baidu',
label: '百度',
url: 'https://www.baidu.com/s?wd=',
placeholder: '百度一下',
},
{
id: 'bing',
label: '必应',
url: 'https://www.bing.com/search?q=',
placeholder: '搜索必应',
},
{
id: 'google',
label: '谷歌',
url: 'https://www.google.com/search?q=',
placeholder: '搜索谷歌',
},
{
id: 'zhihu',
label: '知乎',
url: 'https://www.zhihu.com/search?q=',
placeholder: '知乎搜索',
},
];
export type SearchProviderId = SearchProvider['id'];
export const getSearchProvider = (id?: string) =>
searchProviders.find(item => item.id === id) ?? searchProviders[0];
export type ThemePreset = {
id: 'default' | 'warm' | 'sea' | 'forest';
label: string;
overlay: string;
image: string;
};
export const themePresets: ThemePreset[] = [
{
id: 'default',
label: '默认',
overlay: 'none',
image: "url('/bg.png')",
},
{
id: 'warm',
label: '暖阳',
overlay: 'linear-gradient(135deg, rgba(255, 185, 110, 0.32), rgba(255, 221, 149, 0.14))',
image: "url('/bg.png')",
},
{
id: 'sea',
label: '海盐',
overlay: 'linear-gradient(135deg, rgba(88, 200, 255, 0.3), rgba(183, 240, 255, 0.14))',
image: "url('/bg.png')",
},
{
id: 'forest',
label: '松林',
overlay: 'linear-gradient(135deg, rgba(96, 198, 137, 0.28), rgba(217, 247, 201, 0.12))',
image: "url('/bg.png')",
},
];
export type ThemePresetId = ThemePreset['id'];
export const getThemePreset = (id?: string) =>
themePresets.find(item => item.id === id) ?? themePresets[0];
const clamp = (value: number, min: number) => (value < min ? min : value);
const clampRange = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
export type LayoutDensityPreset = {
value: 1 | 2 | 3 | 4;
label: string;
cellSize: number;
gap: number;
};
const baseGrid = layoutConfig.grid;
const baseIcon = layoutConfig.icon;
export const layoutDensityPresets: LayoutDensityPreset[] = [
{
value: 1,
label: '宽松',
cellSize: baseGrid.cellSize + 16,
gap: baseGrid.gap + 10,
},
{
value: 2,
label: '标准',
cellSize: baseGrid.cellSize,
gap: baseGrid.gap,
},
{
value: 3,
label: '紧凑',
cellSize: clamp(baseGrid.cellSize - 18, 76),
gap: clamp(baseGrid.gap - 6, 8),
},
{
value: 4,
label: '更紧凑',
cellSize: clamp(baseGrid.cellSize - 32, 64),
gap: clamp(baseGrid.gap - 10, 4),
},
];
export const getLayoutDensityPreset = (value?: number) =>
layoutDensityPresets.find(item => item.value === value) ?? layoutDensityPresets[1];
export const iconDensityRange = {
min: 0,
max: 10,
step: 1,
default: 5,
};
const iconDensityScaleRange = {
loose: 1.3,
dense: 0.05,
};
export const getIconDensityScale = (value?: number) => {
const normalized = clampRange(
typeof value === 'number' ? value : iconDensityRange.default,
iconDensityRange.min,
iconDensityRange.max
);
if (normalized === iconDensityRange.default) {
return 1;
}
if (normalized < iconDensityRange.default) {
// Smaller slider value = denser布局
const t = (iconDensityRange.default - normalized) /
(iconDensityRange.default - iconDensityRange.min);
return 1 - t * (1 - iconDensityScaleRange.dense);
}
const t = (normalized - iconDensityRange.default) /
(iconDensityRange.max - iconDensityRange.default);
return 1 + t * (iconDensityScaleRange.loose - 1);
};
export const iconSizeRange = {
min: 70,
max: 100,
step: 1,
default: Math.round(
((baseGrid.cellSize - baseIcon.padding * 2) / baseGrid.cellSize) * 100
),
};
export const iconRadiusRange = {
min: 0,
max: 24,
step: 1,
default: baseIcon.radius,
};

View File

@ -0,0 +1,28 @@
export type SidebarItem = {
id: string;
label: string;
iconId?: string;
action?: 'add' | 'settings';
variant?: 'add';
};
export const sidebarBaseItems: SidebarItem[] = [
{ id: 'home', label: '主页', iconId: 'home' },
{ id: 'code', label: '编程', iconId: 'code' },
{ id: 'design', label: '设计', iconId: 'design' },
{ id: 'product', label: '产品', iconId: 'product' },
{ id: 'ai', label: 'AI', iconId: 'ai' },
{ id: 'fun', label: '摸鱼', iconId: 'fun' },
];
export const sidebarAddItem: SidebarItem = {
id: 'add',
label: '新增',
action: 'add',
variant: 'add',
iconId: 'add',
};
export const sidebarBottomItems: SidebarItem[] = [
{ id: 'settings', label: '设置', action: 'settings', iconId: 'settings' }
];

View File

@ -4,7 +4,7 @@ export const themeConfig = {
// 小圆角(像素)。
sm: 12,
// 中圆角(像素)。
md: 16,
md: 8,
// 大圆角(像素)。
lg: 24,
// 胶囊圆角(像素)。

View File

@ -1,4 +1,4 @@
import { defineStore } from 'pinia';
import { defineStore, acceptHMRUpdate } from 'pinia';
type IconSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
@ -9,6 +9,7 @@ interface Icon {
url: string;
size?: IconSize;
img?: string; // 可选:用于图片图标(如徽标)
text?: string; // 可选:用于文字图标
bgColor?: string; // 可选:纯色背景
}
@ -74,6 +75,17 @@ export const useLayoutStore = defineStore('layout', {
}
}),
actions: {
addIcon(icon: Omit<Icon, 'id'>) {
const nextId = `custom-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
this.icons.push({ ...icon, id: nextId });
localStorage.setItem('itab_icons', JSON.stringify(this.icons));
},
updateIcon(iconId: string, updates: Partial<Omit<Icon, 'id'>>) {
const icon = this.icons.find(item => item.id === iconId);
if (!icon) return;
Object.assign(icon, updates);
localStorage.setItem('itab_icons', JSON.stringify(this.icons));
},
reorderIcons(draggedId: string, targetId: string) {
const draggedIndex = this.icons.findIndex(p => p.id === draggedId);
const targetIndex = this.icons.findIndex(p => p.id === targetId);
@ -123,3 +135,7 @@ export const useLayoutStore = defineStore('layout', {
},
}
});
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useLayoutStore, import.meta.hot));
}

View File

@ -0,0 +1,129 @@
import { defineStore, acceptHMRUpdate } from 'pinia';
import {
getSearchProvider,
getThemePreset,
iconDensityRange,
iconRadiusRange,
layoutDensityPresets,
searchProviders,
themePresets,
type SearchProviderId,
type ThemePresetId,
} from '@/config/settingsPresets';
type IconDensity = number;
type LayoutDensity = 1 | 2 | 3 | 4;
type SettingsState = {
showSearch: boolean;
showClock: boolean;
showDate: boolean;
showSeconds: boolean;
use24Hour: boolean;
openInNewTab: boolean;
searchProvider: SearchProviderId;
showIconLabels: boolean;
iconDensity: IconDensity;
iconRadius: number;
themeId: ThemePresetId;
layoutDensity: LayoutDensity;
autoAlign: boolean;
compactSidebar: boolean;
showGroupLabels: boolean;
};
const SETTINGS_STORAGE_KEY = 'itab_settings';
const defaultSettings: SettingsState = {
showSearch: true,
showClock: true,
showDate: true,
showSeconds: false,
use24Hour: true,
openInNewTab: true,
searchProvider: getSearchProvider().id,
showIconLabels: true,
iconDensity: iconDensityRange.default,
iconRadius: iconRadiusRange.default,
themeId: getThemePreset().id,
layoutDensity: 2,
autoAlign: true,
compactSidebar: false,
showGroupLabels: true,
};
const searchProviderIds = new Set(searchProviders.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) =>
Math.min(Math.max(value, min), max);
const loadSettings = (): SettingsState => {
const raw = localStorage.getItem(SETTINGS_STORAGE_KEY);
if (!raw) return { ...defaultSettings };
try {
const parsed = JSON.parse(raw) as Partial<SettingsState> | null;
if (!parsed || typeof parsed !== 'object') {
return { ...defaultSettings };
}
return {
showSearch: typeof parsed.showSearch === 'boolean' ? parsed.showSearch : defaultSettings.showSearch,
showClock: typeof parsed.showClock === 'boolean' ? parsed.showClock : defaultSettings.showClock,
showDate: typeof parsed.showDate === 'boolean' ? parsed.showDate : defaultSettings.showDate,
showSeconds: typeof parsed.showSeconds === 'boolean' ? parsed.showSeconds : defaultSettings.showSeconds,
use24Hour: typeof parsed.use24Hour === 'boolean' ? parsed.use24Hour : defaultSettings.use24Hour,
openInNewTab: typeof parsed.openInNewTab === 'boolean' ? parsed.openInNewTab : defaultSettings.openInNewTab,
searchProvider:
typeof parsed.searchProvider === 'string' && searchProviderIds.has(parsed.searchProvider)
? parsed.searchProvider
: defaultSettings.searchProvider,
showIconLabels:
typeof parsed.showIconLabels === 'boolean' ? parsed.showIconLabels : defaultSettings.showIconLabels,
iconDensity:
typeof parsed.iconDensity === 'number'
? clampRange(parsed.iconDensity, iconDensityRange.min, iconDensityRange.max)
: defaultSettings.iconDensity,
iconRadius:
typeof parsed.iconRadius === 'number'
? clampRange(parsed.iconRadius, iconRadiusRange.min, iconRadiusRange.max)
: defaultSettings.iconRadius,
themeId:
typeof parsed.themeId === 'string' && themePresetIds.has(parsed.themeId)
? parsed.themeId
: defaultSettings.themeId,
layoutDensity:
typeof parsed.layoutDensity === 'number' && layoutDensityValues.has(parsed.layoutDensity)
? parsed.layoutDensity
: defaultSettings.layoutDensity,
autoAlign: typeof parsed.autoAlign === 'boolean' ? parsed.autoAlign : defaultSettings.autoAlign,
compactSidebar:
typeof parsed.compactSidebar === 'boolean' ? parsed.compactSidebar : defaultSettings.compactSidebar,
showGroupLabels:
typeof parsed.showGroupLabels === 'boolean' ? parsed.showGroupLabels : defaultSettings.showGroupLabels,
};
} catch {
return { ...defaultSettings };
}
};
const persistSettings = (state: SettingsState) => {
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(state));
};
export const useSettingsStore = defineStore('settings', {
state: (): SettingsState => loadSettings(),
actions: {
setSetting<K extends keyof SettingsState>(key: K, value: SettingsState[K]) {
this[key] = value;
persistSettings(this.$state);
},
reset() {
Object.assign(this.$state, defaultSettings);
persistSettings(this.$state);
},
}
});
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useSettingsStore, import.meta.hot));
}

View File

@ -5,7 +5,7 @@ interface ContextMenuState {
x: number;
y: number;
itemId: string | null;
itemType?: 'icon' | 'widget';
itemType?: 'icon' | 'widget' | 'desktop';
}
interface UIState {
@ -23,7 +23,12 @@ export const useUIStore = defineStore('ui', {
}
}),
actions: {
openContextMenu(x: number, y: number, itemId: string, itemType: 'icon' | 'widget') {
openContextMenu(
x: number,
y: number,
itemId: string | null,
itemType: 'icon' | 'widget' | 'desktop'
) {
this.contextMenu = {
isOpen: true,
x,

View File

@ -1,4 +1,4 @@
import { defineStore } from 'pinia';
import { defineStore, acceptHMRUpdate } from 'pinia';
// 组件数据结构
type WidgetSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
@ -52,6 +52,35 @@ export const useWidgetsStore = defineStore('widgets', {
widgets: savedWidgets ? JSON.parse(savedWidgets) : defaultWidgets,
}),
actions: {
addWidget(payload: { component: string; size?: WidgetSize; data?: any }) {
const nextId = `widget-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const nextWidget: Widget = {
id: nextId,
component: payload.component,
size: payload.size ?? '1x1',
gridPosition: { x: 0, y: 0 },
data: payload.data,
};
this.widgets.push(nextWidget);
localStorage.setItem('itab_widgets', JSON.stringify(this.widgets));
},
replaceWidget(widgetId: string, payload: { component: string; size?: WidgetSize; data?: any }) {
const widget = this.widgets.find(item => item.id === widgetId);
if (!widget) return;
widget.component = payload.component;
if (payload.size) {
widget.size = payload.size;
}
widget.data = payload.data;
localStorage.setItem('itab_widgets', JSON.stringify(this.widgets));
},
deleteWidget(widgetId: string) {
const index = this.widgets.findIndex(widget => widget.id === widgetId);
if (index !== -1) {
this.widgets.splice(index, 1);
localStorage.setItem('itab_widgets', JSON.stringify(this.widgets));
}
},
updateWidgetSize(widgetId: string, newSize: WidgetSize) {
const widget = this.widgets.find(w => w.id === widgetId);
if (widget) {
@ -86,3 +115,7 @@ export const useWidgetsStore = defineStore('widgets', {
}
}
});
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useWidgetsStore, import.meta.hot));
}

View File

@ -27,7 +27,7 @@ body {
width: 100%;
height: 100%;
background-color: $color-background;
background-image: url('/bg.png');
background-image: var(--desktop-bg-overlay, none), var(--desktop-bg-image, url('/bg.png'));
background-size: cover;
background-position: center;
background-repeat: no-repeat;

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB