This commit is contained in:
yinsx
2026-01-23 15:23:04 +08:00
commit 5674ce116e
34 changed files with 3901 additions and 0 deletions

4
app/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
/node_modules/
/public/
/dist/
nul

13
app/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>iTab Clone</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1882
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
app/package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "itab-clone",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"muuri": "^0.9.5",
"pinia": "^2.1.7",
"vue": "^3.3.11"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.2",
"sass": "^1.69.7",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vue-tsc": "^1.8.25"
}
}

22
app/src/App.vue Normal file
View File

@ -0,0 +1,22 @@
<template>
<div id="desktop-app">
<TheSidebar />
<MainContent />
<TheContextMenu />
</div>
</template>
<script setup lang="ts">
import TheSidebar from './components/TheSidebar/index.vue';
import MainContent from './components/MainContent/index.vue';
import TheContextMenu from './components/TheContextMenu/index.vue';
</script>
<style lang="scss">
#desktop-app {
width: 100vw;
height: 100vh;
overflow: hidden;
display: flex;
}
</style>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

171
app/src/config/layout.ts Normal file
View File

@ -0,0 +1,171 @@
export const layoutConfig = {
// Muuri 网格系统尺寸。
grid: {
// 1x1 单元格宽高(像素)。
cellSize: 106,
// 单元格间距(像素)。
gap: 16,
},
// 左侧栏布局。
sidebar: {
// 侧栏宽度(像素)。
width: 68,
// 侧栏上下内边距(像素)。
paddingY: 20,
// 头像占位尺寸(像素)。
profileSize: 40,
// 头像下方间距(像素)。
profileMarginBottom: 30,
// 导航项正方形尺寸(像素)。
navItemSize: 44,
// 导航项间距(像素)。
navGap: 16,
// 激活指示条水平偏移(可为负)。
activeIndicatorOffset: -8,
// 激活指示条宽度(像素)。
activeIndicatorWidth: 4,
// 激活指示条高度(像素)。
activeIndicatorHeight: 20,
// “新增”按钮额外上边距(像素)。
addItemMarginTop: 10,
},
// 主内容区边界。
content: {
// 最大宽度(像素)。
maxWidth: 1400,
// 内容区内边距(像素)。
padding: 40,
},
// 搜索栏尺寸。
searchBar: {
// 最大宽度(像素)。
maxWidth: 560,
// 高度(像素)。
height: 52,
// 水平内边距(像素)。
paddingX: 16,
// 搜索栏下方间距(像素)。
marginBottom: 40,
},
// 图标卡片尺寸与字体。
icon: {
// 图标方块尺寸(像素)。
size: 72,
// 图标首字母字体大小(像素)。
fontSize: 28,
// 卡片内边距(像素)。
padding: 8,
// 标签字体大小(像素)。
labelFontSize: 13,
// 图标与标签间距(像素)。
labelMarginTop: 8,
},
// 组件卡片尺寸与字体。
widget: {
// 内容区内边距(像素)。
contentPadding: 16,
// 标签字体大小(像素)。
labelFontSize: 13,
// 标签上下内边距(像素)。
labelPaddingY: 8,
},
// 时钟尺寸。
clock: {
// 时间字体大小(像素)。
timeFontSize: 82,
// 日期字体大小(像素)。
dateFontSize: 16,
// 时钟下方间距(像素)。
marginBottom: 30,
// 字母间距(像素)。
letterSpacing: 2,
// 日期上方间距(像素)。
dateMarginTop: 8,
},
// 右键菜单尺寸。
contextMenu: {
// 菜单宽度(像素)。
width: 180,
// 菜单内边距(像素)。
padding: 8,
// 菜单项上下内边距(像素)。
itemPaddingY: 10,
// 菜单项左右内边距(像素)。
itemPaddingX: 16,
// 图标尺寸(像素)。
iconSize: 16,
// 图标与文字间距(像素)。
iconMarginRight: 12,
// 布局区块上下内边距(像素)。
layoutSectionPaddingY: 6,
// 布局区块左右内边距(像素)。
layoutSectionPaddingX: 12,
// 布局标题下方间距(像素)。
layoutTitleMarginBottom: 8,
// 布局选项间距(像素)。
layoutOptionsGap: 6,
// 布局选项胶囊宽度(像素)。
layoutOptionWidth: 40,
// 布局选项上下内边距(像素)。
layoutOptionPaddingY: 4,
},
} as const;
// 设置根元素上的像素级样式变量。
const setPx = (root: HTMLElement, name: string, value: number) => {
root.style.setProperty(name, `${value}px`);
};
// 将布局配置写入全局样式变量,供样式使用。
export const applyLayoutConfig = (root: HTMLElement = document.documentElement) => {
setPx(root, '--grid-cell-size', layoutConfig.grid.cellSize);
setPx(root, '--grid-gap', layoutConfig.grid.gap);
setPx(root, '--sidebar-width', layoutConfig.sidebar.width);
setPx(root, '--sidebar-padding-y', layoutConfig.sidebar.paddingY);
setPx(root, '--sidebar-profile-size', layoutConfig.sidebar.profileSize);
setPx(root, '--sidebar-profile-margin-bottom', layoutConfig.sidebar.profileMarginBottom);
setPx(root, '--sidebar-nav-item-size', layoutConfig.sidebar.navItemSize);
setPx(root, '--sidebar-nav-gap', layoutConfig.sidebar.navGap);
setPx(root, '--sidebar-active-indicator-offset', layoutConfig.sidebar.activeIndicatorOffset);
setPx(root, '--sidebar-active-indicator-width', layoutConfig.sidebar.activeIndicatorWidth);
setPx(root, '--sidebar-active-indicator-height', layoutConfig.sidebar.activeIndicatorHeight);
setPx(root, '--sidebar-add-margin-top', layoutConfig.sidebar.addItemMarginTop);
setPx(root, '--content-max-width', layoutConfig.content.maxWidth);
setPx(root, '--content-padding', layoutConfig.content.padding);
setPx(root, '--search-max-width', layoutConfig.searchBar.maxWidth);
setPx(root, '--search-height', layoutConfig.searchBar.height);
setPx(root, '--search-padding-x', layoutConfig.searchBar.paddingX);
setPx(root, '--search-margin-bottom', layoutConfig.searchBar.marginBottom);
setPx(root, '--icon-size', layoutConfig.icon.size);
setPx(root, '--icon-font-size', layoutConfig.icon.fontSize);
setPx(root, '--icon-card-padding', layoutConfig.icon.padding);
setPx(root, '--icon-label-font-size', layoutConfig.icon.labelFontSize);
setPx(root, '--icon-label-margin-top', layoutConfig.icon.labelMarginTop);
setPx(root, '--widget-content-padding', layoutConfig.widget.contentPadding);
setPx(root, '--widget-label-font-size', layoutConfig.widget.labelFontSize);
setPx(root, '--widget-label-padding-y', layoutConfig.widget.labelPaddingY);
setPx(root, '--clock-time-font-size', layoutConfig.clock.timeFontSize);
setPx(root, '--clock-date-font-size', layoutConfig.clock.dateFontSize);
setPx(root, '--clock-margin-bottom', layoutConfig.clock.marginBottom);
setPx(root, '--clock-letter-spacing', layoutConfig.clock.letterSpacing);
setPx(root, '--clock-date-margin-top', layoutConfig.clock.dateMarginTop);
setPx(root, '--context-menu-width', layoutConfig.contextMenu.width);
setPx(root, '--context-menu-padding', layoutConfig.contextMenu.padding);
setPx(root, '--context-menu-item-padding-y', layoutConfig.contextMenu.itemPaddingY);
setPx(root, '--context-menu-item-padding-x', layoutConfig.contextMenu.itemPaddingX);
setPx(root, '--context-menu-icon-size', layoutConfig.contextMenu.iconSize);
setPx(root, '--context-menu-icon-margin-right', layoutConfig.contextMenu.iconMarginRight);
setPx(root, '--context-menu-layout-section-padding-y', layoutConfig.contextMenu.layoutSectionPaddingY);
setPx(root, '--context-menu-layout-section-padding-x', layoutConfig.contextMenu.layoutSectionPaddingX);
setPx(root, '--context-menu-layout-title-margin-bottom', layoutConfig.contextMenu.layoutTitleMarginBottom);
setPx(root, '--context-menu-layout-options-gap', layoutConfig.contextMenu.layoutOptionsGap);
setPx(root, '--context-menu-layout-option-width', layoutConfig.contextMenu.layoutOptionWidth);
setPx(root, '--context-menu-layout-option-padding-y', layoutConfig.contextMenu.layoutOptionPaddingY);
};

