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 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();
|
||||
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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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