This commit is contained in:
yinsx
2026-01-23 16:56:28 +08:00
parent 5674ce116e
commit ce796e2fd7
7 changed files with 131 additions and 32 deletions

View File

@ -8,6 +8,7 @@ import IconCard from '@/components/IconCard/index.vue';
import WidgetCard from '@/components/WidgetCard/index.vue'; import WidgetCard from '@/components/WidgetCard/index.vue';
type GridItemType = 'icon' | 'widget'; type GridItemType = 'icon' | 'widget';
type GridItemSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
interface Icon { interface Icon {
id: string; id: string;
@ -15,9 +16,10 @@ interface Icon {
url: string; url: string;
img?: string; img?: string;
bgColor?: string; bgColor?: string;
size?: GridItemSize;
} }
type WidgetSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4'; type WidgetSize = GridItemSize;
interface Widget { interface Widget {
id: string; id: string;
component: string; component: string;
@ -217,7 +219,7 @@ onMounted(async () => {
dragEnabled: true, dragEnabled: true,
dragStartPredicate: { dragStartPredicate: {
distance: 5, distance: 5,
delay: 150, delay: 0,
}, },
dragSort: true, dragSort: true,
layout: { layout: {
@ -255,8 +257,15 @@ watch(
watch( watch(
() => widgetsStore.widgets.map(widget => widget.size).join('|'), () => widgetsStore.widgets.map(widget => widget.size).join('|'),
() => { async () => {
refreshLayout(); await refreshLayout();
}
);
watch(
() => layoutStore.icons.map(icon => icon.size ?? '1x1').join('|'),
async () => {
await refreshLayout();
} }
); );
@ -277,7 +286,7 @@ onUnmounted(() => {
v-for="item in orderedItems" v-for="item in orderedItems"
:key="`${item.type}-${item.id}`" :key="`${item.type}-${item.id}`"
class="grid-item" class="grid-item"
:class="item.type === 'widget' ? `size-${item.widget.size}` : 'size-1x1'" :class="`size-${item.type === 'widget' ? item.widget.size : (item.icon.size ?? '1x1')}`"
:data-id="item.id" :data-id="item.id"
:data-type="item.type" :data-type="item.type"
> >

View File

@ -2,6 +2,7 @@
<a <a
:href="props.icon.url" :href="props.icon.url"
class="icon-card-wrapper" class="icon-card-wrapper"
:class="`size-${props.icon.size ?? '1x1'}`"
@click.prevent @click.prevent
target="_blank" target="_blank"
> >
@ -14,10 +15,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
type IconSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
interface Icon { interface Icon {
id: string; id: string;
name: string; name: string;
url: string; url: string;
size?: IconSize;
img?: string; img?: string;
bgColor?: string; bgColor?: string;
} }
@ -34,27 +38,30 @@ const props = defineProps<{
.icon-card-wrapper { .icon-card-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: stretch;
justify-content: center; justify-content: flex-start;
width: 100%; width: 100%;
height: 100%; height: 100%;
text-decoration: none; text-decoration: none;
border-radius: $border-radius-medium; border-radius: $border-radius-small;
padding: var(--icon-card-padding); padding: var(--icon-card-padding);
transition: transform $motion-duration-sm $motion-easing-standard, background-color $motion-duration-sm $motion-easing-standard; transition: transform $motion-duration-sm $motion-easing-standard;
user-select: none; // 拖拽时避免选中文本 user-select: none; // 拖拽时避免选中文本
box-sizing: border-box; box-sizing: border-box;
// 悬停效果由父级拖拽状态控制 // 悬停效果由父级拖拽状态控制
&:hover { &:hover {
background-color: rgba(255, 255, 255, 0.05); .icon-card {
box-shadow: $shadow-lg;
}
} }
} }
.icon-card { .icon-card {
width: var(--icon-size); width: 100%;
height: var(--icon-size); flex: 1 1 auto;
border-radius: $border-radius-large; min-height: 0;
border-radius: $border-radius-small;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -62,6 +69,7 @@ const props = defineProps<{
font-weight: 500; font-weight: 500;
color: white; color: white;
box-shadow: $shadow-md; box-shadow: $shadow-md;
transition: box-shadow $motion-duration-sm $motion-easing-standard;
margin-bottom: var(--icon-label-margin-top); margin-bottom: var(--icon-label-margin-top);
background-size: cover; background-size: cover;
background-position: center; background-position: center;
@ -70,11 +78,22 @@ const props = defineProps<{
img { img {
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: $border-radius-large; border-radius: $border-radius-small;
object-fit: cover; object-fit: cover;
} }
} }
.icon-card-wrapper.size-1x1 {
align-items: center;
justify-content: center;
}
.icon-card-wrapper.size-1x1 .icon-card {
width: var(--icon-size);
height: var(--icon-size);
flex: 0 0 auto;
}
.label { .label {
font-size: var(--icon-label-font-size); font-size: var(--icon-label-font-size);
color: $color-text-primary; color: $color-text-primary;

View File

@ -2,6 +2,15 @@
<div v-if="uiStore.contextMenu.isOpen" class="context-menu-overlay" @click="close" @contextmenu.prevent="close"> <div v-if="uiStore.contextMenu.isOpen" class="context-menu-overlay" @click="close" @contextmenu.prevent="close">
<div class="context-menu" :style="menuStyle"> <div class="context-menu" :style="menuStyle">
<ul v-if="uiStore.contextMenu.itemType === 'icon'"> <ul v-if="uiStore.contextMenu.itemType === 'icon'">
<li class="layout-section">
<div class="layout-title">
<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"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"></rect><line x1="3" y1="9" x2="21" y2="9"></line><line x1="9" y1="21" x2="9" y2="9"></line></svg>
<span>布局</span>
</div>
<div class="widget-size-options">
<span v-for="size in iconSizes" :key="size" @click="changeIconSize(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="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="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><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><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>
@ -34,7 +43,10 @@ const uiStore = useUIStore();
const layoutStore = useLayoutStore(); const layoutStore = useLayoutStore();
const widgetsStore = useWidgetsStore(); const widgetsStore = useWidgetsStore();
const widgetSizes = ['1x1', '1x2', '2x1', '2x2', '2x4']; type ItemSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
const widgetSizes: ItemSize[] = ['1x1', '1x2', '2x1', '2x2', '2x4'];
const iconSizes: ItemSize[] = ['1x1', '1x2', '2x1', '2x2', '2x4'];
const menuStyle = computed(() => ({ const menuStyle = computed(() => ({
left: `${uiStore.contextMenu.x}px`, left: `${uiStore.contextMenu.x}px`,
@ -56,12 +68,19 @@ const deleteItem = () => {
close(); close();
}; };
const changeWidgetSize = (newSize: '1x1' | '1x2' | '2x1' | '2x2' | '2x4') => { const changeWidgetSize = (newSize: ItemSize) => {
if (uiStore.contextMenu.itemId) { if (uiStore.contextMenu.itemId) {
widgetsStore.updateWidgetSize(uiStore.contextMenu.itemId, newSize); widgetsStore.updateWidgetSize(uiStore.contextMenu.itemId, newSize);
} }
close(); close();
}; };
const changeIconSize = (newSize: ItemSize) => {
if (uiStore.contextMenu.itemId) {
layoutStore.updateIconSize(uiStore.contextMenu.itemId, newSize);
}
close();
};
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -1,9 +1,11 @@
<template> <template>
<div class="widget-card" :class="`size-${props.widget.size}`"> <div class="widget-card-wrapper" :class="`size-${props.widget.size}`">
<div class="widget-card">
<div class="widget-content"> <div class="widget-content">
<component :is="widgetComponent" :data="props.widget.data" /> <component :is="widgetComponent" :data="props.widget.data" />
</div> </div>
<div class="label">{{ label }}</div> </div>
<div class="label label-below">{{ label }}</div>
</div> </div>
</template> </template>
@ -37,24 +39,45 @@ const label = computed(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
@import '@/styles/tokens.scss'; @import '@/styles/tokens.scss';
.widget-card { .widget-card-wrapper {
width: 100%; width: 100%;
height: 100%; height: 100%;
background-color: $color-surface-1;
backdrop-filter: blur($backdrop-filter-blur) saturate($backdrop-filter-saturation);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: $border-radius-large;
box-shadow: $shadow-md;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; padding: var(--icon-card-padding);
// 根据尺寸类名控制尺寸 // 鏍规嵁灏哄绫诲悕鎺у埗灏哄
&.size-1x1 { grid-column: span 1; grid-row: span 1; } &.size-1x1 { grid-column: span 1; grid-row: span 1; }
&.size-1x2 { grid-column: span 1; grid-row: span 2; } &.size-1x2 { grid-column: span 1; grid-row: span 2; }
&.size-2x1 { grid-column: span 2; grid-row: span 1; } &.size-2x1 { grid-column: span 2; grid-row: span 1; }
&.size-2x2 { grid-column: span 2; grid-row: span 2; } &.size-2x2 { grid-column: span 2; grid-row: span 2; }
&.size-2x4 { grid-column: span 2; grid-row: span 4; } &.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;
min-height: 0;
background-color: $color-surface-1;
backdrop-filter: blur($backdrop-filter-blur) saturate($backdrop-filter-saturation);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: $border-radius-small;
box-shadow: $shadow-md;
display: flex;
flex-direction: column;
overflow: hidden;
margin-bottom: var(--icon-label-margin-top);
}
.widget-card-wrapper.size-1x1 .widget-card {
width: var(--icon-size);
height: var(--icon-size);
flex: 0 0 auto;
} }
.widget-content { .widget-content {
@ -70,7 +93,10 @@ const label = computed(() => {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
padding: var(--widget-label-padding-y) 0;
flex-shrink: 0; flex-shrink: 0;
} }
.label-below {
padding: 0;
}
</style> </style>

View File

@ -2,7 +2,7 @@ export const themeConfig = {
// 圆角配置,统一控制卡片与胶囊样式。 // 圆角配置,统一控制卡片与胶囊样式。
radius: { radius: {
// 小圆角(像素)。 // 小圆角(像素)。
sm: 8, sm: 12,
// 中圆角(像素)。 // 中圆角(像素)。
md: 16, md: 16,
// 大圆角(像素)。 // 大圆角(像素)。

View File

@ -1,10 +1,13 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
type IconSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
// 模拟数据(来自截图参考) // 模拟数据(来自截图参考)
interface Icon { interface Icon {
id: string; id: string;
name: string; name: string;
url: string; url: string;
size?: IconSize;
img?: string; // 可选:用于图片图标(如徽标) img?: string; // 可选:用于图片图标(如徽标)
bgColor?: string; // 可选:纯色背景 bgColor?: string; // 可选:纯色背景
} }
@ -45,9 +48,21 @@ const defaultIcons: Icon[] = [
const savedIcons = localStorage.getItem('itab_icons'); const savedIcons = localStorage.getItem('itab_icons');
const normalizeIcons = (icons: Icon[]): Icon[] =>
icons.map(icon => ({ ...icon, size: icon.size ?? '1x1' }));
const loadIcons = (): Icon[] => {
if (!savedIcons) return normalizeIcons(defaultIcons);
try {
return normalizeIcons(JSON.parse(savedIcons) as Icon[]);
} catch {
return normalizeIcons(defaultIcons);
}
};
export const useLayoutStore = defineStore('layout', { export const useLayoutStore = defineStore('layout', {
state: (): LayoutState => ({ state: (): LayoutState => ({
icons: savedIcons ? JSON.parse(savedIcons) : defaultIcons, icons: loadIcons(),
dragState: { dragState: {
isDragging: false, isDragging: false,
itemId: null, itemId: null,
@ -91,6 +106,13 @@ export const useLayoutStore = defineStore('layout', {
this.icons = ordered; this.icons = ordered;
localStorage.setItem('itab_icons', JSON.stringify(this.icons)); localStorage.setItem('itab_icons', JSON.stringify(this.icons));
}, },
updateIconSize(iconId: string, newSize: IconSize) {
const icon = this.icons.find(item => item.id === iconId);
if (icon) {
icon.size = newSize;
localStorage.setItem('itab_icons', JSON.stringify(this.icons));
}
},
deleteIcon(itemId: string) { deleteIcon(itemId: string) {
const index = this.icons.findIndex(p => p.id === itemId); const index = this.icons.findIndex(p => p.id === itemId);
if (index !== -1) { if (index !== -1) {

View File

@ -26,7 +26,11 @@ body {
#app { #app {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: radial-gradient(ellipse at bottom, #1b2735 0%, #090a0f 100%); background-color: $color-background;
background-image: url('/bg.png');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
} }
// 滚动条样式 // 滚动条样式