26
app/src/config/theme.ts Normal file
View File

@ -0,0 +1,26 @@
export const themeConfig = {
// 圆角配置,统一控制卡片与胶囊样式。
radius: {
// 小圆角(像素)。
sm: 8,
// 中圆角(像素)。
md: 16,
// 大圆角(像素)。
lg: 24,
// 胶囊圆角(像素)。
pill: 20,
},
} as const;
// 设置根元素上的像素级样式变量。
const setPx = (root: HTMLElement, name: string, value: number) => {
root.style.setProperty(name, `${value}px`);
};
// 将主题配置写入全局样式变量,供样式使用。
export const applyThemeConfig = (root: HTMLElement = document.documentElement) => {
setPx(root, '--radius-sm', themeConfig.radius.sm);
setPx(root, '--radius-md', themeConfig.radius.md);
setPx(root, '--radius-lg', themeConfig.radius.lg);
setPx(root, '--radius-pill', themeConfig.radius.pill);
};

17
app/src/main.ts Normal file
View File

@ -0,0 +1,17 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './styles/global.scss'
import App from './App.vue'
import { applyLayoutConfig } from './config/layout'
import { applyThemeConfig } from './config/theme'
import { disableDefaultBehaviors } from './utils/disableDefaultBehaviors'
// 在挂载前应用配置,确保样式变量已就绪。
applyThemeConfig()
applyLayoutConfig()
disableDefaultBehaviors()
const app = createApp(App)
app.use(createPinia())
app.mount('#app')

