1
This commit is contained in:
22
app/package-lock.json
generated
22
app/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
294
app/src/App.vue
294
app/src/App.vue
@ -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">
|
||||
|
||||
324
app/src/components/AddGroupDialog/index.vue
Normal file
324
app/src/components/AddGroupDialog/index.vue
Normal 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>
|
||||
1112
app/src/components/AddIconDialog/index.vue
Normal file
1112
app/src/components/AddIconDialog/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
541
app/src/components/EditIconDialog/index.vue
Normal file
541
app/src/components/EditIconDialog/index.vue
Normal 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>
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
1240
app/src/components/SettingsPanel/index.vue
Normal file
1240
app/src/components/SettingsPanel/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
|
||||
@ -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;
|
||||
@ -162,14 +287,14 @@ const changeIconSize = (newSize: ItemSize) => {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--context-menu-layout-options-gap);
|
||||
|
||||
|
||||
.size-option {
|
||||
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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
110
app/src/composables/useSettingsSync.ts
Normal file
110
app/src/composables/useSettingsSync.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
133
app/src/config/groupIcons.ts
Normal file
133
app/src/config/groupIcons.ts
Normal 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'
|
||||
}
|
||||
];
|
||||
@ -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);
|
||||
|
||||
|
||||
171
app/src/config/settingsPresets.ts
Normal file
171
app/src/config/settingsPresets.ts
Normal 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,
|
||||
};
|
||||
28
app/src/config/sidebarItems.ts
Normal file
28
app/src/config/sidebarItems.ts
Normal 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' }
|
||||
];
|
||||
@ -4,7 +4,7 @@ export const themeConfig = {
|
||||
// 小圆角(像素)。
|
||||
sm: 12,
|
||||
// 中圆角(像素)。
|
||||
md: 16,
|
||||
md: 8,
|
||||
// 大圆角(像素)。
|
||||
lg: 24,
|
||||
// 胶囊圆角(像素)。
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
129
app/src/store/useSettingsStore.ts
Normal file
129
app/src/store/useSettingsStore.ts
Normal 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));
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user