This commit is contained in:
yinsx
2026-01-26 09:51:41 +08:00
parent ce796e2fd7
commit d97ea9c791
14 changed files with 626 additions and 150 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

View File

@ -45,7 +45,14 @@ const gridRef = ref<HTMLElement | null>(null);
const grid = ref<any | null>(null);
const isDragging = ref(false);
const suppressClick = ref(false);
const clickBlockUntil = ref(0);
const gridOrder = ref<GridOrderEntry[]>([]);
const resizedKeys = ref(new Set<string>());
const pendingResizedKeys = new Set<string>();
const widgetSizeMap = new Map<string, GridItemSize>();
const iconSizeMap = new Map<string, GridItemSize>();
const layoutAnimationMs = 240;
const layoutEasing = 'cubic-bezier(0.4, 0, 0.2, 1)';
const buildDefaultOrder = (): GridOrderEntry[] => [
...widgetsStore.widgets.map(widget => ({ id: widget.id, type: 'widget' as const })),
@ -173,6 +180,13 @@ const handleClick = (event: MouseEvent) => {
}
};
const handleClickCapture = (event: MouseEvent) => {
if (isDragging.value || suppressClick.value || Date.now() < clickBlockUntil.value) {
event.preventDefault();
event.stopPropagation();
}
};
const handleContextMenu = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const itemEl = target.closest('.grid-item') as HTMLElement | null;
@ -204,10 +218,40 @@ const persistOrderFromGrid = async () => {
grid.value?.layout();
};
const refreshLayout = async () => {
const refreshLayout = async (instant = false) => {
await nextTick();
grid.value?.refreshItems();
grid.value?.layout();
if (instant) {
grid.value?.layout(true);
} else {
grid.value?.layout();
}
};
const markResized = (type: GridItemType, id: string) => {
pendingResizedKeys.add(`${type}:${id}`);
};
const syncSizeMap = (
items: { id: string; size: GridItemSize }[],
map: Map<string, GridItemSize>
) => {
const changed: string[] = [];
const seen = new Set<string>();
for (const item of items) {
const prev = map.get(item.id);
if (prev && prev !== item.size) {
changed.push(item.id);
}
map.set(item.id, item.size);
seen.add(item.id);
}
for (const id of Array.from(map.keys())) {
if (!seen.has(id)) {
map.delete(id);
}
}
return changed;
};
onMounted(async () => {
@ -226,6 +270,8 @@ onMounted(async () => {
fillGaps: true,
rounding: true,
},
layoutDuration: layoutAnimationMs,
layoutEasing,
});
grid.value.on('dragStart', () => {
@ -235,6 +281,7 @@ onMounted(async () => {
grid.value.on('dragEnd', () => {
isDragging.value = false;
clickBlockUntil.value = Date.now() + 180;
window.setTimeout(() => {
suppressClick.value = false;
}, 0);
@ -244,6 +291,17 @@ onMounted(async () => {
persistOrderFromGrid();
});
grid.value.on('layoutStart', () => {
if (!pendingResizedKeys.size) return;
resizedKeys.value = new Set(pendingResizedKeys);
});
grid.value.on('layoutEnd', () => {
if (!pendingResizedKeys.size) return;
pendingResizedKeys.clear();
resizedKeys.value = new Set<string>();
});
grid.value.layout(true);
});
@ -256,20 +314,35 @@ watch(
);
watch(
() => widgetsStore.widgets.map(widget => widget.size).join('|'),
() => widgetsStore.widgets.map(widget => ({ id: widget.id, size: widget.size })),
async () => {
const changed = syncSizeMap(
widgetsStore.widgets.map(widget => ({ id: widget.id, size: widget.size })),
widgetSizeMap
);
for (const id of changed) {
markResized('widget', id);
}
await refreshLayout();
}
);
watch(
() => layoutStore.icons.map(icon => icon.size ?? '1x1').join('|'),
() => layoutStore.icons.map(icon => ({ id: icon.id, size: icon.size ?? '1x1' })),
async () => {
const changed = syncSizeMap(
layoutStore.icons.map(icon => ({ id: icon.id, size: icon.size ?? '1x1' })),
iconSizeMap
);
for (const id of changed) {
markResized('icon', id);
}
await refreshLayout();
}
);
onUnmounted(() => {
pendingResizedKeys.clear();
grid.value?.destroy();
grid.value = null;
});
@ -279,6 +352,8 @@ onUnmounted(() => {
<div
ref="gridRef"
class="grid-canvas"
:style="{ '--layout-anim-ms': `${layoutAnimationMs}ms` }"
@click.capture="handleClickCapture"
@click="handleClick"
@contextmenu.prevent="handleContextMenu"
>
@ -286,7 +361,10 @@ onUnmounted(() => {
v-for="item in orderedItems"
:key="`${item.type}-${item.id}`"
class="grid-item"
:class="`size-${item.type === 'widget' ? item.widget.size : (item.icon.size ?? '1x1')}`"
:class="[
`size-${item.type === 'widget' ? item.widget.size : (item.icon.size ?? '1x1')}`,
{ 'is-resized': resizedKeys.has(`${item.type}:${item.id}`) }
]"
:data-id="item.id"
:data-type="item.type"
>
@ -320,6 +398,12 @@ onUnmounted(() => {
height: var(--cell-size);
margin: calc(var(--cell-gap) / 2);
cursor: grab;
will-change: transform;
}
.grid-item.is-resized {
overflow: hidden;
border-radius: $border-radius-small;
}
.grid-item.size-1x2 {
@ -343,10 +427,25 @@ onUnmounted(() => {
.grid-item-content {
width: 100%;
height: 100%;
transform-origin: center;
}
.grid-item.is-resized .grid-item-content {
animation: grid-item-resize-zoom var(--layout-anim-ms) $motion-easing-standard;
}
@keyframes grid-item-resize-zoom {
0% {
transform: scale(0.97);
}
100% {
transform: scale(1);
}
}
.grid-item.muuri-item-dragging {
z-index: $z-index-menu;
cursor: grabbing;
transition: none;
}
</style>

View File

@ -99,9 +99,9 @@ const props = defineProps<{
color: $color-text-primary;
text-align: center;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: normal;
word-break: break-word;
line-height: 1.2;
pointer-events: none;
}
</style>

View File

@ -2,7 +2,7 @@
<div class="widget-card-wrapper" :class="`size-${props.widget.size}`">
<div class="widget-card">
<div class="widget-content">
<component :is="widgetComponent" :data="props.widget.data" />
<component :is="widgetComponent" :data="props.widget.data" :size="props.widget.size" />
</div>
</div>
<div class="label label-below">{{ label }}</div>
@ -26,7 +26,7 @@ const props = defineProps<{
// 根据名称动态加载组件
const widgetComponent = defineAsyncComponent(() =>
import(`@/components/widgets/${props.widget.component}.vue`)
import(`@/components/widgets/${props.widget.component}/index.vue`)
);
// 从组件名称提取展示标签
@ -90,9 +90,9 @@ const label = computed(() => {
color: $color-text-primary;
text-align: center;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: normal;
word-break: break-word;
line-height: 1.2;
flex-shrink: 0;
}

View File

@ -1,42 +0,0 @@
<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>

View File

@ -0,0 +1,29 @@
<template>
<WidgetDialog :open="props.open" title="Calendar" @update:open="emit('update:open', $event)">
<div class="dialog-placeholder">
Calendar dialog content placeholder.asdas
<a href="asadsd"></a>
</div>
</WidgetDialog>
</template>
<script setup lang="ts">
import WidgetDialog from '@/components/widgets/WidgetDialog.vue';
const props = defineProps<{
open: boolean;
}>();
const emit = defineEmits<{
(event: 'update:open', value: boolean): void;
}>();
</script>
<style lang="scss" scoped>
@import '@/styles/tokens.scss';
.dialog-placeholder {
font-size: 14px;
color: $color-text-primary;
}
</style>

View File

@ -0,0 +1,93 @@
<template>
<div class="calendar-widget" :class="sizeClass" @click="openDialog = true">
<div class="month">January 2026</div>
<div class="day">22</div>
<div class="details">
<span>Week 4 - Thu</span>
<span>Lunar: Month 1 Day 4</span>
</div>
</div>
<CalendarDialog v-model:open="openDialog" />
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import CalendarDialog from './dialog.vue';
type WidgetSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
const props = defineProps<{
size: WidgetSize;
data?: any;
}>();
const openDialog = ref(false);
const sizeClass = computed(() => `size-${props.size}`);
</script>
<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: 600;
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;
}
&.size-1x1 {
.month {
font-size: 12px;
}
.day {
font-size: 34px;
}
.details {
display: none;
}
}
&.size-1x2,
&.size-2x1 {
.day {
font-size: 44px;
}
}
&.size-2x2,
&.size-2x4 {
.day {
font-size: 56px;
}
.details {
font-size: 13px;
}
}
}
</style>

View File

@ -1,39 +0,0 @@
<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>

View File

@ -0,0 +1,28 @@
<template>
<WidgetDialog :open="props.open" title="Countdown" @update:open="emit('update:open', $event)">
<div class="dialog-placeholder">
Countdown dialog content placeholder.adasdas
</div>
</WidgetDialog>
</template>
<script setup lang="ts">
import WidgetDialog from '@/components/widgets/WidgetDialog.vue';
const props = defineProps<{
open: boolean;
}>();
const emit = defineEmits<{
(event: 'update:open', value: boolean): void;
}>();
</script>
<style lang="scss" scoped>
@import '@/styles/tokens.scss';
.dialog-placeholder {
font-size: 14px;
color: $color-text-primary;
}
</style>

View File

@ -0,0 +1,89 @@
<template>
<div class="countdown-widget" :class="sizeClass" @click="openDialog = true">
<div class="title">Time left</div>
<div class="time-display">02:37:46</div>
<div class="footer">
<span>Release 19d</span>
<span>Weekend 1d</span>
<span>Holiday 4d</span>
</div>
</div>
<CountdownDialog v-model:open="openDialog" />
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import CountdownDialog from './dialog.vue';
type WidgetSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
const props = defineProps<{
size: WidgetSize;
data?: any;
}>();
const openDialog = ref(false);
const sizeClass = computed(() => `size-${props.size}`);
</script>
<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: 600;
color: $color-text-primary;
}
.footer {
display: flex;
justify-content: space-around;
gap: 6px;
font-size: 12px;
color: $color-text-secondary;
flex-wrap: wrap;
}
&.size-1x1 {
justify-content: center;
.title,
.footer {
display: none;
}
.time-display {
font-size: 22px;
}
}
&.size-1x2 {
.footer {
display: none;
}
.time-display {
font-size: 26px;
}
}
&.size-2x2,
&.size-2x4 {
.time-display {
font-size: 34px;
}
}
}
</style>

View File

@ -1,56 +0,0 @@
<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>

View File

@ -0,0 +1,28 @@
<template>
<WidgetDialog :open="props.open" title="Hot Search" @update:open="emit('update:open', $event)">
<div class="dialog-placeholder">
Hot search dialog content placeholder.
</div>
</WidgetDialog>
</template>
<script setup lang="ts">
import WidgetDialog from '@/components/widgets/WidgetDialog.vue';
const props = defineProps<{
open: boolean;
}>();
const emit = defineEmits<{
(event: 'update:open', value: boolean): void;
}>();
</script>
<style lang="scss" scoped>
@import '@/styles/tokens.scss';
.dialog-placeholder {
font-size: 14px;
color: $color-text-primary;
}
</style>

View File

@ -0,0 +1,134 @@
<template>
<div class="hot-search-widget" :class="sizeClass" @click="openDialog = true">
<div class="tabs">
<span
v-for="tab in data.tabs"
:key="tab"
class="tab"
:class="{ active: tab === activeTab }"
>
{{ tab }}
</span>
</div>
<ul class="list">
<li v-for="(item, index) in data.items" :key="index">
<span class="rank">{{ index + 1 }}</span>
<span class="title">{{ item.title }}</span>
</li>
</ul>
</div>
<HotSearchDialog v-model:open="openDialog" />
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import HotSearchDialog from './dialog.vue';
type WidgetSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
const props = defineProps<{
size: WidgetSize;
data?: {
tabs: string[];
items: { title: string; value: string }[];
};
}>();
const openDialog = ref(false);
const sizeClass = computed(() => `size-${props.size}`);
const defaultData = {
tabs: ['Trending'],
items: [
{ title: 'Item A', value: '0' },
{ title: 'Item B', value: '0' },
{ title: 'Item C', value: '0' },
{ title: 'Item D', value: '0' },
{ title: 'Item E', value: '0' }
]
};
const data = computed(() => props.data ?? defaultData);
const activeTab = computed(() => data.value.tabs[0] ?? 'Trending');
</script>
<style lang="scss" scoped>
@import '@/styles/tokens.scss';
.hot-search-widget {
height: 100%;
display: flex;
flex-direction: column;
.tabs {
display: flex;
gap: 12px;
margin-bottom: 8px;
.tab {
font-size: 13px;
color: $color-text-secondary;
&.active {
color: $color-text-primary;
font-weight: 600;
}
}
}
.list {
list-style: none;
font-size: 13px;
display: flex;
flex-direction: column;
gap: 6px;
li {
display: flex;
align-items: center;
gap: 8px;
.rank {
width: 18px;
text-align: center;
color: $color-text-secondary;
}
.title {
flex: 1 1 auto;
min-width: 0;
}
}
}
&.size-1x1 {
.tabs {
display: none;
}
.list {
font-size: 12px;
li:nth-child(n + 3) {
display: none;
}
}
}
&.size-1x2,
&.size-2x1 {
.list {
li:nth-child(n + 4) {
display: none;
}
}
}
&.size-2x2,
&.size-2x4 {
.list {
gap: 8px;
}
}
}
</style>

View File

@ -0,0 +1,113 @@
<template>
<teleport to="body">
<transition name="widget-dialog-fade" appear>
<div
v-if="open"
class="widget-dialog-backdrop"
@click.self="emit('update:open', false)"
>
<div class="widget-dialog" role="dialog" aria-modal="true">
<div class="widget-dialog-header">
<div class="widget-dialog-title">{{ titleText }}</div>
<button class="widget-dialog-close" type="button" @click="emit('update:open', false)">
Close
</button>
</div>
<div class="widget-dialog-body">
<slot />
</div>
</div>
</div>
</transition>
</teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{
open: boolean;
title?: string;
}>();
const emit = defineEmits<{
(event: 'update:open', value: boolean): void;
}>();
const titleText = computed(() => props.title ?? 'Details');
</script>
<style lang="scss" scoped>
@import '@/styles/tokens.scss';
.widget-dialog-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: $z-index-menu;
}
.widget-dialog {
width: min(720px, 92vw);
max-height: 85vh;
background: $color-surface-1;
border-radius: $border-radius-small;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: $shadow-lg;
display: flex;
flex-direction: column;
overflow: hidden;
}
.widget-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.widget-dialog-title {
font-size: 16px;
font-weight: 600;
color: $color-text-primary;
}
.widget-dialog-close {
background: transparent;
border: none;
color: $color-text-secondary;
font-size: 13px;
cursor: pointer;
}
.widget-dialog-body {
padding: 16px;
color: $color-text-primary;
overflow: auto;
}
.widget-dialog-fade-enter-active,
.widget-dialog-fade-leave-active {
transition: opacity 200ms ease;
}
.widget-dialog-fade-enter-from,
.widget-dialog-fade-leave-to {
opacity: 0;
}
.widget-dialog-fade-enter-active .widget-dialog,
.widget-dialog-fade-leave-active .widget-dialog {
transition: transform 220ms ease, opacity 220ms ease;
}
.widget-dialog-fade-enter-from .widget-dialog,
.widget-dialog-fade-leave-to .widget-dialog {
transform: scale(0.96);
opacity: 0;
}
</style>