1
This commit is contained in:
BIN
ScreenShot_2026-01-23_175720_764.png
Normal file
BIN
ScreenShot_2026-01-23_175720_764.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 329 KiB |
@ -45,7 +45,14 @@ const gridRef = ref<HTMLElement | null>(null);
|
|||||||
const grid = ref<any | null>(null);
|
const grid = ref<any | null>(null);
|
||||||
const isDragging = ref(false);
|
const isDragging = ref(false);
|
||||||
const suppressClick = ref(false);
|
const suppressClick = ref(false);
|
||||||
|
const clickBlockUntil = ref(0);
|
||||||
const gridOrder = ref<GridOrderEntry[]>([]);
|
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[] => [
|
const buildDefaultOrder = (): GridOrderEntry[] => [
|
||||||
...widgetsStore.widgets.map(widget => ({ id: widget.id, type: 'widget' as const })),
|
...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 handleContextMenu = (event: MouseEvent) => {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
const itemEl = target.closest('.grid-item') as HTMLElement | null;
|
const itemEl = target.closest('.grid-item') as HTMLElement | null;
|
||||||
@ -204,10 +218,40 @@ const persistOrderFromGrid = async () => {
|
|||||||
grid.value?.layout();
|
grid.value?.layout();
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshLayout = async () => {
|
const refreshLayout = async (instant = false) => {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
grid.value?.refreshItems();
|
grid.value?.refreshItems();
|
||||||
|
if (instant) {
|
||||||
|
grid.value?.layout(true);
|
||||||
|
} else {
|
||||||
grid.value?.layout();
|
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 () => {
|
onMounted(async () => {
|
||||||
@ -226,6 +270,8 @@ onMounted(async () => {
|
|||||||
fillGaps: true,
|
fillGaps: true,
|
||||||
rounding: true,
|
rounding: true,
|
||||||
},
|
},
|
||||||
|
layoutDuration: layoutAnimationMs,
|
||||||
|
layoutEasing,
|
||||||
});
|
});
|
||||||
|
|
||||||
grid.value.on('dragStart', () => {
|
grid.value.on('dragStart', () => {
|
||||||
@ -235,6 +281,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
grid.value.on('dragEnd', () => {
|
grid.value.on('dragEnd', () => {
|
||||||
isDragging.value = false;
|
isDragging.value = false;
|
||||||
|
clickBlockUntil.value = Date.now() + 180;
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
suppressClick.value = false;
|
suppressClick.value = false;
|
||||||
}, 0);
|
}, 0);
|
||||||
@ -244,6 +291,17 @@ onMounted(async () => {
|
|||||||
persistOrderFromGrid();
|
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);
|
grid.value.layout(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -256,20 +314,35 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => widgetsStore.widgets.map(widget => widget.size).join('|'),
|
() => widgetsStore.widgets.map(widget => ({ id: widget.id, size: widget.size })),
|
||||||
async () => {
|
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();
|
await refreshLayout();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => layoutStore.icons.map(icon => icon.size ?? '1x1').join('|'),
|
() => layoutStore.icons.map(icon => ({ id: icon.id, size: icon.size ?? '1x1' })),
|
||||||
async () => {
|
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();
|
await refreshLayout();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
pendingResizedKeys.clear();
|
||||||
grid.value?.destroy();
|
grid.value?.destroy();
|
||||||
grid.value = null;
|
grid.value = null;
|
||||||
});
|
});
|
||||||
@ -279,6 +352,8 @@ onUnmounted(() => {
|
|||||||
<div
|
<div
|
||||||
ref="gridRef"
|
ref="gridRef"
|
||||||
class="grid-canvas"
|
class="grid-canvas"
|
||||||
|
:style="{ '--layout-anim-ms': `${layoutAnimationMs}ms` }"
|
||||||
|
@click.capture="handleClickCapture"
|
||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
@contextmenu.prevent="handleContextMenu"
|
@contextmenu.prevent="handleContextMenu"
|
||||||
>
|
>
|
||||||
@ -286,7 +361,10 @@ 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="`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-id="item.id"
|
||||||
:data-type="item.type"
|
:data-type="item.type"
|
||||||
>
|
>
|
||||||
@ -320,6 +398,12 @@ onUnmounted(() => {
|
|||||||
height: var(--cell-size);
|
height: var(--cell-size);
|
||||||
margin: calc(var(--cell-gap) / 2);
|
margin: calc(var(--cell-gap) / 2);
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-item.is-resized {
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: $border-radius-small;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-item.size-1x2 {
|
.grid-item.size-1x2 {
|
||||||
@ -343,10 +427,25 @@ onUnmounted(() => {
|
|||||||
.grid-item-content {
|
.grid-item-content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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 {
|
.grid-item.muuri-item-dragging {
|
||||||
z-index: $z-index-menu;
|
z-index: $z-index-menu;
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
|
transition: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -99,9 +99,9 @@ const props = defineProps<{
|
|||||||
color: $color-text-primary;
|
color: $color-text-primary;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
white-space: nowrap;
|
white-space: normal;
|
||||||
overflow: hidden;
|
word-break: break-word;
|
||||||
text-overflow: ellipsis;
|
line-height: 1.2;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<div class="widget-card-wrapper" :class="`size-${props.widget.size}`">
|
<div class="widget-card-wrapper" :class="`size-${props.widget.size}`">
|
||||||
<div class="widget-card">
|
<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" :size="props.widget.size" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="label label-below">{{ label }}</div>
|
<div class="label label-below">{{ label }}</div>
|
||||||
@ -26,7 +26,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
// 根据名称动态加载组件
|
// 根据名称动态加载组件
|
||||||
const widgetComponent = defineAsyncComponent(() =>
|
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;
|
color: $color-text-primary;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
white-space: nowrap;
|
white-space: normal;
|
||||||
overflow: hidden;
|
word-break: break-word;
|
||||||
text-overflow: ellipsis;
|
line-height: 1.2;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
|
||||||
29
app/src/components/widgets/CalendarWidget/dialog.vue
Normal file
29
app/src/components/widgets/CalendarWidget/dialog.vue
Normal 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>
|
||||||
93
app/src/components/widgets/CalendarWidget/index.vue
Normal file
93
app/src/components/widgets/CalendarWidget/index.vue
Normal 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>
|
||||||
@ -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>
|
|
||||||
28
app/src/components/widgets/CountdownWidget/dialog.vue
Normal file
28
app/src/components/widgets/CountdownWidget/dialog.vue
Normal 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>
|
||||||
89
app/src/components/widgets/CountdownWidget/index.vue
Normal file
89
app/src/components/widgets/CountdownWidget/index.vue
Normal 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>
|
||||||
@ -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>
|
|
||||||
28
app/src/components/widgets/HotSearchWidget/dialog.vue
Normal file
28
app/src/components/widgets/HotSearchWidget/dialog.vue
Normal 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>
|
||||||
134
app/src/components/widgets/HotSearchWidget/index.vue
Normal file
134
app/src/components/widgets/HotSearchWidget/index.vue
Normal 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>
|
||||||
113
app/src/components/widgets/WidgetDialog.vue
Normal file
113
app/src/components/widgets/WidgetDialog.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user