View File

@ -0,0 +1,103 @@
import { defineStore } from 'pinia';
// 模拟数据(来自截图参考)
interface Icon {
id: string;
name: string;
url: string;
img?: string; // 可选:用于图片图标(如徽标)
bgColor?: string; // 可选:纯色背景
}
interface DragState {
isDragging: boolean;
itemId: string | null;
itemType: 'icon' | 'widget' | null;
startX: number;
startY: number;
currentX: number;
currentY: number;
}
interface LayoutState {
icons: Icon[];
dragState: DragState;
}
const defaultIcons: Icon[] = [
{ id: '1', name: '淘宝', url: 'https://taobao.com', bgColor: '#ff4f00' },
{ id: '2', name: '京东商城', url: 'https://jd.com', bgColor: '#e4393c' },
{ id: '3', name: '百度', url: 'https://baidu.com', bgColor: '#3388ff' },
{ id: '4', name: '备忘录', url: '#', bgColor: '#f9ca24' },
{ id: '5', name: '爱奇艺', url: 'https://iqiyi.com', bgColor: '#00be06' },
{ id: '6', name: '文件夹', url: '#', bgColor: '#4285f4' },
{ id: '7', name: '抖音', url: 'https://douyin.com', bgColor: '#222' },
{ id: '8', name: '小浣熊', url: '#', bgColor: '#f0932b' },
{ id: '9', name: 'AiPPT', url: '#', bgColor: '#d63031' },
{ id: '10', name: '电影日历', url: '#', bgColor: 'transparent', img: 'https://example.com/movie_poster.png' }, // 图片示例
{ id: '11', name: '稿定设计', url: '#', bgColor: '#00aaff' },
{ id: '12', name: '壁纸', url: '#', bgColor: '#1dd1a1' },
{ id: '13', 'name': '即梦AI', url: '#', bgColor: '#6c5ce7' },
{ id: '14', name: '码上掘金', url: '#', bgColor: '#1e80ff' },
{ id: '15', name: '扩展管理', url: '#', bgColor: '#7f8c8d' },
{ id: '16', name: '书签管理', url: '#', bgColor: '#f1c40f' },
];
const savedIcons = localStorage.getItem('itab_icons');
export const useLayoutStore = defineStore('layout', {
state: (): LayoutState => ({
icons: savedIcons ? JSON.parse(savedIcons) : defaultIcons,
dragState: {
isDragging: false,
itemId: null,
itemType: null,
startX: 0,
startY: 0,
currentX: 0,
currentY: 0,
}
}),
actions: {
reorderIcons(draggedId: string, targetId: string) {
const draggedIndex = this.icons.findIndex(p => p.id === draggedId);
const targetIndex = this.icons.findIndex(p => p.id === targetId);
if (draggedIndex !== -1 && targetIndex !== -1) {
// 直接交换
const draggedItem = this.icons[draggedIndex];
this.icons[draggedIndex] = this.icons[targetIndex];
this.icons[targetIndex] = draggedItem;
// 持久化顺序
localStorage.setItem('itab_icons', JSON.stringify(this.icons));
}
},
setIconOrder(orderIds: string[]) {
const ordered: Icon[] = [];
const seen = new Set<string>();
for (const id of orderIds) {
const icon = this.icons.find(item => item.id === id);
if (icon) {
ordered.push(icon);
seen.add(icon.id);
}
}
for (const icon of this.icons) {
if (!seen.has(icon.id)) {
ordered.push(icon);
}
}
this.icons = ordered;
localStorage.setItem('itab_icons', JSON.stringify(this.icons));
},
deleteIcon(itemId: string) {
const index = this.icons.findIndex(p => p.id === itemId);
if (index !== -1) {
this.icons.splice(index, 1);
// 持久化顺序
localStorage.setItem('itab_icons', JSON.stringify(this.icons));
}
},
}
});

