1
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
/node_modules/
|
||||
/public/
|
||||
/dist/
|
||||
nul
|
||||
BIN
ScreenShot_2026-01-22_174129_211.png
Normal file
BIN
ScreenShot_2026-01-22_174129_211.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
4
app/.gitignore
vendored
Normal file
4
app/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
/node_modules/
|
||||
/public/
|
||||
/dist/
|
||||
nul
|
||||
13
app/index.html
Normal file
13
app/index.html
Normal 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
1882
app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
app/package.json
Normal file
23
app/package.json
Normal 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
22
app/src/App.vue
Normal 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>
|
||||
343
app/src/components/GridCanvas/index.vue
Normal file
343
app/src/components/GridCanvas/index.vue
Normal file
@ -0,0 +1,343 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
|
||||
import Muuri from 'muuri';
|
||||
import { useLayoutStore } from '@/store/useLayoutStore';
|
||||
import { useWidgetsStore } from '@/store/useWidgetsStore';
|
||||
import { useUIStore } from '@/store/useUIStore';
|
||||
import IconCard from '@/components/IconCard/index.vue';
|
||||
import WidgetCard from '@/components/WidgetCard/index.vue';
|
||||
|
||||
type GridItemType = 'icon' | 'widget';
|
||||
|
||||
interface Icon {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
img?: string;
|
||||
bgColor?: string;
|
||||
}
|
||||
|
||||
type WidgetSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
|
||||
interface Widget {
|
||||
id: string;
|
||||
component: string;
|
||||
size: WidgetSize;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
interface GridOrderEntry {
|
||||
id: string;
|
||||
type: GridItemType;
|
||||
}
|
||||
|
||||
type OrderedGridItem =
|
||||
| { type: 'icon'; id: string; icon: Icon }
|
||||
| { type: 'widget'; id: string; widget: Widget };
|
||||
|
||||
const GRID_ORDER_STORAGE_KEY = 'itab_grid_order';
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
const widgetsStore = useWidgetsStore();
|
||||
const uiStore = useUIStore();
|
||||
const gridRef = ref<HTMLElement | null>(null);
|
||||
const grid = ref<any | null>(null);
|
||||
const isDragging = ref(false);
|
||||
const suppressClick = ref(false);
|
||||
const gridOrder = ref<GridOrderEntry[]>([]);
|
||||
|
||||
const buildDefaultOrder = (): GridOrderEntry[] => [
|
||||
...widgetsStore.widgets.map(widget => ({ id: widget.id, type: 'widget' as const })),
|
||||
...layoutStore.icons.map(icon => ({ id: icon.id, type: 'icon' as const })),
|
||||
];
|
||||
|
||||
const persistGridOrder = (order: GridOrderEntry[]) => {
|
||||
gridOrder.value = order;
|
||||
localStorage.setItem(GRID_ORDER_STORAGE_KEY, JSON.stringify(order));
|
||||
const iconOrder = order.filter(item => item.type === 'icon').map(item => item.id);
|
||||
const widgetOrder = order.filter(item => item.type === 'widget').map(item => item.id);
|
||||
if (iconOrder.length) {
|
||||
layoutStore.setIconOrder(iconOrder);
|
||||
}
|
||||
if (widgetOrder.length) {
|
||||
widgetsStore.setWidgetOrder(widgetOrder);
|
||||
}
|
||||
};
|
||||
|
||||
const loadGridOrder = () => {
|
||||
const saved = localStorage.getItem(GRID_ORDER_STORAGE_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved) as GridOrderEntry[];
|
||||
if (Array.isArray(parsed)) {
|
||||
gridOrder.value = parsed.filter(item => item?.id && (item.type === 'icon' || item.type === 'widget'));
|
||||
}
|
||||
} catch {
|
||||
gridOrder.value = [];
|
||||
}
|
||||
}
|
||||
if (!gridOrder.value.length) {
|
||||
gridOrder.value = buildDefaultOrder();
|
||||
}
|
||||
persistGridOrder(gridOrder.value);
|
||||
};
|
||||
|
||||
const ensureOrderConsistency = () => {
|
||||
const iconIds = new Set(layoutStore.icons.map(icon => icon.id));
|
||||
const widgetIds = new Set(widgetsStore.widgets.map(widget => widget.id));
|
||||
const nextOrder: GridOrderEntry[] = [];
|
||||
|
||||
for (const entry of gridOrder.value) {
|
||||
if (entry.type === 'icon' && iconIds.has(entry.id)) {
|
||||
nextOrder.push(entry);
|
||||
}
|
||||
if (entry.type === 'widget' && widgetIds.has(entry.id)) {
|
||||
nextOrder.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
for (const widget of widgetsStore.widgets) {
|
||||
if (!nextOrder.some(item => item.type === 'widget' && item.id === widget.id)) {
|
||||
nextOrder.push({ id: widget.id, type: 'widget' });
|
||||
}
|
||||
}
|
||||
|
||||
for (const icon of layoutStore.icons) {
|
||||
if (!nextOrder.some(item => item.type === 'icon' && item.id === icon.id)) {
|
||||
nextOrder.push({ id: icon.id, type: 'icon' });
|
||||
}
|
||||
}
|
||||
|
||||
const orderChanged =
|
||||
nextOrder.length !== gridOrder.value.length ||
|
||||
nextOrder.some((entry, index) => {
|
||||
const current = gridOrder.value[index];
|
||||
return current?.id !== entry.id || current?.type !== entry.type;
|
||||
});
|
||||
|
||||
if (orderChanged) {
|
||||
persistGridOrder(nextOrder);
|
||||
} else {
|
||||
gridOrder.value = nextOrder;
|
||||
}
|
||||
};
|
||||
|
||||
const orderedItems = computed<OrderedGridItem[]>(() => {
|
||||
const iconsById = new Map(layoutStore.icons.map(icon => [icon.id, icon]));
|
||||
const widgetsById = new Map(widgetsStore.widgets.map(widget => [widget.id, widget]));
|
||||
const used = new Set<string>();
|
||||
const items: OrderedGridItem[] = [];
|
||||
|
||||
for (const entry of gridOrder.value) {
|
||||
if (entry.type === 'icon') {
|
||||
const icon = iconsById.get(entry.id);
|
||||
if (icon) {
|
||||
items.push({ type: 'icon', id: icon.id, icon });
|
||||
used.add(`icon:${icon.id}`);
|
||||
}
|
||||
} else {
|
||||
const widget = widgetsById.get(entry.id);
|
||||
if (widget) {
|
||||
items.push({ type: 'widget', id: widget.id, widget });
|
||||
used.add(`widget:${widget.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const widget of widgetsStore.widgets) {
|
||||
const key = `widget:${widget.id}`;
|
||||
if (!used.has(key)) {
|
||||
items.push({ type: 'widget', id: widget.id, widget });
|
||||
}
|
||||
}
|
||||
|
||||
for (const icon of layoutStore.icons) {
|
||||
const key = `icon:${icon.id}`;
|
||||
if (!used.has(key)) {
|
||||
items.push({ type: 'icon', id: icon.id, icon });
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (suppressClick.value || isDragging.value) return;
|
||||
const target = event.target as HTMLElement;
|
||||
const itemEl = target.closest('.grid-item') as HTMLElement | null;
|
||||
if (!itemEl || itemEl.dataset.type !== 'icon') return;
|
||||
const link = target.closest('a') as HTMLAnchorElement | null;
|
||||
if (link && link.href) {
|
||||
window.open(link.href, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const itemEl = target.closest('.grid-item') as HTMLElement | null;
|
||||
if (!itemEl) return;
|
||||
const id = itemEl.dataset.id;
|
||||
const type = itemEl.dataset.type as GridItemType | undefined;
|
||||
if (id && type) {
|
||||
uiStore.openContextMenu(event.clientX, event.clientY, id, type);
|
||||
}
|
||||
};
|
||||
|
||||
const persistOrderFromGrid = async () => {
|
||||
if (!grid.value) return;
|
||||
const items = grid.value.getItems();
|
||||
const nextOrder: GridOrderEntry[] = [];
|
||||
for (const item of items) {
|
||||
const element = item.getElement() as HTMLElement;
|
||||
const id = element.dataset.id;
|
||||
const type = element.dataset.type as GridItemType | undefined;
|
||||
if (id && (type === 'icon' || type === 'widget')) {
|
||||
nextOrder.push({ id, type });
|
||||
}
|
||||
}
|
||||
if (nextOrder.length) {
|
||||
persistGridOrder(nextOrder);
|
||||
}
|
||||
await nextTick();
|
||||
grid.value?.synchronize();
|
||||
grid.value?.layout();
|
||||
};
|
||||
|
||||
const refreshLayout = async () => {
|
||||
await nextTick();
|
||||
grid.value?.refreshItems();
|
||||
grid.value?.layout();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
loadGridOrder();
|
||||
await nextTick();
|
||||
if (!gridRef.value) return;
|
||||
grid.value = new Muuri(gridRef.value, {
|
||||
items: '.grid-item',
|
||||
dragEnabled: true,
|
||||
dragStartPredicate: {
|
||||
distance: 5,
|
||||
delay: 150,
|
||||
},
|
||||
dragSort: true,
|
||||
layout: {
|
||||
fillGaps: true,
|
||||
rounding: true,
|
||||
},
|
||||
});
|
||||
|
||||
grid.value.on('dragStart', () => {
|
||||
isDragging.value = true;
|
||||
suppressClick.value = true;
|
||||
});
|
||||
|
||||
grid.value.on('dragEnd', () => {
|
||||
isDragging.value = false;
|
||||
window.setTimeout(() => {
|
||||
suppressClick.value = false;
|
||||
}, 0);
|
||||
});
|
||||
|
||||
grid.value.on('dragReleaseEnd', () => {
|
||||
persistOrderFromGrid();
|
||||
});
|
||||
|
||||
grid.value.layout(true);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [layoutStore.icons.length, widgetsStore.widgets.length],
|
||||
() => {
|
||||
ensureOrderConsistency();
|
||||
refreshLayout();
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => widgetsStore.widgets.map(widget => widget.size).join('|'),
|
||||
() => {
|
||||
refreshLayout();
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
grid.value?.destroy();
|
||||
grid.value = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="gridRef"
|
||||
class="grid-canvas"
|
||||
@click="handleClick"
|
||||
@contextmenu.prevent="handleContextMenu"
|
||||
>
|
||||
<div
|
||||
v-for="item in orderedItems"
|
||||
:key="`${item.type}-${item.id}`"
|
||||
class="grid-item"
|
||||
:class="item.type === 'widget' ? `size-${item.widget.size}` : 'size-1x1'"
|
||||
:data-id="item.id"
|
||||
:data-type="item.type"
|
||||
>
|
||||
<div class="grid-item-content">
|
||||
<WidgetCard v-if="item.type === 'widget'" :widget="item.widget" />
|
||||
<IconCard v-else :icon="item.icon" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/tokens.scss';
|
||||
|
||||
.grid-canvas {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
user-select: none; /* 标准 */
|
||||
-webkit-user-select: none; /* Safari 浏览器 */
|
||||
-moz-user-select: none; /* Firefox 浏览器 */
|
||||
-ms-user-select: none; /* IE 10+/Edge 浏览器 */
|
||||
--cell-size: var(--grid-cell-size);
|
||||
--cell-gap: var(--grid-gap);
|
||||
padding: calc(var(--cell-gap) / 2);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
position: absolute;
|
||||
width: var(--cell-size);
|
||||
height: var(--cell-size);
|
||||
margin: calc(var(--cell-gap) / 2);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.grid-item.size-1x2 {
|
||||
height: calc(var(--cell-size) * 2 + var(--cell-gap));
|
||||
}
|
||||
|
||||
.grid-item.size-2x1 {
|
||||
width: calc(var(--cell-size) * 2 + var(--cell-gap));
|
||||
}
|
||||
|
||||
.grid-item.size-2x2 {
|
||||
width: calc(var(--cell-size) * 2 + var(--cell-gap));
|
||||
height: calc(var(--cell-size) * 2 + var(--cell-gap));
|
||||
}
|
||||
|
||||
.grid-item.size-2x4 {
|
||||
width: calc(var(--cell-size) * 2 + var(--cell-gap));
|
||||
height: calc(var(--cell-size) * 4 + var(--cell-gap) * 3);
|
||||
}
|
||||
|
||||
.grid-item-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.grid-item.muuri-item-dragging {
|
||||
z-index: $z-index-menu;
|
||||
cursor: grabbing;
|
||||
}
|
||||
</style>
|
||||
88
app/src/components/IconCard/index.vue
Normal file
88
app/src/components/IconCard/index.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<a
|
||||
:href="props.icon.url"
|
||||
class="icon-card-wrapper"
|
||||
@click.prevent
|
||||
target="_blank"
|
||||
>
|
||||
<div class="icon-card" :style="{ backgroundColor: props.icon.bgColor }">
|
||||
<img v-if="props.icon.img" :src="props.icon.img" :alt="props.icon.name" />
|
||||
<span v-else>{{ props.icon.name.charAt(0) }}</span>
|
||||
</div>
|
||||
<div class="label">{{ props.icon.name }}</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Icon {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
img?: string;
|
||||
bgColor?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
icon: Icon;
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/tokens.scss';
|
||||
|
||||
.icon-card-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-decoration: none;
|
||||
border-radius: $border-radius-medium;
|
||||
padding: var(--icon-card-padding);
|
||||
transition: transform $motion-duration-sm $motion-easing-standard, background-color $motion-duration-sm $motion-easing-standard;
|
||||
user-select: none; // 拖拽时避免选中文本
|
||||
box-sizing: border-box;
|
||||
|
||||
// 悬停效果由父级拖拽状态控制
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-card {
|
||||
width: var(--icon-size);
|
||||
height: var(--icon-size);
|
||||
border-radius: $border-radius-large;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--icon-font-size);
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
box-shadow: $shadow-md;
|
||||
margin-bottom: var(--icon-label-margin-top);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
pointer-events: none; // 指针事件交给外层容器处理
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: $border-radius-large;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--icon-label-font-size);
|
||||
color: $color-text-primary;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
40
app/src/components/MainContent/index.vue
Normal file
40
app/src/components/MainContent/index.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<main class="main-content">
|
||||
<div class="main-content-container">
|
||||
<TheClock />
|
||||
<TheSearchBar />
|
||||
<GridCanvas />
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import TheClock from '@/components/TheClock/index.vue';
|
||||
|
||||
import TheSearchBar from '@/components/TheSearchBar/index.vue';
|
||||
|
||||
import GridCanvas from '@/components/GridCanvas/index.vue';
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.main-content {
|
||||
padding-left: var(--sidebar-width); // 与侧栏宽度保持一致
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start; // 内容顶部对齐
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.main-content-container {
|
||||
width: 100%;
|
||||
max-width: var(--content-max-width); // 内容区域最大宽度
|
||||
padding: var(--content-padding); // 内容区内边距
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
58
app/src/components/TheClock/index.vue
Normal file
58
app/src/components/TheClock/index.vue
Normal file
@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="clock-container">
|
||||
<div class="time">{{ currentTime }}</div>
|
||||
<div class="date">{{ currentDate }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
const currentTime = ref('');
|
||||
const currentDate = ref('');
|
||||
let timerId: number;
|
||||
|
||||
const updateTime = () => {
|
||||
const now = new Date();
|
||||
currentTime.value = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
currentDate.value = new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long',
|
||||
}).format(now);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
updateTime();
|
||||
timerId = window.setInterval(updateTime, 1000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(timerId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@import '@/styles/tokens.scss';
|
||||
|
||||
.clock-container {
|
||||
text-align: center;
|
||||
margin-bottom: var(--clock-margin-bottom);
|
||||
color: $color-text-primary;
|
||||
text-shadow: $shadow-md;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: var(--clock-time-font-size);
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--clock-letter-spacing);
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: var(--clock-date-font-size);
|
||||
color: $color-text-secondary;
|
||||
margin-top: var(--clock-date-margin-top);
|
||||
}
|
||||
</style>
|
||||
163
app/src/components/TheContextMenu/index.vue
Normal file
163
app/src/components/TheContextMenu/index.vue
Normal file
@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div v-if="uiStore.contextMenu.isOpen" class="context-menu-overlay" @click="close" @contextmenu.prevent="close">
|
||||
<div class="context-menu" :style="menuStyle">
|
||||
<ul v-if="uiStore.contextMenu.itemType === 'icon'">
|
||||
<li><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="menu-icon"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>在新标签页打开</li>
|
||||
<li><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="menu-icon"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"></path><path d="m15 5 4 4"></path></svg>编辑图标</li>
|
||||
<li><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="menu-icon"><path d="m12 19-7-7 7-7 7 7-7 7Z"></path><path d="M19 5v14"></path><path d="M5 5v14"></path></svg>编辑主页</li>
|
||||
<li @click="deleteItem"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="menu-icon"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>删除</li>
|
||||
</ul>
|
||||
<ul v-else-if="uiStore.contextMenu.itemType === 'widget'">
|
||||
<li class="layout-section">
|
||||
<div class="layout-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="menu-icon"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"></rect><line x1="3" y1="9" x2="21" y2="9"></line><line x1="9" y1="21" x2="9" y2="9"></line></svg>
|
||||
<span>布局</span>
|
||||
</div>
|
||||
<div class="widget-size-options">
|
||||
<span v-for="size in widgetSizes" :key="size" @click="changeWidgetSize(size)" class="size-option">{{ size }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="menu-icon"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"></path></svg>编辑组件</li>
|
||||
<li @click="deleteItem"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="menu-icon"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>删除</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useUIStore } from '@/store/useUIStore';
|
||||
import { useLayoutStore } from '@/store/useLayoutStore';
|
||||
import { useWidgetsStore } from '@/store/useWidgetsStore';
|
||||
|
||||
const uiStore = useUIStore();
|
||||
const layoutStore = useLayoutStore();
|
||||
const widgetsStore = useWidgetsStore();
|
||||
|
||||
const widgetSizes = ['1x1', '1x2', '2x1', '2x2', '2x4'];
|
||||
|
||||
const menuStyle = computed(() => ({
|
||||
left: `${uiStore.contextMenu.x}px`,
|
||||
top: `${uiStore.contextMenu.y}px`,
|
||||
}));
|
||||
|
||||
const close = () => {
|
||||
uiStore.closeContextMenu();
|
||||
};
|
||||
|
||||
const deleteItem = () => {
|
||||
if (uiStore.contextMenu.itemId) {
|
||||
if (uiStore.contextMenu.itemType === 'icon') {
|
||||
layoutStore.deleteIcon(uiStore.contextMenu.itemId);
|
||||
} else if (uiStore.contextMenu.itemType === 'widget') {
|
||||
// 组件删除逻辑
|
||||
}
|
||||
}
|
||||
close();
|
||||
};
|
||||
|
||||
const changeWidgetSize = (newSize: '1x1' | '1x2' | '2x1' | '2x2' | '2x4') => {
|
||||
if (uiStore.contextMenu.itemId) {
|
||||
widgetsStore.updateWidgetSize(uiStore.contextMenu.itemId, newSize);
|
||||
}
|
||||
close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/tokens.scss';
|
||||
|
||||
.context-menu-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: $z-index-menu - 1;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: absolute;
|
||||
width: var(--context-menu-width);
|
||||
background-color: $color-surface-2;
|
||||
backdrop-filter: blur($backdrop-filter-blur) saturate($backdrop-filter-saturation);
|
||||
border-radius: $border-radius-medium;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: $shadow-lg;
|
||||
z-index: $z-index-menu;
|
||||
padding: var(--context-menu-padding);
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--context-menu-item-padding-y) var(--context-menu-item-padding-x);
|
||||
border-radius: $border-radius-small;
|
||||
cursor: pointer;
|
||||
color: $color-text-primary;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&.layout-section {
|
||||
padding: var(--context-menu-layout-section-padding-y) var(--context-menu-layout-section-padding-x);
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
cursor: default;
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
li:last-child {
|
||||
color: #ff5c5c; // 删除项浅红色
|
||||
|
||||
.menu-icon {
|
||||
stroke: #ff5c5c;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
margin-right: var(--context-menu-icon-margin-right);
|
||||
width: var(--context-menu-icon-size);
|
||||
height: var(--context-menu-icon-size);
|
||||
}
|
||||
}
|
||||
|
||||
.layout-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
margin-bottom: var(--context-menu-layout-title-margin-bottom);
|
||||
}
|
||||
|
||||
.widget-size-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--context-menu-layout-options-gap);
|
||||
|
||||
.size-option {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: var(--context-menu-layout-option-width); // 固定宽度便于对齐
|
||||
padding: var(--context-menu-layout-option-padding-y) 0; // 固定宽度下调整内边距
|
||||
border-radius: var(--radius-pill); // 胶囊形状
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
63
app/src/components/TheSearchBar/index.vue
Normal file
63
app/src/components/TheSearchBar/index.vue
Normal file
@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="search-bar-wrapper">
|
||||
<div class="search-bar">
|
||||
<div class="search-icon">
|
||||
<!-- 搜索图标(矢量) -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
||||
</div>
|
||||
<input type="text" placeholder="输入搜索内容" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 搜索逻辑后续补充
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@import '@/styles/tokens.scss';
|
||||
|
||||
.search-bar-wrapper {
|
||||
width: 100%;
|
||||
max-width: var(--search-max-width); // 与设计稿一致
|
||||
margin-bottom: var(--search-margin-bottom);
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
height: var(--search-height);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--search-padding-x);
|
||||
background-color: $color-surface-1;
|
||||
backdrop-filter: blur($backdrop-filter-blur) saturate($backdrop-filter-saturation);
|
||||
border-radius: $border-radius-large;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: $shadow-lg;
|
||||
transition: background-color $motion-duration-sm;
|
||||
|
||||
&:hover, &:focus-within {
|
||||
background-color: $color-surface-2;
|
||||
}
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
color: $color-text-secondary;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: $color-text-primary;
|
||||
font-size: 16px;
|
||||
|
||||
&::placeholder {
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
139
app/src/components/TheSidebar/index.vue
Normal file
139
app/src/components/TheSidebar/index.vue
Normal file
@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-top">
|
||||
<div class="profile-icon">
|
||||
<!-- 用户头像占位 -->
|
||||
</div>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<a href="#" class="nav-item active">
|
||||
<!-- 图标:主页 -->
|
||||
<span>主页</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item">
|
||||
<!-- 图标:编程 -->
|
||||
<span>编程</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item">
|
||||
<!-- 图标:设计 -->
|
||||
<span>设计</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item">
|
||||
<!-- 图标:产品 -->
|
||||
<span>产品</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item">
|
||||
<!-- 图标:AI -->
|
||||
<span>AI</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item">
|
||||
<!-- 图标:摸鱼 -->
|
||||
<span>摸鱼</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item add-item">
|
||||
<!-- 图标:新增 -->
|
||||
</a>
|
||||
</nav>
|
||||
<div class="sidebar-bottom">
|
||||
<a href="#" class="nav-item">
|
||||
<!-- 图标:设置 -->
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 该组件暂时不需要脚本逻辑。
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@import '@/styles/tokens.scss';
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--sidebar-padding-y) 0;
|
||||
background-color: rgba(10, 10, 10, 0.5); // 半透明黑色
|
||||
backdrop-filter: blur($backdrop-filter-blur) saturate($backdrop-filter-saturation);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: $z-index-sidebar;
|
||||
}
|
||||
|
||||
.profile-icon {
|
||||
width: var(--sidebar-profile-size);
|
||||
height: var(--sidebar-profile-size);
|
||||
border-radius: 50%;
|
||||
background-color: $color-accent;
|
||||
margin-bottom: var(--sidebar-profile-margin-bottom);
|
||||
// 可放置“登录”文本或真实头像
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--sidebar-nav-gap);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
width: var(--sidebar-nav-item-size);
|
||||
height: var(--sidebar-nav-item-size);
|
||||
border-radius: $border-radius-medium;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
color: $color-text-secondary;
|
||||
transition: background-color $motion-duration-sm $motion-easing-standard;
|
||||
position: relative;
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
margin-top: 4px; // 预留图标间距
|
||||
}
|
||||
|
||||
&.active, &:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: $color-text-primary;
|
||||
}
|
||||
|
||||
&.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: var(--sidebar-active-indicator-offset);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: var(--sidebar-active-indicator-width);
|
||||
height: var(--sidebar-active-indicator-height);
|
||||
background-color: $color-accent;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-item {
|
||||
margin-top: var(--sidebar-add-margin-top);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
&:hover {
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-bottom {
|
||||
margin-top: auto;
|
||||
}
|
||||
</style>
|
||||
76
app/src/components/WidgetCard/index.vue
Normal file
76
app/src/components/WidgetCard/index.vue
Normal file
@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="widget-card" :class="`size-${props.widget.size}`">
|
||||
<div class="widget-content">
|
||||
<component :is="widgetComponent" :data="props.widget.data" />
|
||||
</div>
|
||||
<div class="label">{{ label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent, computed } from 'vue';
|
||||
|
||||
// 定义组件属性结构
|
||||
interface Widget {
|
||||
id: string;
|
||||
component: string;
|
||||
size: '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
|
||||
data?: any;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
widget: Widget;
|
||||
}>();
|
||||
|
||||
// 根据名称动态加载组件
|
||||
const widgetComponent = defineAsyncComponent(() =>
|
||||
import(`@/components/widgets/${props.widget.component}.vue`)
|
||||
);
|
||||
|
||||
// 从组件名称提取展示标签
|
||||
const label = computed(() => {
|
||||
return props.widget.component.replace('Widget', '');
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/tokens.scss';
|
||||
|
||||
.widget-card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: $color-surface-1;
|
||||
backdrop-filter: blur($backdrop-filter-blur) saturate($backdrop-filter-saturation);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: $border-radius-large;
|
||||
box-shadow: $shadow-md;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
// 根据尺寸类名控制尺寸
|
||||
&.size-1x1 { grid-column: span 1; grid-row: span 1; }
|
||||
&.size-1x2 { grid-column: span 1; grid-row: span 2; }
|
||||
&.size-2x1 { grid-column: span 2; grid-row: span 1; }
|
||||
&.size-2x2 { grid-column: span 2; grid-row: span 2; }
|
||||
&.size-2x4 { grid-column: span 2; grid-row: span 4; }
|
||||
}
|
||||
|
||||
.widget-content {
|
||||
flex-grow: 1;
|
||||
padding: var(--widget-content-padding);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--widget-label-font-size);
|
||||
color: $color-text-primary;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: var(--widget-label-padding-y) 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
42
app/src/components/widgets/CalendarWidget.vue
Normal file
42
app/src/components/widgets/CalendarWidget.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="calendar-widget">
|
||||
<div class="month">2026年1月</div>
|
||||
<div class="day">22</div>
|
||||
<div class="details">
|
||||
<span>第22天 第4周</span>
|
||||
<span>腊月初四 周四</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/tokens.scss';
|
||||
.calendar-widget {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
color: white;
|
||||
|
||||
.month {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #ff6b6b; // 月份强调色
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.day {
|
||||
font-size: 48px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
.details {
|
||||
font-size: 12px;
|
||||
color: $color-text-secondary;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
39
app/src/components/widgets/CountdownWidget.vue
Normal file
39
app/src/components/widgets/CountdownWidget.vue
Normal file
@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="countdown-widget">
|
||||
<div class="title">下班还有</div>
|
||||
<div class="time-display">02:37:46</div>
|
||||
<div class="footer">
|
||||
<!-- 这里后续可改为动态数据 -->
|
||||
<span>发薪 19 天</span>
|
||||
<span>周五 1 天</span>
|
||||
<span>腊八节 4 天</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/tokens.scss';
|
||||
.countdown-widget {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
text-align: center;
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
.time-display {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
font-size: 12px;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
56
app/src/components/widgets/HotSearchWidget.vue
Normal file
56
app/src/components/widgets/HotSearchWidget.vue
Normal file
@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="hot-search-widget">
|
||||
<div class="tabs">
|
||||
<span v-for="tab in data.tabs" :key="tab" class="tab active">{{ tab }}</span>
|
||||
</div>
|
||||
<ul class="list">
|
||||
<li v-for="(item, index) in data.items" :key="index">
|
||||
<span>{{ index + 1 }}</span>
|
||||
<span class="title">{{ item.title }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
data: {
|
||||
tabs: string[];
|
||||
items: { title: string; value: string }[];
|
||||
}
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/tokens.scss';
|
||||
.hot-search-widget {
|
||||
height: 100%;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
.tab {
|
||||
font-size: 13px;
|
||||
color: $color-text-secondary;
|
||||
&.active {
|
||||
color: $color-text-primary;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
.list {
|
||||
list-style: none;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.title {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
171
app/src/config/layout.ts
Normal file
171
app/src/config/layout.ts
Normal 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
26
app/src/config/theme.ts
Normal 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
17
app/src/main.ts
Normal 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')
|
||||
103
app/src/store/useLayoutStore.ts
Normal file
103
app/src/store/useLayoutStore.ts
Normal 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));
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
40
app/src/store/useUIStore.ts
Normal file
40
app/src/store/useUIStore.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
88
app/src/store/useWidgetsStore.ts
Normal file
88
app/src/store/useWidgetsStore.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
});
|
||||
49
app/src/styles/global.scss
Normal file
49
app/src/styles/global.scss
Normal 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);
|
||||
}
|
||||
38
app/src/styles/tokens.scss
Normal file
38
app/src/styles/tokens.scss
Normal 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
1
app/src/types/muuri.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
declare module 'muuri';
|
||||
36
app/src/utils/disableDefaultBehaviors.ts
Normal file
36
app/src/utils/disableDefaultBehaviors.ts
Normal 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
31
app/tsconfig.json
Normal 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
10
app/tsconfig.node.json
Normal 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
13
app/vite.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
})
|
||||
BIN
wechat_2026-01-22_152229_725.png
Normal file
BIN
wechat_2026-01-22_152229_725.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 932 KiB |
BIN
wechat_2026-01-22_173911_794.png
Normal file
BIN
wechat_2026-01-22_173911_794.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 362 KiB |
223
产品设计.md
Normal file
223
产品设计.md
Normal file
@ -0,0 +1,223 @@
|
||||
# Prompt:itab 风格“逻辑型交互”完整多端项目(Extension + Web + Mobile H5)架构与 UI 交互实现方案(SCSS)
|
||||
> 技术栈:Vite + Vue3 + TypeScript + Pinia + SCSS
|
||||
> 目标:交互完整、符合 itab 这类“逻辑型工作台”的使用习惯;UI/交互都能跑通(后端接口先 mock),支持插件 New Tab + Web 预览 + 移动端 H5。
|
||||
> 风格:iOS 扁平化 + 轻拟物(圆角/阴影/磨砂)+ 丝滑动画
|
||||
> 规范:统一样式放全局 SCSS,组件目录 `ComponentName/index.vue + index.scss`
|
||||
> 补充:Icon/Widget 下方都有 label(单行省略,Icon 必须可重命名)
|
||||
|
||||
---
|
||||
|
||||
## 0) 你必须理解“符合 itab 的逻辑型交互”是什么意思(必须按此实现)
|
||||
itab 类产品的核心不是“能拖就行”,而是**工作台逻辑**:用户进入新标签页后,第一时间完成“搜索/打开网站/查看信息/快速编辑”,交互必须满足:
|
||||
1) **默认浏览模式是“可用态”**:点击即打开、滚动即查看;不会因为可拖拽导致误触。
|
||||
2) **编辑模式是“布局态”**:所有拖拽、尺寸、删除、批量操作只发生在编辑模式,且有明显工具条与控件。
|
||||
3) **行为一致**:空白处/元素处右键菜单内容合理分层;同一入口在桌面与移动端有等价替代(右键=长按)。
|
||||
4) **轻干扰但明确反馈**:toast、状态条、占位符、落位动画都要“轻”,不要吓人但要清晰。
|
||||
5) **数据先本地后同步**:首屏永远本地秒开;同步/登录只影响“增强能力”,不阻断使用。
|
||||
6) **组件是“卡片 + label”**:label 是信息结构的一部分(命名/识别),不是装饰;重命名入口必须自然(右键/设置/编辑态)。
|
||||
|
||||
---
|
||||
|
||||
## 强约束(必须遵守)
|
||||
1) 三端同源代码:Web/Extension/Mobile H5 共用同一套 Vue3 + Pinia 业务代码;通过 build target/adapter 区分环境。
|
||||
2) Web 可直接预览:无需插件即可体验完整 UI/交互。
|
||||
3) Pinia 必须作为核心状态管理:避免 props 风暴。业务状态/交互状态/弹窗菜单状态必须进 store。
|
||||
4) 禁止第三方拖拽/网格库:不得使用 react-grid-layout、gridstack、interact.js;网格拖拽必须原生:Pointer Events + rAF + transform + 自研 push-down/compact。
|
||||
5) 仅鼠标/触控交互:不需要键盘辅助。
|
||||
6) 元素规则固定:
|
||||
- Icon:固定 `1×1`,可移动/编辑/删除/锁定;不可缩放。
|
||||
- Widget:尺寸只能切换:`1×1, 1×2, 2×1, 2×2, 2×4`(离散)。
|
||||
7) 每个元素都有 label:单行省略;Icon 必须支持重命名(Widget 可选)。
|
||||
8) 组件封装目录强制:`ComponentName/index.vue + index.scss`。
|
||||
9) 统一样式:全局 SCSS(`global.scss`)集中 token,组件局部 SCSS 只能引用 token。
|
||||
10) 丝滑动画:必须给出统一动效体系,所有交互动效使用同一套 motion token。
|
||||
|
||||
---
|
||||
|
||||
# 你需要输出的 Markdown 文档结构(必须严格按顺序)
|
||||
|
||||
## 1. 产品与交互总览(itab 逻辑型)
|
||||
- 用户进入新标签页的 3 个主任务路径(例如:搜索→打开;快速访问→打开;查看组件→轻操作)
|
||||
- “浏览模式/编辑模式”的设计原则与对用户心智的解释
|
||||
- 一致性原则:桌面/移动端等价交互映射(右键=长按)
|
||||
|
||||
## 2. 信息架构 IA(页面/面板/弹窗/菜单:必须完整)
|
||||
必须列出所有入口并写:
|
||||
- 触发方式(按钮/右键/长按/侧边栏)
|
||||
- 承载容器(面板/弹窗/抽屉/路由页)
|
||||
- 关闭方式(点击遮罩、返回、关闭按钮)
|
||||
- 与主页面的关系(是否阻断、是否保留状态)
|
||||
|
||||
至少包含:
|
||||
- 主页面(搜索栏 + 网格 + 侧边栏)
|
||||
- 侧边栏面板:账号、组件库、设置、主题/壁纸、同步中心、帮助/关于
|
||||
- 弹窗:登录、添加/编辑 Icon、Widget 设置、导入导出、确认删除
|
||||
- 菜单:空白右键、Icon 右键、Widget 右键(移动端长按 ActionSheet)
|
||||
- Onboarding 新手引导(首次进入)
|
||||
|
||||
## 3. 关键交互规范(必须写到“照着就能实现”,并符合 itab 逻辑)
|
||||
### 3.1 浏览模式(默认态)
|
||||
必须定义:
|
||||
- 点击 Icon:打开链接(新标签/当前标签策略写清,可在设置里切换)
|
||||
- 点击 Widget:进入 widget 的“轻交互”(如滚动、按钮刷新、查看详情);不触发拖拽
|
||||
- 点击 label:默认与点击卡片一致(或只选中,不打开——你必须选一个并解释为啥更像 itab)
|
||||
- hover/pressed 反馈:阴影、缩放、光泽(轻拟物)
|
||||
- 空白处点击:取消选中、关闭菜单
|
||||
|
||||
### 3.2 编辑模式(布局态)
|
||||
必须定义:
|
||||
- 进入/退出入口(顶部工具条/侧边栏开关/右键菜单)
|
||||
- 编辑模式视觉:
|
||||
- 网格底纹
|
||||
- 卡片工具按钮(锁定/删除/设置/尺寸)
|
||||
- 选中态描边
|
||||
- 编辑模式操作:
|
||||
- 拖拽移动(只允许从手柄触发,label 不触发拖拽)
|
||||
- Widget 尺寸切换(五档 size picker)
|
||||
- 多元素管理(至少支持:批量删除 或 批量锁定 任选其一;若不做必须说明原因)
|
||||
- 编辑模式退出后:自动保存布局,toast 提示(轻提示)
|
||||
|
||||
### 3.3 右键菜单(itab 逻辑:层级清晰、动作合理)
|
||||
必须分别定义:
|
||||
1) 空白处右键菜单
|
||||
2) Icon 右键菜单
|
||||
3) Widget 右键菜单
|
||||
对每一项写:名称/图标/动作/是否二级菜单/是否需要确认。
|
||||
|
||||
必须包含与 itab 逻辑一致的动作集合(可微调但要齐全):
|
||||
- 空白:添加 Icon、添加 Widget、进入/退出编辑模式、主题/壁纸、布局设置(compact/网格密度)、导入导出、同步中心、帮助/关于
|
||||
- Icon:打开、在新标签打开(或设置项控制)、编辑(含重命名)、更换图标、复制链接、删除、锁定/解锁
|
||||
- Widget:刷新、设置、尺寸(五档二级菜单)、复制配置(mock)、删除、锁定/解锁
|
||||
|
||||
移动端替代:
|
||||
- 长按空白/元素 → ActionSheet(同菜单项结构)
|
||||
|
||||
### 3.4 侧边栏(工作台逻辑:主入口集合)
|
||||
必须定义:
|
||||
- 桌面侧边栏开合(overlay + 磨砂背景)
|
||||
- 分区:账号区(头像/登录态)、快捷区(编辑/同步/主题)、面板区(组件库/设置/主题/同步)
|
||||
- 面板切换动效与状态保留(切换后保留滚动位置与筛选条件)
|
||||
- 与编辑模式联动:进入编辑模式时自动打开“组件库/布局设置”面板(或保持上次面板——你必须选一个并解释)
|
||||
|
||||
移动端:
|
||||
- 侧边栏替代形态(建议全屏面板或底部抽屉),写清返回逻辑与层级管理
|
||||
|
||||
### 3.5 Label(逻辑型识别与管理)
|
||||
必须定义:
|
||||
- label 视觉:单行省略、对齐、间距(使用全局 label token)
|
||||
- label 的交互:
|
||||
- 浏览模式:点击 label 的行为(与卡片一致 or 仅选中)
|
||||
- 编辑模式:label 不触发拖拽;重命名入口(至少 Icon)
|
||||
- label 与布局关系:
|
||||
- 网格占用只算卡片(w×h),label 是视觉附属
|
||||
- 像素渲染要加固定 labelHeight,避免视觉重叠(写公式)
|
||||
|
||||
---
|
||||
|
||||
## 4. 工程架构(Vite + Vue3 + TS + Pinia,多端同源)
|
||||
### 4.1 Monorepo 目录结构(必须可落地)
|
||||
给出目录树并解释职责,要求包含:
|
||||
- apps/web、apps/extension、apps/mobile(可选)
|
||||
- packages/core、ui、engine、widgets、shared
|
||||
- 每个模块的职责边界与依赖方向(禁止环依赖)
|
||||
|
||||
### 4.2 组件文件组织(必须符合你的封装规范)
|
||||
- 每个组件一个文件夹:`ComponentName/index.vue` + `index.scss`
|
||||
- UI 基础组件与业务组件放置规则
|
||||
- 示例:Sidebar/ContextMenu/GridCanvas/IconTile/WidgetCard/Label
|
||||
|
||||
---
|
||||
|
||||
## 5. Pinia 设计(避免 props 风暴,必须非常具体)
|
||||
### 5.1 Store 拆分与字段(必须给清单)
|
||||
至少包含:
|
||||
- useLayoutStore:items、layoutsByBreakpoint、editMode、selectedId、dragState、compactEnabled、gridConfig
|
||||
- useUIStore:sidebarOpen、activePanel、dialogs、toasts、contextMenuState、actionSheetState(移动端)、overlayStack
|
||||
- useAuthStore:user、token(mock)、status
|
||||
- useSettingsStore:theme、wallpaper、searchEngine、openLinkBehavior、gridDensity、motionLevel(可选)
|
||||
- useWidgetsStore:registry、instances、schemas、mockData
|
||||
- useSyncStore:syncState(mock)、lastSyncAt、conflictFlag
|
||||
- useOnboardingStore:hasSeen、steps、currentStep
|
||||
|
||||
必须写:
|
||||
- 哪些组件只通过 store 取数据(容器组件)
|
||||
- 哪些组件允许 props(纯展示)
|
||||
- 如何避免 store 间耦合:通过 services/adapters 作为协调层
|
||||
|
||||
---
|
||||
|
||||
## 6. UI 风格与全局 SCSS(必须能“一处改全局”)
|
||||
### 6.1 global.scss 结构与 tokens(必须详细)
|
||||
- tokens.scss:SCSS 变量 + :root CSS variables 映射(推荐双层)
|
||||
- reset/base/utilities/components
|
||||
- 必须列出 token 分类与命名规则(颜色/圆角/阴影/动效/间距/层级/label)
|
||||
|
||||
### 6.2 通用组件样式(必须给示例)
|
||||
- Card、Button、Menu、Drawer、Modal、Toast、Label 的通用 class 示例(引用 token)
|
||||
- pressed/hover 状态的阴影与轻缩放
|
||||
- 磨砂背景 class(带降级)
|
||||
|
||||
---
|
||||
|
||||
## 7. 动效体系(必须体现“丝滑”与一致性)
|
||||
- motion token:duration/easing
|
||||
- 菜单/抽屉/弹窗/落位动画统一规则
|
||||
- 拖拽落位动画:松手后卡片过渡到最终位置(transform 过渡)
|
||||
- 避免卡顿策略:只动 transform/opacity/box-shadow
|
||||
|
||||
---
|
||||
|
||||
## 8. 网格布局引擎(原生算法,必须给伪代码)
|
||||
必须包含并解释:
|
||||
- collides(a,b)
|
||||
- clampRect(rect, cols)
|
||||
- resolvePushDown(target, items, cols):队列迭代 + 固定排序 + icon 优先被推挤
|
||||
- compactIcons(items, cols):icon-only compact(可配置)
|
||||
- findFirstFit(size, items, cols, startY)
|
||||
并写清:
|
||||
- 拖动中:transform + placeholder
|
||||
- 松手 commit:resolve + compact + save + 落位动画
|
||||
- labelHeight 的像素渲染处理公式
|
||||
|
||||
---
|
||||
|
||||
## 9. Mock 与持久化(IndexedDB)
|
||||
- 表:layout/settings/user/widgetsData/onboarding
|
||||
- 迁移:version + migrations
|
||||
- mock 登录、mock 同步、mock widget 刷新、模拟网络失败开关
|
||||
- 导入导出 JSON(包含 label)
|
||||
|
||||
---
|
||||
|
||||
## 10. 逐功能实现清单(必须覆盖 itab 常见功能)
|
||||
用“功能 → UI 入口 → 交互流程 → store 字段变化 → 组件列表”形式写清:
|
||||
- 搜索(引擎切换、输入框动效、建议 mock)
|
||||
- Icon 管理(添加/编辑/重命名/更换图标/删除/锁定)
|
||||
- Widget 管理(添加/设置/刷新/尺寸切换/删除/锁定)
|
||||
- 主题/壁纸(选择、预览、应用、随机切换 mock)
|
||||
- 设置(布局设置、打开链接行为、动效强度、隐私)
|
||||
- 同步中心(mock)
|
||||
- Onboarding 引导(高亮与遮罩)
|
||||
- Toast/错误态/空态
|
||||
|
||||
---
|
||||
|
||||
## 11. 验收标准(至少 35 条,必须体现“符合 itab 逻辑型交互”)
|
||||
必须覆盖:
|
||||
- 默认浏览模式不误触拖拽,点击即打开/查看
|
||||
- 编辑模式才可拖拽与改尺寸,退出自动保存
|
||||
- 右键菜单/长按菜单结构合理、定位正确、动效顺滑
|
||||
- 侧边栏作为“主入口集合”逻辑清晰
|
||||
- label 显示与重命名流程自然(至少 Icon)
|
||||
- 全局 SCSS 改 token 可全局换肤(圆角/阴影/颜色/动效)
|
||||
- Web 可预览、Extension 可运行、H5 交互等价(右键=长按)
|
||||
- 性能:TTI、拖拽帧率、commit 耗时可观测(本地开发面板)
|
||||
|
||||
---
|
||||
|
||||
## 输出要求(必须)
|
||||
- 输出必须是 Markdown
|
||||
- 不要空话:给参数(cols/rowHeight/gap/snap阈值/labelHeight/动画时长)
|
||||
- Pinia 设计要清晰,避免 props 风暴
|
||||
- 拖拽网格必须自研原生算法
|
||||
- 登录/同步/刷新均 mock,但 UI/交互要完整可实现
|
||||
Reference in New Issue
Block a user