1
This commit is contained in:
343
app/src/components/GridCanvas/index.vue
Normal file
343
app/src/components/GridCanvas/index.vue
Normal file
@ -0,0 +1,343 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
|
||||
import Muuri from 'muuri';
|
||||
import { useLayoutStore } from '@/store/useLayoutStore';
|
||||
import { useWidgetsStore } from '@/store/useWidgetsStore';
|
||||
import { useUIStore } from '@/store/useUIStore';
|
||||
import IconCard from '@/components/IconCard/index.vue';
|
||||
import WidgetCard from '@/components/WidgetCard/index.vue';
|
||||
|
||||
type GridItemType = 'icon' | 'widget';
|
||||
|
||||
interface Icon {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
img?: string;
|
||||
bgColor?: string;
|
||||
}
|
||||
|
||||
type WidgetSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
|
||||
interface Widget {
|
||||
id: string;
|
||||
component: string;
|
||||
size: WidgetSize;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
interface GridOrderEntry {
|
||||
id: string;
|
||||
type: GridItemType;
|
||||
}
|
||||
|
||||
type OrderedGridItem =
|
||||
| { type: 'icon'; id: string; icon: Icon }
|
||||
| { type: 'widget'; id: string; widget: Widget };
|
||||
|
||||
const GRID_ORDER_STORAGE_KEY = 'itab_grid_order';
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
const widgetsStore = useWidgetsStore();
|
||||
const uiStore = useUIStore();
|
||||
const gridRef = ref<HTMLElement | null>(null);
|
||||
const grid = ref<any | null>(null);
|
||||
const isDragging = ref(false);
|
||||
const suppressClick = ref(false);
|
||||
const gridOrder = ref<GridOrderEntry[]>([]);
|
||||
|
||||
const buildDefaultOrder = (): GridOrderEntry[] => [
|
||||
...widgetsStore.widgets.map(widget => ({ id: widget.id, type: 'widget' as const })),
|
||||
...layoutStore.icons.map(icon => ({ id: icon.id, type: 'icon' as const })),
|
||||
];
|
||||
|
||||
const persistGridOrder = (order: GridOrderEntry[]) => {
|
||||
gridOrder.value = order;
|
||||
localStorage.setItem(GRID_ORDER_STORAGE_KEY, JSON.stringify(order));
|
||||
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) {
|
||||
layoutStore.setIconOrder(iconOrder);
|
||||
}
|
||||
if (widgetOrder.length) {
|
||||
widgetsStore.setWidgetOrder(widgetOrder);
|
||||
}
|
||||
};
|
||||
|
||||
const loadGridOrder = () => {
|
||||
const saved = localStorage.getItem(GRID_ORDER_STORAGE_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved) as GridOrderEntry[];
|
||||
if (Array.isArray(parsed)) {
|
||||
gridOrder.value = parsed.filter(item => item?.id && (item.type === 'icon' || item.type === 'widget'));
|
||||
}
|
||||
} catch {
|
||||
gridOrder.value = [];
|
||||
}
|
||||
}
|
||||
if (!gridOrder.value.length) {
|
||||
gridOrder.value = buildDefaultOrder();
|
||||
}
|
||||
persistGridOrder(gridOrder.value);
|
||||
};
|
||||
|
||||
const ensureOrderConsistency = () => {
|
||||
const iconIds = new Set(layoutStore.icons.map(icon => icon.id));
|
||||
const widgetIds = new Set(widgetsStore.widgets.map(widget => widget.id));
|
||||
const nextOrder: GridOrderEntry[] = [];
|
||||
|
||||
for (const entry of gridOrder.value) {
|
||||
if (entry.type === 'icon' && iconIds.has(entry.id)) {
|
||||
nextOrder.push(entry);
|
||||
}
|
||||
if (entry.type === 'widget' && widgetIds.has(entry.id)) {
|
||||
nextOrder.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
for (const widget of widgetsStore.widgets) {
|
||||
if (!nextOrder.some(item => item.type === 'widget' && item.id === widget.id)) {
|
||||
nextOrder.push({ id: widget.id, type: 'widget' });
|
||||
}
|
||||
}
|
||||
|
||||
for (const icon of layoutStore.icons) {
|
||||
if (!nextOrder.some(item => item.type === 'icon' && item.id === icon.id)) {
|
||||
nextOrder.push({ id: icon.id, type: 'icon' });
|
||||
}
|
||||
}
|
||||
|
||||
const orderChanged =
|
||||
nextOrder.length !== gridOrder.value.length ||
|
||||
nextOrder.some((entry, index) => {
|
||||
const current = gridOrder.value[index];
|
||||
return current?.id !== entry.id || current?.type !== entry.type;
|
||||
});
|
||||
|
||||
if (orderChanged) {
|
||||
persistGridOrder(nextOrder);
|
||||
} else {
|
||||
gridOrder.value = nextOrder;
|
||||
}
|
||||
};
|
||||
|
||||
const orderedItems = computed<OrderedGridItem[]>(() => {
|
||||
const iconsById = new Map(layoutStore.icons.map(icon => [icon.id, icon]));
|
||||
const widgetsById = new Map(widgetsStore.widgets.map(widget => [widget.id, widget]));
|
||||
const used = new Set<string>();
|
||||
const items: OrderedGridItem[] = [];
|
||||
|
||||
for (const entry of gridOrder.value) {
|
||||
if (entry.type === 'icon') {
|
||||
const icon = iconsById.get(entry.id);
|
||||
if (icon) {
|
||||
items.push({ type: 'icon', id: icon.id, icon });
|
||||
used.add(`icon:${icon.id}`);
|
||||
}
|
||||
} else {
|
||||
const widget = widgetsById.get(entry.id);
|
||||
if (widget) {
|
||||
items.push({ type: 'widget', id: widget.id, widget });
|
||||
used.add(`widget:${widget.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const widget of widgetsStore.widgets) {
|
||||
const key = `widget:${widget.id}`;
|
||||
if (!used.has(key)) {
|
||||
items.push({ type: 'widget', id: widget.id, widget });
|
||||
}
|
||||
}
|
||||
|
||||
for (const icon of layoutStore.icons) {
|
||||
const key = `icon:${icon.id}`;
|
||||
if (!used.has(key)) {
|
||||
items.push({ type: 'icon', id: icon.id, icon });
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (suppressClick.value || isDragging.value) return;
|
||||
const target = event.target as HTMLElement;
|
||||
const itemEl = target.closest('.grid-item') as HTMLElement | null;
|
||||
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 handleContextMenu = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const itemEl = target.closest('.grid-item') as HTMLElement | null;
|
||||
if (!itemEl) return;
|
||||
const id = itemEl.dataset.id;
|
||||
const type = itemEl.dataset.type as GridItemType | undefined;
|
||||
if (id && type) {
|
||||
uiStore.openContextMenu(event.clientX, event.clientY, id, type);
|
||||
}
|
||||
};
|
||||
|
||||
const persistOrderFromGrid = async () => {
|
||||
if (!grid.value) return;
|
||||
const items = grid.value.getItems();
|
||||
const nextOrder: GridOrderEntry[] = [];
|
||||
for (const item of items) {
|
||||
const element = item.getElement() as HTMLElement;
|
||||
const id = element.dataset.id;
|
||||
const type = element.dataset.type as GridItemType | undefined;
|
||||
if (id && (type === 'icon' || type === 'widget')) {
|
||||
nextOrder.push({ id, type });
|
||||
}
|
||||
}
|
||||
if (nextOrder.length) {
|
||||
persistGridOrder(nextOrder);
|
||||
}
|
||||
await nextTick();
|
||||
grid.value?.synchronize();
|
||||
grid.value?.layout();
|
||||
};
|
||||
|
||||
const refreshLayout = async () => {
|
||||
await nextTick();
|
||||
grid.value?.refreshItems();
|
||||
grid.value?.layout();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
loadGridOrder();
|
||||
await nextTick();
|
||||
if (!gridRef.value) return;
|
||||
grid.value = new Muuri(gridRef.value, {
|
||||
items: '.grid-item',
|
||||
dragEnabled: true,
|
||||
dragStartPredicate: {
|
||||
distance: 5,
|
||||
delay: 150,
|
||||
},
|
||||
dragSort: true,
|
||||
layout: {
|
||||
fillGaps: true,
|
||||
rounding: true,
|
||||
},
|
||||
});
|
||||
|
||||
grid.value.on('dragStart', () => {
|
||||
isDragging.value = true;
|
||||
suppressClick.value = true;
|
||||
});
|
||||
|
||||
grid.value.on('dragEnd', () => {
|
||||
isDragging.value = false;
|
||||
window.setTimeout(() => {
|
||||
suppressClick.value = false;
|
||||
}, 0);
|
||||
});
|
||||
|
||||
grid.value.on('dragReleaseEnd', () => {
|
||||
persistOrderFromGrid();
|
||||
});
|
||||
|
||||
grid.value.layout(true);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [layoutStore.icons.length, widgetsStore.widgets.length],
|
||||
() => {
|
||||
ensureOrderConsistency();
|
||||
refreshLayout();
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => widgetsStore.widgets.map(widget => widget.size).join('|'),
|
||||
() => {
|
||||
refreshLayout();
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
grid.value?.destroy();
|
||||
grid.value = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="gridRef"
|
||||
class="grid-canvas"
|
||||
@click="handleClick"
|
||||
@contextmenu.prevent="handleContextMenu"
|
||||
>
|
||||
<div
|
||||
v-for="item in orderedItems"
|
||||
:key="`${item.type}-${item.id}`"
|
||||
class="grid-item"
|
||||
:class="item.type === 'widget' ? `size-${item.widget.size}` : 'size-1x1'"
|
||||
:data-id="item.id"
|
||||
:data-type="item.type"
|
||||
>
|
||||
<div class="grid-item-content">
|
||||
<WidgetCard v-if="item.type === 'widget'" :widget="item.widget" />
|
||||
<IconCard v-else :icon="item.icon" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/tokens.scss';
|
||||
|
||||
.grid-canvas {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
user-select: none; /* 标准 */
|
||||
-webkit-user-select: none; /* Safari 浏览器 */
|
||||
-moz-user-select: none; /* Firefox 浏览器 */
|
||||
-ms-user-select: none; /* IE 10+/Edge 浏览器 */
|
||||
--cell-size: var(--grid-cell-size);
|
||||
--cell-gap: var(--grid-gap);
|
||||
padding: calc(var(--cell-gap) / 2);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
position: absolute;
|
||||
width: var(--cell-size);
|
||||
height: var(--cell-size);
|
||||
margin: calc(var(--cell-gap) / 2);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.grid-item.size-1x2 {
|
||||
height: calc(var(--cell-size) * 2 + var(--cell-gap));
|
||||
}
|
||||
|
||||
.grid-item.size-2x1 {
|
||||
width: calc(var(--cell-size) * 2 + var(--cell-gap));
|
||||
}
|
||||
|
||||
.grid-item.size-2x2 {
|
||||
width: calc(var(--cell-size) * 2 + var(--cell-gap));
|
||||
height: calc(var(--cell-size) * 2 + var(--cell-gap));
|
||||
}
|
||||
|
||||
.grid-item.size-2x4 {
|
||||
width: calc(var(--cell-size) * 2 + var(--cell-gap));
|
||||
height: calc(var(--cell-size) * 4 + var(--cell-gap) * 3);
|
||||
}
|
||||
|
||||
.grid-item-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.grid-item.muuri-item-dragging {
|
||||
z-index: $z-index-menu;
|
||||
cursor: grabbing;
|
||||
}
|
||||
</style>
|
||||
88
app/src/components/IconCard/index.vue
Normal file
88
app/src/components/IconCard/index.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<a
|
||||
:href="props.icon.url"
|
||||
class="icon-card-wrapper"
|
||||
@click.prevent
|
||||
target="_blank"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<div class="label">{{ props.icon.name }}</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Icon {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
img?: string;
|
||||
bgColor?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
icon: Icon;
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/tokens.scss';
|
||||
|
||||
.icon-card-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-decoration: none;
|
||||
border-radius: $border-radius-medium;
|
||||
padding: var(--icon-card-padding);
|
||||
transition: transform $motion-duration-sm $motion-easing-standard, background-color $motion-duration-sm $motion-easing-standard;
|
||||
user-select: none; // 拖拽时避免选中文本
|
||||
box-sizing: border-box;
|
||||
|
||||
// 悬停效果由父级拖拽状态控制
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-card {
|
||||
width: var(--icon-size);
|
||||
height: var(--icon-size);
|
||||
border-radius: $border-radius-large;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--icon-font-size);
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
box-shadow: $shadow-md;
|
||||
margin-bottom: var(--icon-label-margin-top);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
pointer-events: none; // 指针事件交给外层容器处理
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: $border-radius-large;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--icon-label-font-size);
|
||||
color: $color-text-primary;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
40
app/src/components/MainContent/index.vue
Normal file
40
app/src/components/MainContent/index.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<main class="main-content">
|
||||
<div class="main-content-container">
|
||||
<TheClock />
|
||||
<TheSearchBar />
|
||||
<GridCanvas />
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import TheClock from '@/components/TheClock/index.vue';
|
||||
|
||||
import TheSearchBar from '@/components/TheSearchBar/index.vue';
|
||||
|
||||
import GridCanvas from '@/components/GridCanvas/index.vue';
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.main-content {
|
||||
padding-left: var(--sidebar-width); // 与侧栏宽度保持一致
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start; // 内容顶部对齐
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.main-content-container {
|
||||
width: 100%;
|
||||
max-width: var(--content-max-width); // 内容区域最大宽度
|
||||
padding: var(--content-padding); // 内容区内边距
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
58
app/src/components/TheClock/index.vue
Normal file
58
app/src/components/TheClock/index.vue
Normal file
@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="clock-container">
|
||||
<div class="time">{{ currentTime }}</div>
|
||||
<div class="date">{{ currentDate }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
const currentTime = ref('');
|
||||
const currentDate = ref('');
|
||||
let timerId: number;
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
updateTime();
|
||||
timerId = window.setInterval(updateTime, 1000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(timerId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@import '@/styles/tokens.scss';
|
||||
|
||||
.clock-container {
|
||||
text-align: center;
|
||||
margin-bottom: var(--clock-margin-bottom);
|
||||
color: $color-text-primary;
|
||||
text-shadow: $shadow-md;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: var(--clock-time-font-size);
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--clock-letter-spacing);
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: var(--clock-date-font-size);
|
||||
color: $color-text-secondary;
|
||||
margin-top: var(--clock-date-margin-top);
|
||||
}
|
||||
</style>
|
||||
163
app/src/components/TheContextMenu/index.vue
Normal file
163
app/src/components/TheContextMenu/index.vue
Normal file
@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div v-if="uiStore.contextMenu.isOpen" class="context-menu-overlay" @click="close" @contextmenu.prevent="close">
|
||||
<div class="context-menu" :style="menuStyle">
|
||||
<ul v-if="uiStore.contextMenu.itemType === 'icon'">
|
||||
<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="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>
|
||||
</ul>
|
||||
<ul v-else-if="uiStore.contextMenu.itemType === 'widget'">
|
||||
<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 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>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useUIStore } from '@/store/useUIStore';
|
||||
import { useLayoutStore } from '@/store/useLayoutStore';
|
||||
import { useWidgetsStore } from '@/store/useWidgetsStore';
|
||||
|
||||
const uiStore = useUIStore();
|
||||
const layoutStore = useLayoutStore();
|
||||
const widgetsStore = useWidgetsStore();
|
||||
|
||||
const widgetSizes = ['1x1', '1x2', '2x1', '2x2', '2x4'];
|
||||
|
||||
const menuStyle = computed(() => ({
|
||||
left: `${uiStore.contextMenu.x}px`,
|
||||
top: `${uiStore.contextMenu.y}px`,
|
||||
}));
|
||||
|
||||
const close = () => {
|
||||
uiStore.closeContextMenu();
|
||||
};
|
||||
|
||||
const deleteItem = () => {
|
||||
if (uiStore.contextMenu.itemId) {
|
||||
if (uiStore.contextMenu.itemType === 'icon') {
|
||||
layoutStore.deleteIcon(uiStore.contextMenu.itemId);
|
||||
} else if (uiStore.contextMenu.itemType === 'widget') {
|
||||
// 组件删除逻辑
|
||||
}
|
||||
}
|
||||
close();
|
||||
};
|
||||
|
||||
const changeWidgetSize = (newSize: '1x1' | '1x2' | '2x1' | '2x2' | '2x4') => {
|
||||
if (uiStore.contextMenu.itemId) {
|
||||
widgetsStore.updateWidgetSize(uiStore.contextMenu.itemId, newSize);
|
||||
}
|
||||
close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/tokens.scss';
|
||||
|
||||
.context-menu-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: $z-index-menu - 1;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: absolute;
|
||||
width: var(--context-menu-width);
|
||||
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: var(--context-menu-padding);
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--context-menu-item-padding-y) var(--context-menu-item-padding-x);
|
||||
border-radius: $border-radius-small;
|
||||
cursor: pointer;
|
||||
color: $color-text-primary;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&.layout-section {
|
||||
padding: var(--context-menu-layout-section-padding-y) var(--context-menu-layout-section-padding-x);
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
cursor: default;
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
li:last-child {
|
||||
color: #ff5c5c; // 删除项浅红色
|
||||
|
||||
.menu-icon {
|
||||
stroke: #ff5c5c;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
margin-right: var(--context-menu-icon-margin-right);
|
||||
width: var(--context-menu-icon-size);
|
||||
height: var(--context-menu-icon-size);
|
||||
}
|
||||
}
|
||||
|
||||
.layout-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
margin-bottom: var(--context-menu-layout-title-margin-bottom);
|
||||
}
|
||||
|
||||
.widget-size-options {
|
||||
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); // 胶囊形状
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
63
app/src/components/TheSearchBar/index.vue
Normal file
63
app/src/components/TheSearchBar/index.vue
Normal file
@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="search-bar-wrapper">
|
||||
<div class="search-bar">
|
||||
<div class="search-icon">
|
||||
<!-- 搜索图标(矢量) -->
|
||||
<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="输入搜索内容" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 搜索逻辑后续补充
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@import '@/styles/tokens.scss';
|
||||
|
||||
.search-bar-wrapper {
|
||||
width: 100%;
|
||||
max-width: var(--search-max-width); // 与设计稿一致
|
||||
margin-bottom: var(--search-margin-bottom);
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
height: var(--search-height);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--search-padding-x);
|
||||
background-color: $color-surface-1;
|
||||
backdrop-filter: blur($backdrop-filter-blur) saturate($backdrop-filter-saturation);
|
||||
border-radius: $border-radius-large;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: $shadow-lg;
|
||||
transition: background-color $motion-duration-sm;
|
||||
|
||||
&:hover, &:focus-within {
|
||||
background-color: $color-surface-2;
|
||||
}
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
color: $color-text-secondary;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: $color-text-primary;
|
||||
font-size: 16px;
|
||||
|
||||
&::placeholder {
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
139
app/src/components/TheSidebar/index.vue
Normal file
139
app/src/components/TheSidebar/index.vue
Normal file
@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-top">
|
||||
<div class="profile-icon">
|
||||
<!-- 用户头像占位 -->
|
||||
</div>
|
||||
</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>
|
||||
</nav>
|
||||
<div class="sidebar-bottom">
|
||||
<a href="#" class="nav-item">
|
||||
<!-- 图标:设置 -->
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 该组件暂时不需要脚本逻辑。
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@import '@/styles/tokens.scss';
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--sidebar-padding-y) 0;
|
||||
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;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: $z-index-sidebar;
|
||||
}
|
||||
|
||||
.profile-icon {
|
||||
width: var(--sidebar-profile-size);
|
||||
height: var(--sidebar-profile-size);
|
||||
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;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--sidebar-nav-gap);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
width: var(--sidebar-nav-item-size);
|
||||
height: var(--sidebar-nav-item-size);
|
||||
border-radius: $border-radius-medium;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
color: $color-text-secondary;
|
||||
transition: background-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;
|
||||
}
|
||||
}
|
||||
|
||||
.add-item {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-bottom {
|
||||
margin-top: auto;
|
||||
}
|
||||
</style>
|
||||
76
app/src/components/WidgetCard/index.vue
Normal file
76
app/src/components/WidgetCard/index.vue
Normal file
@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="widget-card" :class="`size-${props.widget.size}`">
|
||||
<div class="widget-content">
|
||||
<component :is="widgetComponent" :data="props.widget.data" />
|
||||
</div>
|
||||
<div class="label">{{ label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent, computed } from 'vue';
|
||||
|
||||
// 定义组件属性结构
|
||||
interface Widget {
|
||||
id: string;
|
||||
component: string;
|
||||
size: '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
|
||||
data?: any;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
widget: Widget;
|
||||
}>();
|
||||
|
||||
// 根据名称动态加载组件
|
||||
const widgetComponent = defineAsyncComponent(() =>
|
||||
import(`@/components/widgets/${props.widget.component}.vue`)
|
||||
);
|
||||
|
||||
// 从组件名称提取展示标签
|
||||
const label = computed(() => {
|
||||
return props.widget.component.replace('Widget', '');
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/tokens.scss';
|
||||
|
||||
.widget-card {
|
||||
width: 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;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
// 根据尺寸类名控制尺寸
|
||||
&.size-1x1 { grid-column: span 1; grid-row: span 1; }
|
||||
&.size-1x2 { grid-column: span 1; grid-row: span 2; }
|
||||
&.size-2x1 { grid-column: span 2; grid-row: span 1; }
|
||||
&.size-2x2 { grid-column: span 2; grid-row: span 2; }
|
||||
&.size-2x4 { grid-column: span 2; grid-row: span 4; }
|
||||
}
|
||||
|
||||
.widget-content {
|
||||
flex-grow: 1;
|
||||
padding: var(--widget-content-padding);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--widget-label-font-size);
|
||||
color: $color-text-primary;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: var(--widget-label-padding-y) 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
42
app/src/components/widgets/CalendarWidget.vue
Normal file
42
app/src/components/widgets/CalendarWidget.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="calendar-widget">
|
||||
<div class="month">2026年1月</div>
|
||||
<div class="day">22</div>
|
||||
<div class="details">
|
||||
<span>第22天 第4周</span>
|
||||
<span>腊月初四 周四</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/tokens.scss';
|
||||
.calendar-widget {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
color: white;
|
||||
|
||||
.month {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #ff6b6b; // 月份强调色
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.day {
|
||||
font-size: 48px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
.details {
|
||||
font-size: 12px;
|
||||
color: $color-text-secondary;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
39
app/src/components/widgets/CountdownWidget.vue
Normal file
39
app/src/components/widgets/CountdownWidget.vue
Normal file
@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="countdown-widget">
|
||||
<div class="title">下班还有</div>
|
||||
<div class="time-display">02:37:46</div>
|
||||
<div class="footer">
|
||||
<!-- 这里后续可改为动态数据 -->
|
||||
<span>发薪 19 天</span>
|
||||
<span>周五 1 天</span>
|
||||
<span>腊八节 4 天</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/tokens.scss';
|
||||
.countdown-widget {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
text-align: center;
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
.time-display {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
font-size: 12px;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
56
app/src/components/widgets/HotSearchWidget.vue
Normal file
56
app/src/components/widgets/HotSearchWidget.vue
Normal file
@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="hot-search-widget">
|
||||
<div class="tabs">
|
||||
<span v-for="tab in data.tabs" :key="tab" class="tab active">{{ tab }}</span>
|
||||
</div>
|
||||
<ul class="list">
|
||||
<li v-for="(item, index) in data.items" :key="index">
|
||||
<span>{{ index + 1 }}</span>
|
||||
<span class="title">{{ item.title }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
data: {
|
||||
tabs: string[];
|
||||
items: { title: string; value: string }[];
|
||||
}
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/tokens.scss';
|
||||
.hot-search-widget {
|
||||
height: 100%;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
.tab {
|
||||
font-size: 13px;
|
||||
color: $color-text-secondary;
|
||||
&.active {
|
||||
color: $color-text-primary;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
.list {
|
||||
list-style: none;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.title {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user