View File

@ -0,0 +1,40 @@
import { defineStore } from 'pinia';
interface ContextMenuState {
isOpen: boolean;
x: number;
y: number;
itemId: string | null;
itemType?: 'icon' | 'widget';
}
interface UIState {
contextMenu: ContextMenuState;
}
export const useUIStore = defineStore('ui', {
state: (): UIState => ({
contextMenu: {
isOpen: false,
x: 0,
y: 0,
itemId: null,
itemType: 'icon',
}
}),
actions: {
openContextMenu(x: number, y: number, itemId: string, itemType: 'icon' | 'widget') {
this.contextMenu = {
isOpen: true,
x,
y,
itemId,
itemType,
};
},
closeContextMenu() {
this.contextMenu.isOpen = false;
this.contextMenu.itemId = null;
},
},
});

View File

@ -0,0 +1,88 @@
import { defineStore } from 'pinia';
// 组件数据结构
type WidgetSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
interface Widget {
id: string;
component: string; // 渲染的组件名
size: WidgetSize; // 组件尺寸
gridPosition: { x: number; y: number }; // 网格位置
data?: any; // 组件数据
}
interface WidgetsState {
widgets: Widget[];
}
const defaultWidgets: Widget[] = [
{
id: 'widget-1',
component: 'CalendarWidget',
size: '1x1',
gridPosition: { x: 0, y: 0 }
},
{
id: 'widget-2',
component: 'HotSearchWidget',
size: '2x1',
gridPosition: { x: 1, y: 0 },
data: {
tabs: ['百度', '微博', '抖音'],
items: [
{ title: '茅台确认“马茅”包装少写一撇', value: '780.9万' },
{ title: '双休不应成为奢侈品', value: '771.2万' },
{ title: '突破8100亿元这场双向奔赴很燃', value: '761.8万' },
{ title: '第一个2万亿经济大区要来了', value: '752.2万' }
]
}
},
{
id: 'widget-3',
component: 'CountdownWidget',
size: '2x1',
gridPosition: { x: 0, y: 1 }
}
];
const savedWidgets = localStorage.getItem('itab_widgets');
// 模拟数据(来自截图参考)
export const useWidgetsStore = defineStore('widgets', {
state: (): WidgetsState => ({
widgets: savedWidgets ? JSON.parse(savedWidgets) : defaultWidgets,
}),
actions: {
updateWidgetSize(widgetId: string, newSize: WidgetSize) {
const widget = this.widgets.find(w => w.id === widgetId);
if (widget) {
widget.size = newSize;
localStorage.setItem('itab_widgets', JSON.stringify(this.widgets));
}
},
updateWidgetPosition(widgetId: string, x: number, y: number) {
const widget = this.widgets.find(w => w.id === widgetId);
if (widget) {
widget.gridPosition = { x, y };
localStorage.setItem('itab_widgets', JSON.stringify(this.widgets));
}
},
setWidgetOrder(orderIds: string[]) {
const ordered: Widget[] = [];
const seen = new Set<string>();
for (const id of orderIds) {
const widget = this.widgets.find(item => item.id === id);
if (widget) {
ordered.push(widget);
seen.add(widget.id);
}
}
for (const widget of this.widgets) {
if (!seen.has(widget.id)) {
ordered.push(widget);
}
}
this.widgets = ordered;
localStorage.setItem('itab_widgets', JSON.stringify(this.widgets));
}
}
});

View File

@ -0,0 +1,49 @@
@import './tokens.scss';
// 样式重置
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
// 全局样式
html, body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: $color-text-primary;
background-color: $color-background;
overscroll-behavior: none;
}
body {
width: 100vw;
height: 100vh;
overflow: hidden;
}
#app {
width: 100%;
height: 100%;
background: radial-gradient(ellipse at bottom, #1b2735 0%, #090a0f 100%);
}
// 滚动条样式
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}

View File

@ -0,0 +1,38 @@
// 样式变量定义
// 颜色
$color-background: #121212;
$color-text-primary: #ffffff;
$color-text-secondary: #a9a9a9;
$color-accent: #ff9500;
// 表面颜色(用于卡片、弹窗等)
$color-surface-1: rgba(30, 30, 30, 0.7);
$color-surface-2: rgba(45, 45, 45, 0.7);
// 毛玻璃效果(云母/亚克力)
$backdrop-filter-blur: 16px;
$backdrop-filter-saturation: 180%;
// 几何尺寸
$border-radius-small: var(--radius-sm);
$border-radius-medium: var(--radius-md);
$border-radius-large: var(--radius-lg);
$grid-gap: var(--grid-gap);
// 阴影(轻拟物)
$shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.1), 0 1px 1px rgba(0, 0, 0, 0.06);
$shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06);
$shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1), 0 4px 6px rgba(0, 0, 0, 0.07);
// 动画
$motion-duration-sm: 0.2s;
$motion-duration-md: 0.3s;
$motion-easing-standard: cubic-bezier(0.4, 0, 0.2, 1);
// 层级
$z-index-base: 0;
$z-index-content: 1;
$z-index-sidebar: 100;
$z-index-modal: 200;
$z-index-menu: 300;

1
app/src/types/muuri.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module 'muuri';

View File

@ -0,0 +1,36 @@
// 禁用浏览器默认交互(右键菜单、文本选择、拖拽)。
const isEditableTarget = (target: EventTarget | null) => {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
const tag = target.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea' || tag === 'select';
};
export const disableDefaultBehaviors = () => {
document.addEventListener(
'contextmenu',
(event) => {
event.preventDefault();
},
{ capture: true }
);
document.addEventListener(
'selectstart',
(event) => {
if (isEditableTarget(event.target)) return;
event.preventDefault();
},
{ capture: true }
);
document.addEventListener(
'dragstart',
(event) => {
if (isEditableTarget(event.target)) return;
event.preventDefault();
},
{ capture: true }
);
};

31
app/tsconfig.json Normal file
View File

@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Aliases */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
app/tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

13
app/vite.config.ts Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})